@idosgames/mcp 0.1.34 → 0.1.35
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 +42 -42
- package/registry/modules/ai.json +1 -1
- package/registry/modules/board-game.json +93 -17
- package/registry/modules/boosts.json +1 -1
- package/registry/modules/character.json +20 -12
- package/registry/modules/chat.json +1 -1
- package/registry/modules/collections.json +1 -1
- package/registry/modules/coop-events.json +1 -1
- package/registry/modules/craft.json +1 -1
- package/registry/modules/deals.json +1 -1
- package/registry/modules/events.json +1 -1
- package/registry/modules/friends.json +1 -1
- package/registry/modules/idle-rpg.json +1 -1
- package/registry/modules/inventory.json +1 -1
- package/registry/modules/invite.json +1 -1
- package/registry/modules/leaderboard.json +1 -1
- package/registry/modules/lootboxes.json +3 -3
- package/registry/modules/mailbox.json +1 -1
- package/registry/modules/marketplace.json +1 -1
- package/registry/modules/mini-game.json +1 -1
- package/registry/modules/multiplayer.json +5 -5
- package/registry/modules/premium.json +1 -1
- package/registry/modules/profile.json +1 -1
- package/registry/modules/pvp.json +1 -1
- package/registry/modules/quests.json +1 -1
- package/registry/modules/rewards.json +1 -1
- package/registry/modules/season.json +1 -1
- package/registry/modules/store.json +1 -1
- package/registry/modules/tutorials.json +1 -1
- package/registry/modules/voxelcraft.json +11 -11
- package/registry/modules/workshop.json +1 -1
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@idosgames/core": "0.21.6",
|
|
35
35
|
"@idosgames/module-sdk": "0.11.0",
|
|
36
|
-
"@idosgames/react": "0.5.
|
|
36
|
+
"@idosgames/react": "0.5.1",
|
|
37
37
|
"react": "19.2.7"
|
|
38
38
|
},
|
|
39
39
|
"contentHash": "bb17d0684afb6135854bc4abdab52d3c0fcdbc01d45ac23fd1c66dd25fce5d47",
|
|
@@ -55,38 +55,46 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@idosgames/core": "0.21.6",
|
|
57
57
|
"@idosgames/module-sdk": "0.11.0",
|
|
58
|
-
"@idosgames/react": "0.5.
|
|
58
|
+
"@idosgames/react": "0.5.1",
|
|
59
59
|
"react": "19.2.7"
|
|
60
60
|
},
|
|
61
|
-
"contentHash": "
|
|
61
|
+
"contentHash": "11d35f75b630d77b37d3e2f619071640caaaec3c0b39634df8c7d6d215012527",
|
|
62
62
|
"files": [
|
|
63
63
|
{
|
|
64
64
|
"path": "CharacterScreen.tsx",
|
|
65
|
-
"content": "import { useRef, useState, type ReactNode } from \"react\";\nimport {\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport {\n Badge,\n Button,\n EmptyState,\n Icon,\n ResourceList,\n SectionTitle,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n panel,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n bestLoadout,\n rankCost,\n resolveLadder,\n resolveSlots,\n roster,\n unequipAllSlots,\n type SlotView,\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 { Bag, SlotTile, Stat } from \"./components/parts\";\nimport { useData } from \"./components/useData\";\n\n// Heroes (Unity: the Hero button → CharacterListPanel, CharacterDetailPanel, EquipmentPanel). The\n// screen is the equipment hub of the selected hero — portrait, rank and Power, the slots, \"Equip\n// best\", and the gear the player owns. Everything with more data opens as a popup: the hero list\n// (select / unlock), the stats (upgrade, and \"where does this number come from\"), a piece of gear\n// (equip / take off / level up), a slot's fitting gear, and skins.\n\n/**\n * `onSelect` — the player picked a hero to play with; the module turns it into the\n * `character:hero-selected@1` event for the game.\n */\nexport function makeCharacterScreen(\n features: FeatureRegistry,\n onSelect: (characterId: string) => void,\n) {\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Heroes\n features={features}\n onSelect={onSelect}\n openInstance={\n typeof args?.itemInstanceID === \"string\" ? args.itemInstanceID : null\n }\n />\n );\n };\n}\n\ntype PopupState =\n | { kind: \"heroes\" }\n | { kind: \"stats\" }\n | { kind: \"skins\" }\n | { kind: \"slot\"; slot: SlotView }\n | { kind: \"piece\"; instanceID: string }\n | null;\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 layout = useGameLayout();\n const toast = useToast();\n const kit = useUiKit();\n const cardRef = useRef<HTMLDivElement>(null);\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\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 ladder = resolveLadder(section, hero.def);\n const nextRank = rankCost(ladder, hero.rank);\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 rarity = catalog.rarityColor(hero.def.Classification?.RarityID);\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\n\n const rankUp = async () => {\n setBusy(\"rank\");\n const res = await client.character.upgradeCharacterLevel(hero.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(cardRef.current);\n };\n\n const equipBest = async () => {\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(cardRef.current);\n };\n\n // Always enabled (Unity EquipmentPanel.OnUnequipAllClicked): it is the way out of a local/server\n // mismatch, and a mismatch looks exactly like \"nothing is worn here\". The server empties only what\n // it has; the hero and the gear are re-read afterwards either way — the player pressed it because\n // what they see is not what they expect.\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 const card = (\n <div\n ref={cardRef}\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: `radial-gradient(circle at 50% 30%, ${rarity}55 0%, ${v.panelDeep} 75%)`,\n border: `2px solid ${rarity}`,\n }}\n >\n <div\n className=\"idos-bounce\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 28,\n display: \"grid\",\n placeItems: \"center\",\n background: v.wellSoft,\n border: `3px solid ${rarity}`,\n }}\n >\n <Icon\n glyph={\n hero.def.Identity?.AssetPaths?.portrait ??\n hero.def.Identity?.AssetPaths?.icon ??\n \"hero\"\n }\n size={80}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22 }}>{heroName}</div>\n {hero.def.Classification?.ClassID ? (\n <div style={{ ...outlined, fontSize: 12, color: rarity }}>\n {hero.def.Classification.ClassID} · {hero.def.Classification.RarityID}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={t(\"rank\")}\n value={`${hero.rank}${ladder?.MaxRank ? ` / ${ladder.MaxRank}` : \"\"}`}\n />\n <Stat label={t(\"power\")} value={formatAmount(hero.power)} />\n </div>\n {hero.state === \"locked\" ? null : nextRank === null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"maxRank\")}\n </div>\n ) : (\n <Button\n tone=\"gold\"\n attract={canAfford(nextRank, balances)}\n disabled={!canAfford(nextRank, balances)}\n busy={busy === \"rank\"}\n onClick={() => void rankUp()}\n data-tutorial-anchor=\"heroes:rank-up\"\n >\n {t(\"rankUp\")}{\" \"}\n {nextRank.length ? (\n <ResourceList\n lines={nextRank}\n size={18}\n dimIf={() => !canAfford(nextRank, balances)}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n )}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"heroes\" })}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"heroes\")}\n </Button>\n <Badge\n count={\n list.filter(\n (h) =>\n h.state === \"locked\" &&\n h.unlockPrice.length > 0 &&\n canAfford(h.unlockPrice, balances),\n ).length\n }\n dot\n />\n </span>\n {hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"stats\" })}\n >\n <Icon glyph=\"sword\" size={18} /> {t(\"stats\")}\n </Button>\n ) : null}\n {skinCount > 0 && hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"skins\" })}\n >\n <Icon glyph=\"user\" size={18} /> {t(\"skins\")}\n </Button>\n ) : null}\n </div>\n </div>\n );\n\n const equipment = (\n <div style={{ display: \"grid\", gap: 14 }}>\n <SectionTitle\n right={\n hero.state === \"locked\" ? null : (\n <span style={{ display: \"flex\", gap: 6 }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n busy={busy === \"off\"}\n onClick={() => void unequipAll()}\n >\n {t(\"unequipAll\")}\n </Button>\n {changes.length > 0 ? (\n <Button\n tone=\"green\"\n size=\"sm\"\n attract\n busy={busy === \"best\"}\n onClick={() => void equipBest()}\n data-tutorial-anchor=\"heroes:equip-best\"\n >\n {t(\"equipBest\")}\n </Button>\n ) : null}\n </span>\n )\n }\n >\n {t(\"equipment\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 88 : 104}px, 1fr))`,\n gap: 12,\n justifyItems: \"center\",\n }}\n >\n {slots.map((slot) => (\n <SlotTile\n key={slot.id}\n slot={slot}\n hero={hero}\n pieces={pieces}\n onClick={() => setPopup({ kind: \"slot\", slot })}\n />\n ))}\n </div>\n <Bag\n pieces={pieces.filter((p) => !p.equippedBy)}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n />\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 4fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {card}\n {equipment}\n </div>\n ) : (\n <>\n {card}\n {equipment}\n </>\n )}\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\" ? (\n <StatsPopup\n hero={hero}\n section={section}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\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"
|
|
65
|
+
"content": "import {\r\n useEffect,\r\n useLayoutEffect,\r\n useRef,\r\n useState,\r\n type CSSProperties,\r\n type ReactNode,\r\n} from \"react\";\r\nimport {\r\n type FeatureRegistry,\r\n type FeatureScreenProps,\r\n} from \"@idosgames/module-sdk\";\r\nimport {\r\n Badge,\r\n EmptyState,\r\n ItemTile,\r\n KitPortal,\r\n ResourceList,\r\n canAfford,\r\n errorText,\r\n formatAmount,\r\n useCatalog,\r\n useSound,\r\n useToast,\r\n useUiKit,\r\n} from \"@idosgames/react/ui\";\r\nimport {\r\n bestLoadout,\r\n breakdown,\r\n rankCost,\r\n resolveLadder,\r\n resolveSlots,\r\n resolveStats,\r\n roster,\r\n statNumber,\r\n unequipAllSlots,\r\n type GearPiece,\r\n type ItemDefWithGear,\r\n type RosterEntry,\r\n type SlotView,\r\n type StatView,\r\n} from \"./model\";\r\nimport { t } from \"./i18n\";\r\nimport { readSelected, resolveSelected, saveSelected } from \"./selection\";\r\nimport { HeroesPopup } from \"./components/HeroesPopup\";\r\nimport { PiecePopup } from \"./components/PiecePopup\";\r\nimport { SkinsPopup } from \"./components/SkinsPopup\";\r\nimport { SlotPopup } from \"./components/SlotPopup\";\r\nimport { StatsPopup } from \"./components/StatsPopup\";\r\nimport { useData } from \"./components/useData\";\r\nimport {\r\n HeroArt,\r\n heroKind,\r\n LockArt,\r\n PATTERN,\r\n SlotSilhouette,\r\n classColor,\r\n slotKind,\r\n type SlotKind,\r\n} from \"./components/art\";\r\n\r\n// Heroes in the reference's look (BoardSurvival BoardScene: Equipment = EquipmentPanel,\r\n// Character_Detail = CharacterDetailPanel, Character_List, Character_Stat_Popup, Popup_EquipmentItem).\r\n// The main screen is the equipment hub: the hero in the middle of a blue backdrop with three gear\r\n// slots on each side, Damage and Health under the name, Detail / Unequip All / Equip All, and below\r\n// a bar of category tabs over the gear the player owns. Detail opens the hero's card (rarity, rank,\r\n// stats, lore, Upgrade Level, STATS). Everything is laid out in the reference's 1080-wide canvas\r\n// units and scaled to the screen, so the numbers come straight from the scene.\r\n\r\nconst W = 1080;\r\nconst INK = \"#10172e\";\r\n\r\nexport function makeCharacterScreen(\r\n features: FeatureRegistry,\r\n onSelect: (characterId: string) => void,\r\n) {\r\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\r\n return (\r\n <Heroes\r\n features={features}\r\n onSelect={onSelect}\r\n openInstance={\r\n typeof args?.itemInstanceID === \"string\" ? args.itemInstanceID : null\r\n }\r\n />\r\n );\r\n };\r\n}\r\n\r\ntype PopupState =\r\n | { kind: \"heroes\" }\r\n | { kind: \"stats\" }\r\n | { kind: \"skins\" }\r\n | { kind: \"detail\" }\r\n | { kind: \"detailStats\" }\r\n | { kind: \"slot\"; slot: SlotView }\r\n | { kind: \"piece\"; instanceID: string }\r\n | null;\r\n\r\n/** Slot positions (Group_Slots): weapon, ring, shoes on the left; helmet, torso, pants on the right. */\r\nconst SLOT_POS: Array<{ kind: SlotKind; x: number; y: number }> = [\r\n { kind: \"weapon\", x: -400, y: 210 },\r\n { kind: \"ring\", x: -400, y: 470 },\r\n { kind: \"shoes\", x: -400, y: 730 },\r\n { kind: \"helmet\", x: 400, y: 210 },\r\n { kind: \"torso\", x: 400, y: 470 },\r\n { kind: \"pants\", x: 400, y: 730 },\r\n];\r\n\r\n/** Places the hero's slots: each on its kind's spot, the rest on the free spots in order. */\r\nfunction placeSlots(slots: SlotView[]): Array<{ slot: SlotView; x: number; y: number; kind: SlotKind }> {\r\n const free = [...SLOT_POS];\r\n const placed: Array<{ slot: SlotView; x: number; y: number; kind: SlotKind }> = [];\r\n const rest: SlotView[] = [];\r\n for (const slot of slots) {\r\n const kind = slotKind(slot.id);\r\n const i = free.findIndex((p) => p.kind === kind);\r\n if (i >= 0) {\r\n const p = free.splice(i, 1)[0]!;\r\n placed.push({ slot, x: p.x, y: p.y, kind });\r\n } else rest.push(slot);\r\n }\r\n for (const slot of rest) {\r\n const p = free.shift();\r\n if (!p) break;\r\n placed.push({ slot, x: p.x, y: p.y, kind: slotKind(slot.id) === \"other\" ? p.kind : slotKind(slot.id) });\r\n }\r\n return placed;\r\n}\r\n\r\n/** Damage and Health under the name (Stat_Attack / Stat_Hp), by the hero's stat ids. */\r\nfunction headlineStats(stats: StatView[]): [StatView | undefined, StatView | undefined] {\r\n const attack = stats.find((s) => /att|dam|dmg|str|pow/i.test(s.id) || /att|dam/i.test(s.name));\r\n const hp = stats.find((s) => /^hp|health|life|vit/i.test(s.id) || /hp|health/i.test(s.name));\r\n return [attack ?? stats[0], hp ?? stats.find((s) => s !== (attack ?? stats[0]))];\r\n}\r\n\r\nfunction Heroes({\r\n features,\r\n onSelect,\r\n openInstance,\r\n}: {\r\n features: FeatureRegistry;\r\n onSelect: (characterId: string) => void;\r\n openInstance: string | null;\r\n}): ReactNode {\r\n const { client, section, owned, items, pieces, balances } = useData();\r\n const catalog = useCatalog();\r\n const toast = useToast();\r\n const kit = useUiKit();\r\n const heroRef = useRef<HTMLDivElement>(null);\r\n const [popup, setPopup] = useState<PopupState>(\r\n openInstance ? { kind: \"piece\", instanceID: openInstance } : null,\r\n );\r\n const [busy, setBusy] = useState<string | null>(null);\r\n const [selectedID, setSelectedID] = useState<string | null>(readSelected);\r\n const [tab, setTab] = useState<string>(\"all\");\r\n\r\n const list = roster(section, owned);\r\n if (list.length === 0) return <EmptyState glyph=\"hero\" text={t(\"noHeroes\")} />;\r\n const hero = resolveSelected(list, selectedID)!;\r\n const select = (id: string) => {\r\n setSelectedID(id);\r\n saveSelected(id);\r\n onSelect(id);\r\n };\r\n\r\n const slots = resolveSlots(section, hero.def);\r\n const changes =\r\n hero.state === \"locked\"\r\n ? []\r\n : bestLoadout(hero.id, hero.rank, slots, pieces, hero.model?.Equipment, items);\r\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\r\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\r\n const color = classColor(hero.def.Classification?.ClassID);\r\n const stats = resolveStats(section, hero.def);\r\n const worn = wornGear(hero, pieces, items);\r\n const levels = hero.model?.StatLevels ?? {};\r\n const value = (s: StatView | undefined): string =>\r\n s ? statNumber(breakdown(s, Number(levels[s.id] ?? 0), hero.rank, hero.def, worn).total) : \"—\";\r\n const [atk, hp] = headlineStats(stats);\r\n\r\n const equipAll = async () => {\r\n if (changes.length === 0) return;\r\n setBusy(\"best\");\r\n const res = await client.character.equipItems(hero.id, changes);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(\"claim\");\r\n kit.fx.burst(heroRef.current);\r\n };\r\n\r\n // Always enabled (Unity EquipmentPanel.OnUnequipAllClicked): the way out of a local/server mismatch.\r\n const unequipAll = async () => {\r\n setBusy(\"off\");\r\n const slotIDs = unequipAllSlots(hero.id, slots, hero.model?.Equipment, pieces);\r\n const res = slotIDs.length > 0 ? await client.character.unequipItems(hero.id, slotIDs) : null;\r\n await client.cache.ensureState([\"Character\", \"InventoryV2\"], { force: true });\r\n setBusy(null);\r\n if (res && !res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(\"click\");\r\n };\r\n\r\n // Category tabs (EquipmentCategoryTab): All, then one per slot kind the gear covers.\r\n const bagPieces = pieces.filter((p) => !p.equippedBy);\r\n const kindOf = (p: GearPiece): SlotKind => slotKind(items.get(p.itemID)?.ItemClass ?? p.itemID);\r\n const tabs: Array<{ id: string; kind: SlotKind }> = [\r\n { id: \"all\", kind: \"bag\" },\r\n ...([\"weapon\", \"ring\", \"shoes\", \"helmet\", \"torso\", \"pants\", \"other\"] as SlotKind[])\r\n .filter((k) => bagPieces.some((p) => kindOf(p) === k) || placeSlots(slots).some((s) => s.kind === k))\r\n .map((k) => ({ id: k, kind: k })),\r\n ];\r\n const shown = tab === \"all\" ? bagPieces : bagPieces.filter((p) => kindOf(p) === tab);\r\n const heroesBadge = list.filter(\r\n (h) => h.state === \"locked\" && h.unlockPrice.length > 0 && canAfford(h.unlockPrice, balances),\r\n ).length;\r\n\r\n return (\r\n <div style={{ margin: \"-14px -14px -24px\" }}>\r\n {/* ── Middle: the hero on the blue backdrop (the backdrop spans the screen, the canvas fits it) ── */}\r\n <div style={{ position: \"relative\", overflow: \"hidden\" }}>\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n inset: 0,\r\n background: \"radial-gradient(ellipse at 50% 35%, #5d93f7 0%, #3d87f5 45%, #2c6fe0 100%)\",\r\n }}\r\n />\r\n <div style={{ position: \"absolute\", inset: 0, background: PATTERN, animation: \"idos-hero-pattern 6s linear infinite\" }} />\r\n <Fit height={1111}>\r\n <div style={{ position: \"relative\", height: 1111 }}>\r\n <div style={{ ...center(0, 603, 174, 45), borderRadius: \"50%\", background: \"rgba(0,0,0,.2)\" }} />\r\n <div\r\n ref={heroRef}\r\n onClick={() => setPopup({ kind: \"heroes\" })}\r\n style={{ ...center(0, 355, 384, 512), cursor: \"pointer\", animation: \"idos-hero-idle 2.4s ease-in-out infinite\", transformOrigin: \"50% 100%\" }}\r\n >\r\n <HeroArt\r\n color={color}\r\n kind={heroKind(hero.def.Classification?.ClassID)}\r\n style={{ filter: hero.state === \"locked\" ? \"grayscale(1) brightness(.7)\" : undefined }}\r\n />\r\n {heroesBadge > 0 ? (\r\n <span style={{ position: \"absolute\", right: 50, top: 20, transform: \"scale(2.6)\", transformOrigin: \"100% 0\" }}>\r\n <Badge count={heroesBadge} />\r\n </span>\r\n ) : null}\r\n </div>\r\n <div style={{ ...center(0, 665, 700, 81), display: \"grid\", placeItems: \"center\", ...text(64) }}>{heroName}</div>\r\n\r\n {placeSlots(slots).map(({ slot, x, y, kind }) => (\r\n <SlotFrame\r\n key={slot.id}\r\n style={center(x, y, 210, 210)}\r\n kind={kind}\r\n slot={slot}\r\n hero={hero}\r\n pieces={pieces}\r\n onClick={() => setPopup({ kind: \"slot\", slot })}\r\n />\r\n ))}\r\n\r\n <HeadStat style={center(-91, 809, 234, 103)} glyph=\"sword\" label={atk?.name ?? t(\"power\")} value={atk ? value(atk) : formatAmount(hero.power)} />\r\n <HeadStat style={center(142.5, 809, 234, 103)} glyph=\"heart\" label={hp?.name ?? t(\"rank\")} value={hp ? value(hp) : String(hero.rank)} />\r\n\r\n <UButton style={center(-400, 971, 250, 120)} tone=\"purple\" onClick={() => setPopup({ kind: \"detail\" })}>\r\n {t(\"detail\")}\r\n </UButton>\r\n {hero.state !== \"locked\" ? (\r\n <>\r\n <UButton style={center(-135, 971, 250, 120)} tone=\"sky\" size={34} busy={busy === \"off\"} onClick={() => void unequipAll()}>\r\n {t(\"unequipAll\")}\r\n </UButton>\r\n <UButton\r\n style={center(135, 971, 250, 120)}\r\n tone=\"yellow\"\r\n busy={busy === \"best\"}\r\n disabled={changes.length === 0}\r\n attract={changes.length > 0}\r\n onClick={() => void equipAll()}\r\n data-tutorial-anchor=\"heroes:equip-best\"\r\n >\r\n {t(\"equipAll\")}\r\n </UButton>\r\n {skinCount > 0 ? (\r\n <UButton style={center(400, 971, 250, 120)} tone=\"yellow\" onClick={() => setPopup({ kind: \"skins\" })}>\r\n {t(\"skins\")}\r\n </UButton>\r\n ) : null}\r\n </>\r\n ) : null}\r\n </div>\r\n </Fit>\r\n </div>\r\n\r\n {/* ── Category tabs (Menu_MiddleBtn) ── */}\r\n <div\r\n style={{\r\n height: 58,\r\n background: \"linear-gradient(180deg,#252a49 0%,#1f2440 100%)\",\r\n borderTop: `3px solid ${INK}`,\r\n boxShadow: \"inset 0 4px 0 rgba(255,255,255,.08)\",\r\n display: \"flex\",\r\n justifyContent: \"center\",\r\n overflowX: \"auto\",\r\n }}\r\n className=\"idos-scroll\"\r\n >\r\n {tabs.map((tb) => (\r\n <button\r\n key={tb.id}\r\n type=\"button\"\r\n onClick={() => {\r\n kit.play(\"tab\");\r\n setTab(tb.id);\r\n }}\r\n style={{\r\n flex: \"0 1 88px\",\r\n minWidth: 52,\r\n height: 56,\r\n border: \"none\",\r\n background: tab === tb.id ? \"rgba(255,255,255,.07)\" : \"transparent\",\r\n position: \"relative\",\r\n cursor: \"pointer\",\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n }}\r\n >\r\n <span style={{ marginBottom: 6 }}>\r\n <SlotSilhouette kind={tb.kind} size={34} opacity={tab === tb.id ? 1 : 0.55} />\r\n </span>\r\n {tab === tb.id ? (\r\n <span style={{ position: \"absolute\", left: 8, right: 8, bottom: 5, height: 8, borderRadius: 4, background: \"#3082ff\", boxShadow: \"0 0 8px #3082ff\" }} />\r\n ) : null}\r\n </button>\r\n ))}\r\n </div>\r\n\r\n {/* ── The gear (Scroll View) ── */}\r\n <div style={{ background: \"#1a1f38\", padding: \"14px 12px 22px\", minHeight: 180 }}>\r\n {shown.length === 0 ? (\r\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14, padding: \"22px 16px\" }}>\r\n <div style={{ ...text(15), opacity: 0.75, whiteSpace: \"normal\", textAlign: \"center\", maxWidth: 420 }}>{t(\"noGear\")}</div>\r\n {features.get(\"store\") ? (\r\n <UButton style={{ position: \"relative\", width: 140, height: 50 }} size={20} tone=\"green\" onClick={() => features.open(\"store\")}>\r\n {catalog.localize(\"Shop\")}\r\n </UButton>\r\n ) : null}\r\n </div>\r\n ) : (\r\n <div\r\n style={{\r\n display: \"grid\",\r\n gridTemplateColumns: \"repeat(auto-fill, minmax(84px, 1fr))\",\r\n gap: 12,\r\n justifyItems: \"center\",\r\n maxWidth: 900,\r\n margin: \"0 auto\",\r\n }}\r\n >\r\n {shown.map((p, i) => (\r\n <div key={p.instanceID} style={{ animation: `idos-hero-pop .3s cubic-bezier(.34,1.56,.64,1) ${Math.min(i, 12) * 0.03}s both` }}>\r\n <ItemTile itemID={p.itemID} level={p.level} size={80} onClick={() => setPopup({ kind: \"piece\", instanceID: p.instanceID })} />\r\n </div>\r\n ))}\r\n </div>\r\n )}\r\n </div>\r\n\r\n <style>{HERO_CSS}</style>\r\n\r\n {popup?.kind === \"detail\" || popup?.kind === \"detailStats\" ? (\r\n <DetailWindow\r\n hero={hero}\r\n stats={stats}\r\n value={value}\r\n balances={balances}\r\n section={section}\r\n onStats={() => setPopup({ kind: \"detailStats\" })}\r\n onClose={() => setPopup(null)}\r\n />\r\n ) : null}\r\n {popup?.kind === \"heroes\" ? (\r\n <HeroesPopup list={list} current={hero.id} balances={balances} onSelect={select} onClose={() => setPopup(null)} />\r\n ) : null}\r\n {popup?.kind === \"stats\" || popup?.kind === \"detailStats\" ? (\r\n <StatsPopup\r\n hero={hero}\r\n section={section}\r\n pieces={pieces}\r\n items={items}\r\n balances={balances}\r\n onClose={() => setPopup(popup.kind === \"detailStats\" ? { kind: \"detail\" } : null)}\r\n />\r\n ) : null}\r\n {popup?.kind === \"skins\" ? <SkinsPopup hero={hero} onClose={() => setPopup(null)} /> : null}\r\n {popup?.kind === \"slot\" ? (\r\n <SlotPopup\r\n slot={popup.slot}\r\n hero={hero}\r\n pieces={pieces}\r\n items={items}\r\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\r\n onClose={() => setPopup(null)}\r\n />\r\n ) : null}\r\n {popup?.kind === \"piece\" ? (\r\n <PiecePopup\r\n instanceID={popup.instanceID}\r\n hero={hero}\r\n slots={slots}\r\n pieces={pieces}\r\n items={items}\r\n balances={balances}\r\n onClose={() => setPopup(null)}\r\n />\r\n ) : null}\r\n </div>\r\n );\r\n}\r\n\r\nfunction wornGear(\r\n hero: RosterEntry,\r\n pieces: GearPiece[],\r\n items: Map<string, ItemDefWithGear>,\r\n): Array<{ def: ItemDefWithGear | undefined; level: number }> {\r\n return Object.values(hero.model?.Equipment ?? {})\r\n .filter((e) => e?.ItemInstanceID)\r\n .map((e) => {\r\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\r\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\r\n });\r\n}\r\n\r\n// ── Layout helpers ───────────────────────────────────────────────────────────────────────────\r\n\r\n/**\r\n * The 1080-wide canvas of a section, scaled to fit both the width AND a share of the window height\r\n * (a phone fits it by width, a wide screen by height) and centred — the backdrop behind spans the\r\n * whole width, so a wide screen never shows a giant stretched hero nor a strip of empty page.\r\n */\r\nfunction Fit({ height, children }: { height: number; children: ReactNode }): ReactNode {\r\n const outer = useRef<HTMLDivElement>(null);\r\n const [scale, setScale] = useState(0.35);\r\n useLayoutEffect(() => {\r\n const o = outer.current;\r\n if (!o) return;\r\n const fit = (): void => {\r\n const byWidth = o.clientWidth / W;\r\n const byHeight = (window.innerHeight * 0.56) / height;\r\n const s = Math.max(0.22, Math.min(byWidth, byHeight, 0.9));\r\n if (s > 0) setScale(s);\r\n };\r\n fit();\r\n const ro = new ResizeObserver(fit);\r\n ro.observe(o);\r\n window.addEventListener(\"resize\", fit);\r\n return () => {\r\n ro.disconnect();\r\n window.removeEventListener(\"resize\", fit);\r\n };\r\n }, [height]);\r\n return (\r\n <div ref={outer} style={{ position: \"relative\", width: \"100%\", height: height * scale }}>\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n left: \"50%\",\r\n top: 0,\r\n width: W,\r\n height,\r\n marginLeft: -W / 2,\r\n transform: `scale(${scale})`,\r\n transformOrigin: \"50% 0\",\r\n }}\r\n >\r\n {children}\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\n/** A box centred at (x, y): x from the canvas centre, y from the top of its section. */\r\nfunction center(x: number, y: number, w: number, h: number): CSSProperties {\r\n return { position: \"absolute\", left: W / 2 + x - w / 2, top: y - h / 2, width: w, height: h };\r\n}\r\n\r\n/** A font size that fits `label` into `width` canvas units (long translations shrink, never wrap). */\r\nfunction fitText(label: string, width: number, max: number): number {\r\n return Math.min(max, Math.floor(width / Math.max(1, label.length * 0.62)));\r\n}\r\n\r\n/** The reference's outlined white game text. */\r\nfunction text(size: number, color = \"#fff\"): CSSProperties {\r\n const w = Math.max(2, Math.round(size / 14));\r\n return {\r\n color,\r\n fontSize: size,\r\n fontWeight: 900,\r\n lineHeight: 1.1,\r\n WebkitTextStroke: `${w}px ${INK}`,\r\n paintOrder: \"stroke fill\",\r\n textShadow: `0 ${w + 1}px 0 ${INK}`,\r\n whiteSpace: \"nowrap\",\r\n };\r\n}\r\n\r\nconst TONES: Record<string, [string, string, string]> = {\r\n purple: [\"#b46cff\", \"#8a3cf0\", \"#5a1fb0\"],\r\n sky: [\"#5cc8ff\", \"#1c8fe6\", \"#0d5aa8\"],\r\n yellow: [\"#ffd84a\", \"#f5a90c\", \"#a8650a\"],\r\n green: [\"#7be34a\", \"#33b31d\", \"#1f7a10\"],\r\n};\r\n\r\n/** Button01 (purple / sky / yellow / green): a glossy plate, dark outline, a bottom lip. */\r\nfunction UButton({\r\n style,\r\n tone,\r\n size = 42,\r\n busy,\r\n disabled,\r\n attract,\r\n onClick,\r\n children,\r\n ...rest\r\n}: {\r\n style: CSSProperties;\r\n tone: keyof typeof TONES;\r\n size?: number;\r\n busy?: boolean;\r\n disabled?: boolean;\r\n attract?: boolean;\r\n onClick: () => void;\r\n children: ReactNode;\r\n \"data-tutorial-anchor\"?: string;\r\n}): ReactNode {\r\n const play = useSound();\r\n const [top, bottom, lip] = TONES[tone]!;\r\n const off = disabled || busy;\r\n return (\r\n <button\r\n type=\"button\"\r\n {...rest}\r\n disabled={off}\r\n onClick={() => {\r\n play(\"click\");\r\n onClick();\r\n }}\r\n style={{\r\n ...style,\r\n borderRadius: 34,\r\n border: `6px solid ${INK}`,\r\n background: `linear-gradient(180deg, ${top} 0%, ${bottom} 100%)`,\r\n boxShadow: `inset 0 8px 0 rgba(255,255,255,.4), 0 9px 0 ${lip}, 0 14px 0 rgba(0,0,0,.3)`,\r\n cursor: off ? \"default\" : \"pointer\",\r\n filter: disabled ? \"grayscale(.55) brightness(.85)\" : undefined,\r\n animation: attract ? \"idos-hero-attract 1.6s ease-in-out infinite\" : undefined,\r\n fontFamily: \"inherit\",\r\n padding: \"0 10px\",\r\n ...text(size),\r\n }}\r\n >\r\n {busy ? \"…\" : children}\r\n </button>\r\n );\r\n}\r\n\r\n/** An equipment slot (EquipmentSlotView): a blue frame, the slot's white silhouette, a lock. */\r\nfunction SlotFrame({\r\n style,\r\n kind,\r\n slot,\r\n hero,\r\n pieces,\r\n onClick,\r\n}: {\r\n style: CSSProperties;\r\n kind: SlotKind;\r\n slot: SlotView;\r\n hero: RosterEntry;\r\n pieces: GearPiece[];\r\n onClick: () => void;\r\n}): ReactNode {\r\n const play = useSound();\r\n const worn = hero.model?.Equipment?.[slot.id];\r\n const piece = worn?.ItemInstanceID ? pieces.find((p) => p.instanceID === worn.ItemInstanceID) : undefined;\r\n const locked = hero.rank < slot.minRank;\r\n return (\r\n <div style={style}>\r\n {worn?.ItemID ? (\r\n <ItemTile itemID={worn.ItemID} level={piece?.level} size={210} onClick={onClick} />\r\n ) : (\r\n <button\r\n type=\"button\"\r\n onClick={() => {\r\n play(\"click\");\r\n onClick();\r\n }}\r\n style={{\r\n width: \"100%\",\r\n height: \"100%\",\r\n borderRadius: 40,\r\n border: `6px solid ${INK}`,\r\n background: \"radial-gradient(circle at 50% 35%, #1a7de8 0%, #0a69d6 60%, #0858b8 100%)\",\r\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.28), 0 8px 0 rgba(0,0,0,.3)\",\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n cursor: \"pointer\",\r\n position: \"relative\",\r\n }}\r\n >\r\n <SlotSilhouette kind={kind} size={130} opacity={0.39} />\r\n {locked ? (\r\n <span style={{ position: \"absolute\", left: 105 + 77 - 25, top: 105 + 65 - 35 }}>\r\n <LockArt size={71} />\r\n </span>\r\n ) : null}\r\n </button>\r\n )}\r\n {locked ? (\r\n <div style={{ position: \"absolute\", left: 0, right: 0, bottom: -44, textAlign: \"center\", ...text(28) }}>\r\n {t(\"slotLocked\")} {slot.minRank}\r\n </div>\r\n ) : null}\r\n </div>\r\n );\r\n}\r\n\r\n/** Stat_Attack / Stat_Hp: an icon, the stat's name and its value. */\r\nfunction HeadStat({ style, glyph, label, value }: { style: CSSProperties; glyph: \"sword\" | \"heart\"; label: string; value: string }): ReactNode {\r\n return (\r\n <div style={{ ...style, display: \"flex\", alignItems: \"center\", gap: 14 }}>\r\n <StatGlyph kind={glyph} size={78} />\r\n <div style={{ display: \"grid\" }}>\r\n <span style={text(30)}>{label}</span>\r\n <span style={text(44)}>{value}</span>\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\nfunction StatGlyph({ kind, size }: { kind: \"sword\" | \"heart\" | \"shield\" | \"crown\"; size: number }): ReactNode {\r\n const shapes = {\r\n sword: (\r\n <g stroke={INK} strokeWidth=\"5\" strokeLinejoin=\"round\">\r\n <path d=\"M60 6 L70 16 L32 54 L24 46 Z\" fill=\"#dfe8f5\" />\r\n <path d=\"M20 40 L38 58 L32 64 L14 46 Z\" fill=\"#ffc21a\" />\r\n <path d=\"M14 56 L22 64 L10 76 L2 68 Z\" fill=\"#8a5a2b\" />\r\n </g>\r\n ),\r\n heart: (\r\n <path\r\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\"\r\n fill=\"#f0353b\"\r\n stroke={INK}\r\n strokeWidth=\"5\"\r\n strokeLinejoin=\"round\"\r\n />\r\n ),\r\n shield: (\r\n <path d=\"M39 4 L70 16 Q70 52 39 74 Q8 52 8 16 Z\" fill=\"#3d8cf0\" stroke={INK} strokeWidth=\"5\" strokeLinejoin=\"round\" />\r\n ),\r\n crown: (\r\n <path d=\"M8 60 L8 22 L24 36 L39 12 L54 36 L70 22 L70 60 Z\" fill=\"#ffd23a\" stroke={INK} strokeWidth=\"5\" strokeLinejoin=\"round\" />\r\n ),\r\n };\r\n return (\r\n <svg viewBox=\"0 0 78 78\" width={size} height={size} aria-hidden style={{ flex: \"none\" }}>\r\n {shapes[kind]}\r\n </svg>\r\n );\r\n}\r\n\r\n// ── Character_Detail (CharacterDetailPanel) ─────────────────────────────────────────────────\r\n\r\nconst DH = 2340;\r\n/** A box in the detail canvas: x from the centre, y up from the centre (Unity), canvas 1080 × 2340. */\r\nfunction dAt(x: number, y: number, w: number, h: number): CSSProperties {\r\n return { position: \"absolute\", left: W / 2 + x - w / 2, top: DH / 2 - y - h / 2, width: w, height: h };\r\n}\r\n\r\nfunction DetailWindow({\r\n hero,\r\n stats,\r\n value,\r\n balances,\r\n section,\r\n onStats,\r\n onClose,\r\n}: {\r\n hero: RosterEntry;\r\n stats: StatView[];\r\n value: (s: StatView | undefined) => string;\r\n balances: Record<string, { Amount?: number }> | undefined;\r\n section: Parameters<typeof resolveLadder>[0];\r\n onStats: () => void;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const { client } = useData();\r\n const catalog = useCatalog();\r\n const kit = useUiKit();\r\n const toast = useToast();\r\n const host = useRef<HTMLDivElement>(null);\r\n const card = useRef<HTMLDivElement>(null);\r\n const [scale, setScale] = useState(0.35);\r\n const [shown, setShown] = useState(false);\r\n const [busy, setBusy] = useState(false);\r\n\r\n useLayoutEffect(() => {\r\n const el = host.current;\r\n if (!el) return;\r\n const fit = (): void => setScale(Math.min(el.clientWidth / W, el.clientHeight / DH));\r\n fit();\r\n const ro = new ResizeObserver(fit);\r\n ro.observe(el);\r\n return () => ro.disconnect();\r\n }, []);\r\n useEffect(() => {\r\n kit.play(\"open\");\r\n const id = requestAnimationFrame(() => setShown(true));\r\n return () => cancelAnimationFrame(id);\r\n }, []);\r\n\r\n const close = (): void => {\r\n kit.play(\"close\");\r\n setShown(false);\r\n setTimeout(onClose, 220);\r\n };\r\n\r\n const ladder = resolveLadder(section, hero.def);\r\n const nextRank = rankCost(ladder, hero.rank);\r\n const affordable = nextRank !== null && canAfford(nextRank, balances);\r\n const rarityID = hero.def.Classification?.RarityID ?? \"\";\r\n const rarity = catalog.rarityColor(rarityID);\r\n const maxRank = Number(ladder?.MaxRank ?? 0);\r\n const stars = Math.max(0, Math.min(6, maxRank > 0 ? Math.round((hero.rank / maxRank) * 6) : Math.min(6, hero.rank)));\r\n\r\n const rankUp = async (): Promise<void> => {\r\n setBusy(true);\r\n const res = await client.character.upgradeCharacterLevel(hero.id);\r\n setBusy(false);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(\"levelUp\");\r\n kit.fx.burst(card.current);\r\n };\r\n\r\n const four = stats.slice(0, 4);\r\n const statGlyph = (s: StatView): \"sword\" | \"heart\" | \"shield\" | \"crown\" =>\r\n /hp|health|life|vit/i.test(s.id) ? \"heart\" : /def|arm|block/i.test(s.id) ? \"shield\" : /att|dam|dmg|str/i.test(s.id) ? \"sword\" : \"crown\";\r\n\r\n return (\r\n <KitPortal>\r\n <div\r\n ref={host}\r\n style={{\r\n position: \"fixed\",\r\n inset: 0,\r\n // Below the kit's popups (20): STATS opens over the card, like Character_Stat_Popup.\r\n zIndex: 15,\r\n overflow: \"hidden\",\r\n pointerEvents: \"auto\",\r\n opacity: shown ? 1 : 0,\r\n transform: shown ? \"scale(1)\" : \"scale(1.06, .94)\",\r\n transition: \"opacity .22s ease, transform .3s cubic-bezier(.34,1.56,.64,1)\",\r\n background: \"radial-gradient(ellipse at 50% 30%, #6a5cff 0%, #3d87f5 55%, #2c6fe0 100%)\",\r\n }}\r\n >\r\n <div style={{ position: \"absolute\", inset: 0, background: PATTERN }} />\r\n <div\r\n style={{\r\n position: \"absolute\",\r\n left: \"50%\",\r\n top: \"50%\",\r\n width: W,\r\n height: DH,\r\n marginLeft: -W / 2,\r\n marginTop: -DH / 2,\r\n transform: `scale(${scale})`,\r\n }}\r\n >\r\n {/* Rarity label + name */}\r\n <div\r\n style={{\r\n ...dAt(0, 890, 220, 66),\r\n background: rarity,\r\n transform: \"skewX(-14deg)\",\r\n border: `5px solid ${INK}`,\r\n borderRadius: 10,\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n }}\r\n >\r\n <span style={{ ...text(30), transform: \"skewX(14deg)\" }}>{(rarityID || \"—\").toUpperCase()}</span>\r\n </div>\r\n <div style={{ ...dAt(0, 805, 900, 113), display: \"grid\", placeItems: \"center\", ...text(90) }}>\r\n {catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}\r\n </div>\r\n {/* The hero */}\r\n <div style={{ ...dAt(-2, 104, 220, 56), borderRadius: \"50%\", background: \"rgba(0,0,0,.2)\" }} />\r\n <div ref={card} style={{ ...dAt(0, 416, 480, 640), animation: \"idos-hero-idle 2.4s ease-in-out infinite\", transformOrigin: \"50% 100%\" }}>\r\n <HeroArt color={classColor(hero.def.Classification?.ClassID)} kind={heroKind(hero.def.Classification?.ClassID)} />\r\n </div>\r\n {/* Rank badge, stars, class */}\r\n <div style={{ ...dAt(-208, 17, 102, 107), display: \"grid\", placeItems: \"center\" }}>\r\n <svg viewBox=\"0 0 102 107\" style={{ position: \"absolute\", inset: 0 }} aria-hidden>\r\n <path d=\"M51 4 L94 26 L94 76 L51 103 L8 76 L8 26 Z\" fill=\"#2f86f0\" stroke={INK} strokeWidth=\"6\" strokeLinejoin=\"round\" />\r\n <path d=\"M51 14 L84 32 L84 50 L18 50 L18 32 Z\" fill=\"rgba(255,255,255,.25)\" />\r\n </svg>\r\n <span style={{ position: \"relative\", ...text(52) }}>{hero.rank}</span>\r\n </div>\r\n <div style={{ ...dAt(0, 17, 300, 78), display: \"flex\", justifyContent: \"center\" }}>\r\n {Array.from({ length: 6 }, (_, k) => (\r\n <svg key={k} viewBox=\"0 0 71 76\" width={56} height={60} aria-hidden>\r\n <path\r\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\"\r\n fill={k < stars ? \"#ffd23a\" : \"rgba(16,23,46,.45)\"}\r\n stroke={INK}\r\n strokeWidth=\"5\"\r\n strokeLinejoin=\"round\"\r\n />\r\n </svg>\r\n ))}\r\n </div>\r\n {hero.def.Classification?.ClassID ? (\r\n <div\r\n style={{\r\n ...dAt(195, 17, 78, 78),\r\n borderRadius: \"50%\",\r\n border: `6px solid ${INK}`,\r\n background: classColor(hero.def.Classification.ClassID),\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n ...text(30),\r\n }}\r\n >\r\n {hero.def.Classification.ClassID.slice(0, 1).toUpperCase()}\r\n </div>\r\n ) : null}\r\n\r\n {/* Four stats */}\r\n {four.map((s, i) => (\r\n <div\r\n key={s.id}\r\n style={{\r\n ...dAt(i % 2 === 0 ? -230 : 230, i < 2 ? -232 : -392, 410, 115),\r\n borderRadius: 26,\r\n border: `5px solid ${INK}`,\r\n background: \"linear-gradient(180deg,#2c6bdf,#1d58c7)\",\r\n boxShadow: \"inset 0 6px 0 rgba(255,255,255,.2)\",\r\n display: \"flex\",\r\n alignItems: \"center\",\r\n gap: 16,\r\n padding: \"0 18px\",\r\n boxSizing: \"border-box\",\r\n }}\r\n >\r\n <StatGlyph kind={statGlyph(s)} size={86} />\r\n <div style={{ display: \"grid\", minWidth: 0 }}>\r\n <span style={{ ...text(34), overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{s.name}</span>\r\n <span style={text(52)}>{value(s)}</span>\r\n </div>\r\n </div>\r\n ))}\r\n {/* Lore */}\r\n <div\r\n style={{\r\n ...dAt(0, -606, 870, 220),\r\n borderRadius: 26,\r\n border: `5px solid ${INK}`,\r\n background: \"linear-gradient(180deg,#b3c2d6,#9fb0c8)\",\r\n boxShadow: \"inset 0 6px 0 rgba(255,255,255,.45)\",\r\n padding: \"18px 34px\",\r\n boxSizing: \"border-box\",\r\n display: \"grid\",\r\n gridTemplateRows: \"auto 1fr\",\r\n justifyItems: \"center\",\r\n gap: 8,\r\n }}\r\n >\r\n <span style={text(38)}>{t(\"lore\")}</span>\r\n <span style={{ fontSize: 30, fontWeight: 700, color: \"#1c2340\", textAlign: \"center\", lineHeight: 1.25, whiteSpace: \"normal\" }}>\r\n {catalog.localize(hero.def.Identity?.Description ?? \"\") || \"—\"}\r\n </span>\r\n </div>\r\n {/* Upgrade Level (= rank up) and STATS */}\r\n {hero.state !== \"locked\" ? (\r\n <>\r\n <UButton\r\n style={dAt(-220, -810, 400, 175)}\r\n tone=\"green\"\r\n busy={busy}\r\n disabled={nextRank === null || !affordable}\r\n attract={affordable}\r\n onClick={() => void rankUp()}\r\n data-tutorial-anchor=\"heroes:rank-up\"\r\n >\r\n <span style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\r\n <span style={text(fitText(nextRank === null ? t(\"maxRank\") : t(\"upgradeLevel\"), 360, 40))}>\r\n {nextRank === null ? t(\"maxRank\") : t(\"upgradeLevel\")}\r\n </span>\r\n {nextRank && nextRank.length ? (\r\n <span style={{ transform: \"scale(2.3)\", transformOrigin: \"50% 0\", marginBottom: 30 }}>\r\n <ResourceList lines={nextRank} size={22} dimIf={() => !affordable} />\r\n </span>\r\n ) : nextRank ? (\r\n <span style={text(40)}>{t(\"free\")}</span>\r\n ) : null}\r\n </span>\r\n </UButton>\r\n <UButton style={dAt(220, -810, 400, 175)} tone=\"yellow\" size={fitText(t(\"statsCaps\"), 360, 55)} onClick={onStats}>\r\n {t(\"statsCaps\")}\r\n </UButton>\r\n </>\r\n ) : null}\r\n {/* Bottom bar with the round close button */}\r\n <div\r\n style={{\r\n ...dAt(0, -1037, W, 266),\r\n background: \"linear-gradient(180deg,#252a49,#1f2440)\",\r\n borderTop: `6px solid ${INK}`,\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n }}\r\n >\r\n <button\r\n type=\"button\"\r\n aria-label=\"Close\"\r\n onClick={close}\r\n style={{\r\n width: 117,\r\n height: 118,\r\n borderRadius: \"50%\",\r\n border: `6px solid ${INK}`,\r\n background: \"linear-gradient(180deg,#5d6a92,#3b4568)\",\r\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.25), 0 8px 0 rgba(0,0,0,.4)\",\r\n cursor: \"pointer\",\r\n ...text(56),\r\n lineHeight: 1,\r\n fontFamily: \"inherit\",\r\n }}\r\n >\r\n ✕\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n </KitPortal>\r\n );\r\n}\r\n\r\nconst HERO_CSS = `\r\n@keyframes idos-hero-idle { 0%,100% { transform: scale(1, 1); } 50% { transform: scale(1.025, .975); } }\r\n@keyframes idos-hero-pattern { from { background-position: 0 0; } to { background-position: 72px 72px; } }\r\n@keyframes idos-hero-pop { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }\r\n@keyframes idos-hero-attract { 0%,100% { transform: scale(1); } 50% { transform: scale(1.05); } }\r\n`;\r\n"
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
|
-
"path": "components/
|
|
69
|
-
"content": "import {
|
|
68
|
+
"path": "components/art.tsx",
|
|
69
|
+
"content": "import type { ReactNode } from \"react\";\n\n// The hero screen's art, redrawn in SVG after the reference (BoardSurvival Content/Sprites:\n// Characters, SlotIcons) — the module ships text only. Same look: chunky cartoon shapes, a heavy dark\n// outline, the slot silhouettes white and see-through on their frames.\n\nconst INK = \"#10172e\";\n\n/** Jacket colour of a hero by its class (a stable colour for any other class id). */\nexport function classColor(classID: string | null | undefined): string {\n const id = (classID ?? \"\").toLowerCase();\n if (/war|knight|tank|fighter/.test(id)) return \"#d9433b\";\n if (/arch|bow|hunt|range/.test(id)) return \"#3fa548\";\n if (/mag|wiz|sorc|caster/.test(id)) return \"#7d4ae0\";\n if (/rog|assas|thief/.test(id)) return \"#3b3f52\";\n if (!id) return \"#2f6fd6\";\n let h = 0;\n for (const c of id) h = (h * 31 + c.charCodeAt(0)) % 360;\n return `hsl(${h} 62% 48%)`;\n}\n\nexport { HeroArt, heroKind, type HeroKind } from \"./heroArt\";\n\nexport type SlotKind = \"weapon\" | \"helmet\" | \"ring\" | \"shoes\" | \"torso\" | \"pants\" | \"bag\" | \"other\";\n\n/** Slot kind by a slot or item-class id (\"Weapon\", \"MainHand\", \"Boots\", \"Chest\"…). */\nexport function slotKind(id: string | null | undefined): SlotKind {\n const s = (id ?? \"\").toLowerCase();\n if (/weap|sword|hand|bow|staff|wand/.test(s)) return \"weapon\";\n if (/helm|head|hat|cap/.test(s)) return \"helmet\";\n if (/ring|amul|neck|acces|trink/.test(s)) return \"ring\";\n if (/shoe|boot|feet|foot/.test(s)) return \"shoes\";\n if (/torso|chest|body|armor|armour|shirt/.test(s)) return \"torso\";\n if (/pant|leg|trous/.test(s)) return \"pants\";\n return \"other\";\n}\n\n/** White slot silhouette (SlotIcons/*), drawn see-through on an empty slot. */\nexport function SlotSilhouette({ kind, size, opacity = 1 }: { kind: SlotKind; size: number; opacity?: number }): ReactNode {\n const paths: Record<SlotKind, ReactNode> = {\n weapon: (\n <>\n <path d=\"M84 8 L96 20 L44 72 L36 64 Z\" />\n <path d=\"M30 58 L46 74 L40 80 L24 64 Z\" />\n <path d=\"M14 70 L34 90 L26 98 L6 78 Z\" />\n <path d=\"M26 82 L14 94\" strokeWidth=\"10\" stroke=\"#fff\" />\n </>\n ),\n helmet: (\n <>\n <path d=\"M14 62 Q14 14 52 14 Q90 14 90 62 L90 80 L66 80 L66 62 L38 62 L38 80 L14 80 Z\" />\n <rect x=\"46\" y=\"4\" width=\"12\" height=\"16\" rx=\"4\" />\n </>\n ),\n ring: (\n <>\n <path d=\"M52 32 A30 30 0 1 1 51.9 32 Z M52 44 A18 18 0 1 0 52.1 44 Z\" fillRule=\"evenodd\" />\n <path d=\"M40 8 L64 8 L72 22 L52 36 L32 22 Z\" />\n </>\n ),\n shoes: (\n <>\n <path d=\"M30 10 L58 10 L58 54 L90 64 Q98 70 96 82 L96 92 L14 92 Q8 92 8 86 L8 70 Q30 64 30 50 Z\" />\n </>\n ),\n torso: (\n <>\n <path d=\"M30 10 L42 6 Q52 20 62 6 L74 10 L98 30 L84 50 L76 44 L76 96 L28 96 L28 44 L20 50 L6 30 Z\" />\n </>\n ),\n pants: (\n <>\n <path d=\"M22 8 L82 8 L90 96 L62 96 L52 40 L42 96 L14 96 Z\" />\n </>\n ),\n bag: (\n <>\n <path d=\"M30 30 Q30 8 52 8 Q74 8 74 30\" fill=\"none\" stroke=\"#fff\" strokeWidth=\"9\" />\n <path d=\"M14 34 L90 34 L96 92 Q96 98 90 98 L14 98 Q8 98 8 92 Z\" />\n <rect x=\"38\" y=\"50\" width=\"28\" height=\"18\" rx=\"5\" fill=\"rgba(0,0,0,.25)\" />\n </>\n ),\n other: <circle cx=\"52\" cy=\"52\" r=\"34\" />,\n };\n return (\n <svg viewBox=\"0 0 104 104\" width={size} height={size} style={{ opacity, display: \"block\" }} aria-hidden>\n <g fill=\"#fff\">{paths[kind]}</g>\n </svg>\n );\n}\n\n/** A small padlock (Icon_Lock01_s). */\nexport function LockArt({ size }: { size: number }): ReactNode {\n return (\n <svg viewBox=\"0 0 51 71\" width={size * (51 / 71)} height={size} aria-hidden>\n <g stroke={INK} strokeWidth=\"5\" strokeLinejoin=\"round\">\n <path d=\"M12 32 L12 22 Q12 6 25.5 6 Q39 6 39 22 L39 32\" fill=\"none\" stroke={INK} strokeWidth=\"12\" />\n <path d=\"M12 32 L12 22 Q12 6 25.5 6 Q39 6 39 22 L39 32\" fill=\"none\" stroke=\"#c9d3e3\" strokeWidth=\"6\" />\n <rect x=\"4\" y=\"30\" width=\"43\" height=\"36\" rx=\"8\" fill=\"#ffc21a\" />\n <circle cx=\"25.5\" cy=\"46\" r=\"5\" fill={INK} />\n </g>\n </svg>\n );\n}\n\n/** The background's faint diagonal pattern (ScrollBackground, white at 8 %). */\nexport const PATTERN =\n \"repeating-linear-gradient(135deg, rgba(255,255,255,.08) 0 18px, rgba(255,255,255,0) 18px 36px)\";\n"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"path": "components/heroArt.tsx",
|
|
73
|
+
"content": "import type { CSSProperties, ReactNode } from \"react\";\n\n// The hero in the middle of the heroes screen: a chibi medieval fighter in the reference's look\n// (big head, heavy dark outline). The knight wears a plumed helmet and a tabard over plate, a sword\n// in one hand and a heater shield on the other arm; the archer a hood, a bow and a quiver; the mage\n// a pointed hat, a robe and a staff. The tabard / hood / robe takes the hero's class colour.\n\nconst INK = \"#10172e\";\n\nexport type HeroKind = \"knight\" | \"archer\" | \"mage\";\n\n/** Which medieval hero to draw for a class: the knight unless the class reads as archer or mage. */\nexport function heroKind(classID: string | null | undefined): HeroKind {\n const id = (classID ?? \"\").toLowerCase();\n if (/arch|bow|hunt|range/.test(id)) return \"archer\";\n if (/mag|wiz|sorc|caster|priest|heal|druid/.test(id)) return \"mage\";\n return \"knight\";\n}\n\nexport function HeroArt({\n color,\n kind = \"knight\",\n style,\n}: {\n color: string;\n kind?: HeroKind;\n style?: CSSProperties;\n}): ReactNode {\n const skin = \"#f2b98a\";\n const steel = \"#c9d3e3\";\n const steelDark = \"#8f9db5\";\n const leather = \"#8a5a2b\";\n const knight = kind === \"knight\";\n return (\n <svg\n viewBox=\"0 0 384 512\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\", ...style }}\n aria-hidden\n >\n <g stroke={INK} strokeWidth=\"9\" strokeLinejoin=\"round\" strokeLinecap=\"round\">\n {/* Behind the body: the quiver or the staff */}\n {kind === \"archer\" ? (\n <g>\n <rect x=\"250\" y=\"250\" width=\"44\" height=\"120\" rx=\"12\" fill={leather} transform=\"rotate(18 272 310)\" />\n <path d=\"M262 238 L272 214 M280 242 L294 222 M298 250 L314 232\" strokeWidth=\"8\" />\n </g>\n ) : null}\n {kind === \"mage\" ? (\n <g>\n <rect x=\"300\" y=\"166\" width=\"20\" height=\"324\" rx=\"8\" fill={leather} />\n <circle cx=\"310\" cy=\"152\" r=\"30\" fill=\"#7fe3ff\" />\n <circle cx=\"300\" cy=\"142\" r=\"9\" fill=\"#fff\" stroke=\"none\" />\n </g>\n ) : null}\n\n {/* Legs and boots */}\n <path d=\"M140 420 L136 474 L182 474 L186 430 Z\" fill={knight ? steelDark : \"#5b4a3a\"} />\n <path d=\"M244 420 L248 474 L202 474 L198 430 Z\" fill={knight ? steelDark : \"#5b4a3a\"} />\n <path d=\"M128 470 L186 470 L188 500 L120 500 Q118 480 128 470 Z\" fill={leather} />\n <path d=\"M256 470 L198 470 L196 500 L264 500 Q266 480 256 470 Z\" fill={leather} />\n\n {/* Body */}\n {kind === \"mage\" ? (\n <>\n <path d=\"M108 326 Q192 290 276 326 L300 470 Q192 494 84 470 Z\" fill={color} />\n <path d=\"M192 330 L192 470\" stroke=\"#ffd23a\" strokeWidth=\"9\" />\n </>\n ) : (\n <>\n <path d=\"M104 330 Q192 292 280 330 L288 438 Q192 458 96 438 Z\" fill={knight ? steel : \"#6d5338\"} />\n {knight ? (\n <path d=\"M150 316 L234 316 L240 450 Q192 466 144 450 Z\" fill={color} />\n ) : (\n <path d=\"M128 318 L256 318 L262 444 Q192 462 122 444 Z\" fill={color} />\n )}\n <rect x=\"96\" y=\"414\" width=\"192\" height=\"24\" rx=\"8\" fill={leather} />\n <rect x=\"178\" y=\"410\" width=\"28\" height=\"32\" rx=\"6\" fill=\"#ffd23a\" />\n </>\n )}\n {knight ? <path d=\"M170 352 L214 352 M192 336 L192 396\" stroke=\"#ffd23a\" strokeWidth=\"12\" /> : null}\n\n {/* Arms (plate for the knight) and shoulder pauldrons */}\n <path d=\"M104 334 Q72 372 84 420 L114 424 Q110 380 126 354 Z\" fill={knight ? steel : color} />\n <path d=\"M280 334 Q312 372 300 420 L270 424 Q274 380 258 354 Z\" fill={knight ? steel : color} />\n {knight ? (\n <>\n <ellipse cx=\"102\" cy=\"332\" rx=\"36\" ry=\"26\" fill={steel} />\n <ellipse cx=\"282\" cy=\"332\" rx=\"36\" ry=\"26\" fill={steel} />\n </>\n ) : null}\n\n {/* Weapon hand: the sword or the bow */}\n {knight ? (\n <g transform=\"rotate(20 298 430)\">\n <path d=\"M286 200 L308 200 L308 404 L286 404 Z\" fill=\"#eef3fa\" />\n <path d=\"M297 168 L314 202 L280 202 Z\" fill=\"#eef3fa\" />\n <path d=\"M297 214 L297 396\" stroke=\"#b8c4d8\" strokeWidth=\"5\" />\n <rect x=\"262\" y=\"400\" width=\"70\" height=\"20\" rx=\"8\" fill=\"#ffd23a\" />\n <rect x=\"287\" y=\"418\" width=\"20\" height=\"40\" rx=\"6\" fill={leather} />\n <circle cx=\"297\" cy=\"464\" r=\"11\" fill=\"#ffd23a\" />\n </g>\n ) : null}\n {kind === \"archer\" ? (\n <g>\n <path d=\"M318 250 Q376 340 318 440\" fill=\"none\" stroke={INK} strokeWidth=\"22\" />\n <path d=\"M318 250 Q376 340 318 440\" fill=\"none\" stroke={leather} strokeWidth=\"12\" />\n <path d=\"M318 254 L318 436\" stroke=\"#f3efe2\" strokeWidth=\"4\" />\n </g>\n ) : null}\n <circle cx=\"300\" cy=\"430\" r=\"20\" fill={knight ? steel : skin} />\n\n {/* Shield arm: the heater shield for the knight */}\n {knight ? (\n <g>\n <path d=\"M38 328 L134 328 Q138 422 86 472 Q34 422 38 328 Z\" fill={color} />\n <path d=\"M56 344 L116 344 Q118 410 86 448 Q54 410 56 344 Z\" fill=\"none\" stroke=\"#ffd23a\" strokeWidth=\"8\" />\n <path d=\"M86 354 L86 434 M62 386 L110 386\" stroke=\"#ffd23a\" strokeWidth=\"10\" />\n <path d=\"M52 340 Q70 334 94 336\" stroke=\"#fff\" strokeWidth=\"6\" opacity=\"0.5\" fill=\"none\" />\n </g>\n ) : (\n <circle cx=\"84\" cy=\"430\" r=\"20\" fill={skin} />\n )}\n\n {/* Head */}\n <circle cx=\"72\" cy=\"218\" r=\"24\" fill={skin} />\n <circle cx=\"312\" cy=\"218\" r=\"24\" fill={skin} />\n <rect x=\"78\" y=\"114\" width=\"228\" height=\"194\" rx=\"80\" fill={skin} />\n <path d=\"M124 198 L166 192 M218 192 L260 198\" strokeWidth=\"12\" />\n <ellipse cx=\"148\" cy=\"230\" rx=\"11\" ry=\"16\" fill={INK} />\n <ellipse cx=\"236\" cy=\"230\" rx=\"11\" ry=\"16\" fill={INK} />\n <path d=\"M174 274 Q192 286 210 274\" fill=\"none\" strokeWidth=\"7\" />\n\n {/* Headgear */}\n {knight ? (\n <g>\n <path d=\"M64 214 Q58 80 192 72 Q326 80 320 214 L294 214 Q292 130 192 126 Q92 130 90 214 Z\" fill={steel} />\n <path d=\"M90 170 L294 170\" stroke={steelDark} strokeWidth=\"10\" />\n <path d=\"M184 126 L200 126 L198 214 L186 214 Z\" fill={steelDark} />\n <path d=\"M192 72 Q196 28 238 20 Q226 44 240 62 Q214 58 204 76 Z\" fill=\"#e8534e\" />\n <path d=\"M96 112 Q130 86 176 84\" stroke=\"#fff\" strokeWidth=\"7\" opacity=\"0.55\" fill=\"none\" />\n </g>\n ) : null}\n {kind === \"archer\" ? (\n <g>\n <path d=\"M56 238 Q38 68 192 56 Q346 68 328 238 L302 238 Q302 132 192 130 Q82 132 82 238 Z\" fill={color} />\n <path d=\"M192 56 Q242 18 274 46 Q240 50 222 72 Z\" fill={color} />\n <path d=\"M248 52 Q292 38 302 68\" stroke=\"#e8534e\" strokeWidth=\"8\" fill=\"none\" />\n </g>\n ) : null}\n {kind === \"mage\" ? (\n <g>\n <path d=\"M58 150 L326 150 Q332 172 302 178 L82 178 Q52 172 58 150 Z\" fill={color} />\n <path d=\"M104 152 Q150 120 176 40 Q190 4 234 20 Q208 36 216 84 Q230 128 284 152 Z\" fill={color} />\n <path d=\"M214 92 L222 106 L238 108 L226 118 L230 134 L214 126 L200 134 L204 118 L192 108 L208 106 Z\" fill=\"#ffd23a\" strokeWidth=\"5\" />\n <path d=\"M118 300 Q192 362 266 300 Q252 342 192 354 Q132 342 118 300 Z\" fill=\"#f2f2f2\" />\n </g>\n ) : null}\n </g>\n </svg>\n );\n}\n"
|
|
70
74
|
},
|
|
71
75
|
{
|
|
72
|
-
"path": "components/
|
|
73
|
-
"content": "import { useState, type ReactNode } from \"react\";\nimport {\n Button,\n
|
|
76
|
+
"path": "components/HeroesPopup.tsx",
|
|
77
|
+
"content": "import { useState, type ReactNode } from \"react\";\r\nimport { useIDosGamesClient } from \"@idosgames/react\";\r\nimport {\r\n Button,\r\n Card,\r\n Icon,\r\n ResourceList,\r\n canAfford,\r\n errorText,\r\n formatAmount,\r\n outlined,\r\n useCatalog,\r\n usePopupClose,\r\n useStagger,\r\n useToast,\r\n useUiKit,\r\n v,\r\n} from \"@idosgames/react/ui\";\r\nimport { UnityPopup } from \"./UnityPopup\";\r\nimport { HeroArt, classColor, heroKind } from \"./art\";\r\nimport { type RosterEntry } from \"../model\";\r\nimport { t } from \"../i18n\";\r\n\r\nexport function HeroesPopup({\r\n list,\r\n current,\r\n balances,\r\n onSelect,\r\n onClose,\r\n}: {\r\n list: RosterEntry[];\r\n current: string;\r\n balances: Record<string, { Amount?: number }> | undefined;\r\n onSelect: (id: string) => void;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const catalog = useCatalog();\r\n const toast = useToast();\r\n const kit = useUiKit();\r\n const stagger = useStagger();\r\n const [busy, setBusy] = useState<string | null>(null);\r\n\r\n const unlock = async (e: RosterEntry) => {\r\n setBusy(e.id);\r\n const res = await client.character.unlockCharacter(e.id);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(\"levelUp\");\r\n onSelect(e.id);\r\n };\r\n\r\n return (\r\n <UnityPopup title={t(\"heroes\")} onClose={onClose} width={560}>\r\n <div\r\n style={{\r\n display: \"grid\",\r\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\r\n gap: 10,\r\n }}\r\n >\r\n {list.map((e, i) => {\r\n const color = catalog.rarityColor(e.def.Classification?.RarityID);\r\n const affordable = canAfford(e.unlockPrice, balances);\r\n return (\r\n <Card\r\n key={e.id}\r\n highlight={e.id === current}\r\n style={{\r\n display: \"grid\",\r\n justifyItems: \"center\",\r\n gap: 6,\r\n opacity: e.state === \"locked\" ? 0.85 : 1,\r\n border: `2px solid ${e.id === current ? v.gold : color}`,\r\n ...stagger(i),\r\n }}\r\n >\r\n <div style={{ position: \"relative\" }}>\r\n <div\r\n style={{\r\n width: 72,\r\n height: 96,\r\n filter: e.state === \"locked\" ? \"grayscale(1) brightness(.75)\" : undefined,\r\n }}\r\n >\r\n <HeroArt color={classColor(e.def.Classification?.ClassID)} kind={heroKind(e.def.Classification?.ClassID)} />\r\n </div>\r\n {e.state === \"locked\" ? (\r\n <span style={{ position: \"absolute\", right: -8, bottom: -4 }}>\r\n <Icon glyph=\"lock\" size={22} />\r\n </span>\r\n ) : null}\r\n </div>\r\n <div style={{ ...outlined, fontSize: 15 }}>\r\n {catalog.localize(e.def.Identity?.DisplayName ?? e.id)}\r\n </div>\r\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {e.state === \"locked\"\r\n ? t(\"locked\")\r\n : `${t(\"rank\")} ${e.rank} · ${t(\"power\")} ${formatAmount(e.power)}`}\r\n </div>\r\n {e.state === \"locked\" ? (\r\n e.unlockPrice.length > 0 ? (\r\n <Button\r\n tone=\"gold\"\r\n size=\"sm\"\r\n attract={affordable}\r\n disabled={!affordable}\r\n busy={busy === e.id}\r\n onClick={() => void unlock(e)}\r\n style={{ width: \"100%\" }}\r\n >\r\n {t(\"unlock\")}{\" \"}\r\n <ResourceList lines={e.unlockPrice} size={16} />\r\n </Button>\r\n ) : (\r\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\r\n {t(\"notForSale\")}\r\n </span>\r\n )\r\n ) : e.id === current ? (\r\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\r\n {t(\"selected\")}\r\n </span>\r\n ) : (\r\n <SelectButton onSelect={() => onSelect(e.id)} />\r\n )}\r\n </Card>\r\n );\r\n })}\r\n </div>\r\n </UnityPopup>\r\n );\r\n}\r\n\r\nfunction SelectButton({ onSelect }: { onSelect: () => void }): ReactNode {\r\n const close = usePopupClose();\r\n return (\r\n <Button\r\n size=\"sm\"\r\n onClick={() => {\r\n onSelect();\r\n close();\r\n }}\r\n style={{ width: \"100%\" }}\r\n >\r\n {t(\"select\")}\r\n </Button>\r\n );\r\n}\r\n"
|
|
74
78
|
},
|
|
75
79
|
{
|
|
76
80
|
"path": "components/PiecePopup.tsx",
|
|
77
|
-
"content": "import { useRef, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n
|
|
81
|
+
"content": "import { useRef, useState, type ReactNode } from \"react\";\r\nimport { useIDosGamesClient } from \"@idosgames/react\";\r\nimport {\r\n Button,\r\n Icon,\r\n ResourceList,\r\n canAfford,\r\n errorText,\r\n outlined,\r\n useCatalog,\r\n useToast,\r\n useUiKit,\r\n v,\r\n type ResourceLine,\r\n} from \"@idosgames/react/ui\";\r\nimport { UnityPopup } from \"./UnityPopup\";\r\nimport {\r\n fitProblem,\r\n itemLevelCost,\r\n pieceBonuses,\r\n statNumber,\r\n type GearPiece,\r\n type ItemDefWithGear,\r\n type RosterEntry,\r\n type SlotView,\r\n} from \"../model\";\r\nimport { t } from \"../i18n\";\r\n\r\nexport function PiecePopup({\r\n instanceID,\r\n hero,\r\n slots,\r\n pieces,\r\n items,\r\n balances,\r\n onClose,\r\n}: {\r\n instanceID: string;\r\n hero: RosterEntry;\r\n slots: SlotView[];\r\n pieces: GearPiece[];\r\n items: Map<string, ItemDefWithGear>;\r\n balances: Record<string, { Amount?: number }> | undefined;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const catalog = useCatalog();\r\n const toast = useToast();\r\n const kit = useUiKit();\r\n const iconRef = useRef<HTMLDivElement>(null);\r\n const [busy, setBusy] = useState<string | null>(null);\r\n const piece = pieces.find((p) => p.instanceID === instanceID);\r\n if (!piece) return null;\r\n const def = items.get(piece.itemID);\r\n const color = catalog.rarityColor(def?.Metadata?.RarityID);\r\n const bonuses = pieceBonuses(def, piece.level);\r\n const fitSlot = slots.find(\r\n (s) => fitProblem(piece, s, hero.id, hero.rank, items) === null,\r\n );\r\n const onThisHero =\r\n piece.equippedBy?.characterID === hero.id ? piece.equippedBy : null;\r\n const levelCost = itemLevelCost(def, piece.level);\r\n const affordable = levelCost\r\n ? canAfford(levelCost as ResourceLine[], balances)\r\n : false;\r\n\r\n const act = async (kind: \"equip\" | \"unequip\" | \"level\") => {\r\n setBusy(kind);\r\n const res =\r\n kind === \"equip\" && fitSlot\r\n ? await client.character.equipItems(hero.id, [\r\n { SlotID: fitSlot.id, ItemInstanceID: piece.instanceID },\r\n ])\r\n : kind === \"unequip\" && onThisHero\r\n ? await client.character.unequipItems(hero.id, [onThisHero.slotID])\r\n : await client.item.upgradeLevel(piece.instanceID);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(kind === \"level\" ? \"levelUp\" : \"claim\");\r\n kit.fx.burst(iconRef.current);\r\n };\r\n\r\n return (\r\n <UnityPopup title={catalog.itemName(piece.itemID)} onClose={onClose}>\r\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\r\n <div\r\n ref={iconRef}\r\n className=\"idos-glow\"\r\n style={{\r\n width: 110,\r\n height: 110,\r\n borderRadius: 26,\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n border: `4px solid ${color}`,\r\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\r\n }}\r\n >\r\n <Icon\r\n glyph={catalog.iconOf({\r\n kind: \"item\",\r\n id: piece.itemID,\r\n amount: 1,\r\n })}\r\n size={72}\r\n />\r\n </div>\r\n <div style={{ ...outlined, color, fontSize: 14 }}>\r\n {def?.Metadata?.RarityID ?? \"Common\"} · {t(\"level\")} {piece.level}\r\n {def?.Upgrade?.MaxLevel ? ` / ${def.Upgrade.MaxLevel}` : \"\"}\r\n </div>\r\n {Object.entries(bonuses).map(([stat, value]) => (\r\n <div key={stat} style={{ ...outlined, fontSize: 15, color: v.green }}>\r\n +{statNumber(value)} {stat}\r\n </div>\r\n ))}\r\n {Object.entries(def?.Stats?.PercentBonuses ?? {}).map(\r\n ([stat, value]) => (\r\n <div\r\n key={stat}\r\n style={{ ...outlined, fontSize: 15, color: v.gold }}\r\n >\r\n +{value}% {stat}\r\n </div>\r\n ),\r\n )}\r\n {piece.equippedBy && !onThisHero ? (\r\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {t(\"wornBy\")} {piece.equippedBy.characterID}\r\n </div>\r\n ) : null}\r\n </div>\r\n <div style={{ display: \"grid\", gap: 8 }}>\r\n {onThisHero ? (\r\n <Button\r\n tone=\"grey\"\r\n busy={busy === \"unequip\"}\r\n onClick={() => void act(\"unequip\")}\r\n >\r\n {t(\"unequip\")}\r\n </Button>\r\n ) : fitSlot ? (\r\n <Button\r\n tone=\"blue\"\r\n busy={busy === \"equip\"}\r\n onClick={() => void act(\"equip\")}\r\n >\r\n {t(\"equip\")} → {fitSlot.id}\r\n </Button>\r\n ) : (\r\n <div\r\n style={{\r\n ...outlined,\r\n fontSize: 13,\r\n color: v.textDim,\r\n textAlign: \"center\",\r\n }}\r\n >\r\n {t(\"cannot\")}\r\n </div>\r\n )}\r\n {levelCost === null ? (\r\n def?.Upgrade ? (\r\n <div\r\n style={{\r\n ...outlined,\r\n fontSize: 13,\r\n color: v.textDim,\r\n textAlign: \"center\",\r\n }}\r\n >\r\n {t(\"maxLevel\")}\r\n </div>\r\n ) : null\r\n ) : (\r\n <Button\r\n tone=\"gold\"\r\n disabled={!affordable}\r\n busy={busy === \"level\"}\r\n onClick={() => void act(\"level\")}\r\n >\r\n {t(\"levelUp\")}{\" \"}\r\n <ResourceList\r\n lines={levelCost}\r\n size={18}\r\n dimIf={() => !affordable}\r\n />\r\n </Button>\r\n )}\r\n </div>\r\n </UnityPopup>\r\n );\r\n}\r\n"
|
|
78
82
|
},
|
|
79
83
|
{
|
|
80
84
|
"path": "components/SkinsPopup.tsx",
|
|
81
|
-
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Icon,\n
|
|
85
|
+
"content": "import { useEffect, useState, type ReactNode } from \"react\";\r\nimport { useIDosGamesClient } from \"@idosgames/react\";\r\nimport {\r\n Button,\r\n Card,\r\n Icon,\r\n ResourceList,\r\n costLines,\r\n errorText,\r\n outlined,\r\n useCatalog,\r\n useStagger,\r\n useToast,\r\n useUiKit,\r\n v,\r\n} from \"@idosgames/react/ui\";\r\nimport { UnityPopup } from \"./UnityPopup\";\r\nimport { SKIN_KEY, type RosterEntry } from \"../model\";\r\nimport { t } from \"../i18n\";\r\n\r\nexport function SkinsPopup({\r\n hero,\r\n onClose,\r\n}: {\r\n hero: RosterEntry;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const catalog = useCatalog();\r\n const toast = useToast();\r\n const kit = useUiKit();\r\n const stagger = useStagger();\r\n const [view, setView] = useState<{\r\n WornSkinID?: string | null;\r\n Skins?: Array<{\r\n SkinID: string;\r\n Owned?: boolean | null;\r\n IsWorn?: boolean | null;\r\n Purchasable?: boolean | null;\r\n MeetsRequirements?: boolean | null;\r\n PriceOptions?: Record<string, { Cost?: unknown }> | null;\r\n }> | null;\r\n } | null>(null);\r\n const [busy, setBusy] = useState<string | null>(null);\r\n\r\n const load = async () => {\r\n const res = await client.character.getCharacterSkins(hero.id);\r\n if (res.ok) setView(res.data.Characters?.[hero.id] ?? null);\r\n };\r\n useEffect(() => {\r\n void load();\r\n // once per hero\r\n }, [hero.id]);\r\n\r\n const act = async (skinID: string, kind: \"wear\" | \"buy\" | \"base\") => {\r\n setBusy(skinID);\r\n const res =\r\n kind === \"buy\"\r\n ? await client.character.unlockSkin(hero.id, skinID, {\r\n autoEquip: true,\r\n })\r\n : kind === \"base\"\r\n ? await client.character.unequipSkin(hero.id)\r\n : await client.character.equipSkin(hero.id, skinID);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(kind === \"buy\" ? \"purchase\" : \"claim\");\r\n await load();\r\n };\r\n\r\n const defs = hero.def.Skins?.Definitions ?? {};\r\n const wornID = hero.model?.Equipment?.[SKIN_KEY]?.ItemID ?? null;\r\n return (\r\n <UnityPopup title={t(\"skins\")} onClose={onClose} width={520}>\r\n <div\r\n style={{\r\n display: \"grid\",\r\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\r\n gap: 10,\r\n }}\r\n >\r\n <Card\r\n highlight={!wornID}\r\n style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}\r\n >\r\n <Icon glyph=\"hero\" size={52} />\r\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"baseLook\")}</div>\r\n {wornID ? (\r\n <Button\r\n size=\"sm\"\r\n busy={busy === \"@base\"}\r\n onClick={() => void act(\"@base\", \"base\")}\r\n >\r\n {t(\"wear\")}\r\n </Button>\r\n ) : (\r\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\r\n {t(\"worn\")}\r\n </span>\r\n )}\r\n </Card>\r\n {(view?.Skins ?? []).map((s, i) => {\r\n const def = defs[s.SkinID];\r\n const price = Object.values(s.PriceOptions ?? {})[0];\r\n return (\r\n <Card\r\n key={s.SkinID}\r\n highlight={s.IsWorn === true}\r\n style={{\r\n display: \"grid\",\r\n justifyItems: \"center\",\r\n gap: 6,\r\n ...stagger(i),\r\n }}\r\n >\r\n <Icon\r\n glyph={def?.Identity?.AssetPaths?.icon ?? \"user\"}\r\n size={52}\r\n />\r\n <div style={{ ...outlined, fontSize: 14 }}>\r\n {catalog.localize(def?.Identity?.DisplayName ?? s.SkinID)}\r\n </div>\r\n {s.IsWorn ? (\r\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\r\n {t(\"worn\")}\r\n </span>\r\n ) : s.Owned ? (\r\n <Button\r\n size=\"sm\"\r\n disabled={s.MeetsRequirements === false}\r\n busy={busy === s.SkinID}\r\n onClick={() => void act(s.SkinID, \"wear\")}\r\n >\r\n {t(\"wear\")}\r\n </Button>\r\n ) : s.Purchasable && price ? (\r\n <Button\r\n size=\"sm\"\r\n tone=\"gold\"\r\n busy={busy === s.SkinID}\r\n onClick={() => void act(s.SkinID, \"buy\")}\r\n >\r\n <ResourceList\r\n lines={costLines(price.Cost as never)}\r\n size={16}\r\n />\r\n </Button>\r\n ) : (\r\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\r\n {t(\"notForSale\")}\r\n </span>\r\n )}\r\n </Card>\r\n );\r\n })}\r\n </div>\r\n </UnityPopup>\r\n );\r\n}\r\n"
|
|
82
86
|
},
|
|
83
87
|
{
|
|
84
88
|
"path": "components/SlotPopup.tsx",
|
|
85
|
-
"content": "import { type ReactNode } from \"react\";\nimport {\n Card,\n EmptyState,\n ItemTile,\n
|
|
89
|
+
"content": "import { type ReactNode } from \"react\";\r\nimport {\r\n Card,\r\n EmptyState,\r\n ItemTile,\r\n outlined,\r\n useCatalog,\r\n useStagger,\r\n v,\r\n} from \"@idosgames/react/ui\";\r\nimport { UnityPopup } from \"./UnityPopup\";\r\nimport {\r\n fitProblem,\r\n type GearPiece,\r\n type ItemDefWithGear,\r\n type RosterEntry,\r\n type SlotView,\r\n} from \"../model\";\r\nimport { t } from \"../i18n\";\r\n\r\nexport function SlotPopup({\r\n slot,\r\n hero,\r\n pieces,\r\n items,\r\n onPick,\r\n onClose,\r\n}: {\r\n slot: SlotView;\r\n hero: RosterEntry;\r\n pieces: GearPiece[];\r\n items: Map<string, ItemDefWithGear>;\r\n onPick: (instanceID: string) => void;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const worn = hero.model?.Equipment?.[slot.id]?.ItemInstanceID ?? null;\r\n const fitting = pieces.filter(\r\n (p) =>\r\n p.instanceID !== worn &&\r\n (!p.equippedBy || p.equippedBy.characterID === hero.id) &&\r\n fitProblem(p, slot, hero.id, hero.rank, items) === null,\r\n );\r\n const stagger = useStagger();\r\n return (\r\n <UnityPopup title={slot.id} onClose={onClose} width={440}>\r\n {worn ? (\r\n <PickRow\r\n instanceID={worn}\r\n pieces={pieces}\r\n label={t(\"worn\")}\r\n onPick={onPick}\r\n />\r\n ) : null}\r\n {fitting.length === 0 ? (\r\n <EmptyState\r\n glyph=\"sword\"\r\n text={\r\n hero.rank < slot.minRank\r\n ? `${t(\"slotLocked\")} ${slot.minRank}`\r\n : t(\"noGear\")\r\n }\r\n />\r\n ) : (\r\n <div style={{ display: \"grid\", gap: 8 }}>\r\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\r\n {t(\"fits\")}\r\n </div>\r\n {fitting.map((p, i) => (\r\n <div key={p.instanceID} style={stagger(i)}>\r\n <PickRow\r\n instanceID={p.instanceID}\r\n pieces={pieces}\r\n onPick={onPick}\r\n />\r\n </div>\r\n ))}\r\n </div>\r\n )}\r\n </UnityPopup>\r\n );\r\n}\r\n\r\nfunction PickRow({\r\n instanceID,\r\n pieces,\r\n label,\r\n onPick,\r\n}: {\r\n instanceID: string;\r\n pieces: GearPiece[];\r\n label?: string;\r\n onPick: (id: string) => void;\r\n}): ReactNode {\r\n const catalog = useCatalog();\r\n const piece = pieces.find((p) => p.instanceID === instanceID);\r\n if (!piece) return null;\r\n return (\r\n <Card\r\n onClick={() => onPick(instanceID)}\r\n style={{ display: \"flex\", alignItems: \"center\", gap: 12, padding: 10 }}\r\n >\r\n <ItemTile itemID={piece.itemID} level={piece.level} size={56} />\r\n <div style={{ flex: 1, minWidth: 0 }}>\r\n <div style={{ ...outlined, fontSize: 15 }}>\r\n {catalog.itemName(piece.itemID)}\r\n </div>\r\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {label ? `${label} · ` : \"\"}\r\n {t(\"level\")} {piece.level}\r\n </div>\r\n </div>\r\n </Card>\r\n );\r\n}\r\n"
|
|
86
90
|
},
|
|
87
91
|
{
|
|
88
92
|
"path": "components/StatsPopup.tsx",
|
|
89
|
-
"content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n
|
|
93
|
+
"content": "import { useState, type ReactNode } from \"react\";\r\nimport { useIDosGamesClient } from \"@idosgames/react\";\r\nimport {\r\n Button,\r\n Card,\r\n ProgressBar,\r\n ResourceList,\r\n canAfford,\r\n errorText,\r\n outlined,\r\n useCatalog,\r\n useSound,\r\n useStagger,\r\n useToast,\r\n v,\r\n} from \"@idosgames/react/ui\";\r\nimport { UnityPopup } from \"./UnityPopup\";\r\nimport {\r\n breakdown,\r\n resolveStats,\r\n statCost,\r\n statMaxLevel,\r\n statNumber,\r\n unmetRequirements,\r\n upgradeAllPlan,\r\n type CharacterSection,\r\n type GearPiece,\r\n type ItemDefWithGear,\r\n type RosterEntry,\r\n type StatView,\r\n} from \"../model\";\r\nimport { t } from \"../i18n\";\r\n\r\nexport function StatsPopup({\r\n hero,\r\n section,\r\n pieces,\r\n items,\r\n balances,\r\n onClose,\r\n}: {\r\n hero: RosterEntry;\r\n section: CharacterSection | undefined;\r\n pieces: GearPiece[];\r\n items: Map<string, ItemDefWithGear>;\r\n balances: Record<string, { Amount?: number }> | undefined;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const catalog = useCatalog();\r\n const toast = useToast();\r\n const play = useSound();\r\n const stagger = useStagger();\r\n const [busy, setBusy] = useState<string | null>(null);\r\n const [detail, setDetail] = useState<StatView | null>(null);\r\n const stats = resolveStats(section, hero.def);\r\n const levels = hero.model?.StatLevels ?? {};\r\n const worn = Object.values(hero.model?.Equipment ?? {})\r\n .filter((e) => e?.ItemInstanceID)\r\n .map((e) => {\r\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\r\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\r\n });\r\n\r\n const upgrade = async (stat: StatView) => {\r\n setBusy(stat.id);\r\n const res = await client.character.upgradeStatLevel(hero.id, stat.id);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n play(\"levelUp\");\r\n };\r\n\r\n // \"Upgrade all\" (Unity CharacterStatPopup): as many levels as the balances cover, spread over the\r\n // stats cheapest-first, in ONE atomic batch.\r\n const plan = upgradeAllPlan(stats, levels, hero.rank, hero.def, balances);\r\n const upgradeAll = async () => {\r\n setBusy(\"all\");\r\n const res = await client.character.upgradeStatLevelsBatch(\r\n plan.map((p) => ({ CharacterID: hero.id, ...p })),\r\n );\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n play(\"levelUp\");\r\n };\r\n\r\n return (\r\n <UnityPopup\r\n title={`${t(\"stats\")} · ${catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}`}\r\n onClose={onClose}\r\n width={480}\r\n >\r\n <div style={{ display: \"grid\", gap: 10 }}>\r\n <Button\r\n tone=\"gold\"\r\n attract={plan.length > 0}\r\n disabled={plan.length === 0}\r\n busy={busy === \"all\"}\r\n onClick={() => void upgradeAll()}\r\n >\r\n {t(\"upgradeAll\")}\r\n {plan.length > 0 ? ` +${plan.reduce((n, p) => n + p.Levels, 0)}` : \"\"}\r\n </Button>\r\n {stats.map((stat, i) => {\r\n const level = Number(levels[stat.id] ?? 0);\r\n const cap = statMaxLevel(stat, hero.rank, hero.def);\r\n const capped = cap > 0 && level >= cap;\r\n const cost = statCost(stat, level + 1);\r\n const missing = unmetRequirements(stat, levels);\r\n const affordable = canAfford(cost, balances);\r\n const total = breakdown(stat, level, hero.rank, hero.def, worn).total;\r\n return (\r\n <Card\r\n key={stat.id}\r\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\r\n >\r\n <button\r\n type=\"button\"\r\n onClick={() => setDetail(stat)}\r\n style={{\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n alignItems: \"center\",\r\n background: \"none\",\r\n border: \"none\",\r\n padding: 0,\r\n cursor: \"pointer\",\r\n color: v.text,\r\n }}\r\n >\r\n <span style={{ ...outlined, fontSize: 15 }}>\r\n {catalog.localize(stat.name)} ⓘ\r\n </span>\r\n <span style={{ ...outlined, fontSize: 18, color: v.gold }}>\r\n {statNumber(total)}\r\n </span>\r\n </button>\r\n {cap > 0 ? (\r\n <ProgressBar\r\n value={level}\r\n max={cap}\r\n label={`${t(\"level\")} ${level} / ${cap}`}\r\n height={16}\r\n />\r\n ) : null}\r\n {missing.length > 0 ? (\r\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\r\n {t(\"needs\")}:{\" \"}\r\n {missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}\r\n </div>\r\n ) : capped ? (\r\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {t(\"statCapped\")}\r\n </div>\r\n ) : (\r\n <Button\r\n size=\"sm\"\r\n tone=\"green\"\r\n disabled={!affordable}\r\n busy={busy === stat.id}\r\n onClick={() => void upgrade(stat)}\r\n >\r\n {t(\"upgrade\")} →{\" \"}\r\n {statNumber(\r\n breakdown(stat, level + 1, hero.rank, hero.def, worn).total,\r\n )}{\" \"}\r\n <ResourceList\r\n lines={cost}\r\n size={16}\r\n dimIf={() => !affordable}\r\n />\r\n </Button>\r\n )}\r\n </Card>\r\n );\r\n })}\r\n </div>\r\n {detail ? (\r\n <BreakdownPopup\r\n stat={detail}\r\n level={Number(levels[detail.id] ?? 0)}\r\n hero={hero}\r\n worn={worn}\r\n onClose={() => setDetail(null)}\r\n />\r\n ) : null}\r\n </UnityPopup>\r\n );\r\n}\r\n\r\nfunction BreakdownPopup({\r\n stat,\r\n level,\r\n hero,\r\n worn,\r\n onClose,\r\n}: {\r\n stat: StatView;\r\n level: number;\r\n hero: RosterEntry;\r\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const catalog = useCatalog();\r\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\r\n const rows: Array<[string, number, string]> = [\r\n [t(\"base\"), b.base, v.text],\r\n [t(\"fromLevel\"), b.fromLevel, v.green],\r\n [t(\"fromRank\"), b.fromRank, v.blue],\r\n [t(\"fromGear\"), b.fromGear, v.gold],\r\n ];\r\n const round = statNumber;\r\n return (\r\n <UnityPopup\r\n title={`${catalog.localize(stat.name)} — ${t(\"where\")}`}\r\n onClose={onClose}\r\n width={380}\r\n variant=\"center\"\r\n >\r\n {stat.description ? (\r\n <div\r\n style={{\r\n ...outlined,\r\n fontWeight: 600,\r\n fontSize: 13,\r\n color: v.textDim,\r\n textAlign: \"center\",\r\n }}\r\n >\r\n {catalog.localize(stat.description)}\r\n </div>\r\n ) : null}\r\n <div style={{ display: \"grid\", gap: 6 }}>\r\n {rows.map(([label, value, color]) => (\r\n <div\r\n key={label}\r\n style={{\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n ...outlined,\r\n fontSize: 15,\r\n }}\r\n >\r\n <span style={{ color: v.textDim }}>{label}</span>\r\n <span style={{ color }}>\r\n {value === 0 && label !== t(\"base\")\r\n ? \"—\"\r\n : `${label === t(\"base\") ? \"\" : \"+\"}${round(value)}`}\r\n </span>\r\n </div>\r\n ))}\r\n {b.percent ? (\r\n <div\r\n style={{\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n ...outlined,\r\n fontSize: 15,\r\n }}\r\n >\r\n <span style={{ color: v.textDim }}>{t(\"percent\")}</span>\r\n <span style={{ color: v.gold }}>+{round(b.percent)}%</span>\r\n </div>\r\n ) : null}\r\n <div style={{ height: 2, background: v.panelEdge, margin: \"4px 0\" }} />\r\n <div\r\n style={{\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n ...outlined,\r\n fontSize: 18,\r\n }}\r\n >\r\n <span>{t(\"total\")}</span>\r\n <span style={{ color: v.gold }}>{round(b.total)}</span>\r\n </div>\r\n </div>\r\n </UnityPopup>\r\n );\r\n}\r\n"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"path": "components/UnityPopup.tsx",
|
|
97
|
+
"content": "import type { ComponentProps, ReactNode } from \"react\";\nimport { Popup } from \"@idosgames/react/ui\";\n\n// The hero screen's popups in the reference's look (Popup02~09_Topber: a navy body, a blue top bar\n// with the title, a light streak along it) — the kit's Popup underneath, so closing, the exit\n// animation and usePopupClose() work exactly as everywhere else.\n\nconst NAVY = \"#1f2440\";\nconst TOP = \"#0d80f2\";\n\nexport function UnityPopup({\n title,\n children,\n panelStyle,\n ...rest\n}: ComponentProps<typeof Popup>): ReactNode {\n return (\n <Popup\n {...rest}\n title={\n title ? (\n <div\n style={{\n // The kit pads its title row 18px + 30px; the bar spans the whole panel top.\n margin: \"-18px -48px 4px\",\n padding: \"14px 56px 12px\",\n background: `linear-gradient(180deg, ${TOP} 0%, #0a6ad4 100%)`,\n borderBottom: \"4px solid #10172e\",\n boxShadow: \"inset 0 6px 0 rgba(255,255,255,.27)\",\n borderRadius: \"18px 18px 0 0\",\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 24,\n letterSpacing: 0.5,\n textShadow: \"0 2px 0 #10172e, 0 0 3px #10172e\",\n }}\n >\n {title}\n </div>\n ) : undefined\n }\n panelStyle={{\n background: `linear-gradient(180deg, #262c4e 0%, ${NAVY} 100%)`,\n border: \"4px solid #10172e\",\n boxShadow: \"0 0 0 3px #3c4a86, 0 14px 30px rgba(0,0,0,.5)\",\n ...panelStyle,\n }}\n >\n {children}\n </Popup>\n );\n}\n"
|
|
90
98
|
},
|
|
91
99
|
{
|
|
92
100
|
"path": "components/useData.ts",
|
|
@@ -98,7 +106,7 @@
|
|
|
98
106
|
},
|
|
99
107
|
{
|
|
100
108
|
"path": "i18n.ts",
|
|
101
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Heroes\",\n heroes: \"Heroes\",\n stats: \"Stats\",\n skins: \"Skins\",\n rank: \"Rank\",\n power: \"Power\",\n rankUp: \"Rank up\",\n maxRank: \"Max rank\",\n free: \"Free\",\n select: \"Select\",\n selected: \"Selected\",\n unlock: \"Unlock\",\n notForSale: \"Found in rewards\",\n equipment: \"Equipment\",\n equipBest: \"Equip best\",\n unequipAll: \"Take off all\",\n upgradeAll: \"Upgrade all\",\n emptySlot: \"Empty\",\n slotLocked: \"Rank\",\n equip: \"Equip\",\n unequip: \"Take off\",\n levelUp: \"Level up\",\n maxLevel: \"Max level\",\n level: \"Level\",\n wornBy: \"Worn by\",\n bag: \"Your gear\",\n noGear: \"No gear yet — get some in the shop or from chests.\",\n noHeroes: \"No heroes in this game yet. Add them in LiveOps → Characters.\",\n upgrade: \"Upgrade\",\n statCapped: \"Rank up the hero to raise this cap\",\n needs: \"Needs\",\n where: \"Where it comes from\",\n base: \"Base\",\n fromLevel: \"Stat levels\",\n fromRank: \"Hero rank\",\n fromGear: \"Gear\",\n percent: \"Gear, %\",\n total: \"Total\",\n wear: \"Wear\",\n worn: \"Worn\",\n buy: \"Buy\",\n baseLook: \"Base look\",\n fits: \"Fits\",\n cannot: \"Cannot wear\",\n locked: \"Locked\",\n} as const;\n\nexport type Key = keyof typeof EN;\n\nexport const t = makeT<Key>(EN, {\n title: \"Герои\",\n heroes: \"Герои\",\n stats: \"Характеристики\",\n skins: \"Облики\",\n rank: \"Ранг\",\n power: \"Сила\",\n rankUp: \"Повысить ранг\",\n maxRank: \"Максимальный ранг\",\n free: \"Бесплатно\",\n select: \"Выбрать\",\n selected: \"Выбран\",\n unlock: \"Открыть\",\n notForSale: \"Выпадает в наградах\",\n equipment: \"Снаряжение\",\n equipBest: \"Надеть лучшее\",\n unequipAll: \"Снять всё\",\n upgradeAll: \"Улучшить всё\",\n emptySlot: \"Пусто\",\n slotLocked: \"Ранг\",\n equip: \"Надеть\",\n unequip: \"Снять\",\n levelUp: \"Улучшить\",\n maxLevel: \"Макс. уровень\",\n level: \"Уровень\",\n wornBy: \"Надето на\",\n bag: \"Ваше снаряжение\",\n noGear: \"Снаряжения пока нет — оно есть в магазине и сундуках.\",\n noHeroes: \"Героев пока нет. Добавьте их в LiveOps → Characters.\",\n upgrade: \"Улучшить\",\n statCapped: \"Повысьте ранг героя, чтобы поднять предел\",\n needs: \"Нужно\",\n where: \"Откуда число\",\n base: \"База\",\n fromLevel: \"Уровни характеристики\",\n fromRank: \"Ранг героя\",\n fromGear: \"Снаряжение\",\n percent: \"Снаряжение, %\",\n total: \"Итого\",\n wear: \"Надеть\",\n worn: \"Надет\",\n buy: \"Купить\",\n baseLook: \"Обычный облик\",\n fits: \"Подходит\",\n cannot: \"Нельзя надеть\",\n locked: \"Закрыто\",\n});\n"
|
|
109
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\r\n\r\nconst EN = {\r\n title: \"Heroes\",\r\n heroes: \"Heroes\",\r\n stats: \"Stats\",\r\n skins: \"Skins\",\r\n rank: \"Rank\",\r\n power: \"Power\",\r\n rankUp: \"Rank up\",\r\n maxRank: \"Max rank\",\r\n free: \"Free\",\r\n select: \"Select\",\r\n selected: \"Selected\",\r\n unlock: \"Unlock\",\r\n notForSale: \"Found in rewards\",\r\n equipment: \"Equipment\",\r\n equipBest: \"Equip best\",\r\n unequipAll: \"Take off all\",\r\n upgradeAll: \"Upgrade all\",\r\n emptySlot: \"Empty\",\r\n slotLocked: \"Rank\",\r\n equip: \"Equip\",\r\n unequip: \"Take off\",\r\n levelUp: \"Level up\",\r\n maxLevel: \"Max level\",\r\n level: \"Level\",\r\n wornBy: \"Worn by\",\r\n bag: \"Your gear\",\r\n noGear: \"No gear yet — get some in the shop or from chests.\",\r\n noHeroes: \"No heroes in this game yet. Add them in LiveOps → Characters.\",\r\n upgrade: \"Upgrade\",\r\n statCapped: \"Rank up the hero to raise this cap\",\r\n needs: \"Needs\",\r\n where: \"Where it comes from\",\r\n base: \"Base\",\r\n fromLevel: \"Stat levels\",\r\n fromRank: \"Hero rank\",\r\n fromGear: \"Gear\",\r\n percent: \"Gear, %\",\r\n total: \"Total\",\r\n wear: \"Wear\",\r\n worn: \"Worn\",\r\n buy: \"Buy\",\r\n baseLook: \"Base look\",\r\n fits: \"Fits\",\r\n cannot: \"Cannot wear\",\r\n locked: \"Locked\",\r\n detail: \"Detail\",\r\n equipAll: \"Equip All\",\r\n lore: \"Lore\",\r\n upgradeLevel: \"UPGRADE LEVEL\",\r\n statsCaps: \"STATS\",\r\n} as const;\r\n\r\nexport type Key = keyof typeof EN;\r\n\r\nexport const t = makeT<Key>(EN, {\r\n title: \"Герои\",\r\n heroes: \"Герои\",\r\n stats: \"Характеристики\",\r\n skins: \"Облики\",\r\n rank: \"Ранг\",\r\n power: \"Сила\",\r\n rankUp: \"Повысить ранг\",\r\n maxRank: \"Максимальный ранг\",\r\n free: \"Бесплатно\",\r\n select: \"Выбрать\",\r\n selected: \"Выбран\",\r\n unlock: \"Открыть\",\r\n notForSale: \"Выпадает в наградах\",\r\n equipment: \"Снаряжение\",\r\n equipBest: \"Надеть лучшее\",\r\n unequipAll: \"Снять всё\",\r\n upgradeAll: \"Улучшить всё\",\r\n emptySlot: \"Пусто\",\r\n slotLocked: \"Ранг\",\r\n equip: \"Надеть\",\r\n unequip: \"Снять\",\r\n levelUp: \"Улучшить\",\r\n maxLevel: \"Макс. уровень\",\r\n level: \"Уровень\",\r\n wornBy: \"Надето на\",\r\n bag: \"Ваше снаряжение\",\r\n noGear: \"Снаряжения пока нет — оно есть в магазине и сундуках.\",\r\n noHeroes: \"Героев пока нет. Добавьте их в LiveOps → Characters.\",\r\n upgrade: \"Улучшить\",\r\n statCapped: \"Повысьте ранг героя, чтобы поднять предел\",\r\n needs: \"Нужно\",\r\n where: \"Откуда число\",\r\n base: \"База\",\r\n fromLevel: \"Уровни характеристики\",\r\n fromRank: \"Ранг героя\",\r\n fromGear: \"Снаряжение\",\r\n percent: \"Снаряжение, %\",\r\n total: \"Итого\",\r\n wear: \"Надеть\",\r\n worn: \"Надет\",\r\n buy: \"Купить\",\r\n baseLook: \"Обычный облик\",\r\n fits: \"Подходит\",\r\n cannot: \"Нельзя надеть\",\r\n locked: \"Закрыто\",\r\n detail: \"Подробнее\",\r\n equipAll: \"Надеть всё\",\r\n lore: \"Описание\",\r\n upgradeLevel: \"ПОВЫСИТЬ УРОВЕНЬ\",\r\n statsCaps: \"ХАРАКТЕРИСТИКИ\",\r\n});\r\n"
|
|
102
110
|
},
|
|
103
111
|
{
|
|
104
112
|
"path": "index.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@idosgames/core": "0.21.6",
|
|
39
39
|
"@idosgames/module-sdk": "0.11.0",
|
|
40
|
-
"@idosgames/react": "0.5.
|
|
40
|
+
"@idosgames/react": "0.5.1",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "8ee2540d83607f36c770ed998f2a3806edac1c0373b89a3425549f3f80f0e9ff",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@idosgames/core": "0.21.6",
|
|
38
38
|
"@idosgames/module-sdk": "0.11.0",
|
|
39
|
-
"@idosgames/react": "0.5.
|
|
39
|
+
"@idosgames/react": "0.5.1",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "bb88c07207fcc242fa2328e5aece9f7afca487cf8a6e0947de8576f387377a34",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@idosgames/core": "0.21.6",
|
|
36
36
|
"@idosgames/module-sdk": "0.11.0",
|
|
37
|
-
"@idosgames/react": "0.5.
|
|
37
|
+
"@idosgames/react": "0.5.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "ed7bff8cefd18dfb597b5a09e58c2ee5fc8500dc41e37091fa1e798540307db6",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@idosgames/core": "0.21.6",
|
|
39
39
|
"@idosgames/module-sdk": "0.11.0",
|
|
40
|
-
"@idosgames/react": "0.5.
|
|
40
|
+
"@idosgames/react": "0.5.1",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "f4d962e257dc5647a013afc7899b43134964f6505431f9ed1147611c67c4af90",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@idosgames/core": "0.21.6",
|
|
38
38
|
"@idosgames/module-sdk": "0.11.0",
|
|
39
|
-
"@idosgames/react": "0.5.
|
|
39
|
+
"@idosgames/react": "0.5.1",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "4f1608aa1fd90cf3561d8da43160187b46cc15b0f2f22fd0d980142ef90f0ef6",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@idosgames/core": "0.21.6",
|
|
41
41
|
"@idosgames/module-sdk": "0.11.0",
|
|
42
|
-
"@idosgames/react": "0.5.
|
|
42
|
+
"@idosgames/react": "0.5.1",
|
|
43
43
|
"react": "19.2.7"
|
|
44
44
|
},
|
|
45
45
|
"contentHash": "c3172782893c86d170bbd96c9a2dfbf9bab9ab5e2780c67d72f598ce8bc43753",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@idosgames/core": "0.21.6",
|
|
39
39
|
"@idosgames/module-sdk": "0.11.0",
|
|
40
|
-
"@idosgames/react": "0.5.
|
|
40
|
+
"@idosgames/react": "0.5.1",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "4e73bcfda1814bd4cc118ca318056e2d32d238e392c5c9cc2002fa892c223433",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@idosgames/core": "0.21.6",
|
|
36
36
|
"@idosgames/module-sdk": "0.11.0",
|
|
37
|
-
"@idosgames/react": "0.5.
|
|
37
|
+
"@idosgames/react": "0.5.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "0b02bc09a1a29ecb6d08fa281dfe316b01f8f341350ba27f386a05572827700e",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@idosgames/core": "0.21.6",
|
|
39
39
|
"@idosgames/module-sdk": "0.11.0",
|
|
40
|
-
"@idosgames/react": "0.5.
|
|
40
|
+
"@idosgames/react": "0.5.1",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "7d314ec2a1801e22dbe5b2e2df731e7a582f8efb9cca0328de5e30b52cc27047",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@idosgames/core": "0.21.6",
|
|
40
40
|
"@idosgames/module-sdk": "0.11.0",
|
|
41
|
-
"@idosgames/react": "0.5.
|
|
41
|
+
"@idosgames/react": "0.5.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "587cf8ee4685d907eaebfb1a0f96a7b269685cfefd990c05b057cf9626e75193",
|