feat: rough implementation of stats table
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user