@idosgames/mcp 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +6 -3
- package/package.json +1 -1
- package/registry/host.json +14 -6
- package/registry/index.json +32 -26
- package/registry/modules/board-game.json +12 -15
- package/registry/modules/idle-rpg.json +14 -17
- package/registry/modules/voxelcraft.json +28 -24
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/cloud-code.json +2 -2
- package/registry/skills/idosgames-agent-debug-surface.json +6 -0
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/idosgames-title-bootstrap.json +6 -0
- package/registry/skills/quest-system.json +3 -3
- package/registry/skills/title-custom-data.json +6 -0
- package/registry/skills/title-system.json +2 -2
- package/registry/skills/user-custom-data.json +1 -1
|
@@ -37,15 +37,12 @@
|
|
|
37
37
|
"version": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.1.
|
|
42
|
-
"@idosgames/react": "0.1.
|
|
43
|
-
"@idosgames/wallet": "0.1.
|
|
44
|
-
"@solana/wallet-adapter-base": "0.9.27",
|
|
45
|
-
"@solana/wallet-adapter-react": "0.15.39",
|
|
40
|
+
"@idosgames/core": "0.2.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.3",
|
|
42
|
+
"@idosgames/react": "0.1.1",
|
|
43
|
+
"@idosgames/wallet": "0.1.13",
|
|
46
44
|
"@tanstack/react-query": "5.101.2",
|
|
47
45
|
"react": "19.2.7",
|
|
48
|
-
"react-dom": "19.2.7",
|
|
49
46
|
"three": "0.185.1",
|
|
50
47
|
"viem": "2.55.2",
|
|
51
48
|
"wagmi": "3.7.2"
|
|
@@ -57,11 +54,11 @@
|
|
|
57
54
|
},
|
|
58
55
|
{
|
|
59
56
|
"path": "components/BoardHud.tsx",
|
|
60
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useUserState, useBoardState } from \"../react/hooks\";\nimport {\n readBoardConfig,\n resolveStageName,\n resolveTiles,\n} from \"../data/boardConfig\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst chip: CSSProperties = {\n background: \"#
|
|
57
|
+
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useUserState, useBoardState } from \"../react/hooks\";\nimport {\n readBoardConfig,\n resolveStageName,\n resolveTiles,\n} from \"../data/boardConfig\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst chip: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n};\nconst infoChip: CSSProperties = {\n ...chip,\n color: \"#cfe0ff\",\n background: \"#04276bcc\",\n};\n\nexport function BoardHud(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const board = useBoardState();\n const cfg = readBoardConfig(client);\n\n const currencies: Record<string, UserVirtualCurrencyState> =\n state?.InventoryV2?.VirtualCurrencies ?? {};\n const amount = (id?: string): number =>\n id ? (currencies[id]?.Amount ?? 0) : 0;\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n const stageName = resolveStageName(cfg, stage);\n\n return (\n <div style={row}>\n {cfg.RollCurrencyID ? (\n <span style={chip}>🎲 {amount(cfg.RollCurrencyID)}</span>\n ) : null}\n {cfg.SoftCurrencyID ? (\n <span style={chip}>🪙 {amount(cfg.SoftCurrencyID)}</span>\n ) : null}\n {cfg.ShieldCurrencyID ? (\n <span style={chip}>🛡 {amount(cfg.ShieldCurrencyID)}</span>\n ) : null}\n <span style={infoChip}>\n Stage {stage}\n {stageName ? ` · ${stageName}` : \"\"}\n </span>\n <span style={infoChip}>\n Tile {board?.Position ?? 0}/{tileCount > 0 ? tileCount - 1 : 0}\n </span>\n <span style={infoChip}>Cycles {board?.CyclesCompleted ?? 0}</span>\n </div>\n );\n}\n"
|
|
61
58
|
},
|
|
62
59
|
{
|
|
63
60
|
"path": "components/BoardRoot.tsx",
|
|
64
|
-
"content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { StatusProvider } from \"../react/status\";\nimport {\n readBoardConfig,\n resolveBuildingCount,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { BoardHud } from \"./BoardHud\";\nimport { InteractionPanel } from \"./InteractionPanel\";\nimport { StatusBar } from \"./StatusBar\";\nimport { WalletPanel } from \"./WalletPanel\";\n\nconst overlay: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n boxSizing: \"border-box\",\n padding: 16,\n fontFamily: \"system-ui, sans-serif\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"#
|
|
61
|
+
"content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { StatusProvider } from \"../react/status\";\nimport {\n readBoardConfig,\n resolveBuildingCount,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { BoardHud } from \"./BoardHud\";\nimport { InteractionPanel } from \"./InteractionPanel\";\nimport { StatusBar } from \"./StatusBar\";\nimport { WalletPanel } from \"./WalletPanel\";\n\nconst overlay: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n boxSizing: \"border-box\",\n padding: 16,\n fontFamily: \"system-ui, sans-serif\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 10,\n padding: \"8px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst walletDock: CSSProperties = {\n position: \"absolute\",\n top: 60,\n right: 16,\n pointerEvents: \"none\",\n};\n\nexport function BoardRoot(): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const [ready, setReady] = useState(false);\n const [walletOpen, setWalletOpen] = useState(false);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.gameLoop.getBoardDefinition();\n await client.gameLoop.getUserBoardState();\n if (!active) return;\n const cfg = readBoardConfig(client);\n const board = client.data.user.state?.GameLoop?.Board;\n const stage = board?.StageLevel ?? 1;\n controller.loadBoard(\n resolveTiles(cfg, stage),\n resolveBuildingCount(cfg, stage),\n );\n controller.setPosition(board?.Position ?? 0);\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n setReady(true);\n })();\n return () => {\n active = false;\n };\n }, [client, controller]);\n\n // Keep city pad heights in sync with building levels as the cache updates.\n useEffect(() => {\n const off = client.on(\"user:anyUpdated\", () => {\n const board = client.data.user.state?.GameLoop?.Board;\n controller.setBuildingLevels(\n (board?.BuildingStates ?? []).map((b) => b.Level ?? 0),\n );\n });\n return off;\n }, [client, controller]);\n\n return (\n <StatusProvider>\n <div style={overlay}>\n <button\n type=\"button\"\n style={walletLauncher}\n onClick={() => setWalletOpen((v) => !v)}\n >\n 💰 Wallet\n </button>\n {walletOpen && (\n <div style={walletDock}>\n <WalletPanel onClose={() => setWalletOpen(false)} />\n </div>\n )}\n <BoardHud />\n <div style={footer}>\n {ready ? (\n <InteractionPanel />\n ) : (\n <div style={{ opacity: 0.6, pointerEvents: \"auto\" }}>\n Loading board…\n </div>\n )}\n <StatusBar />\n </div>\n </div>\n </StatusProvider>\n );\n}\n"
|
|
65
62
|
},
|
|
66
63
|
{
|
|
67
64
|
"path": "components/BuildPanel.tsx",
|
|
@@ -73,7 +70,7 @@
|
|
|
73
70
|
},
|
|
74
71
|
{
|
|
75
72
|
"path": "components/panelStyles.ts",
|
|
76
|
-
"content": "import type { CSSProperties } from \"react\";\n\nexport const panelCard: CSSProperties = {\n pointerEvents: \"auto\",\n background: \"#
|
|
73
|
+
"content": "import type { CSSProperties } from \"react\";\n\nexport const panelCard: CSSProperties = {\n pointerEvents: \"auto\",\n background: \"#04276bee\",\n border: \"1px solid #1e59c8\",\n borderRadius: 12,\n padding: 14,\n color: \"#fff\",\n minWidth: 260,\n maxWidth: 360,\n backdropFilter: \"blur(2px)\",\n};\n\nexport const panelTitle: CSSProperties = { fontWeight: 700, marginBottom: 8 };\n\nexport const primaryButton: CSSProperties = {\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"8px 16px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\n\nexport const secondaryButton: CSSProperties = {\n background: \"#1e59c8\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"6px 12px\",\n fontWeight: 600,\n cursor: \"pointer\",\n};\n"
|
|
77
74
|
},
|
|
78
75
|
{
|
|
79
76
|
"path": "components/RaidPanel.tsx",
|
|
@@ -81,7 +78,7 @@
|
|
|
81
78
|
},
|
|
82
79
|
{
|
|
83
80
|
"path": "components/RollControls.tsx",
|
|
84
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { useBoardState } from \"../react/hooks\";\nimport { useStatus } from \"../react/status\";\nimport {\n buildStepPath,\n readBoardConfig,\n resolveTiles,\n} from \"../data/boardConfig\";\n\nconst wrap: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst multRow: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n};\n\nfunction multButton(active: boolean): CSSProperties {\n return {\n background: active ? \"#ffd479\" : \"#
|
|
81
|
+
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { useBoardState } from \"../react/hooks\";\nimport { useStatus } from \"../react/status\";\nimport {\n buildStepPath,\n readBoardConfig,\n resolveTiles,\n} from \"../data/boardConfig\";\n\nconst wrap: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst multRow: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n};\n\nfunction multButton(active: boolean): CSSProperties {\n return {\n background: active ? \"#ffd479\" : \"#052e7ecc\",\n color: active ? \"#052e7e\" : \"#cfe0ff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"4px 10px\",\n fontSize: 12,\n fontWeight: 700,\n cursor: \"pointer\",\n };\n}\nconst rollButton: CSSProperties = {\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 12,\n padding: \"12px 40px\",\n fontSize: 16,\n fontWeight: 700,\n cursor: \"pointer\",\n};\n\nexport function RollControls(): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const board = useBoardState();\n const { setStatus } = useStatus();\n\n const cfg = readBoardConfig(client);\n const multipliers = cfg.AllowedRollMultipliers ?? [1];\n const [mult, setMult] = useState<number>(multipliers[0] ?? 1);\n const [busy, setBusy] = useState(false);\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n\n const roll = async (): Promise<void> => {\n setBusy(true);\n const from = board?.Position ?? 0;\n const result = await client.gameLoop.boardLoopRoll(mult);\n if (result.ok) {\n const data = result.data;\n const path = buildStepPath(\n data.OldPosition ?? from,\n data.NewPosition,\n tileCount,\n );\n await controller.rollDice(data.Steps ?? path.length);\n await controller.moveToken(path);\n controller.highlightTile(data.NewPosition);\n const extra = data.ActionRequired\n ? ` · action: ${data.ActionRequired}`\n : data.SpecialModeOffer\n ? \" · SPECIAL offered\"\n : \"\";\n setStatus(\n `Rolled ${data.Steps ?? path.length} → tile ${data.NewPosition} (${data.LandedTileType ?? \"?\"})${extra}`,\n \"success\",\n );\n } else {\n setStatus(`Roll failed: ${result.error}`, \"error\");\n }\n setBusy(false);\n };\n\n return (\n <div style={wrap}>\n <div style={multRow}>\n {multipliers.map((m) => (\n <button\n key={m}\n type=\"button\"\n style={multButton(m === mult)}\n onClick={() => setMult(m)}\n >\n ×{m}\n </button>\n ))}\n </div>\n <button\n type=\"button\"\n style={{ ...rollButton, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() => void roll()}\n >\n {busy ? \"Rolling…\" : `Roll ×${mult}`}\n </button>\n </div>\n );\n}\n"
|
|
85
82
|
},
|
|
86
83
|
{
|
|
87
84
|
"path": "components/SpecialPanel.tsx",
|
|
@@ -89,11 +86,11 @@
|
|
|
89
86
|
},
|
|
90
87
|
{
|
|
91
88
|
"path": "components/StatusBar.tsx",
|
|
92
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"../react/status\";\n\nconst colors: Record<string, string> = {\n info: \"#
|
|
89
|
+
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"../react/status\";\n\nconst colors: Record<string, string> = {\n info: \"#a9c6ff\",\n success: \"#5ad19a\",\n error: \"#ff6b6b\",\n};\n\nconst bar: CSSProperties = {\n minHeight: 20,\n fontSize: 13,\n fontFamily: \"system-ui, sans-serif\",\n};\n\nexport function StatusBar(): ReactNode {\n const { message } = useStatus();\n if (!message) return <div style={bar} />;\n return (\n <div style={{ ...bar, color: colors[message.kind] ?? \"#fff\" }}>\n {message.kind === \"error\" ? \"⚠ \" : message.kind === \"success\" ? \"✓ \" : \"\"}\n {message.text}\n </div>\n );\n}\n"
|
|
93
90
|
},
|
|
94
91
|
{
|
|
95
92
|
"path": "components/WalletPanel.tsx",
|
|
96
|
-
"content": "import {\n useEffect,\n useMemo,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { parseUnits } from \"viem\";\nimport {\n arbitrum,\n base,\n bsc,\n mainnet,\n optimism,\n polygon,\n polygonAmoy,\n sepolia,\n} from \"viem/chains\";\nimport { useAccount, useConnect, useDisconnect, useSwitchChain } from \"wagmi\";\nimport {\n createEvmWalletConfig,\n IDosGamesWalletProvider,\n useEvmBridge,\n} from \"@idosgames/wallet/react\";\nimport type { BridgeResult } from \"@idosgames/wallet\";\nimport type {\n BlockchainNetworkDefinition,\n CryptoCurrencyDefinition,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { ENV_WALLETCONNECT_PROJECT_ID } from \"../env\";\n\n// A curated EVM chain set for the demo — enough to cover the networks a title is likely to use.\n// wagmi needs at least one chain up front; the actual network you deposit to comes from the\n// title's blockchain config (the picker below), and we switch the wallet's chain to match.\nconst SUPPORTED_CHAINS = [\n mainnet,\n polygon,\n bsc,\n arbitrum,\n base,\n optimism,\n sepolia,\n polygonAmoy,\n] as const;\n\n// Built once. Set VITE_WALLETCONNECT_PROJECT_ID (get one at cloud.walletconnect.com) to enable\n// MOBILE wallets via the WalletConnect QR/deep-link modal; without it, browser extensions still work.\nconst wagmiConfig = createEvmWalletConfig({\n chains: SUPPORTED_CHAINS,\n walletConnectProjectId: ENV_WALLETCONNECT_PROJECT_ID || undefined,\n appName: \"iDosGames Board\",\n});\n\nconst card: CSSProperties = {\n pointerEvents: \"auto\",\n background: \"#1b1730f5\",\n border: \"1px solid #34294f\",\n borderRadius: 12,\n padding: 16,\n color: \"#fff\",\n width: 320,\n fontFamily: \"system-ui, sans-serif\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n maxHeight: \"80vh\",\n overflow: \"auto\",\n};\nconst title: CSSProperties = { fontWeight: 700, fontSize: 16 };\nconst label: CSSProperties = { fontSize: 12, opacity: 0.7 };\nconst input: CSSProperties = {\n background: \"#241d40\",\n border: \"1px solid #34294f\",\n borderRadius: 8,\n color: \"#fff\",\n padding: \"8px 10px\",\n width: \"100%\",\n boxSizing: \"border-box\",\n};\nconst primary: CSSProperties = {\n background: \"#6c5ce7\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"9px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst ghost: CSSProperties = {\n background: \"#34294f\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"7px 12px\",\n fontWeight: 600,\n cursor: \"pointer\",\n};\nconst row: CSSProperties = { display: \"flex\", gap: 8 };\n\n/** Public entry: the wallet screen wrapped in its own wagmi/react-query provider. */\nexport function WalletPanel({ onClose }: { onClose?: () => void }): ReactNode {\n return (\n <IDosGamesWalletProvider wagmiConfig={wagmiConfig}>\n <WalletPanelInner onClose={onClose} />\n </IDosGamesWalletProvider>\n );\n}\n\nfunction WalletPanelInner({ onClose }: { onClose?: () => void }): ReactNode {\n const client = useIDosGamesClient();\n // The title comes from the host's client, never re-derived here: a module that resolved its own\n // title could disagree with the host and bridge deposits into a different title's wallet.\n const bridge = useEvmBridge(client, client.titleID);\n const { address, isConnected, chainId } = useAccount();\n const { connect, connectors } = useConnect();\n const { disconnect } = useDisconnect();\n const { switchChainAsync } = useSwitchChain();\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n const [networks, setNetworks] = useState<\n Record<string, BlockchainNetworkDefinition>\n >({});\n const [currencies, setCurrencies] = useState<\n Record<string, CryptoCurrencyDefinition>\n >({});\n const [networkID, setNetworkID] = useState<string>(\"\");\n const [currencyID, setCurrencyID] = useState<string>(\"\");\n const [amount, setAmount] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [result, setResult] = useState<BridgeResult<unknown> | null>(null);\n\n // Load blockchain config + on-chain state once; re-render on cache changes for the balance.\n useEffect(() => {\n let active = true;\n void (async () => {\n const defs = await client.blockchain.getDefinitions();\n await client.blockchain.getUserState();\n if (!active || !defs.ok) return;\n const nets = defs.data.Blockchain?.Networks ?? {};\n const evmNets: Record<string, BlockchainNetworkDefinition> = {};\n for (const [id, net] of Object.entries(nets))\n if (net.Type === \"EVM\") evmNets[id] = net;\n setNetworks(evmNets);\n setCurrencies(defs.data.CryptoCurrencies ?? {});\n const firstNet = Object.keys(evmNets)[0] ?? \"\";\n setNetworkID(firstNet);\n })();\n return () => {\n active = false;\n };\n }, [client]);\n\n useEffect(() => client.on(\"user:anyUpdated\", force), [client]);\n\n // Currencies that have an ERC-20 binding on the selected network (a contract we can deposit).\n const eligibleCurrencies = useMemo(() => {\n return Object.entries(currencies).filter(([, def]) =>\n def.Networks?.some(\n (b) => b.NetworkID === networkID && !!b.ContractAddress,\n ),\n );\n }, [currencies, networkID]);\n\n useEffect(() => {\n const first = eligibleCurrencies[0]?.[0] ?? \"\";\n setCurrencyID((prev) =>\n eligibleCurrencies.some(([id]) => id === prev) ? prev : first,\n );\n }, [eligibleCurrencies]);\n\n const network = networkID ? networks[networkID] : undefined;\n const currency = currencyID ? currencies[currencyID] : undefined;\n const binding = currency?.Networks?.find((b) => b.NetworkID === networkID);\n const balance = currencyID\n ? client.data.user.getCryptoCurrencyAmount(currencyID)\n : \"0\";\n\n async function ensureChain(): Promise<boolean> {\n if (!network?.ChainID || chainId === network.ChainID) return true;\n try {\n await switchChainAsync({ chainId: network.ChainID });\n return true;\n } catch {\n setResult({\n ok: false,\n stage: \"approve\",\n error: `Switch your wallet to chain ${network.ChainID} to continue.`,\n });\n return false;\n }\n }\n\n async function runDeposit(): Promise<void> {\n if (!network || !binding?.ContractAddress) return;\n setBusy(true);\n setResult(null);\n if (await ensureChain()) {\n const decimals = binding.Decimals ?? 18;\n let raw: bigint;\n try {\n raw = parseUnits(amount || \"0\", decimals);\n } catch {\n setResult({ ok: false, stage: \"approve\", error: \"Invalid amount.\" });\n setBusy(false);\n return;\n }\n const res = await bridge.depositToken({\n network,\n tokenAddress: binding.ContractAddress as `0x${string}`,\n amount: raw,\n });\n setResult(res);\n if (res.ok) await client.blockchain.getUserState();\n }\n setBusy(false);\n }\n\n async function runWithdraw(): Promise<void> {\n if (!network || !bridge.account) return;\n setBusy(true);\n setResult(null);\n if (await ensureChain()) {\n const res = await bridge.withdrawToken({\n currencyID,\n networkID,\n walletAddress: bridge.account,\n amount: amount || \"0\",\n });\n setResult(res);\n if (res.ok) await client.blockchain.getUserState();\n }\n setBusy(false);\n }\n\n return (\n <div style={card}>\n <div style={{ ...row, justifyContent: \"space-between\" }}>\n <span style={title}>Crypto wallet</span>\n {onClose && (\n <button type=\"button\" style={ghost} onClick={onClose}>\n ✕\n </button>\n )}\n </div>\n\n {/* Connect */}\n {isConnected ? (\n <div style={row}>\n <span style={{ ...label, flex: 1, alignSelf: \"center\" }}>\n {address?.slice(0, 6)}…{address?.slice(-4)}\n </span>\n <button type=\"button\" style={ghost} onClick={() => disconnect()}>\n Disconnect\n </button>\n </div>\n ) : (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 6 }}>\n <span style={label}>Connect a browser or mobile wallet</span>\n {connectors.map((c) => (\n <button\n key={c.uid}\n type=\"button\"\n style={ghost}\n onClick={() => connect({ connector: c })}\n >\n {c.name}\n </button>\n ))}\n </div>\n )}\n\n {Object.keys(networks).length === 0 ? (\n <span style={label}>No EVM networks configured for this title.</span>\n ) : (\n <>\n <div>\n <div style={label}>Network</div>\n <select\n style={input}\n value={networkID}\n onChange={(e) => setNetworkID(e.target.value)}\n >\n {Object.entries(networks).map(([id, net]) => (\n <option key={id} value={id}>\n {net.DisplayName ?? id}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <div style={label}>Token</div>\n <select\n style={input}\n value={currencyID}\n onChange={(e) => setCurrencyID(e.target.value)}\n >\n {eligibleCurrencies.length === 0 && (\n <option value=\"\">— no depositable tokens —</option>\n )}\n {eligibleCurrencies.map(([id, def]) => (\n <option key={id} value={id}>\n {def.DisplayName ?? id}\n </option>\n ))}\n </select>\n </div>\n\n <div style={label}>\n In-game balance: <b>{balance}</b> {currencyID}\n </div>\n\n <div>\n <div style={label}>Amount</div>\n <input\n style={input}\n inputMode=\"decimal\"\n placeholder=\"0.0\"\n value={amount}\n onChange={(e) => setAmount(e.target.value)}\n />\n </div>\n\n <div style={row}>\n <button\n type=\"button\"\n style={{ ...primary, flex: 1, opacity: busy ? 0.6 : 1 }}\n disabled={busy || !isConnected || !binding?.ContractAddress}\n onClick={() => void runDeposit()}\n >\n Deposit\n </button>\n <button\n type=\"button\"\n style={{ ...ghost, flex: 1, opacity: busy ? 0.6 : 1 }}\n disabled={busy || !isConnected || !currencyID}\n onClick={() => void runWithdraw()}\n >\n Withdraw\n </button>\n </div>\n </>\n )}\n\n {result && <ResultLine result={result} />}\n </div>\n );\n}\n\nfunction ResultLine({ result }: { result: BridgeResult<unknown> }): ReactNode {\n const style: CSSProperties = {\n fontSize: 12,\n borderRadius: 8,\n padding: \"8px 10px\",\n background: result.ok ? \"#1e3a2a\" : \"#3a1e28\",\n color: result.ok ? \"#8ef0b0\" : \"#f2a0b4\",\n wordBreak: \"break-all\",\n };\n if (result.ok)\n return <div style={style}>✓ Done · tx {result.onChainTxHash}</div>;\n return (\n <div style={style}>\n ✕ [{result.stage}] {result.error}\n {result.titleTransactionID\n ? ` · already debited (tx ${result.titleTransactionID}) — retry/confirm, don't re-request`\n : \"\"}\n </div>\n );\n}\n"
|
|
93
|
+
"content": "import type { ReactNode } from \"react\";\nimport { LazyWalletPanel } from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"../react/context\";\n\n// The in-game crypto wallet (deposit/withdraw) — the same lazy pattern as the login button. The\n// whole panel, including everything that touches wagmi/Reown AppKit, lives in @idosgames/wallet and\n// is fetched with `await import()` on mount, so Reown AppKit stays out of the page's initial module\n// graph and the live preview boots. Because the wallet config is memoised per project id, a wallet\n// the player connected on the sign-in screen is already connected here.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls AppKit\n// into the startup graph and blanks the preview for web3 titles.\nexport function WalletPanel({ onClose }: { onClose?: () => void }): ReactNode {\n const client = useIDosGamesClient();\n return (\n <LazyWalletPanel\n client={client}\n appName=\"iDosGames Board\"\n onClose={onClose}\n />\n );\n}\n"
|
|
97
94
|
},
|
|
98
95
|
{
|
|
99
96
|
"path": "controller-box.ts",
|
|
@@ -113,7 +110,7 @@
|
|
|
113
110
|
},
|
|
114
111
|
{
|
|
115
112
|
"path": "game/BoardController.ts",
|
|
116
|
-
"content": "import * as THREE from \"three\";\nimport type { TileInfo } from \"../data/boardConfig\";\n\nconst TILE_COLORS: Record<string, number> = {\n Chance: 0xf0a020,\n Reward: 0x4caf50,\n Special: 0x9c5cff,\n RandomAction: 0xe8534e,\n Shield: 0x4a90e2,\n};\nconst DEFAULT_TILE_COLOR = 0x6b6b7a;\n\n// Glyphs drawn on top of non-reward tiles so the board reads at a glance.\nconst TILE_SYMBOLS: Record<string, string> = {\n Chance: \"?\",\n Special: \"★\",\n RandomAction: \"✖\",\n Shield: \"✚\",\n};\n\nconst BUILDING_COLORS = [\n 0x6c5ce7, 0x4caf50, 0xe8534e, 0xf0a020, 0x4a90e2, 0x42c9c2,\n];\n\nconst TOKEN_Y = 0.85;\nconst TILE_Y = 0.13;\nconst STEP_MS = 240;\nconst HIGHLIGHT_MS = 850;\nconst BODY_H = 1.0;\nconst DICE_MS = 950;\nconst DICE_BASE_Y = 3.2;\nconst DICE_DROP = 3.4;\n\n// Standard bounce-out easing (0 → 1 with decaying bounces) for the dice toss.\nfunction bounceOut(t: number): number {\n const n1 = 7.5625;\n const d1 = 2.75;\n if (t < 1 / d1) return n1 * t * t;\n if (t < 2 / d1) {\n const u = t - 1.5 / d1;\n return n1 * u * u + 0.75;\n }\n if (t < 2.5 / d1) {\n const u = t - 2.25 / d1;\n return n1 * u * u + 0.9375;\n }\n const u = t - 2.625 / d1;\n return n1 * u * u + 0.984375;\n}\n\n// BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z. Assign pip values so opposite faces sum to 7.\nconst FACE_VALUES = [2, 5, 3, 4, 1, 6];\n// Resting rotation that brings each value's face to the top (+Y).\nconst REST_ROTATION: Record<number, [number, number, number]> = {\n 1: [-Math.PI / 2, 0, 0],\n 2: [0, 0, Math.PI / 2],\n 3: [0, 0, 0],\n 4: [Math.PI, 0, 0],\n 5: [0, 0, -Math.PI / 2],\n 6: [Math.PI / 2, 0, 0],\n};\n\nconst PIP_LAYOUTS: Record<number, string[]> = {\n 1: [\"C\"],\n 2: [\"TL\", \"BR\"],\n 3: [\"TL\", \"C\", \"BR\"],\n 4: [\"TL\", \"TR\", \"BL\", \"BR\"],\n 5: [\"TL\", \"TR\", \"C\", \"BL\", \"BR\"],\n 6: [\"TL\", \"TR\", \"ML\", \"MR\", \"BL\", \"BR\"],\n};\nconst PIP_COORDS: Record<string, [number, number]> = {\n TL: [0.28, 0.28],\n TR: [0.72, 0.28],\n ML: [0.28, 0.5],\n C: [0.5, 0.5],\n MR: [0.72, 0.5],\n BL: [0.28, 0.72],\n BR: [0.72, 0.72],\n};\n\ninterface Tween {\n from: THREE.Vector3;\n to: THREE.Vector3;\n start: number;\n resolve: () => void;\n}\n\ninterface DiceTween {\n entries: {\n mesh: THREE.Mesh;\n from: THREE.Euler;\n to: THREE.Euler;\n rest: THREE.Euler;\n }[];\n start: number;\n resolve: () => void;\n}\n\ninterface Building {\n body: THREE.Mesh;\n roof: THREE.Mesh;\n}\n\n/**\n * Three.js renderer for the board: a square loop of tiles (colored + glyph-marked by type), the\n * player's city as towers that grow with their level, an animated token, and a pair of pip dice that\n * tumble to the rolled total. Engine-only — it knows nothing about the SDK.\n */\nexport class BoardController {\n private readonly renderer: THREE.WebGLRenderer;\n private readonly scene: THREE.Scene;\n private readonly camera: THREE.PerspectiveCamera;\n private readonly token: THREE.Mesh;\n private readonly tileGroup = new THREE.Group();\n private readonly cityGroup = new THREE.Group();\n private readonly lookAt = new THREE.Vector3(0, 0, 0);\n private readonly diceTextures: THREE.CanvasTexture[] = [];\n private readonly diceMaterials: THREE.MeshStandardMaterial[] = [];\n private readonly dice: THREE.Mesh[] = [];\n private readonly tileDecalTextures = new Map<string, THREE.CanvasTexture>();\n\n private tilePositions: THREE.Vector3[] = [];\n private tileMeshes: THREE.Mesh[] = [];\n private tileColors: THREE.Color[] = [];\n private buildings: Building[] = [];\n private tween: Tween | null = null;\n private diceTween: DiceTween | null = null;\n private highlight: { index: number; start: number } | null = null;\n private frame = 0;\n private disposed = false;\n private running = true;\n private resizeObserver?: ResizeObserver;\n\n private readonly loop = (): void => {\n if (this.disposed) return;\n this.frame = requestAnimationFrame(this.loop);\n\n if (this.tween) {\n const t = Math.min(1, (performance.now() - this.tween.start) / STEP_MS);\n const e = t * t * (3 - 2 * t); // smoothstep\n const { from, to } = this.tween;\n this.token.position.x = from.x + (to.x - from.x) * e;\n this.token.position.z = from.z + (to.z - from.z) * e;\n this.token.position.y = TOKEN_Y + Math.sin(t * Math.PI) * 0.6;\n if (t >= 1) {\n this.token.position.set(to.x, TOKEN_Y, to.z);\n const done = this.tween.resolve;\n this.tween = null;\n done();\n }\n }\n\n this.updateDice();\n this.updateHighlight();\n this.token.rotation.y += 0.02;\n\n // Gentle camera lean: drift the look-at point a fraction toward the token.\n this.lookAt.lerp(\n new THREE.Vector3(\n this.token.position.x * 0.35,\n 0,\n this.token.position.z * 0.35,\n ),\n 0.05,\n );\n this.camera.lookAt(this.lookAt);\n\n this.renderer.render(this.scene, this.camera);\n };\n\n constructor(private readonly host: HTMLElement) {\n this.renderer = new THREE.WebGLRenderer({ antialias: true });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.renderer.domElement.style.display = \"block\";\n host.appendChild(this.renderer.domElement);\n\n this.scene = new THREE.Scene();\n this.scene.background = new THREE.Color(0x16122b);\n\n this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);\n this.camera.position.set(0, 24, 19);\n this.camera.lookAt(0, 0, 0);\n\n const ambient = new THREE.AmbientLight(0xffffff, 0.75);\n const dir = new THREE.DirectionalLight(0xffffff, 0.85);\n dir.position.set(12, 22, 8);\n this.scene.add(ambient, dir, this.tileGroup, this.cityGroup);\n\n const tokenMat = new THREE.MeshStandardMaterial({\n color: 0xffd479,\n emissive: 0x6b5a1f,\n });\n this.token = new THREE.Mesh(\n new THREE.SphereGeometry(0.5, 24, 16),\n tokenMat,\n );\n this.token.position.set(0, TOKEN_Y, 0);\n this.scene.add(this.token);\n\n this.buildDice();\n\n this.resize();\n this.resizeObserver = new ResizeObserver(() => {\n this.resize();\n });\n this.resizeObserver.observe(host);\n\n this.loop();\n }\n\n // ---- dice ----\n\n private buildDice(): void {\n const textureByValue: Record<number, THREE.CanvasTexture> = {};\n for (let v = 1; v <= 6; v++) {\n const texture = new THREE.CanvasTexture(this.makePipCanvas(v));\n this.diceTextures.push(texture);\n textureByValue[v] = texture;\n }\n for (const value of FACE_VALUES) {\n this.diceMaterials.push(\n new THREE.MeshStandardMaterial({\n map: textureByValue[value],\n color: 0xffffff,\n }),\n );\n }\n\n const geo = new THREE.BoxGeometry(1.2, 1.2, 1.2);\n for (let i = 0; i < 2; i++) {\n const die = new THREE.Mesh(geo, this.diceMaterials);\n die.position.set(i === 0 ? -1.0 : 1.0, 3.2, 0);\n const rest = REST_ROTATION[3] ?? [0, 0, 0];\n die.rotation.set(rest[0], 0, rest[2]);\n this.scene.add(die);\n this.dice.push(die);\n }\n }\n\n private makePipCanvas(value: number): HTMLCanvasElement {\n const canvas = document.createElement(\"canvas\");\n canvas.width = 128;\n canvas.height = 128;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return canvas;\n const s = canvas.width;\n ctx.fillStyle = \"#f6f3ff\";\n ctx.fillRect(0, 0, s, s);\n ctx.strokeStyle = \"#d9d2f0\";\n ctx.lineWidth = 4;\n ctx.strokeRect(4, 4, s - 8, s - 8);\n ctx.fillStyle = \"#241d40\";\n for (const slot of PIP_LAYOUTS[value] ?? []) {\n const coord = PIP_COORDS[slot];\n if (!coord) continue;\n ctx.beginPath();\n ctx.arc(coord[0] * s, coord[1] * s, s * 0.1, 0, Math.PI * 2);\n ctx.fill();\n }\n return canvas;\n }\n\n private splitTotal(total: number): [number, number] {\n const clamped = Math.max(2, Math.min(12, Math.round(total)));\n const min1 = Math.max(1, clamped - 6);\n const max1 = Math.min(6, clamped - 1);\n const v1 = min1 + Math.floor(Math.random() * (max1 - min1 + 1));\n return [v1, clamped - v1];\n }\n\n /** Tumble both dice and land them showing two faces that sum to the rolled total. */\n rollDice(total: number): Promise<void> {\n const values = this.splitTotal(total);\n const entries = this.dice.map((mesh, i) => {\n const r = REST_ROTATION[values[i] ?? 3] ?? [0, 0, 0];\n const rest = new THREE.Euler(r[0], 0, r[2]);\n const turns = (n: number): number => Math.PI * 2 * n;\n const to = new THREE.Euler(\n rest.x + turns(2 + i),\n rest.y + turns(2),\n rest.z + turns(2 + i),\n );\n return { mesh, from: mesh.rotation.clone(), to, rest };\n });\n return new Promise<void>((resolve) => {\n this.diceTween = { entries, start: performance.now(), resolve };\n });\n }\n\n private updateDice(): void {\n if (!this.diceTween) return;\n const t = Math.min(1, (performance.now() - this.diceTween.start) / DICE_MS);\n const e = 1 - Math.pow(1 - t, 3); // easeOutCubic for the tumble\n const y = DICE_BASE_Y + DICE_DROP * (1 - bounceOut(t)); // drop + bounce for the toss\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.set(\n entry.from.x + (entry.to.x - entry.from.x) * e,\n entry.from.y + (entry.to.y - entry.from.y) * e,\n entry.from.z + (entry.to.z - entry.from.z) * e,\n );\n entry.mesh.position.y = y;\n }\n if (t >= 1) {\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.copy(entry.rest);\n entry.mesh.position.y = DICE_BASE_Y;\n }\n const done = this.diceTween.resolve;\n this.diceTween = null;\n done();\n }\n }\n\n // ---- board ----\n\n loadBoard(tiles: TileInfo[], buildingCount: number): void {\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.tilePositions = [];\n this.tileMeshes = [];\n this.tileColors = [];\n this.buildings = [];\n\n const count = tiles.length;\n const perSide = Math.max(1, Math.round(count / 4));\n const spacing = 1.4;\n const extent = perSide * spacing;\n const half = extent / 2;\n\n for (const tile of tiles) {\n const pos = this.ringPosition(tile.index, perSide, extent, half);\n const colorHex = TILE_COLORS[tile.type] ?? DEFAULT_TILE_COLOR;\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.1, 0.25, 1.1),\n new THREE.MeshStandardMaterial({ color: colorHex }),\n );\n mesh.position.set(pos.x, TILE_Y, pos.z);\n\n const symbol = TILE_SYMBOLS[tile.type];\n if (symbol) {\n const decal = new THREE.Mesh(\n new THREE.PlaneGeometry(0.7, 0.7),\n new THREE.MeshBasicMaterial({\n map: this.getDecalTexture(tile.type, symbol),\n transparent: true,\n }),\n );\n decal.rotation.x = -Math.PI / 2;\n decal.position.set(0, 0.14, 0);\n mesh.add(decal);\n }\n\n this.tileGroup.add(mesh);\n this.tilePositions[tile.index] = new THREE.Vector3(pos.x, TOKEN_Y, pos.z);\n this.tileMeshes[tile.index] = mesh;\n this.tileColors[tile.index] = new THREE.Color(colorHex);\n }\n\n const startX = -((buildingCount - 1) * 1.7) / 2;\n for (let i = 0; i < buildingCount; i++) {\n const color = BUILDING_COLORS[i % BUILDING_COLORS.length] ?? 0x6c5ce7;\n const body = new THREE.Mesh(\n new THREE.BoxGeometry(0.9, BODY_H, 0.9),\n new THREE.MeshStandardMaterial({ color }),\n );\n const roof = new THREE.Mesh(\n new THREE.BoxGeometry(1.05, 0.22, 1.05),\n new THREE.MeshStandardMaterial({\n color: new THREE.Color(color).multiplyScalar(0.6),\n }),\n );\n body.position.set(startX + i * 1.7, BODY_H / 2, 0);\n roof.position.set(startX + i * 1.7, BODY_H + 0.11, 0);\n this.cityGroup.add(body, roof);\n this.buildings[i] = { body, roof };\n }\n }\n\n private getDecalTexture(type: string, symbol: string): THREE.CanvasTexture {\n const cached = this.tileDecalTextures.get(type);\n if (cached) return cached;\n const canvas = document.createElement(\"canvas\");\n canvas.width = 96;\n canvas.height = 96;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n ctx.clearRect(0, 0, 96, 96);\n ctx.fillStyle = \"rgba(20,16,40,0.85)\";\n ctx.font = \"bold 64px system-ui, sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(symbol, 48, 52);\n }\n const texture = new THREE.CanvasTexture(canvas);\n this.tileDecalTextures.set(type, texture);\n return texture;\n }\n\n private ringPosition(\n index: number,\n perSide: number,\n extent: number,\n half: number,\n ): THREE.Vector3 {\n const side = Math.floor(index / perSide) % 4;\n const f = (index % perSide) / perSide;\n let x = 0;\n let z = 0;\n switch (side) {\n case 0:\n x = -half + f * extent;\n z = half;\n break;\n case 1:\n x = half;\n z = half - f * extent;\n break;\n case 2:\n x = half - f * extent;\n z = -half;\n break;\n default:\n x = -half;\n z = -half + f * extent;\n break;\n }\n return new THREE.Vector3(x, 0, z);\n }\n\n /** Start/stop the render loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs its RAF). */\n setRunning(running: boolean): void {\n const shouldRun = running && !this.disposed;\n if (shouldRun === this.running) return;\n this.running = shouldRun;\n if (shouldRun) this.frame = requestAnimationFrame(this.loop);\n else cancelAnimationFrame(this.frame);\n }\n\n /** Snap the token to a tile with no animation. */\n setPosition(index: number): void {\n const p = this.tilePositions[index];\n if (p) this.token.position.set(p.x, TOKEN_Y, p.z);\n }\n\n /** Animate the token through the given tile indices, one hop at a time. */\n async moveToken(path: number[]): Promise<void> {\n for (const idx of path) {\n const to = this.tilePositions[idx];\n if (!to) continue;\n await this.tweenTo(to);\n }\n }\n\n /** Pulse a tile (emissive + lift) to mark where the token landed. */\n highlightTile(index: number): void {\n if (this.tileMeshes[index])\n this.highlight = { index, start: performance.now() };\n }\n\n /** Grow the city towers to reflect each building's level. */\n setBuildingLevels(levels: number[]): void {\n for (let i = 0; i < this.buildings.length; i++) {\n const building = this.buildings[i];\n if (!building) continue;\n const level = levels[i] ?? 0;\n const scaleY = 0.5 + level * 0.9;\n const height = BODY_H * scaleY;\n building.body.scale.y = scaleY;\n building.body.position.y = height / 2;\n building.roof.position.y = height + 0.11;\n }\n }\n\n private updateHighlight(): void {\n if (!this.highlight) return;\n const mesh = this.tileMeshes[this.highlight.index];\n const baseColor = this.tileColors[this.highlight.index];\n if (!mesh || !baseColor) {\n this.highlight = null;\n return;\n }\n const mat = mesh.material as THREE.MeshStandardMaterial;\n const t = (performance.now() - this.highlight.start) / HIGHLIGHT_MS;\n if (t >= 1) {\n mat.emissive.setHex(0x000000);\n mat.emissiveIntensity = 1;\n mesh.position.y = TILE_Y;\n this.highlight = null;\n return;\n }\n const pulse = Math.sin(Math.min(1, t) * Math.PI); // 0 → 1 → 0\n mat.emissive.copy(baseColor);\n mat.emissiveIntensity = pulse * 0.9;\n mesh.position.y = TILE_Y + pulse * 0.35;\n }\n\n private tweenTo(to: THREE.Vector3): Promise<void> {\n return new Promise<void>((resolve) => {\n this.tween = {\n from: this.token.position.clone(),\n to: to.clone(),\n start: performance.now(),\n resolve,\n };\n });\n }\n\n private resize(): void {\n const w = this.host.clientWidth || 640;\n const h = this.host.clientHeight || 640;\n this.renderer.setSize(w, h, false);\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n }\n\n private disposeGroup(group: THREE.Group): void {\n group.traverse((obj) => {\n if (obj instanceof THREE.Mesh) {\n obj.geometry.dispose();\n const material = obj.material;\n if (Array.isArray(material)) material.forEach((m) => m.dispose());\n else material.dispose();\n }\n });\n group.clear();\n }\n\n destroy(): void {\n this.disposed = true;\n cancelAnimationFrame(this.frame);\n this.resizeObserver?.disconnect();\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.token.geometry.dispose();\n (this.token.material as THREE.Material).dispose();\n if (this.dice[0]) this.dice[0].geometry.dispose();\n this.diceMaterials.forEach((m) => m.dispose());\n this.diceTextures.forEach((t) => t.dispose());\n this.tileDecalTextures.forEach((t) => t.dispose());\n this.renderer.dispose();\n this.renderer.domElement.remove();\n }\n}\n"
|
|
113
|
+
"content": "import * as THREE from \"three\";\nimport type { TileInfo } from \"../data/boardConfig\";\n\nconst TILE_COLORS: Record<string, number> = {\n Chance: 0xf0a020,\n Reward: 0x4caf50,\n Special: 0x9c5cff,\n RandomAction: 0xe8534e,\n Shield: 0x4a90e2,\n};\nconst DEFAULT_TILE_COLOR = 0x6b6b7a;\n\n// Glyphs drawn on top of non-reward tiles so the board reads at a glance.\nconst TILE_SYMBOLS: Record<string, string> = {\n Chance: \"?\",\n Special: \"★\",\n RandomAction: \"✖\",\n Shield: \"✚\",\n};\n\nconst BUILDING_COLORS = [\n 0x6c5ce7, 0x4caf50, 0xe8534e, 0xf0a020, 0x4a90e2, 0x42c9c2,\n];\n\nconst TOKEN_Y = 0.85;\nconst TILE_Y = 0.13;\nconst STEP_MS = 240;\nconst HIGHLIGHT_MS = 850;\nconst BODY_H = 1.0;\nconst DICE_MS = 950;\nconst DICE_BASE_Y = 3.2;\nconst DICE_DROP = 3.4;\n\n// Standard bounce-out easing (0 → 1 with decaying bounces) for the dice toss.\nfunction bounceOut(t: number): number {\n const n1 = 7.5625;\n const d1 = 2.75;\n if (t < 1 / d1) return n1 * t * t;\n if (t < 2 / d1) {\n const u = t - 1.5 / d1;\n return n1 * u * u + 0.75;\n }\n if (t < 2.5 / d1) {\n const u = t - 2.25 / d1;\n return n1 * u * u + 0.9375;\n }\n const u = t - 2.625 / d1;\n return n1 * u * u + 0.984375;\n}\n\n// BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z. Assign pip values so opposite faces sum to 7.\nconst FACE_VALUES = [2, 5, 3, 4, 1, 6];\n// Resting rotation that brings each value's face to the top (+Y).\nconst REST_ROTATION: Record<number, [number, number, number]> = {\n 1: [-Math.PI / 2, 0, 0],\n 2: [0, 0, Math.PI / 2],\n 3: [0, 0, 0],\n 4: [Math.PI, 0, 0],\n 5: [0, 0, -Math.PI / 2],\n 6: [Math.PI / 2, 0, 0],\n};\n\nconst PIP_LAYOUTS: Record<number, string[]> = {\n 1: [\"C\"],\n 2: [\"TL\", \"BR\"],\n 3: [\"TL\", \"C\", \"BR\"],\n 4: [\"TL\", \"TR\", \"BL\", \"BR\"],\n 5: [\"TL\", \"TR\", \"C\", \"BL\", \"BR\"],\n 6: [\"TL\", \"TR\", \"ML\", \"MR\", \"BL\", \"BR\"],\n};\nconst PIP_COORDS: Record<string, [number, number]> = {\n TL: [0.28, 0.28],\n TR: [0.72, 0.28],\n ML: [0.28, 0.5],\n C: [0.5, 0.5],\n MR: [0.72, 0.5],\n BL: [0.28, 0.72],\n BR: [0.72, 0.72],\n};\n\ninterface Tween {\n from: THREE.Vector3;\n to: THREE.Vector3;\n start: number;\n resolve: () => void;\n}\n\ninterface DiceTween {\n entries: {\n mesh: THREE.Mesh;\n from: THREE.Euler;\n to: THREE.Euler;\n rest: THREE.Euler;\n }[];\n start: number;\n resolve: () => void;\n}\n\ninterface Building {\n body: THREE.Mesh;\n roof: THREE.Mesh;\n}\n\n/**\n * Three.js renderer for the board: a square loop of tiles (colored + glyph-marked by type), the\n * player's city as towers that grow with their level, an animated token, and a pair of pip dice that\n * tumble to the rolled total. Engine-only — it knows nothing about the SDK.\n */\nexport class BoardController {\n private readonly renderer: THREE.WebGLRenderer;\n private readonly scene: THREE.Scene;\n private readonly camera: THREE.PerspectiveCamera;\n private readonly token: THREE.Mesh;\n private readonly tileGroup = new THREE.Group();\n private readonly cityGroup = new THREE.Group();\n private readonly lookAt = new THREE.Vector3(0, 0, 0);\n private readonly diceTextures: THREE.CanvasTexture[] = [];\n private readonly diceMaterials: THREE.MeshStandardMaterial[] = [];\n private readonly dice: THREE.Mesh[] = [];\n private readonly tileDecalTextures = new Map<string, THREE.CanvasTexture>();\n\n private tilePositions: THREE.Vector3[] = [];\n private tileMeshes: THREE.Mesh[] = [];\n private tileColors: THREE.Color[] = [];\n private buildings: Building[] = [];\n private tween: Tween | null = null;\n private diceTween: DiceTween | null = null;\n private highlight: { index: number; start: number } | null = null;\n private frame = 0;\n private disposed = false;\n private running = true;\n private resizeObserver?: ResizeObserver;\n\n private readonly loop = (): void => {\n if (this.disposed) return;\n this.frame = requestAnimationFrame(this.loop);\n\n if (this.tween) {\n const t = Math.min(1, (performance.now() - this.tween.start) / STEP_MS);\n const e = t * t * (3 - 2 * t); // smoothstep\n const { from, to } = this.tween;\n this.token.position.x = from.x + (to.x - from.x) * e;\n this.token.position.z = from.z + (to.z - from.z) * e;\n this.token.position.y = TOKEN_Y + Math.sin(t * Math.PI) * 0.6;\n if (t >= 1) {\n this.token.position.set(to.x, TOKEN_Y, to.z);\n const done = this.tween.resolve;\n this.tween = null;\n done();\n }\n }\n\n this.updateDice();\n this.updateHighlight();\n this.token.rotation.y += 0.02;\n\n // Gentle camera lean: drift the look-at point a fraction toward the token.\n this.lookAt.lerp(\n new THREE.Vector3(\n this.token.position.x * 0.35,\n 0,\n this.token.position.z * 0.35,\n ),\n 0.05,\n );\n this.camera.lookAt(this.lookAt);\n\n this.renderer.render(this.scene, this.camera);\n };\n\n constructor(private readonly host: HTMLElement) {\n this.renderer = new THREE.WebGLRenderer({ antialias: true });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.renderer.domElement.style.display = \"block\";\n host.appendChild(this.renderer.domElement);\n\n this.scene = new THREE.Scene();\n this.scene.background = new THREE.Color(0x063d99);\n\n this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);\n this.camera.position.set(0, 24, 19);\n this.camera.lookAt(0, 0, 0);\n\n const ambient = new THREE.AmbientLight(0xffffff, 0.75);\n const dir = new THREE.DirectionalLight(0xffffff, 0.85);\n dir.position.set(12, 22, 8);\n this.scene.add(ambient, dir, this.tileGroup, this.cityGroup);\n\n const tokenMat = new THREE.MeshStandardMaterial({\n color: 0xffd479,\n emissive: 0x6b5a1f,\n });\n this.token = new THREE.Mesh(\n new THREE.SphereGeometry(0.5, 24, 16),\n tokenMat,\n );\n this.token.position.set(0, TOKEN_Y, 0);\n this.scene.add(this.token);\n\n this.buildDice();\n\n this.resize();\n this.resizeObserver = new ResizeObserver(() => {\n this.resize();\n });\n this.resizeObserver.observe(host);\n\n this.loop();\n }\n\n // ---- dice ----\n\n private buildDice(): void {\n const textureByValue: Record<number, THREE.CanvasTexture> = {};\n for (let v = 1; v <= 6; v++) {\n const texture = new THREE.CanvasTexture(this.makePipCanvas(v));\n this.diceTextures.push(texture);\n textureByValue[v] = texture;\n }\n for (const value of FACE_VALUES) {\n this.diceMaterials.push(\n new THREE.MeshStandardMaterial({\n map: textureByValue[value],\n color: 0xffffff,\n }),\n );\n }\n\n const geo = new THREE.BoxGeometry(1.2, 1.2, 1.2);\n for (let i = 0; i < 2; i++) {\n const die = new THREE.Mesh(geo, this.diceMaterials);\n die.position.set(i === 0 ? -1.0 : 1.0, 3.2, 0);\n const rest = REST_ROTATION[3] ?? [0, 0, 0];\n die.rotation.set(rest[0], 0, rest[2]);\n this.scene.add(die);\n this.dice.push(die);\n }\n }\n\n private makePipCanvas(value: number): HTMLCanvasElement {\n const canvas = document.createElement(\"canvas\");\n canvas.width = 128;\n canvas.height = 128;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return canvas;\n const s = canvas.width;\n ctx.fillStyle = \"#f6f3ff\";\n ctx.fillRect(0, 0, s, s);\n ctx.strokeStyle = \"#d9d2f0\";\n ctx.lineWidth = 4;\n ctx.strokeRect(4, 4, s - 8, s - 8);\n ctx.fillStyle = \"#052e7e\";\n for (const slot of PIP_LAYOUTS[value] ?? []) {\n const coord = PIP_COORDS[slot];\n if (!coord) continue;\n ctx.beginPath();\n ctx.arc(coord[0] * s, coord[1] * s, s * 0.1, 0, Math.PI * 2);\n ctx.fill();\n }\n return canvas;\n }\n\n private splitTotal(total: number): [number, number] {\n const clamped = Math.max(2, Math.min(12, Math.round(total)));\n const min1 = Math.max(1, clamped - 6);\n const max1 = Math.min(6, clamped - 1);\n const v1 = min1 + Math.floor(Math.random() * (max1 - min1 + 1));\n return [v1, clamped - v1];\n }\n\n /** Tumble both dice and land them showing two faces that sum to the rolled total. */\n rollDice(total: number): Promise<void> {\n const values = this.splitTotal(total);\n const entries = this.dice.map((mesh, i) => {\n const r = REST_ROTATION[values[i] ?? 3] ?? [0, 0, 0];\n const rest = new THREE.Euler(r[0], 0, r[2]);\n const turns = (n: number): number => Math.PI * 2 * n;\n const to = new THREE.Euler(\n rest.x + turns(2 + i),\n rest.y + turns(2),\n rest.z + turns(2 + i),\n );\n return { mesh, from: mesh.rotation.clone(), to, rest };\n });\n return new Promise<void>((resolve) => {\n this.diceTween = { entries, start: performance.now(), resolve };\n });\n }\n\n private updateDice(): void {\n if (!this.diceTween) return;\n const t = Math.min(1, (performance.now() - this.diceTween.start) / DICE_MS);\n const e = 1 - Math.pow(1 - t, 3); // easeOutCubic for the tumble\n const y = DICE_BASE_Y + DICE_DROP * (1 - bounceOut(t)); // drop + bounce for the toss\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.set(\n entry.from.x + (entry.to.x - entry.from.x) * e,\n entry.from.y + (entry.to.y - entry.from.y) * e,\n entry.from.z + (entry.to.z - entry.from.z) * e,\n );\n entry.mesh.position.y = y;\n }\n if (t >= 1) {\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.copy(entry.rest);\n entry.mesh.position.y = DICE_BASE_Y;\n }\n const done = this.diceTween.resolve;\n this.diceTween = null;\n done();\n }\n }\n\n // ---- board ----\n\n loadBoard(tiles: TileInfo[], buildingCount: number): void {\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.tilePositions = [];\n this.tileMeshes = [];\n this.tileColors = [];\n this.buildings = [];\n\n const count = tiles.length;\n const perSide = Math.max(1, Math.round(count / 4));\n const spacing = 1.4;\n const extent = perSide * spacing;\n const half = extent / 2;\n\n for (const tile of tiles) {\n const pos = this.ringPosition(tile.index, perSide, extent, half);\n const colorHex = TILE_COLORS[tile.type] ?? DEFAULT_TILE_COLOR;\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.1, 0.25, 1.1),\n new THREE.MeshStandardMaterial({ color: colorHex }),\n );\n mesh.position.set(pos.x, TILE_Y, pos.z);\n\n const symbol = TILE_SYMBOLS[tile.type];\n if (symbol) {\n const decal = new THREE.Mesh(\n new THREE.PlaneGeometry(0.7, 0.7),\n new THREE.MeshBasicMaterial({\n map: this.getDecalTexture(tile.type, symbol),\n transparent: true,\n }),\n );\n decal.rotation.x = -Math.PI / 2;\n decal.position.set(0, 0.14, 0);\n mesh.add(decal);\n }\n\n this.tileGroup.add(mesh);\n this.tilePositions[tile.index] = new THREE.Vector3(pos.x, TOKEN_Y, pos.z);\n this.tileMeshes[tile.index] = mesh;\n this.tileColors[tile.index] = new THREE.Color(colorHex);\n }\n\n const startX = -((buildingCount - 1) * 1.7) / 2;\n for (let i = 0; i < buildingCount; i++) {\n const color = BUILDING_COLORS[i % BUILDING_COLORS.length] ?? 0x6c5ce7;\n const body = new THREE.Mesh(\n new THREE.BoxGeometry(0.9, BODY_H, 0.9),\n new THREE.MeshStandardMaterial({ color }),\n );\n const roof = new THREE.Mesh(\n new THREE.BoxGeometry(1.05, 0.22, 1.05),\n new THREE.MeshStandardMaterial({\n color: new THREE.Color(color).multiplyScalar(0.6),\n }),\n );\n body.position.set(startX + i * 1.7, BODY_H / 2, 0);\n roof.position.set(startX + i * 1.7, BODY_H + 0.11, 0);\n this.cityGroup.add(body, roof);\n this.buildings[i] = { body, roof };\n }\n }\n\n private getDecalTexture(type: string, symbol: string): THREE.CanvasTexture {\n const cached = this.tileDecalTextures.get(type);\n if (cached) return cached;\n const canvas = document.createElement(\"canvas\");\n canvas.width = 96;\n canvas.height = 96;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n ctx.clearRect(0, 0, 96, 96);\n ctx.fillStyle = \"rgba(20,16,40,0.85)\";\n ctx.font = \"bold 64px system-ui, sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(symbol, 48, 52);\n }\n const texture = new THREE.CanvasTexture(canvas);\n this.tileDecalTextures.set(type, texture);\n return texture;\n }\n\n private ringPosition(\n index: number,\n perSide: number,\n extent: number,\n half: number,\n ): THREE.Vector3 {\n const side = Math.floor(index / perSide) % 4;\n const f = (index % perSide) / perSide;\n let x = 0;\n let z = 0;\n switch (side) {\n case 0:\n x = -half + f * extent;\n z = half;\n break;\n case 1:\n x = half;\n z = half - f * extent;\n break;\n case 2:\n x = half - f * extent;\n z = -half;\n break;\n default:\n x = -half;\n z = -half + f * extent;\n break;\n }\n return new THREE.Vector3(x, 0, z);\n }\n\n /** Start/stop the render loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs its RAF). */\n setRunning(running: boolean): void {\n const shouldRun = running && !this.disposed;\n if (shouldRun === this.running) return;\n this.running = shouldRun;\n if (shouldRun) this.frame = requestAnimationFrame(this.loop);\n else cancelAnimationFrame(this.frame);\n }\n\n /** Snap the token to a tile with no animation. */\n setPosition(index: number): void {\n const p = this.tilePositions[index];\n if (p) this.token.position.set(p.x, TOKEN_Y, p.z);\n }\n\n /**\n * What the scene currently shows. Read by the module's agent surface (`agent.ts`): a rendered\n * board is a single canvas, so this is the only way the AI Coder's agent can tell where the\n * token stands and whether the board was loaded at all.\n */\n describeScene(): {\n running: boolean;\n tileCount: number;\n buildingCount: number;\n token: { x: number; z: number };\n } {\n return {\n running: this.running,\n tileCount: this.tilePositions.length,\n buildingCount: this.buildings.length,\n token: {\n x: Math.round(this.token.position.x * 100) / 100,\n z: Math.round(this.token.position.z * 100) / 100,\n },\n };\n }\n\n /** Animate the token through the given tile indices, one hop at a time. */\n async moveToken(path: number[]): Promise<void> {\n for (const idx of path) {\n const to = this.tilePositions[idx];\n if (!to) continue;\n await this.tweenTo(to);\n }\n }\n\n /** Pulse a tile (emissive + lift) to mark where the token landed. */\n highlightTile(index: number): void {\n if (this.tileMeshes[index])\n this.highlight = { index, start: performance.now() };\n }\n\n /** Grow the city towers to reflect each building's level. */\n setBuildingLevels(levels: number[]): void {\n for (let i = 0; i < this.buildings.length; i++) {\n const building = this.buildings[i];\n if (!building) continue;\n const level = levels[i] ?? 0;\n const scaleY = 0.5 + level * 0.9;\n const height = BODY_H * scaleY;\n building.body.scale.y = scaleY;\n building.body.position.y = height / 2;\n building.roof.position.y = height + 0.11;\n }\n }\n\n private updateHighlight(): void {\n if (!this.highlight) return;\n const mesh = this.tileMeshes[this.highlight.index];\n const baseColor = this.tileColors[this.highlight.index];\n if (!mesh || !baseColor) {\n this.highlight = null;\n return;\n }\n const mat = mesh.material as THREE.MeshStandardMaterial;\n const t = (performance.now() - this.highlight.start) / HIGHLIGHT_MS;\n if (t >= 1) {\n mat.emissive.setHex(0x000000);\n mat.emissiveIntensity = 1;\n mesh.position.y = TILE_Y;\n this.highlight = null;\n return;\n }\n const pulse = Math.sin(Math.min(1, t) * Math.PI); // 0 → 1 → 0\n mat.emissive.copy(baseColor);\n mat.emissiveIntensity = pulse * 0.9;\n mesh.position.y = TILE_Y + pulse * 0.35;\n }\n\n private tweenTo(to: THREE.Vector3): Promise<void> {\n return new Promise<void>((resolve) => {\n this.tween = {\n from: this.token.position.clone(),\n to: to.clone(),\n start: performance.now(),\n resolve,\n };\n });\n }\n\n private resize(): void {\n const w = this.host.clientWidth || 640;\n const h = this.host.clientHeight || 640;\n this.renderer.setSize(w, h, false);\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n }\n\n private disposeGroup(group: THREE.Group): void {\n group.traverse((obj) => {\n if (obj instanceof THREE.Mesh) {\n obj.geometry.dispose();\n const material = obj.material;\n if (Array.isArray(material)) material.forEach((m) => m.dispose());\n else material.dispose();\n }\n });\n group.clear();\n }\n\n destroy(): void {\n this.disposed = true;\n cancelAnimationFrame(this.frame);\n this.resizeObserver?.disconnect();\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.token.geometry.dispose();\n (this.token.material as THREE.Material).dispose();\n if (this.dice[0]) this.dice[0].geometry.dispose();\n this.diceMaterials.forEach((m) => m.dispose());\n this.diceTextures.forEach((t) => t.dispose());\n this.tileDecalTextures.forEach((t) => t.dispose());\n this.renderer.dispose();\n this.renderer.domElement.remove();\n }\n}\n"
|
|
117
114
|
},
|
|
118
115
|
{
|
|
119
116
|
"path": "index.ts",
|
|
@@ -121,7 +118,7 @@
|
|
|
121
118
|
},
|
|
122
119
|
{
|
|
123
120
|
"path": "module.ts",
|
|
124
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createBoardScene } from \"./scene\";\nimport { makeBoardRootPanel } from \"./RootPanel\";\n\n// The Board Game feature module. `setup` wires the Three.js scene and the board's overlay UI to one\n// shared controller and registers a nav entry. No app shell, no client creation, no login.\nexport const boardGameModule: Module = defineModule({\n id: \"board-game\",\n meta: {\n name: \"Board Game\",\n type: \"game\",\n genre: \"board\",\n engine: \"three\",\n },\n setup(ctx) {\n const box = createControllerBox<BoardController>();\n ctx.registerScene(createBoardScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeBoardRootPanel(box),\n });\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" });\n },\n});\n"
|
|
121
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createBoardScene } from \"./scene\";\nimport { makeBoardRootPanel } from \"./RootPanel\";\n\n// The Board Game feature module. `setup` wires the Three.js scene and the board's overlay UI to one\n// shared controller and registers a nav entry. No app shell, no client creation, no login.\nexport const boardGameModule: Module = defineModule({\n id: \"board-game\",\n meta: {\n name: \"Board Game\",\n type: \"game\",\n genre: \"board\",\n engine: \"three\",\n },\n setup(ctx) {\n const box = createControllerBox<BoardController>();\n ctx.registerScene(createBoardScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeBoardRootPanel(box),\n });\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" });\n\n // Поверхность для агента. Здесь она НАМЕРЕННО тонкая: игровой интерфейс этого модуля —\n // обычный React в DOM, агент его и так читает деревом и нажимает кнопки как человек.\n // Дублировать эти же действия в actions значило бы завести второй способ играть, который\n // разъедется с настоящим. Из canvas'а агенту не видно только состояние сцены — его и отдаём.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? { mounted: true, scene: controller.describeScene(), ui: \"dom\" }\n : { mounted: false };\n },\n describeActions: {},\n });\n },\n});\n"
|
|
125
122
|
},
|
|
126
123
|
{
|
|
127
124
|
"path": "react/context.tsx",
|
|
@@ -37,43 +37,40 @@
|
|
|
37
37
|
"version": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.1.
|
|
42
|
-
"@idosgames/react": "0.1.
|
|
43
|
-
"@idosgames/wallet": "0.1.
|
|
44
|
-
"@solana/wallet-adapter-base": "0.9.27",
|
|
45
|
-
"@solana/wallet-adapter-react": "0.15.39",
|
|
40
|
+
"@idosgames/core": "0.2.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.1.3",
|
|
42
|
+
"@idosgames/react": "0.1.1",
|
|
43
|
+
"@idosgames/wallet": "0.1.13",
|
|
46
44
|
"@tanstack/react-query": "5.101.2",
|
|
47
45
|
"phaser": "4.2.1",
|
|
48
46
|
"react": "19.2.7",
|
|
49
|
-
"react-dom": "19.2.7",
|
|
50
47
|
"viem": "2.55.2",
|
|
51
48
|
"wagmi": "3.7.2"
|
|
52
49
|
},
|
|
53
50
|
"files": [
|
|
54
51
|
{
|
|
55
52
|
"path": "components/CharacterDetail.tsx",
|
|
56
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { useUserState } from \"@idosgames/react\";\nimport { useStatus } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n resolveStats,\n computeStatValue,\n computeUpgradeCost,\n} from \"../data/characterConfig\";\n\nfunction formatStat(value: number): string {\n return Number.isInteger(value) ? String(value) : value.toFixed(2);\n}\n\nconst panel: CSSProperties = {\n border: \"1px solid #
|
|
53
|
+
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { useUserState } from \"@idosgames/react\";\nimport { useStatus } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n resolveStats,\n computeStatValue,\n computeUpgradeCost,\n} from \"../data/characterConfig\";\n\nfunction formatStat(value: number): string {\n return Number.isInteger(value) ? String(value) : value.toFixed(2);\n}\n\nconst panel: CSSProperties = {\n border: \"1px solid #1e59c8\",\n background: \"#04276b\",\n borderRadius: 12,\n padding: 16,\n minWidth: 240,\n color: \"#fff\",\n};\nconst button: CSSProperties = {\n background: \"#0d66fe\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"8px 14px\",\n cursor: \"pointer\",\n fontWeight: 600,\n};\nconst statRow: CSSProperties = {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n padding: \"4px 0\",\n};\n\nexport function CharacterDetail({\n characterID,\n}: {\n characterID: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const { setStatus } = useStatus();\n const [busy, setBusy] = useState(false);\n\n if (!characterID)\n return <div style={panel}>Pick a character to see details.</div>;\n\n const config = readCharacterConfig(client);\n const def = config.Definitions?.[characterID];\n const character: CharacterModel | undefined =\n state?.Character?.Characters?.[characterID];\n const availability = resolveAvailability(def, character !== undefined);\n const name = def?.Identity?.DisplayName ?? characterID;\n const subtitle = [def?.Classification?.RarityID, def?.Classification?.ClassID]\n .filter(Boolean)\n .join(\" · \");\n\n const run = async (\n label: string,\n action: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n const result = await action();\n setStatus(\n result.ok ? `${label} ✓` : `${label} failed: ${result.error}`,\n result.ok ? \"success\" : \"error\",\n );\n setBusy(false);\n };\n\n const header = (\n <>\n <div style={{ fontWeight: 700, fontSize: 18 }}>{name}</div>\n {subtitle ? (\n <div style={{ opacity: 0.7, fontSize: 12, marginTop: 2 }}>\n {subtitle}\n </div>\n ) : null}\n </>\n );\n\n // Locked: offer unlock.\n if (availability === \"locked\") {\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.7, margin: \"10px 0\" }}>\n Locked — unlock to add it to your roster.\n </div>\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Unlock\", () =>\n client.character.unlockCharacter(characterID),\n )\n }\n >\n Unlock\n </button>\n </div>\n );\n }\n\n // Available (unlocked by default, no model yet): first level-up instantiates it.\n if (availability === \"available\") {\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.7, margin: \"10px 0\" }}>\n Unlocked by default. Level up to instantiate this character.\n </div>\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Level up\", () =>\n client.character.upgradeCharacterLevel(characterID),\n )\n }\n >\n Level up\n </button>\n </div>\n );\n }\n\n // Owned: full controls.\n const statDefs = resolveStats(config, def);\n const statLevels: Record<string, number> = character?.StatLevels ?? {};\n\n return (\n <div style={panel}>\n {header}\n <div style={{ opacity: 0.85, margin: \"8px 0 12px\" }}>\n Level {character?.Level ?? 0} · Power {character?.Power ?? 0}\n </div>\n\n <button\n type=\"button\"\n style={{ ...button, opacity: busy ? 0.6 : 1 }}\n disabled={busy}\n onClick={() =>\n void run(\"Level up\", () =>\n client.character.upgradeCharacterLevel(characterID),\n )\n }\n >\n Upgrade level\n </button>\n\n <div style={{ marginTop: 16, fontWeight: 600, opacity: 0.8 }}>Stats</div>\n {statDefs.length === 0 ? (\n <div style={{ opacity: 0.5, fontSize: 13 }}>no stats configured</div>\n ) : (\n statDefs.map((stat) => {\n const level = statLevels[stat.statID] ?? 0;\n const value = computeStatValue(stat, level);\n const atMax = stat.maxLevel != null && level >= stat.maxLevel;\n const cost = computeUpgradeCost(stat, level + 1);\n return (\n <div key={stat.statID} style={statRow}>\n <span>\n {stat.displayName}: <b>{formatStat(value)}</b>\n <span style={{ opacity: 0.5 }}>\n {\" \"}\n · Lv {level}\n {stat.maxLevel ? `/${stat.maxLevel}` : \"\"}\n </span>\n </span>\n <span style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n {!atMax && cost > 0 ? (\n <span style={{ fontSize: 11, opacity: 0.6 }}>\n {cost} {stat.costCurrency}\n </span>\n ) : null}\n <button\n type=\"button\"\n style={{\n ...button,\n padding: \"2px 10px\",\n background: \"#1e59c8\",\n opacity: busy || atMax ? 0.5 : 1,\n }}\n disabled={busy || atMax}\n onClick={() =>\n void run(`Upgrade ${stat.displayName}`, () =>\n client.character.upgradeStatLevel(\n characterID,\n stat.statID,\n ),\n )\n }\n >\n {atMax ? \"MAX\" : \"+\"}\n </button>\n </span>\n </div>\n );\n })\n )}\n </div>\n );\n}\n"
|
|
57
54
|
},
|
|
58
55
|
{
|
|
59
56
|
"path": "components/CharacterRoster.tsx",
|
|
60
|
-
"content": "import {\n useEffect,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n type Availability,\n} from \"../data/characterConfig\";\n\nexport interface RosterEntry {\n id: string;\n name: string;\n rarity?: string;\n className?: string;\n availability: Availability;\n level?: number;\n power?: number;\n}\n\n/** Loads character definitions + the player's characters, merges them into a roster, and keeps\n * it live across cache updates. */\nexport function useRoster(): { entries: RosterEntry[]; loading: boolean } {\n const client = useIDosGamesClient();\n const [loading, setLoading] = useState(true);\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.character.getCharacterDefinitions();\n await client.character.getUserCharacters();\n if (active) setLoading(false);\n })();\n const off = client.on(\"user:anyUpdated\", () => {\n force();\n });\n return () => {\n active = false;\n off();\n };\n }, [client]);\n\n const config = readCharacterConfig(client);\n const defs = config.Definitions ?? {};\n const owned = client.data.user.state?.Character?.Characters ?? {};\n const ids = new Set<string>([...Object.keys(defs), ...Object.keys(owned)]);\n\n const entries: RosterEntry[] = [...ids]\n .map((id) => {\n const def = defs[id];\n const character: CharacterModel | undefined = owned[id];\n const entry: RosterEntry = {\n id,\n name: def?.Identity?.DisplayName ?? id,\n rarity: def?.Classification?.RarityID,\n className: def?.Classification?.ClassID,\n availability: resolveAvailability(def, character !== undefined),\n level: character?.Level ?? undefined,\n power: character?.Power ?? undefined,\n };\n return { entry, sortOrder: def?.Identity?.SortOrder ?? 999 };\n })\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((x) => x.entry);\n\n return { entries, loading };\n}\n\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n};\n\nconst statusStyle: Record<Availability, CSSProperties> = {\n owned: {\n color: \"#ffffff\",\n background: \"#
|
|
57
|
+
"content": "import {\n useEffect,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n type Availability,\n} from \"../data/characterConfig\";\n\nexport interface RosterEntry {\n id: string;\n name: string;\n rarity?: string;\n className?: string;\n availability: Availability;\n level?: number;\n power?: number;\n}\n\n/** Loads character definitions + the player's characters, merges them into a roster, and keeps\n * it live across cache updates. */\nexport function useRoster(): { entries: RosterEntry[]; loading: boolean } {\n const client = useIDosGamesClient();\n const [loading, setLoading] = useState(true);\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.character.getCharacterDefinitions();\n await client.character.getUserCharacters();\n if (active) setLoading(false);\n })();\n const off = client.on(\"user:anyUpdated\", () => {\n force();\n });\n return () => {\n active = false;\n off();\n };\n }, [client]);\n\n const config = readCharacterConfig(client);\n const defs = config.Definitions ?? {};\n const owned = client.data.user.state?.Character?.Characters ?? {};\n const ids = new Set<string>([...Object.keys(defs), ...Object.keys(owned)]);\n\n const entries: RosterEntry[] = [...ids]\n .map((id) => {\n const def = defs[id];\n const character: CharacterModel | undefined = owned[id];\n const entry: RosterEntry = {\n id,\n name: def?.Identity?.DisplayName ?? id,\n rarity: def?.Classification?.RarityID,\n className: def?.Classification?.ClassID,\n availability: resolveAvailability(def, character !== undefined),\n level: character?.Level ?? undefined,\n power: character?.Power ?? undefined,\n };\n return { entry, sortOrder: def?.Identity?.SortOrder ?? 999 };\n })\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((x) => x.entry);\n\n return { entries, loading };\n}\n\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n};\n\nconst statusStyle: Record<Availability, CSSProperties> = {\n owned: {\n color: \"#ffffff\",\n background: \"#052e7e\",\n border: \"1px solid #1e59c8\",\n },\n available: {\n color: \"#cfe0ff\",\n background: \"#063073\",\n border: \"1px solid #3b78e0\",\n },\n locked: {\n color: \"#a9c6ff\",\n background: \"#04276b\",\n border: \"1px solid #1e59c8\",\n },\n};\n\nfunction card(availability: Availability, selected: boolean): CSSProperties {\n return {\n textAlign: \"left\",\n borderRadius: 12,\n padding: 12,\n cursor: \"pointer\",\n ...statusStyle[availability],\n ...(selected ? { border: \"2px solid #ffd479\" } : {}),\n };\n}\n\nexport function rosterStatusLine(entry: RosterEntry): string {\n switch (entry.availability) {\n case \"owned\":\n return `Lv ${entry.level ?? 0} · Power ${entry.power ?? 0}`;\n case \"available\":\n return \"Available\";\n case \"locked\":\n return \"Locked\";\n }\n}\n\nexport function CharacterRoster({\n selectedID,\n onSelect,\n}: {\n selectedID: string | null;\n onSelect: (entry: RosterEntry) => void;\n}): ReactNode {\n const { entries, loading } = useRoster();\n if (loading) return <div style={{ opacity: 0.6 }}>Loading characters…</div>;\n if (entries.length === 0)\n return <div style={{ opacity: 0.6 }}>No characters configured.</div>;\n\n return (\n <div style={grid}>\n {entries.map((entry) => (\n <button\n key={entry.id}\n type=\"button\"\n style={card(entry.availability, entry.id === selectedID)}\n onClick={() => {\n onSelect(entry);\n }}\n >\n <div style={{ fontWeight: 700, marginBottom: 4 }}>{entry.name}</div>\n <div style={{ fontSize: 11, opacity: 0.7 }}>\n {[entry.rarity, entry.className].filter(Boolean).join(\" · \")}\n </div>\n <div style={{ fontSize: 13, marginTop: 6 }}>\n {rosterStatusLine(entry)}\n </div>\n </button>\n ))}\n </div>\n );\n}\n"
|
|
61
58
|
},
|
|
62
59
|
{
|
|
63
60
|
"path": "components/CurrencyHud.tsx",
|
|
64
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useUserState } from \"@idosgames/react\";\n\nconst bar: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n padding: \"8px 0\",\n};\nconst chip: CSSProperties = {\n background: \"#
|
|
61
|
+
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useUserState } from \"@idosgames/react\";\n\nconst bar: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n padding: \"8px 0\",\n};\nconst chip: CSSProperties = {\n background: \"#052e7e\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n};\n\nexport function CurrencyHud(): ReactNode {\n const state = useUserState();\n const currencies = state?.InventoryV2?.VirtualCurrencies ?? {};\n const entries = Object.entries(currencies);\n return (\n <div style={bar}>\n {entries.length === 0 ? (\n <span style={{ opacity: 0.5, fontSize: 13 }}>no currencies</span>\n ) : (\n entries.map(([id, currency]) => (\n <span key={id} style={chip}>\n {id}: {currency.Amount ?? 0}\n </span>\n ))\n )}\n </div>\n );\n}\n"
|
|
65
62
|
},
|
|
66
63
|
{
|
|
67
64
|
"path": "components/EquipmentPanel.tsx",
|
|
68
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type {\n EquippedItem,\n EquipSlotPair,\n UnstackableItemInstanceState,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { useUserState } from \"@idosgames/react\";\nimport { useStatus } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n resolveSlots,\n} from \"../data/characterConfig\";\n\nconst panel: CSSProperties = {\n border: \"1px solid #
|
|
65
|
+
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type {\n EquippedItem,\n EquipSlotPair,\n UnstackableItemInstanceState,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { useUserState } from \"@idosgames/react\";\nimport { useStatus } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n resolveSlots,\n} from \"../data/characterConfig\";\n\nconst panel: CSSProperties = {\n border: \"1px solid #1e59c8\",\n background: \"#04276b\",\n borderRadius: 12,\n padding: 16,\n color: \"#fff\",\n};\nconst slotRow: CSSProperties = {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n padding: \"6px 0\",\n borderBottom: \"1px solid #1a4fb8\",\n};\nconst smallButton: CSSProperties = {\n background: \"#1e59c8\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 6,\n padding: \"3px 10px\",\n cursor: \"pointer\",\n fontSize: 12,\n fontWeight: 600,\n};\n\nfunction equippedSlotID(\n item: UnstackableItemInstanceState,\n): string | undefined {\n const slot = item.EquippedSlot as { SlotID?: string } | null | undefined;\n return slot?.SlotID ?? undefined;\n}\n\nexport function EquipmentPanel({\n characterID,\n}: {\n characterID: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const { setStatus } = useStatus();\n const [busy, setBusy] = useState(false);\n const [pickSlot, setPickSlot] = useState<string | null>(null);\n\n if (!characterID) return null;\n\n const config = readCharacterConfig(client);\n const def = config.Definitions?.[characterID];\n const slots = resolveSlots(config, def);\n if (slots.length === 0) return null;\n\n const character = state?.Character?.Characters?.[characterID];\n const owned = resolveAvailability(def, character !== undefined) === \"owned\";\n const level = character?.Level ?? 0;\n const equipment: Record<string, EquippedItem> = character?.Equipment ?? {};\n const equippedCount = Object.keys(equipment).length;\n\n const unstackables: Record<string, UnstackableItemInstanceState> =\n state?.InventoryV2?.UnstackableItems ?? {};\n const freeItems = Object.values(unstackables).filter(\n (item) => equippedSlotID(item) === undefined,\n );\n\n const run = async (\n label: string,\n action: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n const result = await action();\n setStatus(\n result.ok ? `${label} ✓` : `${label} failed: ${result.error}`,\n result.ok ? \"success\" : \"error\",\n );\n setBusy(false);\n setPickSlot(null);\n };\n\n const equip = (slotID: string, item: UnstackableItemInstanceState): void => {\n const pair: EquipSlotPair = {\n SlotID: slotID,\n ItemID: item.ItemID,\n CatalogID: item.CatalogID ?? undefined,\n };\n void run(`Equip ${item.ItemID} → ${slotID}`, () =>\n client.character.equipItems(characterID, [pair]),\n );\n };\n const unequip = (slotID: string): void => {\n void run(`Unequip ${slotID}`, () =>\n client.character.unequipItems(characterID, [slotID]),\n );\n };\n const unequipAll = (): void => {\n void run(\"Unequip all\", () => client.character.unequipAllCharacters());\n };\n\n return (\n <div style={panel}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n }}\n >\n <div style={{ fontWeight: 700 }}>Equipment</div>\n <button\n type=\"button\"\n style={{\n ...smallButton,\n opacity: busy || equippedCount === 0 ? 0.5 : 1,\n }}\n disabled={busy || equippedCount === 0}\n onClick={unequipAll}\n >\n Unequip all\n </button>\n </div>\n\n {!owned ? (\n <div style={{ opacity: 0.6, fontSize: 12, margin: \"8px 0\" }}>\n Slots from config. Equip becomes available once the character is\n owned.\n </div>\n ) : null}\n\n <div style={{ marginTop: 8 }}>\n {slots.map((slot) => {\n const slotLocked = level < slot.minCharacterLevel;\n const equipped: EquippedItem | undefined = equipment[slot.slotID];\n const hasItem = !!equipped?.ItemInstanceID || !!equipped?.ItemID;\n return (\n <div key={slot.slotID} style={slotRow}>\n <div>\n <div style={{ fontWeight: 600 }}>{slot.slotID}</div>\n {slotLocked ? (\n <div style={{ fontSize: 11, color: \"#ff9f6b\" }}>\n Locked · needs Lv {slot.minCharacterLevel}\n </div>\n ) : (\n <div style={{ fontSize: 12, opacity: 0.7 }}>\n {hasItem ? equipped?.ItemID : \"Empty\"}\n </div>\n )}\n </div>\n {!slotLocked && hasItem ? (\n <button\n type=\"button\"\n style={smallButton}\n disabled={busy}\n onClick={() => unequip(slot.slotID)}\n >\n Unequip\n </button>\n ) : null}\n {!slotLocked && !hasItem && owned ? (\n <button\n type=\"button\"\n style={{ ...smallButton, background: \"#0d66fe\" }}\n disabled={busy}\n onClick={() =>\n setPickSlot(pickSlot === slot.slotID ? null : slot.slotID)\n }\n >\n {pickSlot === slot.slotID ? \"Cancel\" : \"Equip\"}\n </button>\n ) : null}\n </div>\n );\n })}\n </div>\n\n {pickSlot ? (\n <div style={{ marginTop: 10 }}>\n <div style={{ fontSize: 12, opacity: 0.8, marginBottom: 6 }}>\n Choose an item for {pickSlot}:\n </div>\n {freeItems.length === 0 ? (\n <div style={{ opacity: 0.5, fontSize: 12 }}>\n No equippable items in inventory.\n </div>\n ) : (\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 6 }}>\n {freeItems.map((item) => (\n <button\n key={item.ItemInstanceID}\n type=\"button\"\n style={smallButton}\n disabled={busy}\n onClick={() => equip(pickSlot, item)}\n >\n {item.ItemID} · Lv {Math.max(1, item.Level ?? 1)}\n </button>\n ))}\n </div>\n )}\n </div>\n ) : null}\n </div>\n );\n}\n"
|
|
69
66
|
},
|
|
70
67
|
{
|
|
71
68
|
"path": "components/StatusBar.tsx",
|
|
72
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"@idosgames/react\";\n\nconst colors: Record<string, string> = {\n info: \"#
|
|
69
|
+
"content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useStatus } from \"@idosgames/react\";\n\nconst colors: Record<string, string> = {\n info: \"#a9c6ff\",\n success: \"#5ad19a\",\n error: \"#ff6b6b\",\n};\n\nconst bar: CSSProperties = {\n marginTop: 12,\n minHeight: 20,\n fontSize: 13,\n fontFamily: \"system-ui, sans-serif\",\n};\n\nexport function StatusBar(): ReactNode {\n const { message } = useStatus();\n if (!message) return <div style={bar} />;\n return (\n <div style={{ ...bar, color: colors[message.kind] ?? \"#fff\" }}>\n {message.kind === \"error\" ? \"⚠ \" : message.kind === \"success\" ? \"✓ \" : \"\"}\n {message.text}\n </div>\n );\n}\n"
|
|
73
70
|
},
|
|
74
71
|
{
|
|
75
72
|
"path": "components/WalletPanel.tsx",
|
|
76
|
-
"content": "import {\n useEffect,\n useMemo,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { parseUnits } from \"viem\";\nimport {\n arbitrum,\n base,\n bsc,\n mainnet,\n optimism,\n polygon,\n polygonAmoy,\n sepolia,\n} from \"viem/chains\";\nimport { useAccount, useConnect, useDisconnect, useSwitchChain } from \"wagmi\";\nimport {\n createEvmWalletConfig,\n IDosGamesWalletProvider,\n useEvmBridge,\n} from \"@idosgames/wallet/react\";\nimport type { BridgeResult } from \"@idosgames/wallet\";\nimport type {\n BlockchainNetworkDefinition,\n CryptoCurrencyDefinition,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { ENV_WALLETCONNECT_PROJECT_ID } from \"../env\";\n\n// A curated EVM chain set for the demo — enough to cover the networks a title is likely to use.\n// wagmi needs at least one chain up front; the actual network you deposit to comes from the\n// title's blockchain config (the picker below), and we switch the wallet's chain to match.\nconst SUPPORTED_CHAINS = [\n mainnet,\n polygon,\n bsc,\n arbitrum,\n base,\n optimism,\n sepolia,\n polygonAmoy,\n] as const;\n\n// Built once. Set VITE_WALLETCONNECT_PROJECT_ID (get one at cloud.walletconnect.com) to enable\n// MOBILE wallets via the WalletConnect QR/deep-link modal; without it, browser extensions still work.\nconst wagmiConfig = createEvmWalletConfig({\n chains: SUPPORTED_CHAINS,\n walletConnectProjectId: ENV_WALLETCONNECT_PROJECT_ID || undefined,\n appName: \"iDosGames Idle RPG\",\n});\n\nconst card: CSSProperties = {\n background: \"#181334\",\n border: \"1px solid #2c2650\",\n borderRadius: 12,\n padding: 16,\n color: \"#fff\",\n width: \"100%\",\n boxSizing: \"border-box\",\n fontFamily: \"system-ui, sans-serif\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 10,\n};\nconst title: CSSProperties = { fontWeight: 700, fontSize: 16 };\nconst label: CSSProperties = { fontSize: 12, opacity: 0.7 };\nconst input: CSSProperties = {\n background: \"#241d40\",\n border: \"1px solid #34294f\",\n borderRadius: 8,\n color: \"#fff\",\n padding: \"8px 10px\",\n width: \"100%\",\n boxSizing: \"border-box\",\n};\nconst primary: CSSProperties = {\n background: \"#6c5ce7\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"9px 14px\",\n fontWeight: 700,\n cursor: \"pointer\",\n};\nconst ghost: CSSProperties = {\n background: \"#34294f\",\n color: \"#fff\",\n border: \"none\",\n borderRadius: 8,\n padding: \"7px 12px\",\n fontWeight: 600,\n cursor: \"pointer\",\n};\nconst row: CSSProperties = { display: \"flex\", gap: 8 };\n\n/** Public entry: the wallet screen wrapped in its own wagmi/react-query provider. */\nexport function WalletPanel(): ReactNode {\n return (\n <IDosGamesWalletProvider wagmiConfig={wagmiConfig}>\n <WalletPanelInner />\n </IDosGamesWalletProvider>\n );\n}\n\nfunction WalletPanelInner(): ReactNode {\n const client = useIDosGamesClient();\n // The title comes from the host's client, never re-derived here: a module that resolved its own\n // title could disagree with the host and bridge deposits into a different title's wallet.\n const bridge = useEvmBridge(client, client.titleID);\n const { address, isConnected, chainId } = useAccount();\n const { connect, connectors } = useConnect();\n const { disconnect } = useDisconnect();\n const { switchChainAsync } = useSwitchChain();\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n const [networks, setNetworks] = useState<\n Record<string, BlockchainNetworkDefinition>\n >({});\n const [currencies, setCurrencies] = useState<\n Record<string, CryptoCurrencyDefinition>\n >({});\n const [networkID, setNetworkID] = useState<string>(\"\");\n const [currencyID, setCurrencyID] = useState<string>(\"\");\n const [amount, setAmount] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [result, setResult] = useState<BridgeResult<unknown> | null>(null);\n\n // Load blockchain config + on-chain state once; re-render on cache changes for the balance.\n useEffect(() => {\n let active = true;\n void (async () => {\n const defs = await client.blockchain.getDefinitions();\n await client.blockchain.getUserState();\n if (!active || !defs.ok) return;\n const nets = defs.data.Blockchain?.Networks ?? {};\n const evmNets: Record<string, BlockchainNetworkDefinition> = {};\n for (const [id, net] of Object.entries(nets))\n if (net.Type === \"EVM\") evmNets[id] = net;\n setNetworks(evmNets);\n setCurrencies(defs.data.CryptoCurrencies ?? {});\n const firstNet = Object.keys(evmNets)[0] ?? \"\";\n setNetworkID(firstNet);\n })();\n return () => {\n active = false;\n };\n }, [client]);\n\n useEffect(() => client.on(\"user:anyUpdated\", force), [client]);\n\n // Currencies that have an ERC-20 binding on the selected network (a contract we can deposit).\n const eligibleCurrencies = useMemo(() => {\n return Object.entries(currencies).filter(([, def]) =>\n def.Networks?.some(\n (b) => b.NetworkID === networkID && !!b.ContractAddress,\n ),\n );\n }, [currencies, networkID]);\n\n useEffect(() => {\n const first = eligibleCurrencies[0]?.[0] ?? \"\";\n setCurrencyID((prev) =>\n eligibleCurrencies.some(([id]) => id === prev) ? prev : first,\n );\n }, [eligibleCurrencies]);\n\n const network = networkID ? networks[networkID] : undefined;\n const currency = currencyID ? currencies[currencyID] : undefined;\n const binding = currency?.Networks?.find((b) => b.NetworkID === networkID);\n const balance = currencyID\n ? client.data.user.getCryptoCurrencyAmount(currencyID)\n : \"0\";\n\n async function ensureChain(): Promise<boolean> {\n if (!network?.ChainID || chainId === network.ChainID) return true;\n try {\n await switchChainAsync({ chainId: network.ChainID });\n return true;\n } catch {\n setResult({\n ok: false,\n stage: \"approve\",\n error: `Switch your wallet to chain ${network.ChainID} to continue.`,\n });\n return false;\n }\n }\n\n async function runDeposit(): Promise<void> {\n if (!network || !binding?.ContractAddress) return;\n setBusy(true);\n setResult(null);\n if (await ensureChain()) {\n const decimals = binding.Decimals ?? 18;\n let raw: bigint;\n try {\n raw = parseUnits(amount || \"0\", decimals);\n } catch {\n setResult({ ok: false, stage: \"approve\", error: \"Invalid amount.\" });\n setBusy(false);\n return;\n }\n const res = await bridge.depositToken({\n network,\n tokenAddress: binding.ContractAddress as `0x${string}`,\n amount: raw,\n });\n setResult(res);\n if (res.ok) await client.blockchain.getUserState();\n }\n setBusy(false);\n }\n\n async function runWithdraw(): Promise<void> {\n if (!network || !bridge.account) return;\n setBusy(true);\n setResult(null);\n if (await ensureChain()) {\n const res = await bridge.withdrawToken({\n currencyID,\n networkID,\n walletAddress: bridge.account,\n amount: amount || \"0\",\n });\n setResult(res);\n if (res.ok) await client.blockchain.getUserState();\n }\n setBusy(false);\n }\n\n return (\n <div style={card}>\n <span style={title}>Crypto wallet</span>\n\n {/* Connect */}\n {isConnected ? (\n <div style={row}>\n <span style={{ ...label, flex: 1, alignSelf: \"center\" }}>\n {address?.slice(0, 6)}…{address?.slice(-4)}\n </span>\n <button type=\"button\" style={ghost} onClick={() => disconnect()}>\n Disconnect\n </button>\n </div>\n ) : (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 6 }}>\n <span style={label}>Connect a browser or mobile wallet</span>\n {connectors.map((c) => (\n <button\n key={c.uid}\n type=\"button\"\n style={ghost}\n onClick={() => connect({ connector: c })}\n >\n {c.name}\n </button>\n ))}\n </div>\n )}\n\n {Object.keys(networks).length === 0 ? (\n <span style={label}>No EVM networks configured for this title.</span>\n ) : (\n <>\n <div>\n <div style={label}>Network</div>\n <select\n style={input}\n value={networkID}\n onChange={(e) => setNetworkID(e.target.value)}\n >\n {Object.entries(networks).map(([id, net]) => (\n <option key={id} value={id}>\n {net.DisplayName ?? id}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <div style={label}>Token</div>\n <select\n style={input}\n value={currencyID}\n onChange={(e) => setCurrencyID(e.target.value)}\n >\n {eligibleCurrencies.length === 0 && (\n <option value=\"\">— no depositable tokens —</option>\n )}\n {eligibleCurrencies.map(([id, def]) => (\n <option key={id} value={id}>\n {def.DisplayName ?? id}\n </option>\n ))}\n </select>\n </div>\n\n <div style={label}>\n In-game balance: <b>{balance}</b> {currencyID}\n </div>\n\n <div>\n <div style={label}>Amount</div>\n <input\n style={input}\n inputMode=\"decimal\"\n placeholder=\"0.0\"\n value={amount}\n onChange={(e) => setAmount(e.target.value)}\n />\n </div>\n\n <div style={row}>\n <button\n type=\"button\"\n style={{ ...primary, flex: 1, opacity: busy ? 0.6 : 1 }}\n disabled={busy || !isConnected || !binding?.ContractAddress}\n onClick={() => void runDeposit()}\n >\n Deposit\n </button>\n <button\n type=\"button\"\n style={{ ...ghost, flex: 1, opacity: busy ? 0.6 : 1 }}\n disabled={busy || !isConnected || !currencyID}\n onClick={() => void runWithdraw()}\n >\n Withdraw\n </button>\n </div>\n </>\n )}\n\n {result && <ResultLine result={result} />}\n </div>\n );\n}\n\nfunction ResultLine({ result }: { result: BridgeResult<unknown> }): ReactNode {\n const style: CSSProperties = {\n fontSize: 12,\n borderRadius: 8,\n padding: \"8px 10px\",\n background: result.ok ? \"#1e3a2a\" : \"#3a1e28\",\n color: result.ok ? \"#8ef0b0\" : \"#f2a0b4\",\n wordBreak: \"break-all\",\n };\n if (result.ok)\n return <div style={style}>✓ Done · tx {result.onChainTxHash}</div>;\n return (\n <div style={style}>\n ✕ [{result.stage}] {result.error}\n {result.titleTransactionID\n ? ` · already debited (tx ${result.titleTransactionID}) — retry/confirm, don't re-request`\n : \"\"}\n </div>\n );\n}\n"
|
|
73
|
+
"content": "import type { ReactNode } from \"react\";\nimport { LazyWalletPanel } from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The in-game crypto wallet (deposit/withdraw) — the same lazy pattern as the login button. The\n// whole panel, including everything that touches wagmi/Reown AppKit, lives in @idosgames/wallet and\n// is fetched with `await import()` on mount, so Reown AppKit stays out of the page's initial module\n// graph and the live preview boots. Because the wallet config is memoised per project id, a wallet\n// the player connected on the sign-in screen is already connected here.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls AppKit\n// into the startup graph and blanks the preview for web3 titles.\nexport function WalletPanel(): ReactNode {\n const client = useIDosGamesClient();\n return (\n <LazyWalletPanel\n client={client}\n appName=\"iDosGames Idle RPG\"\n style={{ width: \"100%\", background: \"#04276b\" }}\n />\n );\n}\n"
|
|
77
74
|
},
|
|
78
75
|
{
|
|
79
76
|
"path": "controller-box.ts",
|
|
@@ -89,7 +86,7 @@
|
|
|
89
86
|
},
|
|
90
87
|
{
|
|
91
88
|
"path": "game/IdleRpgController.ts",
|
|
92
|
-
"content": "import Phaser from \"phaser\";\nimport { IdleRpgScene, type CharacterDisplay } from \"../phaser/IdleRpgScene\";\n\n/** Owns the Phaser game instance and bridges React → scene (selecting a character). */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly scene: IdleRpgScene;\n\n constructor(parent: HTMLElement) {\n this.scene = new IdleRpgScene();\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: 480,\n height: 640,\n backgroundColor: \"#
|
|
89
|
+
"content": "import Phaser from \"phaser\";\nimport { IdleRpgScene, type CharacterDisplay } from \"../phaser/IdleRpgScene\";\n\n/** Owns the Phaser game instance and bridges React → scene (selecting a character). */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly scene: IdleRpgScene;\n\n constructor(parent: HTMLElement) {\n this.scene = new IdleRpgScene();\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: 480,\n height: 640,\n backgroundColor: \"#063d99\",\n scene: this.scene,\n scale: {\n // Hug the left of the full-bleed host so the host's UI panel can dock on the right without\n // covering the character card.\n mode: Phaser.Scale.FIT,\n autoCenter: Phaser.Scale.CENTER_VERTICALLY,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n }\n\n selectCharacter(display: CharacterDisplay): void {\n this.scene.setSelected(display);\n this.selected = display;\n }\n\n /** Последний выбранный персонаж — единственное, что сцена показывает и чего нет в DOM. */\n private selected: CharacterDisplay | null = null;\n\n /**\n * Что сейчас на канвасе. Читается поверхностью для агента (`module.ts`): у отрисованной сцены\n * нет DOM, и без этого агент не может сказать, дошёл ли выбор персонажа до Phaser.\n */\n describeScene(): { running: boolean; selected: CharacterDisplay | null } {\n return { running: this.game.loop.running, selected: this.selected };\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs). */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n destroy(): void {\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
|
|
93
90
|
},
|
|
94
91
|
{
|
|
95
92
|
"path": "index.ts",
|
|
@@ -97,11 +94,11 @@
|
|
|
97
94
|
},
|
|
98
95
|
{
|
|
99
96
|
"path": "module.ts",
|
|
100
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeIdleRpgRootPanel } from \"./RootPanel\";\n\n// The Idle RPG feature module. `setup` is called once by the host: it wires a Phaser scene and the\n// module's UI panel to one shared controller, and registers a nav entry so the player can enter this\n// mode. No app shell, no client creation, no login — the host owns all of that.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const box = createControllerBox<IdleRpgController>();\n ctx.registerScene(createIdleRpgScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeIdleRpgRootPanel(box),\n });\n ctx.registerRoute({ id: \"idle-rpg\", label: \"Idle RPG\", icon: \"⚔️\" });\n },\n});\n"
|
|
97
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeIdleRpgRootPanel } from \"./RootPanel\";\n\n// The Idle RPG feature module. `setup` is called once by the host: it wires a Phaser scene and the\n// module's UI panel to one shared controller, and registers a nav entry so the player can enter this\n// mode. No app shell, no client creation, no login — the host owns all of that.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const box = createControllerBox<IdleRpgController>();\n ctx.registerScene(createIdleRpgScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeIdleRpgRootPanel(box),\n });\n ctx.registerRoute({ id: \"idle-rpg\", label: \"Idle RPG\", icon: \"⚔️\" });\n\n // Тонкая поверхность для агента — по той же причине, что и в board-game: интерфейс модуля\n // живёт в DOM, агент читает и нажимает его сам. Из Phaser'а ему не видно только то, что\n // рисует сцена.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? { mounted: true, scene: controller.describeScene(), ui: \"dom\" }\n : { mounted: false };\n },\n describeActions: {},\n });\n },\n});\n"
|
|
101
98
|
},
|
|
102
99
|
{
|
|
103
100
|
"path": "phaser/IdleRpgScene.ts",
|
|
104
|
-
"content": "import Phaser from \"phaser\";\n\nexport interface CharacterDisplay {\n name: string;\n rarity?: string;\n className?: string;\n statusLine: string;\n owned: boolean;\n}\n\nconst RARITY_COLORS: Record<string, number> = {\n Common: 0x9aa0b5,\n Uncommon: 0x5ad19a,\n Rare: 0x4a90e2,\n Epic: 0x9c5cff,\n Legendary: 0xf0a020,\n Mythic: 0xe8534e,\n Exotic: 0xff5fa2,\n};\n\nfunction rarityColor(rarity?: string): number {\n const resolved = rarity ? RARITY_COLORS[rarity] : undefined;\n return resolved ?? 0x6c5ce7;\n}\n\n/** 2D character display: a rarity-framed card with a procedural emblem, the character's name/class,\n * and an idle bob. The IdleRPG screen is UI-driven (React overlays); Phaser owns the live visuals. */\nexport class IdleRpgScene extends Phaser.Scene {\n private container?: Phaser.GameObjects.Container;\n private current: CharacterDisplay | null = null;\n\n constructor() {\n super(\"idle-rpg\");\n }\n\n create(): void {\n const { width, height } = this.scale;\n this.cameras.main.setBackgroundColor(\"#
|
|
101
|
+
"content": "import Phaser from \"phaser\";\n\nexport interface CharacterDisplay {\n name: string;\n rarity?: string;\n className?: string;\n statusLine: string;\n owned: boolean;\n}\n\nconst RARITY_COLORS: Record<string, number> = {\n Common: 0x9aa0b5,\n Uncommon: 0x5ad19a,\n Rare: 0x4a90e2,\n Epic: 0x9c5cff,\n Legendary: 0xf0a020,\n Mythic: 0xe8534e,\n Exotic: 0xff5fa2,\n};\n\nfunction rarityColor(rarity?: string): number {\n const resolved = rarity ? RARITY_COLORS[rarity] : undefined;\n return resolved ?? 0x6c5ce7;\n}\n\n/** 2D character display: a rarity-framed card with a procedural emblem, the character's name/class,\n * and an idle bob. The IdleRPG screen is UI-driven (React overlays); Phaser owns the live visuals. */\nexport class IdleRpgScene extends Phaser.Scene {\n private container?: Phaser.GameObjects.Container;\n private current: CharacterDisplay | null = null;\n\n constructor() {\n super(\"idle-rpg\");\n }\n\n create(): void {\n const { width, height } = this.scale;\n this.cameras.main.setBackgroundColor(\"#063d99\");\n\n this.add\n .text(width / 2, 30, \"IdleRPG\", {\n fontFamily: \"sans-serif\",\n fontSize: \"30px\",\n color: \"#ffd479\",\n })\n .setOrigin(0.5, 0);\n\n this.container = this.add.container(width / 2, height / 2);\n this.tweens.add({\n targets: this.container,\n y: height / 2 - 12,\n yoyo: true,\n repeat: -1,\n duration: 1100,\n ease: \"Sine.easeInOut\",\n });\n\n this.render();\n }\n\n setSelected(display: CharacterDisplay): void {\n this.current = display;\n this.render();\n }\n\n private render(): void {\n const container = this.container;\n if (!container) return;\n container.removeAll(true);\n\n if (!this.current) {\n const glow = this.add.graphics();\n glow.fillStyle(0x6c5ce7, 0.12);\n glow.fillCircle(0, -30, 140);\n const ring = this.add.graphics();\n ring.lineStyle(3, 0x4a4368, 1);\n ring.strokeCircle(0, -40, 56);\n const hint = this.add\n .text(0, 70, \"Select a character\", {\n fontFamily: \"sans-serif\",\n fontSize: \"20px\",\n color: \"#a9c6ff\",\n })\n .setOrigin(0.5);\n container.add([glow, ring, hint]);\n return;\n }\n\n const color = rarityColor(this.current.rarity);\n const initial = (this.current.className ?? this.current.name)\n .charAt(0)\n .toUpperCase();\n const dim = !this.current.owned;\n\n const glow = this.add.graphics();\n glow.fillStyle(color, dim ? 0.08 : 0.18);\n glow.fillCircle(0, -40, 150);\n\n const card = this.add.graphics();\n card.fillStyle(0x1b1730, 0.92);\n card.fillRoundedRect(-110, -165, 220, 300, 16);\n card.lineStyle(4, color, dim ? 0.5 : 1);\n card.strokeRoundedRect(-110, -165, 220, 300, 16);\n\n const emblem = this.add.graphics();\n emblem.fillStyle(color, dim ? 0.5 : 1);\n emblem.fillCircle(0, -72, 26);\n emblem.fillRoundedRect(-34, -46, 68, 86, 16);\n\n const initialText = this.add\n .text(0, -2, initial, {\n fontFamily: \"sans-serif\",\n fontSize: \"40px\",\n color: \"#04276b\",\n fontStyle: \"bold\",\n })\n .setOrigin(0.5);\n\n const nameText = this.add\n .text(0, 70, this.current.name, {\n fontFamily: \"sans-serif\",\n fontSize: \"24px\",\n color: \"#ffffff\",\n fontStyle: \"bold\",\n })\n .setOrigin(0.5);\n\n const subParts = [this.current.rarity, this.current.className]\n .filter(Boolean)\n .join(\" · \");\n const subText = this.add\n .text(0, 100, subParts, {\n fontFamily: \"sans-serif\",\n fontSize: \"15px\",\n color: hexCss(color),\n })\n .setOrigin(0.5);\n\n const statusText = this.add\n .text(0, 126, this.current.statusLine, {\n fontFamily: \"sans-serif\",\n fontSize: \"15px\",\n color: \"#cfe0ff\",\n })\n .setOrigin(0.5);\n\n container.add([\n glow,\n card,\n emblem,\n initialText,\n nameText,\n subText,\n statusText,\n ]);\n }\n}\n\nfunction hexCss(color: number): string {\n return `#${color.toString(16).padStart(6, \"0\")}`;\n}\n"
|
|
105
102
|
},
|
|
106
103
|
{
|
|
107
104
|
"path": "react/controller.ts",
|
|
@@ -109,7 +106,7 @@
|
|
|
109
106
|
},
|
|
110
107
|
{
|
|
111
108
|
"path": "RootPanel.tsx",
|
|
112
|
-
"content": "import {\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { IdleRpgController } from \"./game/IdleRpgController\";\nimport { CurrencyHud } from \"./components/CurrencyHud\";\nimport {\n CharacterRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nimport { CharacterDetail } from \"./components/CharacterDetail\";\nimport { EquipmentPanel } from \"./components/EquipmentPanel\";\nimport { StatusBar } from \"./components/StatusBar\";\nimport { WalletPanel } from \"./components/WalletPanel\";\n\n// The module's whole UI as a single overlay panel. The host already provides the client + status\n// contexts (mountHost wraps the tree); this panel adds the module's own controller context once the\n// Phaser scene has published its controller into the shared box. It docks to the right so the\n// full-bleed Phaser character card stays visible on the left.\n\n/** Builds the panel component bound to the controller published by this module's scene. */\nexport function makeIdleRpgRootPanel(\n box: ControllerBox<IdleRpgController>,\n): ComponentType {\n return function IdleRpgRootPanel(): ReactNode {\n const controller = useSyncExternalStore(box.subscribe, box.get);\n if (!controller) {\n return <div style={loadingStyle}>Loading RPG…</div>;\n }\n return (\n <IdleRpgControllerProvider controller={controller}>\n <Layout />\n </IdleRpgControllerProvider>\n );\n };\n}\n\nfunction Layout(): ReactNode {\n const controller = useIdleRpgController();\n const [selected, setSelected] = useState<string | null>(null);\n\n const onSelect = (entry: RosterEntry): void => {\n setSelected(entry.id);\n controller.selectCharacter({\n name: entry.name,\n rarity: entry.rarity,\n className: entry.className,\n statusLine: rosterStatusLine(entry),\n owned: entry.availability === \"owned\",\n });\n };\n\n return (\n <div style={dockStyle}>\n <CurrencyHud />\n <div style={columnsStyle}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <CharacterRoster selectedID={selected} onSelect={onSelect} />\n </div>\n <div style={detailColumnStyle}>\n <CharacterDetail characterID={selected} />\n <EquipmentPanel characterID={selected} />\n <WalletPanel />\n </div>\n </div>\n <StatusBar />\n </div>\n );\n}\n\nconst dockStyle: CSSProperties = {\n position: \"absolute\",\n top: 0,\n right: 0,\n bottom: 0,\n width: \"min(560px, 62%)\",\n boxSizing: \"border-box\",\n padding: 16,\n overflow: \"auto\",\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(
|
|
109
|
+
"content": "import {\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { IdleRpgController } from \"./game/IdleRpgController\";\nimport { CurrencyHud } from \"./components/CurrencyHud\";\nimport {\n CharacterRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nimport { CharacterDetail } from \"./components/CharacterDetail\";\nimport { EquipmentPanel } from \"./components/EquipmentPanel\";\nimport { StatusBar } from \"./components/StatusBar\";\nimport { WalletPanel } from \"./components/WalletPanel\";\n\n// The module's whole UI as a single overlay panel. The host already provides the client + status\n// contexts (mountHost wraps the tree); this panel adds the module's own controller context once the\n// Phaser scene has published its controller into the shared box. It docks to the right so the\n// full-bleed Phaser character card stays visible on the left.\n\n/** Builds the panel component bound to the controller published by this module's scene. */\nexport function makeIdleRpgRootPanel(\n box: ControllerBox<IdleRpgController>,\n): ComponentType {\n return function IdleRpgRootPanel(): ReactNode {\n const controller = useSyncExternalStore(box.subscribe, box.get);\n if (!controller) {\n return <div style={loadingStyle}>Loading RPG…</div>;\n }\n return (\n <IdleRpgControllerProvider controller={controller}>\n <Layout />\n </IdleRpgControllerProvider>\n );\n };\n}\n\nfunction Layout(): ReactNode {\n const controller = useIdleRpgController();\n const [selected, setSelected] = useState<string | null>(null);\n\n const onSelect = (entry: RosterEntry): void => {\n setSelected(entry.id);\n controller.selectCharacter({\n name: entry.name,\n rarity: entry.rarity,\n className: entry.className,\n statusLine: rosterStatusLine(entry),\n owned: entry.availability === \"owned\",\n });\n };\n\n return (\n <div style={dockStyle}>\n <CurrencyHud />\n <div style={columnsStyle}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <CharacterRoster selectedID={selected} onSelect={onSelect} />\n </div>\n <div style={detailColumnStyle}>\n <CharacterDetail characterID={selected} />\n <EquipmentPanel characterID={selected} />\n <WalletPanel />\n </div>\n </div>\n <StatusBar />\n </div>\n );\n}\n\nconst dockStyle: CSSProperties = {\n position: \"absolute\",\n top: 0,\n right: 0,\n bottom: 0,\n width: \"min(560px, 62%)\",\n boxSizing: \"border-box\",\n padding: 16,\n overflow: \"auto\",\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(4,32,90,0.92)\",\n borderLeft: \"1px solid #1a4fb8\",\n};\nconst columnsStyle: CSSProperties = {\n display: \"flex\",\n gap: 16,\n alignItems: \"flex-start\",\n marginTop: 8,\n};\nconst detailColumnStyle: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n width: 280,\n flex: \"0 0 280px\",\n};\nconst loadingStyle: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n opacity: 0.6,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n};\n"
|
|
113
110
|
},
|
|
114
111
|
{
|
|
115
112
|
"path": "scene.ts",
|