@idosgames/mcp 0.1.44 → 0.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +119 -119
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +6 -6
- package/registry/modules/boosts.json +5 -5
- package/registry/modules/character.json +9 -9
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +6 -6
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +6 -6
- package/registry/modules/events.json +3 -3
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +5 -5
- package/registry/modules/idle-rpg.json +7 -7
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/leaderboard.json +3 -3
- package/registry/modules/lootboxes.json +5 -5
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +3 -3
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +6 -6
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/video-studio.json +8 -8
- package/registry/modules/voxelcraft.json +5 -5
- package/registry/modules/workshop.json +6 -6
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/store-system.json +1 -1
|
@@ -54,16 +54,16 @@
|
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@idosgames/core": "0.
|
|
58
|
-
"@idosgames/module-sdk": "0.13.
|
|
59
|
-
"@idosgames/react": "0.
|
|
57
|
+
"@idosgames/core": "0.26.0",
|
|
58
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
59
|
+
"@idosgames/react": "0.7.0",
|
|
60
60
|
"react": "19.2.7"
|
|
61
61
|
},
|
|
62
|
-
"contentHash": "
|
|
62
|
+
"contentHash": "92f7f5539093cf9793ff5a366db0583ed1e250b9bab95763a14ee49e0739e015",
|
|
63
63
|
"files": [
|
|
64
64
|
{
|
|
65
65
|
"path": "CharacterScreen.tsx",
|
|
66
|
-
"content": "import {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n type CharacterArtRegistry,\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport {\n Badge,\n EmptyState,\n ItemTile,\n KitPortal,\n MovingPattern,\n ResourceList,\n canAfford,\n errorText,\n formatAmount,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n bestLoadout,\n breakdown,\n rankCost,\n resolveLadder,\n resolveSlots,\n resolveStats,\n roster,\n statNumber,\n unequipAllSlots,\n type GearPiece,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n type StatView,\n} from \"./model\";\nimport { t } from \"./i18n\";\nimport { readSelected, resolveSelected, saveSelected } from \"./selection\";\nimport { HeroesPopup } from \"./components/HeroesPopup\";\nimport { PiecePopup } from \"./components/PiecePopup\";\nimport { SkinsPopup } from \"./components/SkinsPopup\";\nimport { SlotPopup } from \"./components/SlotPopup\";\nimport { StatsPopup } from \"./components/StatsPopup\";\nimport { useData } from \"./components/useData\";\nimport {\n CharacterArtContext,\n HeroFigure,\n useCharacterArt,\n} from \"./components/HeroFigure\";\nimport {\n LockArt,\n SlotSilhouette,\n classColor,\n slotKind,\n type SlotKind,\n} from \"./components/art\";\n\n// Heroes in the reference's look (BoardSurvival BoardScene: Equipment = EquipmentPanel,\n// Character_Detail = CharacterDetailPanel, Character_List, Character_Stat_Popup, Popup_EquipmentItem).\n// The main screen is the equipment hub: the hero in the middle of a blue backdrop with three gear\n// slots on each side, Damage and Health under the name, Detail / Unequip All / Equip All, and below\n// a bar of category tabs over the gear the player owns. Detail opens the hero's card (rarity, rank,\n// stats, lore, Upgrade Level, STATS). Everything is laid out in the reference's 1080-wide canvas\n// units and scaled to the screen, so the numbers come straight from the scene.\n\nconst W = 1080;\n// The ink of the outlines — a literal: SVG attributes below cannot read a CSS variable.\nconst INK = \"#17193a\";\n\nexport function makeCharacterScreen(\n features: FeatureRegistry,\n onSelect: (characterId: string) => void,\n art: CharacterArtRegistry,\n) {\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <CharacterArtContext.Provider value={art}>\n <Heroes\n features={features}\n onSelect={onSelect}\n openInstance={\n typeof args?.itemInstanceID === \"string\"\n ? args.itemInstanceID\n : null\n }\n />\n </CharacterArtContext.Provider>\n );\n };\n}\n\ntype PopupState =\n | { kind: \"heroes\" }\n | { kind: \"stats\" }\n | { kind: \"skins\" }\n | { kind: \"detail\" }\n | { kind: \"detailStats\" }\n | { kind: \"slot\"; slot: SlotView }\n | { kind: \"piece\"; instanceID: string }\n | null;\n\n/** Slot positions (Group_Slots): weapon, ring, shoes on the left; helmet, torso, pants on the right. */\nconst SLOT_POS: Array<{ kind: SlotKind; x: number; y: number }> = [\n { kind: \"weapon\", x: -400, y: 210 },\n { kind: \"ring\", x: -400, y: 470 },\n { kind: \"shoes\", x: -400, y: 730 },\n { kind: \"helmet\", x: 400, y: 210 },\n { kind: \"torso\", x: 400, y: 470 },\n { kind: \"pants\", x: 400, y: 730 },\n];\n\n/** Places the hero's slots: each on its kind's spot, the rest on the free spots in order. */\nfunction placeSlots(\n slots: SlotView[],\n): Array<{ slot: SlotView; x: number; y: number; kind: SlotKind }> {\n const free = [...SLOT_POS];\n const placed: Array<{\n slot: SlotView;\n x: number;\n y: number;\n kind: SlotKind;\n }> = [];\n const rest: SlotView[] = [];\n for (const slot of slots) {\n const kind = slotKind(slot.id);\n const i = free.findIndex((p) => p.kind === kind);\n if (i >= 0) {\n const p = free.splice(i, 1)[0]!;\n placed.push({ slot, x: p.x, y: p.y, kind });\n } else rest.push(slot);\n }\n for (const slot of rest) {\n const p = free.shift();\n if (!p) break;\n placed.push({\n slot,\n x: p.x,\n y: p.y,\n kind: slotKind(slot.id) === \"other\" ? p.kind : slotKind(slot.id),\n });\n }\n return placed;\n}\n\n/** Damage and Health under the name (Stat_Attack / Stat_Hp), by the hero's stat ids. */\nfunction headlineStats(\n stats: StatView[],\n): [StatView | undefined, StatView | undefined] {\n const attack = stats.find(\n (s) => /att|dam|dmg|str|pow/i.test(s.id) || /att|dam/i.test(s.name),\n );\n const hp = stats.find(\n (s) => /^hp|health|life|vit/i.test(s.id) || /hp|health/i.test(s.name),\n );\n return [\n attack ?? stats[0],\n hp ?? stats.find((s) => s !== (attack ?? stats[0])),\n ];\n}\n\nfunction Heroes({\n features,\n onSelect,\n openInstance,\n}: {\n features: FeatureRegistry;\n onSelect: (characterId: string) => void;\n openInstance: string | null;\n}): ReactNode {\n const { client, section, owned, items, pieces, balances } = useData();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const heroRef = useRef<HTMLDivElement>(null);\n // the game's own hero moves by itself; the screen's picture bobs\n const bob = useCharacterArt()\n ? undefined\n : \"idos-hero-idle 2.4s ease-in-out infinite\";\n const [popup, setPopup] = useState<PopupState>(\n openInstance ? { kind: \"piece\", instanceID: openInstance } : null,\n );\n const [busy, setBusy] = useState<string | null>(null);\n const [selectedID, setSelectedID] = useState<string | null>(readSelected);\n const [tab, setTab] = useState<string>(\"all\");\n\n const list = roster(section, owned);\n if (list.length === 0)\n return <EmptyState glyph=\"hero\" text={t(\"noHeroes\")} />;\n const hero = resolveSelected(list, selectedID)!;\n const select = (id: string) => {\n setSelectedID(id);\n saveSelected(id);\n onSelect(id);\n };\n\n const slots = resolveSlots(section, hero.def);\n const changes =\n hero.state === \"locked\"\n ? []\n : bestLoadout(\n hero.id,\n hero.rank,\n slots,\n pieces,\n hero.model?.Equipment,\n items,\n );\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\n const heroRarity = catalog.rarityColor(\n hero.def.Classification?.RarityID ?? \"\",\n );\n // The row of actions under the stats shares one width: three buttons get wider plates than four.\n const actions = hero.state === \"locked\" ? 1 : skinCount > 0 ? 4 : 3;\n const actionAt = (i: number): CSSProperties => {\n const gap = 20;\n const w = actions === 4 ? 250 : actions === 3 ? 320 : 340;\n const row = actions * w + (actions - 1) * gap;\n return center(-row / 2 + w / 2 + i * (w + gap), 971, w, 120);\n };\n const color = classColor(hero.def.Classification?.ClassID);\n const stats = resolveStats(section, hero.def);\n const worn = wornGear(hero, pieces, items);\n const levels = hero.model?.StatLevels ?? {};\n const value = (s: StatView | undefined): string =>\n s\n ? statNumber(\n breakdown(s, Number(levels[s.id] ?? 0), hero.rank, hero.def, worn)\n .total,\n )\n : \"—\";\n const [atk, hp] = headlineStats(stats);\n\n const equipAll = async () => {\n if (changes.length === 0) return;\n setBusy(\"best\");\n const res = await client.character.equipItems(hero.id, changes);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(heroRef.current);\n };\n\n // Always enabled (Unity EquipmentPanel.OnUnequipAllClicked): the way out of a local/server mismatch.\n const unequipAll = async () => {\n setBusy(\"off\");\n const slotIDs = unequipAllSlots(\n hero.id,\n slots,\n hero.model?.Equipment,\n pieces,\n );\n const res =\n slotIDs.length > 0\n ? await client.character.unequipItems(hero.id, slotIDs)\n : null;\n await client.cache.ensureState([\"Character\", \"InventoryV2\"], {\n force: true,\n });\n setBusy(null);\n if (res && !res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"click\");\n };\n\n // Category tabs (EquipmentCategoryTab): All, then one per slot kind the gear covers.\n const bagPieces = pieces.filter((p) => !p.equippedBy);\n const kindOf = (p: GearPiece): SlotKind =>\n slotKind(items.get(p.itemID)?.ItemClass ?? p.itemID);\n const tabs: Array<{ id: string; kind: SlotKind }> = [\n { id: \"all\", kind: \"bag\" },\n ...(\n [\n \"weapon\",\n \"ring\",\n \"shoes\",\n \"helmet\",\n \"torso\",\n \"pants\",\n \"other\",\n ] as SlotKind[]\n )\n .filter(\n (k) =>\n bagPieces.some((p) => kindOf(p) === k) ||\n placeSlots(slots).some((s) => s.kind === k),\n )\n .map((k) => ({ id: k, kind: k })),\n ];\n const shown =\n tab === \"all\" ? bagPieces : bagPieces.filter((p) => kindOf(p) === tab);\n const heroesBadge = list.filter(\n (h) =>\n h.state === \"locked\" &&\n h.unlockPrice.length > 0 &&\n canAfford(h.unlockPrice, balances),\n ).length;\n\n return (\n // fullBleed: the lobby gives this screen its whole area; the gear below the stage fills the rest\n <div style={{ flex: \"1 0 auto\", display: \"flex\", flexDirection: \"column\" }}>\n {/* ── Middle: the hero on the blue backdrop (the backdrop spans the screen, the canvas fits it) ── */}\n <div\n style={{\n position: \"relative\",\n overflow: \"hidden\",\n // the stage runs under the lobby's top row (fullBleed) and keeps the hero below it\n paddingTop: \"var(--idos-lobby-top, 0px)\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n // the shown hero's rarity colours the stage, as on its card (Common blue, Rare green…)\n background: `radial-gradient(ellipse at 50% 35%, color-mix(in srgb, ${heroRarity} 60%, #fff) 0%, ${heroRarity} 45%, color-mix(in srgb, ${heroRarity} 62%, #000) 100%)`,\n }}\n />\n <MovingPattern />\n <Fit height={1111}>\n <div style={{ position: \"relative\", height: 1111 }}>\n <div\n style={{\n ...center(0, 603, 174, 45),\n borderRadius: \"50%\",\n background: \"rgba(0,0,0,.2)\",\n }}\n />\n <div\n ref={heroRef}\n onClick={() => setPopup({ kind: \"heroes\" })}\n style={{\n ...center(0, 355, 384, 512),\n cursor: \"pointer\",\n animation: bob,\n transformOrigin: \"50% 100%\",\n // over the slots: a long weapon reaches past them (the game's figure lets clicks through)\n zIndex: 1,\n filter:\n hero.state === \"locked\"\n ? \"grayscale(1) brightness(.7)\"\n : undefined,\n }}\n >\n <HeroFigure hero={hero} color={color} />\n {heroesBadge > 0 ? (\n <span\n style={{\n position: \"absolute\",\n right: 50,\n top: 20,\n transform: \"scale(2.6)\",\n transformOrigin: \"100% 0\",\n }}\n >\n <Badge count={heroesBadge} />\n </span>\n ) : null}\n </div>\n <div\n style={{\n ...center(0, 665, 700, 81),\n display: \"grid\",\n placeItems: \"center\",\n ...text(64),\n }}\n >\n {heroName}\n </div>\n\n {placeSlots(slots).map(({ slot, x, y, kind }) => (\n <SlotFrame\n key={slot.id}\n style={center(x, y, 210, 210)}\n kind={kind}\n slot={slot}\n hero={hero}\n pieces={pieces}\n onClick={() => setPopup({ kind: \"slot\", slot })}\n />\n ))}\n\n <HeadStat\n style={center(-91, 809, 234, 103)}\n glyph=\"sword\"\n label={atk?.name ?? t(\"power\")}\n value={atk ? value(atk) : formatAmount(hero.power)}\n />\n <HeadStat\n style={center(142.5, 809, 234, 103)}\n glyph=\"heart\"\n label={hp?.name ?? t(\"rank\")}\n value={hp ? value(hp) : String(hero.rank)}\n />\n\n <UButton\n style={actionAt(0)}\n tone=\"purple\"\n onClick={() => setPopup({ kind: \"detail\" })}\n >\n {t(\"detail\")}\n </UButton>\n {hero.state !== \"locked\" ? (\n <>\n <UButton\n style={actionAt(1)}\n tone=\"sky\"\n busy={busy === \"off\"}\n onClick={() => void unequipAll()}\n >\n {t(\"unequipAll\")}\n </UButton>\n <UButton\n style={actionAt(2)}\n tone=\"yellow\"\n busy={busy === \"best\"}\n disabled={changes.length === 0}\n attract={changes.length > 0}\n onClick={() => void equipAll()}\n data-tutorial-anchor=\"heroes:equip-best\"\n >\n {t(\"equipAll\")}\n </UButton>\n {skinCount > 0 ? (\n <UButton\n style={actionAt(3)}\n tone=\"yellow\"\n onClick={() => setPopup({ kind: \"skins\" })}\n >\n {t(\"skins\")}\n </UButton>\n ) : null}\n </>\n ) : null}\n </div>\n </Fit>\n </div>\n\n {/* ── Category tabs (Menu_MiddleBtn) ── */}\n <div\n style={{\n height: 58,\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.nav} 85%, #fff) 0%, ${v.nav} 100%)`,\n borderTop: `3px solid ${INK}`,\n boxShadow: \"inset 0 4px 0 rgba(255,255,255,.08)\",\n display: \"flex\",\n justifyContent: \"center\",\n overflowX: \"auto\",\n // only sideways: the 56px tabs under the 3px line overflow by a hair, and auto here drew\n // a dark scrollbar at the right edge\n overflowY: \"hidden\",\n }}\n className=\"idos-scroll\"\n >\n {tabs.map((tb) => (\n <button\n key={tb.id}\n type=\"button\"\n onClick={() => {\n kit.play(\"tab\");\n setTab(tb.id);\n }}\n style={{\n flex: \"0 1 88px\",\n minWidth: 52,\n height: 56,\n border: \"none\",\n background:\n tab === tb.id ? \"rgba(255,255,255,.07)\" : \"transparent\",\n position: \"relative\",\n cursor: \"pointer\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ marginBottom: 6 }}>\n <SlotSilhouette\n kind={tb.kind}\n size={34}\n opacity={tab === tb.id ? 1 : 0.55}\n />\n </span>\n {tab === tb.id ? (\n <span\n style={{\n position: \"absolute\",\n left: 8,\n right: 8,\n bottom: 5,\n height: 8,\n borderRadius: 4,\n background: v.blue,\n boxShadow: `0 0 8px ${v.blue}`,\n }}\n />\n ) : null}\n </button>\n ))}\n </div>\n\n {/* ── The gear (Scroll View) ── */}\n <div\n style={{\n background: `color-mix(in srgb, ${v.nav} 70%, ${v.ink})`,\n padding: \"14px 12px 22px\",\n minHeight: 180,\n flex: \"1 0 auto\",\n }}\n >\n {shown.length === 0 ? (\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 14,\n padding: \"22px 16px\",\n }}\n >\n <div\n style={{\n ...text(15),\n opacity: 0.75,\n whiteSpace: \"normal\",\n textAlign: \"center\",\n maxWidth: 420,\n }}\n >\n {t(\"noGear\")}\n </div>\n {features.get(\"store\") ? (\n <UButton\n style={{ position: \"relative\", width: 140, height: 50 }}\n size={20}\n tone=\"green\"\n onClick={() => features.open(\"store\")}\n >\n {catalog.localize(\"Shop\")}\n </UButton>\n ) : null}\n </div>\n ) : (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(84px, 1fr))\",\n gap: 12,\n justifyItems: \"center\",\n maxWidth: 900,\n margin: \"0 auto\",\n }}\n >\n {shown.map((p, i) => (\n <div\n key={p.instanceID}\n style={{\n animation: `idos-hero-pop .3s cubic-bezier(.34,1.56,.64,1) ${Math.min(i, 12) * 0.03}s both`,\n }}\n >\n <ItemTile\n itemID={p.itemID}\n level={p.level}\n size={80}\n onClick={() =>\n setPopup({ kind: \"piece\", instanceID: p.instanceID })\n }\n />\n </div>\n ))}\n </div>\n )}\n </div>\n\n <style>{HERO_CSS}</style>\n\n {popup?.kind === \"detail\" || popup?.kind === \"detailStats\" ? (\n <DetailWindow\n hero={hero}\n stats={stats}\n value={value}\n balances={balances}\n section={section}\n onStats={() => setPopup({ kind: \"detailStats\" })}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"heroes\" ? (\n <HeroesPopup\n list={list}\n current={hero.id}\n balances={balances}\n onSelect={select}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"stats\" || popup?.kind === \"detailStats\" ? (\n <StatsPopup\n hero={hero}\n section={section}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() =>\n setPopup(popup.kind === \"detailStats\" ? { kind: \"detail\" } : null)\n }\n />\n ) : null}\n {popup?.kind === \"skins\" ? (\n <SkinsPopup hero={hero} onClose={() => setPopup(null)} />\n ) : null}\n {popup?.kind === \"slot\" ? (\n <SlotPopup\n slot={popup.slot}\n hero={hero}\n pieces={pieces}\n items={items}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"piece\" ? (\n <PiecePopup\n instanceID={popup.instanceID}\n hero={hero}\n slots={slots}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction wornGear(\n hero: RosterEntry,\n pieces: GearPiece[],\n items: Map<string, ItemDefWithGear>,\n): Array<{ def: ItemDefWithGear | undefined; level: number }> {\n return Object.values(hero.model?.Equipment ?? {})\n .filter((e) => e?.ItemInstanceID)\n .map((e) => {\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\n });\n}\n\n// ── Layout helpers ───────────────────────────────────────────────────────────────────────────\n\n/**\n * The 1080-wide canvas of a section, scaled to fit both the width AND a share of the window height\n * (a phone fits it by width, a wide screen by height) and centred — the backdrop behind spans the\n * whole width, so a wide screen never shows a giant stretched hero nor a strip of empty page.\n */\nfunction Fit({\n height,\n children,\n}: {\n height: number;\n children: ReactNode;\n}): ReactNode {\n const outer = useRef<HTMLDivElement>(null);\n const [scale, setScale] = useState(0.35);\n useLayoutEffect(() => {\n const o = outer.current;\n if (!o) return;\n const fit = (): void => {\n const byWidth = o.clientWidth / W;\n const byHeight = (window.innerHeight * 0.56) / height;\n const s = Math.max(0.22, Math.min(byWidth, byHeight, 0.9));\n if (s > 0) setScale(s);\n };\n fit();\n const ro = new ResizeObserver(fit);\n ro.observe(o);\n window.addEventListener(\"resize\", fit);\n return () => {\n ro.disconnect();\n window.removeEventListener(\"resize\", fit);\n };\n }, [height]);\n return (\n <div\n ref={outer}\n style={{ position: \"relative\", width: \"100%\", height: height * scale }}\n >\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: 0,\n width: W,\n height,\n marginLeft: -W / 2,\n transform: `scale(${scale})`,\n transformOrigin: \"50% 0\",\n }}\n >\n {children}\n </div>\n </div>\n );\n}\n\n/** A box centred at (x, y): x from the canvas centre, y from the top of its section. */\nfunction center(x: number, y: number, w: number, h: number): CSSProperties {\n return {\n position: \"absolute\",\n left: W / 2 + x - w / 2,\n top: y - h / 2,\n width: w,\n height: h,\n };\n}\n\n/** A font size that fits `label` into `width` canvas units (long translations shrink, never wrap). */\nfunction fitText(label: string, width: number, max: number): number {\n return Math.min(max, Math.floor(width / Math.max(1, label.length * 0.62)));\n}\n\n/** The reference's outlined white game text. */\nfunction text(size: number, color = \"#fff\"): CSSProperties {\n const w = Math.max(2, Math.round(size / 14));\n return {\n color,\n fontSize: size,\n fontWeight: 900,\n lineHeight: 1.1,\n WebkitTextStroke: `${w}px ${INK}`,\n paintOrder: \"stroke fill\",\n whiteSpace: \"nowrap\",\n };\n}\n\nconst TONES: Record<string, [string, string, string]> = {\n purple: [\"#b46cff\", \"#8a3cf0\", \"#5a1fb0\"],\n sky: [\"#5cc8ff\", \"#1c8fe6\", \"#0d5aa8\"],\n yellow: [\"#ffd84a\", \"#f5a90c\", \"#a8650a\"],\n green: [\"#7be34a\", \"#33b31d\", \"#1f7a10\"],\n};\n\n/** Button01 (purple / sky / yellow / green): a glossy plate, dark outline, a bottom lip. */\nfunction UButton({\n style,\n tone,\n size = 42,\n busy,\n disabled,\n attract,\n onClick,\n children,\n ...rest\n}: {\n style: CSSProperties;\n tone: keyof typeof TONES;\n size?: number;\n busy?: boolean;\n disabled?: boolean;\n attract?: boolean;\n onClick: () => void;\n children: ReactNode;\n \"data-tutorial-anchor\"?: string;\n}): ReactNode {\n const play = useSound();\n const [top, bottom] = TONES[tone]!;\n const off = disabled || busy;\n return (\n <button\n type=\"button\"\n {...rest}\n disabled={off}\n onClick={() => {\n play(\"click\");\n onClick();\n }}\n style={{\n ...style,\n borderRadius: 34,\n border: `6px solid ${INK}`,\n background: `linear-gradient(180deg, ${top} 0%, ${bottom} 100%)`,\n boxShadow: `inset 0 8px 0 rgba(255,255,255,.4)`,\n cursor: off ? \"default\" : \"pointer\",\n filter: disabled ? \"grayscale(.55) brightness(.85)\" : undefined,\n animation: attract\n ? \"idos-hero-attract 1.6s ease-in-out infinite\"\n : undefined,\n fontFamily: \"inherit\",\n padding: \"0 28px\",\n ...text(size),\n }}\n >\n <FitLabel size={size}>{busy ? \"…\" : children}</FitLabel>\n </button>\n );\n}\n\n/**\n * What never runs past its button: a label longer than the room (\"Подробнее\", \"ПОВЫСИТЬ УРОВЕНЬ\"\n * over its price) is scaled down as a whole — whatever font sizes its own parts set — instead of\n * spilling over the plate's edges, and stays centred. Re-measured when the content, the size or the\n * button's width changes, and once the font has loaded.\n */\nfunction FitLabel({\n size,\n children,\n}: {\n size: number;\n children: ReactNode;\n}): ReactNode {\n const ref = useRef<HTMLSpanElement>(null);\n useLayoutEffect(() => {\n const el = ref.current;\n const box = el?.parentElement;\n if (!el || !box) return;\n const fit = () => {\n el.style.transform = \"\";\n el.style.marginLeft = el.style.marginRight = \"\";\n const cs = getComputedStyle(box);\n const room =\n box.clientWidth -\n parseFloat(cs.paddingLeft) -\n parseFloat(cs.paddingRight);\n const natural = el.offsetWidth;\n if (natural <= room || room <= 0) return;\n el.style.transform = `scale(${room / natural})`;\n // the layout box shrinks to the room too, so the scaled label sits in the middle\n el.style.marginLeft = el.style.marginRight = `${-(natural - room) / 2}px`;\n };\n fit();\n const ro =\n typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(fit);\n ro?.observe(box);\n void document.fonts?.ready.then(fit);\n return () => ro?.disconnect();\n }, [size, children]);\n return (\n <span\n ref={ref}\n style={{\n display: \"inline-block\",\n whiteSpace: \"nowrap\",\n transformOrigin: \"50% 50%\",\n }}\n >\n {children}\n </span>\n );\n}\n\n/** An equipment slot (EquipmentSlotView): a blue frame, the slot's white silhouette, a lock. */\nfunction SlotFrame({\n style,\n kind,\n slot,\n hero,\n pieces,\n onClick,\n}: {\n style: CSSProperties;\n kind: SlotKind;\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n onClick: () => void;\n}): ReactNode {\n const play = useSound();\n const worn = hero.model?.Equipment?.[slot.id];\n const piece = worn?.ItemInstanceID\n ? pieces.find((p) => p.instanceID === worn.ItemInstanceID)\n : undefined;\n const locked = hero.rank < slot.minRank;\n return (\n <div style={style}>\n {worn?.ItemID ? (\n <ItemTile\n itemID={worn.ItemID}\n level={piece?.level}\n size={210}\n onClick={onClick}\n />\n ) : (\n <button\n type=\"button\"\n onClick={() => {\n play(\"click\");\n onClick();\n }}\n style={{\n width: \"100%\",\n height: \"100%\",\n borderRadius: 40,\n border: `6px solid ${INK}`,\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.panel} 75%, #fff) 0%, ${v.panel} 60%, ${v.panelDeep} 100%)`,\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.28)\",\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n position: \"relative\",\n }}\n >\n <SlotSilhouette kind={kind} size={130} opacity={0.39} />\n {locked ? (\n <span\n style={{\n position: \"absolute\",\n left: 105 + 77 - 25,\n top: 105 + 65 - 35,\n }}\n >\n <LockArt size={71} />\n </span>\n ) : null}\n </button>\n )}\n {locked ? (\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: -44,\n textAlign: \"center\",\n ...text(28),\n }}\n >\n {t(\"slotLocked\")} {slot.minRank}\n </div>\n ) : null}\n </div>\n );\n}\n\n/** Stat_Attack / Stat_Hp: an icon, the stat's name and its value. */\nfunction HeadStat({\n style,\n glyph,\n label,\n value,\n}: {\n style: CSSProperties;\n glyph: \"sword\" | \"heart\";\n label: string;\n value: string;\n}): ReactNode {\n return (\n <div style={{ ...style, display: \"flex\", alignItems: \"center\", gap: 14 }}>\n <StatGlyph kind={glyph} size={78} />\n <div style={{ display: \"grid\" }}>\n <span style={text(30)}>{label}</span>\n <span style={text(44)}>{value}</span>\n </div>\n </div>\n );\n}\n\nfunction StatGlyph({\n kind,\n size,\n}: {\n kind: \"sword\" | \"heart\" | \"shield\" | \"crown\";\n size: number;\n}): ReactNode {\n const shapes = {\n sword: (\n <g stroke={INK} strokeWidth=\"5\" strokeLinejoin=\"round\">\n <path d=\"M60 6 L70 16 L32 54 L24 46 Z\" fill=\"#dfe8f5\" />\n <path d=\"M20 40 L38 58 L32 64 L14 46 Z\" fill=\"#ffc21a\" />\n <path d=\"M14 56 L22 64 L10 76 L2 68 Z\" fill=\"#8a5a2b\" />\n </g>\n ),\n heart: (\n <path\n d=\"M39 70 L10 40 Q0 28 8 16 Q18 4 32 12 Q36 15 39 20 Q42 15 46 12 Q60 4 70 16 Q78 28 68 40 Z\"\n fill=\"#f0353b\"\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n ),\n shield: (\n <path\n d=\"M39 4 L70 16 Q70 52 39 74 Q8 52 8 16 Z\"\n fill=\"#3d8cf0\"\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n ),\n crown: (\n <path\n d=\"M8 60 L8 22 L24 36 L39 12 L54 36 L70 22 L70 60 Z\"\n fill=\"#ffd23a\"\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n ),\n };\n return (\n <svg\n viewBox=\"0 0 78 78\"\n width={size}\n height={size}\n aria-hidden\n style={{ flex: \"none\" }}\n >\n {shapes[kind]}\n </svg>\n );\n}\n\n// ── Character_Detail (CharacterDetailPanel) ─────────────────────────────────────────────────\n\nconst DH = 2340;\n/** A box in the detail canvas: x from the centre, y up from the centre (Unity), canvas 1080 × 2340. */\nfunction dAt(x: number, y: number, w: number, h: number): CSSProperties {\n return {\n position: \"absolute\",\n left: W / 2 + x - w / 2,\n top: DH / 2 - y - h / 2,\n width: w,\n height: h,\n };\n}\n\nfunction DetailWindow({\n hero,\n stats,\n value,\n balances,\n section,\n onStats,\n onClose,\n}: {\n hero: RosterEntry;\n stats: StatView[];\n value: (s: StatView | undefined) => string;\n balances: Record<string, { Amount?: number }> | undefined;\n section: Parameters<typeof resolveLadder>[0];\n onStats: () => void;\n onClose: () => void;\n}): ReactNode {\n const { client } = useData();\n const catalog = useCatalog();\n const kit = useUiKit();\n const toast = useToast();\n const host = useRef<HTMLDivElement>(null);\n const card = useRef<HTMLDivElement>(null);\n const bob = useCharacterArt()\n ? undefined\n : \"idos-hero-idle 2.4s ease-in-out infinite\";\n const [scale, setScale] = useState(0.35);\n const [shown, setShown] = useState(false);\n const [busy, setBusy] = useState(false);\n\n useLayoutEffect(() => {\n const el = host.current;\n if (!el) return;\n const fit = (): void =>\n setScale(Math.min(el.clientWidth / W, el.clientHeight / DH));\n fit();\n const ro = new ResizeObserver(fit);\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n useEffect(() => {\n kit.play(\"open\");\n const id = requestAnimationFrame(() => setShown(true));\n return () => cancelAnimationFrame(id);\n }, []);\n\n const close = (): void => {\n kit.play(\"close\");\n setShown(false);\n setTimeout(onClose, 220);\n };\n\n const ladder = resolveLadder(section, hero.def);\n const nextRank = rankCost(ladder, hero.rank);\n const affordable = nextRank !== null && canAfford(nextRank, balances);\n const rarityID = hero.def.Classification?.RarityID ?? \"\";\n const rarity = catalog.rarityColor(rarityID);\n const maxRank = Number(ladder?.MaxRank ?? 0);\n const stars = Math.max(\n 0,\n Math.min(\n 6,\n maxRank > 0\n ? Math.round((hero.rank / maxRank) * 6)\n : Math.min(6, hero.rank),\n ),\n );\n\n const rankUp = async (): Promise<void> => {\n setBusy(true);\n const res = await client.character.upgradeCharacterLevel(hero.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(card.current);\n };\n\n const four = stats.slice(0, 4);\n const statGlyph = (s: StatView): \"sword\" | \"heart\" | \"shield\" | \"crown\" =>\n /hp|health|life|vit/i.test(s.id)\n ? \"heart\"\n : /def|arm|block/i.test(s.id)\n ? \"shield\"\n : /att|dam|dmg|str/i.test(s.id)\n ? \"sword\"\n : \"crown\";\n\n return (\n <KitPortal>\n <div\n ref={host}\n style={{\n position: \"fixed\",\n inset: 0,\n // Below the kit's popups (20): STATS opens over the card, like Character_Stat_Popup.\n zIndex: 15,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n opacity: shown ? 1 : 0,\n transform: shown ? \"scale(1)\" : \"scale(1.06, .94)\",\n transition:\n \"opacity .22s ease, transform .3s cubic-bezier(.34,1.56,.64,1)\",\n // the hero's rarity colours the whole card (Common blue, Rare green, Epic purple…)\n background: `radial-gradient(ellipse at 50% 30%, color-mix(in srgb, ${rarity} 55%, #fff) 0%, ${rarity} 55%, color-mix(in srgb, ${rarity} 62%, #000) 100%)`,\n }}\n >\n <MovingPattern />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: \"50%\",\n width: W,\n height: DH,\n marginLeft: -W / 2,\n marginTop: -DH / 2,\n transform: `scale(${scale})`,\n }}\n >\n {/* Rarity label + name */}\n <div\n style={{\n ...dAt(0, 890, 220, 66),\n background: rarity,\n transform: \"skewX(-14deg)\",\n border: `5px solid ${INK}`,\n borderRadius: 10,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ ...text(30), transform: \"skewX(14deg)\" }}>\n {(rarityID || \"—\").toUpperCase()}\n </span>\n </div>\n <div\n style={{\n ...dAt(0, 805, 900, 113),\n display: \"grid\",\n placeItems: \"center\",\n ...text(90),\n }}\n >\n {catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}\n </div>\n {/* The hero */}\n <div\n style={{\n ...dAt(-2, 104, 220, 56),\n borderRadius: \"50%\",\n background: \"rgba(0,0,0,.2)\",\n }}\n />\n <div\n ref={card}\n style={{\n ...dAt(0, 416, 480, 640),\n animation: bob,\n transformOrigin: \"50% 100%\",\n }}\n >\n <HeroFigure\n hero={hero}\n color={classColor(hero.def.Classification?.ClassID)}\n />\n </div>\n {/* Rank badge, stars, class */}\n <div\n style={{\n ...dAt(-208, 17, 102, 107),\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <svg\n viewBox=\"0 0 102 107\"\n style={{ position: \"absolute\", inset: 0 }}\n aria-hidden\n >\n <path\n d=\"M51 4 L94 26 L94 76 L51 103 L8 76 L8 26 Z\"\n fill=\"#2f86f0\"\n stroke={INK}\n strokeWidth=\"6\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M51 14 L84 32 L84 50 L18 50 L18 32 Z\"\n fill=\"rgba(255,255,255,.25)\"\n />\n </svg>\n <span style={{ position: \"relative\", ...text(52) }}>\n {hero.rank}\n </span>\n </div>\n <div\n style={{\n ...dAt(0, 17, 300, 78),\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n {Array.from({ length: 6 }, (_, k) => (\n <svg\n key={k}\n viewBox=\"0 0 71 76\"\n width={56}\n height={60}\n aria-hidden\n >\n <path\n d=\"M35.5 4 L45 26 L68 28 L50 44 L56 68 L35.5 55 L15 68 L21 44 L3 28 L26 26 Z\"\n fill={k < stars ? \"#ffd23a\" : \"rgba(16,23,46,.45)\"}\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n ))}\n </div>\n {hero.def.Classification?.ClassID ? (\n <div\n style={{\n ...dAt(195, 17, 78, 78),\n borderRadius: \"50%\",\n border: `6px solid ${INK}`,\n background: classColor(hero.def.Classification.ClassID),\n display: \"grid\",\n placeItems: \"center\",\n ...text(30),\n }}\n >\n {hero.def.Classification.ClassID.slice(0, 1).toUpperCase()}\n </div>\n ) : null}\n\n {/* Four stats */}\n {four.map((s, i) => (\n <div\n key={s.id}\n style={{\n ...dAt(i % 2 === 0 ? -230 : 230, i < 2 ? -232 : -392, 410, 115),\n borderRadius: 26,\n border: `5px solid ${INK}`,\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n boxShadow: \"inset 0 6px 0 rgba(255,255,255,.2)\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 16,\n padding: \"0 18px\",\n boxSizing: \"border-box\",\n }}\n >\n <StatGlyph kind={statGlyph(s)} size={86} />\n <div style={{ display: \"grid\", minWidth: 0 }}>\n <span\n style={{\n ...text(34),\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {s.name}\n </span>\n <span style={text(52)}>{value(s)}</span>\n </div>\n </div>\n ))}\n {/* Lore */}\n <div\n style={{\n ...dAt(0, -606, 870, 220),\n borderRadius: 26,\n border: `5px solid ${INK}`,\n background: \"linear-gradient(180deg,#b3c2d6,#9fb0c8)\",\n boxShadow: \"inset 0 6px 0 rgba(255,255,255,.45)\",\n padding: \"18px 34px\",\n boxSizing: \"border-box\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr\",\n justifyItems: \"center\",\n gap: 8,\n }}\n >\n <span style={text(38)}>{t(\"lore\")}</span>\n <span\n style={{\n fontSize: 30,\n fontWeight: 700,\n color: \"#1c2340\",\n textAlign: \"center\",\n lineHeight: 1.25,\n whiteSpace: \"normal\",\n }}\n >\n {catalog.localize(hero.def.Identity?.Description ?? \"\") || \"—\"}\n </span>\n </div>\n {/* Upgrade Level (= rank up) and STATS */}\n {hero.state !== \"locked\" ? (\n <>\n <UButton\n style={dAt(-220, -810, 400, 175)}\n tone=\"green\"\n busy={busy}\n disabled={nextRank === null || !affordable}\n attract={affordable}\n onClick={() => void rankUp()}\n data-tutorial-anchor=\"heroes:rank-up\"\n >\n <span\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <span\n style={text(\n fitText(\n nextRank === null ? t(\"maxRank\") : t(\"upgradeLevel\"),\n 360,\n 40,\n ),\n )}\n >\n {nextRank === null ? t(\"maxRank\") : t(\"upgradeLevel\")}\n </span>\n {nextRank && nextRank.length ? (\n <span\n style={{\n transform: \"scale(2.3)\",\n transformOrigin: \"50% 0\",\n marginBottom: 30,\n }}\n >\n <ResourceList\n lines={nextRank}\n size={22}\n dimIf={() => !affordable}\n />\n </span>\n ) : nextRank ? (\n <span style={text(40)}>{t(\"free\")}</span>\n ) : null}\n </span>\n </UButton>\n <UButton\n style={dAt(220, -810, 400, 175)}\n tone=\"yellow\"\n size={fitText(t(\"statsCaps\"), 360, 55)}\n onClick={onStats}\n >\n {t(\"statsCaps\")}\n </UButton>\n </>\n ) : null}\n {/* Bottom bar with the round close button */}\n <div\n style={{\n ...dAt(0, -1037, W, 266),\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.nav} 85%, #fff) 0%, ${v.nav} 100%)`,\n borderTop: `6px solid ${INK}`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={close}\n style={{\n width: 117,\n height: 118,\n borderRadius: \"50%\",\n border: `6px solid ${INK}`,\n background: \"linear-gradient(180deg,#5d6a92,#3b4568)\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.25)\",\n cursor: \"pointer\",\n ...text(56),\n lineHeight: 1,\n fontFamily: \"inherit\",\n }}\n >\n ✕\n </button>\n </div>\n </div>\n </div>\n </KitPortal>\n );\n}\n\nconst HERO_CSS = `\n@keyframes idos-hero-idle { 0%,100% { transform: scale(1, 1); } 50% { transform: scale(1.025, .975); } }\n@keyframes idos-hero-pop { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }\n@keyframes idos-hero-attract { 0%,100% { transform: scale(1); } 50% { transform: scale(1.05); } }\n`;\n"
|
|
66
|
+
"content": "import {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n type CharacterArtRegistry,\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport {\n Badge,\n EmptyState,\n ItemTile,\n KitPortal,\n MovingPattern,\n ResourceList,\n canAfford,\n errorText,\n formatAmount,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n v,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport {\n bestLoadout,\n breakdown,\n rankCost,\n resolveLadder,\n resolveSlots,\n resolveStats,\n roster,\n statNumber,\n unequipAllSlots,\n type GearPiece,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n type StatView,\n} from \"./model\";\nimport { t } from \"./i18n\";\nimport { readSelected, resolveSelected, saveSelected } from \"./selection\";\nimport { HeroesPopup } from \"./components/HeroesPopup\";\nimport { PiecePopup } from \"./components/PiecePopup\";\nimport { SkinsPopup } from \"./components/SkinsPopup\";\nimport { SlotPopup } from \"./components/SlotPopup\";\nimport { StatsPopup } from \"./components/StatsPopup\";\nimport { useData } from \"./components/useData\";\nimport {\n CharacterArtContext,\n HeroFigure,\n useCharacterArt,\n} from \"./components/HeroFigure\";\nimport {\n LockArt,\n SlotSilhouette,\n classColor,\n slotKind,\n type SlotKind,\n} from \"./components/art\";\n\n// Heroes in the reference's look (BoardSurvival BoardScene: Equipment = EquipmentPanel,\n// Character_Detail = CharacterDetailPanel, Character_List, Character_Stat_Popup, Popup_EquipmentItem).\n// The main screen is the equipment hub: the hero in the middle of a blue backdrop with three gear\n// slots on each side, Damage and Health under the name, Detail / Unequip All / Equip All, and below\n// a bar of category tabs over the gear the player owns. Detail opens the hero's card (rarity, rank,\n// stats, lore, Upgrade Level, STATS). Everything is laid out in the reference's 1080-wide canvas\n// units and scaled to the screen, so the numbers come straight from the scene.\n\nconst W = 1080;\n// The ink of the outlines — a literal: SVG attributes below cannot read a CSS variable.\nconst INK = \"#17193a\";\n\nexport function makeCharacterScreen(\n features: FeatureRegistry,\n onSelect: (characterId: string) => void,\n art: CharacterArtRegistry,\n) {\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <CharacterArtContext.Provider value={art}>\n <Heroes\n features={features}\n onSelect={onSelect}\n openInstance={\n typeof args?.itemInstanceID === \"string\"\n ? args.itemInstanceID\n : null\n }\n />\n </CharacterArtContext.Provider>\n );\n };\n}\n\ntype PopupState =\n | { kind: \"heroes\" }\n | { kind: \"stats\" }\n | { kind: \"skins\" }\n | { kind: \"detail\" }\n | { kind: \"detailStats\" }\n | { kind: \"slot\"; slot: SlotView }\n | { kind: \"piece\"; instanceID: string }\n | null;\n\n/** Slot positions (Group_Slots): weapon, ring, shoes on the left; helmet, torso, pants on the right. */\nconst SLOT_POS: Array<{ kind: SlotKind; x: number; y: number }> = [\n { kind: \"weapon\", x: -400, y: 210 },\n { kind: \"ring\", x: -400, y: 470 },\n { kind: \"shoes\", x: -400, y: 730 },\n { kind: \"helmet\", x: 400, y: 210 },\n { kind: \"torso\", x: 400, y: 470 },\n { kind: \"pants\", x: 400, y: 730 },\n];\n\n/** Places the hero's slots: each on its kind's spot, the rest on the free spots in order. */\nfunction placeSlots(\n slots: SlotView[],\n): Array<{ slot: SlotView; x: number; y: number; kind: SlotKind }> {\n const free = [...SLOT_POS];\n const placed: Array<{\n slot: SlotView;\n x: number;\n y: number;\n kind: SlotKind;\n }> = [];\n const rest: SlotView[] = [];\n for (const slot of slots) {\n const kind = slotKind(slot.id);\n const i = free.findIndex((p) => p.kind === kind);\n if (i >= 0) {\n const p = free.splice(i, 1)[0]!;\n placed.push({ slot, x: p.x, y: p.y, kind });\n } else rest.push(slot);\n }\n for (const slot of rest) {\n const p = free.shift();\n if (!p) break;\n placed.push({\n slot,\n x: p.x,\n y: p.y,\n kind: slotKind(slot.id) === \"other\" ? p.kind : slotKind(slot.id),\n });\n }\n return placed;\n}\n\n/** Damage and Health under the name (Stat_Attack / Stat_Hp), by the hero's stat ids. */\nfunction headlineStats(\n stats: StatView[],\n): [StatView | undefined, StatView | undefined] {\n const attack = stats.find(\n (s) => /att|dam|dmg|str|pow/i.test(s.id) || /att|dam/i.test(s.name),\n );\n const hp = stats.find(\n (s) => /^hp|health|life|vit/i.test(s.id) || /hp|health/i.test(s.name),\n );\n return [\n attack ?? stats[0],\n hp ?? stats.find((s) => s !== (attack ?? stats[0])),\n ];\n}\n\nfunction Heroes({\n features,\n onSelect,\n openInstance,\n}: {\n features: FeatureRegistry;\n onSelect: (characterId: string) => void;\n openInstance: string | null;\n}): ReactNode {\n const { client, section, owned, items, pieces, balances } = useData();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const heroRef = useRef<HTMLDivElement>(null);\n // the game's own hero moves by itself; the screen's picture bobs\n const bob = useCharacterArt()\n ? undefined\n : \"idos-hero-idle 2.4s ease-in-out infinite\";\n const [popup, setPopup] = useState<PopupState>(\n openInstance ? { kind: \"piece\", instanceID: openInstance } : null,\n );\n const [busy, setBusy] = useState<string | null>(null);\n const [selectedID, setSelectedID] = useState<string | null>(readSelected);\n const [tab, setTab] = useState<string>(\"all\");\n\n const list = roster(section, owned);\n if (list.length === 0)\n return <EmptyState glyph=\"hero\" text={t(\"noHeroes\")} />;\n const hero = resolveSelected(list, selectedID)!;\n const select = (id: string) => {\n setSelectedID(id);\n saveSelected(id);\n onSelect(id);\n };\n\n const slots = resolveSlots(section, hero.def);\n const changes =\n hero.state === \"locked\"\n ? []\n : bestLoadout(\n hero.id,\n hero.rank,\n slots,\n pieces,\n hero.model?.Equipment,\n items,\n );\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\n const heroRarity = catalog.rarityColor(\n hero.def.Classification?.RarityID ?? \"\",\n );\n // The row of actions under the stats shares one width: three buttons get wider plates than four.\n const actions = hero.state === \"locked\" ? 1 : skinCount > 0 ? 4 : 3;\n const actionAt = (i: number): CSSProperties => {\n const gap = 20;\n const w = actions === 4 ? 250 : actions === 3 ? 320 : 340;\n const row = actions * w + (actions - 1) * gap;\n return center(-row / 2 + w / 2 + i * (w + gap), 971, w, 120);\n };\n const color = classColor(hero.def.Classification?.ClassID);\n const stats = resolveStats(section, hero.def);\n const worn = wornGear(hero, pieces, items);\n const levels = hero.model?.StatLevels ?? {};\n const value = (s: StatView | undefined): string =>\n s\n ? statNumber(\n breakdown(s, Number(levels[s.id] ?? 0), hero.rank, hero.def, worn)\n .total,\n )\n : \"—\";\n const [atk, hp] = headlineStats(stats);\n\n const equipAll = async () => {\n if (changes.length === 0) return;\n setBusy(\"best\");\n const res = await client.character.equipItems(hero.id, changes);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(heroRef.current);\n };\n\n // Always enabled (Unity EquipmentPanel.OnUnequipAllClicked): the way out of a local/server mismatch.\n const unequipAll = async () => {\n setBusy(\"off\");\n const slotIDs = unequipAllSlots(\n hero.id,\n slots,\n hero.model?.Equipment,\n pieces,\n );\n const res =\n slotIDs.length > 0\n ? await client.character.unequipItems(hero.id, slotIDs)\n : null;\n await client.cache.ensureState([\"Character\", \"InventoryV2\"], {\n force: true,\n });\n setBusy(null);\n if (res && !res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"click\");\n };\n\n // Category tabs (EquipmentCategoryTab): All, then one per slot kind the gear covers.\n const bagPieces = pieces.filter((p) => !p.equippedBy);\n const kindOf = (p: GearPiece): SlotKind =>\n slotKind(items.get(p.itemID)?.ItemClass ?? p.itemID);\n const tabs: Array<{ id: string; kind: SlotKind }> = [\n { id: \"all\", kind: \"bag\" },\n ...(\n [\n \"weapon\",\n \"ring\",\n \"shoes\",\n \"helmet\",\n \"torso\",\n \"pants\",\n \"other\",\n ] as SlotKind[]\n )\n .filter(\n (k) =>\n bagPieces.some((p) => kindOf(p) === k) ||\n placeSlots(slots).some((s) => s.kind === k),\n )\n .map((k) => ({ id: k, kind: k })),\n ];\n const shown =\n tab === \"all\" ? bagPieces : bagPieces.filter((p) => kindOf(p) === tab);\n const heroesBadge = list.filter(\n (h) =>\n h.state === \"locked\" &&\n h.unlockPrice.length > 0 &&\n canAfford(h.unlockPrice, balances),\n ).length;\n\n return (\n // fullBleed: the lobby gives this screen its whole area; the gear below the stage fills the rest\n <div style={{ flex: \"1 0 auto\", display: \"flex\", flexDirection: \"column\" }}>\n {/* ── Middle: the hero on the blue backdrop (the backdrop spans the screen, the canvas fits it) ── */}\n <div\n style={{\n position: \"relative\",\n overflow: \"hidden\",\n // the stage runs under the lobby's top row (fullBleed) and keeps the hero below it\n paddingTop: \"var(--idos-lobby-top, 0px)\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n // the shown hero's rarity colours the stage, as on its card (Common blue, Rare green…)\n background: `radial-gradient(ellipse at 50% 35%, color-mix(in srgb, ${heroRarity} 60%, #fff) 0%, ${heroRarity} 45%, color-mix(in srgb, ${heroRarity} 62%, #000) 100%)`,\n }}\n />\n <MovingPattern />\n <Fit height={1111}>\n <div style={{ position: \"relative\", height: 1111 }}>\n <div\n style={{\n ...center(0, 603, 174, 45),\n borderRadius: \"50%\",\n background: \"rgba(0,0,0,.2)\",\n }}\n />\n <div\n ref={heroRef}\n onClick={() => setPopup({ kind: \"heroes\" })}\n style={{\n ...center(0, 355, 384, 512),\n cursor: \"pointer\",\n animation: bob,\n transformOrigin: \"50% 100%\",\n // over the slots: a long weapon reaches past them (the game's figure lets clicks through)\n zIndex: 1,\n filter:\n hero.state === \"locked\"\n ? \"grayscale(1) brightness(.7)\"\n : undefined,\n }}\n >\n <HeroFigure hero={hero} color={color} />\n {heroesBadge > 0 ? (\n <span\n style={{\n position: \"absolute\",\n right: 50,\n top: 20,\n transform: \"scale(2.6)\",\n transformOrigin: \"100% 0\",\n }}\n >\n <Badge count={heroesBadge} />\n </span>\n ) : null}\n </div>\n <div\n style={{\n ...center(0, 665, 700, 81),\n display: \"grid\",\n placeItems: \"center\",\n ...text(64),\n }}\n >\n {heroName}\n </div>\n\n {placeSlots(slots).map(({ slot, x, y, kind }) => (\n <SlotFrame\n key={slot.id}\n style={center(x, y, 210, 210)}\n kind={kind}\n slot={slot}\n hero={hero}\n pieces={pieces}\n onClick={() => setPopup({ kind: \"slot\", slot })}\n />\n ))}\n\n <HeadStat\n style={center(-91, 809, 234, 103)}\n glyph=\"sword\"\n label={atk?.name ?? t(\"power\")}\n value={atk ? value(atk) : formatAmount(hero.power)}\n />\n <HeadStat\n style={center(142.5, 809, 234, 103)}\n glyph=\"heart\"\n label={hp?.name ?? t(\"rank\")}\n value={hp ? value(hp) : String(hero.rank)}\n />\n\n <UButton\n style={actionAt(0)}\n tone=\"purple\"\n onClick={() => setPopup({ kind: \"detail\" })}\n >\n {t(\"detail\")}\n </UButton>\n {hero.state !== \"locked\" ? (\n <>\n <UButton\n style={actionAt(1)}\n tone=\"sky\"\n busy={busy === \"off\"}\n onClick={() => void unequipAll()}\n >\n {t(\"unequipAll\")}\n </UButton>\n <UButton\n style={actionAt(2)}\n tone=\"yellow\"\n busy={busy === \"best\"}\n disabled={changes.length === 0}\n attract={changes.length > 0}\n onClick={() => void equipAll()}\n data-tutorial-anchor=\"heroes:equip-best\"\n >\n {t(\"equipAll\")}\n </UButton>\n {skinCount > 0 ? (\n <UButton\n style={actionAt(3)}\n tone=\"yellow\"\n onClick={() => setPopup({ kind: \"skins\" })}\n >\n {t(\"skins\")}\n </UButton>\n ) : null}\n </>\n ) : null}\n </div>\n </Fit>\n </div>\n\n {/* ── Category tabs (Menu_MiddleBtn) ── */}\n <div\n style={{\n height: 58,\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.nav} 85%, #fff) 0%, ${v.nav} 100%)`,\n borderTop: `3px solid ${INK}`,\n boxShadow: \"inset 0 4px 0 rgba(255,255,255,.08)\",\n display: \"flex\",\n justifyContent: \"center\",\n overflowX: \"auto\",\n // only sideways: the 56px tabs under the 3px line overflow by a hair, and auto here drew\n // a dark scrollbar at the right edge\n overflowY: \"hidden\",\n }}\n className=\"idos-scroll\"\n >\n {tabs.map((tb) => (\n <button\n key={tb.id}\n type=\"button\"\n onClick={() => {\n kit.play(\"tab\");\n setTab(tb.id);\n }}\n style={{\n flex: \"0 1 88px\",\n minWidth: 52,\n height: 56,\n border: \"none\",\n background:\n tab === tb.id ? \"rgba(255,255,255,.07)\" : \"transparent\",\n position: \"relative\",\n cursor: \"pointer\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ marginBottom: 6 }}>\n <SlotSilhouette\n kind={tb.kind}\n size={34}\n opacity={tab === tb.id ? 1 : 0.55}\n />\n </span>\n {tab === tb.id ? (\n <span\n style={{\n position: \"absolute\",\n left: 8,\n right: 8,\n bottom: 5,\n height: 8,\n borderRadius: 4,\n background: v.blue,\n boxShadow: `0 0 8px ${v.blue}`,\n }}\n />\n ) : null}\n </button>\n ))}\n </div>\n\n {/* ── The gear (Scroll View) ── */}\n <div\n style={{\n background: `color-mix(in srgb, ${v.nav} 70%, ${v.ink})`,\n padding: \"14px 12px 22px\",\n minHeight: 180,\n flex: \"1 0 auto\",\n }}\n >\n {shown.length === 0 ? (\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 14,\n padding: \"22px 16px\",\n }}\n >\n <div\n style={{\n ...text(15),\n opacity: 0.75,\n whiteSpace: \"normal\",\n textAlign: \"center\",\n maxWidth: 420,\n }}\n >\n {t(\"noGear\")}\n </div>\n {features.get(\"store\") ? (\n <UButton\n style={{ position: \"relative\", width: 140, height: 50 }}\n size={20}\n tone=\"green\"\n onClick={() => features.open(\"store\")}\n >\n {catalog.localize(\"Shop\")}\n </UButton>\n ) : null}\n </div>\n ) : (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(84px, 1fr))\",\n gap: 12,\n justifyItems: \"center\",\n maxWidth: 900,\n margin: \"0 auto\",\n }}\n >\n {shown.map((p, i) => (\n <div\n key={p.instanceID}\n style={{\n animation: `idos-hero-pop .3s cubic-bezier(.34,1.56,.64,1) ${Math.min(i, 12) * 0.03}s both`,\n }}\n >\n <ItemTile\n itemID={p.itemID}\n level={p.level}\n size={80}\n onClick={() =>\n setPopup({ kind: \"piece\", instanceID: p.instanceID })\n }\n />\n </div>\n ))}\n </div>\n )}\n </div>\n\n <style>{HERO_CSS}</style>\n\n {popup?.kind === \"detail\" || popup?.kind === \"detailStats\" ? (\n <DetailWindow\n hero={hero}\n stats={stats}\n value={value}\n balances={balances}\n section={section}\n onStats={() => setPopup({ kind: \"detailStats\" })}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"heroes\" ? (\n <HeroesPopup\n list={list}\n current={hero.id}\n balances={balances}\n onSelect={select}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"stats\" || popup?.kind === \"detailStats\" ? (\n <StatsPopup\n hero={hero}\n section={section}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() =>\n setPopup(popup.kind === \"detailStats\" ? { kind: \"detail\" } : null)\n }\n />\n ) : null}\n {popup?.kind === \"skins\" ? (\n <SkinsPopup hero={hero} onClose={() => setPopup(null)} />\n ) : null}\n {popup?.kind === \"slot\" ? (\n <SlotPopup\n slot={popup.slot}\n hero={hero}\n pieces={pieces}\n items={items}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"piece\" ? (\n <PiecePopup\n instanceID={popup.instanceID}\n hero={hero}\n slots={slots}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction wornGear(\n hero: RosterEntry,\n pieces: GearPiece[],\n items: Map<string, ItemDefWithGear>,\n): Array<{ def: ItemDefWithGear | undefined; level: number }> {\n return Object.values(hero.model?.Equipment ?? {})\n .filter((e) => e?.ItemInstanceID)\n .map((e) => {\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\n });\n}\n\n// ── Layout helpers ───────────────────────────────────────────────────────────────────────────\n\n/**\n * The 1080-wide canvas of a section, scaled to fit both the width AND a share of the window height\n * (a phone fits it by width, a wide screen by height) and centred — the backdrop behind spans the\n * whole width, so a wide screen never shows a giant stretched hero nor a strip of empty page.\n */\nfunction Fit({\n height,\n children,\n}: {\n height: number;\n children: ReactNode;\n}): ReactNode {\n const outer = useRef<HTMLDivElement>(null);\n const [scale, setScale] = useState(0.35);\n useLayoutEffect(() => {\n const o = outer.current;\n if (!o) return;\n const fit = (): void => {\n const byWidth = o.clientWidth / W;\n const byHeight = (window.innerHeight * 0.56) / height;\n const s = Math.max(0.22, Math.min(byWidth, byHeight, 0.9));\n if (s > 0) setScale(s);\n };\n fit();\n const ro = new ResizeObserver(fit);\n ro.observe(o);\n window.addEventListener(\"resize\", fit);\n return () => {\n ro.disconnect();\n window.removeEventListener(\"resize\", fit);\n };\n }, [height]);\n return (\n <div\n ref={outer}\n style={{ position: \"relative\", width: \"100%\", height: height * scale }}\n >\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: 0,\n width: W,\n height,\n marginLeft: -W / 2,\n transform: `scale(${scale})`,\n transformOrigin: \"50% 0\",\n }}\n >\n {children}\n </div>\n </div>\n );\n}\n\n/** A box centred at (x, y): x from the canvas centre, y from the top of its section. */\nfunction center(x: number, y: number, w: number, h: number): CSSProperties {\n return {\n position: \"absolute\",\n left: W / 2 + x - w / 2,\n top: y - h / 2,\n width: w,\n height: h,\n };\n}\n\n/** A font size that fits `label` into `width` canvas units (long translations shrink, never wrap). */\nfunction fitText(label: string, width: number, max: number): number {\n return Math.min(max, Math.floor(width / Math.max(1, label.length * 0.62)));\n}\n\n/** The reference's outlined white game text. */\nfunction text(size: number, color = \"#fff\"): CSSProperties {\n const w = Math.max(2, Math.round(size / 14));\n return {\n color,\n fontSize: size,\n fontWeight: 900,\n lineHeight: 1.1,\n WebkitTextStroke: `${w}px ${INK}`,\n paintOrder: \"stroke fill\",\n whiteSpace: \"nowrap\",\n };\n}\n\nconst TONES: Record<string, [string, string, string]> = {\n purple: [\"#b46cff\", \"#8a3cf0\", \"#5a1fb0\"],\n sky: [\"#5cc8ff\", \"#1c8fe6\", \"#0d5aa8\"],\n yellow: [\"#ffd84a\", \"#f5a90c\", \"#a8650a\"],\n green: [\"#7be34a\", \"#33b31d\", \"#1f7a10\"],\n};\n\n/** Button01 (purple / sky / yellow / green): a glossy plate, dark outline, a bottom lip. */\nfunction UButton({\n style,\n tone,\n size = 42,\n busy,\n disabled,\n attract,\n onClick,\n children,\n ...rest\n}: {\n style: CSSProperties;\n tone: keyof typeof TONES;\n size?: number;\n busy?: boolean;\n disabled?: boolean;\n attract?: boolean;\n onClick: () => void;\n children: ReactNode;\n \"data-tutorial-anchor\"?: string;\n}): ReactNode {\n const play = useSound();\n const [top, bottom] = TONES[tone]!;\n const off = disabled || busy;\n return (\n <button\n type=\"button\"\n {...rest}\n disabled={off}\n onClick={() => {\n play(\"click\");\n onClick();\n }}\n style={{\n ...style,\n borderRadius: 34,\n border: `6px solid ${INK}`,\n background: `linear-gradient(180deg, ${top} 0%, ${bottom} 100%)`,\n boxShadow: `inset 0 8px 0 rgba(255,255,255,.4)`,\n cursor: off ? \"default\" : \"pointer\",\n filter: disabled ? \"grayscale(.55) brightness(.85)\" : undefined,\n animation: attract\n ? \"idos-hero-attract 1.6s ease-in-out infinite\"\n : undefined,\n fontFamily: \"inherit\",\n padding: \"0 28px\",\n ...text(size),\n }}\n >\n <FitLabel size={size}>{busy ? \"…\" : children}</FitLabel>\n </button>\n );\n}\n\n/**\n * What never runs past its button: a label longer than the room (\"Подробнее\", \"ПОВЫСИТЬ УРОВЕНЬ\"\n * over its price) is scaled down as a whole — whatever font sizes its own parts set — instead of\n * spilling over the plate's edges, and stays centred. Re-measured when the content, the size or the\n * button's width changes, and once the font has loaded.\n */\nfunction FitLabel({\n size,\n children,\n}: {\n size: number;\n children: ReactNode;\n}): ReactNode {\n const ref = useRef<HTMLSpanElement>(null);\n useLayoutEffect(() => {\n const el = ref.current;\n const box = el?.parentElement;\n if (!el || !box) return;\n const fit = () => {\n el.style.transform = \"\";\n el.style.marginLeft = el.style.marginRight = \"\";\n const cs = getComputedStyle(box);\n const room =\n box.clientWidth -\n parseFloat(cs.paddingLeft) -\n parseFloat(cs.paddingRight);\n const natural = el.offsetWidth;\n if (natural <= room || room <= 0) return;\n el.style.transform = `scale(${room / natural})`;\n // the layout box shrinks to the room too, so the scaled label sits in the middle\n el.style.marginLeft = el.style.marginRight = `${-(natural - room) / 2}px`;\n };\n fit();\n const ro =\n typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(fit);\n ro?.observe(box);\n void document.fonts?.ready.then(fit);\n return () => ro?.disconnect();\n }, [size, children]);\n return (\n <span\n ref={ref}\n style={{\n display: \"inline-block\",\n whiteSpace: \"nowrap\",\n transformOrigin: \"50% 50%\",\n }}\n >\n {children}\n </span>\n );\n}\n\n/** An equipment slot (EquipmentSlotView): a blue frame, the slot's white silhouette, a lock. */\nfunction SlotFrame({\n style,\n kind,\n slot,\n hero,\n pieces,\n onClick,\n}: {\n style: CSSProperties;\n kind: SlotKind;\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n onClick: () => void;\n}): ReactNode {\n const play = useSound();\n const worn = hero.model?.Equipment?.[slot.id];\n const piece = worn?.ItemInstanceID\n ? pieces.find((p) => p.instanceID === worn.ItemInstanceID)\n : undefined;\n const locked = hero.rank < slot.minRank;\n return (\n <div style={style}>\n {worn?.ItemID ? (\n <ItemTile\n itemID={worn.ItemID}\n level={piece?.level}\n size={210}\n onClick={onClick}\n />\n ) : (\n <button\n type=\"button\"\n onClick={() => {\n play(\"click\");\n onClick();\n }}\n style={{\n width: \"100%\",\n height: \"100%\",\n borderRadius: 40,\n border: `6px solid ${INK}`,\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.panel} 75%, #fff) 0%, ${v.panel} 60%, ${v.panelDeep} 100%)`,\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.28)\",\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n position: \"relative\",\n }}\n >\n <SlotSilhouette kind={kind} size={130} opacity={0.39} />\n {locked ? (\n <span\n style={{\n position: \"absolute\",\n left: 105 + 77 - 25,\n top: 105 + 65 - 35,\n }}\n >\n <LockArt size={71} />\n </span>\n ) : null}\n </button>\n )}\n {locked ? (\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: -44,\n textAlign: \"center\",\n ...text(28),\n }}\n >\n {t(\"slotLocked\")} {slot.minRank}\n </div>\n ) : null}\n </div>\n );\n}\n\n/** Stat_Attack / Stat_Hp: an icon, the stat's name and its value. */\nfunction HeadStat({\n style,\n glyph,\n label,\n value,\n}: {\n style: CSSProperties;\n glyph: \"sword\" | \"heart\";\n label: string;\n value: string;\n}): ReactNode {\n return (\n <div style={{ ...style, display: \"flex\", alignItems: \"center\", gap: 14 }}>\n <StatGlyph kind={glyph} size={78} />\n <div style={{ display: \"grid\" }}>\n <span style={text(30)}>{label}</span>\n <span style={text(44)}>{value}</span>\n </div>\n </div>\n );\n}\n\nfunction StatGlyph({\n kind,\n size,\n}: {\n kind: \"sword\" | \"heart\" | \"shield\" | \"crown\";\n size: number;\n}): ReactNode {\n const shapes = {\n sword: (\n <g stroke={INK} strokeWidth=\"5\" strokeLinejoin=\"round\">\n <path d=\"M60 6 L70 16 L32 54 L24 46 Z\" fill=\"#dfe8f5\" />\n <path d=\"M20 40 L38 58 L32 64 L14 46 Z\" fill=\"#ffc21a\" />\n <path d=\"M14 56 L22 64 L10 76 L2 68 Z\" fill=\"#8a5a2b\" />\n </g>\n ),\n heart: (\n <path\n d=\"M39 70 L10 40 Q0 28 8 16 Q18 4 32 12 Q36 15 39 20 Q42 15 46 12 Q60 4 70 16 Q78 28 68 40 Z\"\n fill=\"#f0353b\"\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n ),\n shield: (\n <path\n d=\"M39 4 L70 16 Q70 52 39 74 Q8 52 8 16 Z\"\n fill=\"#3d8cf0\"\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n ),\n crown: (\n <path\n d=\"M8 60 L8 22 L24 36 L39 12 L54 36 L70 22 L70 60 Z\"\n fill=\"#ffd23a\"\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n ),\n };\n return (\n <svg\n viewBox=\"0 0 78 78\"\n width={size}\n height={size}\n aria-hidden\n style={{ flex: \"none\" }}\n >\n {shapes[kind]}\n </svg>\n );\n}\n\n// ── Character_Detail (CharacterDetailPanel) ─────────────────────────────────────────────────\n\nconst DH = 2340;\n/** A box in the detail canvas: x from the centre, y up from the centre (Unity), canvas 1080 × 2340. */\nfunction dAt(x: number, y: number, w: number, h: number): CSSProperties {\n return {\n position: \"absolute\",\n left: W / 2 + x - w / 2,\n top: DH / 2 - y - h / 2,\n width: w,\n height: h,\n };\n}\n\nfunction DetailWindow({\n hero,\n stats,\n value,\n balances,\n section,\n onStats,\n onClose,\n}: {\n hero: RosterEntry;\n stats: StatView[];\n value: (s: StatView | undefined) => string;\n balances: Record<string, { Amount?: number }> | undefined;\n section: Parameters<typeof resolveLadder>[0];\n onStats: () => void;\n onClose: () => void;\n}): ReactNode {\n const { client } = useData();\n const catalog = useCatalog();\n const kit = useUiKit();\n const toast = useToast();\n const host = useRef<HTMLDivElement>(null);\n const card = useRef<HTMLDivElement>(null);\n const bob = useCharacterArt()\n ? undefined\n : \"idos-hero-idle 2.4s ease-in-out infinite\";\n const [scale, setScale] = useState(0.35);\n const [shown, setShown] = useState(false);\n const [busy, setBusy] = useState(false);\n\n useLayoutEffect(() => {\n const el = host.current;\n if (!el) return;\n const fit = (): void =>\n setScale(Math.min(el.clientWidth / W, el.clientHeight / DH));\n fit();\n const ro = new ResizeObserver(fit);\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n useEffect(() => {\n kit.play(\"open\");\n const id = requestAnimationFrame(() => setShown(true));\n return () => cancelAnimationFrame(id);\n }, []);\n\n const close = (): void => {\n kit.play(\"close\");\n setShown(false);\n setTimeout(onClose, 220);\n };\n\n const ladder = resolveLadder(section, hero.def);\n const nextRank = rankCost(ladder, hero.rank);\n const affordable = nextRank !== null && canAfford(nextRank, balances);\n const rarityID = hero.def.Classification?.RarityID ?? \"\";\n const rarity = catalog.rarityColor(rarityID);\n const maxRank = Number(ladder?.MaxRank ?? 0);\n const stars = Math.max(\n 0,\n Math.min(\n 6,\n maxRank > 0\n ? Math.round((hero.rank / maxRank) * 6)\n : Math.min(6, hero.rank),\n ),\n );\n\n const rankUp = async (): Promise<void> => {\n setBusy(true);\n // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.character.upgradeCharacterLevel(hero.id, { creditsConfirmationID }),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(card.current);\n };\n\n const four = stats.slice(0, 4);\n const statGlyph = (s: StatView): \"sword\" | \"heart\" | \"shield\" | \"crown\" =>\n /hp|health|life|vit/i.test(s.id)\n ? \"heart\"\n : /def|arm|block/i.test(s.id)\n ? \"shield\"\n : /att|dam|dmg|str/i.test(s.id)\n ? \"sword\"\n : \"crown\";\n\n return (\n <KitPortal>\n <div\n ref={host}\n style={{\n position: \"fixed\",\n inset: 0,\n // Below the kit's popups (20): STATS opens over the card, like Character_Stat_Popup.\n zIndex: 15,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n opacity: shown ? 1 : 0,\n transform: shown ? \"scale(1)\" : \"scale(1.06, .94)\",\n transition:\n \"opacity .22s ease, transform .3s cubic-bezier(.34,1.56,.64,1)\",\n // the hero's rarity colours the whole card (Common blue, Rare green, Epic purple…)\n background: `radial-gradient(ellipse at 50% 30%, color-mix(in srgb, ${rarity} 55%, #fff) 0%, ${rarity} 55%, color-mix(in srgb, ${rarity} 62%, #000) 100%)`,\n }}\n >\n <MovingPattern />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: \"50%\",\n width: W,\n height: DH,\n marginLeft: -W / 2,\n marginTop: -DH / 2,\n transform: `scale(${scale})`,\n }}\n >\n {/* Rarity label + name */}\n <div\n style={{\n ...dAt(0, 890, 220, 66),\n background: rarity,\n transform: \"skewX(-14deg)\",\n border: `5px solid ${INK}`,\n borderRadius: 10,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ ...text(30), transform: \"skewX(14deg)\" }}>\n {(rarityID || \"—\").toUpperCase()}\n </span>\n </div>\n <div\n style={{\n ...dAt(0, 805, 900, 113),\n display: \"grid\",\n placeItems: \"center\",\n ...text(90),\n }}\n >\n {catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}\n </div>\n {/* The hero */}\n <div\n style={{\n ...dAt(-2, 104, 220, 56),\n borderRadius: \"50%\",\n background: \"rgba(0,0,0,.2)\",\n }}\n />\n <div\n ref={card}\n style={{\n ...dAt(0, 416, 480, 640),\n animation: bob,\n transformOrigin: \"50% 100%\",\n }}\n >\n <HeroFigure\n hero={hero}\n color={classColor(hero.def.Classification?.ClassID)}\n />\n </div>\n {/* Rank badge, stars, class */}\n <div\n style={{\n ...dAt(-208, 17, 102, 107),\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <svg\n viewBox=\"0 0 102 107\"\n style={{ position: \"absolute\", inset: 0 }}\n aria-hidden\n >\n <path\n d=\"M51 4 L94 26 L94 76 L51 103 L8 76 L8 26 Z\"\n fill=\"#2f86f0\"\n stroke={INK}\n strokeWidth=\"6\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M51 14 L84 32 L84 50 L18 50 L18 32 Z\"\n fill=\"rgba(255,255,255,.25)\"\n />\n </svg>\n <span style={{ position: \"relative\", ...text(52) }}>\n {hero.rank}\n </span>\n </div>\n <div\n style={{\n ...dAt(0, 17, 300, 78),\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n {Array.from({ length: 6 }, (_, k) => (\n <svg\n key={k}\n viewBox=\"0 0 71 76\"\n width={56}\n height={60}\n aria-hidden\n >\n <path\n d=\"M35.5 4 L45 26 L68 28 L50 44 L56 68 L35.5 55 L15 68 L21 44 L3 28 L26 26 Z\"\n fill={k < stars ? \"#ffd23a\" : \"rgba(16,23,46,.45)\"}\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n ))}\n </div>\n {hero.def.Classification?.ClassID ? (\n <div\n style={{\n ...dAt(195, 17, 78, 78),\n borderRadius: \"50%\",\n border: `6px solid ${INK}`,\n background: classColor(hero.def.Classification.ClassID),\n display: \"grid\",\n placeItems: \"center\",\n ...text(30),\n }}\n >\n {hero.def.Classification.ClassID.slice(0, 1).toUpperCase()}\n </div>\n ) : null}\n\n {/* Four stats */}\n {four.map((s, i) => (\n <div\n key={s.id}\n style={{\n ...dAt(i % 2 === 0 ? -230 : 230, i < 2 ? -232 : -392, 410, 115),\n borderRadius: 26,\n border: `5px solid ${INK}`,\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n boxShadow: \"inset 0 6px 0 rgba(255,255,255,.2)\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 16,\n padding: \"0 18px\",\n boxSizing: \"border-box\",\n }}\n >\n <StatGlyph kind={statGlyph(s)} size={86} />\n <div style={{ display: \"grid\", minWidth: 0 }}>\n <span\n style={{\n ...text(34),\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {s.name}\n </span>\n <span style={text(52)}>{value(s)}</span>\n </div>\n </div>\n ))}\n {/* Lore */}\n <div\n style={{\n ...dAt(0, -606, 870, 220),\n borderRadius: 26,\n border: `5px solid ${INK}`,\n background: \"linear-gradient(180deg,#b3c2d6,#9fb0c8)\",\n boxShadow: \"inset 0 6px 0 rgba(255,255,255,.45)\",\n padding: \"18px 34px\",\n boxSizing: \"border-box\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr\",\n justifyItems: \"center\",\n gap: 8,\n }}\n >\n <span style={text(38)}>{t(\"lore\")}</span>\n <span\n style={{\n fontSize: 30,\n fontWeight: 700,\n color: \"#1c2340\",\n textAlign: \"center\",\n lineHeight: 1.25,\n whiteSpace: \"normal\",\n }}\n >\n {catalog.localize(hero.def.Identity?.Description ?? \"\") || \"—\"}\n </span>\n </div>\n {/* Upgrade Level (= rank up) and STATS */}\n {hero.state !== \"locked\" ? (\n <>\n <UButton\n style={dAt(-220, -810, 400, 175)}\n tone=\"green\"\n busy={busy}\n disabled={nextRank === null || !affordable}\n attract={affordable}\n onClick={() => void rankUp()}\n data-tutorial-anchor=\"heroes:rank-up\"\n >\n <span\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <span\n style={text(\n fitText(\n nextRank === null ? t(\"maxRank\") : t(\"upgradeLevel\"),\n 360,\n 40,\n ),\n )}\n >\n {nextRank === null ? t(\"maxRank\") : t(\"upgradeLevel\")}\n </span>\n {nextRank && nextRank.length ? (\n <span\n style={{\n transform: \"scale(2.3)\",\n transformOrigin: \"50% 0\",\n marginBottom: 30,\n }}\n >\n <ResourceList\n lines={nextRank}\n size={22}\n dimIf={() => !affordable}\n />\n </span>\n ) : nextRank ? (\n <span style={text(40)}>{t(\"free\")}</span>\n ) : null}\n </span>\n </UButton>\n <UButton\n style={dAt(220, -810, 400, 175)}\n tone=\"yellow\"\n size={fitText(t(\"statsCaps\"), 360, 55)}\n onClick={onStats}\n >\n {t(\"statsCaps\")}\n </UButton>\n </>\n ) : null}\n {/* Bottom bar with the round close button */}\n <div\n style={{\n ...dAt(0, -1037, W, 266),\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.nav} 85%, #fff) 0%, ${v.nav} 100%)`,\n borderTop: `6px solid ${INK}`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={close}\n style={{\n width: 117,\n height: 118,\n borderRadius: \"50%\",\n border: `6px solid ${INK}`,\n background: \"linear-gradient(180deg,#5d6a92,#3b4568)\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.25)\",\n cursor: \"pointer\",\n ...text(56),\n lineHeight: 1,\n fontFamily: \"inherit\",\n }}\n >\n ✕\n </button>\n </div>\n </div>\n </div>\n </KitPortal>\n );\n}\n\nconst HERO_CSS = `\n@keyframes idos-hero-idle { 0%,100% { transform: scale(1, 1); } 50% { transform: scale(1.025, .975); } }\n@keyframes idos-hero-pop { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }\n@keyframes idos-hero-attract { 0%,100% { transform: scale(1); } 50% { transform: scale(1.05); } }\n`;\n"
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
"path": "components/art.tsx",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
"path": "components/HeroesPopup.tsx",
|
|
90
|
-
"content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n LevelShield,\n ResourceList,\n inkFrame,\n inked,\n rarityPlate,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n useCatalog,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { UnityPopup } from \"./UnityPopup\";\nimport { classColor } from \"./art\";\nimport { HeroFigure } from \"./HeroFigure\";\nimport { type RosterEntry } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function HeroesPopup({\n list,\n current,\n balances,\n onSelect,\n onClose,\n}: {\n list: RosterEntry[];\n current: string;\n balances: Record<string, { Amount?: number }> | undefined;\n onSelect: (id: string) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n\n const unlock = async (e: RosterEntry) => {\n setBusy(e.id);\n const res = await client.character.unlockCharacter(e.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n onSelect(e.id);\n };\n\n return (\n <UnityPopup title={t(\"heroes\")} onClose={onClose} width={560}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\n gap: 12,\n }}\n >\n {list.map((e, i) => {\n const color = catalog.rarityColor(e.def.Classification?.RarityID);\n const affordable = canAfford(e.unlockPrice, balances);\n return (\n // The sheet's hero card: a plate of the rarity colour in the ink frame, the hero filling it,\n // the rank in a shield on the corner with the power beside it, the name at the bottom.\n <div\n key={e.id}\n style={{\n display: \"grid\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div\n style={{\n position: \"relative\",\n aspectRatio: \"0.8\",\n borderRadius: 12,\n ...rarityPlate(color),\n border: inkFrame,\n boxShadow:\n e.id === current\n ? `0 0 0 3px ${v.gold}, 0 0 16px 3px color-mix(in srgb, ${v.gold} 55%, transparent)`\n : `inset 0 -4px 0 rgba(0,0,0,.2)`,\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"end center\",\n }}\n >\n <div\n style={{\n width: \"78%\",\n height: \"82%\",\n filter:\n e.state === \"locked\"\n ? \"grayscale(1) brightness(.75)\"\n : undefined,\n }}\n >\n <HeroFigure\n hero={e}\n color={classColor(e.def.Classification?.ClassID)}\n mode=\"still\"\n />\n </div>\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n padding: \"14px 6px 6px\",\n background:\n \"linear-gradient(180deg, transparent, rgba(0,0,0,.45))\",\n textAlign: \"center\",\n ...inked,\n fontSize: 17,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {catalog.localize(e.def.Identity?.DisplayName ?? e.id)}\n </div>\n {e.state === \"locked\" ? (\n <span\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"rgba(23,25,58,.35)\",\n }}\n >\n <Icon glyph=\"lock\" size={44} />\n </span>\n ) : (\n <div\n style={{\n position: \"absolute\",\n left: 4,\n top: 4,\n right: 6,\n display: \"flex\",\n alignItems: \"center\",\n gap: 2,\n }}\n >\n <LevelShield level={e.rank} size={34} />\n <span\n style={{\n ...inked,\n flex: 1,\n fontSize: 13,\n textAlign: \"center\",\n background: `color-mix(in srgb, ${v.ink} 75%, transparent)`,\n borderRadius: 6,\n padding: \"2px 4px\",\n }}\n >\n {formatAmount(e.power)}\n </span>\n </div>\n )}\n </div>\n {e.state === \"locked\" ? (\n e.unlockPrice.length > 0 ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === e.id}\n onClick={() => void unlock(e)}\n style={{ width: \"100%\" }}\n >\n {t(\"unlock\")}{\" \"}\n <ResourceList lines={e.unlockPrice} size={16} />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )\n ) : e.id === current ? (\n <span\n style={{\n ...outlined,\n fontSize: 14,\n color: v.gold,\n textAlign: \"center\",\n }}\n >\n {t(\"selected\")}\n </span>\n ) : (\n <SelectButton onSelect={() => onSelect(e.id)} />\n )}\n </div>\n );\n })}\n </div>\n </UnityPopup>\n );\n}\n\nfunction SelectButton({ onSelect }: { onSelect: () => void }): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n size=\"sm\"\n onClick={() => {\n onSelect();\n close();\n }}\n style={{ width: \"100%\" }}\n >\n {t(\"select\")}\n </Button>\n );\n}\n"
|
|
90
|
+
"content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n LevelShield,\n ResourceList,\n inkFrame,\n inked,\n rarityPlate,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n useCatalog,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport { UnityPopup } from \"./UnityPopup\";\nimport { classColor } from \"./art\";\nimport { HeroFigure } from \"./HeroFigure\";\nimport { type RosterEntry } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function HeroesPopup({\n list,\n current,\n balances,\n onSelect,\n onClose,\n}: {\n list: RosterEntry[];\n current: string;\n balances: Record<string, { Amount?: number }> | undefined;\n onSelect: (id: string) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n\n const unlock = async (e: RosterEntry) => {\n setBusy(e.id);\n // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.character.unlockCharacter(e.id, { creditsConfirmationID }),\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n onSelect(e.id);\n };\n\n return (\n <UnityPopup title={t(\"heroes\")} onClose={onClose} width={560}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\n gap: 12,\n }}\n >\n {list.map((e, i) => {\n const color = catalog.rarityColor(e.def.Classification?.RarityID);\n const affordable = canAfford(e.unlockPrice, balances);\n return (\n // The sheet's hero card: a plate of the rarity colour in the ink frame, the hero filling it,\n // the rank in a shield on the corner with the power beside it, the name at the bottom.\n <div\n key={e.id}\n style={{\n display: \"grid\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div\n style={{\n position: \"relative\",\n aspectRatio: \"0.8\",\n borderRadius: 12,\n ...rarityPlate(color),\n border: inkFrame,\n boxShadow:\n e.id === current\n ? `0 0 0 3px ${v.gold}, 0 0 16px 3px color-mix(in srgb, ${v.gold} 55%, transparent)`\n : `inset 0 -4px 0 rgba(0,0,0,.2)`,\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"end center\",\n }}\n >\n <div\n style={{\n width: \"78%\",\n height: \"82%\",\n filter:\n e.state === \"locked\"\n ? \"grayscale(1) brightness(.75)\"\n : undefined,\n }}\n >\n <HeroFigure\n hero={e}\n color={classColor(e.def.Classification?.ClassID)}\n mode=\"still\"\n />\n </div>\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n padding: \"14px 6px 6px\",\n background:\n \"linear-gradient(180deg, transparent, rgba(0,0,0,.45))\",\n textAlign: \"center\",\n ...inked,\n fontSize: 17,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {catalog.localize(e.def.Identity?.DisplayName ?? e.id)}\n </div>\n {e.state === \"locked\" ? (\n <span\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"rgba(23,25,58,.35)\",\n }}\n >\n <Icon glyph=\"lock\" size={44} />\n </span>\n ) : (\n <div\n style={{\n position: \"absolute\",\n left: 4,\n top: 4,\n right: 6,\n display: \"flex\",\n alignItems: \"center\",\n gap: 2,\n }}\n >\n <LevelShield level={e.rank} size={34} />\n <span\n style={{\n ...inked,\n flex: 1,\n fontSize: 13,\n textAlign: \"center\",\n background: `color-mix(in srgb, ${v.ink} 75%, transparent)`,\n borderRadius: 6,\n padding: \"2px 4px\",\n }}\n >\n {formatAmount(e.power)}\n </span>\n </div>\n )}\n </div>\n {e.state === \"locked\" ? (\n e.unlockPrice.length > 0 ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === e.id}\n onClick={() => void unlock(e)}\n style={{ width: \"100%\" }}\n >\n {t(\"unlock\")}{\" \"}\n <ResourceList lines={e.unlockPrice} size={16} />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )\n ) : e.id === current ? (\n <span\n style={{\n ...outlined,\n fontSize: 14,\n color: v.gold,\n textAlign: \"center\",\n }}\n >\n {t(\"selected\")}\n </span>\n ) : (\n <SelectButton onSelect={() => onSelect(e.id)} />\n )}\n </div>\n );\n })}\n </div>\n </UnityPopup>\n );\n}\n\nfunction SelectButton({ onSelect }: { onSelect: () => void }): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n size=\"sm\"\n onClick={() => {\n onSelect();\n close();\n }}\n style={{ width: \"100%\" }}\n >\n {t(\"select\")}\n </Button>\n );\n}\n"
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
"path": "components/HeroFigure.tsx",
|
|
@@ -95,11 +95,11 @@
|
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
"path": "components/PiecePopup.tsx",
|
|
98
|
-
"content": "import { useRef, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n ResourceList,\n canAfford,\n errorText,\n outlined,\n useCatalog,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { UnityPopup } from \"./UnityPopup\";\nimport {\n fitProblem,\n itemLevelCost,\n pieceBonuses,\n statNumber,\n type GearPiece,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function PiecePopup({\n instanceID,\n hero,\n slots,\n pieces,\n items,\n balances,\n onClose,\n}: {\n instanceID: string;\n hero: RosterEntry;\n slots: SlotView[];\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const iconRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n const def = items.get(piece.itemID);\n const color = catalog.rarityColor(def?.Metadata?.RarityID);\n const bonuses = pieceBonuses(def, piece.level);\n const fitSlot = slots.find(\n (s) => fitProblem(piece, s, hero.id, hero.rank, items) === null,\n );\n const onThisHero =\n piece.equippedBy?.characterID === hero.id ? piece.equippedBy : null;\n const levelCost = itemLevelCost(def, piece.level);\n const affordable = levelCost\n ? canAfford(levelCost as ResourceLine[], balances)\n : false;\n\n const act = async (kind: \"equip\" | \"unequip\" | \"level\") => {\n setBusy(kind);\n const res =\n kind === \"equip\" && fitSlot\n ? await client.character.equipItems(hero.id, [\n { SlotID: fitSlot.id, ItemInstanceID: piece.instanceID },\n ])\n : kind === \"unequip\" && onThisHero\n ? await client.character.unequipItems(hero.id, [onThisHero.slotID])\n : await client.item.upgradeLevel(piece.instanceID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"level\" ? \"levelUp\" : \"claim\");\n kit.fx.burst(iconRef.current);\n };\n\n return (\n <UnityPopup title={catalog.itemName(piece.itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div\n ref={iconRef}\n className=\"idos-glow\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"item\",\n id: piece.itemID,\n amount: 1,\n })}\n size={72}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 14 }}>\n {def?.Metadata?.RarityID ?? \"Common\"} · {t(\"level\")} {piece.level}\n {def?.Upgrade?.MaxLevel ? ` / ${def.Upgrade.MaxLevel}` : \"\"}\n </div>\n {Object.entries(bonuses).map(([stat, value]) => (\n <div key={stat} style={{ ...outlined, fontSize: 15, color: v.green }}>\n +{statNumber(value)} {stat}\n </div>\n ))}\n {Object.entries(def?.Stats?.PercentBonuses ?? {}).map(\n ([stat, value]) => (\n <div\n key={stat}\n style={{ ...outlined, fontSize: 15, color: v.gold }}\n >\n +{value}% {stat}\n </div>\n ),\n )}\n {piece.equippedBy && !onThisHero ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"wornBy\")} {piece.equippedBy.characterID}\n </div>\n ) : null}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {onThisHero ? (\n <Button\n tone=\"grey\"\n busy={busy === \"unequip\"}\n onClick={() => void act(\"unequip\")}\n >\n {t(\"unequip\")}\n </Button>\n ) : fitSlot ? (\n <Button\n tone=\"blue\"\n busy={busy === \"equip\"}\n onClick={() => void act(\"equip\")}\n >\n {t(\"equip\")} → {fitSlot.id}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"cannot\")}\n </div>\n )}\n {levelCost === null ? (\n def?.Upgrade ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"maxLevel\")}\n </div>\n ) : null\n ) : (\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === \"level\"}\n onClick={() => void act(\"level\")}\n >\n {t(\"levelUp\")}{\" \"}\n <ResourceList\n lines={levelCost}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </div>\n </UnityPopup>\n );\n}\n"
|
|
98
|
+
"content": "import { useRef, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n ResourceList,\n canAfford,\n errorText,\n outlined,\n useCatalog,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport { UnityPopup } from \"./UnityPopup\";\nimport {\n fitProblem,\n itemLevelCost,\n pieceBonuses,\n statNumber,\n type GearPiece,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function PiecePopup({\n instanceID,\n hero,\n slots,\n pieces,\n items,\n balances,\n onClose,\n}: {\n instanceID: string;\n hero: RosterEntry;\n slots: SlotView[];\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const iconRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n const def = items.get(piece.itemID);\n const color = catalog.rarityColor(def?.Metadata?.RarityID);\n const bonuses = pieceBonuses(def, piece.level);\n const fitSlot = slots.find(\n (s) => fitProblem(piece, s, hero.id, hero.rank, items) === null,\n );\n const onThisHero =\n piece.equippedBy?.characterID === hero.id ? piece.equippedBy : null;\n const levelCost = itemLevelCost(def, piece.level);\n const affordable = levelCost\n ? canAfford(levelCost as ResourceLine[], balances)\n : false;\n\n const act = async (kind: \"equip\" | \"unequip\" | \"level\") => {\n setBusy(kind);\n const res =\n kind === \"equip\" && fitSlot\n ? await client.character.equipItems(hero.id, [\n { SlotID: fitSlot.id, ItemInstanceID: piece.instanceID },\n ])\n : kind === \"unequip\" && onThisHero\n ? await client.character.unequipItems(hero.id, [onThisHero.slotID])\n : // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n await payWithCredits(client, (creditsConfirmationID) =>\n client.item.upgradeLevel(\n piece.instanceID,\n undefined,\n undefined,\n creditsConfirmationID,\n ),\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"level\" ? \"levelUp\" : \"claim\");\n kit.fx.burst(iconRef.current);\n };\n\n return (\n <UnityPopup title={catalog.itemName(piece.itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div\n ref={iconRef}\n className=\"idos-glow\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"item\",\n id: piece.itemID,\n amount: 1,\n })}\n size={72}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 14 }}>\n {def?.Metadata?.RarityID ?? \"Common\"} · {t(\"level\")} {piece.level}\n {def?.Upgrade?.MaxLevel ? ` / ${def.Upgrade.MaxLevel}` : \"\"}\n </div>\n {Object.entries(bonuses).map(([stat, value]) => (\n <div key={stat} style={{ ...outlined, fontSize: 15, color: v.green }}>\n +{statNumber(value)} {stat}\n </div>\n ))}\n {Object.entries(def?.Stats?.PercentBonuses ?? {}).map(\n ([stat, value]) => (\n <div\n key={stat}\n style={{ ...outlined, fontSize: 15, color: v.gold }}\n >\n +{value}% {stat}\n </div>\n ),\n )}\n {piece.equippedBy && !onThisHero ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"wornBy\")} {piece.equippedBy.characterID}\n </div>\n ) : null}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {onThisHero ? (\n <Button\n tone=\"grey\"\n busy={busy === \"unequip\"}\n onClick={() => void act(\"unequip\")}\n >\n {t(\"unequip\")}\n </Button>\n ) : fitSlot ? (\n <Button\n tone=\"blue\"\n busy={busy === \"equip\"}\n onClick={() => void act(\"equip\")}\n >\n {t(\"equip\")} → {fitSlot.id}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"cannot\")}\n </div>\n )}\n {levelCost === null ? (\n def?.Upgrade ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"maxLevel\")}\n </div>\n ) : null\n ) : (\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === \"level\"}\n onClick={() => void act(\"level\")}\n >\n {t(\"levelUp\")}{\" \"}\n <ResourceList\n lines={levelCost}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </div>\n </UnityPopup>\n );\n}\n"
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
"path": "components/SkinsPopup.tsx",
|
|
102
|
-
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Icon,\n ResourceList,\n costLines,\n errorText,\n outlined,\n useCatalog,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { UnityPopup } from \"./UnityPopup\";\nimport { SKIN_KEY, type RosterEntry } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function SkinsPopup({\n hero,\n onClose,\n}: {\n hero: RosterEntry;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [view, setView] = useState<{\n WornSkinID?: string | null;\n Skins?: Array<{\n SkinID: string;\n Owned?: boolean | null;\n IsWorn?: boolean | null;\n Purchasable?: boolean | null;\n MeetsRequirements?: boolean | null;\n PriceOptions?: Record<string, { Cost?: unknown }> | null;\n }> | null;\n } | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n\n const load = async () => {\n const res = await client.character.getCharacterSkins(hero.id);\n if (res.ok) setView(res.data.Characters?.[hero.id] ?? null);\n };\n useEffect(() => {\n void load();\n // once per hero\n }, [hero.id]);\n\n const act = async (skinID: string, kind: \"wear\" | \"buy\" | \"base\") => {\n setBusy(skinID);\n const res =\n kind === \"buy\"\n ? await client.character.unlockSkin(hero.id, skinID, {\n
|
|
102
|
+
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Icon,\n ResourceList,\n costLines,\n errorText,\n outlined,\n useCatalog,\n useStagger,\n useToast,\n useUiKit,\n v,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport { UnityPopup } from \"./UnityPopup\";\nimport { SKIN_KEY, type RosterEntry } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function SkinsPopup({\n hero,\n onClose,\n}: {\n hero: RosterEntry;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [view, setView] = useState<{\n WornSkinID?: string | null;\n Skins?: Array<{\n SkinID: string;\n Owned?: boolean | null;\n IsWorn?: boolean | null;\n Purchasable?: boolean | null;\n MeetsRequirements?: boolean | null;\n PriceOptions?: Record<string, { Cost?: unknown }> | null;\n }> | null;\n } | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n\n const load = async () => {\n const res = await client.character.getCharacterSkins(hero.id);\n if (res.ok) setView(res.data.Characters?.[hero.id] ?? null);\n };\n useEffect(() => {\n void load();\n // once per hero\n }, [hero.id]);\n\n const act = async (skinID: string, kind: \"wear\" | \"buy\" | \"base\") => {\n setBusy(skinID);\n const res =\n kind === \"buy\"\n ? // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n await payWithCredits(client, (creditsConfirmationID) =>\n client.character.unlockSkin(hero.id, skinID, {\n autoEquip: true,\n creditsConfirmationID,\n }),\n )\n : kind === \"base\"\n ? await client.character.unequipSkin(hero.id)\n : await client.character.equipSkin(hero.id, skinID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"buy\" ? \"purchase\" : \"claim\");\n await load();\n };\n\n const defs = hero.def.Skins?.Definitions ?? {};\n const wornID = hero.model?.Equipment?.[SKIN_KEY]?.ItemID ?? null;\n return (\n <UnityPopup title={t(\"skins\")} onClose={onClose} width={520}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\n gap: 10,\n }}\n >\n <Card\n highlight={!wornID}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}\n >\n <Icon glyph=\"hero\" size={52} />\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"baseLook\")}</div>\n {wornID ? (\n <Button\n size=\"sm\"\n busy={busy === \"@base\"}\n onClick={() => void act(\"@base\", \"base\")}\n >\n {t(\"wear\")}\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n )}\n </Card>\n {(view?.Skins ?? []).map((s, i) => {\n const def = defs[s.SkinID];\n const price = Object.values(s.PriceOptions ?? {})[0];\n return (\n <Card\n key={s.SkinID}\n highlight={s.IsWorn === true}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n ...stagger(i),\n }}\n >\n <Icon\n glyph={def?.Identity?.AssetPaths?.icon ?? \"user\"}\n size={52}\n />\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(def?.Identity?.DisplayName ?? s.SkinID)}\n </div>\n {s.IsWorn ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n ) : s.Owned ? (\n <Button\n size=\"sm\"\n disabled={s.MeetsRequirements === false}\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"wear\")}\n >\n {t(\"wear\")}\n </Button>\n ) : s.Purchasable && price ? (\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"buy\")}\n >\n <ResourceList\n lines={costLines(price.Cost as never)}\n size={16}\n />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )}\n </Card>\n );\n })}\n </div>\n </UnityPopup>\n );\n}\n"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
"path": "components/SlotPopup.tsx",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"path": "components/StatsPopup.tsx",
|
|
110
|
-
"content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Icon,\n U,\n UButton,\n UPopup,\n URow,\n USlider,\n canAfford,\n errorText,\n formatAmount,\n uText,\n useCatalog,\n useSound,\n useStagger,\n useToast,\n} from \"@idosgames/react/ui\";\nimport {\n breakdown,\n resolveStats,\n statCost,\n statMaxLevel,\n statNumber,\n unmetRequirements,\n upgradeAllPlan,\n type CharacterSection,\n type GearPiece,\n type ItemDefWithGear,\n type RosterEntry,\n type StatView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\n// Unity Stat_Breakdown_Popup (\"STATS\"): a blue popup with the book on its title bar, a light row per\n// stat — the name, the total, where it comes from (base · level · rank · gear), the level slider and\n// the upgrade with its price — and the green \"Upgrade All Max\" with the count of levels it buys. A stat\n// without a price is not for sale (a game whose stats grow from gear only): its row shows the total and\n// where it comes from, no level and no upgrade; with no stat for sale \"Upgrade All\" is not drawn.\n\nconst INK = \"#1f2a52\";\n\nexport function StatsPopup({\n hero,\n section,\n pieces,\n items,\n balances,\n onClose,\n}: {\n hero: RosterEntry;\n section: CharacterSection | undefined;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const play = useSound();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const [detail, setDetail] = useState<StatView | null>(null);\n const stats = resolveStats(section, hero.def);\n const levels = hero.model?.StatLevels ?? {};\n const worn = Object.values(hero.model?.Equipment ?? {})\n .filter((e) => e?.ItemInstanceID)\n .map((e) => {\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\n });\n\n const upgrade = async (stat: StatView) => {\n setBusy(stat.id);\n const res = await client.character.upgradeStatLevel(hero.id, stat.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n // \"Upgrade all\" (Unity CharacterStatPopup): as many levels as the balances cover, spread over the\n // stats cheapest-first, in ONE atomic batch.\n const plan = upgradeAllPlan(stats, levels, hero.rank, hero.def, balances);\n const anyForSale = stats.some((s) => s.price.length > 0);\n const planLevels = plan.reduce((n, p) => n + p.Levels, 0);\n const upgradeAll = async () => {\n setBusy(\"all\");\n const res = await client.character.upgradeStatLevelsBatch(\n plan.map((p) => ({ CharacterID: hero.id, ...p })),\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n return (\n <UPopup\n tone=\"blue\"\n icon=\"scroll\"\n title={t(\"statsCaps\")}\n onClose={onClose}\n width={480}\n >\n <div\n style={{\n ...uText(15),\n textAlign: \"center\",\n marginTop: -4,\n }}\n >\n {catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}\n </div>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"grid\",\n gap: 8,\n background: \"#00adff\",\n borderRadius: 14,\n border: `3px solid ${U.ink}`,\n padding: 8,\n maxHeight: \"52vh\",\n overflowY: \"auto\",\n }}\n >\n {stats.map((stat, i) => {\n const level = Number(levels[stat.id] ?? 0);\n const cap = statMaxLevel(stat, hero.rank, hero.def);\n const capped = cap > 0 && level >= cap;\n const forSale = stat.price.length > 0;\n const cost = statCost(stat, level + 1);\n const missing = unmetRequirements(stat, levels);\n const affordable = canAfford(cost, balances);\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\n const next = breakdown(stat, level + 1, hero.rank, hero.def, worn);\n return (\n <div key={stat.id} style={stagger(i)}>\n <URow tone=\"light\">\n <div style={{ display: \"grid\", gap: 6 }}>\n <button\n type=\"button\"\n onClick={() => setDetail(stat)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n textAlign: \"left\",\n }}\n >\n <span\n style={{\n flex: 1,\n minWidth: 0,\n color: INK,\n fontWeight: 900,\n fontSize: 15,\n }}\n >\n {catalog.localize(stat.name)}{\" \"}\n <span style={{ opacity: 0.55 }}>ⓘ</span>\n </span>\n <span style={uText(20, U.gold)}>{statNumber(b.total)}</span>\n </button>\n <Sources b={b} />\n {!forSale ? null : cap > 0 ? (\n <USlider\n value={level}\n max={cap}\n height={20}\n label={`${t(\"level\")} ${level}/${cap}`}\n />\n ) : null}\n {!forSale ? null : missing.length > 0 ? (\n <div\n style={{\n color: \"#d9283b\",\n fontWeight: 900,\n fontSize: 12,\n }}\n >\n {t(\"needs\")}:{\" \"}\n {missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}\n </div>\n ) : capped ? (\n <div\n style={{\n color: INK,\n opacity: 0.6,\n fontWeight: 900,\n fontSize: 12,\n }}\n >\n {t(\"statCapped\")}\n </div>\n ) : (\n <UButton\n tone=\"green\"\n size=\"s\"\n disabled={!affordable}\n busy={busy === stat.id}\n onClick={() => void upgrade(stat)}\n >\n ▲ {statNumber(next.total)}\n {cost.map((l, k) => (\n <span\n key={k}\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 2,\n marginLeft: 4,\n }}\n >\n <Icon glyph={catalog.iconOf(l)} size={18} />\n {formatAmount(l.amount)}\n </span>\n ))}\n </UButton>\n )}\n </div>\n </URow>\n </div>\n );\n })}\n </div>\n {anyForSale ? (\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n justifyItems: \"center\",\n }}\n >\n <span style={{ position: \"relative\" }}>\n <UButton\n tone=\"green\"\n size=\"l\"\n attract={plan.length > 0}\n disabled={plan.length === 0}\n busy={busy === \"all\"}\n onClick={() => void upgradeAll()}\n >\n {t(\"upgradeAll\")}\n </UButton>\n {planLevels > 0 ? (\n <span\n style={{\n position: \"absolute\",\n right: -10,\n top: -10,\n minWidth: 28,\n height: 28,\n padding: \"0 6px\",\n borderRadius: 14,\n background: \"#0ac74f\",\n border: \"3px solid #141726\",\n display: \"grid\",\n placeItems: \"center\",\n ...uText(14),\n }}\n >\n {planLevels}\n </span>\n ) : null}\n </span>\n </div>\n ) : null}\n {detail ? (\n <BreakdownPopup\n stat={detail}\n level={Number(levels[detail.id] ?? 0)}\n hero={hero}\n worn={worn}\n onClose={() => setDetail(null)}\n />\n ) : null}\n </UPopup>\n );\n}\n\ntype Breakdown = ReturnType<typeof breakdown>;\n\n/** The stat's sources as small coloured chips: base · +level · +rank · +gear · +%. */\nfunction Sources({ b }: { b: Breakdown }): ReactNode {\n const chips: Array<[string, number, string, string]> = [\n [t(\"base\"), b.base, \"#5d6a92\", \"\"],\n [t(\"fromLevel\"), b.fromLevel, \"#33b31d\", \"+\"],\n [t(\"fromRank\"), b.fromRank, \"#1c8fe6\", \"+\"],\n [t(\"fromGear\"), b.fromGear, \"#f5a90c\", \"+\"],\n ];\n return (\n <div style={{ display: \"flex\", gap: 4, flexWrap: \"wrap\" }}>\n {chips\n .filter(([, value], i) => i === 0 || value !== 0)\n .map(([label, value, color, sign]) => (\n <span\n key={label}\n style={{\n padding: \"1px 7px\",\n borderRadius: 8,\n background: color,\n border: `2px solid ${U.ink}`,\n ...uText(11),\n }}\n >\n {label} {sign}\n {statNumber(value)}\n </span>\n ))}\n {b.percent ? (\n <span\n style={{\n padding: \"1px 7px\",\n borderRadius: 8,\n background: \"#b46cff\",\n border: `2px solid ${U.ink}`,\n ...uText(11),\n }}\n >\n +{statNumber(b.percent)}%\n </span>\n ) : null}\n </div>\n );\n}\n\nfunction BreakdownPopup({\n stat,\n level,\n hero,\n worn,\n onClose,\n}: {\n stat: StatView;\n level: number;\n hero: RosterEntry;\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\n const rows: Array<[string, number, string, string]> = [\n [t(\"base\"), b.base, \"#5d6a92\", \"\"],\n [t(\"fromLevel\"), b.fromLevel, \"#33b31d\", \"+\"],\n [t(\"fromRank\"), b.fromRank, \"#1c8fe6\", \"+\"],\n [t(\"fromGear\"), b.fromGear, \"#f5a90c\", \"+\"],\n ];\n return (\n <UPopup\n tone=\"blue\"\n title={`${catalog.localize(stat.name)} — ${t(\"where\")}`}\n onClose={onClose}\n width={400}\n variant=\"center\"\n >\n {stat.description ? (\n <div style={{ ...uText(13, \"#e3f2ff\"), textAlign: \"center\" }}>\n {catalog.localize(stat.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n {rows.map(([label, value, color, sign]) => (\n <URow key={label} tone=\"light\">\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span\n style={{\n width: 12,\n height: 12,\n borderRadius: \"50%\",\n background: color,\n border: `2px solid ${U.ink}`,\n }}\n />\n <span\n style={{ flex: 1, color: INK, fontWeight: 900, fontSize: 15 }}\n >\n {label}\n </span>\n <span style={uText(17, value === 0 && sign ? \"#a9b6cc\" : color)}>\n {value === 0 && sign ? \"—\" : `${sign}${statNumber(value)}`}\n </span>\n </div>\n </URow>\n ))}\n {b.percent ? (\n <URow tone=\"light\">\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <span\n style={{ flex: 1, color: INK, fontWeight: 900, fontSize: 15 }}\n >\n {t(\"percent\")}\n </span>\n <span style={uText(17, \"#b46cff\")}>\n +{statNumber(b.percent)}%\n </span>\n </div>\n </URow>\n ) : null}\n <URow tone=\"yellow\">\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <span\n style={{ flex: 1, color: INK, fontWeight: 900, fontSize: 18 }}\n >\n {t(\"total\")}\n </span>\n <span style={uText(22, U.gold)}>{statNumber(b.total)}</span>\n </div>\n </URow>\n </div>\n </UPopup>\n );\n}\n"
|
|
110
|
+
"content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Icon,\n U,\n UButton,\n UPopup,\n URow,\n USlider,\n canAfford,\n errorText,\n formatAmount,\n uText,\n useCatalog,\n useSound,\n useStagger,\n useToast,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport {\n breakdown,\n resolveStats,\n statCost,\n statMaxLevel,\n statNumber,\n unmetRequirements,\n upgradeAllPlan,\n type CharacterSection,\n type GearPiece,\n type ItemDefWithGear,\n type RosterEntry,\n type StatView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\n// Unity Stat_Breakdown_Popup (\"STATS\"): a blue popup with the book on its title bar, a light row per\n// stat — the name, the total, where it comes from (base · level · rank · gear), the level slider and\n// the upgrade with its price — and the green \"Upgrade All Max\" with the count of levels it buys. A stat\n// without a price is not for sale (a game whose stats grow from gear only): its row shows the total and\n// where it comes from, no level and no upgrade; with no stat for sale \"Upgrade All\" is not drawn.\n\nconst INK = \"#1f2a52\";\n\nexport function StatsPopup({\n hero,\n section,\n pieces,\n items,\n balances,\n onClose,\n}: {\n hero: RosterEntry;\n section: CharacterSection | undefined;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const play = useSound();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const [detail, setDetail] = useState<StatView | null>(null);\n const stats = resolveStats(section, hero.def);\n const levels = hero.model?.StatLevels ?? {};\n const worn = Object.values(hero.model?.Equipment ?? {})\n .filter((e) => e?.ItemInstanceID)\n .map((e) => {\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\n });\n\n const upgrade = async (stat: StatView) => {\n setBusy(stat.id);\n // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.character.upgradeStatLevel(hero.id, stat.id, {\n creditsConfirmationID,\n }),\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n // \"Upgrade all\" (Unity CharacterStatPopup): as many levels as the balances cover, spread over the\n // stats cheapest-first, in ONE atomic batch.\n const plan = upgradeAllPlan(stats, levels, hero.rank, hero.def, balances);\n const anyForSale = stats.some((s) => s.price.length > 0);\n const planLevels = plan.reduce((n, p) => n + p.Levels, 0);\n const upgradeAll = async () => {\n setBusy(\"all\");\n const res = await client.character.upgradeStatLevelsBatch(\n plan.map((p) => ({ CharacterID: hero.id, ...p })),\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n return (\n <UPopup\n tone=\"blue\"\n icon=\"scroll\"\n title={t(\"statsCaps\")}\n onClose={onClose}\n width={480}\n >\n <div\n style={{\n ...uText(15),\n textAlign: \"center\",\n marginTop: -4,\n }}\n >\n {catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}\n </div>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"grid\",\n gap: 8,\n background: \"#00adff\",\n borderRadius: 14,\n border: `3px solid ${U.ink}`,\n padding: 8,\n maxHeight: \"52vh\",\n overflowY: \"auto\",\n }}\n >\n {stats.map((stat, i) => {\n const level = Number(levels[stat.id] ?? 0);\n const cap = statMaxLevel(stat, hero.rank, hero.def);\n const capped = cap > 0 && level >= cap;\n const forSale = stat.price.length > 0;\n const cost = statCost(stat, level + 1);\n const missing = unmetRequirements(stat, levels);\n const affordable = canAfford(cost, balances);\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\n const next = breakdown(stat, level + 1, hero.rank, hero.def, worn);\n return (\n <div key={stat.id} style={stagger(i)}>\n <URow tone=\"light\">\n <div style={{ display: \"grid\", gap: 6 }}>\n <button\n type=\"button\"\n onClick={() => setDetail(stat)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n textAlign: \"left\",\n }}\n >\n <span\n style={{\n flex: 1,\n minWidth: 0,\n color: INK,\n fontWeight: 900,\n fontSize: 15,\n }}\n >\n {catalog.localize(stat.name)}{\" \"}\n <span style={{ opacity: 0.55 }}>ⓘ</span>\n </span>\n <span style={uText(20, U.gold)}>{statNumber(b.total)}</span>\n </button>\n <Sources b={b} />\n {!forSale ? null : cap > 0 ? (\n <USlider\n value={level}\n max={cap}\n height={20}\n label={`${t(\"level\")} ${level}/${cap}`}\n />\n ) : null}\n {!forSale ? null : missing.length > 0 ? (\n <div\n style={{\n color: \"#d9283b\",\n fontWeight: 900,\n fontSize: 12,\n }}\n >\n {t(\"needs\")}:{\" \"}\n {missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}\n </div>\n ) : capped ? (\n <div\n style={{\n color: INK,\n opacity: 0.6,\n fontWeight: 900,\n fontSize: 12,\n }}\n >\n {t(\"statCapped\")}\n </div>\n ) : (\n <UButton\n tone=\"green\"\n size=\"s\"\n disabled={!affordable}\n busy={busy === stat.id}\n onClick={() => void upgrade(stat)}\n >\n ▲ {statNumber(next.total)}\n {cost.map((l, k) => (\n <span\n key={k}\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 2,\n marginLeft: 4,\n }}\n >\n <Icon glyph={catalog.iconOf(l)} size={18} />\n {formatAmount(l.amount)}\n </span>\n ))}\n </UButton>\n )}\n </div>\n </URow>\n </div>\n );\n })}\n </div>\n {anyForSale ? (\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n justifyItems: \"center\",\n }}\n >\n <span style={{ position: \"relative\" }}>\n <UButton\n tone=\"green\"\n size=\"l\"\n attract={plan.length > 0}\n disabled={plan.length === 0}\n busy={busy === \"all\"}\n onClick={() => void upgradeAll()}\n >\n {t(\"upgradeAll\")}\n </UButton>\n {planLevels > 0 ? (\n <span\n style={{\n position: \"absolute\",\n right: -10,\n top: -10,\n minWidth: 28,\n height: 28,\n padding: \"0 6px\",\n borderRadius: 14,\n background: \"#0ac74f\",\n border: \"3px solid #141726\",\n display: \"grid\",\n placeItems: \"center\",\n ...uText(14),\n }}\n >\n {planLevels}\n </span>\n ) : null}\n </span>\n </div>\n ) : null}\n {detail ? (\n <BreakdownPopup\n stat={detail}\n level={Number(levels[detail.id] ?? 0)}\n hero={hero}\n worn={worn}\n onClose={() => setDetail(null)}\n />\n ) : null}\n </UPopup>\n );\n}\n\ntype Breakdown = ReturnType<typeof breakdown>;\n\n/** The stat's sources as small coloured chips: base · +level · +rank · +gear · +%. */\nfunction Sources({ b }: { b: Breakdown }): ReactNode {\n const chips: Array<[string, number, string, string]> = [\n [t(\"base\"), b.base, \"#5d6a92\", \"\"],\n [t(\"fromLevel\"), b.fromLevel, \"#33b31d\", \"+\"],\n [t(\"fromRank\"), b.fromRank, \"#1c8fe6\", \"+\"],\n [t(\"fromGear\"), b.fromGear, \"#f5a90c\", \"+\"],\n ];\n return (\n <div style={{ display: \"flex\", gap: 4, flexWrap: \"wrap\" }}>\n {chips\n .filter(([, value], i) => i === 0 || value !== 0)\n .map(([label, value, color, sign]) => (\n <span\n key={label}\n style={{\n padding: \"1px 7px\",\n borderRadius: 8,\n background: color,\n border: `2px solid ${U.ink}`,\n ...uText(11),\n }}\n >\n {label} {sign}\n {statNumber(value)}\n </span>\n ))}\n {b.percent ? (\n <span\n style={{\n padding: \"1px 7px\",\n borderRadius: 8,\n background: \"#b46cff\",\n border: `2px solid ${U.ink}`,\n ...uText(11),\n }}\n >\n +{statNumber(b.percent)}%\n </span>\n ) : null}\n </div>\n );\n}\n\nfunction BreakdownPopup({\n stat,\n level,\n hero,\n worn,\n onClose,\n}: {\n stat: StatView;\n level: number;\n hero: RosterEntry;\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\n const rows: Array<[string, number, string, string]> = [\n [t(\"base\"), b.base, \"#5d6a92\", \"\"],\n [t(\"fromLevel\"), b.fromLevel, \"#33b31d\", \"+\"],\n [t(\"fromRank\"), b.fromRank, \"#1c8fe6\", \"+\"],\n [t(\"fromGear\"), b.fromGear, \"#f5a90c\", \"+\"],\n ];\n return (\n <UPopup\n tone=\"blue\"\n title={`${catalog.localize(stat.name)} — ${t(\"where\")}`}\n onClose={onClose}\n width={400}\n variant=\"center\"\n >\n {stat.description ? (\n <div style={{ ...uText(13, \"#e3f2ff\"), textAlign: \"center\" }}>\n {catalog.localize(stat.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n {rows.map(([label, value, color, sign]) => (\n <URow key={label} tone=\"light\">\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span\n style={{\n width: 12,\n height: 12,\n borderRadius: \"50%\",\n background: color,\n border: `2px solid ${U.ink}`,\n }}\n />\n <span\n style={{ flex: 1, color: INK, fontWeight: 900, fontSize: 15 }}\n >\n {label}\n </span>\n <span style={uText(17, value === 0 && sign ? \"#a9b6cc\" : color)}>\n {value === 0 && sign ? \"—\" : `${sign}${statNumber(value)}`}\n </span>\n </div>\n </URow>\n ))}\n {b.percent ? (\n <URow tone=\"light\">\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <span\n style={{ flex: 1, color: INK, fontWeight: 900, fontSize: 15 }}\n >\n {t(\"percent\")}\n </span>\n <span style={uText(17, \"#b46cff\")}>\n +{statNumber(b.percent)}%\n </span>\n </div>\n </URow>\n ) : null}\n <URow tone=\"yellow\">\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <span\n style={{ flex: 1, color: INK, fontWeight: 900, fontSize: 18 }}\n >\n {t(\"total\")}\n </span>\n <span style={uText(22, U.gold)}>{statNumber(b.total)}</span>\n </div>\n </URow>\n </div>\n </UPopup>\n );\n}\n"
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
"path": "components/UnityPopup.tsx",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"version": "0.1.2"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@idosgames/core": "0.
|
|
39
|
-
"@idosgames/module-sdk": "0.13.
|
|
40
|
-
"@idosgames/react": "0.
|
|
38
|
+
"@idosgames/core": "0.26.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
40
|
+
"@idosgames/react": "0.7.0",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "8ee2540d83607f36c770ed998f2a3806edac1c0373b89a3425549f3f80f0e9ff",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"version": "0.1.3"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.
|
|
38
|
-
"@idosgames/module-sdk": "0.13.
|
|
39
|
-
"@idosgames/react": "0.
|
|
37
|
+
"@idosgames/core": "0.26.0",
|
|
38
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
39
|
+
"@idosgames/react": "0.7.0",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
|
-
"contentHash": "
|
|
42
|
+
"contentHash": "4a0b7e2e2ee14fd0ebf5ae7cb09246895b158e9ff62d372a7e18a041a3d54fbe",
|
|
43
43
|
"files": [
|
|
44
44
|
{
|
|
45
45
|
"path": "CollectionsScreen.tsx",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"path": "components/Packs.tsx",
|
|
58
|
-
"content": "import { useRef, useState, type ReactNode } from \"react\";\nimport { type CollectionDefinitions } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n canAfford,\n errorText,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n chestRows,\n packOptions,\n type ChestRow,\n type PackOption,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function Packs({\n collectionID,\n section,\n balance,\n onChanged,\n}: {\n collectionID: string;\n section: CollectionDefinitions | undefined;\n balance: number;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const state = useUserState();\n const stagger = useStagger();\n const [opening, setOpening] = useState<\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow }\n | null\n >(null);\n const packs = packOptions(section);\n const chests = chestRows(section);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n if (packs.length === 0 && chests.length === 0)\n return <EmptyState glyph=\"ticket\" text={t(\"noPacks\")} />;\n const bulkOf = (p: PackOption) =>\n p.maxOpen >= 10 &&\n canAfford(\n p.price.map((l) => ({ ...l, amount: l.amount * 10 })),\n balances,\n );\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {packs.length > 0 ? (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {packs.map((p, i) => {\n const affordable = canAfford(p.price, balances);\n return (\n <Card\n key={`${p.packTypeID}:${p.optionID ?? \"\"}`}\n style={{\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {p.name}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={p.icon} size={56} />\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {p.cards} {t(\"cards\")}\n </div>\n <Button\n tone={p.price.length === 0 ? \"green\" : \"gold\"}\n disabled={!affordable}\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 1 })\n }\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`collection:pack:${p.packTypeID}`}\n >\n {p.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={p.price} size={18} />\n )}\n </Button>\n {bulkOf(p) ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 10 })\n }\n style={{ width: \"100%\" }}\n >\n {t(\"open\")} ×10\n </Button>\n ) : null}\n </Card>\n );\n })}\n </div>\n ) : null}\n {chests.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle\n right={\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"points\")}: {balance}\n </span>\n }\n >\n {t(\"chests\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {chests.map((c) => (\n <Card\n key={c.chestID}\n style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {c.name}\n </div>\n <Icon glyph={c.icon} size={56} />\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {c.cards} {t(\"cards\")}\n </div>\n <Button\n tone=\"gold\"\n disabled={balance < c.cost}\n onClick={() => setOpening({ kind: \"chest\", chest: c })}\n style={{ width: \"100%\" }}\n >\n {c.cost} {t(\"points\")}\n </Button>\n </Card>\n ))}\n </div>\n </div>\n ) : null}\n {opening ? (\n <OpenPopup\n collectionID={collectionID}\n opening={opening}\n onClose={() => setOpening(null)}\n onOpened={onChanged}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OpenPopup({\n collectionID,\n opening,\n onClose,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onClose: () => void;\n onOpened: () => Promise<void>;\n}): ReactNode {\n const name = opening.kind === \"pack\" ? opening.pack.name : opening.chest.name;\n const count = opening.kind === \"pack\" ? opening.count : 1;\n return (\n <Popup\n title={count > 1 ? `${name} ×${count}` : name}\n onClose={onClose}\n variant=\"center\"\n >\n <OpenBody\n collectionID={collectionID}\n opening={opening}\n onOpened={onOpened}\n />\n </Popup>\n );\n}\n\nfunction OpenBody({\n collectionID,\n opening,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onOpened: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const [got, setGot] = useState<Array<{ id: string; dup: boolean }> | null>(\n null,\n );\n\n const open = async () => {\n setBusy(true);\n const started = Date.now();\n const res =\n opening.kind === \"pack\"\n ? await client.collection.openPack(\n collectionID,\n opening.pack.packTypeID,\n opening.count,\n opening.pack.optionID\n ? { selectedOptionID: opening.pack.optionID }\n : undefined,\n )\n : await client.collection.openCollectionChest(\n collectionID,\n opening.chest.chestID,\n );\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 700 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setGot([\n ...(res.data.GrantedCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: false,\n })),\n ...(res.data.DuplicateCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: true,\n })),\n ]);\n const bonus = grantedBy(res.data);\n if (bonus.length > 0) celebrate(bonus);\n await onOpened();\n };\n\n if (got)\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(72px, 1fr))\",\n gap: 8,\n }}\n >\n {got.map((c, i) => (\n <div\n key={`${c.id}:${i}`}\n style={{\n ...panel,\n padding: 6,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n opacity: c.dup ? 0.6 : 1,\n }}\n >\n <Icon glyph=\"star\" size={28} />\n <span style={{ ...outlined, fontSize: 11, textAlign: \"center\" }}>\n {catalog.localize(c.id)}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 10,\n color: c.dup ? v.textDim : v.green,\n }}\n >\n {c.dup ? t(\"duplicate\") : t(\"new\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={close}>\n {t(\"great\")}\n </Button>\n </div>\n );\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon\n glyph={\n opening.kind === \"pack\" ? opening.pack.icon : opening.chest.icon\n }\n size={110}\n />\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void open()}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n </div>\n );\n}\n\n// ── Trades ───────────────────────────────────────────────────────────────────────────────────\n"
|
|
58
|
+
"content": "import { useRef, useState, type ReactNode } from \"react\";\nimport { type CollectionDefinitions } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n canAfford,\n errorText,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport {\n chestRows,\n packOptions,\n type ChestRow,\n type PackOption,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function Packs({\n collectionID,\n section,\n balance,\n onChanged,\n}: {\n collectionID: string;\n section: CollectionDefinitions | undefined;\n balance: number;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const state = useUserState();\n const stagger = useStagger();\n const [opening, setOpening] = useState<\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow }\n | null\n >(null);\n const packs = packOptions(section);\n const chests = chestRows(section);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n if (packs.length === 0 && chests.length === 0)\n return <EmptyState glyph=\"ticket\" text={t(\"noPacks\")} />;\n const bulkOf = (p: PackOption) =>\n p.maxOpen >= 10 &&\n canAfford(\n p.price.map((l) => ({ ...l, amount: l.amount * 10 })),\n balances,\n );\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {packs.length > 0 ? (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {packs.map((p, i) => {\n const affordable = canAfford(p.price, balances);\n return (\n <Card\n key={`${p.packTypeID}:${p.optionID ?? \"\"}`}\n style={{\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {p.name}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={p.icon} size={56} />\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {p.cards} {t(\"cards\")}\n </div>\n <Button\n tone={p.price.length === 0 ? \"green\" : \"gold\"}\n disabled={!affordable}\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 1 })\n }\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`collection:pack:${p.packTypeID}`}\n >\n {p.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={p.price} size={18} />\n )}\n </Button>\n {bulkOf(p) ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 10 })\n }\n style={{ width: \"100%\" }}\n >\n {t(\"open\")} ×10\n </Button>\n ) : null}\n </Card>\n );\n })}\n </div>\n ) : null}\n {chests.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle\n right={\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"points\")}: {balance}\n </span>\n }\n >\n {t(\"chests\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {chests.map((c) => (\n <Card\n key={c.chestID}\n style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {c.name}\n </div>\n <Icon glyph={c.icon} size={56} />\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {c.cards} {t(\"cards\")}\n </div>\n <Button\n tone=\"gold\"\n disabled={balance < c.cost}\n onClick={() => setOpening({ kind: \"chest\", chest: c })}\n style={{ width: \"100%\" }}\n >\n {c.cost} {t(\"points\")}\n </Button>\n </Card>\n ))}\n </div>\n </div>\n ) : null}\n {opening ? (\n <OpenPopup\n collectionID={collectionID}\n opening={opening}\n onClose={() => setOpening(null)}\n onOpened={onChanged}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OpenPopup({\n collectionID,\n opening,\n onClose,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onClose: () => void;\n onOpened: () => Promise<void>;\n}): ReactNode {\n const name = opening.kind === \"pack\" ? opening.pack.name : opening.chest.name;\n const count = opening.kind === \"pack\" ? opening.count : 1;\n return (\n <Popup\n title={count > 1 ? `${name} ×${count}` : name}\n onClose={onClose}\n variant=\"center\"\n >\n <OpenBody\n collectionID={collectionID}\n opening={opening}\n onOpened={onOpened}\n />\n </Popup>\n );\n}\n\nfunction OpenBody({\n collectionID,\n opening,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onOpened: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const [got, setGot] = useState<Array<{ id: string; dup: boolean }> | null>(\n null,\n );\n\n const open = async () => {\n setBusy(true);\n const started = Date.now();\n const res =\n opening.kind === \"pack\"\n ? // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n await payWithCredits(client, (creditsConfirmationID) =>\n client.collection.openPack(\n collectionID,\n opening.pack.packTypeID,\n opening.count,\n {\n selectedOptionID: opening.pack.optionID || undefined,\n creditsConfirmationID,\n },\n ),\n )\n : await client.collection.openCollectionChest(\n collectionID,\n opening.chest.chestID,\n );\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 700 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setGot([\n ...(res.data.GrantedCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: false,\n })),\n ...(res.data.DuplicateCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: true,\n })),\n ]);\n const bonus = grantedBy(res.data);\n if (bonus.length > 0) celebrate(bonus);\n await onOpened();\n };\n\n if (got)\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(72px, 1fr))\",\n gap: 8,\n }}\n >\n {got.map((c, i) => (\n <div\n key={`${c.id}:${i}`}\n style={{\n ...panel,\n padding: 6,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n opacity: c.dup ? 0.6 : 1,\n }}\n >\n <Icon glyph=\"star\" size={28} />\n <span style={{ ...outlined, fontSize: 11, textAlign: \"center\" }}>\n {catalog.localize(c.id)}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 10,\n color: c.dup ? v.textDim : v.green,\n }}\n >\n {c.dup ? t(\"duplicate\") : t(\"new\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={close}>\n {t(\"great\")}\n </Button>\n </div>\n );\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon\n glyph={\n opening.kind === \"pack\" ? opening.pack.icon : opening.chest.icon\n }\n size={110}\n />\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void open()}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n </div>\n );\n}\n\n// ── Trades ───────────────────────────────────────────────────────────────────────────────────\n"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"path": "components/Trades.tsx",
|