Redesign avatars as a gods/legends pantheon (custom SVG medallions)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 3m7s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 2m24s

Replaced the childish animal emoji avatars with custom inline-SVG "deity
medallions" (gradient disc + gold ring + heraldic emblem) — Athena, Zeus,
Poseidon, Horus, Odin, Thor, Cyrus, Simorgh, Ishtar, Nike, etc. IDs unchanged
so owned avatars keep working; Avatar renders the art (emoji fallback for legacy
ids). Shop now shows the art + the god name (was generic "Avatar").

Files: components/online/avatarArt.tsx (new art + pantheon map), Avatar.tsx
(render art), ShopScreen Preview (avatar → <Avatar/>), mock-service avatar shop
names from AVATAR_ART.

Verified: tsc + next build clean; web rebuilt on :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 18:16:17 +03:30
parent fd7bef36d8
commit ccfc9b0536
4 changed files with 247 additions and 13 deletions
+9
View File
@@ -1,6 +1,7 @@
"use client";
import { avatarEmoji } from "@/lib/online/types";
import { AvatarArtSvg, hasAvatarArt } from "./avatarArt";
import { cn } from "@/lib/cn";
export function Avatar({
@@ -27,6 +28,14 @@ export function Avatar({
/>
);
}
// Custom god/legend medallion art when available; emoji fallback otherwise.
if (hasAvatarArt(id)) {
return (
<span className={cn("inline-flex items-center justify-center", className)} style={{ width: size, height: size }}>
<AvatarArtSvg id={id} size={size} />
</span>
);
}
return (
<span
className={cn("inline-flex items-center justify-center", className)}
+218
View File
@@ -0,0 +1,218 @@
"use client";
/**
* Custom inline-SVG avatar art — a "pantheon" of gods & legends rendered as
* heraldic medallions (gradient disc + gold ring + a bold mythic emblem).
* Keyed by the same avatar ids used in types.ts AVATARS, so owned avatars keep
* working; the Avatar component renders this when art exists, else falls back to
* the emoji. No external assets.
*/
type Emblem =
| "crown" | "trident" | "bolt" | "sun" | "eye" | "ankh" | "lion" | "eagle"
| "owl" | "helmet" | "phoenix" | "laurel" | "star8" | "flame" | "moon"
| "dragon" | "wings" | "skull" | "gem" | "lyre";
interface AvatarArt {
nameFa: string;
nameEn: string;
c1: string; // disc gradient (top)
c2: string; // disc gradient (bottom)
ring: string; // ring / accent
em: Emblem;
emColor: string;
}
/* ----------------------------- emblems ------------------------------- */
// Each emblem is drawn for a 100×100 viewBox, roughly centred in 28..72,
// using currentColor so the wrapper sets the tint.
const EMBLEMS: Record<Emblem, React.ReactNode> = {
crown: (
<path d="M26 64 L26 40 L38 50 L50 32 L62 50 L74 40 L74 64 Z M24 66 h52 v6 h-52 z" />
),
trident: (
<>
<rect x="47" y="34" width="6" height="40" rx="2" />
<path d="M30 40 q0 -14 10 -16 q-4 6 -2 14 M70 40 q0 -14 -10 -16 q4 6 2 14" />
<path d="M30 30 v12 M50 26 v12 M70 30 v12" stroke="currentColor" strokeWidth="5" fill="none" strokeLinecap="round" />
<path d="M30 42 h40" stroke="currentColor" strokeWidth="5" />
</>
),
bolt: <path d="M54 24 L34 56 H48 L44 78 L68 44 H52 Z" />,
sun: (
<>
<circle cx="50" cy="50" r="16" />
<g stroke="currentColor" strokeWidth="4" strokeLinecap="round">
<path d="M50 22 v8 M50 70 v8 M22 50 h8 M70 50 h8 M31 31 l6 6 M63 63 l6 6 M69 31 l-6 6 M37 63 l-6 6" />
</g>
</>
),
eye: (
<>
<path d="M26 50 Q50 32 74 50 Q50 68 26 50 Z" fill="none" stroke="currentColor" strokeWidth="5" />
<circle cx="50" cy="50" r="8" />
<path d="M50 60 q-2 10 -12 12 M58 58 l10 12" stroke="currentColor" strokeWidth="4" fill="none" strokeLinecap="round" />
</>
),
ankh: (
<>
<ellipse cx="50" cy="38" rx="11" ry="13" fill="none" stroke="currentColor" strokeWidth="6" />
<rect x="46" y="50" width="8" height="28" rx="2" />
<rect x="34" y="56" width="32" height="7" rx="2" />
</>
),
lion: (
<>
<g stroke="currentColor" strokeWidth="3">
<path d="M50 26 l8 8 10 -4 -3 11 9 7 -9 5 3 11 -10 -3 -8 8 -8 -8 -10 3 3 -11 -9 -5 9 -7 -3 -11 10 4 z" />
</g>
<circle cx="50" cy="52" r="15" fill="currentColor" />
<circle cx="44" cy="50" r="2.4" fill="#1a1206" />
<circle cx="56" cy="50" r="2.4" fill="#1a1206" />
<path d="M46 58 q4 4 8 0" stroke="#1a1206" strokeWidth="2.4" fill="none" strokeLinecap="round" />
</>
),
eagle: (
<>
<path d="M50 32 q-22 2 -30 16 q16 -6 24 0 q-4 8 -2 18 l8 6 8 -6 q2 -10 -2 -18 q8 -6 24 0 q-8 -14 -30 -16 z" />
<path d="M50 56 l-4 14 h8 z" />
</>
),
owl: (
<>
<circle cx="40" cy="46" r="12" fill="none" stroke="currentColor" strokeWidth="5" />
<circle cx="60" cy="46" r="12" fill="none" stroke="currentColor" strokeWidth="5" />
<circle cx="40" cy="46" r="4" /><circle cx="60" cy="46" r="4" />
<path d="M50 52 l-5 8 h10 z" />
<path d="M30 34 l6 8 M70 34 l-6 8" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
</>
),
helmet: (
<>
<path d="M30 46 a20 20 0 0 1 40 0 v18 h-12 v-10 h-4 v10 h-8 v-10 h-4 v10 h-12 z" />
<rect x="48" y="22" width="4" height="14" />
<path d="M50 22 q10 -2 14 6 q-8 -2 -14 0 z" />
</>
),
phoenix: (
<>
<path d="M50 28 q-6 10 -2 18 q-14 -8 -24 -2 q12 2 16 10 q-10 0 -16 8 q14 -4 22 2 q-2 8 4 14 q6 -6 4 -14 q8 -6 22 -2 q-6 -8 -16 -8 q4 -8 16 -10 q-10 -6 -24 2 q4 -8 -2 -18 z" />
</>
),
laurel: (
<>
<path d="M34 70 Q22 50 34 30 M66 70 Q78 50 66 30" fill="none" stroke="currentColor" strokeWidth="5" strokeLinecap="round" />
<g fill="currentColor">
<ellipse cx="29" cy="40" rx="4" ry="2.6" transform="rotate(-40 29 40)" />
<ellipse cx="30" cy="52" rx="4" ry="2.6" transform="rotate(-20 30 52)" />
<ellipse cx="71" cy="40" rx="4" ry="2.6" transform="rotate(40 71 40)" />
<ellipse cx="70" cy="52" rx="4" ry="2.6" transform="rotate(20 70 52)" />
</g>
<path d="M44 38 l5 5 11 -13" fill="none" stroke="currentColor" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
</>
),
star8: (
<path d="M50 24 l6 16 16 -6 -10 14 14 8 -16 4 4 16 -14 -10 -10 14 -2 -16 -16 4 12 -12 -12 -12 16 4 2 -16 10 14 10 -14 -4 16 16 -4 -14 12 z" />
),
flame: <path d="M50 24 q10 14 6 24 q-2 6 -8 8 q4 -8 -2 -14 q-2 8 -8 10 q-8 4 -8 14 q0 14 22 14 q22 0 22 -16 q0 -12 -10 -22 q2 8 -4 10 q4 -12 -2 -22 z" />,
moon: (
<>
<path d="M58 26 a24 24 0 1 0 0 48 a18 18 0 0 1 0 -48 z" />
<path d="M70 34 l2 5 5 2 -5 2 -2 5 -2 -5 -5 -2 5 -2 z" />
</>
),
dragon: (
<>
<path d="M30 60 q-6 -18 10 -24 q-2 -8 6 -10 q-2 6 2 8 q10 -2 16 6 q8 0 10 8 q-6 -2 -10 2 q4 6 0 12 q-6 -6 -12 -4 q-8 6 -18 2 q-2 6 -8 6 q2 -6 -2 -8 z" />
<circle cx="44" cy="44" r="2.4" fill="#1a1206" />
</>
),
wings: (
<>
<circle cx="50" cy="48" r="9" />
<path d="M41 48 q-18 -10 -26 -2 q12 0 14 6 q-12 -2 -16 6 q14 -4 20 0 M59 48 q18 -10 26 -2 q-12 0 -14 6 q12 -2 16 6 q-14 -4 -20 0" fill="none" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
</>
),
skull: (
<>
<path d="M32 46 a18 18 0 0 1 36 0 v8 q0 6 -6 8 v6 h-24 v-6 q-6 -2 -6 -8 z" />
<circle cx="42" cy="48" r="4.5" fill="#160a0a" />
<circle cx="58" cy="48" r="4.5" fill="#160a0a" />
<path d="M50 54 l-3 7 h6 z" fill="#160a0a" />
</>
),
gem: (
<>
<path d="M50 26 L70 42 L50 76 L30 42 Z" />
<path d="M30 42 H70 M50 26 L42 42 L50 76 M50 26 L58 42 L50 76" fill="none" stroke="#0b1020" strokeWidth="2" opacity="0.5" />
</>
),
lyre: (
<>
<path d="M36 30 q-8 6 -6 26 M64 30 q8 6 6 26" fill="none" stroke="currentColor" strokeWidth="5" strokeLinecap="round" />
<path d="M36 30 q14 -6 28 0" fill="none" stroke="currentColor" strokeWidth="5" strokeLinecap="round" />
<g stroke="currentColor" strokeWidth="2.4"><path d="M44 36 v30 M50 35 v32 M56 36 v30" /></g>
<path d="M32 64 h36 l-6 8 h-24 z" />
</>
),
};
/* --------------- pantheon: id → god/legend + palette ----------------- */
const PANTHEON: [string, AvatarArt][] = [
["a-fox", { nameFa: "آتنا", nameEn: "Athena", c1: "#1f7a6e", c2: "#0c2e2a", ring: "#f4d27a", em: "owl", emColor: "#f6e7b6" }],
["a-lion", { nameFa: "شیردل", nameEn: "Leonidas", c1: "#b5701c", c2: "#3a2207", ring: "#ffd98a", em: "lion", emColor: "#ffe9b0" }],
["a-owl", { nameFa: "هرمس", nameEn: "Hermes", c1: "#2f6fb0", c2: "#0e2240", ring: "#cfe2ff", em: "wings", emColor: "#eaf3ff" }],
["a-cat", { nameFa: "باستت", nameEn: "Bastet", c1: "#1d8f86", c2: "#0a2b29", ring: "#ffd98a", em: "ankh", emColor: "#ffe9b0" }],
["a-tiger", { nameFa: "آرش", nameEn: "Arash", c1: "#a8431f", c2: "#34110a", ring: "#ffb27a", em: "eagle", emColor: "#ffe0c2" }],
["a-panda", { nameFa: "تور", nameEn: "Thor", c1: "#566b86", c2: "#161f2e", ring: "#bcd0ff", em: "bolt", emColor: "#eef4ff" }],
["a-bear", { nameFa: "اودین", nameEn: "Odin", c1: "#475569", c2: "#13151b", ring: "#cbd5e1", em: "helmet", emColor: "#eef2f7" }],
["a-eagle", { nameFa: "هوروس", nameEn: "Horus", c1: "#2f7d7a", c2: "#0b2a2c", ring: "#ffd98a", em: "eagle", emColor: "#ffe9b0" }],
["a-wolf", { nameFa: "فِنریر", nameEn: "Fenrir", c1: "#3b4a63", c2: "#11151f", ring: "#9fb4d6", em: "lion", emColor: "#cfd9ec" }],
["a-shark", { nameFa: "پوزایدون", nameEn: "Poseidon", c1: "#1565a8", c2: "#08203a", ring: "#7fd4ff", em: "trident", emColor: "#e6f6ff" }],
["a-dragon", { nameFa: "اژدها", nameEn: "Drakon", c1: "#157a3e", c2: "#082416", ring: "#9af0b0", em: "dragon", emColor: "#dcffe6" }],
["a-unicorn", { nameFa: "سیمرغ", nameEn: "Simorgh", c1: "#b23a6a", c2: "#34101f", ring: "#ffc6dd", em: "phoenix", emColor: "#ffe3ee" }],
["a-peacock", { nameFa: "هرا", nameEn: "Hera", c1: "#136f86", c2: "#07232b", ring: "#7fe0d6", em: "eye", emColor: "#e6fffb" }],
["a-swan", { nameFa: "آفرودیت", nameEn: "Aphrodite", c1: "#9c5bd6", c2: "#2a1340", ring: "#e9c8ff", em: "wings", emColor: "#f6ecff" }],
["a-tophat", { nameFa: "مرلین", nameEn: "Merlin", c1: "#3b3170", c2: "#140e2c", ring: "#c9b6ff", em: "moon", emColor: "#ece2ff" }],
["a-diamond", { nameFa: "ایشتار", nameEn: "Ishtar", c1: "#1f6fae", c2: "#0a1f3a", ring: "#bfe6ff", em: "star8", emColor: "#eaf6ff" }],
["a-moneybag", { nameFa: "پلوتوس", nameEn: "Plutus", c1: "#b58a16", c2: "#3a2a06", ring: "#ffe28a", em: "gem", emColor: "#fff2c2" }],
["a-trophy", { nameFa: "نیکه", nameEn: "Nike", c1: "#b5901c", c2: "#3a2c07", ring: "#ffe9a0", em: "laurel", emColor: "#fff4c8" }],
["a-robot", { nameFa: "تالوس", nameEn: "Talos", c1: "#4a7c8c", c2: "#11242a", ring: "#a8e0ee", em: "gem", emColor: "#e6fbff" }],
["a-wizard", { nameFa: "زئوس", nameEn: "Zeus", c1: "#9a7b2a", c2: "#2e2208", ring: "#ffe79a", em: "bolt", emColor: "#fff6cf" }],
["a-ninja", { nameFa: "شینوبی", nameEn: "Shinobi", c1: "#2b3340", c2: "#0c0f14", ring: "#8f9bb0", em: "skull", emColor: "#dfe5ee" }],
["a-king", { nameFa: "کوروش", nameEn: "Cyrus", c1: "#9a6b1f", c2: "#2f2008", ring: "#ffd98a", em: "wings", emColor: "#ffefc2" }],
["a-genie", { nameFa: "دیو", nameEn: "Djinn", c1: "#7a2ea8", c2: "#260a3a", ring: "#d6a8ff", em: "flame", emColor: "#f1ddff" }],
["a-crown", { nameFa: "شاهنشاه", nameEn: "Shahanshah", c1: "#b58a16", c2: "#3a2a06", ring: "#ffe28a", em: "crown", emColor: "#fff4c8" }],
["a-gem", { nameFa: "اهورا", nameEn: "Ahura", c1: "#1f8fae", c2: "#082a3a", ring: "#9fe8ff", em: "sun", emColor: "#eafbff" }],
];
export const AVATAR_ART: Record<string, AvatarArt> = Object.fromEntries(PANTHEON);
export function hasAvatarArt(id: string): boolean {
return !!AVATAR_ART[id];
}
export function avatarArtName(id: string, locale: "fa" | "en"): string | null {
const a = AVATAR_ART[id];
return a ? (locale === "fa" ? a.nameFa : a.nameEn) : null;
}
/** Render a god/legend medallion for the given avatar id (returns null if none). */
export function AvatarArtSvg({ id, size }: { id: string; size: number }) {
const a = AVATAR_ART[id];
if (!a) return null;
const gid = `avg-${id}`;
return (
<svg width={size} height={size} viewBox="0 0 100 100" aria-hidden>
<defs>
<radialGradient id={gid} cx="42%" cy="34%" r="80%">
<stop offset="0" stopColor={a.c1} />
<stop offset="1" stopColor={a.c2} />
</radialGradient>
</defs>
<circle cx="50" cy="50" r="48" fill={`url(#${gid})`} />
<circle cx="50" cy="50" r="46.5" fill="none" stroke={a.ring} strokeWidth="2.5" opacity="0.85" />
<g style={{ color: a.emColor }} fill="currentColor" stroke="none">
{EMBLEMS[a.em]}
</g>
</svg>
);
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { Check, Coins, Lock, Sparkles, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { Sticker } from "@/components/online/Sticker";
import { Avatar } from "@/components/online/Avatar";
import { CoinsPill } from "@/components/online/CoinsPill";
import { useSessionStore } from "@/lib/session-store";
import { useI18n } from "@/lib/i18n";
@@ -63,7 +64,9 @@ function Preview({ item, size }: { item: ShopItem; size: number }) {
🏷
</span>
);
default: // avatar, reactionpack, xp → emoji glyph
case "avatar":
return <Avatar id={item.id} size={size} />;
default: // reactionpack, xp → emoji glyph
return <span style={{ fontSize: size * 0.82, lineHeight: 1 }}>{item.kind === "xp" ? "⚡" : item.preview}</span>;
}
}
+11 -7
View File
@@ -24,6 +24,7 @@ import {
OnlineService,
Unsubscribe,
} from "./service";
import { AVATAR_ART } from "@/components/online/avatarArt";
import {
AVATARS,
AppNotification,
@@ -1022,18 +1023,21 @@ export class MockOnlineService implements OnlineService {
}
async getShopItems(): Promise<ShopItem[]> {
const avatarItems: ShopItem[] = AVATARS.filter((a) => (a.price ?? 0) > 0).map((a) => ({
const avatarItems: ShopItem[] = AVATARS.filter((a) => (a.price ?? 0) > 0).map((a) => {
const art = AVATAR_ART[a.id];
return {
id: a.id,
kind: "avatar",
nameFa: "آواتار",
nameEn: "Avatar",
kind: "avatar" as const,
nameFa: art?.nameFa ?? "آواتار",
nameEn: art?.nameEn ?? "Avatar",
price: a.price!,
preview: a.emoji,
descFa: "آواتار نمایه شما در بازی و جدول",
descEn: "Your profile avatar in games & leaderboard",
descFa: "آواتار افسانه‌ای نمایه شما در بازی و جدول",
descEn: "A legendary profile avatar shown in games & the leaderboard",
reqLevel: a.reqLevel,
reqRating: a.reqRating,
}));
};
});
const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({
id: c.id,
kind: "cardback",