feat: artifact section

This commit is contained in:
2026-03-20 21:51:47 +07:00 Unverified
parent 4ca75243d4
commit 9b5e776f40
8 changed files with 219 additions and 84 deletions
+23 -46
View File
@@ -4,11 +4,11 @@ type Feather = [number, number, number, number];
export async function featherImage(
input: ArrayBuffer,
[top, right, bottom, left]: Feather = [0, 0.3, 0, 0],
{ passes = [[0, 0.3, 0, 0]], ratio = 1 }: { passes: Feather[], ratio?: number },
) {
const img = await loadImage(Buffer.from(input));
const targetRatio = 1024 / 1124;
const targetRatio = ratio;
let cropW = img.width;
let cropH = img.height;
@@ -27,55 +27,32 @@ export async function featherImage(
ctx.drawImage(img, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
// mask canvas
const mask = createCanvas(cropW, cropH);
const m = mask.getContext("2d");
const w = cropW;
const h = cropH;
// center
m.fillStyle = "white";
m.fillRect(w * left, h * top, w * (1 - left - right), h * (1 - top - bottom));
const sides = [
[0, 0, 0, h], // top
[w, 0, 0, 0], // right
[0, h, 0, 0], // bottom
[0, 0, w, 0], // left
];
if (right > 0) {
const fw = w * right;
const g = m.createLinearGradient(w - fw, 0, w, 0);
g.addColorStop(0, "white");
g.addColorStop(1, "transparent");
m.fillStyle = g;
m.fillRect(w - fw, 0, fw, h);
for (const [top, right, bottom, left] of passes) {
[top, right, bottom, left].forEach((s, i) => {
if (!s) return;
const g = ctx.createLinearGradient(
...(sides[i] as [number, number, number, number]),
);
g.addColorStop(0, "rgba(255,255,255,0)");
g.addColorStop(s, "rgba(255,255,255,1)");
ctx.globalCompositeOperation = "destination-in";
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "source-over";
});
}
if (left > 0) {
const fw = w * left;
const g = m.createLinearGradient(0, 0, fw, 0);
g.addColorStop(0, "transparent");
g.addColorStop(1, "white");
m.fillStyle = g;
m.fillRect(0, 0, fw, h);
}
if (top > 0) {
const fh = h * top;
const g = m.createLinearGradient(0, 0, 0, fh);
g.addColorStop(0, "transparent");
g.addColorStop(1, "white");
m.fillStyle = g;
m.fillRect(0, 0, w, fh);
}
if (bottom > 0) {
const fh = h * bottom;
const g = m.createLinearGradient(0, h - fh, 0, h);
g.addColorStop(0, "white");
g.addColorStop(1, "transparent");
m.fillStyle = g;
m.fillRect(0, h - fh, w, fh);
}
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(mask, 0, 0);
return canvas.toBuffer("image/png");
}
+4 -2
View File
@@ -432,7 +432,9 @@ export function EnkaIcon({
icon,
...props
}: {
icon: keyof typeof library;
icon: keyof typeof library | (string & {});
} & SVGProps<SVGSVGElement>) {
return icon in library ? library[icon](props) : library.akefelement(props);
return icon in library
? library[icon as keyof typeof library](props)
: library.akefelement(props);
}
+3 -4
View File
@@ -1,15 +1,14 @@
// source: buzzy
export const star = (key: number) => (
export const star = (size: string) => (
<svg
key={key}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 576 512"
style={{
fill: "rgba(255,204,50)",
filter: "drop-shadow(#e3721b 0px 0px 1px)",
height: ".7em",
width: ".7em",
height: size,
width: size,
}}
>
<title>Star</title>
+32 -10
View File
@@ -1,5 +1,5 @@
import { readdirSync } from "node:fs";
import { mkdir, readdir } from "node:fs/promises";
import { mkdir, readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { file, hash, write } from "bun";
import { TTLCache } from "enka.ts";
@@ -14,14 +14,13 @@ export class AssetManager {
private MAX = 300;
private approximateSize = 0;
async getMissingAssets() {
const avatars = await getEnkaAvatarStore();
const weapons = await getEnkaWeaponStore();
const relics = await getEnkaRelicStore();
await mkdir("store").catch(() => {});
const existing = new Set(await readdir("store"));
const missing = new Set<string>();
[
private async getAssetList() {
const [avatars, weapons, relics] = await Promise.all([
getEnkaAvatarStore(),
getEnkaWeaponStore(),
await getEnkaRelicStore(),
]);
return [
...Object.values(avatars).flatMap((a) => [
...a.Consts,
...Object.values(a.Skills),
@@ -30,7 +29,16 @@ export class AssetManager {
]),
...Object.values(weapons).flatMap((w) => [w.Icon, w.AwakenIcon]),
...Object.values(relics.Items).map((r) => r.Icon),
].map((n) => existing.has(hash(n).toString(36)) || missing.add(n));
];
}
async getMissingAssets() {
await mkdir("store").catch(() => {});
const existing = new Set(await readdir("store"));
const missing = new Set<string>();
(await this.getAssetList()).map(
(n) => existing.has(hash(n).toString(36)) || missing.add(n),
);
console.log(
`${missing.size} assets missing, ${existing.size} assets in store`,
@@ -50,6 +58,20 @@ export class AssetManager {
.then((d) => write(join("store", hash(name).toString(36)), d));
console.log("All missing assets fetched and stored");
this.purgeExtraAssets();
}
async purgeExtraAssets() {
const extra = new Set(await readdir("store"));
(await this.getAssetList()).map((n) => extra.delete(hash(n).toString(36)));
if (!extra.size) return;
console.log(
`Clearing extra assets\n${[...extra.values()]
.map((e) => `- ${e}`)
.join("\n")}`,
);
// biome-ignore lint/suspicious/useIterableCallbackReturn: there's no map
extra.forEach((f) => rm(join("store", f)));
}
async load(path: string) {
+14 -1
View File
@@ -96,7 +96,7 @@ export interface RelicSetBonus {
AddProps: Record<number, number>;
}
export type RelicSetMap = Record<number, RelicSetBonus>;
export type RelicSetMap = Record<string, RelicSetBonus>;
export interface RelicStore {
Items: RelicConfigMap;
@@ -106,3 +106,16 @@ export interface RelicStore {
export interface EnkaLocs {
en: Record<string, string>;
}
export const SubstatOrder: Record<string, number> = {
FIGHT_PROP_CRITICAL: 1,
FIGHT_PROP_CRITICAL_HURT: 2,
FIGHT_PROP_ATTACK_PERCENT: 3,
FIGHT_PROP_ELEMENT_MASTERY: 4,
FIGHT_PROP_CHARGE_EFFICIENCY: 5,
FIGHT_PROP_HP_PERCENT: 6,
FIGHT_PROP_DEFENSE_PERCENT: 7,
FIGHT_PROP_ATTACK: 8,
FIGHT_PROP_HP: 9,
FIGHT_PROP_DEFENSE: 10,
};
+140 -18
View File
@@ -3,6 +3,7 @@ import { ImageResponse } from "@takumi-rs/image-response";
import { file, hash } from "bun";
import { NotFoundError, status, t } from "elysia";
import {
EquipType,
fetchEnka,
InternalError,
InvalidFormatError,
@@ -26,6 +27,7 @@ import { AssetManager, getEnkaStores } from "@/lib/assets";
import { Deduplicator } from "@/lib/dedupe";
import { mockData } from "@/lib/mock";
import { buildDisplayRows } from "@/lib/rows";
import { SubstatOrder } from "@/type/enka";
import { PercentageFightProp } from "@/type/fightPropMap";
const assets = new AssetManager();
@@ -36,7 +38,6 @@ const font = file(fontFile);
const shade = file(shadeSvg);
queueMicrotask(async () => {
const assets = new AssetManager();
assets.getMissingAssets();
setInterval(() => {
@@ -146,6 +147,51 @@ export function card(app: App) {
}
}
// #region Processing
const charLvl = char.propMap?.[4001].val ?? 1;
const ascension = char.propMap?.[1002].val ?? 1;
const promoteMaxLevelMap = [20, 40, 50, 60, 70, 80, 90, 100];
const charLvlMax =
ascension * 10 +
(ascension > 0 ? 10 : 0) +
20 +
(charLvl > 90 ? 10 : 0);
const statRows = buildDisplayRows(char, locs);
const artifacts = (
Object.keys(EquipType) as (keyof typeof EquipType)[]
).map((t) => {
const a = char.equipList!.find((e) => e.flat.equipType === t);
if (!a) return null;
return {
set: a.flat.setId!, // it is an artifact, therefore this always exist
stars: a.flat.rankLevel,
level: a.reliquary!.level - 1,
icon: `/ui/${a.flat.icon}.png`,
main: a.flat.reliquaryMainstat!,
sub: a.flat.reliquarySubstats!.sort(
(a, b) =>
(SubstatOrder[a.appendPropId] ?? 999) -
(SubstatOrder[b.appendPropId] ?? 999),
),
};
});
const setCounts = artifacts.reduce<Record<number, number>>(
(acc, a) => {
if (!a) return acc;
acc[a.set] = (acc[a.set] || 0) + 1;
return acc;
},
{},
);
const artifactSets = Object.entries(setCounts)
.filter(([, count]) => count >= 2)
.map(([set, count]) => ({
set: locs.en[relicStore.Sets[set].Name],
count,
}));
// #region Assets loading
const resources = await Promise.all([
assets
@@ -165,6 +211,21 @@ export function card(app: App) {
data: await assets.load(path),
}),
),
...artifacts
.filter(
(x): x is NonNullable<(typeof artifacts)[number]> => !!x,
)
.map(async (art) => ({
src: art.icon,
data: await assets.hook(art.icon, (buf) =>
featherImage(buf, {
passes: [
[0, 0.7, 0, 0],
[0, 0.6, 0, 0],
],
}),
),
})),
assets
.hook(
char.costumeId
@@ -173,7 +234,11 @@ export function card(app: App) {
"AvatarIcon_Side",
"Gacha_AvatarImg",
),
(buf) => featherImage(buf, [0, 0.3, 0, 0]),
(buf) =>
featherImage(buf, {
passes: [[0, 0.3, 0, 0]],
ratio: 1024 / 1124,
}),
)
.then(async (data) => ({
src: "/avatar",
@@ -199,19 +264,6 @@ export function card(app: App) {
`${Date.now() - start}ms - Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`,
);
// #region Processing
const charLvl = char.propMap?.[4001].val ?? 1;
const ascension = char.propMap?.[1002].val ?? 1;
const promoteMaxLevelMap = [20, 40, 50, 60, 70, 80, 90, 100];
const charLvlMax =
ascension * 10 +
(ascension > 0 ? 10 : 0) +
20 +
(charLvl > 90 ? 10 : 0);
const statRows = buildDisplayRows(char, locs);
const artifacts = char.equipList;
if (debug) {
console.log(
`${Date.now() - start}ms - Debug endpoint, returning JSON`,
@@ -231,6 +283,8 @@ export function card(app: App) {
char,
weapon,
statRows,
artifacts,
artifactSets,
},
null,
2,
@@ -404,8 +458,8 @@ export function card(app: App) {
}}
/>
<div tw="absolute w-full top-[93%] flex justify-center">
{Array.from({ length: weapon.store.Rarity }, (_, i) =>
star(i),
{Array.from({ length: weapon.store.Rarity }, () =>
star(".7em"),
)}
</div>
</div>
@@ -503,7 +557,74 @@ export function card(app: App) {
</div>
</div>
{/* right section */}
<div tw="absolute top-0 bottom-0 right-0 w-[23.1em] p-[.7em]"></div>
<div tw="absolute top-0 bottom-0 right-0 w-[23.1em] p-[.7em] flex flex-col justify-between">
{artifacts.map((a) =>
a ? (
<div tw="h-[18%] bg-[#0000004f] rounded-[.3em] relative text-white overflow-hidden flex justify-between gap-[.75em]">
<img
src={a.icon}
alt="artifact"
tw="absolute top-1/2 -left-[10%] w-[45%] -translate-y-1/2 -translate-x-[10%] object-contain"
width={256}
height={256}
/>
{/* main */}
<div tw="h-full w-[30%] text-right shrink-0 flex flex-col justify-center">
<EnkaIcon
icon={a.main.mainPropId}
tw="h-[1em] w-[1em] ml-auto max-h-[22%]"
/>
<div
tw="text-[1.1em] py-[2%] ml-auto"
style={{ textShadow: "0 .07em .1em black" }}
>
{PercentageFightProp.includes(
a.main
.mainPropId as (typeof PercentageFightProp)[number],
)
? `${a.main.statValue.toFixed(1)}%`
: a.main.statValue.toLocaleString()}
</div>
<div tw="flex grow max-h-[20%] items-end justify-end gap-[.75em]">
<div>
{Array.from({ length: a.stars ?? 0 }, () =>
star(".6em"),
)}
</div>
<div tw="text-[75%] leading-none bg-black/50 px-[.25em] py-[.2em] rounded-[.2em] -mb-[.2em]">
+{a.level}
</div>
</div>
</div>
{/* sep */}
<hr tw="border-none h-[84%] w-[2px] mt-[2%] bg-white/60" />
{/* substats */}
<div tw="flex flex-col flex-wrap mt-[.35em] mb-[.35em] mr-[.35em] w-full">
{a.sub.map((s, i) => (
<div
tw={`flex items-center gap-[.25em] w-1/2 h-1/2 ${i === 3 ? "opacity-80" : ""}`}
>
<EnkaIcon
icon={s.appendPropId}
tw="h-[1em] w-[1em]"
/>
<div>
+
{PercentageFightProp.includes(
s.appendPropId as (typeof PercentageFightProp)[number],
)
? `${s.statValue.toFixed(1)}%`
: s.statValue.toLocaleString()}
</div>
</div>
))}
</div>
</div>
) : (
<div tw="h-[18%] bg-[#0000004f] rounded-[.3em] opacity-20" />
),
)}
</div>
</div>,
{
fonts: [await font.arrayBuffer()],
@@ -553,6 +674,7 @@ export function card(app: App) {
);
else if (err instanceof InternalError)
return status(502, "Internal server error");
console.error(err);
throw err;
}
},