feat: rough implementation of stats table

This commit is contained in:
2026-03-20 06:17:27 +07:00 Unverified
parent 44c78846b4
commit 11d7243eb1
3 changed files with 255 additions and 1 deletions
+1 -1
View File
@@ -434,5 +434,5 @@ export function EnkaIcon({
}: {
icon: keyof typeof library;
} & SVGProps<SVGSVGElement>) {
return library[icon](props);
return icon in library ? library[icon](props) : library.akefelement(props);
}
+211
View File
@@ -0,0 +1,211 @@
import { ResolvedAvatarInfo } from "enka.ts";
import { EnkaLocs } from "@/type/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/type/fightPropMap";
// Types
interface DisplayRow {
id: number;
key: keyof typeof FightPropMapEnum | string;
name: string;
value: number;
formatted: string;
base: number;
delta: number;
}
interface BuildDisplayRowsOptions {
whitelist?: (keyof typeof FightPropMapEnum)[];
showAll?: boolean;
}
// Helper functions
function invertEnum(
enumObj: Record<string, string | number>,
): Record<number, string> {
const map: Record<number, string> = {};
for (const k of Object.keys(enumObj)) {
const v = enumObj[k];
if (typeof v === "number") map[v] = k;
}
return map;
}
const DEFAULT_WHITELIST: (keyof typeof FightPropMapEnum)[] = [
"FIGHT_PROP_MAX_HP",
"FIGHT_PROP_CUR_ATTACK",
"FIGHT_PROP_CUR_DEFENSE",
"FIGHT_PROP_ELEMENT_MASTERY",
"FIGHT_PROP_CRITICAL",
"FIGHT_PROP_CRITICAL_HURT",
"FIGHT_PROP_CHARGE_EFFICIENCY",
];
const ELEMENT_TO_PROP: Record<string, string> = {
PYRO: "FIRE",
ELECTRO: "ELEC",
HYDRO: "WATER",
DENDRO: "GRASS",
ANEMO: "WIND",
GEO: "ROCK",
CRYO: "ICE",
};
// build base values from equipList + base from fightPropMap
function buildBaseFromEquip(
apiPayload: ResolvedAvatarInfo,
enumMap: Record<number, string>,
): Record<string, number> {
const base: Record<string, number> = {};
const add = (k: string | undefined, v: number | string | undefined) => {
if (!k) return;
base[k] = (base[k] || 0) + Number(v || 0);
};
// Add base stats from fightPropMap (e.g., base HP, base ATK, base DEF)
const fp = apiPayload.fightPropMap || {};
if (fp[1]) add(enumMap[1] || "FIGHT_PROP_BASE_HP", fp[1]); // BASE_HP
if (fp[4]) add(enumMap[4] || "FIGHT_PROP_BASE_ATTACK", fp[4]); // BASE_ATTACK
if (fp[7]) add(enumMap[7] || "FIGHT_PROP_BASE_DEFENSE", fp[7]); // BASE_DEFENSE
for (const it of apiPayload.equipList || []) {
const flat = it.flat || {};
// main stat
const main = flat.reliquaryMainstat;
if (main) {
const key =
typeof main.mainPropId === "number"
? enumMap[main.mainPropId] || `PROP_${main.mainPropId}`
: main.mainPropId;
add(key, main.statValue);
}
// substats
for (const s of flat.reliquarySubstats || []) {
const key =
typeof s.appendPropId === "number"
? enumMap[s.appendPropId] || `PROP_${s.appendPropId}`
: s.appendPropId;
add(key, s.statValue);
}
// weaponStats - treat weapon base ATK as flat ATK for calculation
if (flat.weaponStats) {
for (const ws of flat.weaponStats) {
const key =
typeof ws.appendPropId === "number"
? enumMap[ws.appendPropId] || `PROP_${ws.appendPropId}`
: ws.appendPropId;
if (key === "FIGHT_PROP_BASE_ATTACK") {
// Treat weapon base ATK as flat ATK
add("FIGHT_PROP_ATTACK", ws.statValue);
} else {
add(key, ws.statValue);
}
}
}
}
return base;
}
function formatValue(
key: string,
value: number | undefined,
percentageList: string[],
): string {
if (value == null) return "0";
const name = String(key);
if (percentageList.includes(name))
return `${(Number(value) * 100).toFixed(1)}%`;
if (/HP|ATTACK|DEFENSE|CUR_ATTACK|MAX_HP|CUR_DEFENSE/.test(name))
return Math.round(Number(value)).toString();
return `${Math.round(Number(value))}`;
}
/**
* buildDisplayRows(apiPayload, FightPropEnum, PercentageFightProp, opts?)
* - apiPayload: the ResolvedAvatarInfo object
* - FightPropEnum: the enum object
* - PercentageFightProp: array of enum key names that are percentages
* - opts: { whitelist: string[], showAll:false }
*
* Returns: DisplayRow[]
*/
export function buildDisplayRows(
apiPayload: ResolvedAvatarInfo,
locs: EnkaLocs,
opts: BuildDisplayRowsOptions = {},
): DisplayRow[] {
const enumMap = invertEnum(FightPropMapEnum);
const whitelist = opts.whitelist || DEFAULT_WHITELIST;
const showAll = !!opts.showAll;
// element bonus key
const element = (apiPayload.avatar?.element || "").toUpperCase();
const elemPrefix = ELEMENT_TO_PROP[element] || null;
const elementBonusKey = elemPrefix
? (`FIGHT_PROP_${elemPrefix}_ADD_HURT` as keyof typeof FightPropMapEnum)
: null;
const base = buildBaseFromEquip(apiPayload, enumMap);
// build rows from fightPropMap
const fp = apiPayload.fightPropMap || {};
const rows: DisplayRow[] = [];
for (const k of Object.keys(fp)) {
const id = Number(k);
const value = fp[k];
const keyName = enumMap[id] || `PROP_${id}`;
// filter
const isWhitelisted =
whitelist.includes(keyName as keyof typeof FightPropMapEnum) ||
(elementBonusKey && keyName === elementBonusKey);
if (!showAll && !isWhitelisted) continue;
const baseVal = Number(base[keyName] || 0);
// For derived stats, use the base stat directly; for others, use sum from equipList
let computedBase = baseVal;
if (keyName === "FIGHT_PROP_MAX_HP") {
computedBase = Number(base.FIGHT_PROP_BASE_HP || 0);
} else if (keyName === "FIGHT_PROP_CUR_ATTACK") {
computedBase = Number(base.FIGHT_PROP_BASE_ATTACK || 0);
} else if (keyName === "FIGHT_PROP_CUR_DEFENSE") {
computedBase = Number(base.FIGHT_PROP_BASE_DEFENSE || 0);
} else if (keyName === "FIGHT_PROP_CRITICAL") {
computedBase =
Number(base.FIGHT_PROP_CRITICAL || 0) -
Number(base.FIGHT_PROP_ANTI_CRITICAL || 0);
}
// For other stats like ELEMENT_MASTERY, computedBase = baseVal (sum from equipList)
if (Number(value) === 0) continue;
const delta = Number(value) - computedBase;
rows.push({
id,
key: keyName as keyof typeof FightPropMapEnum,
name: locs.en[keyName].replace("Max ", ""),
value: Number(value),
formatted: formatValue(keyName, value, PercentageFightProp),
base: computedBase,
delta,
});
}
// ordering
const order: (keyof typeof FightPropMapEnum)[] = [
"FIGHT_PROP_MAX_HP",
"FIGHT_PROP_CUR_ATTACK",
"FIGHT_PROP_CUR_DEFENSE",
"FIGHT_PROP_ELEMENT_MASTERY",
"FIGHT_PROP_CRITICAL",
"FIGHT_PROP_CRITICAL_HURT",
"FIGHT_PROP_CHARGE_EFFICIENCY",
];
rows.sort((a, b) => {
const ai = order.indexOf(a.key as keyof typeof FightPropMapEnum),
bi = order.indexOf(b.key as keyof typeof FightPropMapEnum);
if (ai !== -1 || bi !== -1)
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
return a.key.localeCompare(b.key);
});
return rows;
}
+43
View File
@@ -25,6 +25,7 @@ import { WeaponGlazeSvg } from "@/assets/weapon-glaze";
import { AssetManager, getEnkaStores } from "@/lib/assets";
import { Deduplicator } from "@/lib/dedupe";
import { mockData } from "@/lib/mock";
import { buildDisplayRows } from "@/lib/rows";
import { PercentageFightProp } from "@/type/fightPropMap";
const assets = new AssetManager();
@@ -184,6 +185,10 @@ export function card(app: App) {
const charLvlMax =
promoteMaxLevelMap[char.propMap?.[1002].val ?? 1];
const statRows = buildDisplayRows(char, locs);
console.log(statRows);
const res = new ImageResponse(
<div tw="relative text-[32.25px] text-white rounded-xl overflow-hidden">
<div tw="aspect-[1218/508] h-full">
@@ -388,6 +393,44 @@ export function card(app: App) {
</div>
</div>
</div>
{/* stats table */}
<div tw="text-white flex flex-col pt-[1.5em] 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]">
+{Math.round(r.delta)}
</div>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
{/* right section */}
<div tw="absolute top-0 bottom-0 right-0 w-[23.1em] p-[.7em]"></div>