feat: artifact section
This commit is contained in:
+23
-46
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user