feat(art): dynamic avatar transformation
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Build and Deploy / build-and-deploy (push) Successful in 1m7s
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||||
|
import type { Canvas } from "@napi-rs/canvas";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyFeatherToCanvas,
|
||||||
|
type Feather,
|
||||||
|
} from "@/games/genshin/assets/feather";
|
||||||
|
|
||||||
|
export type CharacterTransform = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawWithTransform(
|
||||||
|
canvas: Canvas,
|
||||||
|
image: Awaited<ReturnType<typeof loadImage>>,
|
||||||
|
transform: CharacterTransform,
|
||||||
|
) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const drawWidth = transform.w * canvas.width;
|
||||||
|
const drawHeight = (image.height / image.width) * drawWidth;
|
||||||
|
const drawX = transform.x * canvas.width;
|
||||||
|
const drawY = transform.y * canvas.height;
|
||||||
|
|
||||||
|
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBuiltInFallback(
|
||||||
|
canvas: Canvas,
|
||||||
|
image: Awaited<ReturnType<typeof loadImage>>,
|
||||||
|
) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const drawHeight = canvas.height * 1.3;
|
||||||
|
const drawWidth = drawHeight * (image.width / image.height);
|
||||||
|
const drawX = canvas.width * 0.5 - drawWidth / 2;
|
||||||
|
const drawY = canvas.height * 0.55 - drawHeight / 2;
|
||||||
|
|
||||||
|
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCustomFallback(
|
||||||
|
canvas: Canvas,
|
||||||
|
image: Awaited<ReturnType<typeof loadImage>>,
|
||||||
|
) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const drawWidth = canvas.width;
|
||||||
|
const drawHeight = (image.height / image.width) * drawWidth;
|
||||||
|
const drawY = (canvas.height - drawHeight) / 2;
|
||||||
|
|
||||||
|
ctx.drawImage(image, 0, drawY, drawWidth, drawHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderCharacterArtToCanvas(
|
||||||
|
input: ArrayBuffer,
|
||||||
|
{
|
||||||
|
stageWidth,
|
||||||
|
stageHeight,
|
||||||
|
transform,
|
||||||
|
passes = [[0, 0.3, 0, 0]],
|
||||||
|
isCustom = false,
|
||||||
|
}: {
|
||||||
|
stageWidth: number;
|
||||||
|
stageHeight: number;
|
||||||
|
transform?: CharacterTransform;
|
||||||
|
passes?: Feather[];
|
||||||
|
isCustom?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const image = await loadImage(Buffer.from(input));
|
||||||
|
const preFeatherCanvas = createCanvas(stageWidth, stageHeight);
|
||||||
|
|
||||||
|
if (transform) drawWithTransform(preFeatherCanvas, image, transform);
|
||||||
|
else if (isCustom) drawCustomFallback(preFeatherCanvas, image);
|
||||||
|
else drawBuiltInFallback(preFeatherCanvas, image);
|
||||||
|
|
||||||
|
const finalCanvas = createCanvas(stageWidth, stageHeight);
|
||||||
|
finalCanvas.getContext("2d").drawImage(preFeatherCanvas, 0, 0);
|
||||||
|
applyFeatherToCanvas(finalCanvas, { passes });
|
||||||
|
|
||||||
|
return {
|
||||||
|
preFeatherCanvas,
|
||||||
|
finalCanvas,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||||
|
import type { Canvas } from "@napi-rs/canvas";
|
||||||
import type { ImageData as CanvasImageData } from "@napi-rs/canvas";
|
import type { ImageData as CanvasImageData } from "@napi-rs/canvas";
|
||||||
import { file } from "bun";
|
import { file } from "bun";
|
||||||
|
|
||||||
@@ -142,11 +143,13 @@ export async function generateBackground({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
element,
|
element,
|
||||||
|
artCanvas,
|
||||||
artSource,
|
artSource,
|
||||||
}: {
|
}: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
element: string;
|
element: string;
|
||||||
|
artCanvas?: Canvas;
|
||||||
artSource?: ArrayBuffer;
|
artSource?: ArrayBuffer;
|
||||||
}) {
|
}) {
|
||||||
const canvas = createCanvas(width * 1.2, height * 1.2);
|
const canvas = createCanvas(width * 1.2, height * 1.2);
|
||||||
@@ -154,24 +157,29 @@ export async function generateBackground({
|
|||||||
|
|
||||||
const elementColor = elementColors[element] ?? "#444444";
|
const elementColor = elementColors[element] ?? "#444444";
|
||||||
|
|
||||||
if (artSource) {
|
if (artCanvas || artSource) {
|
||||||
try {
|
try {
|
||||||
const art = await loadImage(artSource);
|
const sourceCanvas = artCanvas ?? createCanvas(1, 1);
|
||||||
const artCanvas = createCanvas(art.width, art.height);
|
|
||||||
const artCtx = artCanvas.getContext("2d");
|
|
||||||
|
|
||||||
artCtx.drawImage(art, 0, 0);
|
if (!artCanvas && artSource) {
|
||||||
|
const art = await loadImage(artSource);
|
||||||
|
sourceCanvas.width = art.width;
|
||||||
|
sourceCanvas.height = art.height;
|
||||||
|
sourceCanvas.getContext("2d").drawImage(art, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const artCtx = sourceCanvas.getContext("2d");
|
||||||
|
|
||||||
const sliceX = Math.min(
|
const sliceX = Math.min(
|
||||||
Math.round(artCanvas.width * 0.9),
|
Math.round(sourceCanvas.width * 0.9),
|
||||||
artCanvas.width - 3,
|
sourceCanvas.width - 3,
|
||||||
);
|
);
|
||||||
const sliceWidth = Math.min(3, artCanvas.width);
|
const sliceWidth = Math.min(3, sourceCanvas.width);
|
||||||
const adaptiveSlice = artCtx.getImageData(
|
const adaptiveSlice = artCtx.getImageData(
|
||||||
Math.max(0, sliceX),
|
Math.max(0, sliceX),
|
||||||
0,
|
0,
|
||||||
sliceWidth,
|
sliceWidth,
|
||||||
artCanvas.height,
|
sourceCanvas.height,
|
||||||
);
|
);
|
||||||
const bands = buildAdaptiveBands(adaptiveSlice, elementColor);
|
const bands = buildAdaptiveBands(adaptiveSlice, elementColor);
|
||||||
const gradient = ctx.createLinearGradient(
|
const gradient = ctx.createLinearGradient(
|
||||||
|
|||||||
@@ -1,6 +1,44 @@
|
|||||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||||
|
import type { Canvas } from "@napi-rs/canvas";
|
||||||
|
|
||||||
type Feather = [number, number, number, number];
|
export type Feather = [number, number, number, number];
|
||||||
|
|
||||||
|
export function applyFeatherToCanvas(
|
||||||
|
canvas: Canvas,
|
||||||
|
{
|
||||||
|
passes,
|
||||||
|
}: {
|
||||||
|
passes: Feather[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
|
||||||
|
const sides = [
|
||||||
|
[0, 0, 0, h],
|
||||||
|
[w, 0, 0, 0],
|
||||||
|
[0, h, 0, 0],
|
||||||
|
[0, 0, w, 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [top, right, bottom, left] of passes) {
|
||||||
|
[top, right, bottom, left].forEach((s, i) => {
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
const g = ctx.createLinearGradient(
|
||||||
|
...(sides[i] as [number, number, number, number]),
|
||||||
|
);
|
||||||
|
g.addColorStop(0, "rgba(255,255,255,0)");
|
||||||
|
g.addColorStop(s, "rgba(255,255,255,1)");
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = "destination-in";
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function featherImage(
|
export async function featherImage(
|
||||||
input: ArrayBuffer,
|
input: ArrayBuffer,
|
||||||
@@ -30,32 +68,7 @@ export async function featherImage(
|
|||||||
|
|
||||||
ctx.drawImage(img, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
|
ctx.drawImage(img, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
|
||||||
|
|
||||||
const w = cropW;
|
applyFeatherToCanvas(canvas, { passes });
|
||||||
const h = cropH;
|
|
||||||
|
|
||||||
const sides = [
|
|
||||||
[0, 0, 0, h], // top
|
|
||||||
[w, 0, 0, 0], // right
|
|
||||||
[0, h, 0, 0], // bottom
|
|
||||||
[0, 0, w, 0], // left
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [top, right, bottom, left] of passes) {
|
|
||||||
[top, right, bottom, left].forEach((s, i) => {
|
|
||||||
if (!s) return;
|
|
||||||
|
|
||||||
const g = ctx.createLinearGradient(
|
|
||||||
...(sides[i] as [number, number, number, number]),
|
|
||||||
);
|
|
||||||
g.addColorStop(0, "rgba(255,255,255,0)");
|
|
||||||
g.addColorStop(s, "rgba(255,255,255,1)");
|
|
||||||
|
|
||||||
ctx.globalCompositeOperation = "destination-in";
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
ctx.fillRect(0, 0, w, h);
|
|
||||||
ctx.globalCompositeOperation = "source-over";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return canvas.toBuffer("image/png");
|
return canvas.toBuffer("image/png");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const TRANSFORM_CONSTANT = {
|
||||||
|
"10000005": "-0.016674353503593475,0.027102803738317905,1.2263671875000002,0",
|
||||||
|
"10000007": "-0.016674353503593475,0.027102803738317905,1.2263671875000002,0",
|
||||||
|
"10000117": "-0.3267884428741016,0.06140186915887837,1.605322265625,0",
|
||||||
|
"10000118": " -0.31077201576937885,0.05504672897196261,1.605322265625,0",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// thanks stackoverflow
|
||||||
|
type WS = " " | "\n" | "\t";
|
||||||
|
|
||||||
|
type TrimLeft<S extends string> = S extends `${WS}${infer R}` ? TrimLeft<R> : S;
|
||||||
|
type TrimRight<S extends string> = S extends `${infer R}${WS}`
|
||||||
|
? TrimRight<R>
|
||||||
|
: S;
|
||||||
|
type Trim<S extends string> = TrimLeft<TrimRight<S>>;
|
||||||
|
|
||||||
|
type ParseTransform<S extends string> =
|
||||||
|
S extends `${infer X},${infer Y},${infer W},${string}`
|
||||||
|
? {
|
||||||
|
readonly x: Trim<X> extends `${infer N extends number}` ? N : never;
|
||||||
|
readonly y: Trim<Y> extends `${infer N extends number}` ? N : never;
|
||||||
|
readonly w: Trim<W> extends `${infer N extends number}` ? N : never;
|
||||||
|
}
|
||||||
|
: never;
|
||||||
|
|
||||||
|
type TransformMap<T extends Record<string, string>> = {
|
||||||
|
readonly [K in keyof T]: ParseTransform<T[K]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeTransformMap<
|
||||||
|
const T extends Record<string, `${string},${string},${string},${string}`>,
|
||||||
|
>(obj: T): TransformMap<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj).map(([key, value]) => {
|
||||||
|
const [x, y, w] = value.split(",").map(Number);
|
||||||
|
return [key, { x, y, w }];
|
||||||
|
}),
|
||||||
|
) as TransformMap<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRANSFORM_MAP = makeTransformMap(TRANSFORM_CONSTANT);
|
||||||
@@ -606,7 +606,8 @@ export function card(app: App) {
|
|||||||
`https://enka.network/api/profile/${username}/hoyos/${buildHash}/builds/`,
|
`https://enka.network/api/profile/${username}/hoyos/${buildHash}/builds/`,
|
||||||
)
|
)
|
||||||
.then(throwNotOk)
|
.then(throwNotOk)
|
||||||
.then((r) => r.json())) as Promise<
|
.then((r) => r.json())
|
||||||
|
).finally(() => log`Enka data loaded`) as Promise<
|
||||||
Record<
|
Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { EquipType } from "enka.ts";
|
|||||||
import { ImageResponse } from "takumi-js/response";
|
import { ImageResponse } from "takumi-js/response";
|
||||||
|
|
||||||
import { font, iconFont, sansFont } from "@/assets/fonts";
|
import { font, iconFont, sansFont } from "@/assets/fonts";
|
||||||
|
import { renderCharacterArtToCanvas } from "@/games/genshin/assets/avatar";
|
||||||
import { generateBackground } from "@/games/genshin/assets/background";
|
import { generateBackground } from "@/games/genshin/assets/background";
|
||||||
import { ConstsSvg } from "@/games/genshin/assets/consts";
|
import { ConstsSvg } from "@/games/genshin/assets/consts";
|
||||||
import { featherImage } from "@/games/genshin/assets/feather";
|
import { featherImage } from "@/games/genshin/assets/feather";
|
||||||
@@ -17,6 +18,7 @@ import { assets, getAvatar } from "@/games/genshin/data/assets";
|
|||||||
import { buildDisplayRows } from "@/games/genshin/data/rows";
|
import { buildDisplayRows } from "@/games/genshin/data/rows";
|
||||||
import type { RollSummary } from "@/games/genshin/data/substat";
|
import type { RollSummary } from "@/games/genshin/data/substat";
|
||||||
import { buildRollSummary } from "@/games/genshin/data/substat";
|
import { buildRollSummary } from "@/games/genshin/data/substat";
|
||||||
|
import { TRANSFORM_MAP } from "@/games/genshin/data/transform";
|
||||||
import type {
|
import type {
|
||||||
AvatarStore,
|
AvatarStore,
|
||||||
EnkaAffixes,
|
EnkaAffixes,
|
||||||
@@ -171,28 +173,69 @@ export async function CharacterPage({
|
|||||||
const avatarArtPath =
|
const avatarArtPath =
|
||||||
(char.costumeId && avatar.Costumes?.[char.costumeId].Art) ||
|
(char.costumeId && avatar.Costumes?.[char.costumeId].Art) ||
|
||||||
avatar.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg");
|
avatar.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg");
|
||||||
|
const fontSize = 30.466 * (height / 1024);
|
||||||
|
const leftStageWidth = Math.round(30.4 * fontSize);
|
||||||
|
const leftStageHeight = Math.round(height);
|
||||||
|
const defaultTransform = customImage
|
||||||
|
? undefined
|
||||||
|
: TRANSFORM_MAP[`${char.avatar.avatarId}` as keyof typeof TRANSFORM_MAP];
|
||||||
const artBytesRes = customImage
|
const artBytesRes = customImage
|
||||||
? fetch(customImage).then((r) => r.arrayBuffer())
|
? fetch(customImage).then((r) => r.arrayBuffer())
|
||||||
: assets.load(avatarArtPath);
|
: assets.load(avatarArtPath);
|
||||||
const featherAvatar = (buf: ArrayBuffer) =>
|
let avatarCanvasRes: Promise<
|
||||||
featherImage(buf, {
|
Awaited<ReturnType<typeof renderCharacterArtToCanvas>>
|
||||||
passes: [[0, 0.3, 0, 0]],
|
> | null = null;
|
||||||
ratio: 1024 / 1124,
|
const getAvatarCanvas = () =>
|
||||||
});
|
(avatarCanvasRes ??= artBytesRes.then((buf) =>
|
||||||
|
renderCharacterArtToCanvas(buf, {
|
||||||
|
stageWidth: leftStageWidth,
|
||||||
|
stageHeight: leftStageHeight,
|
||||||
|
transform: defaultTransform,
|
||||||
|
isCustom: !!customImage,
|
||||||
|
}),
|
||||||
|
));
|
||||||
const avatarRes = customImage
|
const avatarRes = customImage
|
||||||
? artBytesRes.then(featherAvatar)
|
? getAvatarCanvas().then(({ finalCanvas }) =>
|
||||||
: assets.fnCache(`${avatarArtPath}-avatar`, async () =>
|
finalCanvas.toBuffer("image/png"),
|
||||||
featherAvatar(await artBytesRes),
|
)
|
||||||
|
: assets.fnCache(
|
||||||
|
`${avatarArtPath}-avatar-${leftStageWidth}x${leftStageHeight}`,
|
||||||
|
async () => (await getAvatarCanvas()).finalCanvas.toBuffer("image/png"),
|
||||||
);
|
);
|
||||||
const backgroundRes = adaptiveColor
|
const backgroundAccentRes = adaptiveColor
|
||||||
? artBytesRes.then((artSource) =>
|
? getAvatarCanvas().then(({ preFeatherCanvas }) =>
|
||||||
generateBackground({
|
generateBackground({
|
||||||
artSource,
|
artCanvas: preFeatherCanvas,
|
||||||
element: avatar.Element,
|
element: avatar.Element,
|
||||||
width: 1218,
|
width: 1218,
|
||||||
height: 508,
|
height: 508,
|
||||||
}),
|
}).then((r) => r.accent),
|
||||||
)
|
)
|
||||||
|
: generateBackground({
|
||||||
|
element: avatar.Element,
|
||||||
|
width: 1218,
|
||||||
|
height: 508,
|
||||||
|
}).then((r) => r.accent);
|
||||||
|
const backgroundRes = adaptiveColor
|
||||||
|
? customImage
|
||||||
|
? getAvatarCanvas().then(({ preFeatherCanvas }) =>
|
||||||
|
generateBackground({
|
||||||
|
artCanvas: preFeatherCanvas,
|
||||||
|
element: avatar.Element,
|
||||||
|
width: 1218,
|
||||||
|
height: 508,
|
||||||
|
}).then((r) => r.canvas.toBuffer("image/webp")),
|
||||||
|
)
|
||||||
|
: assets.fnCache(
|
||||||
|
`bg-${avatar.Element}-${avatarArtPath}-${leftStageWidth}x${leftStageHeight}-1218x508`,
|
||||||
|
async () =>
|
||||||
|
generateBackground({
|
||||||
|
artCanvas: (await getAvatarCanvas()).preFeatherCanvas,
|
||||||
|
element: avatar.Element,
|
||||||
|
width: 1218,
|
||||||
|
height: 508,
|
||||||
|
}).then((r) => r.canvas.toBuffer("image/webp")),
|
||||||
|
)
|
||||||
: assets.fnCache(`bg-${avatar.Element}`, async () =>
|
: assets.fnCache(`bg-${avatar.Element}`, async () =>
|
||||||
generateBackground({
|
generateBackground({
|
||||||
element: avatar.Element,
|
element: avatar.Element,
|
||||||
@@ -229,15 +272,7 @@ export async function CharacterPage({
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
avatarRes.then((data) => ({ src: "/avatar", data })),
|
avatarRes.then((data) => ({ src: "/avatar", data })),
|
||||||
backgroundRes.then((data) => ({
|
backgroundRes.then((data) => ({ src: `/bg-${avatar.Element}`, data })),
|
||||||
src: `/bg-${avatar.Element}`,
|
|
||||||
data:
|
|
||||||
data instanceof Buffer || data instanceof ArrayBuffer
|
|
||||||
? data
|
|
||||||
: (
|
|
||||||
data as Awaited<ReturnType<typeof generateBackground>>
|
|
||||||
).canvas.toBuffer("image/webp"),
|
|
||||||
})),
|
|
||||||
] satisfies Promise<{ src: string; data: ArrayBuffer | Buffer }>[]);
|
] 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")}`;
|
log`Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
|
||||||
@@ -272,7 +307,7 @@ export async function CharacterPage({
|
|||||||
<SubstatDisplay
|
<SubstatDisplay
|
||||||
substat={rollSummary}
|
substat={rollSummary}
|
||||||
element={avatar.Element}
|
element={avatar.Element}
|
||||||
accent={((await backgroundRes) as { accent: string }).accent}
|
accent={await backgroundAccentRes}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
tw={`flex relative text-white rounded-[.5em] overflow-hidden aspect-[1218/508] h-[${height}px]`}
|
tw={`flex relative text-white rounded-[.5em] overflow-hidden aspect-[1218/508] h-[${height}px]`}
|
||||||
@@ -284,21 +319,14 @@ export async function CharacterPage({
|
|||||||
tw="w-full h-full object-cover"
|
tw="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div tw="absolute top-0 bottom-0 left-0 w-[30.4em]"></div>
|
||||||
<div
|
<div
|
||||||
tw="absolute top-0 bottom-0 left-0 w-[30.4em] p-[1.6em] flex flex-col"
|
tw="absolute top-0 bottom-0 left-0 w-[30.4em] p-[1.6em] flex flex-col"
|
||||||
style={{
|
style={{
|
||||||
textShadow: "0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)",
|
textShadow: "0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img src="/avatar" alt="char" tw="absolute inset-0 w-full h-full" />
|
||||||
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">
|
<div tw="flex absolute top-0 left-0 bottom-0 right-0">
|
||||||
<img src="shade" alt="shade" tw="opacity-40" />
|
<img src="shade" alt="shade" tw="opacity-40" />
|
||||||
</div>
|
</div>
|
||||||
@@ -399,10 +427,13 @@ export async function CharacterPage({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div tw="text-[0.9em] -mb-[1.3em] mt-[1.3em] right-[.8em] opacity-70">
|
{/* artSource */}
|
||||||
{" "}
|
{artSource && (
|
||||||
{artSource}
|
<div tw="text-[0.9em] -mb-[1.3em] mt-[1.3em] right-[.8em] opacity-70">
|
||||||
</div>
|
{" "}
|
||||||
|
{artSource}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* caption */}
|
{/* caption */}
|
||||||
{caption && (
|
{caption && (
|
||||||
@@ -698,7 +729,7 @@ function SubstatDisplay({
|
|||||||
{substat.map((s) => (
|
{substat.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.id}
|
key={s.id}
|
||||||
tw="mr-[1.2em] flex gap-[.2em] items-center whitespace-nowrap"
|
tw="mr-[1.65em] flex gap-[.2em] items-center whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<div tw="ml-[.2em] mb-[.1em] text-[.75em] flex flex-col items-center font-[Open_Sans]">
|
<div tw="ml-[.2em] mb-[.1em] text-[.75em] flex flex-col items-center font-[Open_Sans]">
|
||||||
{s.efficiency}
|
{s.efficiency}
|
||||||
|
|||||||
Reference in New Issue
Block a user