Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/src/dtos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
22 changes: 22 additions & 0 deletions apps/api/src/dtos/skin-render.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 5 additions & 5 deletions apps/api/src/skin/skin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -32,17 +32,17 @@ 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" });
}

@Get("/extruded")
@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" });
}

Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/skin/skin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ export class SkinService {
return canvas.toBuffer("png");
}

public async getRender(uuid: string, extruded: boolean): Promise<Buffer> {
public async getRender(uuid: string, extruded: boolean, yaw?: number): Promise<Buffer> {
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<Skin> {
Expand Down
1 change: 1 addition & 0 deletions apps/discord-bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
74 changes: 72 additions & 2 deletions apps/discord-bot/src/commands/bedwars/bedwars.command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,91 @@
* 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 { type BoxGeometry, buildShineFrames, buildShineTheme } from "../../util/shine.js";
import { Command, CommandContext, Message, Page } from "@statsify/discord";
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";

/** 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<BedWarsModes> {
public constructor() {
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<BedWarsModes>;

return {
label: mode.formatted,
emoji: emojis[index],
generator: async (t) => {
const background = await getBackground(...mapBackground(this.modes, mode.api));
const baseTheme = getTheme(user);

const profileNode = this.getProfile(
{ player, skin, background, logo, t, user, badge, time: "LIVE" },
{ mode: gameMode, data: noop() }
);

// 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);

// 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 }
);

// 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" }],
attachments: [],
});
},
};
});

return this.paginateService.paginate(context, pages);
}

public getProfile(
base: BaseProfileProps,
{ mode }: ProfileData<BedWarsModes, never>
Expand Down
24 changes: 15 additions & 9 deletions apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,36 +72,42 @@ export const BedWarsProfile = ({
/>
<Table.table>
<Table.tr>
<Table.td title={t("stats.wins")} value={t(stats.wins)} color="§a" />
<Table.td title={t("stats.losses")} value={t(stats.losses)} color="§c" />
<Table.td title={t("stats.wlr")} value={t(stats.wlr)} color="§6" />
<Table.td title={t("stats.wins")} value={t(stats.wins)} color="§a" animated numericValue={stats.wins} />
<Table.td title={t("stats.losses")} value={t(stats.losses)} color="§c" animated numericValue={stats.losses} />
<Table.td title={t("stats.wlr")} value={t(stats.wlr)} color="§6" animated numericValue={stats.wlr} />
</Table.tr>
<Table.tr>
<Table.td
title={t("stats.finalKills")}
value={t(stats.finalKills)}
color="§a"
animated
numericValue={stats.finalKills}
/>
<Table.td
title={t("stats.finalDeaths")}
value={t(stats.finalDeaths)}
color="§c"
animated
numericValue={stats.finalDeaths}
/>
<Table.td title={t("stats.fkdr")} value={t(stats.fkdr)} color="§6" />
<Table.td title={t("stats.fkdr")} value={t(stats.fkdr)} color="§6" animated numericValue={stats.fkdr} />
</Table.tr>
<Table.tr>
<Table.td title={t("stats.kills")} value={t(stats.kills)} color="§a" />
<Table.td title={t("stats.deaths")} value={t(stats.deaths)} color="§c" />
<Table.td title={t("stats.kdr")} value={t(stats.kdr)} color="§6" />
<Table.td title={t("stats.kills")} value={t(stats.kills)} color="§a" animated numericValue={stats.kills} />
<Table.td title={t("stats.deaths")} value={t(stats.deaths)} color="§c" animated numericValue={stats.deaths} />
<Table.td title={t("stats.kdr")} value={t(stats.kdr)} color="§6" animated numericValue={stats.kdr} />
</Table.tr>
<Table.tr>
<Table.td
title={t("stats.bedsBroken")}
value={t(stats.bedsBroken)}
color="§a"
animated
numericValue={stats.bedsBroken}
/>
<Table.td title={t("stats.bedsLost")} value={t(stats.bedsLost)} color="§c" />
<Table.td title={t("stats.bblr")} value={t(stats.bblr)} color="§6" />
<Table.td title={t("stats.bedsLost")} value={t(stats.bedsLost)} color="§c" animated numericValue={stats.bedsLost} />
<Table.td title={t("stats.bblr")} value={t(stats.bblr)} color="§6" animated numericValue={stats.bblr} />
</Table.tr>
<Historical.progression
time={time}
Expand Down
47 changes: 42 additions & 5 deletions apps/discord-bot/src/commands/minecraft/text.command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,18 @@ import { Container } from "typedi";
import { FontRenderer, StyleLocation, render } from "@statsify/rendering";
import { Multiline } from "#components";
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;

// §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"),
args: [
Expand Down Expand Up @@ -50,19 +60,46 @@ export class TextCommand {
};
}

const canvas = render(
// 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 = (
<div direction="column" padding={2}>
<Multiline size={size} align={alignment}>
{text}
</Multiline>
</div>,
theme
</div>
);

const buffer = await canvas.toBuffer("png");
if (!needsAnimation) {
const canvas = render(profileNode, theme);
const buffer = await canvas.toBuffer("png");
return {
files: [{ data: buffer, name: "text.png", type: "image/png" }],
};
}

// 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, ANIMATED_DELAY_MS);

return {
files: [{ data: buffer, name: "text.png", type: "image/png" }],
files: [{ data: webpData, name: "text.webp", type: "image/webp" }],
};
}
}
19 changes: 16 additions & 3 deletions apps/discord-bot/src/components/Table/TableData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -19,7 +23,14 @@ export interface TableDataProps {
* <Table.td title="Wins" value="1" color="§a" />
* ```
*/
export const TableData = ({ title, value, color, size = "regular" }: TableDataProps) => {
export const TableData = ({
title,
value,
color,
size = "regular",
animated,
numericValue,
}: TableDataProps) => {
if (size === "small") {
return (
<box
Expand All @@ -32,7 +43,7 @@ export const TableData = ({ title, value, color, size = "regular" }: TableDataPr
margin={{ top: 6, bottom: 2, left: 1, right: 1 }}
>{`${color}${title}`}
</text>
<text margin={{ top: 0, bottom: 6 }}>{`${color}${value}`}</text>
<text margin={{ top: 0, bottom: 6 }} animated={animated} numericValue={numericValue}>{`${color}${value}`}</text>
</box>
);
}
Expand All @@ -42,7 +53,7 @@ export const TableData = ({ title, value, color, size = "regular" }: TableDataPr
<box width="100%" padding={{ left: 8, right: 8, top: 4, bottom: 4 }}>
<text>§l{color}{title}</text>
<div width="remaining" margin={{ left: 2, right: 2 }} />
<text>{color}{value}</text>
<text animated={animated} numericValue={numericValue}>{color}{value}</text>
</box>
);
}
Expand All @@ -52,6 +63,8 @@ export const TableData = ({ title, value, color, size = "regular" }: TableDataPr
<text margin={{ top: 8, bottom: 4, left: 6, right: 6 }}>{`${color}${title}`}</text>
<text
margin={{ top: 0, bottom: 8, left: 10, right: 10 }}
animated={animated}
numericValue={numericValue}
>{`§^4^${color}${value}`}
</text>
</box>
Expand Down
Loading
Loading