@idosgames/mcp 0.1.42 → 0.1.44
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 +135 -135
- package/registry/modules/ai.json +3 -3
- package/registry/modules/board-game.json +15 -15
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +8 -8
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +3 -3
- package/registry/modules/coop-events.json +3 -3
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +3 -3
- package/registry/modules/events.json +7 -7
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +20 -16
- package/registry/modules/idle-rpg.json +17 -13
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/leaderboard.json +7 -7
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +7 -7
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +3 -3
- 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 +5 -5
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/video-studio.json +4 -4
- package/registry/modules/voxelcraft.json +36 -28
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/ai-generation-system.json +2 -2
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/rewarded-ads.json +1 -1
- package/registry/skills/store-system.json +1 -1
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"name": "iDos Games",
|
|
41
41
|
"url": "https://idosgames.com"
|
|
42
42
|
},
|
|
43
|
-
"version": "0.2.
|
|
43
|
+
"version": "0.2.3",
|
|
44
44
|
"events": {
|
|
45
45
|
"emits": [
|
|
46
46
|
{
|
|
@@ -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.6.
|
|
57
|
+
"@idosgames/core": "0.25.0",
|
|
58
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
59
|
+
"@idosgames/react": "0.6.3",
|
|
60
60
|
"react": "19.2.7"
|
|
61
61
|
},
|
|
62
|
-
"contentHash": "
|
|
62
|
+
"contentHash": "beefddeae9df41abd82e29c3e0a3ab904699c11712eb43c122f01fe77e12fecf",
|
|
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 textShadow: `0 ${w + 1}px 0 ${INK}`,\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, lip] = 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), 0 9px 0 ${lip}, 0 14px 0 rgba(0,0,0,.3)`,\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:\n \"inset 0 7px 0 rgba(255,255,255,.28), 0 8px 0 rgba(0,0,0,.3)\",\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:\n \"inset 0 7px 0 rgba(255,255,255,.25), 0 8px 0 rgba(0,0,0,.4)\",\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} 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"
|
|
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)
|
|
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"
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
"path": "components/HeroFigure.tsx",
|
|
@@ -143,7 +143,7 @@
|
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
145
|
"path": "module.meta.json",
|
|
146
|
-
"content": "{\n \"id\": \"character\",\n \"type\": \"feature\",\n \"summary\": \"Heroes screen for any game: roster, ranks and stats with their costs, equipment slots with 'Equip best', item levels and skins.\",\n \"description\": \"A ready heroes screen (the Hero button of the Unity SDK) for ANY game, built on the @idosgames/react/ui kit and client.character. The screen is the selected hero's equipment hub: portrait, class and rarity, rank and server-computed Power, 'Rank up' with its price, gear slots (locked ones show the rank they open at), 'Equip best' when better gear fits, 'Take off all', and the gear the player owns by class. Everything detailed is a popup: the hero list (select, unlock for currency), the stats (level/cap bars, upgrade prices, requirements, 'Upgrade all' — as many levels as the balances cover, cheapest first, in one batch —, and 'where this number comes from' — base, stat levels, rank, gear flat and percent bonuses), a slot's fitting gear, a piece of gear (equip / take off / level up) and skins (wear, buy). Prices and caps follow the title's curves (rank ladder, stat cost curve, StatMaxLevelCurve, item Upgrade); the server re-checks everything. Registers the 'character' feature; the inventory hands equipment to it with ctx.features.open('character', { itemInstanceID }). Its badge counts heroes to unlock or rank up and better gear. Ships title-seed.json: three heroes (Knight free, Archer and Mage for gems) with stats, ranks and slots. The hero is the game's own character when the game set ctx.characterArt (idle-rpg: its layered hero, live on the hub and the card, still in the list, redressed on every equip), else the screen's own picture by class. Emits character:hero-selected@1 with the hero the player plays with (on start-up and on every pick) so a game can field that hero.\",\n \"provides\": [\n \"hero roster with unlock for currency\",\n \"rank up with price by the rank ladder\",\n \"stat upgrades with costs, caps and requirements\",\n \"upgrade all stats in one batch\",\n \"stat breakdown popup by source\",\n \"equipment slots and equip / take off\",\n \"equip best gear automatically\",\n \"take off all gear\",\n \"item level upgrades\",\n \"character skins\",\n \"demo heroes\",\n \"selected hero for the game (event)\",\n \"the game's own hero drawn on the screen (ctx.characterArt)\"\n ],\n \"tags\": [\n \"heroes\",\n \"characters\",\n \"rpg\",\n \"stats\",\n \"equipment\",\n \"gear\",\n \"skins\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.2.
|
|
146
|
+
"content": "{\n \"id\": \"character\",\n \"type\": \"feature\",\n \"summary\": \"Heroes screen for any game: roster, ranks and stats with their costs, equipment slots with 'Equip best', item levels and skins.\",\n \"description\": \"A ready heroes screen (the Hero button of the Unity SDK) for ANY game, built on the @idosgames/react/ui kit and client.character. The screen is the selected hero's equipment hub: portrait, class and rarity, rank and server-computed Power, 'Rank up' with its price, gear slots (locked ones show the rank they open at), 'Equip best' when better gear fits, 'Take off all', and the gear the player owns by class. Everything detailed is a popup: the hero list (select, unlock for currency), the stats (level/cap bars, upgrade prices, requirements, 'Upgrade all' — as many levels as the balances cover, cheapest first, in one batch —, and 'where this number comes from' — base, stat levels, rank, gear flat and percent bonuses), a slot's fitting gear, a piece of gear (equip / take off / level up) and skins (wear, buy). Prices and caps follow the title's curves (rank ladder, stat cost curve, StatMaxLevelCurve, item Upgrade); the server re-checks everything. Registers the 'character' feature; the inventory hands equipment to it with ctx.features.open('character', { itemInstanceID }). Its badge counts heroes to unlock or rank up and better gear. Ships title-seed.json: three heroes (Knight free, Archer and Mage for gems) with stats, ranks and slots. The hero is the game's own character when the game set ctx.characterArt (idle-rpg: its layered hero, live on the hub and the card, still in the list, redressed on every equip), else the screen's own picture by class. Emits character:hero-selected@1 with the hero the player plays with (on start-up and on every pick) so a game can field that hero.\",\n \"provides\": [\n \"hero roster with unlock for currency\",\n \"rank up with price by the rank ladder\",\n \"stat upgrades with costs, caps and requirements\",\n \"upgrade all stats in one batch\",\n \"stat breakdown popup by source\",\n \"equipment slots and equip / take off\",\n \"equip best gear automatically\",\n \"take off all gear\",\n \"item level upgrades\",\n \"character skins\",\n \"demo heroes\",\n \"selected hero for the game (event)\",\n \"the game's own hero drawn on the screen (ctx.characterArt)\"\n ],\n \"tags\": [\n \"heroes\",\n \"characters\",\n \"rpg\",\n \"stats\",\n \"equipment\",\n \"gear\",\n \"skins\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.2.3\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"when\": \"on start-up (the saved or first playable hero, once the roster is known) and whenever the player selects another hero on the Heroes screen\",\n \"payload\": {\n \"characterId\": \"string\"\n }\n }\n ]\n }\n}\n"
|
|
147
147
|
}
|
|
148
148
|
]
|
|
149
149
|
}
|
|
@@ -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.6.
|
|
38
|
+
"@idosgames/core": "0.25.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
40
|
+
"@idosgames/react": "0.6.3",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "8ee2540d83607f36c770ed998f2a3806edac1c0373b89a3425549f3f80f0e9ff",
|
|
@@ -34,9 +34,9 @@
|
|
|
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.6.
|
|
37
|
+
"@idosgames/core": "0.25.0",
|
|
38
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
39
|
+
"@idosgames/react": "0.6.3",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "090986cb22bc6fe03d78b9227d8720477737f35757a0495d6399b43bc3577fd7",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.3"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.6.
|
|
35
|
+
"@idosgames/core": "0.25.0",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
37
|
+
"@idosgames/react": "0.6.3",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "a2fa5522c2b27ba330c9edf5610df2029a7f6a50843bbdbc955dc73b60f5eb07",
|
|
@@ -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.6.
|
|
38
|
+
"@idosgames/core": "0.25.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
40
|
+
"@idosgames/react": "0.6.3",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "f4d962e257dc5647a013afc7899b43134964f6505431f9ed1147611c67c4af90",
|
|
@@ -34,9 +34,9 @@
|
|
|
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.6.
|
|
37
|
+
"@idosgames/core": "0.25.0",
|
|
38
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
39
|
+
"@idosgames/react": "0.6.3",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "20e587ae8a91d2d1fff38e7b2c1d66a320b2a995d797629b890c6ca9b8e9bb5e",
|
|
@@ -34,19 +34,19 @@
|
|
|
34
34
|
"name": "iDos Games",
|
|
35
35
|
"url": "https://idosgames.com"
|
|
36
36
|
},
|
|
37
|
-
"version": "0.1.
|
|
37
|
+
"version": "0.1.5"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.13.
|
|
42
|
-
"@idosgames/react": "0.6.
|
|
40
|
+
"@idosgames/core": "0.25.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
42
|
+
"@idosgames/react": "0.6.3",
|
|
43
43
|
"react": "19.2.7"
|
|
44
44
|
},
|
|
45
|
-
"contentHash": "
|
|
45
|
+
"contentHash": "97ba661e41bf3aa20fa109b3a102907fb94cac6bb66a4e101e7a0d539c78e4e9",
|
|
46
46
|
"files": [
|
|
47
47
|
{
|
|
48
48
|
"path": "EventsScreen.tsx",
|
|
49
|
-
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Center,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n Spinner,\n U,\n UButton,\n UItemFrame,\n UPopup,\n URays,\n UScreen,\n UTabs,\n UTimer,\n U_PATTERN,\n tokenGlyph,\n uText,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n // One read at a time; a load asked for while one is in flight runs again right after it. The\n // screen, the watcher and the reload after a claim used to overlap — the transport skipped the\n // second as a duplicate, which wiped the list and lost the fresh read after the claim.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.timedEvent.getActiveEvents();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) events = res.data.ActiveEvents ?? [];\n else events ??= [];\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <UScreen\n background={v.blue}\n footer={\n events.length > 1 ? (\n <UTabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(\n e.Content?.DisplayName ?? e.TimedEventID,\n ),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n ) : undefined\n }\n >\n <EventView key={current.TimedEventID} event={current} store={store} />\n </UScreen>\n );\n };\n}\n\n/**\n * Keeps the badge current: loads the events once, then every few minutes; re-reads soon after the\n * player earns event tokens (a milestone may have been reached) and re-counts on premium changes.\n */\nexport function watchEvents(ctx: ModuleContext, store: EventsStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no event running — nothing to re-read\n if (soon) clearTimeout(soon);\n soon = setTimeout(() => void store.load(client), 2_000);\n }),\n // Subscribed directly: the premium state may be patched in place.\n client.on(\"user:anyUpdated\", () => store.refreshBadge()),\n ];\n return () => {\n if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\n });\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n const hasVip = rows.some((r) => r.vip.length > 0);\n const level = rows.filter((r) => goal.earned >= r.required).length;\n const left = countdown(event.ComputedEndUtc, now);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\" }}>\n {/* ── TopBg: the pass hero — curtains, gold piles, the chest over its light ── */}\n <div\n style={{\n position: \"relative\",\n height: 178,\n overflow: \"hidden\",\n background: `radial-gradient(ellipse at 50% 30%, ${v.bgTop} 0%, ${v.blue} 75%)`,\n }}\n >\n <EventHeroArt />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: 8,\n marginLeft: -80,\n width: 160,\n height: 160,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -20 }}>\n <URays size={200} color=\"rgba(255,255,255,.35)\" />\n </span>\n <span\n style={{\n position: \"relative\",\n animation: \"idos-u-float 2.6s ease-in-out infinite\",\n filter: \"drop-shadow(0 6px 0 rgba(0,0,0,.3))\",\n }}\n >\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"chest\"}\n size={112}\n fallback=\"chest\"\n />\n </span>\n </div>\n </div>\n\n {/* ── PanelFrame_TopBar03: the title banner, timer, level slider, Activate ── */}\n <div\n style={{\n position: \"relative\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.nav} 85%, #fff) 0%, ${v.nav} 100%)`,\n borderTop: `6px solid ${U.gold}`,\n borderBottom: `3px solid ${U.ink}`,\n padding: \"30px 12px 12px\",\n display: \"grid\",\n gap: 10,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -26,\n transform: \"translateX(-50%)\",\n maxWidth: \"86%\",\n padding: \"6px 28px 8px\",\n background: `linear-gradient(180deg, #ffe066 0%, ${U.gold} 100%)`,\n border: `3px solid ${U.ink}`,\n borderRadius: \"14px 14px 10px 10px\",\n boxShadow:\n \"inset 0 3px 0 rgba(255,255,255,.5), 0 4px 0 rgba(0,0,0,.3)\",\n whiteSpace: \"nowrap\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n ...uText(20),\n }}\n >\n {catalog.localize(event.Content?.DisplayName ?? event.TimedEventID)}\n </div>\n {left ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UTimer>{left}</UTimer>\n </div>\n ) : null}\n <LevelSlider\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n level={level}\n glyph={tokenGlyph({ kind: \"token\", id: \"TimedEvent\", amount: 0 })}\n />\n {vipID && !vipHeld && store.vipOnSale() ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UButton tone=\"yellow\" attract onClick={() => store.openVip(vipID)}>\n <Icon glyph=\"lock\" size={18} /> {t(\"activate\")}\n </UButton>\n </div>\n ) : null}\n <span style={{ position: \"absolute\", right: 10, top: 12 }}>\n <UButton\n tone=\"sky\"\n size=\"s\"\n onClick={() => setSources(true)}\n aria-label={t(\"howToEarn\")}\n >\n ?\n </UButton>\n </span>\n {event.Content?.Description ? (\n <div style={{ ...uText(13, \"#d6dcff\"), textAlign: \"center\" }}>\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <UButton\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </UButton>\n ) : null}\n </div>\n\n {/* ── Bottom: Free (blue) | the track | Premium (yellow) ── */}\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n gridTemplateColumns: hasVip ? \"1fr 44px 1fr\" : \"1fr 44px\",\n background: hasVip\n ? `${U_PATTERN}, linear-gradient(90deg, #0aabf5 50%, #fad100 50%)`\n : `${U_PATTERN}, #0aabf5`,\n paddingBottom: 14,\n }}\n >\n <ColumnHead tone=\"free\" />\n <span />\n {hasVip ? <ColumnHead tone=\"premium\" held={vipHeld} /> : null}\n {/* the vertical stage track down the middle */}\n <div\n style={{\n position: \"absolute\",\n left: hasVip ? \"50%\" : \"calc(100% - 22px)\",\n top: 62,\n bottom: 24,\n width: 14,\n marginLeft: -7,\n borderRadius: 7,\n background: v.well,\n border: `2px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n height: `${rows.length > 0 ? (level / rows.length) * 100 : 0}%`,\n background: \"linear-gradient(90deg, #ffd06b, #ff9a1a)\",\n transition: \"height .5s ease\",\n }}\n />\n </div>\n {rows.map((row, i) => (\n <MilestoneRowView\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n hasVip={hasVip}\n vipID={vipID}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nconst COIN_SPOTS: Array<[number, number]> = [\n [60, 158],\n [86, 150],\n [112, 156],\n [134, 162],\n [268, 156],\n [294, 148],\n [320, 152],\n [346, 160],\n];\n\n/** Background_Pass curtains + gold piles behind the chest, drawn in SVG. */\nfunction EventHeroArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 400 178\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke={U.ink} strokeWidth=\"3\" strokeLinejoin=\"round\">\n <path d=\"M0 0 L120 0 C100 40 70 60 52 178 L0 178 Z\" fill=\"#c2185b\" />\n <path d=\"M0 0 L84 0 C70 50 40 80 26 178 L0 178 Z\" fill=\"#e91e63\" />\n <path\n d=\"M400 0 L280 0 C300 40 330 60 348 178 L400 178 Z\"\n fill=\"#c2185b\"\n />\n <path\n d=\"M400 0 L316 0 C330 50 360 80 374 178 L400 178 Z\"\n fill=\"#e91e63\"\n />\n <path\n d=\"M-10 0 L410 0 L410 16 C340 30 270 22 200 30 C130 22 60 30 -10 16 Z\"\n fill=\"#ffce0d\"\n />\n <ellipse cx=\"96\" cy=\"170\" rx=\"70\" ry=\"22\" fill=\"#f5a90c\" />\n <ellipse cx=\"304\" cy=\"168\" rx=\"80\" ry=\"24\" fill=\"#f5a90c\" />\n </g>\n <g fill=\"#ffe066\" stroke=\"#a8650a\" strokeWidth=\"2\">\n {COIN_SPOTS.map(([x, y], i) => (\n <ellipse key={i} cx={x} cy={y} rx=\"14\" ry=\"6\" />\n ))}\n </g>\n </svg>\n );\n}\n\n/** Slider_Icon02: the token icon at the left end, the orange fill, the level badge at the right. */\nfunction LevelSlider({\n value,\n max,\n level,\n glyph,\n}: {\n value: number;\n max: number;\n level: number;\n glyph: string;\n}): ReactNode {\n const pct = Math.max(0, Math.min(1, value / max));\n return (\n <div\n style={{\n position: \"relative\",\n height: 52,\n display: \"flex\",\n alignItems: \"center\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 26,\n right: 26,\n height: 28,\n borderRadius: 14,\n background: v.well,\n border: `3px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${pct * 100}%`,\n height: \"100%\",\n background: \"linear-gradient(180deg, #ffd06b 0%, #ff9a1a 100%)\",\n boxShadow: \"inset 0 3px 0 rgba(255,255,255,.35)\",\n transition: \"width .4s ease\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(14),\n }}\n >\n {formatAmount(value)} / {formatAmount(max)}\n </div>\n </div>\n <span\n style={{\n position: \"absolute\",\n left: 0,\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph={glyph} size={50} fallback=\"star\" />\n </span>\n <span\n style={{\n position: \"absolute\",\n right: 0,\n width: 48,\n height: 48,\n borderRadius: \"50%\",\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.blue} 55%, #fff) 0%, ${v.blue} 70%)`,\n border: `3px solid ${U.ink}`,\n boxShadow: \"inset 0 0 0 4px #33d1ff\",\n display: \"grid\",\n placeItems: \"center\",\n ...uText(20),\n }}\n >\n {level}\n </span>\n </div>\n );\n}\n\n/** Icon_Ticket_Silver / _Gold with FREE / PREMIUM over each column. */\nfunction ColumnHead({\n tone,\n held,\n}: {\n tone: \"free\" | \"premium\";\n held?: boolean;\n}): ReactNode {\n const free = tone === \"free\";\n return (\n <div\n style={{\n display: \"grid\",\n placeItems: \"center\",\n padding: \"12px 6px 10px\",\n }}\n >\n <span\n style={{\n padding: \"6px 22px\",\n borderRadius: 10,\n border: `3px solid ${U.ink}`,\n background: free\n ? \"linear-gradient(180deg, #f2f6fb 0%, #a9b6cc 100%)\"\n : \"linear-gradient(180deg, #ffe066 0%, #f5a90c 100%)\",\n boxShadow:\n \"inset 0 3px 0 rgba(255,255,255,.5), 0 3px 0 rgba(0,0,0,.3)\",\n ...uText(18),\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n }}\n >\n {free ? null : <Icon glyph={held ? \"crown\" : \"lock\"} size={18} />}\n {free ? t(\"free\") : t(\"premium\")}\n </span>\n </div>\n );\n}\n\nconst BUBBLE = {\n free: {\n shadow: \"#6370c4\",\n bg: \"#ccdbf5\",\n border: \"#8fa1e8\",\n inner: \"#adc2ff\",\n },\n premium: {\n shadow: \"#d46300\",\n bg: \"#fff542\",\n border: \"#fca621\",\n inner: \"#ffd612\",\n },\n} as const;\n\n/** BubbleFrame05 (FrameFree / FramePremium): a speech bubble pointing at the track, the reward in it. */\nfunction Bubble({\n tone,\n lines,\n dim,\n done,\n locked,\n action,\n onLock,\n}: {\n tone: \"free\" | \"premium\";\n lines: ResourceLine[];\n dim: boolean;\n done: boolean;\n locked?: boolean;\n action?: ReactNode;\n onLock?: () => void;\n}): ReactNode {\n const c = BUBBLE[tone];\n const toRight = tone === \"free\";\n if (lines.length === 0) return <div />;\n return (\n <div\n style={{\n position: \"relative\",\n padding: toRight ? \"6px 12px 24px 8px\" : \"6px 8px 24px 12px\",\n }}\n >\n <div\n style={{\n position: \"relative\",\n borderRadius: 16,\n background: c.bg,\n border: `3px solid ${U.ink}`,\n boxShadow: `0 6px 0 ${c.shadow}`,\n padding: 7,\n minHeight: 88,\n }}\n >\n {/* the pointer toward the track */}\n <span\n style={{\n position: \"absolute\",\n top: 30,\n ...(toRight ? { right: -14 } : { left: -14 }),\n width: 14,\n height: 22,\n background: U.ink,\n clipPath: toRight\n ? \"polygon(0 0, 100% 50%, 0 100%)\"\n : \"polygon(100% 0, 0 50%, 100% 100%)\",\n }}\n />\n <div\n style={{\n borderRadius: 11,\n background: c.inner,\n border: `3px solid ${c.border}`,\n minHeight: 68,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n flexWrap: \"wrap\",\n padding: 4,\n }}\n >\n {lines.slice(0, 2).map((l, i) => (\n <UItemFrame key={i} line={l} size={lines.length > 1 ? 48 : 58} />\n ))}\n </div>\n {dim ? (\n <span\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 13,\n background: \"rgba(20,18,26,.45)\",\n }}\n />\n ) : null}\n {done ? (\n <span style={{ position: \"absolute\", right: -8, top: -10 }}>\n <Icon glyph=\"check\" size={34} />\n </span>\n ) : null}\n {locked ? (\n <button\n type=\"button\"\n onClick={onLock}\n aria-label={t(\"getVip\")}\n style={{\n position: \"absolute\",\n right: -10,\n top: -14,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: onLock ? \"pointer\" : \"default\",\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph=\"lock\" size={36} />\n </button>\n ) : null}\n </div>\n {action ? (\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n {action}\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneRowView({\n event,\n row,\n index,\n earned,\n store,\n hasVip,\n vipID,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n hasVip: boolean;\n vipID: string | null;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n const claimBtn = (label: string) => (\n <UButton\n tone=\"green\"\n size=\"s\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {label}\n </UButton>\n );\n return (\n <>\n <div style={style} data-index={index}>\n <Bubble\n tone=\"free\"\n lines={row.reward}\n dim={!reached}\n done={row.state === \"claimed\"}\n action={row.state === \"ready\" ? claimBtn(t(\"claim\")) : undefined}\n />\n </div>\n {/* the stage badge on the track */}\n <div\n ref={ref}\n style={{\n position: \"relative\",\n zIndex: 1,\n display: \"grid\",\n justifyItems: \"center\",\n paddingTop: 34,\n }}\n >\n <span\n style={{\n minWidth: 38,\n height: 38,\n padding: \"0 4px\",\n borderRadius: 19,\n border: `3px solid ${U.ink}`,\n background: reached\n ? \"linear-gradient(180deg, #ffe066, #f5a90c)\"\n : \"linear-gradient(180deg, #8d97b3, #5d6a92)\",\n display: \"grid\",\n placeItems: \"center\",\n boxShadow: row.featured ? \"0 0 0 3px #ff243d\" : undefined,\n ...uText(row.required >= 1000 ? 10 : 13),\n }}\n >\n {formatAmount(row.required)}\n </span>\n </div>\n {hasVip ? (\n <div style={style}>\n <Bubble\n tone=\"premium\"\n lines={row.vip}\n dim={!reached}\n done={row.vipState === \"granted\"}\n locked={row.vip.length > 0 && row.vipState === \"locked\"}\n onLock={\n vipID && store.vipOnSale()\n ? () => store.openVip(vipID)\n : undefined\n }\n action={\n row.vipState === \"ready\"\n ? claimBtn(row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\"))\n : undefined\n }\n />\n </div>\n ) : null}\n </>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <UPopup tone=\"blue\" title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </UPopup>\n );\n}\n"
|
|
49
|
+
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Center,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n Spinner,\n U,\n UButton,\n UItemFrame,\n UPopup,\n URays,\n UScreen,\n UTabs,\n UTimer,\n U_PATTERN,\n tokenGlyph,\n uText,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n // One read at a time; a load asked for while one is in flight runs again right after it. The\n // screen, the watcher and the reload after a claim used to overlap — the transport skipped the\n // second as a duplicate, which wiped the list and lost the fresh read after the claim.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.timedEvent.getActiveEvents();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) events = res.data.ActiveEvents ?? [];\n else events ??= [];\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <UScreen\n background={v.blue}\n footer={\n events.length > 1 ? (\n <UTabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(\n e.Content?.DisplayName ?? e.TimedEventID,\n ),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n ) : undefined\n }\n >\n <EventView key={current.TimedEventID} event={current} store={store} />\n </UScreen>\n );\n };\n}\n\n/**\n * Keeps the badge current: loads the events once, then every few minutes; re-reads soon after the\n * player earns event tokens (a milestone may have been reached) and re-counts on premium changes.\n */\nexport function watchEvents(ctx: ModuleContext, store: EventsStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no event running — nothing to re-read\n if (soon) clearTimeout(soon);\n soon = setTimeout(() => void store.load(client), 2_000);\n }),\n // Subscribed directly: the premium state may be patched in place.\n client.on(\"user:anyUpdated\", () => store.refreshBadge()),\n ];\n return () => {\n if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\n });\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n const hasVip = rows.some((r) => r.vip.length > 0);\n const level = rows.filter((r) => goal.earned >= r.required).length;\n const left = countdown(event.ComputedEndUtc, now);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\" }}>\n {/* ── TopBg: the pass hero — curtains, gold piles, the chest over its light ── */}\n <div\n style={{\n position: \"relative\",\n height: 178,\n overflow: \"hidden\",\n background: `radial-gradient(ellipse at 50% 30%, ${v.bgTop} 0%, ${v.blue} 75%)`,\n }}\n >\n <EventHeroArt />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: 8,\n marginLeft: -80,\n width: 160,\n height: 160,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -20 }}>\n <URays size={200} color=\"rgba(255,255,255,.35)\" />\n </span>\n <span\n style={{\n position: \"relative\",\n animation: \"idos-u-float 2.6s ease-in-out infinite\",\n }}\n >\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"chest\"}\n size={112}\n fallback=\"chest\"\n />\n </span>\n </div>\n </div>\n\n {/* ── PanelFrame_TopBar03: the title banner, timer, level slider, Activate ── */}\n <div\n style={{\n position: \"relative\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.nav} 85%, #fff) 0%, ${v.nav} 100%)`,\n borderTop: `6px solid ${U.gold}`,\n borderBottom: `3px solid ${U.ink}`,\n padding: \"30px 12px 12px\",\n display: \"grid\",\n gap: 10,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -26,\n transform: \"translateX(-50%)\",\n maxWidth: \"86%\",\n padding: \"6px 28px 8px\",\n background: `linear-gradient(180deg, #ffe066 0%, ${U.gold} 100%)`,\n border: `3px solid ${U.ink}`,\n borderRadius: \"14px 14px 10px 10px\",\n boxShadow: \"inset 0 3px 0 rgba(255,255,255,.5)\",\n whiteSpace: \"nowrap\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n ...uText(20),\n }}\n >\n {catalog.localize(event.Content?.DisplayName ?? event.TimedEventID)}\n </div>\n {left ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UTimer>{left}</UTimer>\n </div>\n ) : null}\n <LevelSlider\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n level={level}\n glyph={tokenGlyph({ kind: \"token\", id: \"TimedEvent\", amount: 0 })}\n />\n {vipID && !vipHeld && store.vipOnSale() ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UButton tone=\"yellow\" attract onClick={() => store.openVip(vipID)}>\n <Icon glyph=\"lock\" size={18} /> {t(\"activate\")}\n </UButton>\n </div>\n ) : null}\n <span style={{ position: \"absolute\", right: 10, top: 12 }}>\n <UButton\n tone=\"sky\"\n size=\"s\"\n onClick={() => setSources(true)}\n aria-label={t(\"howToEarn\")}\n >\n ?\n </UButton>\n </span>\n {event.Content?.Description ? (\n <div style={{ ...uText(13, \"#d6dcff\"), textAlign: \"center\" }}>\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <UButton\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </UButton>\n ) : null}\n </div>\n\n {/* ── Bottom: Free (blue) | the track | Premium (yellow) ── */}\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n gridTemplateColumns: hasVip ? \"1fr 44px 1fr\" : \"1fr 44px\",\n background: hasVip\n ? `${U_PATTERN}, linear-gradient(90deg, #0aabf5 50%, #fad100 50%)`\n : `${U_PATTERN}, #0aabf5`,\n paddingBottom: 14,\n }}\n >\n <ColumnHead tone=\"free\" />\n <span />\n {hasVip ? <ColumnHead tone=\"premium\" held={vipHeld} /> : null}\n {/* the vertical stage track down the middle */}\n <div\n style={{\n position: \"absolute\",\n left: hasVip ? \"50%\" : \"calc(100% - 22px)\",\n top: 62,\n bottom: 24,\n width: 14,\n marginLeft: -7,\n borderRadius: 7,\n background: v.well,\n border: `2px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n height: `${rows.length > 0 ? (level / rows.length) * 100 : 0}%`,\n background: \"linear-gradient(90deg, #ffd06b, #ff9a1a)\",\n transition: \"height .5s ease\",\n }}\n />\n </div>\n {rows.map((row, i) => (\n <MilestoneRowView\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n hasVip={hasVip}\n vipID={vipID}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nconst COIN_SPOTS: Array<[number, number]> = [\n [60, 158],\n [86, 150],\n [112, 156],\n [134, 162],\n [268, 156],\n [294, 148],\n [320, 152],\n [346, 160],\n];\n\n/** Background_Pass curtains + gold piles behind the chest, drawn in SVG. */\nfunction EventHeroArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 400 178\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke={U.ink} strokeWidth=\"3\" strokeLinejoin=\"round\">\n <path d=\"M0 0 L120 0 C100 40 70 60 52 178 L0 178 Z\" fill=\"#c2185b\" />\n <path d=\"M0 0 L84 0 C70 50 40 80 26 178 L0 178 Z\" fill=\"#e91e63\" />\n <path\n d=\"M400 0 L280 0 C300 40 330 60 348 178 L400 178 Z\"\n fill=\"#c2185b\"\n />\n <path\n d=\"M400 0 L316 0 C330 50 360 80 374 178 L400 178 Z\"\n fill=\"#e91e63\"\n />\n <path\n d=\"M-10 0 L410 0 L410 16 C340 30 270 22 200 30 C130 22 60 30 -10 16 Z\"\n fill=\"#ffce0d\"\n />\n <ellipse cx=\"96\" cy=\"170\" rx=\"70\" ry=\"22\" fill=\"#f5a90c\" />\n <ellipse cx=\"304\" cy=\"168\" rx=\"80\" ry=\"24\" fill=\"#f5a90c\" />\n </g>\n <g fill=\"#ffe066\" stroke=\"#a8650a\" strokeWidth=\"2\">\n {COIN_SPOTS.map(([x, y], i) => (\n <ellipse key={i} cx={x} cy={y} rx=\"14\" ry=\"6\" />\n ))}\n </g>\n </svg>\n );\n}\n\n/** Slider_Icon02: the token icon at the left end, the orange fill, the level badge at the right. */\nfunction LevelSlider({\n value,\n max,\n level,\n glyph,\n}: {\n value: number;\n max: number;\n level: number;\n glyph: string;\n}): ReactNode {\n const pct = Math.max(0, Math.min(1, value / max));\n return (\n <div\n style={{\n position: \"relative\",\n height: 52,\n display: \"flex\",\n alignItems: \"center\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 26,\n right: 26,\n height: 28,\n borderRadius: 14,\n background: v.well,\n border: `3px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${pct * 100}%`,\n height: \"100%\",\n background: \"linear-gradient(180deg, #ffd06b 0%, #ff9a1a 100%)\",\n boxShadow: \"inset 0 3px 0 rgba(255,255,255,.35)\",\n transition: \"width .4s ease\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(14),\n }}\n >\n {formatAmount(value)} / {formatAmount(max)}\n </div>\n </div>\n <span\n style={{\n position: \"absolute\",\n left: 0,\n }}\n >\n <Icon glyph={glyph} size={50} fallback=\"star\" />\n </span>\n <span\n style={{\n position: \"absolute\",\n right: 0,\n width: 48,\n height: 48,\n borderRadius: \"50%\",\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.blue} 55%, #fff) 0%, ${v.blue} 70%)`,\n border: `3px solid ${U.ink}`,\n boxShadow: \"inset 0 0 0 4px #33d1ff\",\n display: \"grid\",\n placeItems: \"center\",\n ...uText(20),\n }}\n >\n {level}\n </span>\n </div>\n );\n}\n\n/** Icon_Ticket_Silver / _Gold with FREE / PREMIUM over each column. */\nfunction ColumnHead({\n tone,\n held,\n}: {\n tone: \"free\" | \"premium\";\n held?: boolean;\n}): ReactNode {\n const free = tone === \"free\";\n return (\n <div\n style={{\n display: \"grid\",\n placeItems: \"center\",\n padding: \"12px 6px 10px\",\n }}\n >\n <span\n style={{\n padding: \"6px 22px\",\n borderRadius: 10,\n border: `3px solid ${U.ink}`,\n background: free\n ? \"linear-gradient(180deg, #f2f6fb 0%, #a9b6cc 100%)\"\n : \"linear-gradient(180deg, #ffe066 0%, #f5a90c 100%)\",\n boxShadow: \"inset 0 3px 0 rgba(255,255,255,.5)\",\n ...uText(18),\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n }}\n >\n {free ? null : <Icon glyph={held ? \"crown\" : \"lock\"} size={18} />}\n {free ? t(\"free\") : t(\"premium\")}\n </span>\n </div>\n );\n}\n\nconst BUBBLE = {\n free: {\n shadow: \"#6370c4\",\n bg: \"#ccdbf5\",\n border: \"#8fa1e8\",\n inner: \"#adc2ff\",\n },\n premium: {\n shadow: \"#d46300\",\n bg: \"#fff542\",\n border: \"#fca621\",\n inner: \"#ffd612\",\n },\n} as const;\n\n/** BubbleFrame05 (FrameFree / FramePremium): a speech bubble pointing at the track, the reward in it. */\nfunction Bubble({\n tone,\n lines,\n dim,\n done,\n locked,\n action,\n onLock,\n}: {\n tone: \"free\" | \"premium\";\n lines: ResourceLine[];\n dim: boolean;\n done: boolean;\n locked?: boolean;\n action?: ReactNode;\n onLock?: () => void;\n}): ReactNode {\n const c = BUBBLE[tone];\n const toRight = tone === \"free\";\n if (lines.length === 0) return <div />;\n return (\n <div\n style={{\n position: \"relative\",\n padding: toRight ? \"6px 12px 24px 8px\" : \"6px 8px 24px 12px\",\n }}\n >\n <div\n style={{\n position: \"relative\",\n borderRadius: 16,\n background: c.bg,\n border: `3px solid ${U.ink}`,\n boxShadow: `none`,\n padding: 7,\n minHeight: 88,\n }}\n >\n {/* the pointer toward the track */}\n <span\n style={{\n position: \"absolute\",\n top: 30,\n ...(toRight ? { right: -14 } : { left: -14 }),\n width: 14,\n height: 22,\n background: U.ink,\n clipPath: toRight\n ? \"polygon(0 0, 100% 50%, 0 100%)\"\n : \"polygon(100% 0, 0 50%, 100% 100%)\",\n }}\n />\n <div\n style={{\n borderRadius: 11,\n background: c.inner,\n border: `3px solid ${c.border}`,\n minHeight: 68,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n flexWrap: \"wrap\",\n padding: 4,\n }}\n >\n {lines.slice(0, 2).map((l, i) => (\n <UItemFrame key={i} line={l} size={lines.length > 1 ? 48 : 58} />\n ))}\n </div>\n {dim ? (\n <span\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 13,\n background: \"rgba(20,18,26,.45)\",\n }}\n />\n ) : null}\n {done ? (\n <span style={{ position: \"absolute\", right: -8, top: -10 }}>\n <Icon glyph=\"check\" size={34} />\n </span>\n ) : null}\n {locked ? (\n <button\n type=\"button\"\n onClick={onLock}\n aria-label={t(\"getVip\")}\n style={{\n position: \"absolute\",\n right: -10,\n top: -14,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: onLock ? \"pointer\" : \"default\",\n }}\n >\n <Icon glyph=\"lock\" size={36} />\n </button>\n ) : null}\n </div>\n {action ? (\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n {action}\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneRowView({\n event,\n row,\n index,\n earned,\n store,\n hasVip,\n vipID,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n hasVip: boolean;\n vipID: string | null;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n const claimBtn = (label: string) => (\n <UButton\n tone=\"green\"\n size=\"s\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {label}\n </UButton>\n );\n return (\n <>\n <div style={style} data-index={index}>\n <Bubble\n tone=\"free\"\n lines={row.reward}\n dim={!reached}\n done={row.state === \"claimed\"}\n action={row.state === \"ready\" ? claimBtn(t(\"claim\")) : undefined}\n />\n </div>\n {/* the stage badge on the track */}\n <div\n ref={ref}\n style={{\n position: \"relative\",\n zIndex: 1,\n display: \"grid\",\n justifyItems: \"center\",\n paddingTop: 34,\n }}\n >\n <span\n style={{\n minWidth: 38,\n height: 38,\n padding: \"0 4px\",\n borderRadius: 19,\n border: `3px solid ${U.ink}`,\n background: reached\n ? \"linear-gradient(180deg, #ffe066, #f5a90c)\"\n : \"linear-gradient(180deg, #8d97b3, #5d6a92)\",\n display: \"grid\",\n placeItems: \"center\",\n boxShadow: row.featured ? \"0 0 0 3px #ff243d\" : undefined,\n ...uText(row.required >= 1000 ? 10 : 13),\n }}\n >\n {formatAmount(row.required)}\n </span>\n </div>\n {hasVip ? (\n <div style={style}>\n <Bubble\n tone=\"premium\"\n lines={row.vip}\n dim={!reached}\n done={row.vipState === \"granted\"}\n locked={row.vip.length > 0 && row.vipState === \"locked\"}\n onLock={\n vipID && store.vipOnSale()\n ? () => store.openVip(vipID)\n : undefined\n }\n action={\n row.vipState === \"ready\"\n ? claimBtn(row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\"))\n : undefined\n }\n />\n </div>\n ) : null}\n </>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <UPopup tone=\"blue\" title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </UPopup>\n );\n}\n"
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
"path": "i18n.ts",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
"path": "module.meta.json",
|
|
69
|
-
"content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"hand-off to the VIP system\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
69
|
+
"content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"hand-off to the VIP system\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.5\"\n}\n"
|
|
70
70
|
}
|
|
71
71
|
]
|
|
72
72
|
}
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"version": "0.1.3"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@idosgames/core": "0.
|
|
39
|
-
"@idosgames/module-sdk": "0.13.
|
|
40
|
-
"@idosgames/react": "0.6.
|
|
38
|
+
"@idosgames/core": "0.25.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
40
|
+
"@idosgames/react": "0.6.3",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "cb4b66936fa31747a116946730b9fc2576262e68220570b149de0dbf3bb9a943",
|