@idosgames/mcp 0.1.16 → 0.1.18
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 +19 -7
- package/registry/index.json +731 -33
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/character-ui.json +78 -0
- package/registry/modules/chat-ui.json +74 -0
- package/registry/modules/craft-ui.json +74 -0
- package/registry/modules/deal-offer-ui.json +73 -0
- package/registry/modules/events-ui.json +80 -0
- package/registry/modules/friends-ui.json +73 -0
- package/registry/modules/game-hud.json +15 -10
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory-ui.json +73 -0
- package/registry/modules/leaderboard-ui.json +75 -0
- package/registry/modules/marketplace-ui.json +75 -0
- package/registry/modules/profile-ui.json +74 -0
- package/registry/modules/pvp-ui.json +72 -0
- package/registry/modules/referral-ui.json +73 -0
- package/registry/modules/rewards-ui.json +75 -0
- package/registry/modules/season-ui.json +79 -0
- package/registry/modules/showcase.json +92 -0
- package/registry/modules/store-ui.json +75 -0
- package/registry/modules/tutorial-ui.json +71 -0
- package/registry/modules/voxelcraft.json +98 -41
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +6 -0
- package/registry/skills/leaderboard-system.json +1 -1
- package/registry/skills/multiplayer-realtime.json +3 -3
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "store-ui",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Shop",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.",
|
|
11
|
+
"description": "A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the showcase lobby shows it as a tab, the shared HUD's ☰ lists it, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"shop screen from the title storefront",
|
|
14
|
+
"several stores as tabs",
|
|
15
|
+
"offer carousel and grids with countdowns",
|
|
16
|
+
"free daily gift offer",
|
|
17
|
+
"purchase confirmation popup",
|
|
18
|
+
"reward animation with icons flying to balances",
|
|
19
|
+
"shop badge for free offers",
|
|
20
|
+
"demo store settings"
|
|
21
|
+
],
|
|
22
|
+
"tags": [
|
|
23
|
+
"shop",
|
|
24
|
+
"store",
|
|
25
|
+
"storefront",
|
|
26
|
+
"purchase",
|
|
27
|
+
"offers",
|
|
28
|
+
"ui",
|
|
29
|
+
"screen",
|
|
30
|
+
"economy"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "iDos Games",
|
|
34
|
+
"url": "https://idosgames.com"
|
|
35
|
+
},
|
|
36
|
+
"version": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@idosgames/core": "0.19.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.5.0",
|
|
41
|
+
"@idosgames/react": "0.3.0",
|
|
42
|
+
"react": "19.2.7"
|
|
43
|
+
},
|
|
44
|
+
"contentHash": "f9ef650d747113ee2ad1347cfe8b2d7e8b7d3b2ccfcaf0c1dedbfcdb4d509359",
|
|
45
|
+
"files": [
|
|
46
|
+
{
|
|
47
|
+
"path": "i18n.ts",
|
|
48
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Shop\",\n free: \"Free\",\n soldOut: \"Sold out\",\n refreshesIn: \"Refreshes in\",\n readyIn: \"Ready in\",\n confirmTitle: \"Confirm purchase\",\n confirmBuy: \"Buy for\",\n confirmFree: \"Take for free\",\n emptyShop: \"The shop is empty. Add offers in LiveOps → Store.\",\n left: \"left\",\n retry: \"Retry\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Магазин\",\n free: \"Бесплатно\",\n soldOut: \"Распродано\",\n refreshesIn: \"Обновится через\",\n readyIn: \"Доступно через\",\n confirmTitle: \"Подтвердите покупку\",\n confirmBuy: \"Купить за\",\n confirmFree: \"Забрать бесплатно\",\n emptyShop: \"В магазине пусто. Добавьте товары в LiveOps → Store.\",\n left: \"осталось\",\n retry: \"Повторить\",\n});\n"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "index.ts",
|
|
52
|
+
"content": "// @idosgames/mod-store-ui — Shop screen for any game: the title's storefront with several stores as tabs, a carousel and grids of offers, purchase confirmation and the reward animation.\n\nexport { storeUiModule } from \"./module\";\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "model.test.ts",
|
|
56
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport { grantLines } from \"@idosgames/react/ui\";\nimport {\n earliestRefresh,\n freeOffersWaiting,\n isFree,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\n\n// The storefront the server resolves is not the raw config; the helpers are checked on small\n// resolved shapes, and the seed on \"every offer draws\".\n\nconst offer = (\n id: string,\n free: boolean,\n extra: Record<string, unknown> = {},\n) => ({\n OfferID: id,\n PriceOptions: [\n free\n ? { IsFree: true }\n : {\n Cost: {\n Standard: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: 50 },\n ],\n },\n },\n },\n ],\n ...extra,\n});\n\ndescribe(\"shop\", () => {\n it(\"every seeded offer has a reward the shop can draw\", () => {\n const offers = Object.values(\n (\n seed as unknown as {\n Store: {\n StoreOffers: Record<string, { Reward: { Grant: unknown } }>;\n };\n }\n ).Store.StoreOffers,\n );\n expect(offers.length).toBeGreaterThan(0);\n for (const o of offers)\n expect(grantLines(o.Reward.Grant as never).length).toBeGreaterThan(0);\n });\n\n it(\"orders stores, sections and slots by SortOrder\", () => {\n const front = {\n Stores: [\n { StoreID: \"b\", SortOrder: 2 },\n {\n StoreID: \"a\",\n SortOrder: 1,\n Sections: [\n { SectionID: \"y\", SortOrder: 2, Slots: [] },\n {\n SectionID: \"x\",\n SortOrder: 1,\n Slots: [\n { SlotID: \"2\", SortOrder: 2 },\n { SlotID: \"1\", SortOrder: 1 },\n ],\n },\n ],\n },\n ],\n } as never;\n expect(sortedStores(front).map((s) => s.StoreID)).toEqual([\"a\", \"b\"]);\n const sections = sortedSections(sortedStores(front)[0]);\n expect(sections.map((s) => s.SectionID)).toEqual([\"x\", \"y\"]);\n expect(slotsOf(sections[0]!).map((s) => s.SlotID)).toEqual([\"1\", \"2\"]);\n });\n\n it(\"free = explicitly free or no price; the badge counts free offers still available\", () => {\n expect(isFree(offer(\"gift\", true) as never)).toBe(true);\n expect(isFree({ OfferID: \"x\", PriceOptions: [] } as never)).toBe(true);\n expect(isFree(offer(\"potion\", false) as never)).toBe(false);\n const front = {\n Stores: [\n {\n StoreID: \"main\",\n SortOrder: 0,\n Sections: [\n {\n SectionID: \"d\",\n SortOrder: 0,\n Slots: [\n { SlotID: \"1\", SortOrder: 0, Offer: offer(\"gift\", true) },\n {\n SlotID: \"2\",\n SortOrder: 1,\n Offer: offer(\"gift2\", true, { State: { SoldOut: true } }),\n },\n { SlotID: \"3\", SortOrder: 2, Offer: offer(\"potion\", false) },\n { SlotID: \"4\", SortOrder: 3 },\n ],\n },\n ],\n },\n ],\n } as never;\n expect(freeOffersWaiting(front)).toBe(1);\n expect(freeOffersWaiting(null)).toBe(0);\n });\n\n it(\"a section's countdown is its earliest card refresh\", () => {\n expect(\n earliestRefresh({\n SectionID: \"s\",\n SortOrder: 0,\n Slots: [\n { SlotID: \"a\", SortOrder: 0, RefreshesAtUtc: \"2026-09-28T00:00:00Z\" },\n { SlotID: \"b\", SortOrder: 1, RefreshesAtUtc: \"2026-09-27T20:00:00Z\" },\n ],\n } as never),\n ).toBe(\"2026-09-27T20:00:00Z\");\n });\n});\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "model.ts",
|
|
60
|
+
"content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "module.ts",
|
|
64
|
+
"content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { StoreScreen } from \"./StoreScreen\";\nimport { freeOffersWaiting } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop as a feature of the game (ctx.features): the lobby shows it as a main tab, the shared\n// HUD's ☰ lists it. Its badge counts free offers waiting to be taken (the daily gift), kept fresh\n// by an invisible always-on panel — the screen itself is mounted only while open.\nexport const storeUiModule: Module = defineModule({\n id: \"store-ui\",\n meta: { name: \"Shop\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"store\",\n label: t(\"title\"),\n icon: \"shop\",\n group: \"economy\",\n order: 10,\n primary: true,\n Screen: StoreScreen,\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"store\",\n badge: ctx.features.get(\"store\")?.badge ?? 0,\n }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function StoreBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () =>\n features.setBadge(\n \"store\",\n freeOffersWaiting(client.store.cachedStorefront),\n );\n void client.store.getStorefront().then(update);\n const off = client.on(\"user:anyUpdated\", update);\n // A free gift comes back with the next day: look again every few minutes.\n const id = setInterval(\n () => void client.store.getStorefront().then(update),\n 5 * 60_000,\n );\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "StoreScreen.tsx",
|
|
68
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n OFFER_NOT_IN_ROTATION,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n canAfford,\n costLines,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n earliestRefresh,\n isFree,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n onPick={(offer) =>\n setPicked({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n onPick={(offer) =>\n setPicked({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = costLines(offer.PriceOptions?.[0]?.Cost);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n ...panel,\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: 12,\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n background:\n free && !disabled\n ? `linear-gradient(180deg, #3d5bd6 0%, ${v.panelDeep} 100%)`\n : panel.background,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...outlined,\n fontSize: 14,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone={free ? \"green\" : \"gold\"}\n disabled={disabled}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\" }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(picked.offer.Rewards);\n const name = catalog.localize(\n picked.offer.Identity?.DisplayName ?? picked.offer.OfferID,\n );\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = costLines(option?.Cost);\n const free = isFree(picked.offer);\n\n const buy = async () => {\n setBusy(true);\n const res = await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot: {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n },\n });\n setBusy(false);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "module.meta.json",
|
|
72
|
+
"content": "{\n \"id\": \"store-ui\",\n \"type\": \"feature\",\n \"summary\": \"Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.\",\n \"description\": \"A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the showcase lobby shows it as a tab, the shared HUD's ☰ lists it, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.\",\n \"provides\": [\n \"shop screen from the title storefront\",\n \"several stores as tabs\",\n \"offer carousel and grids with countdowns\",\n \"free daily gift offer\",\n \"purchase confirmation popup\",\n \"reward animation with icons flying to balances\",\n \"shop badge for free offers\",\n \"demo store settings\"\n ],\n \"tags\": [\n \"shop\",\n \"store\",\n \"storefront\",\n \"purchase\",\n \"offers\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "tutorial-ui",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Tutorials",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.",
|
|
11
|
+
"description": "A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, the play button play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the showcase lobby with a gem reward.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"tutorial overlay with a highlighted element",
|
|
14
|
+
"hint bubble with next and skip",
|
|
15
|
+
"any element as an anchor via data-tutorial-anchor",
|
|
16
|
+
"tutorial completion reward",
|
|
17
|
+
"tutorials list with replay",
|
|
18
|
+
"demo welcome tour"
|
|
19
|
+
],
|
|
20
|
+
"tags": [
|
|
21
|
+
"tutorial",
|
|
22
|
+
"onboarding",
|
|
23
|
+
"ftue",
|
|
24
|
+
"coach-marks",
|
|
25
|
+
"hints",
|
|
26
|
+
"ui"
|
|
27
|
+
],
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "iDos Games",
|
|
30
|
+
"url": "https://idosgames.com"
|
|
31
|
+
},
|
|
32
|
+
"version": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@idosgames/core": "0.19.0",
|
|
36
|
+
"@idosgames/module-sdk": "0.5.0",
|
|
37
|
+
"@idosgames/react": "0.3.0",
|
|
38
|
+
"react": "19.2.7"
|
|
39
|
+
},
|
|
40
|
+
"contentHash": "ddd651cc2cff6692f7b98c2904c63f9e0548148b2b77d135c00be6c3039a1c56",
|
|
41
|
+
"files": [
|
|
42
|
+
{
|
|
43
|
+
"path": "i18n.ts",
|
|
44
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Tutorials\",\n next: \"Next\",\n skip: \"Skip\",\n waiting: \"Do it to go on\",\n done: \"Tutorial complete!\",\n claim: \"Claim\",\n noTutorials: \"No tutorials in this game.\",\n replay: \"Replay\",\n completed: \"Completed\",\n inProgress: \"In progress\",\n notStarted: \"Not started\",\n skipped: \"Skipped\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Обучение\",\n next: \"Далее\",\n skip: \"Пропустить\",\n waiting: \"Сделайте это, чтобы продолжить\",\n done: \"Обучение пройдено!\",\n claim: \"Забрать\",\n noTutorials: \"Обучения в этой игре нет.\",\n replay: \"Пройти заново\",\n completed: \"Пройдено\",\n inProgress: \"Идёт\",\n notStarted: \"Не начато\",\n skipped: \"Пропущено\",\n});\n"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "index.ts",
|
|
48
|
+
"content": "// @idosgames/mod-tutorial-ui — Onboarding for any game: hint bubbles next to marked elements, step by step, with a reward and a replay list.\n\nexport { tutorialUiModule } from \"./module\";\n"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "model.test.ts",
|
|
52
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n activeFlow,\n anchorOf,\n bubblePlacement,\n canTapNext,\n stepCounter,\n stepText,\n} from \"./model\";\n\ndescribe(\"tutorial\", () => {\n it(\"the seeded welcome tour: five steps on the lobby's anchors, skippable, replayable\", () => {\n const flow = (\n seed as unknown as {\n Tutorial: {\n Flows: Record<\n string,\n {\n Steps: Record<string, { Identity: { AnchorID?: string } }>;\n Policy: { Skippable: boolean; RestartPolicy: string };\n }\n >;\n };\n }\n ).Tutorial.Flows.welcome!;\n expect(Object.keys(flow.Steps)).toHaveLength(5);\n expect(\n Object.values(flow.Steps).map((s) => s.Identity.AnchorID ?? null),\n ).toEqual([\n null,\n \"tab:store\",\n \"tab:character\",\n \"tab:rewards\",\n \"lobby:profile\",\n ]);\n expect(flow.Policy).toMatchObject({\n Skippable: true,\n RestartPolicy: \"OnRequest\",\n });\n });\n\n it(\"shows the running flow, else the one whose reward waits\", () => {\n const running = { FlowID: \"a\", Status: \"InProgress\", CurrentStep: {} };\n const pending = { FlowID: \"b\", Status: \"Completed\", RewardPending: true };\n expect(activeFlow([pending, running])?.FlowID).toBe(\"a\");\n expect(activeFlow([pending])?.FlowID).toBe(\"b\");\n expect(activeFlow([{ FlowID: \"c\", Status: \"Completed\" }])).toBeNull();\n });\n\n it(\"'Next' only on steps the player closes; the highlight prefers HighlightTarget\", () => {\n expect(canTapNext({ Completion: { Mode: \"ClientAck\" } })).toBe(true);\n expect(canTapNext({})).toBe(true);\n expect(canTapNext({ Completion: { Mode: \"SystemEvent\" } })).toBe(false);\n expect(\n anchorOf({ Identity: { AnchorID: \"a\", HighlightTarget: \"h\" } }),\n ).toBe(\"h\");\n expect(anchorOf({ Identity: { AnchorID: \"a\" } })).toBe(\"a\");\n expect(\n stepText({ Identity: { TitleKey: \"k\", Body: \"b\" } }, (k) => `[${k}]`),\n ).toEqual({ title: \"[k]\", body: \"b\" });\n expect(stepCounter({ FlowID: \"x\", TotalSteps: 5, CompletedSteps: 1 })).toBe(\n \"2 / 5\",\n );\n });\n\n it(\"puts the bubble below the element, above it near the bottom, inside the screen\", () => {\n const screen = { width: 375, height: 812 };\n const bubble = { width: 320, height: 150 };\n expect(\n bubblePlacement(\n { left: 10, top: 100, width: 60, height: 40 },\n screen,\n bubble,\n ),\n ).toEqual({ left: 12, top: 152, above: false });\n const low = bubblePlacement(\n { left: 300, top: 740, width: 70, height: 60 },\n screen,\n bubble,\n );\n expect(low.above).toBe(true);\n expect(low.top).toBe(740 - 12 - 150);\n expect(low.left).toBe(375 - 12 - 320);\n expect(bubblePlacement(null, screen, bubble)).toEqual({\n left: 27.5,\n top: 331,\n above: false,\n });\n });\n});\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "model.ts",
|
|
56
|
+
"content": "// Pure helpers of the tutorial overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry): which\n// flow to show, what the step says, whether the player may tap \"Next\", and where the hint bubble\n// goes around the highlighted element. The server owns the order, the completion and the reward.\n// Covered by model.test.ts.\n\nexport interface StepView {\n Identity?: {\n Title?: string | null;\n TitleKey?: string | null;\n Body?: string | null;\n BodyKey?: string | null;\n AnchorID?: string | null;\n HighlightTarget?: string | null;\n } | null;\n Completion?: { Mode?: string | null } | null;\n}\n\nexport interface FlowView {\n FlowID: string;\n Status?: string | null;\n CurrentStepID?: string | null;\n CurrentStep?: StepView | null;\n TotalSteps?: number | null;\n CompletedSteps?: number | null;\n CanSkip?: boolean | null;\n RewardPending?: boolean | null;\n}\n\n/** The flow to show now: one in progress (with a step), else one whose reward waits. */\nexport function activeFlow(\n flows: readonly FlowView[] | null | undefined,\n): FlowView | null {\n const list = flows ?? [];\n return (\n list.find((f) => f.Status === \"InProgress\" && f.CurrentStep) ??\n list.find((f) => f.RewardPending) ??\n null\n );\n}\n\n/** Only a ClientAck step is closed by the player; SystemEvent / Composite wait for the game. */\nexport function canTapNext(step: StepView | null | undefined): boolean {\n return (step?.Completion?.Mode ?? \"ClientAck\") === \"ClientAck\";\n}\n\n/** The element to highlight: HighlightTarget, else the anchor. */\nexport function anchorOf(step: StepView | null | undefined): string | null {\n return step?.Identity?.HighlightTarget || step?.Identity?.AnchorID || null;\n}\n\n/** Title and body through the title's localization when a key is set. */\nexport function stepText(\n step: StepView | null | undefined,\n localize: (text: string) => string,\n): { title: string; body: string } {\n const id = step?.Identity;\n return {\n title: id?.TitleKey ? localize(id.TitleKey) : (id?.Title ?? \"\"),\n body: id?.BodyKey ? localize(id.BodyKey) : (id?.Body ?? \"\"),\n };\n}\n\nexport interface Rect {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\n/**\n * Where the hint bubble goes: below the element when there is room, above otherwise; horizontally\n * centred on it and kept inside the screen with a margin. No element → the middle of the screen.\n */\nexport function bubblePlacement(\n target: Rect | null,\n viewport: { width: number; height: number },\n bubble: { width: number; height: number },\n margin = 12,\n): { left: number; top: number; above: boolean } {\n const width = Math.min(bubble.width, viewport.width - margin * 2);\n if (!target)\n return {\n left: (viewport.width - width) / 2,\n top: Math.max(margin, (viewport.height - bubble.height) / 2),\n above: false,\n };\n const below = target.top + target.height + margin;\n const above =\n below + bubble.height > viewport.height - margin &&\n target.top - margin - bubble.height >= margin;\n const top = above\n ? target.top - margin - bubble.height\n : Math.min(below, viewport.height - margin - bubble.height);\n const centre = target.left + target.width / 2 - width / 2;\n return {\n left: Math.min(Math.max(margin, centre), viewport.width - margin - width),\n top: Math.max(margin, top),\n above,\n };\n}\n\n/** \"2 / 5\" — the step being shown, counted from 1. */\nexport function stepCounter(flow: FlowView): string {\n const total = Number(flow.TotalSteps ?? 0);\n const done = Number(flow.CompletedSteps ?? 0);\n return total > 0 ? `${Math.min(total, done + 1)} / ${total}` : \"\";\n}\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "module.ts",
|
|
60
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createTutorialStore,\n makeTutorialOverlay,\n makeTutorialsScreen,\n} from \"./TutorialOverlay\";\nimport { t } from \"./i18n\";\n\n// Onboarding for any game: an always-on overlay shows the current step next to the element marked\n// data-tutorial-anchor=\"{AnchorID}\"; the \"Tutorials\" feature lists the flows and replays them.\nexport const tutorialUiModule: Module = defineModule({\n id: \"tutorial-ui\",\n meta: { name: \"Tutorials\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createTutorialStore();\n ctx.registerPanel({\n id: \"overlay\",\n slot: \"modal\",\n activeOnly: false,\n component: makeTutorialOverlay(store),\n });\n ctx.features.register({\n id: \"tutorials\",\n label: t(\"title\"),\n icon: \"bulb\",\n group: \"profile\",\n order: 110,\n Screen: makeTutorialsScreen(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"tutorials\",\n running:\n store\n .get()\n ?.filter((f) => f.Status === \"InProgress\")\n .map((f) => f.FlowID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "TutorialOverlay.tsx",
|
|
64
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Spinner,\n bundleLines,\n errorText,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n activeFlow,\n anchorOf,\n bubblePlacement,\n canTapNext,\n stepCounter,\n stepText,\n type FlowView,\n type Rect,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The tutorial overlay (Unity Runtime/UI/Tutorial): the current step of the running flow as a hint\n// bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module,\n// like Unity's TutorialAnchorRegistry. It waits while a popup is open, \"Next\" only on steps the\n// player closes, and the reward when the flow ends. The \"Tutorials\" feature replays a flow.\n\n/** Tutorial state shared by the overlay and the replay screen. */\nexport function createTutorialStore() {\n let flows: FlowView[] | null = null;\n const listeners = new Set<() => void>();\n return {\n get: () => flows,\n set(next: FlowView[] | null) {\n flows = next;\n for (const l of listeners) l();\n },\n subscribe(l: () => void) {\n listeners.add(l);\n return () => {\n listeners.delete(l);\n };\n },\n };\n}\nexport type TutorialStore = ReturnType<typeof createTutorialStore>;\n\nconst BUBBLE = { width: 320, height: 150 };\n\n/** What an applied operation granted: the standard part plus premium bundles. */\nfunction operationLines(op: unknown) {\n const grant = (\n op as\n | {\n Grant?: {\n Standard?: unknown;\n PremiumTiers?: Array<{ Resources?: unknown } | null> | null;\n } | null;\n }\n | null\n | undefined\n )?.Grant;\n return [\n ...bundleLines(grant?.Standard as never),\n ...(grant?.PremiumTiers ?? []).flatMap((t) =>\n bundleLines(t?.Resources as never),\n ),\n ];\n}\n\nfunction modalOpen(): boolean {\n return document.querySelector(\"[data-idos-ui-layer] [role=dialog]\") !== null;\n}\n\nexport function makeTutorialOverlay(store: TutorialStore) {\n return function TutorialOverlay(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const [flows, setFlows] = useState<FlowView[] | null>(store.get());\n const [rect, setRect] = useState<Rect | null>(null);\n const [blocked, setBlocked] = useState(true);\n const [busy, setBusy] = useState(false);\n const shown = useRef(new Set<string>());\n\n const load = useCallback(async () => {\n const res = await client.tutorial.getUserTutorialState();\n store.set(res.ok ? ((res.data.Flows ?? []) as FlowView[]) : []);\n }, [client]);\n\n useEffect(() => store.subscribe(() => setFlows(store.get())), []);\n useEffect(() => {\n void load();\n // A SystemEvent step closes on a game action; the progress arrives with that action's reply.\n const off = client.on(\"tutorial:systemProgress\", () => void load());\n return () => off();\n }, [client, load]);\n\n const flow = activeFlow(flows);\n const step = flow?.Status === \"InProgress\" ? flow.CurrentStep : null;\n const anchor = anchorOf(step);\n\n // Follow the anchor (it moves with layout, scroll and screen changes) and step aside for popups.\n useEffect(() => {\n if (!flow) return;\n const tick = () => {\n setBlocked(modalOpen());\n const el = anchor\n ? document.querySelector(\n `[data-tutorial-anchor=\"${CSS.escape(anchor)}\"]`,\n )\n : null;\n const r = el?.getBoundingClientRect();\n setRect(\n r && r.width > 0 && r.height > 0\n ? { left: r.left, top: r.top, width: r.width, height: r.height }\n : null,\n );\n };\n tick();\n const id = setInterval(tick, 250);\n return () => clearInterval(id);\n }, [flow?.FlowID, flow?.CurrentStepID, anchor]);\n\n // The funnel measures time on step from this report — once per step shown.\n useEffect(() => {\n if (!flow || !flow.CurrentStepID || blocked) return;\n const key = `${flow.FlowID}:${flow.CurrentStepID}`;\n if (shown.current.has(key)) return;\n shown.current.add(key);\n kit.play(\"notify\");\n void client.tutorial.reportStepShown(flow.FlowID, flow.CurrentStepID);\n }, [flow?.FlowID, flow?.CurrentStepID, blocked]);\n\n if (!flow || blocked) return null;\n\n const act = async (\n fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,\n ) => {\n setBusy(true);\n const res = await fn();\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error ?? \"\"), \"error\");\n await load();\n };\n\n const text = step\n ? stepText(step, (k) => catalog.localize(k))\n : { title: t(\"done\"), body: \"\" };\n const place = bubblePlacement(\n step ? rect : null,\n { width: window.innerWidth, height: window.innerHeight },\n BUBBLE,\n );\n const pad = 6;\n\n return (\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 45,\n pointerEvents: \"none\",\n }}\n data-idos-tutorial\n >\n {step && rect ? (\n <div\n className=\"idos-pulse\"\n style={{\n position: \"fixed\",\n left: rect.left - pad,\n top: rect.top - pad,\n width: rect.width + pad * 2,\n height: rect.height + pad * 2,\n borderRadius: 14,\n border: `3px solid ${v.gold}`,\n boxShadow: \"0 0 0 9999px rgba(0,0,0,.55)\",\n transition: \"all .25s ease\",\n }}\n />\n ) : (\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n background: \"rgba(0,0,0,.55)\",\n }}\n />\n )}\n <div\n style={{\n ...panel,\n position: \"fixed\",\n left: place.left,\n top: place.top,\n width: Math.min(BUBBLE.width, window.innerWidth - 24),\n padding: 14,\n display: \"grid\",\n gap: 8,\n pointerEvents: \"auto\",\n animation: `idos-pop-in ${v.slow} ease both`,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Icon glyph={step ? \"bulb\" : \"trophy\"} size={26} />\n <div style={{ flex: 1, ...outlined, fontSize: 16 }}>\n {text.title}\n </div>\n {step ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {stepCounter(flow)}\n </span>\n ) : null}\n </div>\n {text.body ? (\n <div style={{ fontSize: 14, fontWeight: 600 }}>{text.body}</div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"flex-end\",\n alignItems: \"center\",\n }}\n >\n {step && flow.CanSkip ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy}\n onClick={() =>\n void act(() => client.tutorial.skipFlow(flow.FlowID))\n }\n >\n {t(\"skip\")}\n </Button>\n ) : null}\n {step ? (\n canTapNext(step) ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() =>\n void act(() =>\n client.tutorial.completeStep(\n flow.FlowID,\n flow.CurrentStepID!,\n ),\n )\n }\n data-tutorial-anchor=\"tutorial:next\"\n >\n {t(\"next\")}\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"waiting\")}\n </span>\n )\n ) : (\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() =>\n void act(async () => {\n const res = await client.tutorial.claimFlowReward(\n flow.FlowID,\n );\n // The payout sits in `Granted` (an operation), not where the generic reader looks.\n if (res.ok) celebrate(operationLines(res.data.Granted));\n return res;\n })\n }\n >\n {t(\"claim\")}\n </Button>\n )}\n </div>\n </div>\n </div>\n );\n };\n}\n\ntype FlowDef = {\n Identity?: { DisplayName?: string | null; SortOrder?: number | null } | null;\n Policy?: { RestartPolicy?: string | null } | null;\n};\n\n/** \"Tutorials\": every flow of the title with its status; replay where the title allows it. */\nexport function makeTutorialsScreen(store: TutorialStore) {\n return function TutorialsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const stagger = useStagger();\n const [defs, setDefs] = useState<Record<string, FlowDef | null> | null>(\n null,\n );\n const [flows, setFlows] = useState<FlowView[]>([]);\n const [busy, setBusy] = useState<string | null>(null);\n const load = useCallback(async () => {\n // Listing must not start anything — autoStart off.\n const [d, s] = await Promise.all([\n client.tutorial.getTutorialDefinitions(),\n client.tutorial.getUserTutorialState(false),\n ]);\n setDefs(\n d.ok ? ((d.data.Flows ?? {}) as Record<string, FlowDef | null>) : {},\n );\n setFlows(s.ok ? ((s.data.Flows ?? []) as FlowView[]) : []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const ids = Object.keys(defs).sort(\n (a, b) =>\n Number(defs[a]?.Identity?.SortOrder ?? 0) -\n Number(defs[b]?.Identity?.SortOrder ?? 0),\n );\n if (ids.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noTutorials\")} />;\n const replay = async (id: string) => {\n setBusy(id);\n const reset = await client.tutorial.resetFlow(id);\n const started = reset.ok ? await client.tutorial.startFlow(id) : reset;\n setBusy(null);\n if (!started.ok) return toast(errorText(started.error), \"error\");\n const s = await client.tutorial.getUserTutorialState(false);\n if (s.ok) store.set((s.data.Flows ?? []) as FlowView[]);\n await load();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {ids.map((id, i) => {\n const view = flows.find((f) => f.FlowID === id);\n const status = view?.Status ?? \"NotStarted\";\n const label =\n status === \"Completed\"\n ? t(\"completed\")\n : status === \"InProgress\"\n ? t(\"inProgress\")\n : status === \"Skipped\"\n ? t(\"skipped\")\n : t(\"notStarted\");\n const canReplay =\n defs[id]?.Policy?.RestartPolicy === \"OnRequest\" &&\n (status === \"Completed\" || status === \"Skipped\");\n return (\n <div\n key={id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"bulb\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(defs[id]?.Identity?.DisplayName ?? id)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </div>\n </div>\n {canReplay ? (\n <Button\n size=\"sm\"\n tone=\"blue\"\n busy={busy === id}\n onClick={() => void replay(id)}\n >\n {t(\"replay\")}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n };\n}\n"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "module.meta.json",
|
|
68
|
+
"content": "{\n \"id\": \"tutorial-ui\",\n \"type\": \"feature\",\n \"summary\": \"Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.\",\n \"description\": \"A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\\\"{AnchorID}\\\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, the play button play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the showcase lobby with a gem reward.\",\n \"provides\": [\n \"tutorial overlay with a highlighted element\",\n \"hint bubble with next and skip\",\n \"any element as an anchor via data-tutorial-anchor\",\n \"tutorial completion reward\",\n \"tutorials list with replay\",\n \"demo welcome tour\"\n ],\n \"tags\": [\"tutorial\", \"onboarding\", \"ftue\", \"coach-marks\", \"hints\", \"ui\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|