From 56bfa86b778864ff3314891c75ccbd7d6dbbba0f Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 12 May 2026 12:45:34 -0600 Subject: [PATCH 1/5] Add target calculation commands - Add `/target` and `/calculate` subcommands for game stat goals - Support stat and ratio targets with autocomplete and setback estimates - Add localized command and argument strings --- .../src/commands/target/target.command.ts | 522 ++++++++++++++++++ locales/en-US/default.json | 32 ++ 2 files changed, 554 insertions(+) create mode 100644 apps/discord-bot/src/commands/target/target.command.ts diff --git a/apps/discord-bot/src/commands/target/target.command.ts b/apps/discord-bot/src/commands/target/target.command.ts new file mode 100644 index 000000000..638ef2436 --- /dev/null +++ b/apps/discord-bot/src/commands/target/target.command.ts @@ -0,0 +1,522 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { + type APIApplicationCommandOptionChoice, + ApplicationCommandOptionType, +} from "discord-api-types/v10"; +import { + AbstractArgument, + ApiService, + Command, + CommandContext, + EmbedBuilder, + ErrorMessage, + type LocalizationString, + PlayerArgument, + SubCommand, +} from "@statsify/discord"; +import { + type Constructor, + prettify, + removeFormatting, +} from "@statsify/util"; +import { Container } from "typedi"; +import { + LEADERBOARD_RATIOS, + MetadataScanner, + type Player, + PlayerStats, + type Ratio, +} from "@statsify/schemas"; +import { STATUS_COLORS } from "@statsify/logger"; + +type GameKey = keyof PlayerStats; + +interface TargetStat { + denominator?: string; + denominatorName?: string; + key: string; + name: string; + numerator?: string; + numeratorName?: string; + ratio?: Ratio; + type: "ratio" | "stat"; +} + +const apiService = Container.get(ApiService); +const DEFAULT_SETBACK = 15; + +const GAMES: [GameKey, name: string, group?: string][] = [ + ["arcade", "Arcade"], + ["arenabrawl", "Arena Brawl", "classic"], + ["bedwars", "BedWars"], + ["blitzsg", "BlitzSG"], + ["buildbattle", "Build Battle"], + ["challenges", "Challenges"], + ["copsandcrims", "Cops and Crims"], + ["duels", "Duels"], + ["general", "General"], + ["megawalls", "MegaWalls"], + ["murdermystery", "Murder Mystery"], + ["paintball", "Paintball", "classic"], + ["parkour", "Parkour"], + ["pit", "Pit"], + ["quake", "Quake", "classic"], + ["quests", "Quests"], + ["skywars", "SkyWars"], + ["smashheroes", "Smash Heroes"], + ["speeduhc", "Speed UHC"], + ["tntgames", "TNT Games"], + ["turbokartracers", "Turbo Kart Racers", "classic"], + ["uhc", "UHC"], + ["vampirez", "VampireZ", "classic"], + ["walls", "Walls", "classic"], + ["warlords", "Warlords"], + ["woolgames", "WoolGames"], +]; + +const GAME_NAMES = new Map(GAMES.map(([key, name]) => [key, name])); + +const getGameClass = (game: GameKey) => + Reflect.getMetadata("design:type", PlayerStats.prototype, game) as Constructor; + +const statCache = new Map(); + +const targetArgs = (game: GameKey) => [ + new TargetStatArgument(game), + new TargetArgument(), + new PlayerArgument(), + new SetbackArgument(), +]; + +class TargetStatArgument extends AbstractArgument { + public autocomplete = true; + public description: LocalizationString; + public name = "stat"; + public required = true; + public type = ApplicationCommandOptionType.String; + private readonly game: GameKey; + + public constructor(game: GameKey) { + super(); + this.description = (t) => t("arguments.target-stat"); + this.game = game; + } + + public autocompleteHandler( + context: CommandContext + ): APIApplicationCommandOptionChoice[] { + const currentValue = context.option(this.name, "").toLowerCase(); + const stats = getTargetStats(this.game); + + const filtered = currentValue ? + stats.filter((stat) => + [stat.key, stat.name] + .some((value) => value.toLowerCase().includes(currentValue)) + ) : + stats; + + return filtered + .slice(0, 25) + .map((stat) => ({ name: stat.name.slice(0, 100), value: stat.key })); + } +} + +class TargetArgument extends AbstractArgument { + public description: LocalizationString; + public min_value = 0; + public name = "target"; + public required = true; + public type = ApplicationCommandOptionType.Number; + + public constructor() { + super(); + this.description = (t) => t("arguments.target"); + } +} + +class SetbackArgument extends AbstractArgument { + public description: LocalizationString; + public min_value = 0; + public name = "setback"; + public required = false; + public type = ApplicationCommandOptionType.Integer; + + public constructor() { + super(); + this.description = (t) => t("arguments.target-setback"); + } +} + +@Command({ description: (t) => t("commands.target") }) +export class TargetCommand { + @SubCommand({ description: (t) => t("commands.target-arcade"), args: targetArgs("arcade") }) + public arcade(context: CommandContext) { + return runTarget(context, "arcade"); + } + + @SubCommand({ description: (t) => t("commands.target-arenabrawl"), args: targetArgs("arenabrawl"), group: "classic" }) + public arenabrawl(context: CommandContext) { + return runTarget(context, "arenabrawl"); + } + + @SubCommand({ description: (t) => t("commands.target-bedwars"), args: targetArgs("bedwars") }) + public bedwars(context: CommandContext) { + return runTarget(context, "bedwars"); + } + + @SubCommand({ description: (t) => t("commands.target-blitzsg"), args: targetArgs("blitzsg") }) + public blitzsg(context: CommandContext) { + return runTarget(context, "blitzsg"); + } + + @SubCommand({ description: (t) => t("commands.target-buildbattle"), args: targetArgs("buildbattle") }) + public buildbattle(context: CommandContext) { + return runTarget(context, "buildbattle"); + } + + @SubCommand({ description: (t) => t("commands.target-challenges"), args: targetArgs("challenges") }) + public challenges(context: CommandContext) { + return runTarget(context, "challenges"); + } + + @SubCommand({ description: (t) => t("commands.target-copsandcrims"), args: targetArgs("copsandcrims") }) + public copsandcrims(context: CommandContext) { + return runTarget(context, "copsandcrims"); + } + + @SubCommand({ description: (t) => t("commands.target-duels"), args: targetArgs("duels") }) + public duels(context: CommandContext) { + return runTarget(context, "duels"); + } + + @SubCommand({ description: (t) => t("commands.target-general"), args: targetArgs("general") }) + public general(context: CommandContext) { + return runTarget(context, "general"); + } + + @SubCommand({ description: (t) => t("commands.target-megawalls"), args: targetArgs("megawalls") }) + public megawalls(context: CommandContext) { + return runTarget(context, "megawalls"); + } + + @SubCommand({ description: (t) => t("commands.target-murdermystery"), args: targetArgs("murdermystery") }) + public murdermystery(context: CommandContext) { + return runTarget(context, "murdermystery"); + } + + @SubCommand({ description: (t) => t("commands.target-paintball"), args: targetArgs("paintball"), group: "classic" }) + public paintball(context: CommandContext) { + return runTarget(context, "paintball"); + } + + @SubCommand({ description: (t) => t("commands.target-parkour"), args: targetArgs("parkour") }) + public parkour(context: CommandContext) { + return runTarget(context, "parkour"); + } + + @SubCommand({ description: (t) => t("commands.target-pit"), args: targetArgs("pit") }) + public pit(context: CommandContext) { + return runTarget(context, "pit"); + } + + @SubCommand({ description: (t) => t("commands.target-quake"), args: targetArgs("quake"), group: "classic" }) + public quake(context: CommandContext) { + return runTarget(context, "quake"); + } + + @SubCommand({ description: (t) => t("commands.target-quests"), args: targetArgs("quests") }) + public quests(context: CommandContext) { + return runTarget(context, "quests"); + } + + @SubCommand({ description: (t) => t("commands.target-skywars"), args: targetArgs("skywars") }) + public skywars(context: CommandContext) { + return runTarget(context, "skywars"); + } + + @SubCommand({ description: (t) => t("commands.target-smashheroes"), args: targetArgs("smashheroes") }) + public smashheroes(context: CommandContext) { + return runTarget(context, "smashheroes"); + } + + @SubCommand({ description: (t) => t("commands.target-speeduhc"), args: targetArgs("speeduhc") }) + public speeduhc(context: CommandContext) { + return runTarget(context, "speeduhc"); + } + + @SubCommand({ description: (t) => t("commands.target-tntgames"), args: targetArgs("tntgames") }) + public tntgames(context: CommandContext) { + return runTarget(context, "tntgames"); + } + + @SubCommand({ description: (t) => t("commands.target-turbokartracers"), args: targetArgs("turbokartracers"), group: "classic" }) + public turbokartracers(context: CommandContext) { + return runTarget(context, "turbokartracers"); + } + + @SubCommand({ description: (t) => t("commands.target-uhc"), args: targetArgs("uhc") }) + public uhc(context: CommandContext) { + return runTarget(context, "uhc"); + } + + @SubCommand({ description: (t) => t("commands.target-vampirez"), args: targetArgs("vampirez"), group: "classic" }) + public vampirez(context: CommandContext) { + return runTarget(context, "vampirez"); + } + + @SubCommand({ description: (t) => t("commands.target-walls"), args: targetArgs("walls"), group: "classic" }) + public walls(context: CommandContext) { + return runTarget(context, "walls"); + } + + @SubCommand({ description: (t) => t("commands.target-warlords"), args: targetArgs("warlords") }) + public warlords(context: CommandContext) { + return runTarget(context, "warlords"); + } + + @SubCommand({ description: (t) => t("commands.target-woolgames"), args: targetArgs("woolgames") }) + public woolgames(context: CommandContext) { + return runTarget(context, "woolgames"); + } +} + +@Command({ name: "calculate", description: (t) => t("commands.calculate") }) +export class CalculateCommand extends TargetCommand {} + +async function runTarget(context: CommandContext, game: GameKey) { + const user = context.getUser(); + const player = await apiService.getPlayer(context.option("player"), user); + const target = context.option("target"); + const setback = context.option("setback", DEFAULT_SETBACK); + const stat = resolveTargetStat(game, context.option("stat")); + const gameStats = player.stats[game] as unknown as Record; + const level = getLevel(gameStats); + + if (stat.type === "ratio") { + return buildRatioResponse(player, game, gameStats, stat, target, setback, level); + } + + return buildStatResponse(player, game, gameStats, stat, target, level); +} + +function buildRatioResponse( + player: Player, + game: GameKey, + gameStats: Record, + stat: TargetStat, + target: number, + setback: number, + level?: string +) { + const numerator = getNumber(gameStats, stat.numerator!); + const denominator = getNumber(gameStats, stat.denominator!); + const current = denominator === 0 ? numerator : numerator / denominator; + const needed = Math.max(0, Math.ceil(target * denominator - numerator)); + const neededWithSetback = Math.max( + 0, + Math.ceil(target * (denominator + setback) - numerator) + ); + const numeratorName = stat.numeratorName!; + const denominatorName = singularize(stat.denominatorName!); + + const lines = [ + `Current: **${formatDecimal(current)} ${stat.name}**`, + `Needed: **${formatInteger(needed)} ${numeratorName}** without another ${denominatorName}`, + ]; + + if (setback > 0) { + lines.push( + `Or: **${formatInteger(neededWithSetback)} ${numeratorName}** if you take **${formatInteger(setback)} ${stat.denominatorName}**` + ); + } + + return { + embeds: [ + baseEmbed(player, game, level) + .title(`To reach ${formatDecimal(target)} ${stat.name}:`) + .description(lines.join("\n")), + ], + }; +} + +function buildStatResponse( + player: Player, + game: GameKey, + gameStats: Record, + stat: TargetStat, + target: number, + level?: string +) { + const current = getNumber(gameStats, stat.key); + const needed = Math.max(0, Math.ceil(target - current)); + const statName = statNameLower(stat.name); + + return { + embeds: [ + baseEmbed(player, game, level) + .title(`To reach ${formatTarget(target)} ${stat.name}:`) + .description( + [ + `Current: **${formatTarget(current)} ${stat.name}**`, + `Needed: **${formatInteger(needed)} ${statName}**${needed === 0 ? " (target reached)" : ""}`, + ].join("\n") + ), + ], + }; +} + +function baseEmbed(player: Player, game: GameKey, level?: string) { + const titleParts = [player.displayName]; + if (level) titleParts.push(level); + + return new EmbedBuilder() + .author(titleParts.join(" ")) + .footer(GAME_NAMES.get(game)!) + .color(STATUS_COLORS.info); +} + +function getTargetStats(game: GameKey) { + if (statCache.has(game)) return statCache.get(game)!; + + const metadata = MetadataScanner.scan(getGameClass(game)); + const numberFields = metadata + .filter(([, { type }]) => type.type === Number) + .map(([key, { leaderboard }]) => ({ + key, + name: cleanName(leaderboard.fieldName || leaderboard.name || prettify(key)), + })); + + const byKey = new Map(numberFields.map((field) => [field.key, field])); + const ratioKeys = new Set(LEADERBOARD_RATIOS.map((ratio) => ratio[2])); + const ratios: TargetStat[] = []; + + for (const [numerator, denominator, ratioKey, prettyName] of LEADERBOARD_RATIOS) { + for (const field of numberFields) { + if (lastPathPart(field.key) !== ratioKey) continue; + + const parent = parentPath(field.key); + const numeratorKey = pathWithParent(parent, numerator); + const denominatorKey = pathWithParent(parent, denominator); + const numeratorField = byKey.get(numeratorKey); + const denominatorField = byKey.get(denominatorKey); + + if (!numeratorField || !denominatorField) continue; + + ratios.push({ + denominator: denominatorKey, + denominatorName: statNameLower(denominatorField.name), + key: field.key, + name: parent === "overall" || !parent ? prettyName : `${cleanName(parent)} ${prettyName}`, + numerator: numeratorKey, + numeratorName: statNameLower(numeratorField.name), + ratio: [numerator, denominator, ratioKey, prettyName], + type: "ratio", + }); + } + } + + const stats: TargetStat[] = [ + ...ratios, + ...numberFields + .filter((field) => !ratioKeys.has(lastPathPart(field.key))) + .map((field) => ({ ...field, type: "stat" as const })), + ]; + + statCache.set(game, stats); + return stats; +} + +function resolveTargetStat(game: GameKey, input: string) { + const stats = getTargetStats(game); + const normalized = input.toLowerCase(); + const exact = stats.find((stat) => stat.key === input); + if (exact) return exact; + + const overall = stats.find( + (stat) => + stat.key.toLowerCase() === `overall.${normalized}` || + stat.name.toLowerCase() === normalized + ); + if (overall) return overall; + + const fallback = stats.find( + (stat) => + lastPathPart(stat.key).toLowerCase() === normalized || + stat.name.toLowerCase().includes(normalized) + ); + + if (!fallback) { + throw new ErrorMessage( + "Target stat not found", + `I couldn't find \`${input}\` for ${GAME_NAMES.get(game)}. Use the stat autocomplete to pick a supported target.` + ); + } + + return fallback; +} + +function getLevel(gameStats: Record) { + const formatted = gameStats.levelFormatted || gameStats.naturalLevelFormatted; + if (typeof formatted === "string") return removeFormatting(formatted); + + const level = gameStats.level; + if (typeof level === "number") return `Level ${formatDecimal(level)}`; + + return undefined; +} + +function getNumber(data: Record, path: string) { + const value = path + .split(".") + .reduce((acc, key) => (acc as Record | undefined)?.[key], data); + + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function cleanName(value: string) { + return removeFormatting(value) + .replace(/\s+/g, " ") + .trim(); +} + +function statNameLower(value: string) { + return cleanName(value).toLowerCase(); +} + +function singularize(value: string) { + return value.endsWith("s") ? value.slice(0, -1) : value; +} + +function formatDecimal(value: number) { + return value.toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); +} + +function formatInteger(value: number) { + return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +function formatTarget(value: number) { + return Number.isInteger(value) ? formatInteger(value) : formatDecimal(value); +} + +function lastPathPart(path: string) { + return path.split(".").at(-1)!; +} + +function parentPath(path: string) { + return path.split(".").slice(0, -1).join("."); +} + +function pathWithParent(parent: string, key: string) { + return parent ? `${parent}.${key}` : key; +} diff --git a/locales/en-US/default.json b/locales/en-US/default.json index b66f3e884..5e94dcc89 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -14,6 +14,9 @@ "server": "A Minecraft server name or a server IP", "tags-content": "The content of the tag", "tags-name": "The name of the tag", + "target": "The goal you want to reach", + "target-setback": "How many denominator stats to include in the alternate calculation", + "target-stat": "The stat or ratio you want to target", "text": "A message", "user": "Choose a Discord user" }, @@ -31,6 +34,7 @@ "buildbattle": "$t(commands.hypixel-command, { \"name\": \"Build Battle\" })", "cape": "View someone's Minecraft and Optifine capes", "challenges": "$t(commands.hypixel-command, { \"name\": \"Challenge\" })", + "calculate": "$t(commands.target)", "quests-command": "View your {{name}} questing stats", "quests": "$t(commands.hypixel-command, { \"name\": \"Questing\" })", "quests-overall": "$t(commands.quests-command, { \"name\": \"Overall\" })", @@ -212,6 +216,34 @@ "tags-delete": "Delete a support tag", "tags-rename": "Rename a support tag", "text": "Generate Minecraft text", + "target": "Calculate what you need to reach a stat target", + "target-arcade": "$t(commands.target-command, { \"name\": \"Arcade\" })", + "target-arenabrawl": "$t(commands.target-command, { \"name\": \"Arena Brawl\" })", + "target-bedwars": "$t(commands.target-command, { \"name\": \"BedWars\" })", + "target-blitzsg": "$t(commands.target-command, { \"name\": \"BlitzSG\" })", + "target-buildbattle": "$t(commands.target-command, { \"name\": \"Build Battle\" })", + "target-command": "Calculate a {{name}} stat target", + "target-challenges": "$t(commands.target-command, { \"name\": \"Challenges\" })", + "target-copsandcrims": "$t(commands.target-command, { \"name\": \"Cops and Crims\" })", + "target-duels": "$t(commands.target-command, { \"name\": \"Duels\" })", + "target-general": "$t(commands.target-command, { \"name\": \"General\" })", + "target-megawalls": "$t(commands.target-command, { \"name\": \"MegaWalls\" })", + "target-murdermystery": "$t(commands.target-command, { \"name\": \"Murder Mystery\" })", + "target-paintball": "$t(commands.target-command, { \"name\": \"Paintball\" })", + "target-parkour": "$t(commands.target-command, { \"name\": \"Parkour\" })", + "target-pit": "$t(commands.target-command, { \"name\": \"Pit\" })", + "target-quake": "$t(commands.target-command, { \"name\": \"Quake\" })", + "target-quests": "$t(commands.target-command, { \"name\": \"Quests\" })", + "target-skywars": "$t(commands.target-command, { \"name\": \"SkyWars\" })", + "target-smashheroes": "$t(commands.target-command, { \"name\": \"Smash Heroes\" })", + "target-speeduhc": "$t(commands.target-command, { \"name\": \"Speed UHC\" })", + "target-tntgames": "$t(commands.target-command, { \"name\": \"TNT Games\" })", + "target-turbokartracers": "$t(commands.target-command, { \"name\": \"Turbo Kart Racers\" })", + "target-uhc": "$t(commands.target-command, { \"name\": \"UHC\" })", + "target-vampirez": "$t(commands.target-command, { \"name\": \"VampireZ\" })", + "target-walls": "$t(commands.target-command, { \"name\": \"Walls\" })", + "target-warlords": "$t(commands.target-command, { \"name\": \"Warlords\" })", + "target-woolgames": "$t(commands.target-command, { \"name\": \"WoolGames\" })", "theme": "Change your theme for every profile", "theme-boxes": "Change the appearance of the profile boxes", "theme-font": "Change the font of the profiles", From 9b509d9cb534e209e0cdf853504aa7981a3cc5f3 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 12 May 2026 13:00:50 -0600 Subject: [PATCH 2/5] Revert "Add target calculation commands" (#849) This reverts commit 56bfa86b778864ff3314891c75ccbd7d6dbbba0f. --- .../src/commands/target/target.command.ts | 522 ------------------ locales/en-US/default.json | 32 -- 2 files changed, 554 deletions(-) delete mode 100644 apps/discord-bot/src/commands/target/target.command.ts diff --git a/apps/discord-bot/src/commands/target/target.command.ts b/apps/discord-bot/src/commands/target/target.command.ts deleted file mode 100644 index 638ef2436..000000000 --- a/apps/discord-bot/src/commands/target/target.command.ts +++ /dev/null @@ -1,522 +0,0 @@ -/** - * Copyright (c) Statsify - * - * This source code is licensed under the GNU GPL v3 license found in the - * LICENSE file in the root directory of this source tree. - * https://github.com/Statsify/statsify/blob/main/LICENSE - */ - -import { - type APIApplicationCommandOptionChoice, - ApplicationCommandOptionType, -} from "discord-api-types/v10"; -import { - AbstractArgument, - ApiService, - Command, - CommandContext, - EmbedBuilder, - ErrorMessage, - type LocalizationString, - PlayerArgument, - SubCommand, -} from "@statsify/discord"; -import { - type Constructor, - prettify, - removeFormatting, -} from "@statsify/util"; -import { Container } from "typedi"; -import { - LEADERBOARD_RATIOS, - MetadataScanner, - type Player, - PlayerStats, - type Ratio, -} from "@statsify/schemas"; -import { STATUS_COLORS } from "@statsify/logger"; - -type GameKey = keyof PlayerStats; - -interface TargetStat { - denominator?: string; - denominatorName?: string; - key: string; - name: string; - numerator?: string; - numeratorName?: string; - ratio?: Ratio; - type: "ratio" | "stat"; -} - -const apiService = Container.get(ApiService); -const DEFAULT_SETBACK = 15; - -const GAMES: [GameKey, name: string, group?: string][] = [ - ["arcade", "Arcade"], - ["arenabrawl", "Arena Brawl", "classic"], - ["bedwars", "BedWars"], - ["blitzsg", "BlitzSG"], - ["buildbattle", "Build Battle"], - ["challenges", "Challenges"], - ["copsandcrims", "Cops and Crims"], - ["duels", "Duels"], - ["general", "General"], - ["megawalls", "MegaWalls"], - ["murdermystery", "Murder Mystery"], - ["paintball", "Paintball", "classic"], - ["parkour", "Parkour"], - ["pit", "Pit"], - ["quake", "Quake", "classic"], - ["quests", "Quests"], - ["skywars", "SkyWars"], - ["smashheroes", "Smash Heroes"], - ["speeduhc", "Speed UHC"], - ["tntgames", "TNT Games"], - ["turbokartracers", "Turbo Kart Racers", "classic"], - ["uhc", "UHC"], - ["vampirez", "VampireZ", "classic"], - ["walls", "Walls", "classic"], - ["warlords", "Warlords"], - ["woolgames", "WoolGames"], -]; - -const GAME_NAMES = new Map(GAMES.map(([key, name]) => [key, name])); - -const getGameClass = (game: GameKey) => - Reflect.getMetadata("design:type", PlayerStats.prototype, game) as Constructor; - -const statCache = new Map(); - -const targetArgs = (game: GameKey) => [ - new TargetStatArgument(game), - new TargetArgument(), - new PlayerArgument(), - new SetbackArgument(), -]; - -class TargetStatArgument extends AbstractArgument { - public autocomplete = true; - public description: LocalizationString; - public name = "stat"; - public required = true; - public type = ApplicationCommandOptionType.String; - private readonly game: GameKey; - - public constructor(game: GameKey) { - super(); - this.description = (t) => t("arguments.target-stat"); - this.game = game; - } - - public autocompleteHandler( - context: CommandContext - ): APIApplicationCommandOptionChoice[] { - const currentValue = context.option(this.name, "").toLowerCase(); - const stats = getTargetStats(this.game); - - const filtered = currentValue ? - stats.filter((stat) => - [stat.key, stat.name] - .some((value) => value.toLowerCase().includes(currentValue)) - ) : - stats; - - return filtered - .slice(0, 25) - .map((stat) => ({ name: stat.name.slice(0, 100), value: stat.key })); - } -} - -class TargetArgument extends AbstractArgument { - public description: LocalizationString; - public min_value = 0; - public name = "target"; - public required = true; - public type = ApplicationCommandOptionType.Number; - - public constructor() { - super(); - this.description = (t) => t("arguments.target"); - } -} - -class SetbackArgument extends AbstractArgument { - public description: LocalizationString; - public min_value = 0; - public name = "setback"; - public required = false; - public type = ApplicationCommandOptionType.Integer; - - public constructor() { - super(); - this.description = (t) => t("arguments.target-setback"); - } -} - -@Command({ description: (t) => t("commands.target") }) -export class TargetCommand { - @SubCommand({ description: (t) => t("commands.target-arcade"), args: targetArgs("arcade") }) - public arcade(context: CommandContext) { - return runTarget(context, "arcade"); - } - - @SubCommand({ description: (t) => t("commands.target-arenabrawl"), args: targetArgs("arenabrawl"), group: "classic" }) - public arenabrawl(context: CommandContext) { - return runTarget(context, "arenabrawl"); - } - - @SubCommand({ description: (t) => t("commands.target-bedwars"), args: targetArgs("bedwars") }) - public bedwars(context: CommandContext) { - return runTarget(context, "bedwars"); - } - - @SubCommand({ description: (t) => t("commands.target-blitzsg"), args: targetArgs("blitzsg") }) - public blitzsg(context: CommandContext) { - return runTarget(context, "blitzsg"); - } - - @SubCommand({ description: (t) => t("commands.target-buildbattle"), args: targetArgs("buildbattle") }) - public buildbattle(context: CommandContext) { - return runTarget(context, "buildbattle"); - } - - @SubCommand({ description: (t) => t("commands.target-challenges"), args: targetArgs("challenges") }) - public challenges(context: CommandContext) { - return runTarget(context, "challenges"); - } - - @SubCommand({ description: (t) => t("commands.target-copsandcrims"), args: targetArgs("copsandcrims") }) - public copsandcrims(context: CommandContext) { - return runTarget(context, "copsandcrims"); - } - - @SubCommand({ description: (t) => t("commands.target-duels"), args: targetArgs("duels") }) - public duels(context: CommandContext) { - return runTarget(context, "duels"); - } - - @SubCommand({ description: (t) => t("commands.target-general"), args: targetArgs("general") }) - public general(context: CommandContext) { - return runTarget(context, "general"); - } - - @SubCommand({ description: (t) => t("commands.target-megawalls"), args: targetArgs("megawalls") }) - public megawalls(context: CommandContext) { - return runTarget(context, "megawalls"); - } - - @SubCommand({ description: (t) => t("commands.target-murdermystery"), args: targetArgs("murdermystery") }) - public murdermystery(context: CommandContext) { - return runTarget(context, "murdermystery"); - } - - @SubCommand({ description: (t) => t("commands.target-paintball"), args: targetArgs("paintball"), group: "classic" }) - public paintball(context: CommandContext) { - return runTarget(context, "paintball"); - } - - @SubCommand({ description: (t) => t("commands.target-parkour"), args: targetArgs("parkour") }) - public parkour(context: CommandContext) { - return runTarget(context, "parkour"); - } - - @SubCommand({ description: (t) => t("commands.target-pit"), args: targetArgs("pit") }) - public pit(context: CommandContext) { - return runTarget(context, "pit"); - } - - @SubCommand({ description: (t) => t("commands.target-quake"), args: targetArgs("quake"), group: "classic" }) - public quake(context: CommandContext) { - return runTarget(context, "quake"); - } - - @SubCommand({ description: (t) => t("commands.target-quests"), args: targetArgs("quests") }) - public quests(context: CommandContext) { - return runTarget(context, "quests"); - } - - @SubCommand({ description: (t) => t("commands.target-skywars"), args: targetArgs("skywars") }) - public skywars(context: CommandContext) { - return runTarget(context, "skywars"); - } - - @SubCommand({ description: (t) => t("commands.target-smashheroes"), args: targetArgs("smashheroes") }) - public smashheroes(context: CommandContext) { - return runTarget(context, "smashheroes"); - } - - @SubCommand({ description: (t) => t("commands.target-speeduhc"), args: targetArgs("speeduhc") }) - public speeduhc(context: CommandContext) { - return runTarget(context, "speeduhc"); - } - - @SubCommand({ description: (t) => t("commands.target-tntgames"), args: targetArgs("tntgames") }) - public tntgames(context: CommandContext) { - return runTarget(context, "tntgames"); - } - - @SubCommand({ description: (t) => t("commands.target-turbokartracers"), args: targetArgs("turbokartracers"), group: "classic" }) - public turbokartracers(context: CommandContext) { - return runTarget(context, "turbokartracers"); - } - - @SubCommand({ description: (t) => t("commands.target-uhc"), args: targetArgs("uhc") }) - public uhc(context: CommandContext) { - return runTarget(context, "uhc"); - } - - @SubCommand({ description: (t) => t("commands.target-vampirez"), args: targetArgs("vampirez"), group: "classic" }) - public vampirez(context: CommandContext) { - return runTarget(context, "vampirez"); - } - - @SubCommand({ description: (t) => t("commands.target-walls"), args: targetArgs("walls"), group: "classic" }) - public walls(context: CommandContext) { - return runTarget(context, "walls"); - } - - @SubCommand({ description: (t) => t("commands.target-warlords"), args: targetArgs("warlords") }) - public warlords(context: CommandContext) { - return runTarget(context, "warlords"); - } - - @SubCommand({ description: (t) => t("commands.target-woolgames"), args: targetArgs("woolgames") }) - public woolgames(context: CommandContext) { - return runTarget(context, "woolgames"); - } -} - -@Command({ name: "calculate", description: (t) => t("commands.calculate") }) -export class CalculateCommand extends TargetCommand {} - -async function runTarget(context: CommandContext, game: GameKey) { - const user = context.getUser(); - const player = await apiService.getPlayer(context.option("player"), user); - const target = context.option("target"); - const setback = context.option("setback", DEFAULT_SETBACK); - const stat = resolveTargetStat(game, context.option("stat")); - const gameStats = player.stats[game] as unknown as Record; - const level = getLevel(gameStats); - - if (stat.type === "ratio") { - return buildRatioResponse(player, game, gameStats, stat, target, setback, level); - } - - return buildStatResponse(player, game, gameStats, stat, target, level); -} - -function buildRatioResponse( - player: Player, - game: GameKey, - gameStats: Record, - stat: TargetStat, - target: number, - setback: number, - level?: string -) { - const numerator = getNumber(gameStats, stat.numerator!); - const denominator = getNumber(gameStats, stat.denominator!); - const current = denominator === 0 ? numerator : numerator / denominator; - const needed = Math.max(0, Math.ceil(target * denominator - numerator)); - const neededWithSetback = Math.max( - 0, - Math.ceil(target * (denominator + setback) - numerator) - ); - const numeratorName = stat.numeratorName!; - const denominatorName = singularize(stat.denominatorName!); - - const lines = [ - `Current: **${formatDecimal(current)} ${stat.name}**`, - `Needed: **${formatInteger(needed)} ${numeratorName}** without another ${denominatorName}`, - ]; - - if (setback > 0) { - lines.push( - `Or: **${formatInteger(neededWithSetback)} ${numeratorName}** if you take **${formatInteger(setback)} ${stat.denominatorName}**` - ); - } - - return { - embeds: [ - baseEmbed(player, game, level) - .title(`To reach ${formatDecimal(target)} ${stat.name}:`) - .description(lines.join("\n")), - ], - }; -} - -function buildStatResponse( - player: Player, - game: GameKey, - gameStats: Record, - stat: TargetStat, - target: number, - level?: string -) { - const current = getNumber(gameStats, stat.key); - const needed = Math.max(0, Math.ceil(target - current)); - const statName = statNameLower(stat.name); - - return { - embeds: [ - baseEmbed(player, game, level) - .title(`To reach ${formatTarget(target)} ${stat.name}:`) - .description( - [ - `Current: **${formatTarget(current)} ${stat.name}**`, - `Needed: **${formatInteger(needed)} ${statName}**${needed === 0 ? " (target reached)" : ""}`, - ].join("\n") - ), - ], - }; -} - -function baseEmbed(player: Player, game: GameKey, level?: string) { - const titleParts = [player.displayName]; - if (level) titleParts.push(level); - - return new EmbedBuilder() - .author(titleParts.join(" ")) - .footer(GAME_NAMES.get(game)!) - .color(STATUS_COLORS.info); -} - -function getTargetStats(game: GameKey) { - if (statCache.has(game)) return statCache.get(game)!; - - const metadata = MetadataScanner.scan(getGameClass(game)); - const numberFields = metadata - .filter(([, { type }]) => type.type === Number) - .map(([key, { leaderboard }]) => ({ - key, - name: cleanName(leaderboard.fieldName || leaderboard.name || prettify(key)), - })); - - const byKey = new Map(numberFields.map((field) => [field.key, field])); - const ratioKeys = new Set(LEADERBOARD_RATIOS.map((ratio) => ratio[2])); - const ratios: TargetStat[] = []; - - for (const [numerator, denominator, ratioKey, prettyName] of LEADERBOARD_RATIOS) { - for (const field of numberFields) { - if (lastPathPart(field.key) !== ratioKey) continue; - - const parent = parentPath(field.key); - const numeratorKey = pathWithParent(parent, numerator); - const denominatorKey = pathWithParent(parent, denominator); - const numeratorField = byKey.get(numeratorKey); - const denominatorField = byKey.get(denominatorKey); - - if (!numeratorField || !denominatorField) continue; - - ratios.push({ - denominator: denominatorKey, - denominatorName: statNameLower(denominatorField.name), - key: field.key, - name: parent === "overall" || !parent ? prettyName : `${cleanName(parent)} ${prettyName}`, - numerator: numeratorKey, - numeratorName: statNameLower(numeratorField.name), - ratio: [numerator, denominator, ratioKey, prettyName], - type: "ratio", - }); - } - } - - const stats: TargetStat[] = [ - ...ratios, - ...numberFields - .filter((field) => !ratioKeys.has(lastPathPart(field.key))) - .map((field) => ({ ...field, type: "stat" as const })), - ]; - - statCache.set(game, stats); - return stats; -} - -function resolveTargetStat(game: GameKey, input: string) { - const stats = getTargetStats(game); - const normalized = input.toLowerCase(); - const exact = stats.find((stat) => stat.key === input); - if (exact) return exact; - - const overall = stats.find( - (stat) => - stat.key.toLowerCase() === `overall.${normalized}` || - stat.name.toLowerCase() === normalized - ); - if (overall) return overall; - - const fallback = stats.find( - (stat) => - lastPathPart(stat.key).toLowerCase() === normalized || - stat.name.toLowerCase().includes(normalized) - ); - - if (!fallback) { - throw new ErrorMessage( - "Target stat not found", - `I couldn't find \`${input}\` for ${GAME_NAMES.get(game)}. Use the stat autocomplete to pick a supported target.` - ); - } - - return fallback; -} - -function getLevel(gameStats: Record) { - const formatted = gameStats.levelFormatted || gameStats.naturalLevelFormatted; - if (typeof formatted === "string") return removeFormatting(formatted); - - const level = gameStats.level; - if (typeof level === "number") return `Level ${formatDecimal(level)}`; - - return undefined; -} - -function getNumber(data: Record, path: string) { - const value = path - .split(".") - .reduce((acc, key) => (acc as Record | undefined)?.[key], data); - - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - -function cleanName(value: string) { - return removeFormatting(value) - .replace(/\s+/g, " ") - .trim(); -} - -function statNameLower(value: string) { - return cleanName(value).toLowerCase(); -} - -function singularize(value: string) { - return value.endsWith("s") ? value.slice(0, -1) : value; -} - -function formatDecimal(value: number) { - return value.toLocaleString("en-US", { - maximumFractionDigits: 2, - minimumFractionDigits: 2, - }); -} - -function formatInteger(value: number) { - return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); -} - -function formatTarget(value: number) { - return Number.isInteger(value) ? formatInteger(value) : formatDecimal(value); -} - -function lastPathPart(path: string) { - return path.split(".").at(-1)!; -} - -function parentPath(path: string) { - return path.split(".").slice(0, -1).join("."); -} - -function pathWithParent(parent: string, key: string) { - return parent ? `${parent}.${key}` : key; -} diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 5e94dcc89..b66f3e884 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -14,9 +14,6 @@ "server": "A Minecraft server name or a server IP", "tags-content": "The content of the tag", "tags-name": "The name of the tag", - "target": "The goal you want to reach", - "target-setback": "How many denominator stats to include in the alternate calculation", - "target-stat": "The stat or ratio you want to target", "text": "A message", "user": "Choose a Discord user" }, @@ -34,7 +31,6 @@ "buildbattle": "$t(commands.hypixel-command, { \"name\": \"Build Battle\" })", "cape": "View someone's Minecraft and Optifine capes", "challenges": "$t(commands.hypixel-command, { \"name\": \"Challenge\" })", - "calculate": "$t(commands.target)", "quests-command": "View your {{name}} questing stats", "quests": "$t(commands.hypixel-command, { \"name\": \"Questing\" })", "quests-overall": "$t(commands.quests-command, { \"name\": \"Overall\" })", @@ -216,34 +212,6 @@ "tags-delete": "Delete a support tag", "tags-rename": "Rename a support tag", "text": "Generate Minecraft text", - "target": "Calculate what you need to reach a stat target", - "target-arcade": "$t(commands.target-command, { \"name\": \"Arcade\" })", - "target-arenabrawl": "$t(commands.target-command, { \"name\": \"Arena Brawl\" })", - "target-bedwars": "$t(commands.target-command, { \"name\": \"BedWars\" })", - "target-blitzsg": "$t(commands.target-command, { \"name\": \"BlitzSG\" })", - "target-buildbattle": "$t(commands.target-command, { \"name\": \"Build Battle\" })", - "target-command": "Calculate a {{name}} stat target", - "target-challenges": "$t(commands.target-command, { \"name\": \"Challenges\" })", - "target-copsandcrims": "$t(commands.target-command, { \"name\": \"Cops and Crims\" })", - "target-duels": "$t(commands.target-command, { \"name\": \"Duels\" })", - "target-general": "$t(commands.target-command, { \"name\": \"General\" })", - "target-megawalls": "$t(commands.target-command, { \"name\": \"MegaWalls\" })", - "target-murdermystery": "$t(commands.target-command, { \"name\": \"Murder Mystery\" })", - "target-paintball": "$t(commands.target-command, { \"name\": \"Paintball\" })", - "target-parkour": "$t(commands.target-command, { \"name\": \"Parkour\" })", - "target-pit": "$t(commands.target-command, { \"name\": \"Pit\" })", - "target-quake": "$t(commands.target-command, { \"name\": \"Quake\" })", - "target-quests": "$t(commands.target-command, { \"name\": \"Quests\" })", - "target-skywars": "$t(commands.target-command, { \"name\": \"SkyWars\" })", - "target-smashheroes": "$t(commands.target-command, { \"name\": \"Smash Heroes\" })", - "target-speeduhc": "$t(commands.target-command, { \"name\": \"Speed UHC\" })", - "target-tntgames": "$t(commands.target-command, { \"name\": \"TNT Games\" })", - "target-turbokartracers": "$t(commands.target-command, { \"name\": \"Turbo Kart Racers\" })", - "target-uhc": "$t(commands.target-command, { \"name\": \"UHC\" })", - "target-vampirez": "$t(commands.target-command, { \"name\": \"VampireZ\" })", - "target-walls": "$t(commands.target-command, { \"name\": \"Walls\" })", - "target-warlords": "$t(commands.target-command, { \"name\": \"Warlords\" })", - "target-woolgames": "$t(commands.target-command, { \"name\": \"WoolGames\" })", "theme": "Change your theme for every profile", "theme-boxes": "Change the appearance of the profile boxes", "theme-font": "Change the font of the profiles", From 059317facf8a2414ff16fc29654c8a34ef65c9c6 Mon Sep 17 00:00:00 2001 From: Cody Date: Sat, 30 May 2026 07:11:20 -0600 Subject: [PATCH 3/5] feat(api): add yaw parameter to skin rendering * Extend the getRender method to accept an optional yaw parameter for skin rendering. * Update the renderSkin function to handle yaw for extruded skins. * Modify the skin rendering logic to support yaw adjustments. feat(discord-bot): implement animated text and count-up * Add animated text support in TableData component for dynamic stats. * Introduce count-up animation for stats in BedWars command. * Create utility functions for generating animated WebP images. * Enhance text command to handle obfuscated text with animations. feat(rendering): add support for animated regions in rendering * Implement static rendering that collects animated text regions. * Update FontRenderer to handle obfuscated characters. * Modify rendering logic to support animated text nodes. chore(deps): add sharp for image processing * Include sharp library for handling image encoding and processing. * Update package.json and lock files to reflect new dependency. fix(api): improve player skin fetching with yaw * Add a method to fetch player skins at a specific yaw angle. * Ensure that skin rendering respects user preferences for extruded skins. BREAKING CHANGE: The renderSkin function now requires an additional yaw parameter. --- apps/api/src/dtos/index.ts | 1 + apps/api/src/dtos/skin-render.dto.ts | 22 ++++ apps/api/src/skin/skin.controller.ts | 10 +- apps/api/src/skin/skin.service.ts | 4 +- apps/discord-bot/package.json | 1 + .../src/commands/bedwars/bedwars.command.tsx | 65 +++++++++- .../src/commands/bedwars/bedwars.profile.tsx | 24 ++-- .../src/commands/minecraft/text.command.tsx | 32 ++++- .../src/components/Table/TableData.tsx | 19 ++- apps/discord-bot/src/util/animated-webp.ts | 112 ++++++++++++++++++ apps/discord-bot/src/util/count-up.ts | 93 +++++++++++++++ packages/api-client/src/api.service.ts | 8 ++ packages/rendering/src/font/font-renderer.ts | 36 +++++- packages/rendering/src/font/tokens.ts | 3 +- packages/rendering/src/intrinsics/Text.ts | 8 +- packages/rendering/src/jsx/render.ts | 64 ++++++++++ packages/rendering/src/jsx/types.ts | 16 +++ packages/skin-renderer/index.d.ts | 2 +- packages/skin-renderer/src/camera/orbital.rs | 6 +- packages/skin-renderer/src/native.rs | 19 ++- packages/skin-renderer/src/renderer/mod.rs | 49 ++++++++ pnpm-lock.yaml | 43 +++---- 22 files changed, 580 insertions(+), 57 deletions(-) create mode 100644 apps/api/src/dtos/skin-render.dto.ts create mode 100644 apps/discord-bot/src/util/animated-webp.ts create mode 100644 apps/discord-bot/src/util/count-up.ts diff --git a/apps/api/src/dtos/index.ts b/apps/api/src/dtos/index.ts index 975dd1697..69b814b90 100644 --- a/apps/api/src/dtos/index.ts +++ b/apps/api/src/dtos/index.ts @@ -24,3 +24,4 @@ export * from "./uuid.dto.js"; export * from "./player-rankings.dto.js"; export * from "./player-leaderboard.dto.js"; export * from "./session.dto.js"; +export * from "./skin-render.dto.js"; diff --git a/apps/api/src/dtos/skin-render.dto.ts b/apps/api/src/dtos/skin-render.dto.ts new file mode 100644 index 000000000..572f6aa42 --- /dev/null +++ b/apps/api/src/dtos/skin-render.dto.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNumber, IsOptional, Max, Min } from "class-validator"; +import { Transform } from "class-transformer"; +import { UuidDto } from "./uuid.dto.js"; + +export class SkinRenderDto extends UuidDto { + @IsOptional() + @Transform(({ value }) => +value) + @IsNumber() + @Min(-Math.PI * 2) + @Max(Math.PI * 4) + @ApiPropertyOptional({ description: "Camera horizontal angle in radians (yaw). Defaults to -0.3." }) + public yaw?: number; +} diff --git a/apps/api/src/skin/skin.controller.ts b/apps/api/src/skin/skin.controller.ts index ccc4a11dd..238e54758 100644 --- a/apps/api/src/skin/skin.controller.ts +++ b/apps/api/src/skin/skin.controller.ts @@ -10,7 +10,7 @@ import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation import { Auth } from "#auth"; import { Controller, Get, Query, StreamableFile } from "@nestjs/common"; import { ErrorResponse, GetSkinTexturesResponse, PlayerNotFoundException } from "@statsify/api-client"; -import { HeadDto, PlayerDto, UuidDto } from "#dtos"; +import { HeadDto, PlayerDto, SkinRenderDto } from "#dtos"; import { SkinService } from "./skin.service.js"; @Controller("/skin") @@ -32,8 +32,8 @@ export class SkinController { @Auth() @ApiOperation({ summary: "Get a Player Render" }) @ApiBadRequestResponse({ type: ErrorResponse }) - public async getRender(@Query() { uuid }: UuidDto) { - const render = await this.skinService.getRender(uuid, false); + public async getRender(@Query() { uuid, yaw }: SkinRenderDto) { + const render = await this.skinService.getRender(uuid, false, yaw); return new StreamableFile(render, { type: "image/png" }); } @@ -41,8 +41,8 @@ export class SkinController { @Auth() @ApiOperation({ summary: "Get an Extruded Player Render" }) @ApiBadRequestResponse({ type: ErrorResponse }) - public async getExtrudedRender(@Query() { uuid }: UuidDto) { - const render = await this.skinService.getRender(uuid, true); + public async getExtrudedRender(@Query() { uuid, yaw }: SkinRenderDto) { + const render = await this.skinService.getRender(uuid, true, yaw); return new StreamableFile(render, { type: "image/png" }); } diff --git a/apps/api/src/skin/skin.service.ts b/apps/api/src/skin/skin.service.ts index b96b85cd3..51615a52f 100644 --- a/apps/api/src/skin/skin.service.ts +++ b/apps/api/src/skin/skin.service.ts @@ -43,12 +43,12 @@ export class SkinService { return canvas.toBuffer("png"); } - public async getRender(uuid: string, extruded: boolean): Promise { + public async getRender(uuid: string, extruded: boolean, yaw?: number): Promise { const skin = await this.getSkin(uuid); const { skin: image } = await this.resolveSkin(skin.skinUrl, skin.slim); // This field is set by loadImage from `@statsify/rendering` const { _data: buffer } = image as unknown as { _data: Buffer }; - return renderSkin(buffer, skin.slim ?? false, extruded); + return renderSkin(buffer, skin.slim ?? false, extruded, yaw); } public async getSkin(tag: string): Promise { diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index e81f21f5b..12548d615 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -28,6 +28,7 @@ "luxon": "^3.5.0", "mongoose": "^8.5.2", "reflect-metadata": "^0.2.2", + "sharp": "^0.34.5", "skia-canvas": "https://github.com/samizdatco/skia-canvas/releases/download/v0.9.30/skia-canvas-v0.9.30-linux-x64-glibc.tar.gz", "tiny-discord": "https://github.com/timotejroiko/tiny-discord.git#f6d020085ea88e33ebaf6ce323930deffe74fb0d", "typedi": "^0.10.0" diff --git a/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx b/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx index e404439ae..778033b97 100644 --- a/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx +++ b/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx @@ -6,14 +6,23 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { BEDWARS_MODES, BedWarsModes } from "@statsify/schemas"; +import { BEDWARS_MODES, BedWarsModes, type GameMode } from "@statsify/schemas"; import { BaseHypixelCommand, BaseProfileProps, ProfileData, } from "#commands/base.hypixel-command"; import { BedWarsProfile } from "./bedwars.profile.js"; -import { Command } from "@statsify/discord"; +import { Command, CommandContext, Message, Page } from "@statsify/discord"; +import { buildCountUpFrames, renderStatic } from "../../util/count-up.js"; +import { encodeAnimatedWebP } from "../../util/animated-webp.js"; +import { getBackground, getLogo } from "@statsify/assets"; +import { getTheme } from "#themes"; +import { mapBackground } from "#constants"; +import { noop } from "@statsify/util"; + +const COUNT_UP_FRAMES = 16; +const COUNT_UP_DELAY_MS = 60; @Command({ description: (t) => t("commands.bedwars") }) export class BedWarsCommand extends BaseHypixelCommand { @@ -21,6 +30,58 @@ export class BedWarsCommand extends BaseHypixelCommand { super(BEDWARS_MODES); } + public override async run(context: CommandContext) { + const user = context.getUser(); + const player = await this.apiService.getPlayer(context.option("player"), user); + + const [logo, badge, skin] = await Promise.all([ + getLogo(user), + this.apiService.getUserBadge(player.uuid), + this.apiService.getPlayerSkin(player.uuid, user), + ]); + + const allModes = this.modes.getModes(); + const filteredModes = this.filterModes?.(player, allModes) ?? allModes; + const emojis = this.getModeEmojis?.(filteredModes) ?? []; + + const pages: Page[] = filteredModes.map((mode, index) => { + const gameMode = { ...mode, submode: undefined } as unknown as GameMode; + + return { + label: mode.formatted, + emoji: emojis[index], + generator: async (t) => { + const background = await getBackground(...mapBackground(this.modes, mode.api)); + const theme = getTheme(user); + + const profileNode = this.getProfile( + { player, skin, background, logo, t, user, badge, time: "LIVE" }, + { mode: gameMode, data: noop() } + ); + + const { canvas: staticCanvas, regions } = renderStatic(profileNode, theme); + + const countUpFrames = buildCountUpFrames(staticCanvas, regions, { + frames: COUNT_UP_FRAMES, + delayMs: COUNT_UP_DELAY_MS, + }); + + // Frame 0 = final values so Discord's static thumbnail shows correct stats. + // Animation plays once (loop=1): thumbnail frame, then count from 0 → final. + const allFrames = [countUpFrames.at(-1)!, ...countUpFrames]; + const webpData = await encodeAnimatedWebP(allFrames, COUNT_UP_DELAY_MS, 1); + + return new Message({ + files: [{ name: "bedwars.webp", data: webpData, type: "image/webp" }], + attachments: [], + }); + }, + }; + }); + + return this.paginateService.paginate(context, pages); + } + public getProfile( base: BaseProfileProps, { mode }: ProfileData diff --git a/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx b/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx index 452699499..5b14f2116 100644 --- a/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx +++ b/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx @@ -72,36 +72,42 @@ export const BedWarsProfile = ({ /> - - - + + + - + - - - + + + - - + + t("commands.text"), args: [ @@ -50,19 +54,37 @@ export class TextCommand { }; } - const canvas = render( + // §k in the converted text means at least one obfuscated run is present. + // Animated path: render OBFUSCATED_FRAMES copies; fillText re-scrambles each call. + // Static path: unchanged — single PNG. + const hasObfuscated = text.includes("§k"); + + const profileNode = (
{text} -
, - theme + + ); + + if (!hasObfuscated) { + const canvas = render(profileNode, theme); + const buffer = await canvas.toBuffer("png"); + return { + files: [{ data: buffer, name: "text.png", type: "image/png" }], + }; + } + + // Animated: each render() call picks fresh random glyphs for §k nodes. + // Static characters are pixel-identical across frames (same TextNode[], same draw path). + const frameCanvases = Array.from({ length: OBFUSCATED_FRAMES }, () => + render(profileNode, theme) ); - const buffer = await canvas.toBuffer("png"); + const webpData = await encodeAnimatedWebP(frameCanvases, OBFUSCATED_DELAY_MS); return { - files: [{ data: buffer, name: "text.png", type: "image/png" }], + files: [{ data: webpData, name: "text.webp", type: "image/webp" }], }; } } diff --git a/apps/discord-bot/src/components/Table/TableData.tsx b/apps/discord-bot/src/components/Table/TableData.tsx index 5a38b1574..ff19466c1 100644 --- a/apps/discord-bot/src/components/Table/TableData.tsx +++ b/apps/discord-bot/src/components/Table/TableData.tsx @@ -11,6 +11,10 @@ export interface TableDataProps { value: string; color: string; size?: "small" | "regular" | "inline"; + /** Mark this cell's value text as animatable for count-up rendering. */ + animated?: boolean; + /** Raw numeric value — required when animated=true so count-up can interpolate. */ + numericValue?: number; } /** @@ -19,7 +23,14 @@ export interface TableDataProps { * * ``` */ -export const TableData = ({ title, value, color, size = "regular" }: TableDataProps) => { +export const TableData = ({ + title, + value, + color, + size = "regular", + animated, + numericValue, +}: TableDataProps) => { if (size === "small") { return ( {`${color}${title}`} - {`${color}${value}`} + {`${color}${value}`} ); } @@ -42,7 +53,7 @@ export const TableData = ({ title, value, color, size = "regular" }: TableDataPr §l{color}{title}
- {color}{value} + {color}{value} ); } @@ -52,6 +63,8 @@ export const TableData = ({ title, value, color, size = "regular" }: TableDataPr {`${color}${title}`} {`§^4^${color}${value}`} diff --git a/apps/discord-bot/src/util/animated-webp.ts b/apps/discord-bot/src/util/animated-webp.ts new file mode 100644 index 000000000..4fa41179c --- /dev/null +++ b/apps/discord-bot/src/util/animated-webp.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import sharp from "sharp"; +import type { Canvas } from "skia-canvas"; + +function writeUint24LE(buf: Buffer, offset: number, value: number): void { + buf[offset] = value & 0xff; + buf[offset + 1] = (value >> 8) & 0xff; + buf[offset + 2] = (value >> 16) & 0xff; +} + +function buildChunk(tag: string, payload: Buffer): Buffer { + const header = Buffer.allocUnsafe(8); + header.write(tag.padEnd(4, " "), 0, "ascii"); + header.writeUInt32LE(payload.length, 4); + const pad = payload.length % 2 === 1 ? Buffer.alloc(1) : Buffer.alloc(0); + return Buffer.concat([header, payload, pad]); +} + +/** + * Encodes an array of canvas frames as a single looping animated WebP using + * manual RIFF assembly. Each frame is lossless-encoded via sharp; falls back + * to quality-85 lossy if the combined lossless size exceeds 6 MB. + * + * Used by both the BedWars spinning-skin path and the /text obfuscated path. + */ +export async function encodeAnimatedWebP( + frameCanvases: Canvas[], + delayMs: number, + /** 0 = loop forever (default), 1 = play once, N = play N times. */ + loop = 0 +): Promise { + const frameWidth = frameCanvases[0].width; + const frameHeight = frameCanvases[0].height; + + const frameWebPs: Buffer[] = []; + for (const canvas of frameCanvases) { + const ctx = canvas.getContext("2d"); + const { data } = ctx.getImageData(0, 0, frameWidth, frameHeight); + const webp = await sharp(Buffer.from(data.buffer), { + raw: { width: frameWidth, height: frameHeight, channels: 4 }, + }) + .webp({ lossless: true }) + .toBuffer(); + frameWebPs.push(webp); + } + + const estimatedSize = frameWebPs.reduce((sum, b) => sum + b.length, 0); + if (estimatedSize > 6 * 1024 * 1024) { + frameWebPs.length = 0; + for (const canvas of frameCanvases) { + const ctx = canvas.getContext("2d"); + const { data } = ctx.getImageData(0, 0, frameWidth, frameHeight); + const webp = await sharp(Buffer.from(data.buffer), { + raw: { width: frameWidth, height: frameHeight, channels: 4 }, + }) + .webp({ quality: 85 }) + .toBuffer(); + frameWebPs.push(webp); + } + } + + // Strip the 12-byte RIFF/WEBP header; what remains is the VP8L/VP8 chunk. + const frameStreams = frameWebPs.map((webp) => webp.subarray(12)); + + const vp8xPayload = Buffer.alloc(10); + vp8xPayload[0] = 0x12; // Animation (bit 1) | Alpha (bit 4) + writeUint24LE(vp8xPayload, 4, frameWidth - 1); + writeUint24LE(vp8xPayload, 7, frameHeight - 1); + + const animPayload = Buffer.alloc(6); + animPayload.writeUInt16LE(loop, 4); + + const anmfChunks = frameStreams.map((stream) => { + const payload = Buffer.allocUnsafe(16 + stream.length); + writeUint24LE(payload, 0, 0); + writeUint24LE(payload, 3, 0); + writeUint24LE(payload, 6, frameWidth - 1); + writeUint24LE(payload, 9, frameHeight - 1); + writeUint24LE(payload, 12, delayMs); + payload[15] = 0x02; // no-blend flag + stream.copy(payload, 16); + return buildChunk("ANMF", payload); + }); + + const chunks = Buffer.concat([ + buildChunk("VP8X", vp8xPayload), + buildChunk("ANIM", animPayload), + ...anmfChunks, + ]); + + const riff = Buffer.allocUnsafe(12); + riff.write("RIFF", 0, "ascii"); + riff.writeUInt32LE(chunks.length + 4, 4); + riff.write("WEBP", 8, "ascii"); + + const result = Buffer.concat([riff, chunks]); + + if (result.byteLength > 7 * 1024 * 1024) { + console.warn( + `[animated-webp] output is ${(result.byteLength / 1024 / 1024).toFixed(1)} MB — exceeds 7 MB soft cap` + ); + } + + return result; +} diff --git a/apps/discord-bot/src/util/count-up.ts b/apps/discord-bot/src/util/count-up.ts new file mode 100644 index 000000000..1dd72b0e3 --- /dev/null +++ b/apps/discord-bot/src/util/count-up.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { type AnimatedRegion, FontRenderer, type TextNode, createCanvas } from "@statsify/rendering"; +import { Container } from "typedi"; +import type { Canvas } from "skia-canvas"; + +export { renderStatic } from "@statsify/rendering"; + +/** Cubic ease-out: fast start, decelerates to final value. */ +export const easeOut = (t: number) => 1 - (1 - t) ** 3; + +/** + * Formats a non-negative integer with comma separators, matching the project's + * default locale formatting (en-US style: 1,234,567). + */ +export const formatCount = (n: number): string => + Math.round(n).toLocaleString("en-US"); + +export interface CountUpOptions { + /** Number of animation frames. Default: 16 (~1 s at 60 ms/frame). */ + frames?: number; + /** Delay between frames in milliseconds. Default: 60. */ + delayMs?: number; +} + +/** + * Given a static card canvas and the animated regions collected by `renderStatic`, + * produces an array of composited `Canvas` frames where: + * frame 0 → all animated values at ~0 + * frame N → all animated values at their final `numericValue` + * + * Only the text overlay is re-drawn per frame — the static card is copied once. + */ +export function buildCountUpFrames( + staticCanvas: Canvas, + regions: AnimatedRegion[], + { frames = 16, delayMs: _delayMs = 60 }: CountUpOptions = {} +): Canvas[] { + const renderer = Container.get(FontRenderer); + const { width, height } = staticCanvas; + + return Array.from({ length: frames }, (_, i) => { + const t = easeOut(i / Math.max(frames - 1, 1)); + + const frame = createCanvas(width, height); + const ctx = frame.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + // Layer 1: blit the static card (background, images, non-animated text) + ctx.drawImage(staticCanvas, 0, 0); + + // Layer 2: draw each animated text region with an interpolated value + for (const region of regions) { + const node = region.text[0]; + if (!node) continue; + + const interpolated = region.numericValue * t; + + // Detect decimal places from the final text so ratios (e.g., "2.46") + // use toFixed(2) while integer stats (e.g., "1,234") use comma formatting. + const finalStr = node.text; + const dotIdx = finalStr.indexOf("."); + const formatted = + dotIdx === -1 ? + formatCount(interpolated) : + interpolated.toFixed(finalStr.length - dotIdx - 1); + + // Build the text node directly — NOT via lex() — because lex() prepends §f + // (white) when the string has no § prefix, which would override the color. + // The color is already resolved to a CSS hex string by the initial lex pass. + const newNodes: TextNode[] = [{ + text: formatted, + color: node.color, + bold: node.bold, + italic: node.italic, + underline: node.underline, + strikethrough: node.strikethrough, + obfuscated: false, + size: node.size, + }]; + + renderer.fillText(ctx, newNodes, region.x, region.y); + } + + return frame; + }); +} diff --git a/packages/api-client/src/api.service.ts b/packages/api-client/src/api.service.ts index b2d1d7677..c503848b5 100644 --- a/packages/api-client/src/api.service.ts +++ b/packages/api-client/src/api.service.ts @@ -175,6 +175,14 @@ export class ApiService { }); } + public getPlayerSkinAtYaw(uuid: string, user: User | null, yaw: number) { + const route = User.hasExtrudedSkins(user) ? "skin/extruded" : "skin"; + return this.requestImage(isProduction ? `https://api.statsify.net/${route}` : `/${route}`, { + uuid, + yaw, + }); + } + public getPlayerSkinTextures(tag: string) { return this.requestKey("/skin/textures", "skin", { player: tag, diff --git a/packages/rendering/src/font/font-renderer.ts b/packages/rendering/src/font/font-renderer.ts index bd5d6d775..5de79457d 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -23,6 +23,25 @@ import type { Fill } from "#jsx"; const sizes: Sizes = _sizes; const positions: string[][] = _positions; +// Memoised pool for §k (obfuscated): maps atlas pixel-width → candidate chars. +// Built once at module init from printable ASCII U+0021–U+007E that exist in sizes.ascii. +// Same-width substitution guarantees layout never jitters regardless of text size or bold. +const obfuscatedPool: Map = (() => { + const pool = new Map(); + for (let cp = 0x21; cp <= 0x7e; cp++) { + const unicode = cp.toString(16).padStart(4, "0").toUpperCase(); + const entry = sizes.ascii[unicode]; + if (!entry?.width) continue; + let bucket = pool.get(entry.width); + if (!bucket) { + bucket = []; + pool.set(entry.width, bucket); + } + bucket.push(String.fromCodePoint(cp)); + } + return pool; +})(); + const GRADIENT_TOP_OVERLAY = "rgb(255 255 255 / 0.85)"; const GRADIENT_BOTTOM_OVERLAY = "rgb(0 0 0 / 0.60)"; @@ -103,14 +122,19 @@ export class FontRenderer { italic, underline, strikethrough, + obfuscated, size, } of nodes) { const adjustY = y + size + (largestSize - size) * 5; for (const char of text) { + const drawChar = + obfuscated && char !== " " && char !== "\n" ? + this.pickObfuscatedChar(char) : + char; x += this.fillCharacter( ctx, - char, + drawChar, Math.round(x), Math.round(adjustY), size, @@ -130,6 +154,7 @@ export class FontRenderer { italic: false, underline: false, strikethrough: false, + obfuscated: false, color: "#FFFFFF", size: 2, ...inputState, @@ -183,6 +208,15 @@ export class FontRenderer { return nodes; } + private pickObfuscatedChar(char: string): string { + const unicode = this.getUnicode(char).toUpperCase(); + const width = sizes.ascii[unicode]?.width; + if (!width) return char; + const pool = obfuscatedPool.get(width); + if (!pool?.length) return char; + return pool[Math.floor(Math.random() * pool.length)]; + } + private getUnicode(char: string) { const hex = (char.codePointAt(0) ?? 0).toString(16); return `${"0000".slice(0, Math.max(0, 4 - hex.length))}${hex}`; diff --git a/packages/rendering/src/font/tokens.ts b/packages/rendering/src/font/tokens.ts index 974ae2c17..c0f26acd6 100644 --- a/packages/rendering/src/font/tokens.ts +++ b/packages/rendering/src/font/tokens.ts @@ -16,6 +16,7 @@ export interface TextNode { italic: boolean; underline: boolean; strikethrough: boolean; + obfuscated: boolean; size: number; } @@ -47,7 +48,7 @@ const strikethrough: Token = { const obfuscated: Token = { regex: /^k/, - effect: () => ({}), + effect: () => ({ obfuscated: true }), }; const reset: Token = { diff --git a/packages/rendering/src/intrinsics/Text.ts b/packages/rendering/src/intrinsics/Text.ts index 7b82975dd..acaaddc2f 100644 --- a/packages/rendering/src/intrinsics/Text.ts +++ b/packages/rendering/src/intrinsics/Text.ts @@ -21,10 +21,14 @@ export interface TextProps { italic?: boolean; underline?: boolean; size?: number; + animated?: boolean; + numericValue?: number; } export interface TextRenderProps { text: TextNode[]; + animated?: boolean; + numericValue?: number; } export const component: JSX.RawFC< @@ -40,6 +44,8 @@ export const component: JSX.RawFC< italic = false, underline = false, size = 2, + animated, + numericValue, }) => { let text: string; @@ -73,7 +79,7 @@ export const component: JSX.RawFC< height, }, style: { location: "center", direction: "row", align }, - props: { text: nodes }, + props: { text: nodes, animated, numericValue }, children: [], }; }; diff --git a/packages/rendering/src/jsx/render.ts b/packages/rendering/src/jsx/render.ts index 2dea261d8..61760c789 100644 --- a/packages/rendering/src/jsx/render.ts +++ b/packages/rendering/src/jsx/render.ts @@ -16,6 +16,7 @@ import { createInstructions } from "./create-instructions.js"; import { getPositionalDelta, getTotalSize } from "./util.js"; import { noop } from "@statsify/util"; import type { + AnimatedRegion, ComputedThemeContext, ElementNode, Instruction, @@ -52,6 +53,25 @@ const _render = ( }, }; + if ( + context.paintMode === "static-only" && + instruction.type === "text" && + instruction.props.animated + ) { + // Record the absolute text position for the animated overlay, then skip drawing. + if (context.animatedRegions && instruction.props.numericValue !== undefined) { + context.animatedRegions.push({ + x, + y, + location, + text: instruction.props.text, + numericValue: instruction.props.numericValue, + }); + } + // Text nodes have no children; return here is safe. + return; + } + intrinsicElements[instruction.type]( ctx, instruction.props, @@ -164,3 +184,47 @@ export function render(node: ElementNode, theme?: Theme): Canvas { return canvas; } + +/** + * Renders the card WITHOUT animated text nodes, and returns: + * - `canvas`: the static background frame (all boxes, images, non-animated text) + * - `regions`: position + style info for every animated text element, + * ready for count-up / progress-bar overlay rendering. + */ +export function renderStatic( + node: ElementNode, + theme?: Theme +): { canvas: Canvas; regions: AnimatedRegion[] } { + const instructions = createInstructions(node); + + const width = Math.round(getTotalSize(instructions.x)); + const height = Math.round(getTotalSize(instructions.y)); + + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + const regions: AnimatedRegion[] = []; + + const context: ComputedThemeContext = { + renderer: noop(), + ...theme?.context, + canvasWidth: width, + canvasHeight: height, + paintMode: "static-only", + animatedRegions: regions, + }; + + if (!context.renderer) context.renderer = Container.get(FontRenderer); + + _render( + ctx, + context, + { ...intrinsicRenders, ...theme?.elements }, + instructions, + 0, + 0 + ); + + return { canvas, regions }; +} diff --git a/packages/rendering/src/jsx/types.ts b/packages/rendering/src/jsx/types.ts index cd25ae45f..7baf75a6b 100644 --- a/packages/rendering/src/jsx/types.ts +++ b/packages/rendering/src/jsx/types.ts @@ -19,9 +19,25 @@ export interface BaseThemeContext { renderer: FontRenderer; } +export interface AnimatedRegion { + /** Absolute pixel position of the text baseline (x, y as passed to fillText). */ + x: number; + y: number; + /** Layout location — width/height of the text's own bounding box. */ + location: Location; + /** Final-frame TextNodes; use their style (color, bold, size) to restyle interpolated values. */ + text: import("../font/tokens.js").TextNode[]; + /** Raw numeric value to ease from 0 → numericValue over the animation. */ + numericValue: number; +} + export interface ComputedThemeContext extends BaseThemeContext { canvasWidth: number; canvasHeight: number; + /** When "static-only": skip animated text nodes but record their positions. */ + paintMode?: "static-only"; + /** Populated by renderStatic() — collects animated text regions during static-only render. */ + animatedRegions?: AnimatedRegion[]; } export interface Theme { diff --git a/packages/skin-renderer/index.d.ts b/packages/skin-renderer/index.d.ts index 73c14b8c6..78505f228 100644 --- a/packages/skin-renderer/index.d.ts +++ b/packages/skin-renderer/index.d.ts @@ -1,3 +1,3 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ -export declare function renderSkin(skin: Uint8Array, slim: boolean, extruded: boolean): Promise +export declare function renderSkin(skin: Uint8Array, slim: boolean, extruded: boolean, yaw?: number | undefined | null): Promise diff --git a/packages/skin-renderer/src/camera/orbital.rs b/packages/skin-renderer/src/camera/orbital.rs index 9e0dbec4d..d9843d9c8 100644 --- a/packages/skin-renderer/src/camera/orbital.rs +++ b/packages/skin-renderer/src/camera/orbital.rs @@ -44,6 +44,10 @@ pub struct OrbitalCamera { impl OrbitalCamera { pub fn new(target: Point3, width: f32, height: f32) -> Self { + Self::with_theta(target, width, height, -0.3) + } + + pub fn with_theta(target: Point3, width: f32, height: f32, theta: f32) -> Self { Self { target, @@ -54,7 +58,7 @@ impl OrbitalCamera { max_radius: 100.0, radius: 50.0, - theta: -0.3, + theta, phi: 1.383, min_polar_angle: f32::NEG_INFINITY, diff --git a/packages/skin-renderer/src/native.rs b/packages/skin-renderer/src/native.rs index 1c32482dd..42cfab391 100644 --- a/packages/skin-renderer/src/native.rs +++ b/packages/skin-renderer/src/native.rs @@ -2,6 +2,7 @@ use async_once::AsyncOnce; use lazy_static::lazy_static; use napi::bindgen_prelude::*; use napi_derive::napi; +use tokio::sync::Mutex; use crate::model::{ModelKind, ModelOuterLayer}; use crate::renderer::native::NativeBackend; @@ -13,10 +14,17 @@ lazy_static! { .await .expect("skin renderer initialized") }); + // Serialises renders that mutate the shared GPU camera buffer. + static ref RENDER_YAW_LOCK: Mutex<()> = Mutex::new(()); } #[napi] -pub async fn render_skin(skin: &[u8], slim: bool, extruded: bool) -> Result { +pub async fn render_skin( + skin: &[u8], + slim: bool, + extruded: bool, + yaw: Option, +) -> Result { let renderer = SKIN_RENDERER.get().await; let model_kind = if slim { @@ -32,7 +40,14 @@ pub async fn render_skin(skin: &[u8], slim: bool, extruded: bool) -> Result { + let _guard = RENDER_YAW_LOCK.lock().await; + renderer.render_at_yaw(&model, y as f32).await? + } + None => renderer.render(&model).await?, + }; Ok(buffer.into()) } diff --git a/packages/skin-renderer/src/renderer/mod.rs b/packages/skin-renderer/src/renderer/mod.rs index a762dba01..d1b7cc9ad 100644 --- a/packages/skin-renderer/src/renderer/mod.rs +++ b/packages/skin-renderer/src/renderer/mod.rs @@ -415,4 +415,53 @@ impl SkinRenderer { let (encoder, _) = self.render_sync(model); self.backend.handle_encoder_after_render(encoder).await } + + // Renders at an explicit horizontal yaw (theta). Caller must hold RENDER_YAW_LOCK. + pub async fn render_at_yaw(&self, model: &Model, yaw: f32) -> Result { + let cam = Camera::Orbital(OrbitalCamera::with_theta( + (0.0, 0.0, 0.0).into(), + self.config.width as f32, + self.config.height as f32, + yaw, + )); + + let queue = self.backend.queue(); + + let cam_uniform = CameraUniform::new(&cam); + queue.write_buffer(&self.camera_buffer, 0, cast_slice(&[cam_uniform])); + + let light_uniform = LightUniform::from(&cam); + queue.write_buffer( + &self.light_buffer, + 0, + bytemuck::cast_slice(&[light_uniform]), + ); + + let (encoder, _) = self.render_sync(model); + let result = self.backend.handle_encoder_after_render(encoder).await; + + // Restore default camera so no-yaw renders remain correct after lock release. + let default_cam = Camera::Orbital(OrbitalCamera::new( + (0.0, 0.0, 0.0).into(), + self.config.width as f32, + self.config.height as f32, + )); + let default_cam_uniform = CameraUniform::new(&default_cam); + queue.write_buffer(&self.camera_buffer, 0, cast_slice(&[default_cam_uniform])); + let default_light_uniform = LightUniform::from(&default_cam); + queue.write_buffer( + &self.light_buffer, + 0, + bytemuck::cast_slice(&[default_light_uniform]), + ); + + // Flush restore writes before releasing the caller's lock. + let flush_encoder = self + .backend + .device() + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + queue.submit(Some(flush_encoder.finish())); + + result + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e289028fb..eb9d8e75a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,6 +237,9 @@ importers: reflect-metadata: specifier: ^0.2.2 version: 0.2.2 + sharp: + specifier: ^0.34.5 + version: 0.34.5 skia-canvas: specifier: 3.0.8 version: 3.0.8 @@ -701,6 +704,8 @@ importers: packages/skin-renderer: {} + packages/skin-renderer/pkg: {} + packages/util: dependencies: '@swc/helpers': @@ -829,9 +834,6 @@ packages: '@emnapi/core@1.5.0': resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} @@ -6530,6 +6532,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validator@13.15.15: @@ -6818,7 +6821,7 @@ snapshots: '@commitlint/is-ignored@19.8.1': dependencies: '@commitlint/types': 19.8.1 - semver: 7.7.2 + semver: 7.7.3 '@commitlint/lint@19.8.1': dependencies: @@ -6892,11 +6895,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.5.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 @@ -7123,8 +7121,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.0.0': - optional: true + '@img/colour@1.0.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -7772,14 +7769,14 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.0 optional: true '@napi-rs/wasm-runtime@1.0.3': dependencies: '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.0 optional: true @@ -8804,7 +8801,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 + semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -8820,7 +8817,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 + semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -9315,7 +9312,7 @@ snapshots: bin-version-check@5.1.0: dependencies: bin-version: 6.0.0 - semver: 7.7.2 + semver: 7.7.3 semver-truncate: 3.0.0 bin-version@6.0.0: @@ -10275,7 +10272,7 @@ snapshots: process-warning: 5.0.0 rfdc: 1.4.1 secure-json-parse: 4.0.0 - semver: 7.7.2 + semver: 7.7.3 toad-cache: 3.7.0 fastify@5.5.0: @@ -10793,7 +10790,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 is-callable@1.2.7: {} @@ -11010,7 +11007,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.2 + semver: 7.7.3 jsx-ast-utils@3.3.5: dependencies: @@ -11213,7 +11210,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 math-intrinsics@1.1.0: {} @@ -12100,7 +12097,7 @@ snapshots: semver-truncate@3.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 semver@6.3.1: {} @@ -12110,8 +12107,7 @@ snapshots: semver@7.7.2: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} set-cookie-parser@2.7.1: {} @@ -12169,7 +12165,6 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - optional: true shebang-command@2.0.0: dependencies: From bb653a136e3820f6b91e0b0f1c7265244cb2bd2d Mon Sep 17 00:00:00 2001 From: Cody Date: Sat, 30 May 2026 14:17:04 -0600 Subject: [PATCH 4/5] feat(bedwars): implement shine effect for boxes * Add shine effect to BedWars command rendering * Introduce buildShineTheme and buildShineFrames utilities * Refactor Box rendering to support shine effect --- .../src/commands/bedwars/bedwars.command.tsx | 35 ++-- apps/discord-bot/src/util/shine.ts | 171 ++++++++++++++++++ packages/rendering/src/intrinsics/Box.ts | 44 +++-- 3 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 apps/discord-bot/src/util/shine.ts diff --git a/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx b/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx index 778033b97..ebe978df0 100644 --- a/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx +++ b/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx @@ -13,16 +13,18 @@ import { ProfileData, } from "#commands/base.hypixel-command"; import { BedWarsProfile } from "./bedwars.profile.js"; +import { type BoxGeometry, buildShineFrames, buildShineTheme } from "../../util/shine.js"; import { Command, CommandContext, Message, Page } from "@statsify/discord"; -import { buildCountUpFrames, renderStatic } from "../../util/count-up.js"; import { encodeAnimatedWebP } from "../../util/animated-webp.js"; import { getBackground, getLogo } from "@statsify/assets"; import { getTheme } from "#themes"; import { mapBackground } from "#constants"; import { noop } from "@statsify/util"; +import { render } from "@statsify/rendering"; -const COUNT_UP_FRAMES = 16; -const COUNT_UP_DELAY_MS = 60; +/** Frames × delay = total loop length: 30 × 100 ms = 3.0 s */ +const SHINE_FRAMES = 30; +const SHINE_DELAY_MS = 100; @Command({ description: (t) => t("commands.bedwars") }) export class BedWarsCommand extends BaseHypixelCommand { @@ -52,24 +54,31 @@ export class BedWarsCommand extends BaseHypixelCommand { emoji: emojis[index], generator: async (t) => { const background = await getBackground(...mapBackground(this.modes, mode.api)); - const theme = getTheme(user); + const baseTheme = getTheme(user); const profileNode = this.getProfile( { player, skin, background, logo, t, user, badge, time: "LIVE" }, { mode: gameMode, data: noop() } ); - const { canvas: staticCanvas, regions } = renderStatic(profileNode, theme); + // Render the base card once, collecting every box's painted geometry + // via the theme interceptor — single pass, no re-render per frame. + const boxes: BoxGeometry[] = []; + const shineTheme = buildShineTheme(baseTheme, boxes); + const baseCanvas = render(profileNode, shineTheme); - const countUpFrames = buildCountUpFrames(staticCanvas, regions, { - frames: COUNT_UP_FRAMES, - delayMs: COUNT_UP_DELAY_MS, - }); + // Per-frame: copy base canvas + draw diagonal highlight on each box. + // Only the shine layer changes; all other pixels are identical. + const frameCanvases = buildShineFrames( + baseCanvas, + boxes, + baseCanvas.width, + baseCanvas.height, + { frames: SHINE_FRAMES } + ); - // Frame 0 = final values so Discord's static thumbnail shows correct stats. - // Animation plays once (loop=1): thumbnail frame, then count from 0 → final. - const allFrames = [countUpFrames.at(-1)!, ...countUpFrames]; - const webpData = await encodeAnimatedWebP(allFrames, COUNT_UP_DELAY_MS, 1); + // Looping (loop=0) — the shine sweeps continuously. + const webpData = await encodeAnimatedWebP(frameCanvases, SHINE_DELAY_MS, 0); return new Message({ files: [{ name: "bedwars.webp", data: webpData, type: "image/webp" }], diff --git a/apps/discord-bot/src/util/shine.ts b/apps/discord-bot/src/util/shine.ts new file mode 100644 index 000000000..459cdc7de --- /dev/null +++ b/apps/discord-bot/src/util/shine.ts @@ -0,0 +1,171 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { Box, FontRenderer, type Render, type Theme, createCanvas } from "@statsify/rendering"; +import { Container } from "typedi"; +import type { Canvas } from "skia-canvas"; + +// ─── Tune these for the visual feel ────────────────────────────────────────── + +/** Half-width of the shine band, as a fraction of each box's diagonal. */ +const BAND_HALF = 0.12; + +/** + * Gradient extends this far past each box edge (in diagonal fractions) so the + * band enters and exits fully off-canvas — making t=0 and t=1 both opacity-0 + * for a seamless loop. Must be > BAND_HALF. + */ +const EXTEND = BAND_HALF * 2; + +const TOTAL_EXT = 1 + 2 * EXTEND; + +/** Peak alpha of the white highlight at the centre of the band. */ +const PEAK_OPACITY = 0.18; + +/** How much boxes phase-offset each other, as a fraction of the loop. */ +const WAVE_SPREAD = 0.25; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BoxBorderRadius { + topLeft: number; + topRight: number; + bottomLeft: number; + bottomRight: number; +} + +export interface BoxGeometry { + x: number; + y: number; + width: number; + height: number; + border: BoxBorderRadius; +} + +// Derive prop type from Box.render's signature — avoids importing the interface directly. +type BoxRenderProps = Parameters[1]; + +// ─── Geometry collector ─────────────────────────────────────────────────────── + +/** + * Returns a Theme whose `box` element interceptor draws boxes normally AND + * appends each box's final painted geometry to `boxes`. Works for any base + * theme (including premium per-user box overrides). + */ +export function buildShineTheme( + baseTheme: Theme | undefined, + boxes: BoxGeometry[] +): Theme { + const baseBoxRender: Render = baseTheme?.elements?.box ?? Box.render; + + const interceptBox: Render = (ctx, props, location, context, component) => { + baseBoxRender(ctx, props, location, context, component); + + // Mirror Box.render's own rounding so geometry matches the pixel-perfect draw. + const x = Math.round(location.x); + const y = Math.round(location.y); + const width = Math.round(location.width + location.padding.left + location.padding.right); + const height = Math.round(location.height + location.padding.top + location.padding.bottom); + boxes.push({ x, y, width, height, border: props.border }); + }; + + return { + context: baseTheme?.context ?? { renderer: Container.get(FontRenderer) }, + elements: { ...baseTheme?.elements, box: interceptBox }, + }; +} + +// ─── Frame renderer ─────────────────────────────────────────────────────────── + +export interface ShineOptions { + /** Number of animation frames. Default: 30 (→ 3 s at 100 ms/frame). */ + frames?: number; +} + +/** + * Produces `frames` Canvas frames where the base card is composited with a + * diagonal specular-shine band that sweeps across every box in sequence. + * + * Only the highlight layer changes between frames; base card pixels are static. + */ +export function buildShineFrames( + baseCanvas: Canvas, + boxes: BoxGeometry[], + canvasWidth: number, + canvasHeight: number, + { frames = 30 }: ShineOptions = {} +): Canvas[] { + // Normaliser for the wave-offset — sum of canvas extents gives a good proxy + // for the card diagonal without the sqrt. + const canvasMeasure = canvasWidth + canvasHeight; + + return Array.from({ length: frames }, (_, frameIndex) => { + // Global phase: 0..1 exclusive (frame N loops back to frame 0 seamlessly). + const t = frameIndex / frames; + + const frame = createCanvas(canvasWidth, canvasHeight); + const ctx = frame.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + // Layer 1 — identical base card. + ctx.drawImage(baseCanvas, 0, 0); + + // Layer 2 — per-box diagonal highlight, clipped to each box's rounded-rect. + for (const box of boxes) { + // Skip degenerate boxes (zero area — dividers, hairlines, etc.). + if (box.width <= 0 || box.height <= 0) continue; + + // Per-box phase offset creates a wave across the card from top-left to bottom-right. + const offset = ((box.x + box.y) / canvasMeasure) * WAVE_SPREAD; + const boxT = (t + offset) % 1; + + // ── Build the extended diagonal gradient ────────────────────────────── + // The gradient starts/ends EXTEND * (w,h) past each corner so the band + // is invisible at t=0 and t=1 (seamless loop proof: band centre is + // entirely outside the box at both endpoints). + const dx = box.width * EXTEND; + const dy = box.height * EXTEND; + const gx0 = box.x - dx; + const gy0 = box.y - dy; + const gx1 = box.x + box.width + dx; + const gy1 = box.y + box.height + dy; + + const grad = ctx.createLinearGradient(gx0, gy0, gx1, gy1); + + // Band centre in "band space" (−EXTEND → 1+EXTEND over the loop). + const bandCenter = boxT * TOTAL_EXT - EXTEND; + + // Map to gradient coords [0, 1]. + const peakPos = (bandCenter + EXTEND) / TOTAL_EXT; + const loPos = Math.max(0, (bandCenter - BAND_HALF + EXTEND) / TOTAL_EXT); + const hiPos = Math.min(1, (bandCenter + BAND_HALF + EXTEND) / TOTAL_EXT); + + const clampedPeak = Math.max(0.001, Math.min(0.999, peakPos)); + + grad.addColorStop(0, "rgba(255,255,255,0)"); + if (loPos > 0.001) grad.addColorStop(loPos, "rgba(255,255,255,0)"); + grad.addColorStop(clampedPeak, `rgba(255,255,255,${PEAK_OPACITY})`); + if (hiPos < 0.999) grad.addColorStop(hiPos, "rgba(255,255,255,0)"); + grad.addColorStop(1, "rgba(255,255,255,0)"); + + // ── Draw, clipped to the box's rounded-rect path ────────────────────── + ctx.save(); + Box.buildBoxPath(ctx, box.x, box.y, box.width, box.height, box.border); + ctx.clip(); + + ctx.globalCompositeOperation = "screen"; + ctx.fillStyle = grad; + ctx.fillRect(box.x, box.y, box.width, box.height); + ctx.globalCompositeOperation = "source-over"; + + ctx.restore(); + } + + return frame; + }); +} diff --git a/packages/rendering/src/intrinsics/Box.ts b/packages/rendering/src/intrinsics/Box.ts index e375c0994..1efb80a6a 100644 --- a/packages/rendering/src/intrinsics/Box.ts +++ b/packages/rendering/src/intrinsics/Box.ts @@ -90,6 +90,35 @@ export const component: JSX.RawFC = ({ children, }); +/** + * Traces the stepped-corner rounded-rect path that Box uses for its fill and clip. + * Callers must open a `beginPath` / `closePath` pair around this or call it alone + * (this function calls both internally). Pass post-rounding, post-padding values. + */ +export const buildBoxPath = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + border: BoxBorderRadius +): void => { + ctx.beginPath(); + ctx.moveTo(x + border.topLeft, y); + ctx.lineTo(x + width - border.topRight, y); + ctx.lineTo(x + width - border.topRight, y + border.topRight); + ctx.lineTo(x + width, y + border.topRight); + ctx.lineTo(x + width, y + height - border.bottomRight); + ctx.lineTo(x + width - border.bottomRight, y + height - border.bottomRight); + ctx.lineTo(x + width - border.bottomRight, y + height); + ctx.lineTo(x + border.bottomLeft, y + height); + ctx.lineTo(x + border.bottomLeft, y + height - border.bottomLeft); + ctx.lineTo(x, y + height - border.bottomLeft); + ctx.lineTo(x, y + border.topLeft); + ctx.lineTo(x + border.topLeft, y + border.topLeft); + ctx.closePath(); +}; + export const renderOverlay = ( ctx: CanvasRenderingContext2D, x: number, @@ -131,20 +160,7 @@ export const render: JSX.Render = ( width = Math.round(width); height = Math.round(height); - ctx.beginPath(); - ctx.moveTo(x + border.topLeft, y); - ctx.lineTo(x + width - border.topRight, y); - ctx.lineTo(x + width - border.topRight, y + border.topRight); - ctx.lineTo(x + width, y + border.topRight); - ctx.lineTo(x + width, y + height - border.bottomRight); - ctx.lineTo(x + width - border.bottomRight, y + height - border.bottomRight); - ctx.lineTo(x + width - border.bottomRight, y + height); - ctx.lineTo(x + border.bottomLeft, y + height); - ctx.lineTo(x + border.bottomLeft, y + height - border.bottomLeft); - ctx.lineTo(x, y + height - border.bottomLeft); - ctx.lineTo(x, y + border.topLeft); - ctx.lineTo(x + border.topLeft, y + border.topLeft); - ctx.closePath(); + buildBoxPath(ctx, x, y, width, height, border); ctx.fill(); renderOverlay(ctx, x, y, height); From 3ce1caba725af389286f03374e02cc088e8cbac3 Mon Sep 17 00:00:00 2001 From: Cody Date: Sat, 30 May 2026 15:25:29 -0600 Subject: [PATCH 5/5] feat(rendering): add rainbow text support and animation * Introduce rainbow token for text rendering * Implement animation phase for smooth rainbow transitions * Adjust frame rendering logic to accommodate rainbow effect * Update related interfaces and constants for consistency --- .../src/commands/minecraft/text.command.tsx | 37 +++++++++----- apps/discord-bot/src/util/count-up.ts | 1 + packages/rendering/src/font/font-renderer.ts | 49 ++++++++++++++++++- packages/rendering/src/font/tokens.ts | 7 +++ 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/apps/discord-bot/src/commands/minecraft/text.command.tsx b/apps/discord-bot/src/commands/minecraft/text.command.tsx index a8ff9d8c4..b977f4ff4 100644 --- a/apps/discord-bot/src/commands/minecraft/text.command.tsx +++ b/apps/discord-bot/src/commands/minecraft/text.command.tsx @@ -21,8 +21,14 @@ import { convertColorCodes } from "#lib/convert-color-codes"; import { encodeAnimatedWebP } from "../../util/animated-webp.js"; import { getTheme } from "#themes"; +// §k only: 10 frames × 50 ms = 500 ms fast flicker loop (matches Minecraft tick feel). const OBFUSCATED_FRAMES = 10; -const OBFUSCATED_DELAY_MS = 50; + +// §y present: 20 frames × 50 ms = 1.0 s smooth rainbow cycle. +// 20 frames also covers §k when both codes appear in the same text. +const RAINBOW_FRAMES = 20; + +const ANIMATED_DELAY_MS = 50; @Command({ description: (t) => t("commands.text"), @@ -54,10 +60,10 @@ export class TextCommand { }; } - // §k in the converted text means at least one obfuscated run is present. - // Animated path: render OBFUSCATED_FRAMES copies; fillText re-scrambles each call. - // Static path: unchanged — single PNG. + // Detect animated codes. §k = obfuscated scramble; §y = flowing rainbow. const hasObfuscated = text.includes("§k"); + const hasRainbow = text.includes("§y"); + const needsAnimation = hasObfuscated || hasRainbow; const profileNode = (
@@ -67,7 +73,7 @@ export class TextCommand {
); - if (!hasObfuscated) { + if (!needsAnimation) { const canvas = render(profileNode, theme); const buffer = await canvas.toBuffer("png"); return { @@ -75,13 +81,22 @@ export class TextCommand { }; } - // Animated: each render() call picks fresh random glyphs for §k nodes. - // Static characters are pixel-identical across frames (same TextNode[], same draw path). - const frameCanvases = Array.from({ length: OBFUSCATED_FRAMES }, () => - render(profileNode, theme) - ); + // Use more frames when rainbow is present so the hue advance per frame is + // small enough for a smooth flow. Both §k and §y are driven by the same + // render() call — animationPhase drives rainbow hue; Math.random() drives + // obfuscated reseed. ONE mechanism, ONE render call per frame. + const frames = hasRainbow ? RAINBOW_FRAMES : OBFUSCATED_FRAMES; + const renderer = theme.context.renderer; + + const frameCanvases = Array.from({ length: frames }, (_, i) => { + // phase ∈ [0, 1): advancing by 1/frames per frame. + // At phase = k/frames, hue shifts by 360 * (k/frames)° — completing a full + // 360° cycle after `frames` frames for a seamless loop. + renderer.animationPhase = i / frames; + return render(profileNode, theme); + }); - const webpData = await encodeAnimatedWebP(frameCanvases, OBFUSCATED_DELAY_MS); + const webpData = await encodeAnimatedWebP(frameCanvases, ANIMATED_DELAY_MS); return { files: [{ data: webpData, name: "text.webp", type: "image/webp" }], diff --git a/apps/discord-bot/src/util/count-up.ts b/apps/discord-bot/src/util/count-up.ts index 1dd72b0e3..99fe89705 100644 --- a/apps/discord-bot/src/util/count-up.ts +++ b/apps/discord-bot/src/util/count-up.ts @@ -82,6 +82,7 @@ export function buildCountUpFrames( underline: node.underline, strikethrough: node.strikethrough, obfuscated: false, + rainbow: false, size: node.size, }]; diff --git a/packages/rendering/src/font/font-renderer.ts b/packages/rendering/src/font/font-renderer.ts index 5de79457d..99b7ae1ef 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -45,6 +45,10 @@ const obfuscatedPool: Map = (() => { const GRADIENT_TOP_OVERLAY = "rgb(255 255 255 / 0.85)"; const GRADIENT_BOTTOM_OVERLAY = "rgb(0 0 0 / 0.60)"; +// Hue advance per character for §y rainbow text. +// 16 chars ≈ one full rainbow cycle — enough to show vivid variation in a short word. +const HUE_STEP_PER_CHAR = 22.5; // degrees + type CharacterSizes = Record; interface Sizes { @@ -53,6 +57,9 @@ interface Sizes { } export class FontRenderer { + /** Set to [0, 1) before each animated frame to drive §y rainbow hue and §k reseed. */ + public animationPhase = 0; + private images: Map; private canvases: WeakMap; private scales: WeakMap; @@ -114,6 +121,9 @@ export class FontRenderer { y: number ) { const largestSize = Math.max(...nodes.map((node) => node.size)); + // Global char index advances across all nodes so rainbow hue is continuous + // across a full text run (including spaces, maintaining hue through gaps). + let charIndex = 0; for (const { text, @@ -123,6 +133,7 @@ export class FontRenderer { underline, strikethrough, obfuscated, + rainbow, size, } of nodes) { const adjustY = y + size + (largestSize - size) * 5; @@ -132,6 +143,10 @@ export class FontRenderer { obfuscated && char !== " " && char !== "\n" ? this.pickObfuscatedChar(char) : char; + const charColor = rainbow ? + this.hueToHex(charIndex * HUE_STEP_PER_CHAR + this.animationPhase * 360) : + color; + charIndex++; x += this.fillCharacter( ctx, drawChar, @@ -142,7 +157,7 @@ export class FontRenderer { italic, underline, strikethrough, - color + charColor ); } } @@ -155,6 +170,7 @@ export class FontRenderer { underline: false, strikethrough: false, obfuscated: false, + rainbow: false, color: "#FFFFFF", size: 2, ...inputState, @@ -217,6 +233,37 @@ export class FontRenderer { return pool[Math.floor(Math.random() * pool.length)]; } + /** Converts a hue angle (degrees, any value) to a full-saturation CSS hex color. */ + private hueToHex(hue: number): string { + const h = ((hue % 360) + 360) % 360; + const sector = h / 60; + const x = 1 - Math.abs(sector % 2 - 1); + let r = 0; + let g = 0; + let b = 0; + if (sector < 1) { + r = 1; + g = x; + } else if (sector < 2) { + r = x; + g = 1; + } else if (sector < 3) { + g = 1; + b = x; + } else if (sector < 4) { + g = x; + b = 1; + } else if (sector < 5) { + r = x; + b = 1; + } else { + r = 1; + b = x; + } + const hex = (v: number) => Math.round(v * 255).toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; + } + private getUnicode(char: string) { const hex = (char.codePointAt(0) ?? 0).toString(16); return `${"0000".slice(0, Math.max(0, 4 - hex.length))}${hex}`; diff --git a/packages/rendering/src/font/tokens.ts b/packages/rendering/src/font/tokens.ts index c0f26acd6..a80357ce4 100644 --- a/packages/rendering/src/font/tokens.ts +++ b/packages/rendering/src/font/tokens.ts @@ -17,6 +17,7 @@ export interface TextNode { underline: boolean; strikethrough: boolean; obfuscated: boolean; + rainbow: boolean; size: number; } @@ -51,6 +52,11 @@ const obfuscated: Token = { effect: () => ({ obfuscated: true }), }; +const rainbow: Token = { + regex: /^y/, + effect: () => ({ rainbow: true }), +}; + const reset: Token = { regex: /^r/, effect: (_, __, defaultState) => defaultState, @@ -90,4 +96,5 @@ export const tokens: Token[] = [ underline, strikethrough, obfuscated, + rainbow, ];