feat: add substat breakdown

This commit is contained in:
2026-03-31 11:37:18 +07:00 Unverified
parent 5a15441a8c
commit a0bf5fcf05
9 changed files with 7060 additions and 370 deletions
+2
View File
@@ -6,6 +6,7 @@ import { ResolvedPlayerInfo, TTLCache } from "enka.ts";
import {
AvatarStore,
ElementList,
EnkaAffixes,
EnkaLocs,
EnkaNamecards,
EnkaPfps,
@@ -186,6 +187,7 @@ type StoreTypeMap = {
locs: EnkaLocs;
pfps: EnkaPfps;
namecards: EnkaNamecards;
affixes: EnkaAffixes;
};
export async function getEnkaStore<K extends keyof StoreTypeMap>(
+70
View File
@@ -0,0 +1,70 @@
import { Affix } from "@/type/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/type/fightPropMap";
export type RollSummary = {
id: string;
value: number;
level: number;
efficiency: string;
isPercent: boolean;
formatted: string;
};
export function buildRollSummary(
appendPropIdList: string[],
rollMap: Record<string, Affix>,
): RollSummary[] {
const grouped: Record<
number,
Omit<RollSummary, "formatted" | "efficiency"> & { efficiency: number }
> = {};
for (const id of appendPropIdList) {
const roll = rollMap[id];
if (!roll) continue;
const key = roll.PropType;
if (!grouped[key]) {
grouped[key] = {
id: FightPropMapEnum[key],
value: 0,
level: 0,
efficiency: 0,
isPercent: (PercentageFightProp as unknown as string).includes(
FightPropMapEnum[key],
),
};
}
grouped[key].value += roll.Value;
grouped[key].level += 1;
grouped[key].efficiency += roll.Efficiency;
}
const order: (keyof typeof FightPropMapEnum)[] = [
"FIGHT_PROP_HP_PERCENT",
"FIGHT_PROP_HP",
"FIGHT_PROP_ATTACK_PERCENT",
"FIGHT_PROP_ATTACK",
"FIGHT_PROP_DEFENSE_PERCENT",
"FIGHT_PROP_DEFENSE",
"FIGHT_PROP_ELEMENT_MASTERY",
"FIGHT_PROP_CRITICAL",
"FIGHT_PROP_CRITICAL_HURT",
"FIGHT_PROP_CHARGE_EFFICIENCY",
];
return Object.values(grouped)
.sort((a, b) => {
const ia = order.indexOf(a.id as keyof typeof FightPropMapEnum) ?? 999;
const ib = order.indexOf(b.id as keyof typeof FightPropMapEnum) ?? 999;
return ia - ib;
})
.map((roll) => ({
...roll,
efficiency: roll.efficiency.toFixed(1),
formatted: roll.isPercent
? `${(roll.value * 100).toFixed(1)}%`
: Math.round(roll.value).toString(),
}));
}
+8
View File
@@ -138,6 +138,12 @@ export type EnkaLocs = {
| "it"]: EnkaLocsLanguage;
};
export type Affix = {
PropType: number;
Efficiency: number;
Value: number;
};
export const SubstatOrder: Record<string, number> = {
FIGHT_PROP_CRITICAL: 1,
FIGHT_PROP_CRITICAL_HURT: 2,
@@ -158,6 +164,8 @@ export type EnkaPfps = Record<NumberLike, { IconPath: CdnPath }>;
export type EnkaNamecards = Record<NumberLike, { Icon: CdnPath }>;
export type EnkaAffixes = Record<string, Affix>;
export const Locale = t.Enum(
Object.fromEntries(
(
+452 -363
View File
@@ -14,7 +14,7 @@ import {
import { generateBackground } from "@/assets/background";
import { ConstsSvg } from "@/assets/consts";
import { featherImage } from "@/assets/feather";
import { font, iconFont } from "@/assets/fonts";
import { font, iconFont, sansFont } from "@/assets/fonts";
import { EnkaIcon } from "@/assets/icons";
import { library } from "@/assets/icons-library";
import shadeSvg from "@/assets/shade.svg" with { type: "file" };
@@ -28,6 +28,7 @@ import { cardErrorLocs } from "@/lib/locs";
import { createLogger } from "@/lib/logger";
import { mockData } from "@/lib/mock";
import { buildDisplayRows } from "@/lib/rows";
import { buildRollSummary, RollSummary } from "@/lib/substat";
import { Locale, SubstatOrder } from "@/type/enka";
import { PercentageFightProp } from "@/type/fightPropMap";
import { ErrorPage } from "./error";
@@ -76,6 +77,7 @@ export function card(app: App) {
"relics",
"weapons",
"pfps",
"affixes",
).finally(() => log`Stores loaded`),
),
deduper.acquire(`enka-${uid}`, () =>
@@ -207,7 +209,14 @@ export function card(app: App) {
async ({
params: { uid, char: charId },
log,
query: { debug, h: heightRaw = 1024, w, error: errorType, lang },
query: {
debug,
h: heightRaw = 1024,
w,
error: errorType,
lang,
substat: enableSubstat,
},
}) => {
log`Rendering ${uid}/${charId}`;
const error =
@@ -217,21 +226,27 @@ export function card(app: App) {
const errorLoc = cardErrorLocs[lang === "th" ? "th" : "en"];
try {
// #region Data fetching
const [[avatarStore, weaponStore, relicStore, locs], data] =
await Promise.all([
deduper.acquire("stores", () =>
batchStore("avatars", "weapons", "relics", "locs").finally(
() => log`Stores loaded`,
),
),
feature("API") || uid !== "775956514"
? deduper.acquire(`enka-${uid}`, () =>
fetchEnka(uid, false).finally(
() => log`Enka data loaded`,
),
)
: mockData,
]);
const [
[avatarStore, weaponStore, relicStore, locs, affixes],
data,
] = await Promise.all([
deduper.acquire("stores", () =>
batchStore(
"avatars",
"weapons",
"relics",
"locs",
"affixes",
).finally(() => log`Stores loaded`),
),
feature("API") || uid !== "775956514"
? deduper.acquire(`enka-${uid}`, () =>
fetchEnka(uid, false).finally(
() => log`Enka data loaded`,
),
)
: mockData,
]);
const enkaLoc = locs[lang ?? "en"];
@@ -270,13 +285,13 @@ export function card(app: App) {
charId: char.avatar.avatarId,
lang,
height,
enableSubstat,
debug,
}),
).toString(36);
if (feature("API")) {
if (!debug) {
const cached = cache.get<ImageResponse>(cacheKey);
if (cached) return cached.clone();
}
const cached = cache.get<ImageResponse>(cacheKey);
if (cached) return cached.clone();
}
// #region Processing
@@ -300,6 +315,7 @@ export function card(app: App) {
set: a.flat.setId!, // it is an artifact, therefore this always exist
stars: a.flat.rankLevel,
level: a.reliquary!.level - 1,
appendPropIdList: a.reliquary?.appendPropIdList ?? [],
icon: `/ui/${a.flat.icon}.png`,
main: a.flat.reliquaryMainstat!,
sub: (a.flat.reliquarySubstats ?? []).sort(
@@ -324,6 +340,16 @@ export function card(app: App) {
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
@@ -393,7 +419,7 @@ export function card(app: App) {
}>[]);
log`Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
if (debug) {
if (debug === "dump") {
log`Debug endpoint, returning JSON`;
return JSON.stringify(
{
@@ -410,6 +436,7 @@ export function card(app: App) {
char,
weapon,
statRows,
rollSummary,
artifacts,
artifactSets,
},
@@ -420,380 +447,396 @@ export function card(app: App) {
// #region Rendering
const res = new ImageResponse(
<div
tw={`relative text-[${32.25 * (height / 1024)}px] text-white rounded-[.5em] overflow-hidden`}
>
<div tw="aspect-[1218/508] h-full">
<img
src={`/bg-${avatar.Element}`}
alt="background"
tw="w-full h-full object-cover"
/>
</div>
{/* avatar */}
<img
src="/avatar"
alt="char"
tw="object-cover h-full aspect-[1024/1124] absolute top-0 -left-[2.0em] scale-x-[1.1] scale-y-[1.15]"
/>
{/* left section */}
<div
tw="absolute top-0 bottom-0 left-0 w-[27.4em] p-[1.4em] flex flex-col"
style={{
textShadow:
"0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)",
}}
<div tw={`text-[${32.25 * (height / 1024)}px]`}>
<SubstatDisplay
substat={rollSummary}
element={avatar.Element}
>
{/* left side vignette */}
<div tw="absolute top-0 left-0 bottom-0 right-0">
<img src="shade" alt="shade" tw="opacity-40" />
</div>
{/* char and username */}
<div tw="text-[130%] pb-[.5%] flex items-center gap-[.5em]">
{char.avatar.name}
<div tw="text-[0.75em] opacity-80 flex items-center gap-[.4em]">
<svg
width="16"
height="16"
viewBox="0 0 100 100"
tw="mt-[.2em] h-[.5em] w-[.5em]"
>
<title>doritos</title>
<polygon
points="50,10 90,90 10,90"
fill="currentColor"
/>
</svg>
{data.playerInfo.nickname}
</div>
</div>
{/* level */}
<div>
Lv. <span tw="px-1">{charLvl}</span> /
<span tw="opacity-60 pl-1">{charLvlMax}</span>
</div>
{/* fren */}
<div tw="flex gap-1 items-end">
<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="grow" />
{/* constellation */}
<div
tw="flex align-start flex-col text-[1.12em]"
className={`${avatar.Element} locked`}
tw={`relative text-white rounded-[.5em] overflow-hidden aspect-[1218/508] h-[${height}px]`}
>
{avatar.Consts.map((c, i) => (
<div
style={{
position: "relative",
margin: "-.5em 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: "5%",
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]) => {
const extra =
char.proudSkillExtraLevelMap?.[
`${avatar.ProudMap[key]}`
] ?? 0;
const talentLevel =
(char.skillLevelMap?.[key] ?? 0) + extra;
return (
<div tw="mt-[.6em] relative">
<TalentSvg
tw={`Talents ${avatar.Element} w-[3.6em] opacity-60 text-black`}
/>
<img
key={key}
src={src}
alt={`Talent ${key}`}
tw="w-[2.5em] absolute left-[.15em] top-[.15em] m-[.4em_0_0_.4em]"
/>
<div tw="absolute -bottom-[.1em] w-full flex justify-center">
<div
tw={`${extra ? "bg-[#45deffcc]" : "bg-[#323232b3]"} pt-[5%] pb-[2%] text-[.8em] rounded-[1.4em]`}
>
<span tw="px-[.6em]">{talentLevel}</span>
</div>
</div>
</div>
);
})}
</div>
<div tw="mb-[-1em] ml-[-.3em] mt-[.3em] text-[.9em] opacity-80">
{data.uid}
</div>
</div>
{/* middle section */}
<div tw="absolute top-0 bottom-0 right-[23.1em] w-[25.5em] p-[1.4em_.7em_.7em] flex flex-col">
{/* weapon */}
<div tw="flex flex-start" className="Weapon">
{/* display */}
<div tw="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="aspect-[1218/508] h-full">
<img
src={`/bg-${avatar.Element}`}
alt="background"
tw="w-full h-full object-cover"
/>
<div tw="absolute w-full top-[93%] flex justify-center">
{Array.from({ length: weapon.store.Rarity }, () =>
star(".7em"),
)}
</div>
</div>
{/* caption */}
<div tw="px-[5%] grow text-white self-center flex flex-col">
<div
tw="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>
{/* avatar */}
<img
src="/avatar"
alt="char"
tw="object-cover h-full aspect-[1024/1124] absolute top-0 -left-[2.0em] scale-x-[1.1] scale-y-[1.15]"
/>
{/* left section */}
<div
tw="absolute top-0 bottom-0 left-0 w-[27.4em] p-[1.4em] flex flex-col"
style={{
textShadow:
"0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)",
}}
>
{/* left side vignette */}
<div tw="absolute top-0 left-0 bottom-0 right-0">
<img src="shade" alt="shade" tw="opacity-40" />
</div>
{/* stats */}
<div tw="pt-[3%] pb-[2%] flex gap-[.5em]">
{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]"
{/* char and username */}
<div tw="text-[130%] pb-[.5%] flex items-center gap-[.5em]">
{char.avatar.name}
<div tw="text-[0.75em] opacity-80 flex items-center gap-[.4em]">
<svg
width="16"
height="16"
viewBox="0 0 100 100"
tw="mt-[.2em] h-[.5em] w-[.5em]"
>
<title>doritos</title>
<polygon
points="50,10 90,90 10,90"
fill="currentColor"
/>
</svg>
{data.playerInfo.nickname}
</div>
</div>
{/* level */}
<div>
Lv. <span tw="px-1">{charLvl}</span> /
<span tw="opacity-60 pl-1">{charLvlMax}</span>
</div>
{/* fren */}
<div tw="flex gap-1 items-end">
<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="grow" />
{/* constellation */}
<div
tw="flex align-start flex-col text-[1.12em]"
className={`${avatar.Element} locked`}
>
{avatar.Consts.map((c, i) => (
<div
style={{
position: "relative",
margin: "-.5em 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 tw="pt-[.1em]">
{s.statValue}
{(PercentageFightProp as string[]).includes(
s.appendPropId,
)
? "%"
: ""}
</div>
</div>
))}
</div>
<div tw="flex gap-[.5em]">
<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}
{/* talent-wrap */}
<div
style={{
display: "flex",
position: "absolute",
right: "5%",
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]) => {
const extra =
char.proudSkillExtraLevelMap?.[
`${avatar.ProudMap[key]}`
] ?? 0;
const talentLevel =
(char.skillLevelMap?.[key] ?? 0) + extra;
return (
<div tw="mt-[.6em] relative">
<TalentSvg
tw={`Talents ${avatar.Element} w-[3.6em] opacity-60 text-black`}
/>
<img
key={key}
src={src}
alt={`Talent ${key}`}
tw="w-[2.5em] absolute left-[.15em] top-[.15em] m-[.4em_0_0_.4em]"
/>
<div tw="absolute -bottom-[.1em] w-full flex justify-center">
<div
tw={`${extra ? "bg-[#45deffcc]" : "bg-[#323232b3]"} pt-[5%] pb-[2%] text-[.8em] rounded-[1.4em]`}
>
<span tw="px-[.6em]">{talentLevel}</span>
</div>
</div>
</div>
);
})}
</div>
<div tw="mb-[-1em] ml-[-.3em] mt-[.3em] text-[.9em] opacity-80">
{data.uid}
</div>
</div>
{/* middle section */}
<div tw="absolute top-0 bottom-0 right-[23.1em] w-[25.5em] p-[1.4em_.7em_.7em] flex flex-col">
{/* weapon */}
<div tw="flex flex-start" className="Weapon">
{/* display */}
<div tw="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-[93%] flex justify-center">
{Array.from({ length: weapon.store.Rarity }, () =>
star(".7em"),
)}
</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]">
Lv. {weapon.data.level} /
<span tw="opacity-60 pl-1">
{
promoteMaxLevelMap[
weapon.data.promoteLevel ?? 0
]
}
{/* caption */}
<div tw="px-[5%] grow text-white self-center flex flex-col">
<div
tw="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-[.5em]">
{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-[.5em]">
<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]">
Lv. {weapon.data.level} /
<span tw="opacity-60 pl-1">
{
promoteMaxLevelMap[
weapon.data.promoteLevel ?? 0
]
}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* stats table */}
<div tw="text-white flex flex-col pt-[2em] pr-[3%] grow text-[.95em] 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-[.8em] -mt-[.05em]"
/>
<div tw="flex grow justify-between">
<div>{r.name}</div>
<div tw={i < 3 ? "-top-[.3em]" : ""}>
{r.formatted}
</div>
</div>
{i < 3 && (
<div tw="text-[.8em] opacity-70 absolute w-full -bottom-[.75em]">
<div tw="flex grow justify-end">
<div>
{Math.round(r.base)}
<div tw="text-[#82ff9a] pl-1">
+{Math.round(r.delta)}
{/* stats table */}
<div tw="text-white flex flex-col pt-[2em] pr-[3%] grow text-[.95em] 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-[.8em] -mt-[.05em]"
/>
<div tw="flex grow justify-between">
<div>{r.name}</div>
<div tw={i < 3 ? "-top-[.3em]" : ""}>
{r.formatted}
</div>
</div>
{i < 3 && (
<div tw="text-[.8em] opacity-70 absolute w-full -bottom-[.75em]">
<div tw="flex grow justify-end">
<div>
{Math.round(r.base)}
<div tw="text-[#82ff9a] pl-1">
+{Math.round(r.delta)}
</div>
</div>
</div>
</div>
)}
</div>
))}
{artifactSets.length && (
<div
tw="flex w-full pt-[.5em] 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-[.9em]">
<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="bg-[#00000030] rounded-[.2em] justify-center items-center w-[2em] ml-[.5em] shrink-0 pt-[.2em]">
{s.count}
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
{artifactSets.length && (
<div
tw="flex w-full pt-[.5em] 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-[.9em]">
<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="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-[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-[8%] w-[45%] -translate-y-1/2 -translate-x-[10%] object-contain aspect-square scale-90"
/>
{/* 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">
<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 gap-[.25em] w-1/2 h-1/2 ${i === 3 ? "opacity-80" : ""}`}
>
</div>
{/* right section */}
<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-[8%] w-[45%] -translate-y-1/2 -translate-x-[10%] object-contain aspect-square scale-90"
/>
{/* main */}
<div tw="h-full w-[30%] text-right shrink-0 flex flex-col justify-center">
<EnkaIcon
icon={s.appendPropId}
tw="h-[1em] w-[1em]"
icon={a.main.mainPropId}
tw="h-[1em] w-[1em] ml-auto max-h-[22%]"
/>
<div>
+
<div
tw="text-[1.1em] py-[2%] ml-auto"
style={{ textShadow: "0 .07em .1em black" }}
>
{PercentageFightProp.includes(
s.appendPropId as (typeof PercentageFightProp)[number],
a.main
.mainPropId as (typeof PercentageFightProp)[number],
)
? `${s.statValue.toFixed(1)}%`
: s.statValue.toLocaleString()}
? `${a.main.statValue.toFixed(1)}%`
: a.main.statValue.toLocaleString()}
</div>
<div tw="flex grow max-h-[20%] items-end 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>
))}
</div>
</div>
) : (
<div tw="h-[18%] bg-[#0000004f] rounded-[.3em] opacity-20" />
),
)}
</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>
</SubstatDisplay>
</div>,
{
fonts: [
await font.arrayBuffer(),
await iconFont.arrayBuffer(),
],
height,
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: "webp",
// drawDebugBorder: true,
drawDebugBorder: debug === "render",
stylesheets: [
/* css */ `
.Rock { color: #ffc64a; }
@@ -872,8 +915,15 @@ export function card(app: App) {
description:
"Target width of card, used to calculate height. Has higher priority than `h`.",
}),
debug: t.Boolean({
description: "Return debug information instead of card.",
debug: t.Enum(
{ none: "none", render: "render", dump: "dump" },
{
description: "Return debug information instead of card.",
default: "none",
},
),
substat: t.Boolean({
description: "Add substats breakdown bar under the card.",
default: false,
}),
},
@@ -930,3 +980,42 @@ 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="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="w-full h-[1px] bg-white"></div>
{s.level}
</div>
<EnkaIcon icon={s.id} tw="h-[1em]" />
{s.formatted}
</div>
))}
</div>
</div>
{children}
</div>
);
}
+5 -5
View File
@@ -35,11 +35,11 @@ export async function CardResponse(
</div>
</div>,
{
fonts: [
await font.arrayBuffer(),
await sansFont.arrayBuffer(),
await iconFont.arrayBuffer(),
],
fonts: await Promise.all([
font.arrayBuffer(),
sansFont.arrayBuffer(),
iconFont.arrayBuffer(),
]),
format: "webp",
// same ratio as card
width: 1228,