@idosgames/mcp 0.1.37 → 0.1.38
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/README.md +6 -0
- package/dist/cli.js +2204 -27
- package/package.json +4 -1
- package/registry/host.json +3 -3
- package/registry/index.json +310 -156
- package/registry/modules/ai.json +7 -7
- package/registry/modules/board-game.json +14 -14
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +32 -15
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +7 -7
- package/registry/modules/coop-events.json +7 -7
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +7 -7
- package/registry/modules/events.json +8 -8
- package/registry/modules/friends.json +9 -9
- package/registry/modules/hero-run.json +498 -0
- package/registry/modules/idle-rpg.json +69 -17
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +7 -7
- package/registry/modules/leaderboard.json +10 -10
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +8 -8
- package/registry/modules/marketplace.json +8 -8
- package/registry/modules/mini-game.json +8 -8
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +7 -7
- package/registry/modules/profile.json +7 -7
- package/registry/modules/pvp.json +7 -7
- package/registry/modules/quests.json +8 -8
- package/registry/modules/rewards.json +8 -8
- package/registry/modules/season.json +7 -7
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/video-studio.json +228 -0
- package/registry/modules/voxelcraft.json +42 -125
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/hero-run.json +6 -0
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/video-studio.json +6 -0
- package/registry/video.json +9800 -0
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"name": "iDos Games",
|
|
34
34
|
"url": "https://idosgames.com"
|
|
35
35
|
},
|
|
36
|
-
"version": "0.1.
|
|
36
|
+
"version": "0.1.3"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.
|
|
41
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.23.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.0",
|
|
41
|
+
"@idosgames/react": "0.6.0",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "158824605f43164105e68883333c347fd5110a14b1ccb43c68036db930bbfe5f",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "components/controls.tsx",
|
|
@@ -53,11 +53,11 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "components/MarketTab.tsx",
|
|
56
|
-
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type MarketplaceGroupedOfferView } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Card,\n Center,\n EmptyState,\n formatAmount,\n ItemTile,\n outlined,\n plural,\n Spinner,\n useCatalog,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport { groupCounts } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function MarketTab({\n onPick,\n}: {\n onPick: (itemID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [groups, setGroups] = useState<MarketplaceGroupedOfferView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getGroupedOffers()\n .then((res) => setGroups(res.ok ? (res.data.Groups ?? []) : []));\n }, [client]);\n if (!groups)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const items = groups.filter((g) => g.GoodsItemID);\n if (items.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <
|
|
56
|
+
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type MarketplaceGroupedOfferView } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n Card,\n Center,\n EmptyState,\n formatAmount,\n ItemTile,\n outlined,\n plural,\n Spinner,\n useCatalog,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport { groupCounts } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function MarketTab({\n onPick,\n}: {\n onPick: (itemID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [groups, setGroups] = useState<MarketplaceGroupedOfferView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getGroupedOffers()\n .then((res) => setGroups(res.ok ? (res.data.Groups ?? []) : []));\n }, [client]);\n if (!groups)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const items = groups.filter((g) => g.GoodsItemID);\n if (items.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <Columns gap={8} min={320}>\n {items.map((g, i) => (\n <Card\n key={g.GoodsItemID!}\n onClick={() => onPick(g.GoodsItemID!)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(i),\n }}\n >\n <ItemTile itemID={g.GoodsItemID!} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(g.GoodsItemID!)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {countsLine(groupCounts(g))}\n </div>\n </div>\n </Card>\n ))}\n </Columns>\n );\n}\n\nfunction countsLine({\n listings,\n auctions,\n}: {\n listings: number;\n auctions: number;\n}): string {\n return [\n listings > 0\n ? `${formatAmount(listings)} ${plural(listings, t(\"lots\"))}`\n : \"\",\n auctions > 0\n ? `${formatAmount(auctions)} ${plural(auctions, t(\"auctions\"))}`\n : \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n}\n"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "components/MyTabs.tsx",
|
|
60
|
-
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport {\n type MarketplaceHistoryEntryView,\n type MarketplaceMyStateResponse,\n type MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n bundleLines,\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n grantedBy,\n ItemTile,\n outlined,\n ResourceList,\n Spinner,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\nimport { OfferRow } from \"./OfferRow\";\n\nexport function Trades({\n mine,\n onDone,\n}: {\n mine: MarketplaceMyStateResponse | null;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const celebrate = useCelebrate();\n const incoming = mine?.IncomingTrades ?? [];\n const outgoing = mine?.OutgoingTrades ?? [];\n const answer = async (offer: MarketplaceOfferView, accept: boolean) => {\n const res = await client.marketplace.respondDirectTrade(\n offer.OfferID!,\n accept,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (accept && offer.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: offer.GoodsItemID,\n amount: Number(offer.GoodsAmount ?? 1),\n },\n ]);\n onDone();\n };\n if (incoming.length + outgoing.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"empty\")} />;\n return (\n <
|
|
60
|
+
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport {\n type MarketplaceHistoryEntryView,\n type MarketplaceMyStateResponse,\n type MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n bundleLines,\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n grantedBy,\n ItemTile,\n outlined,\n ResourceList,\n Spinner,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\nimport { OfferRow } from \"./OfferRow\";\n\nexport function Trades({\n mine,\n onDone,\n}: {\n mine: MarketplaceMyStateResponse | null;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const celebrate = useCelebrate();\n const incoming = mine?.IncomingTrades ?? [];\n const outgoing = mine?.OutgoingTrades ?? [];\n const answer = async (offer: MarketplaceOfferView, accept: boolean) => {\n const res = await client.marketplace.respondDirectTrade(\n offer.OfferID!,\n accept,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (accept && offer.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: offer.GoodsItemID,\n amount: Number(offer.GoodsAmount ?? 1),\n },\n ]);\n onDone();\n };\n if (incoming.length + outgoing.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"empty\")} />;\n return (\n <Columns gap={8} min={320}>\n {incoming.map((o) => (\n <Card key={o.OfferID!} highlight style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n {o.GoodsItemID ? (\n <ItemTile itemID={o.GoodsItemID} size={52} />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.GoodsItemID ? catalog.itemName(o.GoodsItemID) : \"\"}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}{\" \"}\n {o.CreatorPublicData?.Username ??\n String(o.CreatorUserID ?? \"\").slice(-6)}{\" \"}\n ·{\" \"}\n {bundleLines(o.Price as never).length ? t(\"wants\") : t(\"gift\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(o.Price as never)} />\n </div>\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={() => void answer(o, false)}>\n {t(\"decline\")}\n </Button>\n <Button tone=\"green\" size=\"sm\" onClick={() => void answer(o, true)}>\n {t(\"accept\")}\n </Button>\n </div>\n </Card>\n ))}\n {outgoing.map((o, i) => (\n <OfferRow\n key={o.OfferID!}\n offer={o}\n index={i}\n onPick={() =>\n void client.marketplace\n .cancelDirectTrade(o.OfferID!)\n .then(() => onDone())\n }\n />\n ))}\n </Columns>\n );\n}\n\nexport function Claims({\n offers,\n onDone,\n}: {\n offers: MarketplaceOfferView[];\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const claim = async (offer: MarketplaceOfferView | null) => {\n setBusy(offer?.OfferID ?? \"all\");\n const res = offer\n ? offer.OfferType === \"Auction\"\n ? await client.marketplace.claimAuction(offer.OfferID!)\n : await client.marketplace.claimBack(offer.OfferID!)\n : await client.marketplace.claimBack();\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n onDone();\n };\n if (offers.length === 0) return <EmptyState glyph=\"gift\" text={t(\"empty\")} />;\n return (\n <Columns gap={8} min={320}>\n {offers.length > 1 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"all\"}\n onClick={() => void claim(null)}\n // across the columns, its own size in the middle\n style={{ gridColumn: \"1 / -1\", justifySelf: \"center\", minWidth: 220 }}\n >\n {t(\"claimAll\")}\n </Button>\n ) : null}\n {offers.map((o, i) => (\n <div\n key={o.OfferID!}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 8,\n alignItems: \"center\",\n }}\n >\n <OfferRow offer={o} index={i} onPick={() => void claim(o)} />\n <Button\n tone=\"gold\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void claim(o)}\n >\n {t(\"claim\")}\n </Button>\n </div>\n ))}\n </Columns>\n );\n}\n\nexport function History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const me = client.auth.context?.userID;\n const [entries, setEntries] = useState<MarketplaceHistoryEntryView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getHistory()\n .then((res) => setEntries(res.ok ? (res.data.Entries ?? []) : []));\n }, [client]);\n if (!entries)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (entries.length === 0)\n return <EmptyState glyph=\"clock\" text={t(\"empty\")} />;\n return (\n <Columns gap={8} min={320}>\n {entries.map((e, i) => (\n <Card\n key={e.OfferID ?? i}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n ...stagger(i),\n }}\n >\n {e.GoodsItemID ? (\n <ItemTile\n itemID={e.GoodsItemID}\n count={Number(e.GoodsAmount ?? 1)}\n size={48}\n />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {e.GoodsItemID ? catalog.itemName(e.GoodsItemID) : \"\"}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: e.SellerUserID === me ? v.green : v.gold,\n }}\n >\n {e.SellerUserID === me ? t(\"sold\") : t(\"bought\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(e.PricePaid as never)} size={16} />\n </Card>\n ))}\n </Columns>\n );\n}\n\n// ── Selling ──────────────────────────────────────────────────────────────────────────────────\n"
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "components/OfferPopup.tsx",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
},
|
|
94
94
|
{
|
|
95
95
|
"path": "module.meta.json",
|
|
96
|
-
"content": "{\n \"id\": \"marketplace\",\n \"type\": \"feature\",\n \"summary\": \"Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.\",\n \"description\": \"A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.\",\n \"provides\": [\n \"player marketplace screen\",\n \"listings and auctions\",\n \"sell popup with price and duration\",\n \"bids with the minimum next bid\",\n \"direct trades\",\n \"claims and history\",\n \"marketplace badge\",\n \"demo marketplace rules\"\n ],\n \"tags\": [\n \"marketplace\",\n \"trading\",\n \"auction\",\n \"listing\",\n \"p2p\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
96
|
+
"content": "{\n \"id\": \"marketplace\",\n \"type\": \"feature\",\n \"summary\": \"Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.\",\n \"description\": \"A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.\",\n \"provides\": [\n \"player marketplace screen\",\n \"listings and auctions\",\n \"sell popup with price and duration\",\n \"bids with the minimum next bid\",\n \"direct trades\",\n \"claims and history\",\n \"marketplace badge\",\n \"demo marketplace rules\"\n ],\n \"tags\": [\n \"marketplace\",\n \"trading\",\n \"auction\",\n \"listing\",\n \"p2p\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
|
|
97
97
|
}
|
|
98
98
|
]
|
|
99
99
|
}
|
|
@@ -27,15 +27,15 @@
|
|
|
27
27
|
"name": "iDos Games",
|
|
28
28
|
"url": "https://idosgames.com"
|
|
29
29
|
},
|
|
30
|
-
"version": "0.1.
|
|
30
|
+
"version": "0.1.3"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@idosgames/core": "0.
|
|
34
|
-
"@idosgames/module-sdk": "0.
|
|
35
|
-
"@idosgames/react": "0.
|
|
33
|
+
"@idosgames/core": "0.23.0",
|
|
34
|
+
"@idosgames/module-sdk": "0.13.0",
|
|
35
|
+
"@idosgames/react": "0.6.0",
|
|
36
36
|
"react": "19.2.7"
|
|
37
37
|
},
|
|
38
|
-
"contentHash": "
|
|
38
|
+
"contentHash": "23c1996b36806b24d7649583b3bb0bbe07fb5ac0e8c9ae9c5267735ecec7820c",
|
|
39
39
|
"files": [
|
|
40
40
|
{
|
|
41
41
|
"path": "i18n.ts",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
"path": "MiniGame.tsx",
|
|
50
|
-
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useSound,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// \"Tap the stars\": a 15-second round that produces a score for the leaderboard. Deliberately tiny —\n// the placeholder a project replaces with its real game, keeping the same contract: play, then hand\n// the score to onFinish (the play screen submits it and reports the round to quests).\n\nconst ROUND_MS = 15_000;\nconst SPAWN_MS = 520;\nconst LIFE_MS = 1_150;\n\ninterface Star {\n id: number;\n x: number;\n y: number;\n golden: boolean;\n bornAt: number;\n}\n\nexport function MiniGame({\n onFinish,\n onCancel,\n height = 380,\n}: {\n onFinish: (score: number) => void;\n onCancel: () => void;\n height?: number;\n}): ReactNode {\n const play = useSound();\n const [stars, setStars] = useState<Star[]>([]);\n const [score, setScore] = useState(0);\n const [leftMs, setLeftMs] = useState(ROUND_MS);\n const [pops, setPops] = useState<\n Array<{ id: number; x: number; y: number; value: number }>\n >([]);\n const scoreRef = useRef(0);\n const nextId = useRef(1);\n const finished = useRef(false);\n // Latest callback without restarting the round when the parent re-renders.\n const finish = useRef(onFinish);\n finish.current = onFinish;\n\n useEffect(() => {\n const started = performance.now();\n const spawn = setInterval(() => {\n const now = performance.now();\n setStars((list) => [\n ...list.filter((s) => now - s.bornAt < LIFE_MS),\n {\n id: nextId.current++,\n x: 8 + Math.random() * 78,\n y: 10 + Math.random() * 72,\n golden: Math.random() < 0.15,\n bornAt: now,\n },\n ]);\n }, SPAWN_MS);\n const clock = setInterval(() => {\n const left = Math.max(0, ROUND_MS - (performance.now() - started));\n setLeftMs(left);\n if (left === 0 && !finished.current) {\n finished.current = true;\n clearInterval(spawn);\n clearInterval(clock);\n finish.current(scoreRef.current);\n }\n }, 100);\n return () => {\n clearInterval(spawn);\n clearInterval(clock);\n };\n }, []);\n\n const hit = (star: Star) => {\n const value = star.golden ? 3 : 1;\n play(star.golden ? \"coin\" : \"countTick\");\n scoreRef.current += value;\n setScore(scoreRef.current);\n setStars((list) => list.filter((s) => s.id !== star.id));\n const popID = nextId.current++;\n setPops((list) => [...list, { id: popID, x: star.x, y: star.y, value }]);\n setTimeout(\n () => setPops((list) => list.filter((p) => p.id !== popID)),\n 600,\n );\n };\n\n return (\n <div\n style={{\n ...panel,\n position: \"relative\",\n height: `min(${height}px, 70vh)`,\n minHeight: 300,\n overflow: \"hidden\",\n background
|
|
50
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useSound,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// \"Tap the stars\": a 15-second round that produces a score for the leaderboard. Deliberately tiny —\n// the placeholder a project replaces with its real game, keeping the same contract: play, then hand\n// the score to onFinish (the play screen submits it and reports the round to quests).\n\nconst ROUND_MS = 15_000;\nconst SPAWN_MS = 520;\nconst LIFE_MS = 1_150;\n\ninterface Star {\n id: number;\n x: number;\n y: number;\n golden: boolean;\n bornAt: number;\n}\n\nexport function MiniGame({\n onFinish,\n onCancel,\n height = 380,\n}: {\n onFinish: (score: number) => void;\n onCancel: () => void;\n height?: number;\n}): ReactNode {\n const play = useSound();\n const [stars, setStars] = useState<Star[]>([]);\n const [score, setScore] = useState(0);\n const [leftMs, setLeftMs] = useState(ROUND_MS);\n const [pops, setPops] = useState<\n Array<{ id: number; x: number; y: number; value: number }>\n >([]);\n const scoreRef = useRef(0);\n const nextId = useRef(1);\n const finished = useRef(false);\n // Latest callback without restarting the round when the parent re-renders.\n const finish = useRef(onFinish);\n finish.current = onFinish;\n\n useEffect(() => {\n const started = performance.now();\n const spawn = setInterval(() => {\n const now = performance.now();\n setStars((list) => [\n ...list.filter((s) => now - s.bornAt < LIFE_MS),\n {\n id: nextId.current++,\n x: 8 + Math.random() * 78,\n y: 10 + Math.random() * 72,\n golden: Math.random() < 0.15,\n bornAt: now,\n },\n ]);\n }, SPAWN_MS);\n const clock = setInterval(() => {\n const left = Math.max(0, ROUND_MS - (performance.now() - started));\n setLeftMs(left);\n if (left === 0 && !finished.current) {\n finished.current = true;\n clearInterval(spawn);\n clearInterval(clock);\n finish.current(scoreRef.current);\n }\n }, 100);\n return () => {\n clearInterval(spawn);\n clearInterval(clock);\n };\n }, []);\n\n const hit = (star: Star) => {\n const value = star.golden ? 3 : 1;\n play(star.golden ? \"coin\" : \"countTick\");\n scoreRef.current += value;\n setScore(scoreRef.current);\n setStars((list) => list.filter((s) => s.id !== star.id));\n const popID = nextId.current++;\n setPops((list) => [...list, { id: popID, x: star.x, y: star.y, value }]);\n setTimeout(\n () => setPops((list) => list.filter((p) => p.id !== popID)),\n 600,\n );\n };\n\n return (\n <div\n style={{\n ...panel,\n position: \"relative\",\n height: `min(${height}px, 70vh)`,\n minHeight: 300,\n overflow: \"hidden\",\n // The playfield: the theme's screen colours, lighter in the middle.\n background: `radial-gradient(circle at 50% 30%, color-mix(in srgb, ${v.bgTop} 75%, #fff) 0%, ${v.bgTop} 45%, ${v.bgBottom} 100%)`,\n touchAction: \"manipulation\",\n userSelect: \"none\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 10,\n left: 12,\n right: 12,\n display: \"flex\",\n justifyContent: \"space-between\",\n zIndex: 2,\n }}\n >\n <span style={{ ...outlined, fontSize: 18 }}>\n {t(\"score\")}: {score}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 18,\n color: leftMs < 4000 ? v.red : v.text,\n }}\n >\n {t(\"timeLeft\")}: {Math.ceil(leftMs / 1000)}\n </span>\n </div>\n <div\n style={{\n position: \"absolute\",\n top: 40,\n left: 0,\n right: 0,\n textAlign: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.textDim,\n opacity: score === 0 ? 1 : 0,\n transition: \"opacity .4s\",\n }}\n >\n {t(\"tapStars\")}\n </div>\n {stars.map((s) => (\n <button\n key={s.id}\n type=\"button\"\n aria-label=\"star\"\n onPointerDown={() => hit(s)}\n style={{\n position: \"absolute\",\n left: `${s.x}%`,\n top: `${s.y}%`,\n width: 56,\n height: 56,\n marginLeft: -28,\n marginTop: -28,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n animation: `idos-star ${v.normal} ease-out`,\n }}\n >\n <Icon\n glyph=\"star\"\n size={s.golden ? 56 : 44}\n color={s.golden ? \"#ffe066\" : \"#fff27a\"}\n />\n </button>\n ))}\n {pops.map((p) => (\n <span\n key={p.id}\n style={{\n position: \"absolute\",\n left: `${p.x}%`,\n top: `${p.y}%`,\n ...outlined,\n fontSize: 22,\n color: v.gold,\n pointerEvents: \"none\",\n animation: \"idos-rise-in .6s ease reverse\",\n }}\n >\n +{p.value}\n </span>\n ))}\n <div\n style={{\n position: \"absolute\",\n bottom: 10,\n left: 0,\n right: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={onCancel}>\n ✕\n </Button>\n </div>\n </div>\n );\n}\n"
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"path": "MiniGamePanel.tsx",
|
|
54
|
-
"content": "import {\n useCallback,\n useState,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n screenBackground,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mini-game's mode — the whole screen while it is on (the lobby's \"Play\" starts it, the base's\n// round button top-left returns to the lobby). A round submits its score to the title's leaderboard,\n// reports itself to quests that count rounds, and feeds the events and seasons that pay for a round;\n// the table itself is the leaderboard module's screen (\"Leaderboard\" opens it through ctx.features).\n\nexport function makeMiniGamePanel(features: FeatureRegistry): ComponentType {\n return function MiniGamePanel(): ReactNode {\n return (\n <div\n className=\"idos-scroll\"\n style={{\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n // Room for the base's \"To the lobby\" button in the top-left corner.\n padding: \"64px 14px 24px\",\n pointerEvents: \"auto\",\n }}\n >\n <div style={{ maxWidth:
|
|
54
|
+
"content": "import {\n useCallback,\n useState,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n screenBackground,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mini-game's mode — the whole screen while it is on (the lobby's \"Play\" starts it, the base's\n// round button top-left returns to the lobby). A round submits its score to the title's leaderboard,\n// reports itself to quests that count rounds, and feeds the events and seasons that pay for a round;\n// the table itself is the leaderboard module's screen (\"Leaderboard\" opens it through ctx.features).\n\nexport function makeMiniGamePanel(features: FeatureRegistry): ComponentType {\n return function MiniGamePanel(): ReactNode {\n return (\n <div\n className=\"idos-scroll idos-screen\"\n style={{\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n // Room for the base's \"To the lobby\" button in the top-left corner.\n padding: \"64px 14px 24px\",\n pointerEvents: \"auto\",\n }}\n >\n {/* the field takes a PC's width too (the stars sit at percentages of it) */}\n <div style={{ maxWidth: 1120, margin: \"0 auto\" }}>\n <Round features={features} />\n </div>\n </div>\n );\n };\n}\n\nfunction Round({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [playing, setPlaying] = useState(false);\n const [result, setResult] = useState<number | null>(null);\n const [bonus, setBonus] = useState<string[]>([]);\n const catalog = useCatalog();\n const board = pickLeaderboard(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n\n const finish = useCallback(\n async (score: number) => {\n setPlaying(false);\n setResult(score);\n kit.play(score > 0 ? \"levelUp\" : \"notify\");\n if (board && score > 0) {\n const res = await client.leaderboard.submitScore(\n board.LeaderboardID,\n score,\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n }\n // One round played — only if a quest counts rounds (otherwise the server refuses the metric).\n if (questsCountGames(configSection<QuestSection>(client, \"Quest\")))\n await client.quest.addQuestProgress(GAMES_PLAYED_METRIC, 1);\n if (score > 0) setBonus(await rewardRound(client, catalog.localize));\n },\n [client, board, toast, kit, catalog],\n );\n\n if (playing)\n return (\n <MiniGame\n height={layout === \"desktop\" ? 560 : layout === \"tablet\" ? 480 : 380}\n onFinish={(s) => void finish(s)}\n onCancel={() => setPlaying(false)}\n />\n );\n\n return (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 20,\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 30%, ${v.panel}) 0%, ${v.panelDeep} 100%)`,\n maxWidth: 520,\n margin: \"0 auto\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph=\"star\" size={72} />\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n {t(\"best\")}\n </div>\n {result !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 18,\n color: v.gold,\n animation: `idos-pop-in ${v.slow} ease-out both`,\n }}\n >\n {t(\"yourScore\")}: {formatAmount(result)}\n {result > 0 && board ? (\n <div\n style={{ fontSize: 13, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"submitted\")}\n </div>\n ) : null}\n {bonus.map((line) => (\n <div\n key={line}\n style={{ fontSize: 14, color: v.green, textAlign: \"center\" }}\n >\n {line}\n </div>\n ))}\n </div>\n ) : null}\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => {\n setResult(null);\n setBonus([]);\n setPlaying(true);\n }}\n data-tutorial-anchor=\"play:start\"\n >\n ▶ {result === null ? t(\"play\") : t(\"playAgain\")}\n </Button>\n {board && features.get(\"leaderboard\")?.available ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n features.open(\"leaderboard\", {\n leaderboardID: board.LeaderboardID,\n })\n }\n >\n <Icon glyph=\"trophy\" size={18} /> {t(\"toTable\")}\n </Button>\n ) : null}\n </div>\n );\n}\n\n/**\n * A round's share of the title's live-ops: tokens of every active event that pays for a round (a\n * client-safe CustomAction, capped per day by the event) and status in every season chain open to\n * clients. Returns what was granted, for the result card. Best-effort: a refusal (a cap reached,\n * no active season) just grants nothing.\n */\nasync function rewardRound(\n client: IDosGamesClient,\n localize: (text: string | null | undefined) => string,\n): Promise<string[]> {\n const lines: string[] = [];\n const eventIDs = eventsCountingRounds(\n configSection<TimedEventSection>(client, \"TimedEvent\"),\n );\n if (eventIDs.length > 0) {\n const active = await client.timedEvent.getActiveEvents();\n for (const e of active.ok ? (active.data.ActiveEvents ?? []) : []) {\n if (\n !e.TimedEventID ||\n !eventIDs.includes(e.TimedEventID) ||\n e.CanEarn === false ||\n !e.Type\n )\n continue;\n const res = await client.timedEvent.grantTokens(\n e.Type,\n e.TimedEventID,\n \"CustomAction\",\n undefined,\n undefined,\n { ActionName: PLAY_ROUND_ACTION },\n );\n const op = res.ok\n ? (res.data.Grant?.Standard?.EventTokens?.[0] as\n { Applied?: number; Amount?: number } | undefined)\n : undefined;\n const amount = Number(op?.Applied ?? op?.Amount ?? 0);\n if (amount > 0)\n lines.push(\n `+${formatAmount(amount)} ${localize(e.Content?.Token?.DisplayName ?? e.Content?.DisplayName ?? e.TimedEventID)}`,\n );\n }\n }\n for (const chain of seasonChainsForRounds(\n configSection<SeasonSection>(client, \"Season\"),\n )) {\n const res = await client.season.grantStatusTokens(chain, ROUND_STATUS);\n if (res.ok && Number(res.data.AmountGranted ?? 0) > 0)\n lines.push(\n `+${formatAmount(Number(res.data.AmountGranted))} ${t(\"seasonStatus\")}`,\n );\n }\n return lines;\n}\n"
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"path": "model.ts",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"path": "module.meta.json",
|
|
66
|
-
"content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
66
|
+
"content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
|
|
67
67
|
}
|
|
68
68
|
]
|
|
69
69
|
}
|
|
@@ -36,15 +36,15 @@
|
|
|
36
36
|
"name": "iDos Games",
|
|
37
37
|
"url": "https://idosgames.com"
|
|
38
38
|
},
|
|
39
|
-
"version": "0.2.
|
|
39
|
+
"version": "0.2.1"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@idosgames/core": "0.
|
|
43
|
-
"@idosgames/module-sdk": "0.
|
|
44
|
-
"@idosgames/react": "0.
|
|
42
|
+
"@idosgames/core": "0.23.0",
|
|
43
|
+
"@idosgames/module-sdk": "0.13.0",
|
|
44
|
+
"@idosgames/react": "0.6.0",
|
|
45
45
|
"react": "19.2.7"
|
|
46
46
|
},
|
|
47
|
-
"contentHash": "
|
|
47
|
+
"contentHash": "03c011db581a59f3e98ea8696f113e02021db798e9a871be151d3e65726db2f8",
|
|
48
48
|
"files": [
|
|
49
49
|
{
|
|
50
50
|
"path": "components/CodeForm.tsx",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "components/RoomsList.tsx",
|
|
63
|
-
"content": "import {\n useCallback,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\nimport { type RoomListItem } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n Icon,\n outlined,\n Popup,\n SectionTitle,\n Spinner,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { visibleRooms } from \"../model\";\nimport { t } from \"../i18n\";\nimport { CodeForm } from \"./CodeForm\";\nimport { CreateForm } from \"./CreateForm\";\nimport { gameLabel, useGames } from \"../helpers\";\nimport { type ScreenDeps } from \"../types\";\n\nexport function RoomsList({\n deps,\n mode,\n create,\n}: {\n deps: ScreenDeps;\n mode: string | null;\n create: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const stagger = useStagger();\n const games = useGames(deps.games);\n const [rooms, setRooms] = useState<RoomListItem[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [popup, setPopup] = useState<\"create\" | \"code\" | null>(\n create ? \"create\" : null,\n );\n const modesList = useMemo(() => games.map((g) => g.mode), [games]);\n\n const load = useCallback(async () => {\n const res = await client.multiplayer.listRooms(0, 50);\n setRooms(res.ok ? (res.data.Rooms ?? []) : []);\n }, [client]);\n useEffect(() => {\n void load();\n const id = setInterval(() => void load(), 5_000);\n return () => clearInterval(id);\n }, [load]);\n\n const join = async (roomID: string, code?: string) => {\n setBusy(roomID);\n const error = await deps.rooms.join(roomID, code);\n setBusy(null);\n if (error)\n toast(\n error === \"inProgress\" ? t(\"inProgress\") : errorText(error),\n \"error\",\n );\n };\n\n const shown = visibleRooms(rooms ?? [], modesList, mode);\n const title = mode\n ? `${t(\"roomsOf\")} ${gameLabel(deps.games.getGame(mode), deps.modes, mode)}`\n : t(\"open\");\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n attract\n onClick={() => setPopup(\"create\")}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"multiplayer:create\"\n >\n <Icon glyph=\"plus\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setPopup(\"code\")}>\n {t(\"byCode\")}\n </Button>\n </div>\n <SectionTitle>{title}</SectionTitle>\n {!rooms ? (\n <Center>\n <Spinner />\n </Center>\n ) : shown.length === 0 ? (\n <EmptyState glyph=\"globe\" text={t(\"noRooms\")} />\n ) : (\n shown.map((r, i) => (\n
|
|
63
|
+
"content": "import {\n useCallback,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\nimport { type RoomListItem } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n Icon,\n outlined,\n Popup,\n SectionTitle,\n Spinner,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { visibleRooms } from \"../model\";\nimport { t } from \"../i18n\";\nimport { CodeForm } from \"./CodeForm\";\nimport { CreateForm } from \"./CreateForm\";\nimport { gameLabel, useGames } from \"../helpers\";\nimport { type ScreenDeps } from \"../types\";\n\nexport function RoomsList({\n deps,\n mode,\n create,\n}: {\n deps: ScreenDeps;\n mode: string | null;\n create: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const stagger = useStagger();\n const games = useGames(deps.games);\n const [rooms, setRooms] = useState<RoomListItem[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [popup, setPopup] = useState<\"create\" | \"code\" | null>(\n create ? \"create\" : null,\n );\n const modesList = useMemo(() => games.map((g) => g.mode), [games]);\n\n const load = useCallback(async () => {\n const res = await client.multiplayer.listRooms(0, 50);\n setRooms(res.ok ? (res.data.Rooms ?? []) : []);\n }, [client]);\n useEffect(() => {\n void load();\n const id = setInterval(() => void load(), 5_000);\n return () => clearInterval(id);\n }, [load]);\n\n const join = async (roomID: string, code?: string) => {\n setBusy(roomID);\n const error = await deps.rooms.join(roomID, code);\n setBusy(null);\n if (error)\n toast(\n error === \"inProgress\" ? t(\"inProgress\") : errorText(error),\n \"error\",\n );\n };\n\n const shown = visibleRooms(rooms ?? [], modesList, mode);\n const title = mode\n ? `${t(\"roomsOf\")} ${gameLabel(deps.games.getGame(mode), deps.modes, mode)}`\n : t(\"open\");\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n attract\n onClick={() => setPopup(\"create\")}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"multiplayer:create\"\n >\n <Icon glyph=\"plus\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setPopup(\"code\")}>\n {t(\"byCode\")}\n </Button>\n </div>\n <SectionTitle>{title}</SectionTitle>\n {!rooms ? (\n <Center>\n <Spinner />\n </Center>\n ) : shown.length === 0 ? (\n <EmptyState glyph=\"globe\" text={t(\"noRooms\")} />\n ) : (\n // the rooms: one column on a phone, side by side on a tablet or a PC\n <Columns gap={8} min={320}>\n {shown.map((r, i) => (\n <Card\n key={r.RoomID}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"globe\" size={34} />\n <div style={{ minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {r.Name || t(\"room\")}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {gameLabel(\n deps.games.getGame(r.Mode ?? \"\"),\n deps.modes,\n r.Mode,\n )}{\" \"}\n · {t(\"players\")}: {r.MemberCount ?? 0} / {r.MaxPlayers ?? 8}\n </div>\n </div>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === r.RoomID}\n disabled={(r.MemberCount ?? 0) >= (r.MaxPlayers ?? 8)}\n onClick={() => void join(r.RoomID ?? \"\")}\n >\n {t(\"join\")}\n </Button>\n </Card>\n ))}\n </Columns>\n )}\n {popup === \"create\" ? (\n <Popup title={t(\"create\")} onClose={() => setPopup(null)} width={420}>\n <CreateForm deps={deps} games={games} preset={mode} />\n </Popup>\n ) : popup === \"code\" ? (\n <Popup title={t(\"byCode\")} onClose={() => setPopup(null)} width={400}>\n <CodeForm onJoin={(id, code) => void join(id, code)} />\n </Popup>\n ) : null}\n </div>\n );\n}\n"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"path": "components/Voice.tsx",
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
"path": "module.meta.json",
|
|
107
|
-
"content": "{\n \"id\": \"multiplayer\",\n \"type\": \"feature\",\n \"summary\": \"The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready, chat and voice chat — the host takes the whole room into a game.\",\n \"description\": \"A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), voice chat (mic off / push-to-talk on V / open mic, every member's mute and volume, who is speaking, a mic button over the running game, the \\\"Conversations may be recorded\\\" notice; the game reads room.voice for talking markers or proximity volume), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.\",\n \"provides\": [\n \"multiplayer rooms lobby\",\n \"rooms of one game\",\n \"public and private rooms by invite\",\n \"room settings of the game\",\n \"room members with ready and host\",\n \"room chat\",\n \"host starts the game for the whole room\",\n \"late joiners into a running game\",\n \"voice chat in rooms\",\n \"push-to-talk\",\n \"who is speaking\"\n ],\n \"tags\": [\n \"multiplayer\",\n \"rooms\",\n \"lobby\",\n \"realtime\",\n \"social\",\n \"ui\",\n \"screen\",\n \"voice\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.2.
|
|
107
|
+
"content": "{\n \"id\": \"multiplayer\",\n \"type\": \"feature\",\n \"summary\": \"The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready, chat and voice chat — the host takes the whole room into a game.\",\n \"description\": \"A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), voice chat (mic off / push-to-talk on V / open mic, every member's mute and volume, who is speaking, a mic button over the running game, the \\\"Conversations may be recorded\\\" notice; the game reads room.voice for talking markers or proximity volume), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.\",\n \"provides\": [\n \"multiplayer rooms lobby\",\n \"rooms of one game\",\n \"public and private rooms by invite\",\n \"room settings of the game\",\n \"room members with ready and host\",\n \"room chat\",\n \"host starts the game for the whole room\",\n \"late joiners into a running game\",\n \"voice chat in rooms\",\n \"push-to-talk\",\n \"who is speaking\"\n ],\n \"tags\": [\n \"multiplayer\",\n \"rooms\",\n \"lobby\",\n \"realtime\",\n \"social\",\n \"ui\",\n \"screen\",\n \"voice\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.2.1\"\n}\n"
|
|
108
108
|
}
|
|
109
109
|
]
|
|
110
110
|
}
|
|
@@ -29,15 +29,15 @@
|
|
|
29
29
|
"name": "iDos Games",
|
|
30
30
|
"url": "https://idosgames.com"
|
|
31
31
|
},
|
|
32
|
-
"version": "0.1.
|
|
32
|
+
"version": "0.1.3"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.
|
|
37
|
-
"@idosgames/react": "0.
|
|
35
|
+
"@idosgames/core": "0.23.0",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.0",
|
|
37
|
+
"@idosgames/react": "0.6.0",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
|
-
"contentHash": "
|
|
40
|
+
"contentHash": "0f604f01a3e78b950a1dd799e80391a40da4321bcc334d24ba5a77094f22ca6c",
|
|
41
41
|
"files": [
|
|
42
42
|
{
|
|
43
43
|
"path": "i18n.ts",
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "PremiumScreen.tsx",
|
|
60
|
-
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Passes run out by their end date, which the rows check against `now` — the state itself is\n // fresh by its revision, so the central cache asks the server only when it changed.\n useEffect(() => {\n void client.cache.ensureState([\"Premium\"]);\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <
|
|
60
|
+
"content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Passes run out by their end date, which the rows check against `now` — the state itself is\n // fresh by its revision, so the central cache asks the server only when it changed.\n useEffect(() => {\n void client.cache.ensureState([\"Premium\"]);\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <Columns gap={12}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </Columns>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n row.premiumID,\n crypto.randomUUID(),\n optionID,\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"gold\"),\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function watchPremium(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly, not keyed on the state object: the cache may patch it in place, and an\n // effect that does not re-run leaves the badge lit after the trial is taken.\n const update = () =>\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection(client, \"Premium\"),\n client.data.user.state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n // A pass running out brings its trial back into view — no event for that, so look now and then.\n ctx.every(60_000, update);\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "module.meta.json",
|
|
64
|
-
"content": "{\n \"id\": \"premium\",\n \"type\": \"feature\",\n \"summary\": \"VIP for any game: premium passes with benefits, until when the player holds one, a free trial and buying with in-game currency.\",\n \"description\": \"A ready VIP screen (Unity PremiumPopUp) for ANY game, built on the @idosgames/react/ui kit and client.premium. Every premium pass of the title, lowest tier first: its name, benefits and duration, a timer while the player holds it, a one-time free trial, and buying (or extending) it with currencies or items. A pass paid in a store (real money) is the platform's checkout and is not sold here. Events and season send the player here for their VIP prizes (ctx.features.open('premium', { premiumID }) marks that pass). Registers the 'premium' feature; its badge shows a free trial waiting. The demo pass comes with the base's settings.\",\n \"provides\": [\n \"VIP passes screen\",\n \"free VIP trial\",\n \"buy or extend VIP with in-game currency\",\n \"VIP timer\",\n \"badge for a free trial\"\n ],\n \"tags\": [\n \"vip\",\n \"premium\",\n \"subscription\",\n \"monetization\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
64
|
+
"content": "{\n \"id\": \"premium\",\n \"type\": \"feature\",\n \"summary\": \"VIP for any game: premium passes with benefits, until when the player holds one, a free trial and buying with in-game currency.\",\n \"description\": \"A ready VIP screen (Unity PremiumPopUp) for ANY game, built on the @idosgames/react/ui kit and client.premium. Every premium pass of the title, lowest tier first: its name, benefits and duration, a timer while the player holds it, a one-time free trial, and buying (or extending) it with currencies or items. A pass paid in a store (real money) is the platform's checkout and is not sold here. Events and season send the player here for their VIP prizes (ctx.features.open('premium', { premiumID }) marks that pass). Registers the 'premium' feature; its badge shows a free trial waiting. The demo pass comes with the base's settings.\",\n \"provides\": [\n \"VIP passes screen\",\n \"free VIP trial\",\n \"buy or extend VIP with in-game currency\",\n \"VIP timer\",\n \"badge for a free trial\"\n ],\n \"tags\": [\n \"vip\",\n \"premium\",\n \"subscription\",\n \"monetization\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
|
|
65
65
|
}
|
|
66
66
|
]
|
|
67
67
|
}
|
|
@@ -31,15 +31,15 @@
|
|
|
31
31
|
"name": "iDos Games",
|
|
32
32
|
"url": "https://idosgames.com"
|
|
33
33
|
},
|
|
34
|
-
"version": "0.1.
|
|
34
|
+
"version": "0.1.5"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.
|
|
38
|
-
"@idosgames/module-sdk": "0.
|
|
39
|
-
"@idosgames/react": "0.
|
|
37
|
+
"@idosgames/core": "0.23.0",
|
|
38
|
+
"@idosgames/module-sdk": "0.13.0",
|
|
39
|
+
"@idosgames/react": "0.6.0",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
|
-
"contentHash": "
|
|
42
|
+
"contentHash": "2448dc72d4408919336d2f4011a8dd8903a913811c8ef00db2b64c21b77f5b52",
|
|
43
43
|
"files": [
|
|
44
44
|
{
|
|
45
45
|
"path": "i18n.ts",
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"path": "ProfileScreen.tsx",
|
|
62
|
-
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type { PlayAccessWallet } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n ConfirmPopup,\n errorText,\n Icon,\n LanguageSwitch,\n onLanguageChange,\n outlined,\n panel,\n Popup,\n Tabs,\n TextInput,\n usePlayerPrefs,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n languageChoices,\n nameProblem,\n shortAddress,\n unlinkRefusal,\n volumePercent,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Profile and settings (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization):\n// the name and ID, sound, volume and motion (kept on this device), the game's language, tutorials,\n// linked wallets and logging out. Renaming and unlinking a wallet are popups. No \"Delete account\"\n// button (owner's decision 03.10.2026) — `client.user.deleteUserAccount()` stays in the SDK. No analytics switch (owner's decision 29.09.2026): anonymous analytics stays on as the SDK\n// ships it (client.analytics).\n\nexport function makeProfileScreen(features: FeatureRegistry) {\n return function ProfileScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [prefs, setPrefs] = usePlayerPrefs();\n const [name, setName] = useState<string | null>(null);\n const [renaming, setRenaming] = useState(false);\n const [ask, setAsk] = useState<\"logout\" | null>(null);\n const [locale, setLocale] = useState(client.localization.locale ?? \"\");\n const id = client.auth.context?.userID ?? \"\";\n const shown = name ?? state?.PublicData?.Username ?? t(\"guest\");\n const languages = languageChoices(client.localization.locales);\n\n const switchLanguage = async (next: string) => {\n const res = await client.localization.setLocale(next);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setLocale(res.data);\n };\n // сменили язык интерфейса — тексты тайтла (если у тайтла есть такой язык) переходят на него же\n useEffect(\n () =>\n onLanguageChange((l) => {\n if (languages.some((x) => x.id === l)) void switchLanguage(l);\n }),\n [languages.map((l) => l.id).join()],\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n maxWidth: 560,\n margin: \"0 auto\",\n width: \"100%\",\n }}\n >\n <div\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n }}\n >\n <div\n style={{\n width: 84,\n height: 84,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: `3px solid color-mix(in srgb, ${v.text} 70%, transparent)`,\n ...outlined,\n fontSize: 36,\n }}\n >\n {(shown.trim()[0] ?? \"?\").toUpperCase()}\n </div>\n <div style={{ ...outlined, fontSize: 20 }}>{shown}</div>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setRenaming(true)}\n data-tutorial-anchor=\"profile:rename\"\n >\n <Icon glyph=\"edit\" size={16} /> {t(\"rename\")}\n </Button>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n maxWidth: \"100%\",\n }}\n >\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"playerID\")}\n </span>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 12,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {id}\n </span>\n <button\n type=\"button\"\n aria-label={t(\"playerID\")}\n onClick={() =>\n void navigator.clipboard\n ?.writeText(id)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n padding: 2,\n }}\n >\n <Icon glyph=\"copy\" size={16} />\n </button>\n </div>\n </div>\n\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"settings\")}\n </div>\n <Toggle\n glyph={prefs.muted ? \"mute\" : \"sound\"}\n label={t(\"sound\")}\n value={!prefs.muted}\n onChange={(on) => {\n setPrefs({ muted: !on });\n if (on) kit.play(\"click\");\n }}\n />\n <div\n style={{ display: \"grid\", gap: 4, opacity: prefs.muted ? 0.5 : 1 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"volume\")}</span>\n <span>{volumePercent(prefs.volume)}%</span>\n </div>\n <input\n type=\"range\"\n min={0}\n max={100}\n step={5}\n disabled={prefs.muted}\n value={volumePercent(prefs.volume)}\n onChange={(e) =>\n setPrefs({ volume: Number(e.target.value) / 100 })\n }\n onPointerUp={() => kit.play(\"coin\")}\n style={{ width: \"100%\", accentColor: v.gold }}\n />\n </div>\n <Toggle\n glyph=\"flame\"\n label={t(\"motion\")}\n value={prefs.reducedMotion}\n onChange={(on) => setPrefs({ reducedMotion: on })}\n />\n {/* язык интерфейса — всего приложения (UI kit setLanguage), тексты тайтла идут следом */}\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"language\")}</div>\n <LanguageSwitch />\n </div>\n {languages.some((l) => l.id !== \"en\" && l.id !== \"ru\") ? (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13 }}>\n {t(\"contentLanguage\")}\n </div>\n <Tabs\n tabs={languages.map((l) => ({ id: l.id, label: l.name }))}\n value={\n languages.find((l) => locale.startsWith(l.id))?.id ??\n languages[0]!.id\n }\n onChange={(l) => void switchLanguage(l)}\n />\n </div>\n ) : null}\n </div>\n\n <WalletsSection />\n\n {features.get(\"tutorials\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"tutorials\")}>\n <Icon glyph=\"bulb\" size={18} /> {t(\"tutorials\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={() => setAsk(\"logout\")}>\n <Icon glyph=\"logout\" size={18} /> {t(\"logout\")}\n </Button>\n\n {renaming ? (\n <RenamePopup\n current={shown}\n onClose={() => setRenaming(false)}\n onDone={(n) => {\n setName(n);\n setRenaming(false);\n }}\n />\n ) : null}\n {ask === \"logout\" ? (\n <ConfirmPopup\n title={t(\"logout\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n // Only the logout: the host returns to the sign-in screen on \"auth:loggedOut\" by itself.\n // A page reload here blanked the AI Coder's live preview (its files arrive by message\n // and do not survive one) and restarted the whole app for nothing.\n onConfirm={() => client.auth.logout()}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"logoutAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n };\n}\n\nfunction Toggle({\n glyph,\n label,\n value,\n onChange,\n}: {\n glyph: \"sound\" | \"mute\" | \"flame\" | \"globe\";\n label: string;\n value: boolean;\n onChange: (on: boolean) => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n aria-checked={value}\n onClick={() => onChange(!value)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n background: \"none\",\n border: \"none\",\n color: v.text,\n cursor: \"pointer\",\n padding: 0,\n textAlign: \"left\",\n }}\n >\n <Icon glyph={glyph} size={24} />\n <span style={{ flex: 1, ...outlined, fontSize: 14 }}>{label}</span>\n <span\n style={{\n width: 52,\n height: 30,\n borderRadius: 15,\n background: value ? v.green : v.wellSoft,\n boxShadow: `inset 0 0 0 1px ${v.line}`,\n position: \"relative\",\n transition: \"background .2s\",\n }}\n >\n <span\n style={{\n position: \"absolute\",\n top: 3,\n left: value ? 25 : 3,\n width: 24,\n height: 24,\n borderRadius: \"50%\",\n background: \"#fff\",\n transition: \"left .2s\",\n boxShadow: \"0 2px 4px rgba(0,0,0,.3)\",\n }}\n />\n </span>\n </button>\n );\n}\n\n/**\n * Wallets linked by signature, each with \"Unlink\" (`client.auth.unlinkWallet`). Hidden while there are\n * none. The wallet an account signs in with has no button: the server refuses it (`CanUnlink`).\n */\nfunction WalletsSection(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [wallets, setWallets] = useState<PlayAccessWallet[]>([]);\n const [asking, setAsking] = useState<PlayAccessWallet | null>(null);\n\n const load = useCallback(async () => {\n const res = await client.blockchain.getPlayAccess();\n if (res.ok) setWallets(res.data.LinkedWallets ?? []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n // The popup shows the spinner and closes itself when this resolves.\n const unlink = async (wallet: PlayAccessWallet) => {\n const res = await client.auth.unlinkWallet(wallet.NetworkID ?? \"\");\n if (!res.ok) {\n const known = unlinkRefusal(res.error);\n toast(known ? t(known) : errorText(res.error), \"error\");\n } else toast(t(\"unlinked\"), \"success\");\n await load();\n };\n\n if (wallets.length === 0) return null;\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"wallets\")}\n </div>\n {wallets.map((w) => (\n <div\n key={`${w.NetworkID}:${w.Address}`}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n minWidth: 0,\n }}\n >\n <Icon glyph=\"link\" size={22} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\" }}>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 13,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {shortAddress(w.Address)}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {(w.NetworkID ?? \"\").toUpperCase()}\n {w.CanUnlink === false ? ` · ${t(\"loginWallet\")}` : \"\"}\n </span>\n </div>\n {w.CanUnlink !== false ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setAsking(w)}>\n {t(\"unlink\")}\n </Button>\n ) : null}\n </div>\n ))}\n {asking ? (\n <ConfirmPopup\n title={t(\"unlinkTitle\")}\n confirmLabel={t(\"unlink\")}\n tone=\"red\"\n onClose={() => setAsking(null)}\n onConfirm={() => unlink(asking)}\n >\n <div\n style={{\n fontFamily: \"monospace\",\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {shortAddress(asking.Address)}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n lineHeight: 1.4,\n }}\n >\n {t(\"unlinkAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction RenamePopup({\n current,\n onClose,\n onDone,\n}: {\n current: string;\n onClose: () => void;\n onDone: (name: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [value, setValue] = useState(current);\n const [busy, setBusy] = useState(false);\n const problem = nameProblem(value);\n const save = async () => {\n if (problem) return;\n setBusy(true);\n const res = await client.user.changeUsername(value.trim());\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"renamed\"), \"success\");\n // The SDK does not patch the cached name — the screen shows the server's answer.\n onDone(res.data.Username ?? value.trim());\n };\n return (\n <Popup title={t(\"rename\")} onClose={onClose} width={400}>\n <TextInput\n value={value}\n autoFocus\n maxLength={40}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void save()}\n placeholder={t(\"newName\")}\n style={{ fontSize: 16 }}\n />\n {problem ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {problem === \"short\" ? t(\"nameShort\") : t(\"nameLong\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!!problem}\n busy={busy}\n onClick={() => void save()}\n >\n {t(\"save\")}\n </Button>\n </Popup>\n );\n}\n"
|
|
62
|
+
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type { PlayAccessWallet } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n ConfirmPopup,\n errorText,\n Icon,\n LanguageSwitch,\n onLanguageChange,\n outlined,\n panel,\n Popup,\n Tabs,\n Toggle as KitToggle,\n TextInput,\n usePlayerPrefs,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n languageChoices,\n nameProblem,\n shortAddress,\n unlinkRefusal,\n volumePercent,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Profile and settings (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization):\n// the name and ID, sound, volume and motion (kept on this device), the game's language, tutorials,\n// linked wallets and logging out. Renaming and unlinking a wallet are popups. No \"Delete account\"\n// button (owner's decision 03.10.2026) — `client.user.deleteUserAccount()` stays in the SDK. No analytics switch (owner's decision 29.09.2026): anonymous analytics stays on as the SDK\n// ships it (client.analytics).\n\nexport function makeProfileScreen(features: FeatureRegistry) {\n return function ProfileScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [prefs, setPrefs] = usePlayerPrefs();\n const [name, setName] = useState<string | null>(null);\n const [renaming, setRenaming] = useState(false);\n const [ask, setAsk] = useState<\"logout\" | null>(null);\n const [locale, setLocale] = useState(client.localization.locale ?? \"\");\n const id = client.auth.context?.userID ?? \"\";\n const shown = name ?? state?.PublicData?.Username ?? t(\"guest\");\n const languages = languageChoices(client.localization.locales);\n\n const switchLanguage = async (next: string) => {\n const res = await client.localization.setLocale(next);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setLocale(res.data);\n };\n // сменили язык интерфейса — тексты тайтла (если у тайтла есть такой язык) переходят на него же\n useEffect(\n () =>\n onLanguageChange((l) => {\n if (languages.some((x) => x.id === l)) void switchLanguage(l);\n }),\n [languages.map((l) => l.id).join()],\n );\n\n return (\n <>\n {/* a phone: one column; a tablet or a PC: the player and wallets | the settings */}\n <Columns\n min={360}\n gap={12}\n style={{ maxWidth: 980, margin: \"0 auto\", width: \"100%\" }}\n >\n <div style={{ display: \"grid\", gap: 12, alignContent: \"start\" }}>\n <div\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n }}\n >\n <div\n style={{\n width: 84,\n height: 84,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: `3px solid color-mix(in srgb, ${v.text} 70%, transparent)`,\n ...outlined,\n fontSize: 36,\n }}\n >\n {(shown.trim()[0] ?? \"?\").toUpperCase()}\n </div>\n <div style={{ ...outlined, fontSize: 20 }}>{shown}</div>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setRenaming(true)}\n data-tutorial-anchor=\"profile:rename\"\n >\n <Icon glyph=\"edit\" size={16} /> {t(\"rename\")}\n </Button>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n maxWidth: \"100%\",\n }}\n >\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"playerID\")}\n </span>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 12,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {id}\n </span>\n <button\n type=\"button\"\n aria-label={t(\"playerID\")}\n onClick={() =>\n void navigator.clipboard\n ?.writeText(id)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n padding: 2,\n }}\n >\n <Icon glyph=\"copy\" size={16} />\n </button>\n </div>\n </div>\n\n <WalletsSection />\n </div>\n <div style={{ display: \"grid\", gap: 12, alignContent: \"start\" }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"settings\")}\n </div>\n <Toggle\n glyph={prefs.muted ? \"mute\" : \"sound\"}\n label={t(\"sound\")}\n value={!prefs.muted}\n onChange={(on) => {\n setPrefs({ muted: !on });\n if (on) kit.play(\"click\");\n }}\n />\n <div\n style={{\n display: \"grid\",\n gap: 4,\n opacity: prefs.muted ? 0.5 : 1,\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"volume\")}</span>\n <span>{volumePercent(prefs.volume)}%</span>\n </div>\n <input\n type=\"range\"\n min={0}\n max={100}\n step={5}\n disabled={prefs.muted}\n value={volumePercent(prefs.volume)}\n onChange={(e) =>\n setPrefs({ volume: Number(e.target.value) / 100 })\n }\n onPointerUp={() => kit.play(\"coin\")}\n style={{ width: \"100%\", accentColor: v.gold }}\n />\n </div>\n <Toggle\n glyph=\"flame\"\n label={t(\"motion\")}\n value={prefs.reducedMotion}\n onChange={(on) => setPrefs({ reducedMotion: on })}\n />\n {/* язык интерфейса — всего приложения (UI kit setLanguage), тексты тайтла идут следом */}\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"language\")}</div>\n <LanguageSwitch />\n </div>\n {languages.some((l) => l.id !== \"en\" && l.id !== \"ru\") ? (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13 }}>\n {t(\"contentLanguage\")}\n </div>\n <Tabs\n tabs={languages.map((l) => ({ id: l.id, label: l.name }))}\n value={\n languages.find((l) => locale.startsWith(l.id))?.id ??\n languages[0]!.id\n }\n onChange={(l) => void switchLanguage(l)}\n />\n </div>\n ) : null}\n </div>\n {features.get(\"tutorials\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"tutorials\")}>\n <Icon glyph=\"bulb\" size={18} /> {t(\"tutorials\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={() => setAsk(\"logout\")}>\n <Icon glyph=\"logout\" size={18} /> {t(\"logout\")}\n </Button>\n </div>\n </Columns>\n {renaming ? (\n <RenamePopup\n current={shown}\n onClose={() => setRenaming(false)}\n onDone={(n) => {\n setName(n);\n setRenaming(false);\n }}\n />\n ) : null}\n {ask === \"logout\" ? (\n <ConfirmPopup\n title={t(\"logout\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n // Only the logout: the host returns to the sign-in screen on \"auth:loggedOut\" by itself.\n // A page reload here blanked the AI Coder's live preview (its files arrive by message\n // and do not survive one) and restarted the whole app for nothing.\n onConfirm={() => client.auth.logout()}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"logoutAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </>\n );\n };\n}\n\nfunction Toggle({\n glyph,\n label,\n value,\n onChange,\n}: {\n glyph: \"sound\" | \"mute\" | \"flame\" | \"globe\";\n label: string;\n value: boolean;\n onChange: (on: boolean) => void;\n}): ReactNode {\n // A row with the kit's ON / OFF switch on its right (the sheet's \"Fusion Animation ON\").\n return (\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <Icon glyph={glyph} size={24} />\n <span style={{ flex: 1, ...outlined, fontSize: 15 }}>{label}</span>\n <KitToggle value={value} onChange={onChange} label={label} />\n </div>\n );\n}\n\n/**\n * Wallets linked by signature, each with \"Unlink\" (`client.auth.unlinkWallet`). Hidden while there are\n * none. The wallet an account signs in with has no button: the server refuses it (`CanUnlink`).\n */\nfunction WalletsSection(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [wallets, setWallets] = useState<PlayAccessWallet[]>([]);\n const [asking, setAsking] = useState<PlayAccessWallet | null>(null);\n\n const load = useCallback(async () => {\n const res = await client.blockchain.getPlayAccess();\n if (res.ok) setWallets(res.data.LinkedWallets ?? []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n // The popup shows the spinner and closes itself when this resolves.\n const unlink = async (wallet: PlayAccessWallet) => {\n const res = await client.auth.unlinkWallet(wallet.NetworkID ?? \"\");\n if (!res.ok) {\n const known = unlinkRefusal(res.error);\n toast(known ? t(known) : errorText(res.error), \"error\");\n } else toast(t(\"unlinked\"), \"success\");\n await load();\n };\n\n if (wallets.length === 0) return null;\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"wallets\")}\n </div>\n {wallets.map((w) => (\n <div\n key={`${w.NetworkID}:${w.Address}`}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n minWidth: 0,\n }}\n >\n <Icon glyph=\"link\" size={22} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\" }}>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 13,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {shortAddress(w.Address)}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {(w.NetworkID ?? \"\").toUpperCase()}\n {w.CanUnlink === false ? ` · ${t(\"loginWallet\")}` : \"\"}\n </span>\n </div>\n {w.CanUnlink !== false ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setAsking(w)}>\n {t(\"unlink\")}\n </Button>\n ) : null}\n </div>\n ))}\n {asking ? (\n <ConfirmPopup\n title={t(\"unlinkTitle\")}\n confirmLabel={t(\"unlink\")}\n tone=\"red\"\n onClose={() => setAsking(null)}\n onConfirm={() => unlink(asking)}\n >\n <div\n style={{\n fontFamily: \"monospace\",\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {shortAddress(asking.Address)}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n lineHeight: 1.4,\n }}\n >\n {t(\"unlinkAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction RenamePopup({\n current,\n onClose,\n onDone,\n}: {\n current: string;\n onClose: () => void;\n onDone: (name: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [value, setValue] = useState(current);\n const [busy, setBusy] = useState(false);\n const problem = nameProblem(value);\n const save = async () => {\n if (problem) return;\n setBusy(true);\n const res = await client.user.changeUsername(value.trim());\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"renamed\"), \"success\");\n // The SDK does not patch the cached name — the screen shows the server's answer.\n onDone(res.data.Username ?? value.trim());\n };\n return (\n <Popup title={t(\"rename\")} onClose={onClose} width={400}>\n <TextInput\n value={value}\n autoFocus\n maxLength={40}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void save()}\n placeholder={t(\"newName\")}\n style={{ fontSize: 16 }}\n />\n {problem ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {problem === \"short\" ? t(\"nameShort\") : t(\"nameLong\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!!problem}\n busy={busy}\n onClick={() => void save()}\n >\n {t(\"save\")}\n </Button>\n </Popup>\n );\n}\n"
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"path": "module.meta.json",
|
|
66
|
-
"content": "{\n \"id\": \"profile\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Profile and settings for any game: name and ID, sound, volume and motion, language, tutorials and log out.\",\n \"description\": \"A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more). Buttons: Tutorials (when tutorials is installed) and Log out (confirmed). Registers the 'profile' feature; the lobby's profile button opens it. No title settings needed.\",\n \"provides\": [\n \"player name and ID\",\n \"rename popup\",\n \"sound, volume and fewer animations\",\n \"language picker\",\n \"log out\"\n ],\n \"tags\": [\n \"profile\",\n \"settings\",\n \"account\",\n \"sound\",\n \"language\",\n \"privacy\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
66
|
+
"content": "{\n \"id\": \"profile\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Profile and settings for any game: name and ID, sound, volume and motion, language, tutorials and log out.\",\n \"description\": \"A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more). Buttons: Tutorials (when tutorials is installed) and Log out (confirmed). Registers the 'profile' feature; the lobby's profile button opens it. No title settings needed.\",\n \"provides\": [\n \"player name and ID\",\n \"rename popup\",\n \"sound, volume and fewer animations\",\n \"language picker\",\n \"log out\"\n ],\n \"tags\": [\n \"profile\",\n \"settings\",\n \"account\",\n \"sound\",\n \"language\",\n \"privacy\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.5\"\n}\n"
|
|
67
67
|
}
|
|
68
68
|
]
|
|
69
69
|
}
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
"name": "iDos Games",
|
|
31
31
|
"url": "https://idosgames.com"
|
|
32
32
|
},
|
|
33
|
-
"version": "0.1.
|
|
33
|
+
"version": "0.1.2"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@idosgames/core": "0.
|
|
37
|
-
"@idosgames/module-sdk": "0.
|
|
38
|
-
"@idosgames/react": "0.
|
|
36
|
+
"@idosgames/core": "0.23.0",
|
|
37
|
+
"@idosgames/module-sdk": "0.13.0",
|
|
38
|
+
"@idosgames/react": "0.6.0",
|
|
39
39
|
"react": "19.2.7"
|
|
40
40
|
},
|
|
41
|
-
"contentHash": "
|
|
41
|
+
"contentHash": "c374268f2fc7cfb1fb4a6073cceca7cb0666f85bc2f1c37bc4ba818233207b61",
|
|
42
42
|
"files": [
|
|
43
43
|
{
|
|
44
44
|
"path": "components/BattlePopups.tsx",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
"path": "components/Duels.tsx",
|
|
53
|
-
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type PvPMatch } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Spinner,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { matchEntry, type DuelRule } from \"../model\";\nimport { t } from \"../i18n\";\nimport { isExpired, shortID } from \"../helpers\";\n\nfunction DuelRow({\n match,\n rules,\n index,\n right,\n onPick,\n}: {\n match: PvPMatch;\n rules: DuelRule[];\n index: number;\n right?: ReactNode;\n onPick?: () => void;\n}): ReactNode {\n const stagger = useStagger();\n const e = matchEntry(match);\n const rule = rules.find((r) => r.id === match.RuleID);\n return (\n <div\n onClick={onPick}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Icon glyph=\"swords\" size={30} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {rule?.name ?? match.RuleID} · {t(\"player\")}{\" \"}\n {shortID(match.CreatorID)}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"entry\")}{\" \"}\n {e.currency ? (\n <ResourceList\n lines={[\n {\n kind: e.crypto ? \"crypto\" : \"currency\",\n id: e.currency,\n amount: e.amount,\n },\n ]}\n size={16}\n />\n ) : null}\n {match.CreatorCharacterID ? ` · ${match.CreatorCharacterID}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nexport function OpenDuels({\n rules,\n onJoin,\n}: {\n rules: DuelRule[];\n onJoin: (m: PvPMatch) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<PvPMatch[] | null>(null);\n useEffect(() => {\n void client.match\n .getAvailableMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noOpen\")} />;\n return (\n <
|
|
53
|
+
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type PvPMatch } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n Center,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Spinner,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { matchEntry, type DuelRule } from \"../model\";\nimport { t } from \"../i18n\";\nimport { isExpired, shortID } from \"../helpers\";\n\nfunction DuelRow({\n match,\n rules,\n index,\n right,\n onPick,\n}: {\n match: PvPMatch;\n rules: DuelRule[];\n index: number;\n right?: ReactNode;\n onPick?: () => void;\n}): ReactNode {\n const stagger = useStagger();\n const e = matchEntry(match);\n const rule = rules.find((r) => r.id === match.RuleID);\n return (\n <div\n onClick={onPick}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Icon glyph=\"swords\" size={30} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {rule?.name ?? match.RuleID} · {t(\"player\")}{\" \"}\n {shortID(match.CreatorID)}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"entry\")}{\" \"}\n {e.currency ? (\n <ResourceList\n lines={[\n {\n kind: e.crypto ? \"crypto\" : \"currency\",\n id: e.currency,\n amount: e.amount,\n },\n ]}\n size={16}\n />\n ) : null}\n {match.CreatorCharacterID ? ` · ${match.CreatorCharacterID}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nexport function OpenDuels({\n rules,\n onJoin,\n}: {\n rules: DuelRule[];\n onJoin: (m: PvPMatch) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<PvPMatch[] | null>(null);\n useEffect(() => {\n void client.match\n .getAvailableMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noOpen\")} />;\n return (\n <Columns gap={8} min={320}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n onPick={() => onJoin(m)}\n right={\n <Button size=\"sm\" tone=\"green\">\n {t(\"fight\")}\n </Button>\n }\n />\n ))}\n </Columns>\n );\n}\n\nexport function MyDuels({\n rules,\n onChange,\n}: {\n rules: DuelRule[];\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const me = client.auth.context?.userID;\n const [list, setList] = useState<PvPMatch[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.match\n .getMyMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noMine\")} />;\n const cancel = async (id: string) => {\n setBusy(id);\n const res = await client.match.cancelMatch(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onChange();\n };\n return (\n <Columns gap={8} min={320}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n right={\n isExpired(m) ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"expired\")}\n </span>\n ) : m.Status === \"Open\" && m.CreatorID === me ? (\n <div style={{ display: \"grid\", gap: 4, justifyItems: \"end\" }}>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"waiting\")}\n </span>\n <Button\n size=\"sm\"\n tone=\"grey\"\n busy={busy === m.MatchID}\n onClick={() => void cancel(m.MatchID)}\n >\n {t(\"cancel\")}\n </Button>\n </div>\n ) : m.Status === \"Cancelled\" ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"cancelled\")}\n </span>\n ) : m.Status === \"Completed\" ? (\n <span\n style={{\n ...outlined,\n fontSize: 13,\n color: !m.WinnerUserID\n ? v.textDim\n : m.WinnerUserID === me\n ? v.green\n : v.red,\n }}\n >\n {!m.WinnerUserID\n ? t(\"draw\")\n : m.WinnerUserID === me\n ? t(\"youWon\")\n : t(\"youLost\")}\n </span>\n ) : null\n }\n />\n ))}\n </Columns>\n );\n}\n"
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
"path": "components/HeroPicker.tsx",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
},
|
|
91
91
|
{
|
|
92
92
|
"path": "module.meta.json",
|
|
93
|
-
"content": "{\n \"id\": \"pvp\",\n \"type\": \"feature\",\n \"summary\": \"PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.\",\n \"description\": \"A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the demo heroes' Attack and Health.\",\n \"provides\": [\n \"open duels to join\",\n \"my duels with cancel\",\n \"create a duel with an entry\",\n \"zone battle strategy\",\n \"fight result replayed round by round\",\n \"demo duel rule\"\n ],\n \"tags\": [\"pvp\", \"duel\", \"arena\", \"battle\", \"matchmaking\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
93
|
+
"content": "{\n \"id\": \"pvp\",\n \"type\": \"feature\",\n \"summary\": \"PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.\",\n \"description\": \"A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the demo heroes' Attack and Health.\",\n \"provides\": [\n \"open duels to join\",\n \"my duels with cancel\",\n \"create a duel with an entry\",\n \"zone battle strategy\",\n \"fight result replayed round by round\",\n \"demo duel rule\"\n ],\n \"tags\": [\"pvp\", \"duel\", \"arena\", \"battle\", \"matchmaking\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
|
|
94
94
|
}
|
|
95
95
|
]
|
|
96
96
|
}
|