feat: character list

This commit is contained in:
2026-03-22 05:30:48 +07:00 Unverified
parent db2de4868e
commit 39d21ef9ba
9 changed files with 313 additions and 11 deletions
+2 -2
View File
@@ -10,7 +10,7 @@
"@takumi-rs/image-response": "^0.71.7",
"@types/react": "^19.2.14",
"elysia": "latest",
"enka.ts": "^1.1.9",
"enka.ts": "^1.1.10",
"react": "^19.2.4",
"react-dom": "^19.2.4",
},
@@ -114,7 +114,7 @@
"elysia": ["[email protected]", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg=="],
"enka.ts": ["[email protected].9", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-6dBNMIQ/LQ2y6RtcxJCGBjTPOyGcF93OdGbxJ5NW/p9KZ+0Jaqj4dZfrVvYpynbBMGj1NNuvIW3f+g6m5SGrlg=="],
"enka.ts": ["[email protected].10", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lf6nEc2kPiycqS4Is4WxJI8iGMWcOYOphEX42TTy1zn6dizTDjoF8ViD2L2pAjf98gSXz5DzIA6uK0/1AfRVEg=="],
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
+1 -1
View File
@@ -12,7 +12,7 @@
"@takumi-rs/image-response": "^0.71.7",
"@types/react": "^19.2.14",
"elysia": "latest",
"enka.ts": "^1.1.9",
"enka.ts": "^1.1.10",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
+2 -1
View File
@@ -37,10 +37,11 @@ export class AssetManager {
...Object.values(a.Skills),
...Object.values(a.Costumes ?? {}).flatMap((c) => [c.Art, c.SideIcon]),
a.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg"),
a.SideIconName.replace("_Side", ""),
]),
...Object.values(weapons).flatMap((w) => [w.Icon, w.AwakenIcon]),
...Object.values(relics.Items).map((r) => r.Icon),
...Object.values(pfps).map((p) => p.IconPath),
...Object.values(pfps).map((p) => p.IconPath.replace("_Circle", "")),
...Object.values(namecards).map((p) => p.Icon),
];
}
+2
View File
@@ -50,3 +50,5 @@ export function createLogger(label?: string) {
console.write(normalized);
};
}
export type Logger = ReturnType<typeof createLogger>;
+115
View File
@@ -0,0 +1,115 @@
// GENERATED BY util/genNamecardRelation
export const CharacterNamecardMap: Record<string, number> = {
"10000002": 210081,
"10000003": 210008,
"10000006": 210011,
"10000014": 210009,
"10000015": 210010,
"10000016": 210005,
"10000020": 210006,
"10000021": 210003,
"10000022": 210007,
"10000023": 210017,
"10000024": 210039,
"10000025": 210018,
"10000026": 210058,
"10000027": 210038,
"10000029": 210004,
"10000030": 210044,
"10000031": 210013,
"10000032": 210016,
"10000033": 210046,
"10000034": 210014,
"10000035": 210019,
"10000036": 210040,
"10000037": 210053,
"10000038": 210054,
"10000039": 210043,
"10000041": 210015,
"10000042": 210020,
"10000043": 210012,
"10000044": 210045,
"10000045": 210063,
"10000046": 210059,
"10000047": 210073,
"10000048": 210069,
"10000049": 210082,
"10000050": 210101,
"10000051": 210070,
"10000052": 210092,
"10000053": 210083,
"10000054": 210093,
"10000055": 210104,
"10000056": 210094,
"10000057": 210105,
"10000058": 210112,
"10000059": 210122,
"10000060": 210119,
"10000061": 210162,
"10000062": 210095,
"10000063": 210107,
"10000064": 210108,
"10000065": 210120,
"10000066": 210114,
"10000067": 210125,
"10000068": 210126,
"10000069": 210124,
"10000070": 210136,
"10000071": 210134,
"10000072": 210135,
"10000073": 210140,
"10000074": 210141,
"10000075": 210143,
"10000076": 210144,
"10000077": 210148,
"10000078": 210147,
"10000079": 210152,
"10000080": 210153,
"10000081": 210156,
"10000082": 210155,
"10000083": 210166,
"10000084": 210165,
"10000085": 210167,
"10000086": 210175,
"10000087": 210174,
"10000088": 210181,
"10000089": 210180,
"10000090": 210185,
"10000091": 210184,
"10000092": 210188,
"10000093": 210187,
"10000094": 210193,
"10000095": 210201,
"10000096": 210195,
"10000097": 210202,
"10000098": 210200,
"10000099": 210205,
"10000100": 210210,
"10000101": 210208,
"10000102": 210209,
"10000103": 210219,
"10000104": 210221,
"10000105": 210222,
"10000106": 210226,
"10000107": 210227,
"10000108": 210228,
"10000109": 210233,
"10000110": 210237,
"10000111": 210236,
"10000112": 210241,
"10000113": 210242,
"10000114": 210244,
"10000115": 210245,
"10000116": 121279,
"10000119": 210253,
"10000120": 210254,
"10000121": 121279,
"10000122": 210261,
"10000123": 210263,
"10000124": 210264,
"10000125": 210267,
"10000126": 121462,
"10000127": 210269,
"10000128": 210273,
};
+6 -1
View File
@@ -107,6 +107,7 @@ export function card(app: App) {
.get(
"/genshin/:uid",
async ({ params: { uid }, query: { debug }, log }) => {
// #region Character List
const [[avatarStore, locs, pfps, namecards], data] =
await Promise.all([
deduper.acquire("stores", () =>
@@ -128,7 +129,10 @@ export function card(app: App) {
pfps[
`${data.playerInfo.profilePicture.id ?? 1}`
].IconPath.replace("_Circle", ""),
namecards[`${data.playerInfo.namecardId ?? 210001}`].Icon,
namecards[`${data.playerInfo.nameCardId ?? 210001}`].Icon,
namecards,
avatarStore,
log,
);
},
{
@@ -163,6 +167,7 @@ export function card(app: App) {
description:
"Generate a Genshin Impact character list card image from a UID.",
},
afterResponse: ({ log }) => log`Rendering completed`,
},
)
.get(
+125 -1
View File
@@ -1,16 +1,27 @@
import { ResolvedRoot } from "enka.ts";
import { assets } from "@/lib/assets";
import { Logger } from "@/lib/logger";
import { CharacterNamecardMap } from "@/lib/namecards";
import { AvatarStore, EnkaNamecards } from "@/type/enka";
import { CardResponse } from "./response";
export async function ListPage(
{ playerInfo: p, uid }: ResolvedRoot,
pfp: string,
namecard: string,
namecards: EnkaNamecards,
avatars: AvatarStore,
log: Logger,
) {
return CardResponse(
<div tw="flex font-[Open_Sans]">
{/* left bar */}
<div tw="flex flex-col justify-between p-[1em] w-[37.5%]">
<div
tw="flex flex-col justify-between p-[1em] w-[37.5%]"
style={{
textShadow: "0px .08em .2em black",
}}
>
{/* profile */}
<div tw="flex gap-[1em] relative">
<div tw="absolute -top-1 -left-1 -bottom-1 -right-1 overflow-hidden rounded-[.3em] opacity-50">
@@ -45,11 +56,124 @@ export async function ListPage(
</div>
</div>
</div>
<div tw="pt-[.2em]">
<div tw="opacity-50">UID</div>
{`: ${uid}`}
</div>
<div tw="text-[1.2em] p-[.7em]">
<div tw="grid grid-cols-[2em_1fr_1.25fr] gap-y-[1em]">
<img tw="aspect-square h-[2em]" src="/i/achi" alt="achievements" />
<div tw="flex items-center font-[ShinShin]">
{p.finishAchievementNum}
</div>
<div tw="text-[.9em] flex items-center justify-end opacity-50 pb-2">
Achievements
</div>
{p.towerFloorIndex && (
<>
<img tw="aspect-square h-[2em]" src="/i/abyss" alt="abyss" />
<div tw="flex items-center font-[ShinShin]">
{p.towerFloorIndex}-{p.towerLevelIndex}
{p.towerStarIndex ? ` | ${p.towerStarIndex}★` : ""}
</div>
<div tw="text-[.9em] flex items-center justify-end opacity-50 pb-2">
Spiral Abyss
</div>
</>
)}
{p.theaterActIndex && (
<>
<img
tw="aspect-square h-[2em]"
src="/i/theater"
alt="theater"
/>
<div tw="flex items-center font-[ShinShin]">{`${p.theaterActIndex} | ${p.theaterStarIndex}`}</div>
<div tw="text-[.9em] flex items-center justify-end opacity-50 pb-2">
Theater
</div>
</>
)}
{p.stygianSeconds && (
<>
<img
tw="aspect-square h-[2em]"
src="/i/stygian"
alt="stygian"
/>
<div tw="flex items-center font-[ShinShin]">
{p.stygianSeconds}s
</div>
<div tw="text-[.9em] flex items-center justify-end opacity-50 pb-2">
Stygian Onslaught
</div>
</>
)}
</div>
</div>
</div>
{/* character list (for some reason ________ absolute hates me) */}
<div tw="absolute top-0 right-0 w-[62.5%] h-[117%]">
<div tw="flex relative flex-wrap gap-x-[1.25%] gap-y-[0.75%] p-[.5em] w-full">
{p.showAvatarInfoList.map((a) => (
<div tw="w-[49.625%] bg-black/50 relative overflow-hidden rounded-lg flex">
<img
src={`/namecard/${a.avatar.avatarId}`}
alt="namecard"
tw="absolute top-0 left-0 right-0 bottom-0 object-cover brightness-50"
/>
<img
src={`/pfp/${a.avatar.avatarId}`}
alt="pfp"
tw="absolute top-0 left-0 bottom-0 aspect-square brightness-0 scale-120 opacity-20"
/>
<img
src={`/pfp/${a.avatar.avatarId}`}
alt="pfp"
tw="absolute top-0 left-0 bottom-0 aspect-square"
/>
<div tw="ml-[20%] p-[.25em] font-[ShinShin] flex flex-col">
<div>{a.avatar.name}</div>
<div>Lv. {a.level}</div>
</div>
</div>
))}
</div>
</div>
</div>,
[
{ src: "/pfp", data: assets.load(pfp) },
{ src: "/namecard", data: assets.load(namecard) },
{ src: "/i/achi", data: assets.load("/img/achievements.png") },
{ src: "/i/abyss", data: assets.load("/img/abyss.png") },
{ src: "/i/theater", data: assets.load("/ui/UI_Icon_Tower_Star1.png") },
{
src: "/i/stygian",
data: assets.load(
`/ui/UI_LeyLineChallenge_Medal_${p.stygianIndex ?? 1}.png`,
),
},
...(p.showAvatarInfoList
.flatMap((a) => {
const card =
namecards[`${CharacterNamecardMap[`${a.avatar.avatarId}`]}`];
return [
card
? {
src: `/namecard/${a.avatar.avatarId}`,
data: assets.load(card ? card.Icon : namecard),
}
: null,
{
src: `/pfp/${a.avatar.avatarId}`,
data: assets.load(
avatars[a.avatar.avatarId].SideIconName.replace("_Side", ""),
),
},
];
})
.filter(Boolean) as { src: string; data: Promise<ArrayBuffer> }[]),
],
log,
);
}
+18 -5
View File
@@ -1,7 +1,9 @@
import { feature } from "bun:bundle";
import ImageResponse from "@takumi-rs/image-response";
import { MaybePromise } from "bun";
import { ReactNode } from "react";
import { font, iconFont, sansFont } from "@/assets/fonts";
import { createLogger } from "@/lib/logger";
export async function CardResponse(
children: ReactNode,
@@ -9,6 +11,7 @@ export async function CardResponse(
src: string;
data: MaybePromise<ArrayBuffer | Buffer>;
}>[] = [],
log = createLogger("RES"),
) {
return new ImageResponse(
<div
@@ -41,11 +44,21 @@ export async function CardResponse(
format: "webp",
// same ratio as card
width: 1228,
fetchedResources: await Promise.all(assets).then((list) =>
Promise.all(
list.map(async (a) => ({ src: a.src, data: await a.data })),
),
),
fetchedResources: await Promise.all(assets)
.then((list) =>
Promise.all(
list.map(async (a) => ({ src: a.src, data: await a.data })),
),
)
.then((l) => {
const res = l.filter((r) => {
if (r.data.byteLength) return true;
if (!feature("API")) console.warn(`[DEV] ${r.src} is blank`);
return false;
});
log`Image resources loaded\n${res.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
return res;
}),
height: 512,
// drawDebugBorder: true,
},
+42
View File
@@ -0,0 +1,42 @@
import { join } from "node:path";
import { $, MaybePromise, write } from "bun";
import { createLogger } from "@/lib/logger";
const log = createLogger();
fetch("https://gi.yatta.moe/api/v2/en/avatar")
.then((a) => a.json())
.then(async (a) => {
const avatars = Object.keys(a.data.items);
log`fetch complete, ${avatars.length} avatars`;
const map = Object.fromEntries(
(
await asyncMap(avatars, (a) =>
fetch(`https://gi.yatta.moe/api/v2/en/avatar/${a}`)
.then((c) => c.text())
.then((t) => {
try {
const c = JSON.parse(t);
log`${c.data.route}`;
return [a, c.data.other?.nameCard?.id] as const;
} catch {
log`${a}: ${t}`;
return [];
}
}),
)
).filter((e) => e[1]),
);
log`crawling complete`;
const target = join(__dirname, "../src/lib/namecards.ts");
await write(
target,
`// GENERATED BY util/genNamecardRelation\n\nexport const CharacterNamecardMap: Record<string, number> = ${JSON.stringify(map)}`,
);
await $`bun biome format ${target} --fix`;
});
async function asyncMap<S, R>(arr: S[], fn: (a: S) => MaybePromise<R>) {
const res: R[] = [];
for (const ent of arr) res.push(await fn(ent));
return res;
}