refactor: separate card renderer from uid endpoint
Build and Deploy / build-and-deploy (push) Successful in 3m0s

This commit is contained in:
2026-07-07 16:54:36 +07:00 Unverified
parent b2f816f604
commit a18af70b2a
5 changed files with 692 additions and 702 deletions
+6 -6
View File
@@ -7,11 +7,11 @@
"dependencies": { "dependencies": {
"@elysiajs/openapi": "^1.4.15", "@elysiajs/openapi": "^1.4.15",
"@napi-rs/canvas": "^0.1.100", "@napi-rs/canvas": "^0.1.100",
"@types/react": "^19.2.14", "@types/react": "^19.2.16",
"elysia": "latest", "elysia": "latest",
"enka.ts": "^2.1.3", "enka.ts": "^2.2.0",
"react": "^19.2.6", "react": "^19.2.7",
"react-dom": "^19.2.6", "react-dom": "^19.2.7",
"takumi-js": "1.1.2", "takumi-js": "1.1.2",
}, },
"devDependencies": { "devDependencies": {
@@ -19,7 +19,7 @@
"bun-types": "latest", "bun-types": "latest",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"oxfmt": "^0.47.0", "oxfmt": "^0.47.0",
"oxlint": "^1.65.0", "oxlint": "^1.68.0",
}, },
}, },
}, },
@@ -178,7 +178,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-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], "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-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
"enka.ts": ["enka.ts@2.1.3", "", { "dependencies": { "devalue": "^5.8.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-8F1/U4Tn/moIGNy1VKCQ2BcWy1fhwvzzLWPRHWwMCFksYYpYRQ1fpoCqAQPlWhs3BDdGj7OchRCy+Ai3DWYkxA=="], "enka.ts": ["enka.ts@2.2.0", "", { "dependencies": { "devalue": "^5.8.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-2eMb1LxvnfGpeqVpgGT6vZ8717+RzwfCjJQHI3DgHYLXBmY9kNSF1HYLPTg4ZxMz3KnNuhyMA/SLFZ47pRW7Xw=="],
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "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 @@
"@napi-rs/canvas": "^0.1.100", "@napi-rs/canvas": "^0.1.100",
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"elysia": "latest", "elysia": "latest",
"enka.ts": "^2.1.3", "enka.ts": "^2.2.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"takumi-js": "1.1.2" "takumi-js": "1.1.2"
+106 -695
View File
@@ -1,9 +1,8 @@
import { feature } from "bun:bundle"; import { feature } from "bun:bundle";
import { file, hash, inspect } from "bun"; import { file, hash, inspect } from "bun";
import { status, t } from "elysia"; import { redirect, status, t } from "elysia";
import { import {
EquipType,
enka, enka,
InternalError, InternalError,
InvalidFormatError, InvalidFormatError,
@@ -12,45 +11,33 @@ import {
RatelimitError, RatelimitError,
TTLCache, TTLCache,
} from "enka.ts"; } from "enka.ts";
import { ImageResponse } from "takumi-js/response"; import type { ImageResponse } from "takumi-js/response";
import { font, iconFont, sansFont } from "@/assets/fonts";
import { generateBackground } from "@/games/genshin/assets/background";
import { ConstsSvg } from "@/games/genshin/assets/consts";
import { featherImage } from "@/games/genshin/assets/feather";
import { EnkaIcon } from "@/games/genshin/assets/icons";
import type { library } from "@/games/genshin/assets/icons-library";
import ListDiffExample from "@/games/genshin/assets/listoriginal.webp" with { type: "file" }; import ListDiffExample from "@/games/genshin/assets/listoriginal.webp" with { type: "file" };
import DiffPageExample from "@/games/genshin/assets/ororo.png" with { type: "file" }; import DiffPageExample from "@/games/genshin/assets/ororo.png" with { type: "file" };
import shadeSvg from "@/games/genshin/assets/shade.svg" with { type: "file" }; import { assets, batchStore } from "@/games/genshin/data/assets";
import { star } from "@/games/genshin/assets/stars";
import { TalentSvg } from "@/games/genshin/assets/talent";
import { WeaponGlazeSvg } from "@/games/genshin/assets/weapon-glaze";
import { assets, batchStore, getAvatar } from "@/games/genshin/data/assets";
import { cardErrorLocs } from "@/games/genshin/data/locs"; import { cardErrorLocs } from "@/games/genshin/data/locs";
import { resolveShowcaseChar } from "@/games/genshin/data/matcher";
import { mockData } from "@/games/genshin/data/mock"; import { mockData } from "@/games/genshin/data/mock";
import { buildDisplayRows } from "@/games/genshin/data/rows"; import type { NumberLike } from "@/games/genshin/types/enka";
import type { RollSummary } from "@/games/genshin/data/substat"; import { Locale } from "@/games/genshin/types/enka";
import { buildRollSummary } from "@/games/genshin/data/substat";
import { Locale, SubstatOrder } from "@/games/genshin/types/enka";
import {
FightPropMapEnum,
PercentageFightProp,
} from "@/games/genshin/types/fightPropMap";
import type { App } from "@/index"; import type { App } from "@/index";
import { Deduplicator } from "@/lib/dedupe"; import { Deduplicator } from "@/lib/dedupe";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { throwNotOk } from "@/lib/util";
import { ErrorPage } from "@/v1/error"; import { ErrorPage } from "@/v1/error";
import DiffPage from "../../../hmr.html"; import DiffPage from "../../../hmr.html";
import {
CharacterPage,
NotInShowcaseError,
PrivateShowcaseError,
UnsupportedCharacterError,
} from "./render/character";
import { ListPage } from "./render/list"; import { ListPage } from "./render/list";
const cache = new TTLCache(); const cache = new TTLCache();
const deduper = new Deduplicator(); const deduper = new Deduplicator();
const shade = file(shadeSvg);
var purging = 0; var purging = 0;
queueMicrotask(() => { queueMicrotask(() => {
@@ -302,36 +289,13 @@ export function card(app: App) {
: mockData, : mockData,
]); ]);
const enkaLoc = locs[lang ?? "en"];
if (typeof data.avatarInfoList === "undefined")
return error(400, errorLoc.private);
const char = resolveShowcaseChar(charId, data.avatarInfoList);
if (!char) return error(400, errorLoc.notInShowcase);
const avatar = getAvatar(
avatarStore,
data.playerInfo,
char.avatar.avatarId,
);
if (!avatar) return error(400, errorLoc.unsupported);
if (!char.equipList)
return error(501, "INTERNAL: No equipList provided");
const weaponRaw = char.equipList.find((e) => e.weapon)!;
const weapon = {
data: weaponRaw.weapon!,
flat: weaponRaw.flat,
store: weaponStore[weaponRaw.itemId ?? "Unknown"],
};
const height = const height =
typeof w === "number" ? w / (1218 / 508) : heightRaw; typeof w === "number" ? w / (1218 / 508) : heightRaw;
const cacheKey = hash( const cacheKey = hash(
JSON.stringify({ JSON.stringify({
data, data,
charId: char.avatar.avatarId, charId,
lang, lang,
height, height,
enableSubstat, enableSubstat,
@@ -341,619 +305,32 @@ export function card(app: App) {
}), }),
).toString(36); ).toString(36);
if (feature("API")) { if (feature("API")) {
const cached = cache.get<ImageResponse>(cacheKey); const cached = cache.get<ImageResponse | string>(cacheKey);
if (cached) return cached.clone(); if (cached)
return typeof cached === "string" ? cached : cached.clone();
} }
// #region Processing const res = await CharacterPage({
const charLvl = char.propMap?.[4001].val ?? 1; data,
const ascension = char.propMap?.[1002].val ?? 1; charId,
const promoteMaxLevelMap = [20, 40, 50, 60, 70, 80, 90, 100]; avatarStore,
const charLvlMax = weaponStore,
ascension * 10 + relicStore,
(ascension > 0 ? 10 : 0) + locs,
20 + affixes,
(charLvl > 90 ? 10 : 0); lang,
height,
const statRows = buildDisplayRows(char, enkaLoc); debug,
enableSubstat,
const artifacts = ( enableRollQuality,
Object.keys(EquipType) as (keyof typeof EquipType)[] format,
).map((t) => { log,
const a = char.equipList!.find((e) => e.flat.equipType === t);
if (!a) return null;
const appendPropIdList = a.reliquary?.appendPropIdList ?? [];
const props = enableRollQuality
? appendPropIdList.map((id) => ({
id: FightPropMapEnum[affixes[`${id}`].PropType],
...affixes[`${id}`],
}))
: [];
return {
set: a.flat.setId!, // it is an artifact, therefore this always exist
stars: a.flat.rankLevel,
level: a.reliquary!.level - 1,
appendPropIdList,
icon: `/ui/${a.flat.icon}.png`,
main: a.flat.reliquaryMainstat!,
sub: (a.flat.reliquarySubstats ?? [])
.map((s) => ({
...s,
props: props.filter((p) => p.id === s.appendPropId),
}))
.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: enkaLoc[relicStore.Sets[set].Name],
count,
}));
// substat breakdown
const rollSummary = enableSubstat
? buildRollSummary(
artifacts
.filter((a) => !!a)
.flatMap((a) => a!.appendPropIdList.map(String)),
affixes,
)
: undefined;
// #region Assets loading
const resources = await Promise.all([
assets
.load(
// when reached level 40, then ascend, promoteLevel is 2
weapon.data.promoteLevel >= 2
? weapon.store.AwakenIcon
: weapon.store.Icon,
)
.then((data) => ({
src: "/weapon",
data,
})),
...[...avatar.Consts, ...Object.values(avatar.Skills)].map(
async (path) => ({
src: path,
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 && avatar.Costumes?.[char.costumeId].Art) ||
avatar.SideIconName.replace(
"AvatarIcon_Side",
"Gacha_AvatarImg",
),
(buf) =>
featherImage(buf, {
passes: [[0, 0.3, 0, 0]],
ratio: 1024 / 1124,
}),
)
.then(async (data) => ({
src: "/avatar",
data,
})),
assets
.fnCache(`bg-${avatar.Element}`, () =>
generateBackground({
element: avatar.Element,
width: 1218,
height: 508,
}).then((r) => r.canvas.toBuffer("image/webp")),
)
.then((data) => ({
src: `/bg-${avatar.Element}`,
data,
})),
] satisfies Promise<{
src: string;
data: ArrayBuffer | Buffer;
}>[]);
log`Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
if (debug === "dump") {
log`Debug endpoint, returning JSON`;
return JSON.stringify(
{
charLvl,
charLvlMax,
cacheKey,
cache: assets.health,
assets: Object.fromEntries(
resources.map((e) => [
e.src,
`${(e.data.byteLength / 1000).toFixed(2)}Kb`,
]),
),
char,
weapon,
statRows,
rollSummary,
artifacts,
artifactSets,
},
null,
2,
);
}
// #region Rendering
const res = new ImageResponse(
<div
tw={`flex text-[${30.466 * (height / 1024)}px] font-[ShinShin]`}
>
<SubstatDisplay
substat={rollSummary}
element={avatar.Element}
>
<div
tw={`flex relative text-white rounded-[.5em] overflow-hidden aspect-[1218/508] h-[${height}px]`}
>
<div tw="flex aspect-[1218/508] h-full">
<img
src={`/bg-${avatar.Element}`}
alt="background"
tw="w-full h-full object-cover"
/>
</div>
{/* left section */}
<div
tw="absolute top-0 bottom-0 left-0 w-[30.4em] p-[1.6em] flex flex-col"
style={{
textShadow:
"0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)",
}}
>
{/* avatar */}
<img
src="/avatar"
alt="char"
tw="absolute h-full aspect-[1024/1124] left-[50%] top-[55%] max-w-none"
style={{
transform: "translate(-50%, -50%) scale(1.3)",
transformOrigin: "50% 50%",
}}
/>
{/* left side vignette */}
<div tw="flex absolute top-0 left-0 bottom-0 right-0">
<img src="shade" alt="shade" tw="opacity-40" />
</div>
{/* char and username */}
<div tw="text-[120%] pb-[.5%] flex items-center gap-[.5em]">
{char.avatar.name}
<div tw="text-[0.77em] opacity-80 flex items-center gap-[.7em] pl-[.28em] pt-[.5em]">
<svg
width="16"
height="16"
viewBox="0 0 100 100"
tw="mt-[.2em] h-[.7em] w-[.7em]"
>
<title>doritos</title>
<polygon
points="50,10 90,90 10,90"
fill="currentColor"
/>
</svg>
{data.playerInfo.nickname}
</div>
</div>
{/* level */}
<div tw="flex text-[102%] pt-[.3em]">
Lv. <span tw="px-[.1em]">{charLvl}</span> /
<span tw="opacity-60 pl-[.3em]">{charLvlMax}</span>
</div>
{/* fren */}
<div tw="text-[110%] flex gap-[.3em] items-end pt-[.35em]">
<svg
xmlns="http://www.w3.org/2000/svg"
tw="h-[1.2em] mb-[.2em]"
viewBox="0 0 14 14"
>
<title>fren</title>
<path
fill="white"
d="M6.922 12.375c0-.134-.97-1.102-1.669-1.663l-.697-.56.728-.045c.426-.027.931-.11 1.218-.203l.49-.157.457.155a4.4 4.4 0 0 0 1.2.202l.744.048-.77.618c-.825.663-1.555 1.4-1.555 1.571 0 .058-.033.105-.073.105a.07.07 0 0 1-.073-.071M4.163 9.43q-1.678-.451-2.552-2.236C1.225 6.407.679 5.763.39 5.756c-.082-.002-.032-.055.135-.143.348-.184.749-.66.979-1.162.373-.814.44-.915.94-1.426.553-.566 1.156-.909 1.86-1.06.449-.096 1.677-.051 1.99.073l.21.083-.468.428c-.257.236-.558.56-.668.718-.177.255-.253.3-.63.375a2.094 2.094 0 0 0-1.487 3c.798 1.591 3.044 1.548 3.83-.072.137-.28.163-.45.155-1.006-.009-.664-.007-.67.247-.967.285-.333.755-.606.983-.572.367.056.632 1.656.422 2.545-.323 1.37-1.443 2.51-2.818 2.868-.519.135-1.39.132-1.907-.007Zm3.65-.017-.384-.123.48-.427c.264-.235.562-.553.663-.706.154-.232.246-.29.56-.358.526-.112.95-.332 1.21-.628.965-1.1.677-2.697-.607-3.36-.306-.158-.471-.193-.92-.193-.626 0-1.013.156-1.48.6-.477.454-.59.748-.602 1.557-.009.651-.026.73-.207.968-.25.327-.687.62-.957.639-.19.014-.223-.023-.388-.422-.255-.62-.264-1.744-.018-2.459C5.713 2.906 7.125 1.9 8.816 1.9c1.538 0 2.764.79 3.47 2.236.41.84.74 1.274 1.09 1.44l.273.13-.262.135c-.37.19-.722.651-1.067 1.4-.497 1.08-1.461 1.905-2.567 2.2-.525.139-1.462.126-1.94-.027z"
></path>
</svg>
{char.fetterInfo?.expLevel ?? 0}
</div>
{/* sep */}
<div tw="flex grow" />
{/* constellation */}
<div
tw="flex align-start flex-col text-[1.12em]"
className={`${avatar.Element} locked`}
>
{avatar.Consts.filter((c) => c !== "None").map(
(c, i) => (
<div
style={{
position: "relative",
display: "flex",
margin: "-.55em 0 0em -.6em",
}}
>
<ConstsSvg
locked={i >= (char.talentIdList?.length || 0)}
/>
<img
src={c}
alt={`Constellation ${i + 1}`}
tw={`absolute top-0 left-0 w-[1.6em] mt-[.72em] ml-[.7em] pointer-events-auto ${
i >= (char.talentIdList?.length || 0)
? "opacity-40"
: ""
}`}
/>
</div>
),
)}
</div>
{/* talent-wrap */}
<div
style={{
display: "flex",
position: "absolute",
right: "4.1%",
bottom: "6%",
flexDirection: "column",
alignItems: "flex-end",
width: "60%",
fontSize: "1.05em",
}}
>
{avatar.SkillOrder.map(
(s) =>
Object.entries(avatar.Skills).find(
(k) => k[0] === `${s}`,
)!,
).map(([key, src]) => {
if (src === "None") return;
const extra =
char.proudSkillExtraLevelMap?.[
`${avatar.ProudMap[key]}`
] ?? 0;
const talentLevel =
(char.skillLevelMap?.[key] ?? 0) + extra;
return (
<div tw="mt-[.6em] relative flex">
<TalentSvg
tw={`Talents ${avatar.Element} w-[3.6em] opacity-60 text-black mb-[.5em] -mt-[.25em]`}
/>
<img
key={key}
src={src}
alt={`Talent ${key}`}
tw="w-[2.5em] absolute left-[.15em] top-[.05em] m-[.4em_0_0_.4em]"
/>
<div tw="absolute -bottom-[.1em] w-full flex justify-center">
<div
tw={`flex ${extra ? "bg-[#45deffcc]" : "bg-[#323232b3]"} pt-[5%] pb-[2%] text-[.85em] rounded-[1.4em]`}
>
<span tw="px-[.6em]">{talentLevel}</span>
</div>
</div>
</div>
);
})}
</div>
<div tw="flex mb-[-1em] ml-[-.3em] mt-[.3em] text-[.96em] opacity-80">
{data.uid}
</div>
</div>
{/* middle section */}
<div tw="absolute top-0 bottom-0 right-[24.2em] w-[25.5em] p-[1.5em_.7em_.7em] flex flex-col text-[1.01em]">
{/* weapon */}
<div tw="flex flex-start" className="Weapon">
{/* display */}
<div tw="flex w-[27%] leading-none shrink-0 min-h-[5.5em] relative">
<img src="/weapon" alt="Weapon" tw="w-full" />
<WeaponGlazeSvg
tw="absolute top-[90%] -left-[15%] w-[130%]"
style={{
color: {
1: "#868a82",
2: "#70ca7a",
3: "#5cabff",
4: "#cc7bff",
5: "#dd8637",
}[weapon.store.Rarity],
}}
/>
<div tw="absolute w-full top-[91%] flex justify-center">
{Array.from({ length: weapon.store.Rarity }, () =>
star(".8em"),
)}
</div>
</div>
{/* caption */}
<div tw="px-[5%] grow text-white self-center flex flex-col">
<div
tw="flex text-[108%] leading-[1.3]"
style={{
textShadow:
"0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)",
}}
>
<span>
{enkaLoc[weapon.store.NameTextMapHash]}
</span>
</div>
{/* stats */}
<div tw="pt-[3%] pb-[2%] flex gap-[.7em]">
{weapon.flat.weaponStats?.map((s) => (
<div tw="rounded-[.2em] leading-none bg-white/15 px-[.34em] py-[0.25em] flex items-center gap-[.7em] text-[1.03em]">
<EnkaIcon
icon={
s.appendPropId as keyof typeof library
}
tw="h-[1.1em]"
/>
<div tw="pt-[.1em]">
{s.statValue}
{(PercentageFightProp as string[]).includes(
s.appendPropId,
)
? "%"
: ""}
</div>
</div>
))}
</div>
<div tw="flex gap-[.85em]">
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em] text-[wheat]">
<div tw="pt-[.1em]">
R
{Object.values(
weapon.data.affixMap || { _: 0 },
)[0] + 1}
</div>
</div>
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em]">
<div tw="pt-[.1em] flex items-center">
<span tw="pr-[.1em]">Lv.</span>{" "}
{weapon.data.level}/
<div tw="opacity-60 pl-[.25em]">
{
promoteMaxLevelMap[
weapon.data.promoteLevel ?? 0
]
}
</div>
</div>
</div>
</div>
</div>
</div>
{/* stats table */}
<div tw="text-white flex flex-col pt-[2.2em] pr-[4.7%] -mb-[.8em] grow text-[.96em] justify-between">
{/* <div /> justify spacer */}
{statRows.map((r, i) => (
<div tw="flex items-center whitespace-nowrap">
<EnkaIcon
icon={
(
{
FIGHT_PROP_MAX_HP: "FIGHT_PROP_HP",
FIGHT_PROP_CUR_ATTACK:
"FIGHT_PROP_ATTACK",
FIGHT_PROP_CUR_DEFENSE:
"FIGHT_PROP_DEFENSE",
} satisfies Record<
string,
keyof typeof library
>
)[r.key] || (r.key as keyof typeof library)
}
tw="h-[1em] mr-[1em] -mt-[.05em]"
/>
<div tw="flex grow justify-between text-[1.03em]">
<div>{r.name}</div>
<div tw={i < 3 ? "-top-[.3em]" : ""}>
{r.formatted}
</div>
</div>
{i < 3 && (
<div tw="flex text-[.83em] opacity-70 absolute w-full -bottom-[.75em]">
<div tw="flex grow justify-end">
<div tw="flex">
{Math.round(r.base).toLocaleString()}
<div tw="flex text-[#82ff9a] pl-1">
+{Math.round(r.delta).toLocaleString()}
</div>
</div>
</div>
</div>
)}
</div>
))}
{artifactSets.length && (
<div
tw="flex w-full gap-[.5em]"
style={{
textShadow:
"0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)",
}}
>
<div tw="w-[2em] h-[2em] shrink-0 text-[1.15em] bg-[#00000030] flex justify-center items-center font-[shicon] leading-[2.3em] rounded-[.2em]">
</div>
<div tw="flex flex-col grow justify-center gap-[.1em]">
{artifactSets.map((s) => (
<div tw="flex text-[.92em]">
<div tw="grow flex justify-center items-center leading-none text-[#90ee90] whitespace-nowrap overflow-ellipsis overflow-hidden px-[.3em]">
{s.set}
</div>
<div tw="flex bg-[#00000030] rounded-[.2em] justify-center items-center w-[2em] ml-[.5em] shrink-0 pt-[.2em]">
{s.count}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* right section */}
<div tw="absolute top-0 bottom-0 right-0 w-[25.7em] 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-[.5em]">
<img
src={a.icon}
alt="artifact"
tw="absolute top-[48%] -left-[8%] w-[45%] -translate-y-1/2 -translate-x-[10%] object-contain aspect-square scale-90"
/>
{/* main */}
<div tw="h-full w-[30%] text-[105%] text-right shrink-0 flex flex-col justify-center mr-[.25em]">
<EnkaIcon
icon={a.main.mainPropId}
tw="h-[1em] w-[1em] ml-auto max-h-[22%] mt-[.5em]"
/>
<div
tw="text-[1.1em] pt-[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-center justify-end">
<div>
{Array.from({ length: a.stars ?? 0 }, () =>
star(".6em"),
)}
</div>
<div tw="w-[2em] flex justify-end">
<div tw="text-[75%] leading-none bg-black/50 px-[.25em] py-[.2em] rounded-[.2em] -mb-[.2em]">
+{a.level}
</div>
</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 pl-[.7em] relative gap-[.5em] w-1/2 h-1/2 ${i === 3 ? "opacity-80" : ""} ${i > 1 ? "pl-[.3em]" : ""}`}
>
{enableRollQuality && (
<div tw="absolute top-0 m-[.1em] left-[-.25em] bottom-0 right-[.25em] bg-gradient-to-t from-transparent to-black/25 rounded-[.2em] flex gap-0.5">
{s.props.map((p) => (
<div
tw="flex justify-center items-end grow bg-gradient-to-t from-transparent to-white/10"
style={{
borderBottomWidth: "1px",
borderStyle: "solid",
borderColor:
"rgba(255,255,255,.8)",
maxWidth: `${(p.Efficiency / s.props.length) * 100}%`,
}}
>
{Array(p.Position).fill(
<div tw="flex bg-[#fff8] w-1 h-1 mr-[3px] mb-[3px] rounded-full" />,
)}
</div>
))}
</div>
)}
<EnkaIcon
icon={s.appendPropId}
tw="h-[1.1em] w-[1.1em]"
/>
<div tw="flex text-[1.05em]">
+
{PercentageFightProp.includes(
s.appendPropId as (typeof PercentageFightProp)[number],
)
? `${s.statValue.toFixed(1)}%`
: s.statValue.toLocaleString()}
</div>
</div>
))}
</div>
</div>
) : (
<div tw="flex h-[18%] bg-[#0000004f] rounded-[.3em] opacity-20" />
),
)}
</div>
</div>
</SubstatDisplay>
</div>,
{
fonts: await Promise.all([
font.arrayBuffer(),
sansFont.arrayBuffer(),
iconFont.arrayBuffer(),
]),
persistentImages: [
{ data: await shade.arrayBuffer(), src: "shade" },
],
fetchedResources: resources.filter((e) => e.data.byteLength),
format,
drawDebugBorder: debug === "render",
stylesheets: [
/* css */ `
.Rock { color: #ffc64a; }
.Talents.Rock { color: #4a3a18!important }
.Wind { color: #71fdec; }
.Talents.Wind { color: #1d4d47!important }
.Ice { color: #98efff; }
.Talents.Ice { color: #193d43!important }
.Water { color: #56a2ff; }
.Talents.Water { color: #18293e!important }
.Electric { color: #ffa1ff; }
.Talents.Electric { color: #34123b!important }
.Fire { color: #f58453; }
.Talents.Fire { color: #541900!important }
.Grass { color: #a5ffab; }
.Talents.Grass { color: #134517!important }
`,
],
},
);
queueMicrotask(() => { queueMicrotask(() => {
cache.set(cacheKey, res, 1000 * 60 * 60 * 24); // cache for 24 hour cache.set(cacheKey, res, 1000 * 60 * 60 * 24); // cache for 24 hour
}); });
return res.clone(); return typeof res === "string" ? res : res.clone();
} catch (err) { } catch (err) {
if (err instanceof InvalidFormatError) if (err instanceof InvalidFormatError)
return error(400, errorLoc.uidFormat); return error(400, errorLoc.uidFormat);
@@ -965,6 +342,12 @@ export function card(app: App) {
return error(429, errorLoc.ratelimit); return error(429, errorLoc.ratelimit);
else if (err instanceof InternalError) else if (err instanceof InternalError)
return status(502, errorLoc.internal); return status(502, errorLoc.internal);
else if (err instanceof PrivateShowcaseError)
return error(400, errorLoc.private);
else if (err instanceof NotInShowcaseError)
return error(400, errorLoc.notInShowcase);
else if (err instanceof UnsupportedCharacterError)
return error(400, errorLoc.unsupported);
log.error`${err}`; log.error`${err}`;
try { try {
return error( return error(
@@ -1054,7 +437,74 @@ export function card(app: App) {
}, },
afterResponse: ({ log }) => log`Rendering completed`, afterResponse: ({ log }) => log`Rendering completed`,
}, },
), )
.get("/enka/:username", async ({ params: { username } }) => {
const cacheKey = `enka-redir::${username}`;
if (feature("API")) {
const cached = cache.get<Response>(cacheKey);
if (cached) return cached.clone();
}
const [hoyo] = await fetch(
`https://enka.network/api/profile/${username}/hoyos/`,
)
.then(throwNotOk)
.then(
(r) =>
r.json() as Promise<
Record<string, { hoyo_type: number; order: NumberLike }>
>,
)
.then((e) =>
Object.entries(e)
.map(([k, v]) => ({ hash: k, ...v }))
.sort((a, b) => Number(a.order) - Number(b.order))
.filter((e) => e.hoyo_type === 0),
);
if (!hoyo) return status(404, "this needs to be changed.");
const builds = await fetch(
`https://enka.network/api/profile/${username}/hoyos/${hoyo.hash}/builds/`,
)
.then(throwNotOk)
.then(
(r) =>
r.json() as Promise<
Record<string, { id: number; order: NumberLike }[]>
>,
)
.then((e) =>
((
pair:
| [string, { id: number; order: `${number}` }[]]
| undefined,
) =>
pair
? {
char: pair[0],
...pair[1]
.sort((a, b) => Number(a.order) - Number(b.order))
.shift(),
}
: undefined)(Object.entries(e).shift()),
);
if (!builds?.id) return status(404, "this needs to be changed.");
const res = redirect(
`/v1/card/enka/${username}/${hoyo.hash}/${builds.char}/${builds.id}`,
);
queueMicrotask(() => {
cache.set(
`enka-redir::${username}::${hoyo.hash}`,
builds,
1000 * 120,
);
cache.set(cacheKey, res, 1000 * 60); // cache for 1 minute
});
return res.clone();
})
.get("/enka/:username/:hash/:char/:build", () => {
}),
) )
.get( .get(
"/health/enka", "/health/enka",
@@ -1086,42 +536,3 @@ export function card(app: App) {
}, },
); );
} }
function SubstatDisplay({
substat,
element,
children,
}: {
substat: RollSummary[] | undefined;
element: string;
children?: React.ReactNode;
}) {
if (!substat) return children;
return (
<div tw="flex flex-col-reverse">
<div
tw="flex h-[2.3em] bg-current relative -top-[.5em] pt-[.5em] -mb-[.5em] rounded-b-[.5em]"
className={`Talents ${element}`}
>
<div tw="text-white w-full flex items-center flex-wrap justify-end bg-black/25">
{/* substat */}
{substat.map((s) => (
<div tw="mr-[1.2em] flex gap-[.2em] items-center whitespace-nowrap">
{/* arq */}
<div tw="ml-[.2em] mb-[.1em] text-[.75em] flex flex-col items-center font-[Open_Sans]">
{s.efficiency}
{/* hr */}
<div tw="flex w-full h-[1px] bg-white"></div>
{s.level}
</div>
<EnkaIcon icon={s.id} tw="h-[1em]" />
{s.formatted}
</div>
))}
</div>
</div>
{children}
</div>
);
}
+565
View File
@@ -0,0 +1,565 @@
import { file } from "bun";
import type { ResolvedRoot } from "enka.ts";
import { EquipType } from "enka.ts";
import { ImageResponse } from "takumi-js/response";
import { font, iconFont, sansFont } from "@/assets/fonts";
import { generateBackground } from "@/games/genshin/assets/background";
import { ConstsSvg } from "@/games/genshin/assets/consts";
import { featherImage } from "@/games/genshin/assets/feather";
import { EnkaIcon } from "@/games/genshin/assets/icons";
import type { library } from "@/games/genshin/assets/icons-library";
import shadeSvg from "@/games/genshin/assets/shade.svg" with { type: "file" };
import { star } from "@/games/genshin/assets/stars";
import { TalentSvg } from "@/games/genshin/assets/talent";
import { WeaponGlazeSvg } from "@/games/genshin/assets/weapon-glaze";
import { assets, getAvatar } from "@/games/genshin/data/assets";
import { resolveShowcaseChar } from "@/games/genshin/data/matcher";
import { buildDisplayRows } from "@/games/genshin/data/rows";
import type { RollSummary } from "@/games/genshin/data/substat";
import { buildRollSummary } from "@/games/genshin/data/substat";
import type {
AvatarStore,
EnkaAffixes,
EnkaLocs,
RelicStore,
WeaponStore,
} from "@/games/genshin/types/enka";
import { SubstatOrder } from "@/games/genshin/types/enka";
import {
FightPropMapEnum,
PercentageFightProp,
} from "@/games/genshin/types/fightPropMap";
import type { Logger } from "@/lib/logger";
const shade = file(shadeSvg);
export class PrivateShowcaseError extends Error {
constructor() {
super("PRIVATE_SHOWCASE");
}
}
export class NotInShowcaseError extends Error {
constructor() {
super("NOT_IN_SHOWCASE");
}
}
export class UnsupportedCharacterError extends Error {
constructor() {
super("UNSUPPORTED_CHARACTER");
}
}
type CharacterPageOptions = {
data: ResolvedRoot;
charId: string;
avatarStore: AvatarStore;
weaponStore: WeaponStore;
relicStore: RelicStore;
locs: EnkaLocs;
affixes: EnkaAffixes;
lang?: keyof EnkaLocs;
height: number;
debug?: "none" | "render" | "dump";
enableSubstat?: boolean;
enableRollQuality?: boolean;
format?: "jpeg" | "webp" | "png";
log: Logger;
};
export async function CharacterPage({
data,
charId,
avatarStore,
weaponStore,
relicStore,
locs,
affixes,
lang,
height,
debug = "none",
enableSubstat = false,
enableRollQuality = false,
format = "webp",
log,
}: CharacterPageOptions) {
const enkaLoc = locs[lang ?? "en"];
if (typeof data.avatarInfoList === "undefined")
throw new PrivateShowcaseError();
const char = resolveShowcaseChar(charId, data.avatarInfoList);
if (!char) throw new NotInShowcaseError();
const avatar = getAvatar(avatarStore, data.playerInfo, char.avatar.avatarId);
if (!avatar) throw new UnsupportedCharacterError();
if (!char.equipList) throw new Error("INTERNAL: No equipList provided");
const weaponRaw = char.equipList.find((e) => e.weapon)!;
const weapon = {
data: weaponRaw.weapon!,
flat: weaponRaw.flat,
store: weaponStore[weaponRaw.itemId ?? "Unknown"],
};
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, enkaLoc);
const artifactSlots = Object.keys(EquipType) as (keyof typeof EquipType)[];
const artifacts = artifactSlots.map((t) => {
const a = char.equipList!.find((e) => e.flat.equipType === t);
if (!a) return null;
const appendPropIdList = a.reliquary?.appendPropIdList ?? [];
const props = enableRollQuality
? appendPropIdList.map((id) => ({
id: FightPropMapEnum[affixes[`${id}`].PropType],
...affixes[`${id}`],
}))
: [];
return {
set: a.flat.setId!,
stars: a.flat.rankLevel,
level: a.reliquary!.level - 1,
appendPropIdList,
icon: `/ui/${a.flat.icon}.png`,
main: a.flat.reliquaryMainstat!,
sub: (a.flat.reliquarySubstats ?? [])
.map((s) => ({
...s,
props: props.filter((p) => p.id === s.appendPropId),
}))
.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: enkaLoc[relicStore.Sets[set].Name],
count,
}));
const rollSummary = enableSubstat
? buildRollSummary(
artifacts
.filter((a) => !!a)
.flatMap((a) => a!.appendPropIdList.map(String)),
affixes,
)
: undefined;
const resources = await Promise.all([
assets
.load(
weapon.data.promoteLevel >= 2 ? weapon.store.AwakenIcon : weapon.store.Icon,
)
.then((data) => ({ src: "/weapon", data })),
...[...avatar.Consts, ...Object.values(avatar.Skills)].map(async (path) => ({
src: path,
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 && avatar.Costumes?.[char.costumeId].Art) ||
avatar.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg"),
(buf) =>
featherImage(buf, {
passes: [[0, 0.3, 0, 0]],
ratio: 1024 / 1124,
}),
)
.then((data) => ({ src: "/avatar", data })),
assets
.fnCache(`bg-${avatar.Element}`, () =>
generateBackground({
element: avatar.Element,
width: 1218,
height: 508,
}).then((r) => r.canvas.toBuffer("image/webp")),
)
.then((data) => ({ src: `/bg-${avatar.Element}`, data })),
] satisfies Promise<{ src: string; data: ArrayBuffer | Buffer; }>[]);
log`Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
if (debug === "dump") {
log`Debug endpoint, returning JSON`;
return JSON.stringify(
{
charLvl,
charLvlMax,
cache: assets.health,
assets: Object.fromEntries(
resources.map((e) => [e.src, `${(e.data.byteLength / 1000).toFixed(2)}Kb`]),
),
char,
weapon,
statRows,
rollSummary,
artifacts,
artifactSets,
},
null,
2,
);
}
return new ImageResponse(
<div tw={`flex text-[${30.466 * (height / 1024)}px] font-[ShinShin]`}>
<SubstatDisplay substat={rollSummary} element={avatar.Element}>
<div
tw={`flex relative text-white rounded-[.5em] overflow-hidden aspect-[1218/508] h-[${height}px]`}
>
<div tw="flex aspect-[1218/508] h-full">
<img src={`/bg-${avatar.Element}`} alt="background" tw="w-full h-full object-cover" />
</div>
<div
tw="absolute top-0 bottom-0 left-0 w-[30.4em] p-[1.6em] flex flex-col"
style={{ textShadow: "0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)" }}
>
<img
src="/avatar"
alt="char"
tw="absolute h-full aspect-[1024/1124] left-[50%] top-[55%] max-w-none"
style={{
transform: "translate(-50%, -50%) scale(1.3)",
transformOrigin: "50% 50%",
}}
/>
<div tw="flex absolute top-0 left-0 bottom-0 right-0">
<img src="shade" alt="shade" tw="opacity-40" />
</div>
<div tw="text-[120%] pb-[.5%] flex items-center gap-[.5em]">
{char.avatar.name}
<div tw="text-[0.77em] opacity-80 flex items-center gap-[.7em] pl-[.28em] pt-[.5em]">
<svg width="16" height="16" viewBox="0 0 100 100" tw="mt-[.2em] h-[.7em] w-[.7em]">
<title>doritos</title>
<polygon points="50,10 90,90 10,90" fill="currentColor" />
</svg>
{data.playerInfo.nickname}
</div>
</div>
<div tw="flex text-[102%] pt-[.3em]">
Lv. <span tw="px-[.1em]">{charLvl}</span> /
<span tw="opacity-60 pl-[.3em]">{charLvlMax}</span>
</div>
<div tw="text-[110%] flex gap-[.3em] items-end pt-[.35em]">
<svg xmlns="http://www.w3.org/2000/svg" tw="h-[1.2em] mb-[.2em]" viewBox="0 0 14 14">
<title>fren</title>
<path
fill="white"
d="M6.922 12.375c0-.134-.97-1.102-1.669-1.663l-.697-.56.728-.045c.426-.027.931-.11 1.218-.203l.49-.157.457.155a4.4 4.4 0 0 0 1.2.202l.744.048-.77.618c-.825.663-1.555 1.4-1.555 1.571 0 .058-.033.105-.073.105a.07.07 0 0 1-.073-.071M4.163 9.43q-1.678-.451-2.552-2.236C1.225 6.407.679 5.763.39 5.756c-.082-.002-.032-.055.135-.143.348-.184.749-.66.979-1.162.373-.814.44-.915.94-1.426.553-.566 1.156-.909 1.86-1.06.449-.096 1.677-.051 1.99.073l.21.083-.468.428c-.257.236-.558.56-.668.718-.177.255-.253.3-.63.375a2.094 2.094 0 0 0-1.487 3c.798 1.591 3.044 1.548 3.83-.072.137-.28.163-.45.155-1.006-.009-.664-.007-.67.247-.967.285-.333.755-.606.983-.572.367.056.632 1.656.422 2.545-.323 1.37-1.443 2.51-2.818 2.868-.519.135-1.39.132-1.907-.007Zm3.65-.017-.384-.123.48-.427c.264-.235.562-.553.663-.706.154-.232.246-.29.56-.358.526-.112.95-.332 1.21-.628.965-1.1.677-2.697-.607-3.36-.306-.158-.471-.193-.92-.193-.626 0-1.013.156-1.48.6-.477.454-.59.748-.602 1.557-.009.651-.026.73-.207.968-.25.327-.687.62-.957.639-.19.014-.223-.023-.388-.422-.255-.62-.264-1.744-.018-2.459C5.713 2.906 7.125 1.9 8.816 1.9c1.538 0 2.764.79 3.47 2.236.41.84.74 1.274 1.09 1.44l.273.13-.262.135c-.37.19-.722.651-1.067 1.4-.497 1.08-1.461 1.905-2.567 2.2-.525.139-1.462.126-1.94-.027z"
></path>
</svg>
{char.fetterInfo?.expLevel ?? 0}
</div>
<div tw="flex grow" />
<div tw="flex align-start flex-col text-[1.12em]" className={`${avatar.Element} locked`}>
{avatar.Consts.filter((c) => c !== "None").map((c, i) => (
<div
key={c}
style={{ position: "relative", display: "flex", margin: "-.55em 0 0em -.6em" }}
>
<ConstsSvg locked={i >= (char.talentIdList?.length || 0)} />
<img
src={c}
alt={`Constellation ${i + 1}`}
tw={`absolute top-0 left-0 w-[1.6em] mt-[.72em] ml-[.7em] pointer-events-auto ${i >= (char.talentIdList?.length || 0) ? "opacity-40" : ""}`}
/>
</div>
))}
</div>
<div
style={{
display: "flex",
position: "absolute",
right: "4.1%",
bottom: "6%",
flexDirection: "column",
alignItems: "flex-end",
width: "60%",
fontSize: "1.05em",
}}
>
{avatar.SkillOrder.map((s) => Object.entries(avatar.Skills).find((k) => k[0] === `${s}`)!).map(
([key, src]) => {
if (src === "None") return null;
const extra = char.proudSkillExtraLevelMap?.[`${avatar.ProudMap[key]}`] ?? 0;
const talentLevel = (char.skillLevelMap?.[key] ?? 0) + extra;
return (
<div key={key} tw="mt-[.6em] relative flex">
<TalentSvg tw={`Talents ${avatar.Element} w-[3.6em] opacity-60 text-black mb-[.5em] -mt-[.25em]`} />
<img src={src} alt={`Talent ${key}`} tw="w-[2.5em] absolute left-[.15em] top-[.05em] m-[.4em_0_0_.4em]" />
<div tw="absolute -bottom-[.1em] w-full flex justify-center">
<div tw={`flex ${extra ? "bg-[#45deffcc]" : "bg-[#323232b3]"} pt-[5%] pb-[2%] text-[.85em] rounded-[1.4em]`}>
<span tw="px-[.6em]">{talentLevel}</span>
</div>
</div>
</div>
);
},
)}
</div>
<div tw="flex mb-[-1em] ml-[-.3em] mt-[.3em] text-[.96em] opacity-80">{data.uid}</div>
</div>
<div tw="absolute top-0 bottom-0 right-[24.2em] w-[25.5em] p-[1.5em_.7em_.7em] flex flex-col text-[1.01em]">
<div tw="flex flex-start" className="Weapon">
<div tw="flex w-[27%] leading-none shrink-0 min-h-[5.5em] relative">
<img src="/weapon" alt="Weapon" tw="w-full" />
<WeaponGlazeSvg
tw="absolute top-[90%] -left-[15%] w-[130%]"
style={{
color: {
1: "#868a82",
2: "#70ca7a",
3: "#5cabff",
4: "#cc7bff",
5: "#dd8637",
}[weapon.store.Rarity],
}}
/>
<div tw="absolute w-full top-[91%] flex justify-center">
{Array.from({ length: weapon.store.Rarity }, () => star(".8em"))}
</div>
</div>
<div tw="px-[5%] grow text-white self-center flex flex-col">
<div tw="flex text-[108%] leading-[1.3]" style={{ textShadow: "0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)" }}>
<span>{enkaLoc[weapon.store.NameTextMapHash]}</span>
</div>
<div tw="pt-[3%] pb-[2%] flex gap-[.7em]">
{weapon.flat.weaponStats?.map((s) => (
<div key={s.appendPropId} tw="rounded-[.2em] leading-none bg-white/15 px-[.34em] py-[0.25em] flex items-center gap-[.7em] text-[1.03em]">
<EnkaIcon icon={s.appendPropId as keyof typeof library} tw="h-[1.1em]" />
<div tw="pt-[.1em]">
{s.statValue}
{(PercentageFightProp as string[]).includes(s.appendPropId) ? "%" : ""}
</div>
</div>
))}
</div>
<div tw="flex gap-[.85em]">
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em] text-[wheat]">
<div tw="pt-[.1em]">R{Object.values(weapon.data.affixMap || { _: 0 })[0] + 1}</div>
</div>
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em]">
<div tw="pt-[.1em] flex items-center">
<span tw="pr-[.1em]">Lv.</span> {weapon.data.level}/
<div tw="opacity-60 pl-[.25em]">{promoteMaxLevelMap[weapon.data.promoteLevel ?? 0]}</div>
</div>
</div>
</div>
</div>
</div>
<div tw="text-white flex flex-col pt-[2.2em] pr-[4.7%] -mb-[.8em] grow text-[.96em] justify-between">
{statRows.map((r, i) => (
<div key={r.key} tw="flex items-center whitespace-nowrap">
<EnkaIcon
icon={
(
{
FIGHT_PROP_MAX_HP: "FIGHT_PROP_HP",
FIGHT_PROP_CUR_ATTACK: "FIGHT_PROP_ATTACK",
FIGHT_PROP_CUR_DEFENSE: "FIGHT_PROP_DEFENSE",
} satisfies Record<string, keyof typeof library>
)[r.key] || (r.key as keyof typeof library)
}
tw="h-[1em] mr-[1em] -mt-[.05em]"
/>
<div tw="flex grow justify-between text-[1.03em]">
<div>{r.name}</div>
<div tw={i < 3 ? "-top-[.3em]" : ""}>{r.formatted}</div>
</div>
{i < 3 && (
<div tw="flex text-[.83em] opacity-70 absolute w-full -bottom-[.75em]">
<div tw="flex grow justify-end">
<div tw="flex">
{Math.round(r.base).toLocaleString()}
<div tw="flex text-[#82ff9a] pl-1">+{Math.round(r.delta).toLocaleString()}</div>
</div>
</div>
</div>
)}
</div>
))}
{artifactSets.length && (
<div tw="flex w-full gap-[.5em]" style={{ textShadow: "0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)" }}>
<div tw="w-[2em] h-[2em] shrink-0 text-[1.15em] bg-[#00000030] flex justify-center items-center font-[shicon] leading-[2.3em] rounded-[.2em]">
</div>
<div tw="flex flex-col grow justify-center gap-[.1em]">
{artifactSets.map((s) => (
<div key={s.set} tw="flex text-[.92em]">
<div tw="grow flex justify-center items-center leading-none text-[#90ee90] whitespace-nowrap overflow-ellipsis overflow-hidden px-[.3em]">
{s.set}
</div>
<div tw="flex bg-[#00000030] rounded-[.2em] justify-center items-center w-[2em] ml-[.5em] shrink-0 pt-[.2em]">
{s.count}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
<div tw="absolute top-0 bottom-0 right-0 w-[25.7em] p-[.7em] flex flex-col justify-between">
{artifacts.map((a, index) =>
a ? (
<div key={artifactSlots[index]} tw="h-[18%] bg-[#0000004f] rounded-[.3em] relative text-white overflow-hidden flex justify-between gap-[.5em]">
<img src={a.icon} alt="artifact" tw="absolute top-[48%] -left-[8%] w-[45%] -translate-y-1/2 -translate-x-[10%] object-contain aspect-square scale-90" />
<div tw="h-full w-[30%] text-[105%] text-right shrink-0 flex flex-col justify-center mr-[.25em]">
<EnkaIcon icon={a.main.mainPropId} tw="h-[1em] w-[1em] ml-auto max-h-[22%] mt-[.5em]" />
<div tw="text-[1.1em] pt-[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-center justify-end">
<div>{Array.from({ length: a.stars ?? 0 }, () => star(".6em"))}</div>
<div tw="w-[2em] flex justify-end">
<div tw="text-[75%] leading-none bg-black/50 px-[.25em] py-[.2em] rounded-[.2em] -mb-[.2em]">+{a.level}</div>
</div>
</div>
</div>
<hr tw="border-none h-[84%] w-[2px] mt-[2%] bg-white/60" />
<div tw="flex flex-col flex-wrap mt-[.35em] mb-[.35em] mr-[.35em] w-full">
{a.sub.map((s, i) => (
<div key={`${s.appendPropId}-${s.statValue}`} tw={`flex items-center pl-[.7em] relative gap-[.5em] w-1/2 h-1/2 ${i === 3 ? "opacity-80" : ""} ${i > 1 ? "pl-[.3em]" : ""}`}>
{enableRollQuality && (
<div tw="absolute top-0 m-[.1em] left-[-.25em] bottom-0 right-[.25em] bg-gradient-to-t from-transparent to-black/25 rounded-[.2em] flex gap-0.5">
{s.props.map((p) => (
<div
key={`${p.Position}-${p.Value}-${p.Efficiency}`}
tw="flex justify-center items-end grow bg-gradient-to-t from-transparent to-white/10"
style={{
borderBottomWidth: "1px",
borderStyle: "solid",
borderColor: "rgba(255,255,255,.8)",
maxWidth: `${(p.Efficiency / s.props.length) * 100}%`,
}}
>
{Array(p.Position).fill(<div tw="flex bg-[#fff8] w-1 h-1 mr-[3px] mb-[3px] rounded-full" />)}
</div>
))}
</div>
)}
<EnkaIcon icon={s.appendPropId} tw="h-[1.1em] w-[1.1em]" />
<div tw="flex text-[1.05em]">
+
{PercentageFightProp.includes(s.appendPropId as (typeof PercentageFightProp)[number])
? `${s.statValue.toFixed(1)}%`
: s.statValue.toLocaleString()}
</div>
</div>
))}
</div>
</div>
) : (
<div key={artifactSlots[index]} tw="flex h-[18%] bg-[#0000004f] rounded-[.3em] opacity-20" />
),
)}
</div>
</div>
</SubstatDisplay>
</div>,
{
fonts: await Promise.all([
font.arrayBuffer(),
sansFont.arrayBuffer(),
iconFont.arrayBuffer(),
]),
persistentImages: [{ data: await shade.arrayBuffer(), src: "shade" }],
fetchedResources: resources.filter((e) => e.data.byteLength),
format,
drawDebugBorder: debug === "render",
stylesheets: [
/* css */ `
.Rock { color: #ffc64a; }
.Talents.Rock { color: #4a3a18!important }
.Wind { color: #71fdec; }
.Talents.Wind { color: #1d4d47!important }
.Ice { color: #98efff; }
.Talents.Ice { color: #193d43!important }
.Water { color: #56a2ff; }
.Talents.Water { color: #18293e!important }
.Electric { color: #ffa1ff; }
.Talents.Electric { color: #34123b!important }
.Fire { color: #f58453; }
.Talents.Fire { color: #541900!important }
.Grass { color: #a5ffab; }
.Talents.Grass { color: #134517!important }
`,
],
},
);
}
function SubstatDisplay({
substat,
element,
children,
}: {
substat: RollSummary[] | undefined;
element: string;
children?: React.ReactNode;
}) {
if (!substat) return children;
return (
<div tw="flex flex-col-reverse">
<div
tw="flex h-[2.3em] bg-current relative -top-[.5em] pt-[.5em] -mb-[.5em] rounded-b-[.5em]"
className={`Talents ${element}`}
>
<div tw="text-white w-full flex items-center flex-wrap justify-end bg-black/25">
{substat.map((s) => (
<div key={s.id} tw="mr-[1.2em] flex gap-[.2em] items-center whitespace-nowrap">
<div tw="ml-[.2em] mb-[.1em] text-[.75em] flex flex-col items-center font-[Open_Sans]">
{s.efficiency}
<div tw="flex w-full h-[1px] bg-white"></div>
{s.level}
</div>
<EnkaIcon icon={s.id} tw="h-[1em]" />
{s.formatted}
</div>
))}
</div>
</div>
{children}
</div>
);
}
+14
View File
@@ -1,3 +1,17 @@
export function arrRandom<T>(arr: readonly T[]): T | undefined { export function arrRandom<T>(arr: readonly T[]): T | undefined {
return arr[Math.floor(Math.random() * arr.length)]; return arr[Math.floor(Math.random() * arr.length)];
} }
export class ResponseNotOkError extends Error {
constructor(
readonly status: number,
readonly response: Response,
) {
super(`RESPONSE_NOT_OK: Got status ${status}`);
}
}
export function throwNotOk(res: Response) {
if (!res.ok) throw new ResponseNotOkError(res.status, res);
return res;
}