@idosgames/mcp 0.1.42 → 0.1.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +135 -135
- package/registry/modules/ai.json +3 -3
- package/registry/modules/board-game.json +15 -15
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +8 -8
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +3 -3
- package/registry/modules/coop-events.json +3 -3
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +3 -3
- package/registry/modules/events.json +7 -7
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +20 -16
- package/registry/modules/idle-rpg.json +17 -13
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/leaderboard.json +7 -7
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +7 -7
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +3 -3
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +5 -5
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/video-studio.json +4 -4
- package/registry/modules/voxelcraft.json +36 -28
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/ai-generation-system.json +2 -2
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/rewarded-ads.json +1 -1
- package/registry/skills/store-system.json +1 -1
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"name": "iDos Games",
|
|
35
35
|
"url": "https://idosgames.com"
|
|
36
36
|
},
|
|
37
|
-
"version": "0.2.
|
|
37
|
+
"version": "0.2.3",
|
|
38
38
|
"events": {
|
|
39
39
|
"listens": [
|
|
40
40
|
{
|
|
@@ -45,17 +45,17 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@idosgames/core": "0.
|
|
49
|
-
"@idosgames/module-sdk": "0.13.
|
|
50
|
-
"@idosgames/react": "0.6.
|
|
51
|
-
"@idosgames/wallet": "0.6.
|
|
48
|
+
"@idosgames/core": "0.25.0",
|
|
49
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
50
|
+
"@idosgames/react": "0.6.3",
|
|
51
|
+
"@idosgames/wallet": "0.6.8",
|
|
52
52
|
"@tanstack/react-query": "5.101.2",
|
|
53
53
|
"react": "19.2.7",
|
|
54
54
|
"three": "0.185.1",
|
|
55
55
|
"viem": "2.55.2",
|
|
56
56
|
"wagmi": "3.7.2"
|
|
57
57
|
},
|
|
58
|
-
"contentHash": "
|
|
58
|
+
"contentHash": "99fc32290b3370f1335d4f2f81a847aea2cb42235ae6101cead722037d3c0215",
|
|
59
59
|
"files": [
|
|
60
60
|
{
|
|
61
61
|
"path": "chrome.ts",
|
|
@@ -71,15 +71,15 @@
|
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
"path": "components/BoardHud.tsx",
|
|
74
|
-
"content": "import {\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { ResourceCounter, U, uText, v } from \"@idosgames/react/ui\";\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 stageProgress,\n} from \"../data/boardConfig\";\nimport type { NewsStore } from \"../chrome\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst ribbon: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n height: 32,\n padding: \"0 11px\",\n borderRadius: 16,\n border: `3px solid ${U.ink}`,\n background: `linear-gradient(180deg, ${v.purple} 0%, ${v.purpleDeep} 100%)`,\n boxShadow: `inset 0 3px 0 rgba(255,255,255,.35)
|
|
74
|
+
"content": "import {\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { ResourceCounter, U, uText, v } from \"@idosgames/react/ui\";\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 stageProgress,\n} from \"../data/boardConfig\";\nimport type { NewsStore } from \"../chrome\";\n\nconst row: CSSProperties = {\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n pointerEvents: \"auto\",\n};\nconst ribbon: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n height: 32,\n padding: \"0 11px\",\n borderRadius: 16,\n border: `3px solid ${U.ink}`,\n background: `linear-gradient(180deg, ${v.purple} 0%, ${v.purpleDeep} 100%)`,\n boxShadow: `inset 0 3px 0 rgba(255,255,255,.35)`,\n ...uText(13),\n};\n// The kit's dark plate in the ink frame, outlined game text.\nconst chip: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n height: 28,\n padding: \"0 9px\",\n borderRadius: 8,\n background: v.well,\n border: `2px solid ${U.ink}`,\n boxShadow: v.wellShadow,\n ...uText(12),\n};\nconst newsChip: CSSProperties = {\n ...chip,\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n};\n\nconst noSubscribe = (): (() => void) => () => {};\nconst noNews = (): string | null => null;\n\nexport function BoardHud({\n showBalances = true,\n news,\n}: {\n /** Coin and shield counters — hidden when a shared-UI provider (the base's lobby) draws balances. */\n showBalances?: boolean;\n /** News from other modules; click the chip to dismiss it. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const board = useBoardState();\n const cfg = readBoardConfig(client);\n const headline = useSyncExternalStore(\n news?.subscribe ?? noSubscribe,\n news?.get ?? noNews,\n );\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 const progress = stageProgress(client, stage, board?.BuildingStates ?? []);\n\n // The rolls are drawn by the roll button itself (they are what it spends); coins and shields here,\n // unless the lobby draws the balances.\n return (\n <div style={{ display: \"grid\", gap: 8, pointerEvents: \"auto\" }}>\n <StageBar\n stage={stage}\n fraction={progress.max > 0 ? progress.built / progress.max : 0}\n stageCount={progress.stageCount}\n />\n <div style={row}>\n <span style={ribbon}>\n ★ Board {stage}\n {stageName ? ` · ${stageName}` : \"\"}\n </span>\n {showBalances && cfg.SoftCurrencyID ? (\n <ResourceCounter\n currencyID={cfg.SoftCurrencyID}\n amount={amount(cfg.SoftCurrencyID)}\n size={22}\n />\n ) : null}\n {showBalances && cfg.ShieldCurrencyID ? (\n <ResourceCounter\n currencyID={cfg.ShieldCurrencyID}\n amount={amount(cfg.ShieldCurrencyID)}\n size={22}\n />\n ) : null}\n <span style={chip}>\n Tile {board?.Position ?? 0}/{tileCount > 0 ? tileCount - 1 : 0}\n </span>\n <span style={chip}>Laps {board?.CyclesCompleted ?? 0}</span>\n {headline ? (\n <button type=\"button\" style={newsChip} onClick={() => news?.clear()}>\n {headline} ✕\n </button>\n ) : null}\n </div>\n </div>\n );\n}\n\nconst STAGE_TONES = {\n passed: [\"#14adfa\", \"#3dffff\"],\n current: [\"#ffd600\", \"#ffff7d\"],\n future: [\"#474a66\", \"#a1a3c7\"],\n} as const;\n\n/**\n * Slider_StageHorizontal_l (Unity StageProgressView): numbered stage icons on a dark track — passed\n * stages blue, the current one gold, the next ones slate — with the cyan fill reaching the current\n * stage plus how much of it is built. A window of five stages around the current one.\n */\nfunction StageBar({\n stage,\n fraction,\n stageCount,\n}: {\n stage: number;\n fraction: number;\n stageCount: number | null;\n}): ReactNode {\n const last = stageCount ?? Number.POSITIVE_INFINITY;\n const first = Math.max(1, Math.min(stage - 1, last - 4));\n const stages = Array.from({ length: 5 }, (_, i) => first + i).filter(\n (n) => n <= last,\n );\n const span = Math.max(1, stages.length - 1);\n const at = stages.indexOf(stage);\n const fill =\n at < 0 ? 0 : Math.min(1, (at + Math.max(0, Math.min(1, fraction))) / span);\n return (\n <div\n style={{\n position: \"relative\",\n height: 40,\n margin: \"0 6px\",\n maxWidth: 420,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 18,\n right: 18,\n top: 13,\n height: 14,\n borderRadius: 7,\n background: \"#232639\",\n border: `2px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${fill * 100}%`,\n height: \"100%\",\n background: \"linear-gradient(180deg, #9cffff 0%, #3dffff 100%)\",\n transition: \"width .6s ease\",\n }}\n />\n </div>\n {stages.map((n, i) => {\n const tone = n < stage ? \"passed\" : n === stage ? \"current\" : \"future\";\n const [outer, inner] = STAGE_TONES[tone];\n const size = n === stage ? 40 : 34;\n return (\n <span\n key={n}\n style={{\n position: \"absolute\",\n left: `calc(18px + (100% - 36px) * ${i / span} - ${size / 2}px)`,\n top: (40 - size) / 2,\n width: size,\n height: size,\n borderRadius: \"50%\",\n background: outer,\n border: `3px solid ${U.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n animation:\n n === stage\n ? \"idos-board-heartbeat 1.6s ease-in-out infinite\"\n : undefined,\n }}\n >\n <span\n style={{\n position: \"absolute\",\n inset: size * 0.2,\n borderRadius: \"50%\",\n background: inner,\n opacity: 0.55,\n }}\n />\n <span\n style={{ position: \"relative\", ...uText(n === stage ? 17 : 14) }}\n >\n {n}\n </span>\n </span>\n );\n })}\n </div>\n );\n}\n"
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
"path": "components/BoardRoot.tsx",
|
|
78
|
-
"content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { FULL_CHROME, type BoardChrome, type NewsStore } from \"../chrome\";\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\";\nimport { BOARD_CSS } from \"./boardCss\";\nimport { panelCard, panelText, panelTitle } from \"./panelStyles\";\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: \"16px 16px 20px\",\n fontFamily: \"var(--idos-ui-font, system-ui, sans-serif)\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"linear-gradient(180deg, #ffd85a 0%, #f0a020 100%)\",\n color: \"#5a2d00\",\n border: \"2px solid #fff\",\n borderRadius: 16,\n boxShadow: \"
|
|
78
|
+
"content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { FULL_CHROME, type BoardChrome, type NewsStore } from \"../chrome\";\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\";\nimport { BOARD_CSS } from \"./boardCss\";\nimport { panelCard, panelText, panelTitle } from \"./panelStyles\";\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: \"16px 16px 20px\",\n fontFamily: \"var(--idos-ui-font, system-ui, sans-serif)\",\n color: \"#fff\",\n pointerEvents: \"none\",\n};\nconst footer: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n alignItems: \"center\",\n};\n\nconst walletLauncher: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n pointerEvents: \"auto\",\n background: \"linear-gradient(180deg, #ffd85a 0%, #f0a020 100%)\",\n color: \"#5a2d00\",\n border: \"2px solid #fff\",\n borderRadius: 16,\n boxShadow: \"none\",\n padding: \"6px 14px\",\n fontWeight: 900,\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n};\nconst walletDock: CSSProperties = {\n position: \"absolute\",\n top: 60,\n right: 16,\n pointerEvents: \"none\",\n};\n\nexport function BoardRoot({\n chrome = FULL_CHROME,\n news,\n}: {\n /** Which shared chrome to draw here (the rest is drawn by a shared-UI provider such as the base). */\n chrome?: BoardChrome;\n /** News from other modules, shown as a chip in the board HUD. */\n news?: NewsStore;\n} = {}): ReactNode {\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const [ready, setReady] = useState(false);\n const [configured, setConfigured] = useState(true);\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 setConfigured(resolveTiles(cfg, stage).length > 0);\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 // No StatusProvider of our own: the host wraps every module in ONE root provider, and a nested one\n // would swallow the board's messages — a shared status line would never see them.\n return (\n // a surface over the board's canvas (not the canvas): smaller on a small phone (@idosgames/react/ui fit.ts)\n <div className=\"idos-fit\" style={overlay}>\n <style>{BOARD_CSS}</style>\n {chrome.wallet && (\n <button\n type=\"button\"\n style={walletLauncher}\n onClick={() => setWalletOpen((v) => !v)}\n >\n 💰 Wallet\n </button>\n )}\n {chrome.wallet && walletOpen && (\n <div style={walletDock}>\n <WalletPanel onClose={() => setWalletOpen(false)} />\n </div>\n )}\n <div style={chrome.lobbyButton ? { paddingLeft: 52 } : undefined}>\n <BoardHud showBalances={chrome.balances} news={news} />\n </div>\n <div style={footer}>\n {ready && !configured ? (\n <div\n className=\"idos-board-panel\"\n style={{ ...panelCard, textAlign: \"center\" }}\n >\n <div style={{ ...panelTitle, justifyContent: \"center\" }}>\n The board is not set up yet\n </div>\n <div style={panelText}>\n This title has no board settings (GameLoop → Board). Add them in\n the dashboard and reload — the tiles, dice and city appear here.\n </div>\n </div>\n ) : ready ? (\n <InteractionPanel />\n ) : (\n <div style={{ opacity: 0.6, pointerEvents: \"auto\" }}>\n Loading board…\n </div>\n )}\n {chrome.status && <StatusBar />}\n </div>\n </div>\n );\n}\n"
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
81
|
"path": "components/BuildPanel.tsx",
|
|
82
|
-
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n Icon,\n formatAmount,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState, useUserState } from \"../react/hooks\";\nimport { useRunAction } from \"../react/useRunAction\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport { buildCost, resolveStageBuildings } from \"../data/boardConfig\";\nimport { BuildingArt, FireArt, StageBackdrop } from \"./modal/art\";\nimport {\n Stage,\n at,\n easeOutBack,\n outlinedText,\n tween,\n type StageHandle,\n} from \"./modal/stage\";\n\n// The city (the reference's Building_Window, BoardSurvival BoardScene): the stage backdrop with the\n// five buildings standing where they stand on the stage, each at its level, and a row of cards along\n// the bottom — the next level's picture, the price, a star per level. Tapping a card builds; a hammer\n// beat squashes and pops the building on the stage. The board HUD keeps a \"City\" button that opens it.\n\n/** Building positions on the stage (Building_Window/Buildings, SlotIndex 0..4), canvas units. */\nconst STAGE_SLOTS: Array<[number, number]> = [\n [-320, -360],\n [320, -360],\n [-320, 200],\n [320, 200],\n [0, 0],\n];\n/** Cards: 260 × 350, centres 263 apart along the bottom, the row fitted to the width (FitRowToWidth). */\nconst CARD_W = 260;\nconst CARD_H = 350;\nconst CARD_STEP = 263;\n\nconst launcher: CSSProperties = {\n pointerEvents: \"auto\",\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 8,\n height: 44,\n padding: \"0 16px\",\n borderRadius: 22,\n border: \"3px solid #fff\",\n background: \"linear-gradient(180deg, #7ad35a 0%, #3e9e35 100%)\",\n boxShadow: \"0 4px 0 #2a6e22, 0 8px 14px rgba(0,0,0,.3)\",\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 17,\n textShadow: \"0 2px 0 rgba(0,0,0,.3)\",\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n};\n\nexport function BuildPanel(): ReactNode {\n const board = useBoardState();\n const [open, setOpen] = useState(false);\n if ((board?.BuildingStates ?? []).length === 0) return null;\n return (\n <>\n <button type=\"button\" style={launcher} onClick={() => setOpen(true)}>\n <HouseGlyph /> City\n </button>\n {open ? <CityWindow onClose={() => setOpen(false)} /> : null}\n </>\n );\n}\n\nfunction CityWindow({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const { busy, run } = useRunAction();\n const stage = useRef<StageHandle>(null);\n const buildingEls = useRef<Array<HTMLDivElement | null>>([]);\n const [upgrading, setUpgrading] = useState<number | null>(null);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n const stageLevel = board?.StageLevel ?? 1;\n const defs = resolveStageBuildings(client, stageLevel);\n const buildings = board?.BuildingStates ?? [];\n const balance = (id: string): number =>\n state?.InventoryV2?.VirtualCurrencies?.[id]?.Amount ?? 0;\n\n const close = async (): Promise<void> => {\n play(\"close\");\n await stage.current?.fadeOut(0.25);\n onClose();\n };\n\n const build = async (slot: number): Promise<void> => {\n if (busy || upgrading !== null) return;\n setUpgrading(slot);\n play(\"click\");\n let ok = false;\n await run(`Build #${slot}`, async () => {\n const res = await client.gameLoop.boardLoopBuild(slot);\n ok = res.ok;\n if (!res.ok) toast(res.error, \"error\");\n return res;\n });\n if (ok) {\n // The hammer beat (StageBuilding.PlayUpgradeEffect): squash, pop, settle, sparks.\n const el = buildingEls.current[slot];\n play(\"purchase\");\n kit.fx.burst(el);\n await tween(0.1, (t) => {\n if (el) el.style.transform = `scale(${1 - 0.12 * t})`;\n });\n await tween(0.16, (t) => {\n if (el) el.style.transform = `scale(${0.88 + 0.24 * easeOutBack(t)})`;\n });\n await tween(0.12, (t) => {\n if (el) el.style.transform = `scale(${1.12 - 0.12 * t})`;\n });\n stage.current?.shake(5, 0.15, 24);\n }\n setUpgrading(null);\n };\n\n const rowWidth = CARD_STEP * 4 + CARD_W;\n const rowScale = Math.min(1, 1040 / rowWidth);\n\n return (\n <Stage ref={stage} backdrop={<StageBackdrop />} dim=\"rgba(0,0,0,.5)\">\n {/* The stage: the buildings at their levels. */}\n {buildings.map((b) => {\n const pos = STAGE_SLOTS[b.SlotIndex];\n if (!pos) return null;\n const level = b.Level ?? 0;\n return (\n <div\n key={b.SlotIndex}\n ref={(el) => {\n buildingEls.current[b.SlotIndex] = el;\n }}\n style={{ ...at(pos[0], pos[1] + 137, 450, 450) }}\n >\n <BuildingArt\n slot={b.SlotIndex}\n level={b.IsDamaged ? 1 : level}\n damaged={!!b.IsDamaged}\n />\n {b.IsDamaged ? (\n <div\n style={{\n position: \"absolute\",\n left: 140,\n top: 60,\n width: 170,\n height: 200,\n animation: \"idos-board-breathe .5s ease-in-out infinite\",\n }}\n >\n <FireArt />\n </div>\n ) : null}\n </div>\n );\n })}\n\n {/* Title plate + close */}\n <div\n style={{\n ...at(0, 860, 560, 110),\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <div\n style={{\n padding: \"10px 46px\",\n borderRadius: 26,\n background: \"linear-gradient(180deg,#5cc8ff,#1c8fe6)\",\n border: \"7px solid #17193a\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.4), 0 8px 0 #0d5aa8\",\n ...outlinedText(56),\n }}\n >\n CITY\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => void close()}\n aria-label=\"Close\"\n style={{\n ...at(440, 860, 110, 110),\n borderRadius: 30,\n border: \"7px solid #17193a\",\n background: \"linear-gradient(180deg,#ff7a6b,#e83b2e)\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.4), 0 8px 0 #8f1a12\",\n cursor: \"pointer\",\n ...outlinedText(60),\n lineHeight: 1,\n fontFamily: \"inherit\",\n }}\n >\n ✕\n </button>\n\n {/* The row of cards */}\n <div\n style={{\n position: \"absolute\",\n left: 540 - (rowWidth * rowScale) / 2,\n bottom: 180,\n width: rowWidth,\n height: CARD_H,\n transform: `scale(${rowScale})`,\n transformOrigin: \"0 100%\",\n }}\n >\n {buildings.map((b, i) => {\n const def = defs.find((d) => d.SlotIndex === b.SlotIndex);\n const level = b.Level ?? 0;\n const max = def?.MaxLevel ?? 6;\n const maxed = max > 0 && level >= max;\n const price =\n b.IsDamaged || maxed\n ? { amount: 0, currencyID: undefined }\n : buildCost(client, stageLevel, b.SlotIndex, level);\n const short =\n price.amount > 0 &&\n price.currencyID !== undefined &&\n balance(price.currencyID) < price.amount;\n const claimed = !!b.MaxLevelRewardClaimed;\n const disabled =\n busy || upgrading !== null || (maxed && claimed) || short;\n const preview = b.IsDamaged\n ? Math.max(1, level)\n : Math.min(max, level + 1);\n return (\n <div\n key={b.SlotIndex}\n onClick={() => {\n if (!disabled) void build(b.SlotIndex);\n }}\n style={{\n position: \"absolute\",\n left: i * CARD_STEP,\n bottom: 0,\n width: CARD_W,\n height: CARD_H,\n cursor: disabled ? \"default\" : \"pointer\",\n animation: `idos-board-card-in .35s cubic-bezier(.34,1.56,.64,1) ${0.05 * i}s both`,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 30,\n border: \"7px solid #17193a\",\n background:\n \"linear-gradient(180deg,#e9f4ff 0%,#bcd9f7 70%,#8fbbe9 100%)\",\n boxShadow:\n \"inset 0 6px 0 rgba(255,255,255,.7), 0 10px 0 rgba(0,0,0,.35)\",\n overflow: \"hidden\",\n filter: maxed && claimed ? \"grayscale(.6)\" : undefined,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n height: 92,\n background: \"linear-gradient(180deg,#3f7fd8,#2a5fb8)\",\n borderTop: \"5px solid #17193a\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n left: 10,\n top: 40,\n width: 240,\n height: 240,\n }}\n >\n <BuildingArt slot={b.SlotIndex} level={preview} />\n </div>\n {b.IsDamaged ? (\n <div\n style={{\n position: \"absolute\",\n left: 68,\n top: 170,\n width: 123,\n height: 110,\n }}\n >\n <FireArt />\n </div>\n ) : null}\n </div>\n {/* Price (or Claim / Done at the top level) */}\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n top: 6,\n height: 60,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n }}\n >\n {maxed ? (\n <span\n style={outlinedText(46, claimed ? \"#cfd8e6\" : \"#ffe066\")}\n >\n {claimed ? \"Done\" : \"Claim\"}\n </span>\n ) : price.amount > 0 && price.currencyID ? (\n <>\n <Icon\n glyph={catalog.iconOf({\n kind: \"currency\",\n id: price.currencyID,\n amount: 0,\n })}\n size={56}\n />\n <span style={outlinedText(48, short ? \"#ff7b7b\" : \"#fff\")}>\n {formatAmount(price.amount)}\n </span>\n </>\n ) : (\n <span style={outlinedText(44)}>\n {b.IsDamaged ? \"Repair\" : \"Build\"}\n </span>\n )}\n </div>\n {/* A star per level: outlines for every level, filled up to the current one. */}\n <div\n style={{\n position: \"absolute\",\n left: 10,\n right: 10,\n bottom: 22,\n height: 48,\n display: \"flex\",\n justifyContent: \"center\",\n gap: 2,\n }}\n >\n {Array.from({ length: Math.min(6, max) }, (_, k) => (\n <Star\n key={k}\n filled={k < level}\n gold={k === max - 1 && k < level}\n />\n ))}\n </div>\n </div>\n );\n })}\n </div>\n </Stage>\n );\n}\n\nfunction Star({ filled, gold }: { filled: boolean; gold: boolean }): ReactNode {\n const fill = !filled ? \"rgba(16,23,46,.45)\" : gold ? \"#ffd23a\" : \"#e6ecf5\";\n return (\n <svg viewBox=\"0 0 42 45\" width={38} height={41} aria-hidden>\n <path\n d=\"M21 3 L26.5 15.5 L40 16.8 L29.8 25.8 L32.8 39 L21 32 L9.2 39 L12.2 25.8 L2 16.8 L15.5 15.5 Z\"\n fill={fill}\n stroke=\"#17193a\"\n strokeWidth=\"3.5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n );\n}\n\nfunction HouseGlyph(): ReactNode {\n return (\n <svg width={22} height={22} viewBox=\"0 0 24 24\" aria-hidden>\n <path\n d=\"M3 11L12 3l9 8\"\n fill=\"#e8534e\"\n stroke=\"#fff\"\n strokeWidth=\"2\"\n strokeLinejoin=\"round\"\n />\n <rect\n x=\"6\"\n y=\"10\"\n width=\"12\"\n height=\"11\"\n rx=\"1.5\"\n fill=\"#fff4dc\"\n stroke=\"#fff\"\n strokeWidth=\"1.5\"\n />\n <rect x=\"10.5\" y=\"14\" width=\"3\" height=\"7\" fill=\"#8a5a2b\" />\n </svg>\n );\n}\n"
|
|
82
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n Icon,\n formatAmount,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { useBoardState, useUserState } from \"../react/hooks\";\nimport { useRunAction } from \"../react/useRunAction\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport { buildCost, resolveStageBuildings } from \"../data/boardConfig\";\nimport { BuildingArt, FireArt, StageBackdrop } from \"./modal/art\";\nimport {\n Stage,\n at,\n easeOutBack,\n outlinedText,\n tween,\n type StageHandle,\n} from \"./modal/stage\";\n\n// The city (the reference's Building_Window, BoardSurvival BoardScene): the stage backdrop with the\n// five buildings standing where they stand on the stage, each at its level, and a row of cards along\n// the bottom — the next level's picture, the price, a star per level. Tapping a card builds; a hammer\n// beat squashes and pops the building on the stage. The board HUD keeps a \"City\" button that opens it.\n\n/** Building positions on the stage (Building_Window/Buildings, SlotIndex 0..4), canvas units. */\nconst STAGE_SLOTS: Array<[number, number]> = [\n [-320, -360],\n [320, -360],\n [-320, 200],\n [320, 200],\n [0, 0],\n];\n/** Cards: 260 × 350, centres 263 apart along the bottom, the row fitted to the width (FitRowToWidth). */\nconst CARD_W = 260;\nconst CARD_H = 350;\nconst CARD_STEP = 263;\n\nconst launcher: CSSProperties = {\n pointerEvents: \"auto\",\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 8,\n height: 44,\n padding: \"0 16px\",\n borderRadius: 22,\n border: \"3px solid #fff\",\n background: \"linear-gradient(180deg, #7ad35a 0%, #3e9e35 100%)\",\n boxShadow: \"none\",\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 17,\n textShadow: \"none\",\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n};\n\nexport function BuildPanel(): ReactNode {\n const board = useBoardState();\n const [open, setOpen] = useState(false);\n if ((board?.BuildingStates ?? []).length === 0) return null;\n return (\n <>\n <button type=\"button\" style={launcher} onClick={() => setOpen(true)}>\n <HouseGlyph /> City\n </button>\n {open ? <CityWindow onClose={() => setOpen(false)} /> : null}\n </>\n );\n}\n\nfunction CityWindow({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const board = useBoardState();\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const { busy, run } = useRunAction();\n const stage = useRef<StageHandle>(null);\n const buildingEls = useRef<Array<HTMLDivElement | null>>([]);\n const [upgrading, setUpgrading] = useState<number | null>(null);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n const stageLevel = board?.StageLevel ?? 1;\n const defs = resolveStageBuildings(client, stageLevel);\n const buildings = board?.BuildingStates ?? [];\n const balance = (id: string): number =>\n state?.InventoryV2?.VirtualCurrencies?.[id]?.Amount ?? 0;\n\n const close = async (): Promise<void> => {\n play(\"close\");\n await stage.current?.fadeOut(0.25);\n onClose();\n };\n\n const build = async (slot: number): Promise<void> => {\n if (busy || upgrading !== null) return;\n setUpgrading(slot);\n play(\"click\");\n let ok = false;\n await run(`Build #${slot}`, async () => {\n const res = await client.gameLoop.boardLoopBuild(slot);\n ok = res.ok;\n if (!res.ok) toast(res.error, \"error\");\n return res;\n });\n if (ok) {\n // The hammer beat (StageBuilding.PlayUpgradeEffect): squash, pop, settle, sparks.\n const el = buildingEls.current[slot];\n play(\"purchase\");\n kit.fx.burst(el);\n await tween(0.1, (t) => {\n if (el) el.style.transform = `scale(${1 - 0.12 * t})`;\n });\n await tween(0.16, (t) => {\n if (el) el.style.transform = `scale(${0.88 + 0.24 * easeOutBack(t)})`;\n });\n await tween(0.12, (t) => {\n if (el) el.style.transform = `scale(${1.12 - 0.12 * t})`;\n });\n stage.current?.shake(5, 0.15, 24);\n }\n setUpgrading(null);\n };\n\n const rowWidth = CARD_STEP * 4 + CARD_W;\n const rowScale = Math.min(1, 1040 / rowWidth);\n\n return (\n <Stage ref={stage} backdrop={<StageBackdrop />} dim=\"rgba(0,0,0,.5)\">\n {/* The stage: the buildings at their levels. */}\n {buildings.map((b) => {\n const pos = STAGE_SLOTS[b.SlotIndex];\n if (!pos) return null;\n const level = b.Level ?? 0;\n return (\n <div\n key={b.SlotIndex}\n ref={(el) => {\n buildingEls.current[b.SlotIndex] = el;\n }}\n style={{ ...at(pos[0], pos[1] + 137, 450, 450) }}\n >\n <BuildingArt\n slot={b.SlotIndex}\n level={b.IsDamaged ? 1 : level}\n damaged={!!b.IsDamaged}\n />\n {b.IsDamaged ? (\n <div\n style={{\n position: \"absolute\",\n left: 140,\n top: 60,\n width: 170,\n height: 200,\n animation: \"idos-board-breathe .5s ease-in-out infinite\",\n }}\n >\n <FireArt />\n </div>\n ) : null}\n </div>\n );\n })}\n\n {/* Title plate + close */}\n <div\n style={{\n ...at(0, 860, 560, 110),\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <div\n style={{\n padding: \"10px 46px\",\n borderRadius: 26,\n background: \"linear-gradient(180deg,#5cc8ff,#1c8fe6)\",\n border: \"7px solid #17193a\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.4)\",\n ...outlinedText(56),\n }}\n >\n CITY\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => void close()}\n aria-label=\"Close\"\n style={{\n ...at(440, 860, 110, 110),\n borderRadius: 30,\n border: \"7px solid #17193a\",\n background: \"linear-gradient(180deg,#ff7a6b,#e83b2e)\",\n boxShadow: \"inset 0 7px 0 rgba(255,255,255,.4)\",\n cursor: \"pointer\",\n ...outlinedText(60),\n lineHeight: 1,\n fontFamily: \"inherit\",\n }}\n >\n ✕\n </button>\n\n {/* The row of cards */}\n <div\n style={{\n position: \"absolute\",\n left: 540 - (rowWidth * rowScale) / 2,\n bottom: 180,\n width: rowWidth,\n height: CARD_H,\n transform: `scale(${rowScale})`,\n transformOrigin: \"0 100%\",\n }}\n >\n {buildings.map((b, i) => {\n const def = defs.find((d) => d.SlotIndex === b.SlotIndex);\n const level = b.Level ?? 0;\n const max = def?.MaxLevel ?? 6;\n const maxed = max > 0 && level >= max;\n const price =\n b.IsDamaged || maxed\n ? { amount: 0, currencyID: undefined }\n : buildCost(client, stageLevel, b.SlotIndex, level);\n const short =\n price.amount > 0 &&\n price.currencyID !== undefined &&\n balance(price.currencyID) < price.amount;\n const claimed = !!b.MaxLevelRewardClaimed;\n const disabled =\n busy || upgrading !== null || (maxed && claimed) || short;\n const preview = b.IsDamaged\n ? Math.max(1, level)\n : Math.min(max, level + 1);\n return (\n <div\n key={b.SlotIndex}\n onClick={() => {\n if (!disabled) void build(b.SlotIndex);\n }}\n style={{\n position: \"absolute\",\n left: i * CARD_STEP,\n bottom: 0,\n width: CARD_W,\n height: CARD_H,\n cursor: disabled ? \"default\" : \"pointer\",\n animation: `idos-board-card-in .35s cubic-bezier(.34,1.56,.64,1) ${0.05 * i}s both`,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 30,\n border: \"7px solid #17193a\",\n background:\n \"linear-gradient(180deg,#e9f4ff 0%,#bcd9f7 70%,#8fbbe9 100%)\",\n boxShadow: \"inset 0 6px 0 rgba(255,255,255,.7)\",\n overflow: \"hidden\",\n filter: maxed && claimed ? \"grayscale(.6)\" : undefined,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n height: 92,\n background: \"linear-gradient(180deg,#3f7fd8,#2a5fb8)\",\n borderTop: \"5px solid #17193a\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n left: 10,\n top: 40,\n width: 240,\n height: 240,\n }}\n >\n <BuildingArt slot={b.SlotIndex} level={preview} />\n </div>\n {b.IsDamaged ? (\n <div\n style={{\n position: \"absolute\",\n left: 68,\n top: 170,\n width: 123,\n height: 110,\n }}\n >\n <FireArt />\n </div>\n ) : null}\n </div>\n {/* Price (or Claim / Done at the top level) */}\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n top: 6,\n height: 60,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n }}\n >\n {maxed ? (\n <span\n style={outlinedText(46, claimed ? \"#cfd8e6\" : \"#ffe066\")}\n >\n {claimed ? \"Done\" : \"Claim\"}\n </span>\n ) : price.amount > 0 && price.currencyID ? (\n <>\n <Icon\n glyph={catalog.iconOf({\n kind: \"currency\",\n id: price.currencyID,\n amount: 0,\n })}\n size={56}\n />\n <span style={outlinedText(48, short ? \"#ff7b7b\" : \"#fff\")}>\n {formatAmount(price.amount)}\n </span>\n </>\n ) : (\n <span style={outlinedText(44)}>\n {b.IsDamaged ? \"Repair\" : \"Build\"}\n </span>\n )}\n </div>\n {/* A star per level: outlines for every level, filled up to the current one. */}\n <div\n style={{\n position: \"absolute\",\n left: 10,\n right: 10,\n bottom: 22,\n height: 48,\n display: \"flex\",\n justifyContent: \"center\",\n gap: 2,\n }}\n >\n {Array.from({ length: Math.min(6, max) }, (_, k) => (\n <Star\n key={k}\n filled={k < level}\n gold={k === max - 1 && k < level}\n />\n ))}\n </div>\n </div>\n );\n })}\n </div>\n </Stage>\n );\n}\n\nfunction Star({ filled, gold }: { filled: boolean; gold: boolean }): ReactNode {\n const fill = !filled ? \"rgba(16,23,46,.45)\" : gold ? \"#ffd23a\" : \"#e6ecf5\";\n return (\n <svg viewBox=\"0 0 42 45\" width={38} height={41} aria-hidden>\n <path\n d=\"M21 3 L26.5 15.5 L40 16.8 L29.8 25.8 L32.8 39 L21 32 L9.2 39 L12.2 25.8 L2 16.8 L15.5 15.5 Z\"\n fill={fill}\n stroke=\"#17193a\"\n strokeWidth=\"3.5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n );\n}\n\nfunction HouseGlyph(): ReactNode {\n return (\n <svg width={22} height={22} viewBox=\"0 0 24 24\" aria-hidden>\n <path\n d=\"M3 11L12 3l9 8\"\n fill=\"#e8534e\"\n stroke=\"#fff\"\n strokeWidth=\"2\"\n strokeLinejoin=\"round\"\n />\n <rect\n x=\"6\"\n y=\"10\"\n width=\"12\"\n height=\"11\"\n rx=\"1.5\"\n fill=\"#fff4dc\"\n stroke=\"#fff\"\n strokeWidth=\"1.5\"\n />\n <rect x=\"10.5\" y=\"14\" width=\"3\" height=\"7\" fill=\"#8a5a2b\" />\n </svg>\n );\n}\n"
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
"path": "components/InteractionPanel.tsx",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
"path": "components/modal/stage.tsx",
|
|
94
|
-
"content": "import {\n forwardRef,\n useCallback,\n useEffect,\n useImperativeHandle,\n useLayoutEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n Icon,\n KitPortal,\n formatAmount,\n useCatalog,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// The stage every full-screen board panel plays on (the reference's Attack/Raid/Special panels):\n// a dim that fades in first, then the panel squashes in over it; everything inside is laid out in the\n// reference's 1080×1920 canvas units and scaled to the screen like its CanvasScaler, so the numbers\n// taken from the scene (positions, sizes, font sizes) are used as they are.\n\nexport const DESIGN_W = 1080;\nexport const DESIGN_H = 1920;\n\n// ── Timing ──────────────────────────────────────────────────────────────────────────────────────\n\n/** Next frame — a timer when the tab is hidden (no frames there), so a sequence still finishes. */\nfunction nextFrame(cb: () => void): void {\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\")\n setTimeout(cb, 16);\n else requestAnimationFrame(cb);\n}\n\n/** Runs `step(t)` for t 0→1 over `seconds` (real time), resolves at the end. */\nexport function tween(\n seconds: number,\n step: (t: number) => void,\n): Promise<void> {\n return new Promise((resolve) => {\n if (seconds <= 0) {\n step(1);\n resolve();\n return;\n }\n const start = performance.now();\n const tick = (): void => {\n const t = Math.min(1, (performance.now() - start) / (seconds * 1000));\n step(t);\n if (t >= 1) resolve();\n else nextFrame(tick);\n };\n nextFrame(tick);\n });\n}\n\nexport const sleep = (seconds: number): Promise<void> =>\n new Promise((r) => setTimeout(r, Math.max(0, seconds * 1000)));\n\nexport const easeOutBack = (t: number): number => {\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;\n};\nexport const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;\nexport const easeInCubic = (t: number): number => t ** 3;\n\n// ── Stage ───────────────────────────────────────────────────────────────────────────────────────\n\nexport interface StageHandle {\n /** Perlin-like decaying shake of the panel, in canvas units (TriggerShake). */\n shake(intensity: number, seconds: number, frequency?: number): void;\n /** Scale punch of the whole panel at an impact (BoardPunchZoom). */\n punchZoom(intensity: number, seconds: number, springy: boolean): void;\n /** Soft full-screen edge tint (EdgeFlashRoutine). */\n edgeFlash(color: string, seconds: number): void;\n /** Near-opaque full-frame flash at contact (ImpactFlashRoutine). */\n impactFlash(color: string, seconds: number): void;\n /** Fades the panel out (FadeOut: alpha and scale to zero) and the dim with it. */\n fadeOut(seconds?: number): Promise<void>;\n}\n\n/**\n * The full-screen stage. `revealed` turns the panel on after the dim has faded in; the backdrop\n * fills the screen (cover), `children` sit in the scaled 1080×1920 canvas.\n */\nexport const Stage = forwardRef<\n StageHandle,\n {\n backdrop?: ReactNode;\n children: ReactNode;\n /** Dim colour behind the panel (the reference's `Dim` / `Panal_Dim_Black`). */\n dim?: string;\n /** Fired once the dim has faded in and the panel starts squashing in. */\n onRevealed?: () => void;\n /** Canvas height: 1920, or taller for a panel laid out for a tall phone (the raid: 2340). */\n height?: number;\n }\n>(function Stage(\n {\n backdrop,\n children,\n dim = \"rgba(0,0,0,.72)\",\n onRevealed,\n height = DESIGN_H,\n },\n ref,\n) {\n const host = useRef<HTMLDivElement>(null);\n const panel = useRef<HTMLDivElement>(null);\n const shaker = useRef<HTMLDivElement>(null);\n const edge = useRef<HTMLDivElement>(null);\n const impact = useRef<HTMLDivElement>(null);\n const dimRef = useRef<HTMLDivElement>(null);\n const [scale, setScale] = useState(0.4);\n const [revealed, setRevealed] = useState(false);\n const shakeId = useRef(0);\n\n useLayoutEffect(() => {\n const el = host.current;\n if (!el) return;\n const fit = (): void => {\n const r = el.getBoundingClientRect();\n if (r.width > 0 && r.height > 0)\n setScale(Math.min(r.width / DESIGN_W, r.height / height));\n };\n fit();\n const ro = new ResizeObserver(fit);\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // Dim first (0.2 s), then the panel: alpha in with a squash & stretch settle (FadeIn, 0.3 s).\n useEffect(() => {\n let alive = true;\n const d = dimRef.current;\n void (async () => {\n await tween(0.2, (t) => {\n if (d) d.style.opacity = String(t);\n });\n if (!alive) return;\n setRevealed(true);\n onRevealed?.();\n const p = panel.current;\n await tween(0.3, (t) => {\n if (!p) return;\n const e = easeOutBack(t);\n const sx = 1.15 + (1 - 1.15) * e;\n const sy = 0.85 + (1 - 0.85) * e;\n p.style.opacity = String(t);\n p.style.transform = `scale(${sx}, ${sy})`;\n });\n })();\n return () => {\n alive = false;\n };\n }, []);\n\n useImperativeHandle(\n ref,\n (): StageHandle => ({\n shake(intensity, seconds, frequency = 24) {\n const el = shaker.current;\n if (!el) return;\n const id = ++shakeId.current;\n const seed = Math.random() * 100;\n void tween(seconds, (t) => {\n if (id !== shakeId.current) return;\n const decay = 1 - t;\n const time = (performance.now() / 1000) * frequency;\n const ox = (noise(time, seed) - 0.5) * 2 * intensity * decay;\n const oy = (noise(seed, time) - 0.5) * 2 * intensity * decay;\n el.style.translate = t >= 1 ? \"0 0\" : `${ox}px ${oy}px`;\n });\n },\n punchZoom(intensity, seconds, springy) {\n const el = shaker.current;\n if (!el) return;\n void tween(seconds, (t) => {\n const e = springy ? easeOutBack(t) : easeOutCubic(t);\n el.style.scale = String(1 + Math.sin(e * Math.PI) * intensity);\n }).then(() => {\n el.style.scale = \"1\";\n });\n },\n edgeFlash(color, seconds) {\n const el = edge.current;\n if (!el) return;\n el.style.boxShadow = `inset 0 0 ${180 * scale + 40}px ${60 * scale + 10}px ${color}`;\n void tween(seconds, (t) => {\n el.style.opacity = String(t < 0.5 ? t * 2 : (1 - t) * 2);\n });\n },\n impactFlash(color, seconds) {\n const el = impact.current;\n if (!el) return;\n el.style.background = color;\n void tween(seconds, (t) => {\n el.style.opacity = String(1 - t);\n });\n },\n async fadeOut(seconds = 0.3) {\n const p = panel.current;\n const d = dimRef.current;\n await tween(seconds, (t) => {\n const k = 1 - t;\n if (p) {\n p.style.opacity = String(k);\n p.style.transform = `scale(${k})`;\n }\n if (d) d.style.opacity = String(k);\n });\n },\n }),\n [scale],\n );\n\n return (\n <KitPortal>\n <div\n ref={host}\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n fontFamily: \"inherit\",\n }}\n >\n <div\n ref={dimRef}\n style={{\n position: \"absolute\",\n inset: 0,\n background: dim,\n opacity: 0,\n }}\n />\n <div\n ref={panel}\n style={{\n position: \"absolute\",\n inset: 0,\n opacity: 0,\n visibility: revealed ? \"visible\" : \"hidden\",\n transformOrigin: \"50% 50%\",\n }}\n >\n <div ref={shaker} style={{ position: \"absolute\", inset: 0 }}>\n {backdrop}\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: \"50%\",\n width: DESIGN_W,\n height,\n marginLeft: -DESIGN_W / 2,\n marginTop: -height / 2,\n transform: `scale(${scale})`,\n transformOrigin: \"50% 50%\",\n }}\n >\n {children}\n </div>\n </div>\n <div\n ref={edge}\n style={{\n position: \"absolute\",\n inset: 0,\n opacity: 0,\n pointerEvents: \"none\",\n }}\n />\n <div\n ref={impact}\n style={{\n position: \"absolute\",\n inset: 0,\n opacity: 0,\n pointerEvents: \"none\",\n }}\n />\n </div>\n </div>\n </KitPortal>\n );\n});\n\n/** Smooth 2-D value noise in 0..1 — stands in for Mathf.PerlinNoise. */\nfunction noise(x: number, y: number): number {\n const s =\n Math.sin(x * 1.7 + y * 3.1) * 0.5 +\n Math.sin(x * 0.63 - y * 2.3 + 1.3) * 0.5;\n return 0.5 + s * 0.5;\n}\n\n/** Positions a box in canvas units the Unity way: anchor at the centre, `x` right, `y` UP. */\nexport function at(\n x: number,\n y: number,\n w: number,\n h: number,\n pivotY = 0.5,\n canvasH = DESIGN_H,\n): CSSProperties {\n return {\n position: \"absolute\",\n left: DESIGN_W / 2 + x - w / 2,\n top: canvasH / 2 - y - h * (1 - pivotY),\n width: w,\n height: h,\n };\n}\n\n/** The reference's chunky outlined game text (TMP with outline + drop shadow). */\nexport function outlinedText(\n size: number,\n color = \"#fff\",\n stroke = \"#17193a\",\n): CSSProperties {\n const w = Math.max(2, Math.round(size / 14));\n return {\n color,\n fontSize: size,\n fontWeight: 900,\n lineHeight: 1.05,\n WebkitTextStroke: `${w}px ${stroke}`,\n paintOrder: \"stroke fill\",\n textShadow: `0 ${w + 2}px 0 ${stroke}`,\n letterSpacing: 0.5,\n };\n}\n\n// ── Target info (avatar + name at the top) ──────────────────────────────────────────────────────\n\nexport interface TargetInfoHandle {\n /** Squash/stretch + wobble when the target is hit (TargetAvatarReaction). */\n react(big: boolean): void;\n}\n\nexport const TargetInfo = forwardRef<\n TargetInfoHandle,\n { name: string; avatarUrl?: string | null; top: number }\n>(function TargetInfo({ name, avatarUrl: rawAvatar, top }, ref) {\n // Titles made for Unity send a resource key (\"avatar_default\"), not an address — draw the initial then.\n const avatarUrl =\n rawAvatar && /^(https?:|data:|\\/)/.test(rawAvatar) ? rawAvatar : null;\n const avatar = useRef<HTMLDivElement>(null);\n useImperativeHandle(ref, () => ({\n react(big) {\n const el = avatar.current;\n if (!el) return;\n const amp = 0.2 * (big ? 1.4 : 1);\n void tween(big ? 0.5 : 0.3, (t) => {\n const decay = 1 - t;\n const s = 1 + Math.sin(t * Math.PI * 3) * amp * decay;\n const r = Math.sin(t * Math.PI * 2) * (big ? 8 : 5) * decay;\n el.style.transform = t >= 1 ? \"\" : `scale(${s}) rotate(${r}deg)`;\n });\n },\n }));\n const initial = (name.trim()[0] ?? \"?\").toUpperCase();\n return (\n <div\n style={{\n position: \"absolute\",\n left: DESIGN_W / 2 - 100,\n top,\n width: 200,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n }}\n >\n <div\n ref={avatar}\n style={{\n width: 200,\n height: 200,\n borderRadius: \"50%\",\n border: \"10px solid #fff\",\n boxShadow: \"0 0 0 7px #17193a, 0 12px 0 7px rgba(0,0,0,.35)\",\n background: avatarUrl\n ? `center / cover no-repeat url(\"${avatarUrl}\")`\n : \"linear-gradient(180deg,#7fc4ff,#3a7be0)\",\n display: \"grid\",\n placeItems: \"center\",\n boxSizing: \"border-box\",\n }}\n >\n {avatarUrl ? null : <span style={outlinedText(96)}>{initial}</span>}\n </div>\n <div\n style={{\n ...outlinedText(46),\n marginTop: 22,\n whiteSpace: \"nowrap\",\n maxWidth: 700,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {name}\n </div>\n </div>\n );\n});\n\n// ── Result banner (PanelFrame04 + OutTitle tab, bottom of the screen) ───────────────────────────\n\n/**\n * The result banner: a navy plate across the screen with a yellow stripe and a yellow tab holding the\n * title (\"HIT!\", \"BIG REWARD!\"), the reward icon and its amount counting up. It is pulled up from\n * below the screen like the reference's banner clip.\n */\nexport function ResultBanner({\n title,\n line,\n amount,\n tickSeconds,\n bottom,\n emptyText,\n}: {\n title: string;\n line: ResourceLine | null;\n amount: number;\n tickSeconds: number;\n /** Distance of the plate's bottom edge from the canvas bottom, in canvas units. */\n bottom: number;\n emptyText?: string;\n}): ReactNode {\n const catalog = useCatalog();\n const root = useRef<HTMLDivElement>(null);\n const titleRef = useRef<HTMLDivElement>(null);\n const amountRef = useRef<HTMLSpanElement>(null);\n const [shown, setShown] = useState(0);\n\n useEffect(() => {\n let alive = true;\n const r = root.current;\n const ti = titleRef.current;\n void (async () => {\n await tween(0.35, (t) => {\n if (r)\n r.style.transform = `translateY(${(1 - easeOutBack(t)) * 420}px)`;\n });\n await sleep(0.1);\n await tween(0.28, (t) => {\n if (ti) ti.style.transform = `scale(${easeOutBack(t)})`;\n });\n if (amount > 0) {\n await tween(tickSeconds, (t) => {\n if (alive) setShown(Math.round(amount * t));\n });\n const a = amountRef.current;\n await tween(0.2, (t) => {\n if (a)\n a.style.transform = `scale(${1 + Math.sin(t * Math.PI) * 0.25})`;\n });\n }\n })();\n return () => {\n alive = false;\n };\n }, []);\n\n return (\n <div\n ref={root}\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom,\n height: 240,\n transform: \"translateY(420px)\",\n }}\n >\n {/* Plate */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background:\n \"linear-gradient(180deg,#3a3a54 0%,#36364e 60%,#2b2b40 100%)\",\n borderTop: \"7px solid #17193a\",\n borderBottom: \"7px solid #17193a\",\n boxShadow: \"0 14px 0 rgba(0,0,0,.35)\",\n }}\n />\n {/* Yellow stripe along the top edge */}\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n top: 0,\n height: 33,\n background: \"#ffce0d\",\n borderBottom: \"6px solid #17193a\",\n }}\n />\n {/* Title tab */}\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -118,\n width: 800,\n height: 150,\n marginLeft: -400,\n background: \"#ffce0d\",\n clipPath: \"polygon(9% 0, 91% 0, 100% 100%, 0 100%)\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -112,\n width: 776,\n height: 140,\n marginLeft: -388,\n background:\n \"linear-gradient(180deg,#ffe26b 0%,#ffce0d 55%,#f5b800 100%)\",\n clipPath: \"polygon(9.4% 0, 90.6% 0, 99.5% 100%, 0.5% 100%)\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <div\n ref={titleRef}\n style={{\n ...outlinedText(92, \"#fff\", \"#7a3b00\"),\n transform: \"scale(0)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {title}\n </div>\n </div>\n {/* Reward */}\n <div\n style={{\n position: \"absolute\",\n inset: \"40px 0 0 0\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 24,\n }}\n >\n {line ? (\n <>\n <span data-board-reward-icon style={{ display: \"inline-grid\" }}>\n <Icon glyph={catalog.iconOf(line)} size={135} />\n </span>\n <span\n ref={amountRef}\n style={{ ...outlinedText(140), display: \"inline-block\" }}\n >\n {formatAmount(shown)}\n </span>\n </>\n ) : (\n <span style={outlinedText(64)}>{emptyText ?? \"\"}</span>\n )}\n </div>\n </div>\n );\n}\n\n/** Imperative restart of a CSS animation on an element (Animator.Play from frame 0). */\nexport function useReplay(): [\n (el: HTMLElement | null, animation: string) => void,\n] {\n const replay = useCallback((el: HTMLElement | null, animation: string) => {\n if (!el) return;\n el.style.animation = \"none\";\n void el.offsetWidth;\n el.style.animation = animation;\n }, []);\n return [replay];\n}\n"
|
|
94
|
+
"content": "import {\n forwardRef,\n useCallback,\n useEffect,\n useImperativeHandle,\n useLayoutEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n Icon,\n KitPortal,\n formatAmount,\n useCatalog,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// The stage every full-screen board panel plays on (the reference's Attack/Raid/Special panels):\n// a dim that fades in first, then the panel squashes in over it; everything inside is laid out in the\n// reference's 1080×1920 canvas units and scaled to the screen like its CanvasScaler, so the numbers\n// taken from the scene (positions, sizes, font sizes) are used as they are.\n\nexport const DESIGN_W = 1080;\nexport const DESIGN_H = 1920;\n\n// ── Timing ──────────────────────────────────────────────────────────────────────────────────────\n\n/** Next frame — a timer when the tab is hidden (no frames there), so a sequence still finishes. */\nfunction nextFrame(cb: () => void): void {\n if (typeof document !== \"undefined\" && document.visibilityState === \"hidden\")\n setTimeout(cb, 16);\n else requestAnimationFrame(cb);\n}\n\n/** Runs `step(t)` for t 0→1 over `seconds` (real time), resolves at the end. */\nexport function tween(\n seconds: number,\n step: (t: number) => void,\n): Promise<void> {\n return new Promise((resolve) => {\n if (seconds <= 0) {\n step(1);\n resolve();\n return;\n }\n const start = performance.now();\n const tick = (): void => {\n const t = Math.min(1, (performance.now() - start) / (seconds * 1000));\n step(t);\n if (t >= 1) resolve();\n else nextFrame(tick);\n };\n nextFrame(tick);\n });\n}\n\nexport const sleep = (seconds: number): Promise<void> =>\n new Promise((r) => setTimeout(r, Math.max(0, seconds * 1000)));\n\nexport const easeOutBack = (t: number): number => {\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;\n};\nexport const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;\nexport const easeInCubic = (t: number): number => t ** 3;\n\n// ── Stage ───────────────────────────────────────────────────────────────────────────────────────\n\nexport interface StageHandle {\n /** Perlin-like decaying shake of the panel, in canvas units (TriggerShake). */\n shake(intensity: number, seconds: number, frequency?: number): void;\n /** Scale punch of the whole panel at an impact (BoardPunchZoom). */\n punchZoom(intensity: number, seconds: number, springy: boolean): void;\n /** Soft full-screen edge tint (EdgeFlashRoutine). */\n edgeFlash(color: string, seconds: number): void;\n /** Near-opaque full-frame flash at contact (ImpactFlashRoutine). */\n impactFlash(color: string, seconds: number): void;\n /** Fades the panel out (FadeOut: alpha and scale to zero) and the dim with it. */\n fadeOut(seconds?: number): Promise<void>;\n}\n\n/**\n * The full-screen stage. `revealed` turns the panel on after the dim has faded in; the backdrop\n * fills the screen (cover), `children` sit in the scaled 1080×1920 canvas.\n */\nexport const Stage = forwardRef<\n StageHandle,\n {\n backdrop?: ReactNode;\n children: ReactNode;\n /** Dim colour behind the panel (the reference's `Dim` / `Panal_Dim_Black`). */\n dim?: string;\n /** Fired once the dim has faded in and the panel starts squashing in. */\n onRevealed?: () => void;\n /** Canvas height: 1920, or taller for a panel laid out for a tall phone (the raid: 2340). */\n height?: number;\n }\n>(function Stage(\n {\n backdrop,\n children,\n dim = \"rgba(0,0,0,.72)\",\n onRevealed,\n height = DESIGN_H,\n },\n ref,\n) {\n const host = useRef<HTMLDivElement>(null);\n const panel = useRef<HTMLDivElement>(null);\n const shaker = useRef<HTMLDivElement>(null);\n const edge = useRef<HTMLDivElement>(null);\n const impact = useRef<HTMLDivElement>(null);\n const dimRef = useRef<HTMLDivElement>(null);\n const [scale, setScale] = useState(0.4);\n const [revealed, setRevealed] = useState(false);\n const shakeId = useRef(0);\n\n useLayoutEffect(() => {\n const el = host.current;\n if (!el) return;\n const fit = (): void => {\n const r = el.getBoundingClientRect();\n if (r.width > 0 && r.height > 0)\n setScale(Math.min(r.width / DESIGN_W, r.height / height));\n };\n fit();\n const ro = new ResizeObserver(fit);\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // Dim first (0.2 s), then the panel: alpha in with a squash & stretch settle (FadeIn, 0.3 s).\n useEffect(() => {\n let alive = true;\n const d = dimRef.current;\n void (async () => {\n await tween(0.2, (t) => {\n if (d) d.style.opacity = String(t);\n });\n if (!alive) return;\n setRevealed(true);\n onRevealed?.();\n const p = panel.current;\n await tween(0.3, (t) => {\n if (!p) return;\n const e = easeOutBack(t);\n const sx = 1.15 + (1 - 1.15) * e;\n const sy = 0.85 + (1 - 0.85) * e;\n p.style.opacity = String(t);\n p.style.transform = `scale(${sx}, ${sy})`;\n });\n })();\n return () => {\n alive = false;\n };\n }, []);\n\n useImperativeHandle(\n ref,\n (): StageHandle => ({\n shake(intensity, seconds, frequency = 24) {\n const el = shaker.current;\n if (!el) return;\n const id = ++shakeId.current;\n const seed = Math.random() * 100;\n void tween(seconds, (t) => {\n if (id !== shakeId.current) return;\n const decay = 1 - t;\n const time = (performance.now() / 1000) * frequency;\n const ox = (noise(time, seed) - 0.5) * 2 * intensity * decay;\n const oy = (noise(seed, time) - 0.5) * 2 * intensity * decay;\n el.style.translate = t >= 1 ? \"0 0\" : `${ox}px ${oy}px`;\n });\n },\n punchZoom(intensity, seconds, springy) {\n const el = shaker.current;\n if (!el) return;\n void tween(seconds, (t) => {\n const e = springy ? easeOutBack(t) : easeOutCubic(t);\n el.style.scale = String(1 + Math.sin(e * Math.PI) * intensity);\n }).then(() => {\n el.style.scale = \"1\";\n });\n },\n edgeFlash(color, seconds) {\n const el = edge.current;\n if (!el) return;\n el.style.boxShadow = `inset 0 0 ${180 * scale + 40}px ${60 * scale + 10}px ${color}`;\n void tween(seconds, (t) => {\n el.style.opacity = String(t < 0.5 ? t * 2 : (1 - t) * 2);\n });\n },\n impactFlash(color, seconds) {\n const el = impact.current;\n if (!el) return;\n el.style.background = color;\n void tween(seconds, (t) => {\n el.style.opacity = String(1 - t);\n });\n },\n async fadeOut(seconds = 0.3) {\n const p = panel.current;\n const d = dimRef.current;\n await tween(seconds, (t) => {\n const k = 1 - t;\n if (p) {\n p.style.opacity = String(k);\n p.style.transform = `scale(${k})`;\n }\n if (d) d.style.opacity = String(k);\n });\n },\n }),\n [scale],\n );\n\n return (\n <KitPortal>\n <div\n ref={host}\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n fontFamily: \"inherit\",\n }}\n >\n <div\n ref={dimRef}\n style={{\n position: \"absolute\",\n inset: 0,\n background: dim,\n opacity: 0,\n }}\n />\n <div\n ref={panel}\n style={{\n position: \"absolute\",\n inset: 0,\n opacity: 0,\n visibility: revealed ? \"visible\" : \"hidden\",\n transformOrigin: \"50% 50%\",\n }}\n >\n <div ref={shaker} style={{ position: \"absolute\", inset: 0 }}>\n {backdrop}\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: \"50%\",\n width: DESIGN_W,\n height,\n marginLeft: -DESIGN_W / 2,\n marginTop: -height / 2,\n transform: `scale(${scale})`,\n transformOrigin: \"50% 50%\",\n }}\n >\n {children}\n </div>\n </div>\n <div\n ref={edge}\n style={{\n position: \"absolute\",\n inset: 0,\n opacity: 0,\n pointerEvents: \"none\",\n }}\n />\n <div\n ref={impact}\n style={{\n position: \"absolute\",\n inset: 0,\n opacity: 0,\n pointerEvents: \"none\",\n }}\n />\n </div>\n </div>\n </KitPortal>\n );\n});\n\n/** Smooth 2-D value noise in 0..1 — stands in for Mathf.PerlinNoise. */\nfunction noise(x: number, y: number): number {\n const s =\n Math.sin(x * 1.7 + y * 3.1) * 0.5 +\n Math.sin(x * 0.63 - y * 2.3 + 1.3) * 0.5;\n return 0.5 + s * 0.5;\n}\n\n/** Positions a box in canvas units the Unity way: anchor at the centre, `x` right, `y` UP. */\nexport function at(\n x: number,\n y: number,\n w: number,\n h: number,\n pivotY = 0.5,\n canvasH = DESIGN_H,\n): CSSProperties {\n return {\n position: \"absolute\",\n left: DESIGN_W / 2 + x - w / 2,\n top: canvasH / 2 - y - h * (1 - pivotY),\n width: w,\n height: h,\n };\n}\n\n/** The reference's chunky outlined game text (TMP with outline + drop shadow). */\nexport function outlinedText(\n size: number,\n color = \"#fff\",\n stroke = \"#17193a\",\n): CSSProperties {\n const w = Math.max(2, Math.round(size / 14));\n return {\n color,\n fontSize: size,\n fontWeight: 900,\n lineHeight: 1.05,\n WebkitTextStroke: `${w}px ${stroke}`,\n paintOrder: \"stroke fill\",\n letterSpacing: 0.5,\n };\n}\n\n// ── Target info (avatar + name at the top) ──────────────────────────────────────────────────────\n\nexport interface TargetInfoHandle {\n /** Squash/stretch + wobble when the target is hit (TargetAvatarReaction). */\n react(big: boolean): void;\n}\n\nexport const TargetInfo = forwardRef<\n TargetInfoHandle,\n { name: string; avatarUrl?: string | null; top: number }\n>(function TargetInfo({ name, avatarUrl: rawAvatar, top }, ref) {\n // Titles made for Unity send a resource key (\"avatar_default\"), not an address — draw the initial then.\n const avatarUrl =\n rawAvatar && /^(https?:|data:|\\/)/.test(rawAvatar) ? rawAvatar : null;\n const avatar = useRef<HTMLDivElement>(null);\n useImperativeHandle(ref, () => ({\n react(big) {\n const el = avatar.current;\n if (!el) return;\n const amp = 0.2 * (big ? 1.4 : 1);\n void tween(big ? 0.5 : 0.3, (t) => {\n const decay = 1 - t;\n const s = 1 + Math.sin(t * Math.PI * 3) * amp * decay;\n const r = Math.sin(t * Math.PI * 2) * (big ? 8 : 5) * decay;\n el.style.transform = t >= 1 ? \"\" : `scale(${s}) rotate(${r}deg)`;\n });\n },\n }));\n const initial = (name.trim()[0] ?? \"?\").toUpperCase();\n return (\n <div\n style={{\n position: \"absolute\",\n left: DESIGN_W / 2 - 100,\n top,\n width: 200,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n }}\n >\n <div\n ref={avatar}\n style={{\n width: 200,\n height: 200,\n borderRadius: \"50%\",\n border: \"10px solid #fff\",\n boxShadow: \"0 0 0 7px #17193a\",\n background: avatarUrl\n ? `center / cover no-repeat url(\"${avatarUrl}\")`\n : \"linear-gradient(180deg,#7fc4ff,#3a7be0)\",\n display: \"grid\",\n placeItems: \"center\",\n boxSizing: \"border-box\",\n }}\n >\n {avatarUrl ? null : <span style={outlinedText(96)}>{initial}</span>}\n </div>\n <div\n style={{\n ...outlinedText(46),\n marginTop: 22,\n whiteSpace: \"nowrap\",\n maxWidth: 700,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {name}\n </div>\n </div>\n );\n});\n\n// ── Result banner (PanelFrame04 + OutTitle tab, bottom of the screen) ───────────────────────────\n\n/**\n * The result banner: a navy plate across the screen with a yellow stripe and a yellow tab holding the\n * title (\"HIT!\", \"BIG REWARD!\"), the reward icon and its amount counting up. It is pulled up from\n * below the screen like the reference's banner clip.\n */\nexport function ResultBanner({\n title,\n line,\n amount,\n tickSeconds,\n bottom,\n emptyText,\n}: {\n title: string;\n line: ResourceLine | null;\n amount: number;\n tickSeconds: number;\n /** Distance of the plate's bottom edge from the canvas bottom, in canvas units. */\n bottom: number;\n emptyText?: string;\n}): ReactNode {\n const catalog = useCatalog();\n const root = useRef<HTMLDivElement>(null);\n const titleRef = useRef<HTMLDivElement>(null);\n const amountRef = useRef<HTMLSpanElement>(null);\n const [shown, setShown] = useState(0);\n\n useEffect(() => {\n let alive = true;\n const r = root.current;\n const ti = titleRef.current;\n void (async () => {\n await tween(0.35, (t) => {\n if (r)\n r.style.transform = `translateY(${(1 - easeOutBack(t)) * 420}px)`;\n });\n await sleep(0.1);\n await tween(0.28, (t) => {\n if (ti) ti.style.transform = `scale(${easeOutBack(t)})`;\n });\n if (amount > 0) {\n await tween(tickSeconds, (t) => {\n if (alive) setShown(Math.round(amount * t));\n });\n const a = amountRef.current;\n await tween(0.2, (t) => {\n if (a)\n a.style.transform = `scale(${1 + Math.sin(t * Math.PI) * 0.25})`;\n });\n }\n })();\n return () => {\n alive = false;\n };\n }, []);\n\n return (\n <div\n ref={root}\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom,\n height: 240,\n transform: \"translateY(420px)\",\n }}\n >\n {/* Plate */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background:\n \"linear-gradient(180deg,#3a3a54 0%,#36364e 60%,#2b2b40 100%)\",\n borderTop: \"7px solid #17193a\",\n borderBottom: \"7px solid #17193a\",\n boxShadow: \"none\",\n }}\n />\n {/* Yellow stripe along the top edge */}\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n top: 0,\n height: 33,\n background: \"#ffce0d\",\n borderBottom: \"6px solid #17193a\",\n }}\n />\n {/* Title tab */}\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -118,\n width: 800,\n height: 150,\n marginLeft: -400,\n background: \"#ffce0d\",\n clipPath: \"polygon(9% 0, 91% 0, 100% 100%, 0 100%)\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -112,\n width: 776,\n height: 140,\n marginLeft: -388,\n background:\n \"linear-gradient(180deg,#ffe26b 0%,#ffce0d 55%,#f5b800 100%)\",\n clipPath: \"polygon(9.4% 0, 90.6% 0, 99.5% 100%, 0.5% 100%)\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <div\n ref={titleRef}\n style={{\n ...outlinedText(92, \"#fff\", \"#7a3b00\"),\n transform: \"scale(0)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {title}\n </div>\n </div>\n {/* Reward */}\n <div\n style={{\n position: \"absolute\",\n inset: \"40px 0 0 0\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 24,\n }}\n >\n {line ? (\n <>\n <span data-board-reward-icon style={{ display: \"inline-grid\" }}>\n <Icon glyph={catalog.iconOf(line)} size={135} />\n </span>\n <span\n ref={amountRef}\n style={{ ...outlinedText(140), display: \"inline-block\" }}\n >\n {formatAmount(shown)}\n </span>\n </>\n ) : (\n <span style={outlinedText(64)}>{emptyText ?? \"\"}</span>\n )}\n </div>\n </div>\n );\n}\n\n/** Imperative restart of a CSS animation on an element (Animator.Play from frame 0). */\nexport function useReplay(): [\n (el: HTMLElement | null, animation: string) => void,\n] {\n const replay = useCallback((el: HTMLElement | null, animation: string) => {\n if (!el) return;\n el.style.animation = \"none\";\n void el.offsetWidth;\n el.style.animation = animation;\n }, []);\n return [replay];\n}\n"
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
"path": "components/panelStyles.ts",
|
|
@@ -99,19 +99,19 @@
|
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
"path": "components/RaidPanel.tsx",
|
|
102
|
-
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { BoardPendingInteraction, ResourceGrant } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { readBoardConfig } from \"../data/boardConfig\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport {\n BarsArt,\n RaidBackdrop,\n SafeDoorArt,\n SafeFrameArt,\n TumblerArt,\n type BarsTone,\n} from \"./modal/art\";\nimport {\n ResultBanner,\n Stage,\n TargetInfo,\n at,\n easeOutBack,\n easeOutCubic,\n outlinedText,\n sleep,\n tween,\n type StageHandle,\n type TargetInfoHandle,\n} from \"./modal/stage\";\n\n// The raid (the reference's BetterRaidPanel + BetterRaidCell, BoardSurvival BoardScene): a blue vault\n// with the target on top, a legend of the three prizes (three frames each, lit one by one as the\n// player finds them, with the amount each prize pays), and twelve safes. Tapping a safe unscrews its\n// tumbler, flings the door off and pops the ingot out; three of a kind ends the raid — the rest of the\n// safes open, the winning ones glow, and the result banner rises with the loot counting up.\n\n/** The raid canvas is laid out for a tall phone: the reference's grid runs below 1920. */\nconst H = 2340;\nconst CELL = 330;\n/** Cell centres: GridLayoutGroup 3 columns, cell 330, spacing 50 × 0, upper-centre, grid at (0, -230). */\nconst CELLS: Array<[number, number]> = Array.from({ length: 12 }, (_, i) => {\n const col = i % 3;\n const row = Math.floor(i / 3);\n return [(col - 1) * 380, 270 - CELL / 2 - row * CELL];\n});\n\ntype Tier = 1 | 2 | 3;\nconst TONE: Record<Tier, BarsTone> = { 1: \"bronze\", 2: \"silver\", 3: \"gold\" };\nconst OUTCOME: Record<Tier, \"Small\" | \"Medium\" | \"Big\"> = {\n 1: \"Small\",\n 2: \"Medium\",\n 3: \"Big\",\n};\nconst LABEL: Record<Tier, string> = {\n 1: \"SMALL REWARD\",\n 2: \"MEDIUM REWARD\",\n 3: \"LARGE REWARD\",\n};\nconst RESULT: Record<Tier, string> = {\n 1: \"SMALL REWARD!\",\n 2: \"MEDIUM REWARD!\",\n 3: \"BIG REWARD!\",\n};\n\n/** Legend rows: frame size, frame centres x, row centre y (relative to the legend centre), text y. */\nconst LEGEND_Y = 530;\nconst ROWS: Record<\n Tier,\n {\n size: number;\n xs: number[];\n y: number;\n textY: number;\n bg: string;\n light: string;\n }\n> = {\n 3: {\n size: 150,\n xs: [-446.25, -281.25, -116.25],\n y: 202.5 - 75,\n textY: 127.5,\n bg: \"#ffd850\",\n light: \"#fffb4e\",\n },\n 2: {\n size: 125,\n xs: [-394.25, -261.25, -128.25],\n y: 202.5 - 160 - 62.5,\n textY: -20,\n bg: \"#00c0ff\",\n light: \"#35fbff\",\n },\n 1: {\n size: 110,\n xs: [-361.25, -241.25, -121.25],\n y: 202.5 - 295 - 55,\n textY: -147.5,\n bg: \"#53d804\",\n light: \"#b2f11f\",\n },\n};\n\nfunction symbolOf(v: unknown): Tier | 0 {\n const n =\n typeof v === \"string\"\n ? (({ Small: 1, Medium: 2, Big: 3 } as Record<string, number>)[v] ??\n Number(v))\n : Number(v);\n return n === 1 || n === 2 || n === 3 ? n : 0;\n}\n\ninterface Preview {\n line: ResourceLine | null;\n}\n\nexport function RaidPanel({\n pending,\n onDone,\n}: {\n pending: BoardPendingInteraction;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const catalog = useCatalog();\n const stage = useRef<StageHandle>(null);\n const target = useRef<TargetInfoHandle>(null);\n const cellEls = useRef<Array<HTMLDivElement | null>>([]);\n const frameEls = useRef<Record<string, HTMLDivElement | null>>({});\n\n const fast =\n readBoardConfig(client).RaidMode === \"Fast\" ||\n (pending.RaidLayout ?? []).some(\n (c, i) =>\n symbolOf(c?.Symbol) > 0 && !(pending.OpenedIndices ?? []).includes(i),\n );\n const layout = (pending.RaidLayout ?? []).map((c) => symbolOf(c?.Symbol));\n const [opened, setOpened] = useState<Record<number, Tier | 0>>(() => {\n const o: Record<number, Tier | 0> = {};\n for (const i of pending.OpenedIndices ?? []) o[i] = layout[i] ?? 0;\n return o;\n });\n /** Opened indices, updated synchronously on every tap (rapid taps must not see a stale list). */\n const openedList = useRef<number[]>([...(pending.OpenedIndices ?? [])]);\n const [revealing, setRevealing] = useState<Set<number>>(new Set());\n const [finished, setFinished] = useState<Tier | null>(null);\n const [glow, setGlow] = useState<Set<number>>(new Set());\n const [nearMiss, setNearMiss] = useState<Tier | null>(null);\n const [result, setResult] = useState<{\n tier: Tier | null;\n line: ResourceLine | null;\n amount: number;\n lines: ResourceLine[];\n } | null>(null);\n const [intro, setIntro] = useState(true);\n const busy = useRef(false);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n // Prize preview per tier: \"how much a small/medium/large raid pays\" (RaidRewardPreview).\n const previewSrc = (\n pending as BoardPendingInteraction & {\n RaidRewardPreview?: {\n AttackerGainByOutcome?: Record<\n string,\n {\n Entries?: Array<\n { Amount?: number | null } & Record<string, unknown>\n > | null;\n }\n > | null;\n };\n }\n ).RaidRewardPreview?.AttackerGainByOutcome;\n const preview: Record<Tier, Preview> = {\n 1: { line: null },\n 2: { line: null },\n 3: { line: null },\n };\n for (const t of [1, 2, 3] as Tier[]) {\n const bundle = previewSrc?.[OUTCOME[t]];\n const lines = grantLines({ Standard: bundle } as ResourceGrant).filter(\n (l) => l.amount > 0,\n );\n preview[t] = { line: lines[0] ?? null };\n }\n\n // The legend counts only the safes the player opened — not the rest revealed at the end.\n const counts: Record<Tier, number> = { 1: 0, 2: 0, 3: 0 };\n for (const idx of openedList.current) {\n const v = opened[idx];\n if (v) counts[v]++;\n }\n\n // Staggered scale-in of the closed safes (0.05 s apart, 0.3 s EaseOutBack).\n useEffect(() => {\n void (async () => {\n await sleep(0.55);\n const runs = CELLS.map((_, i) => {\n const el = cellEls.current[i];\n if (!el || opened[i] !== undefined) return Promise.resolve();\n return sleep(0.05 * i).then(() =>\n tween(0.3, (t) => {\n el.style.transform = `scale(${easeOutBack(t)})`;\n }),\n );\n });\n await Promise.all(runs);\n setIntro(false);\n })();\n }, []);\n\n const revealCell = async (i: number, tier: Tier | 0): Promise<void> => {\n const el = cellEls.current[i];\n const door = el?.querySelector<HTMLElement>(\"[data-door]\");\n const tumbler = el?.querySelector<HTMLElement>(\"[data-tumbler]\");\n play(\"click\");\n // Unscrew the tumbler: two turns, EaseOutCubic, 0.45 s.\n await tween(0.45, (t) => {\n if (tumbler)\n tumbler.style.transform = `rotate(${-720 * easeOutCubic(t)}deg)`;\n });\n // Anticipation bump against the fly-out, then the door and tumbler fly down 260 and fade.\n await tween(0.08, (t) => {\n const y = -18 * easeOutBack(t);\n if (door) door.style.translate = `0 ${y}px`;\n if (tumbler) tumbler.style.translate = `0 ${y}px`;\n });\n play(\"close\");\n await tween(0.35, (t) => {\n const y = -18 + 278 * easeOutCubic(t);\n for (const d of [door, tumbler]) {\n if (!d) continue;\n d.style.translate = `0 ${y}px`;\n d.style.opacity = String(1 - t);\n }\n });\n setOpened((o) => ({ ...o, [i]: tier }));\n play(tier === 3 ? \"reward\" : \"coin\");\n await sleep(0.02); // let the opened safe render its ingot\n const reward = el?.querySelector<HTMLElement>(\"[data-reward]\");\n await tween(0.3, (t) => {\n if (reward) reward.style.transform = `scale(${easeOutBack(t)})`;\n });\n setRevealing((r) => {\n const next = new Set(r);\n next.delete(i);\n return next;\n });\n };\n\n const litFrame = (tier: Tier, index: number): void => {\n const f = frameEls.current[`${tier}:${index}`];\n void tween(0.4, (t) => {\n if (f) f.style.transform = `scale(${1 + Math.sin(t * Math.PI) * 0.3})`;\n });\n if (index === 1) setNearMiss(tier);\n };\n\n const finish = async (\n tier: Tier | null,\n grant: ResourceGrant | null | undefined,\n fullLayout: Array<Tier | 0>,\n ): Promise<void> => {\n // Open the rest at once, light the winning set, hold a beat.\n setOpened((o) => {\n const next = { ...o };\n fullLayout.forEach((s, i) => {\n if (next[i] === undefined) next[i] = s;\n });\n return next;\n });\n setNearMiss(null);\n if (tier)\n setGlow(\n new Set(\n fullLayout.map((s, i) => (s === tier ? i : -1)).filter((i) => i >= 0),\n ),\n );\n await sleep(0.8);\n\n const lines = grantLines(grant).filter((l) => l.amount > 0);\n const first = lines[0] ?? null;\n kit.fx.reserve(lines);\n setResult({ tier, line: first, amount: first?.amount ?? 0, lines });\n stage.current?.shake(10, 0.2, 22);\n stage.current?.edgeFlash(\"rgba(255,255,255,.2)\", 0.4);\n play(\"reward\");\n await sleep(0.35 + 0.1 + 0.28);\n if (first) kit.fx.burst(document.querySelector(\"[data-board-reward-icon]\"));\n await sleep((first ? 1.2 : 0) + 2.0);\n\n const icon =\n document\n .querySelector(\"[data-board-reward-icon]\")\n ?.getBoundingClientRect() ?? null;\n const fade = stage.current?.fadeOut();\n kit.fx.fly(lines, () => icon);\n await fade;\n onDone();\n };\n\n const tap = async (i: number): Promise<void> => {\n if (intro || finished || openedList.current.includes(i)) return;\n stage.current?.shake(4, 0.12, 26);\n\n if (fast && layout.length > 0) {\n const tier = layout[i] ?? 0;\n openedList.current.push(i);\n const openedNow = [...openedList.current];\n const tally: Record<Tier, number> = { 1: 0, 2: 0, 3: 0 };\n for (const idx of openedNow) {\n const s = layout[idx] ?? 0;\n if (s) tally[s]++;\n }\n const won = ([1, 2, 3] as Tier[]).find((t) => tally[t] >= 3) ?? null;\n if (won) setFinished(won);\n setRevealing((r) => new Set(r).add(i));\n await revealCell(i, tier);\n target.current?.react(tier === 3);\n if (tier) litFrame(tier, tally[tier] - 1);\n if (!won) return;\n const res = await client.gameLoop.boardLoopRaidFast(openedNow);\n if (!res.ok) {\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const grant =\n res.data.Operation?.Grant ?? res.data.DualResult?.FromResult?.Grant;\n await finish(won, grant, layout);\n return;\n }\n\n // Sequential: every dig is a server round-trip; the grid waits for it.\n if (busy.current) return;\n busy.current = true;\n openedList.current.push(i);\n setRevealing((r) => new Set(r).add(i));\n const res = await client.gameLoop.boardLoopRaid(i);\n busy.current = false;\n if (!res.ok) {\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const tier = symbolOf(res.data.FoundSymbol);\n await revealCell(i, tier);\n target.current?.react(tier === 3);\n if (tier) litFrame(tier, counts[tier]);\n const status = res.data.Status ?? \"\";\n if (status.startsWith(\"FINISHED\") || (res.data.AttemptsLeft ?? 1) <= 0) {\n const outcome = symbolOf(res.data.Outcome);\n setFinished(outcome || 1);\n const full = (res.data.RaidLayout ?? []).map((c) => symbolOf(c?.Symbol));\n const grant =\n res.data.Operation?.Grant ?? res.data.DualResult?.FromResult?.Grant;\n await finish(outcome || null, grant, full.length ? full : layout);\n }\n };\n\n return (\n <Stage\n ref={stage}\n backdrop={<RaidBackdrop />}\n height={H}\n dim=\"rgba(0,0,0,.6)\"\n >\n <TargetInfo\n ref={target}\n name={pending.TargetPublicData?.Username ?? \"???\"}\n avatarUrl={pending.TargetPublicData?.AvatarUrl}\n top={40}\n />\n\n {/* Prize legend */}\n {([3, 2, 1] as Tier[]).map((tier) => {\n const row = ROWS[tier];\n const won = result?.tier === tier ? result : null;\n const p = preview[tier];\n const text =\n won && won.line\n ? `x${formatAmount(won.amount)}`\n : p.line\n ? `x${formatAmount(p.line.amount)}`\n : LABEL[tier];\n const line = won?.line ?? p.line;\n return (\n <div key={tier}>\n {row.xs.map((x, k) => {\n const lit = counts[tier] > k;\n const pulse = nearMiss === tier && !lit;\n return (\n <div\n key={k}\n ref={(el) => {\n frameEls.current[`${tier}:${k}`] = el;\n }}\n style={{\n ...at(x, LEGEND_Y + row.y, row.size, row.size, 0.5, H),\n borderRadius: row.size * 0.2,\n background: `radial-gradient(circle at 50% 40%, ${row.light} 0%, ${row.bg} 70%)`,\n border: \"6px solid #17193a\",\n boxShadow: pulse\n ? \"0 0 0 6px #ff9a00, 0 0 34px 10px rgba(255,154,0,.8)\"\n : \"inset 0 6px 0 rgba(255,255,255,.45), 0 6px 0 rgba(0,0,0,.35)\",\n animation: pulse\n ? \"idos-board-heartbeat .66s ease-in-out infinite\"\n : undefined,\n boxSizing: \"border-box\",\n overflow: \"hidden\",\n }}\n >\n <div style={{ position: \"absolute\", inset: \"8%\" }}>\n <BarsArt tone={TONE[tier]} />\n </div>\n {lit ? null : (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"rgba(0,0,0,.78)\",\n }}\n />\n )}\n </div>\n );\n })}\n <div\n style={{\n ...at(13.75, LEGEND_Y + row.textY, 110, 110, 0.5, H),\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {line ? <Icon glyph={catalog.iconOf(line)} size={100} /> : null}\n </div>\n <div\n style={{\n ...at(295, LEGEND_Y + row.textY, 452, 110, 0.5, H),\n display: \"flex\",\n alignItems: \"center\",\n ...outlinedText(won ? 62 : 54, won ? \"#ffe066\" : \"#fff\"),\n whiteSpace: \"nowrap\",\n }}\n >\n {text}\n </div>\n </div>\n );\n })}\n\n {/* Safes */}\n {CELLS.map(([x, y], i) => {\n const state = opened[i];\n const isOpen = state !== undefined;\n const tier = isOpen ? state : 0;\n return (\n <div\n key={i}\n ref={(el) => {\n cellEls.current[i] = el;\n }}\n onClick={() => void tap(i)}\n style={{\n ...at(x, y, CELL, CELL, 0.5, H),\n transform: isOpen ? undefined : \"scale(0)\",\n cursor: isOpen || finished ? \"default\" : \"pointer\",\n }}\n >\n <SafeFrameArt open={isOpen} />\n {glow.has(i) ? (\n <div\n style={{\n position: \"absolute\",\n inset: 40,\n borderRadius: 30,\n boxShadow: \"0 0 50px 22px rgba(255,236,120,.9)\",\n animation: \"idos-board-breathe 1s ease-in-out infinite\",\n }}\n />\n ) : null}\n {isOpen && tier ? (\n <div\n data-reward\n style={{\n position: \"absolute\",\n inset: 60,\n transform: revealing.has(i) ? \"scale(0)\" : undefined,\n }}\n >\n <BarsArt tone={TONE[tier]} />\n </div>\n ) : null}\n {isOpen ? null : (\n <>\n <div data-door style={{ position: \"absolute\", inset: 0 }}>\n <SafeDoorArt />\n </div>\n <div data-tumbler style={{ position: \"absolute\", inset: 0 }}>\n <TumblerArt />\n </div>\n </>\n )}\n </div>\n );\n })}\n\n {result ? (\n <ResultBanner\n title={result.tier ? RESULT[result.tier] : \"REWARD\"}\n line={result.line}\n amount={result.amount}\n tickSeconds={1.2}\n bottom={190}\n emptyText=\"Nothing was stolen\"\n />\n ) : null}\n </Stage>\n );\n}\n"
|
|
102
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { BoardPendingInteraction, ResourceGrant } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { readBoardConfig } from \"../data/boardConfig\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport {\n BarsArt,\n RaidBackdrop,\n SafeDoorArt,\n SafeFrameArt,\n TumblerArt,\n type BarsTone,\n} from \"./modal/art\";\nimport {\n ResultBanner,\n Stage,\n TargetInfo,\n at,\n easeOutBack,\n easeOutCubic,\n outlinedText,\n sleep,\n tween,\n type StageHandle,\n type TargetInfoHandle,\n} from \"./modal/stage\";\n\n// The raid (the reference's BetterRaidPanel + BetterRaidCell, BoardSurvival BoardScene): a blue vault\n// with the target on top, a legend of the three prizes (three frames each, lit one by one as the\n// player finds them, with the amount each prize pays), and twelve safes. Tapping a safe unscrews its\n// tumbler, flings the door off and pops the ingot out; three of a kind ends the raid — the rest of the\n// safes open, the winning ones glow, and the result banner rises with the loot counting up.\n\n/** The raid canvas is laid out for a tall phone: the reference's grid runs below 1920. */\nconst H = 2340;\nconst CELL = 330;\n/** Cell centres: GridLayoutGroup 3 columns, cell 330, spacing 50 × 0, upper-centre, grid at (0, -230). */\nconst CELLS: Array<[number, number]> = Array.from({ length: 12 }, (_, i) => {\n const col = i % 3;\n const row = Math.floor(i / 3);\n return [(col - 1) * 380, 270 - CELL / 2 - row * CELL];\n});\n\ntype Tier = 1 | 2 | 3;\nconst TONE: Record<Tier, BarsTone> = { 1: \"bronze\", 2: \"silver\", 3: \"gold\" };\nconst OUTCOME: Record<Tier, \"Small\" | \"Medium\" | \"Big\"> = {\n 1: \"Small\",\n 2: \"Medium\",\n 3: \"Big\",\n};\nconst LABEL: Record<Tier, string> = {\n 1: \"SMALL REWARD\",\n 2: \"MEDIUM REWARD\",\n 3: \"LARGE REWARD\",\n};\nconst RESULT: Record<Tier, string> = {\n 1: \"SMALL REWARD!\",\n 2: \"MEDIUM REWARD!\",\n 3: \"BIG REWARD!\",\n};\n\n/** Legend rows: frame size, frame centres x, row centre y (relative to the legend centre), text y. */\nconst LEGEND_Y = 530;\nconst ROWS: Record<\n Tier,\n {\n size: number;\n xs: number[];\n y: number;\n textY: number;\n bg: string;\n light: string;\n }\n> = {\n 3: {\n size: 150,\n xs: [-446.25, -281.25, -116.25],\n y: 202.5 - 75,\n textY: 127.5,\n bg: \"#ffd850\",\n light: \"#fffb4e\",\n },\n 2: {\n size: 125,\n xs: [-394.25, -261.25, -128.25],\n y: 202.5 - 160 - 62.5,\n textY: -20,\n bg: \"#00c0ff\",\n light: \"#35fbff\",\n },\n 1: {\n size: 110,\n xs: [-361.25, -241.25, -121.25],\n y: 202.5 - 295 - 55,\n textY: -147.5,\n bg: \"#53d804\",\n light: \"#b2f11f\",\n },\n};\n\nfunction symbolOf(v: unknown): Tier | 0 {\n const n =\n typeof v === \"string\"\n ? (({ Small: 1, Medium: 2, Big: 3 } as Record<string, number>)[v] ??\n Number(v))\n : Number(v);\n return n === 1 || n === 2 || n === 3 ? n : 0;\n}\n\ninterface Preview {\n line: ResourceLine | null;\n}\n\nexport function RaidPanel({\n pending,\n onDone,\n}: {\n pending: BoardPendingInteraction;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const catalog = useCatalog();\n const stage = useRef<StageHandle>(null);\n const target = useRef<TargetInfoHandle>(null);\n const cellEls = useRef<Array<HTMLDivElement | null>>([]);\n const frameEls = useRef<Record<string, HTMLDivElement | null>>({});\n\n const fast =\n readBoardConfig(client).RaidMode === \"Fast\" ||\n (pending.RaidLayout ?? []).some(\n (c, i) =>\n symbolOf(c?.Symbol) > 0 && !(pending.OpenedIndices ?? []).includes(i),\n );\n const layout = (pending.RaidLayout ?? []).map((c) => symbolOf(c?.Symbol));\n const [opened, setOpened] = useState<Record<number, Tier | 0>>(() => {\n const o: Record<number, Tier | 0> = {};\n for (const i of pending.OpenedIndices ?? []) o[i] = layout[i] ?? 0;\n return o;\n });\n /** Opened indices, updated synchronously on every tap (rapid taps must not see a stale list). */\n const openedList = useRef<number[]>([...(pending.OpenedIndices ?? [])]);\n const [revealing, setRevealing] = useState<Set<number>>(new Set());\n const [finished, setFinished] = useState<Tier | null>(null);\n const [glow, setGlow] = useState<Set<number>>(new Set());\n const [nearMiss, setNearMiss] = useState<Tier | null>(null);\n const [result, setResult] = useState<{\n tier: Tier | null;\n line: ResourceLine | null;\n amount: number;\n lines: ResourceLine[];\n } | null>(null);\n const [intro, setIntro] = useState(true);\n const busy = useRef(false);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n // Prize preview per tier: \"how much a small/medium/large raid pays\" (RaidRewardPreview).\n const previewSrc = (\n pending as BoardPendingInteraction & {\n RaidRewardPreview?: {\n AttackerGainByOutcome?: Record<\n string,\n {\n Entries?: Array<\n { Amount?: number | null } & Record<string, unknown>\n > | null;\n }\n > | null;\n };\n }\n ).RaidRewardPreview?.AttackerGainByOutcome;\n const preview: Record<Tier, Preview> = {\n 1: { line: null },\n 2: { line: null },\n 3: { line: null },\n };\n for (const t of [1, 2, 3] as Tier[]) {\n const bundle = previewSrc?.[OUTCOME[t]];\n const lines = grantLines({ Standard: bundle } as ResourceGrant).filter(\n (l) => l.amount > 0,\n );\n preview[t] = { line: lines[0] ?? null };\n }\n\n // The legend counts only the safes the player opened — not the rest revealed at the end.\n const counts: Record<Tier, number> = { 1: 0, 2: 0, 3: 0 };\n for (const idx of openedList.current) {\n const v = opened[idx];\n if (v) counts[v]++;\n }\n\n // Staggered scale-in of the closed safes (0.05 s apart, 0.3 s EaseOutBack).\n useEffect(() => {\n void (async () => {\n await sleep(0.55);\n const runs = CELLS.map((_, i) => {\n const el = cellEls.current[i];\n if (!el || opened[i] !== undefined) return Promise.resolve();\n return sleep(0.05 * i).then(() =>\n tween(0.3, (t) => {\n el.style.transform = `scale(${easeOutBack(t)})`;\n }),\n );\n });\n await Promise.all(runs);\n setIntro(false);\n })();\n }, []);\n\n const revealCell = async (i: number, tier: Tier | 0): Promise<void> => {\n const el = cellEls.current[i];\n const door = el?.querySelector<HTMLElement>(\"[data-door]\");\n const tumbler = el?.querySelector<HTMLElement>(\"[data-tumbler]\");\n play(\"click\");\n // Unscrew the tumbler: two turns, EaseOutCubic, 0.45 s.\n await tween(0.45, (t) => {\n if (tumbler)\n tumbler.style.transform = `rotate(${-720 * easeOutCubic(t)}deg)`;\n });\n // Anticipation bump against the fly-out, then the door and tumbler fly down 260 and fade.\n await tween(0.08, (t) => {\n const y = -18 * easeOutBack(t);\n if (door) door.style.translate = `0 ${y}px`;\n if (tumbler) tumbler.style.translate = `0 ${y}px`;\n });\n play(\"close\");\n await tween(0.35, (t) => {\n const y = -18 + 278 * easeOutCubic(t);\n for (const d of [door, tumbler]) {\n if (!d) continue;\n d.style.translate = `0 ${y}px`;\n d.style.opacity = String(1 - t);\n }\n });\n setOpened((o) => ({ ...o, [i]: tier }));\n play(tier === 3 ? \"reward\" : \"coin\");\n await sleep(0.02); // let the opened safe render its ingot\n const reward = el?.querySelector<HTMLElement>(\"[data-reward]\");\n await tween(0.3, (t) => {\n if (reward) reward.style.transform = `scale(${easeOutBack(t)})`;\n });\n setRevealing((r) => {\n const next = new Set(r);\n next.delete(i);\n return next;\n });\n };\n\n const litFrame = (tier: Tier, index: number): void => {\n const f = frameEls.current[`${tier}:${index}`];\n void tween(0.4, (t) => {\n if (f) f.style.transform = `scale(${1 + Math.sin(t * Math.PI) * 0.3})`;\n });\n if (index === 1) setNearMiss(tier);\n };\n\n const finish = async (\n tier: Tier | null,\n grant: ResourceGrant | null | undefined,\n fullLayout: Array<Tier | 0>,\n ): Promise<void> => {\n // Open the rest at once, light the winning set, hold a beat.\n setOpened((o) => {\n const next = { ...o };\n fullLayout.forEach((s, i) => {\n if (next[i] === undefined) next[i] = s;\n });\n return next;\n });\n setNearMiss(null);\n if (tier)\n setGlow(\n new Set(\n fullLayout.map((s, i) => (s === tier ? i : -1)).filter((i) => i >= 0),\n ),\n );\n await sleep(0.8);\n\n const lines = grantLines(grant).filter((l) => l.amount > 0);\n const first = lines[0] ?? null;\n kit.fx.reserve(lines);\n setResult({ tier, line: first, amount: first?.amount ?? 0, lines });\n stage.current?.shake(10, 0.2, 22);\n stage.current?.edgeFlash(\"rgba(255,255,255,.2)\", 0.4);\n play(\"reward\");\n await sleep(0.35 + 0.1 + 0.28);\n if (first) kit.fx.burst(document.querySelector(\"[data-board-reward-icon]\"));\n await sleep((first ? 1.2 : 0) + 2.0);\n\n const icon =\n document\n .querySelector(\"[data-board-reward-icon]\")\n ?.getBoundingClientRect() ?? null;\n const fade = stage.current?.fadeOut();\n kit.fx.fly(lines, () => icon);\n await fade;\n onDone();\n };\n\n const tap = async (i: number): Promise<void> => {\n if (intro || finished || openedList.current.includes(i)) return;\n stage.current?.shake(4, 0.12, 26);\n\n if (fast && layout.length > 0) {\n const tier = layout[i] ?? 0;\n openedList.current.push(i);\n const openedNow = [...openedList.current];\n const tally: Record<Tier, number> = { 1: 0, 2: 0, 3: 0 };\n for (const idx of openedNow) {\n const s = layout[idx] ?? 0;\n if (s) tally[s]++;\n }\n const won = ([1, 2, 3] as Tier[]).find((t) => tally[t] >= 3) ?? null;\n if (won) setFinished(won);\n setRevealing((r) => new Set(r).add(i));\n await revealCell(i, tier);\n target.current?.react(tier === 3);\n if (tier) litFrame(tier, tally[tier] - 1);\n if (!won) return;\n const res = await client.gameLoop.boardLoopRaidFast(openedNow);\n if (!res.ok) {\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const grant =\n res.data.Operation?.Grant ?? res.data.DualResult?.FromResult?.Grant;\n await finish(won, grant, layout);\n return;\n }\n\n // Sequential: every dig is a server round-trip; the grid waits for it.\n if (busy.current) return;\n busy.current = true;\n openedList.current.push(i);\n setRevealing((r) => new Set(r).add(i));\n const res = await client.gameLoop.boardLoopRaid(i);\n busy.current = false;\n if (!res.ok) {\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const tier = symbolOf(res.data.FoundSymbol);\n await revealCell(i, tier);\n target.current?.react(tier === 3);\n if (tier) litFrame(tier, counts[tier]);\n const status = res.data.Status ?? \"\";\n if (status.startsWith(\"FINISHED\") || (res.data.AttemptsLeft ?? 1) <= 0) {\n const outcome = symbolOf(res.data.Outcome);\n setFinished(outcome || 1);\n const full = (res.data.RaidLayout ?? []).map((c) => symbolOf(c?.Symbol));\n const grant =\n res.data.Operation?.Grant ?? res.data.DualResult?.FromResult?.Grant;\n await finish(outcome || null, grant, full.length ? full : layout);\n }\n };\n\n return (\n <Stage\n ref={stage}\n backdrop={<RaidBackdrop />}\n height={H}\n dim=\"rgba(0,0,0,.6)\"\n >\n <TargetInfo\n ref={target}\n name={pending.TargetPublicData?.Username ?? \"???\"}\n avatarUrl={pending.TargetPublicData?.AvatarUrl}\n top={40}\n />\n\n {/* Prize legend */}\n {([3, 2, 1] as Tier[]).map((tier) => {\n const row = ROWS[tier];\n const won = result?.tier === tier ? result : null;\n const p = preview[tier];\n const text =\n won && won.line\n ? `x${formatAmount(won.amount)}`\n : p.line\n ? `x${formatAmount(p.line.amount)}`\n : LABEL[tier];\n const line = won?.line ?? p.line;\n return (\n <div key={tier}>\n {row.xs.map((x, k) => {\n const lit = counts[tier] > k;\n const pulse = nearMiss === tier && !lit;\n return (\n <div\n key={k}\n ref={(el) => {\n frameEls.current[`${tier}:${k}`] = el;\n }}\n style={{\n ...at(x, LEGEND_Y + row.y, row.size, row.size, 0.5, H),\n borderRadius: row.size * 0.2,\n background: `radial-gradient(circle at 50% 40%, ${row.light} 0%, ${row.bg} 70%)`,\n border: \"6px solid #17193a\",\n boxShadow: pulse\n ? \"0 0 0 6px #ff9a00, 0 0 34px 10px rgba(255,154,0,.8)\"\n : \"inset 0 6px 0 rgba(255,255,255,.45)\",\n animation: pulse\n ? \"idos-board-heartbeat .66s ease-in-out infinite\"\n : undefined,\n boxSizing: \"border-box\",\n overflow: \"hidden\",\n }}\n >\n <div style={{ position: \"absolute\", inset: \"8%\" }}>\n <BarsArt tone={TONE[tier]} />\n </div>\n {lit ? null : (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"rgba(0,0,0,.78)\",\n }}\n />\n )}\n </div>\n );\n })}\n <div\n style={{\n ...at(13.75, LEGEND_Y + row.textY, 110, 110, 0.5, H),\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {line ? <Icon glyph={catalog.iconOf(line)} size={100} /> : null}\n </div>\n <div\n style={{\n ...at(295, LEGEND_Y + row.textY, 452, 110, 0.5, H),\n display: \"flex\",\n alignItems: \"center\",\n ...outlinedText(won ? 62 : 54, won ? \"#ffe066\" : \"#fff\"),\n whiteSpace: \"nowrap\",\n }}\n >\n {text}\n </div>\n </div>\n );\n })}\n\n {/* Safes */}\n {CELLS.map(([x, y], i) => {\n const state = opened[i];\n const isOpen = state !== undefined;\n const tier = isOpen ? state : 0;\n return (\n <div\n key={i}\n ref={(el) => {\n cellEls.current[i] = el;\n }}\n onClick={() => void tap(i)}\n style={{\n ...at(x, y, CELL, CELL, 0.5, H),\n transform: isOpen ? undefined : \"scale(0)\",\n cursor: isOpen || finished ? \"default\" : \"pointer\",\n }}\n >\n <SafeFrameArt open={isOpen} />\n {glow.has(i) ? (\n <div\n style={{\n position: \"absolute\",\n inset: 40,\n borderRadius: 30,\n boxShadow: \"0 0 50px 22px rgba(255,236,120,.9)\",\n animation: \"idos-board-breathe 1s ease-in-out infinite\",\n }}\n />\n ) : null}\n {isOpen && tier ? (\n <div\n data-reward\n style={{\n position: \"absolute\",\n inset: 60,\n transform: revealing.has(i) ? \"scale(0)\" : undefined,\n }}\n >\n <BarsArt tone={TONE[tier]} />\n </div>\n ) : null}\n {isOpen ? null : (\n <>\n <div data-door style={{ position: \"absolute\", inset: 0 }}>\n <SafeDoorArt />\n </div>\n <div data-tumbler style={{ position: \"absolute\", inset: 0 }}>\n <TumblerArt />\n </div>\n </>\n )}\n </div>\n );\n })}\n\n {result ? (\n <ResultBanner\n title={result.tier ? RESULT[result.tier] : \"REWARD\"}\n line={result.line}\n amount={result.amount}\n tickSeconds={1.2}\n bottom={190}\n emptyText=\"Nothing was stolen\"\n />\n ) : null}\n </Stage>\n );\n}\n"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
"path": "components/RollButton.tsx",
|
|
106
|
-
"content": "import type { CSSProperties, PointerEvent, ReactNode } from \"react\";\n\n// The big round ROLL button and its AUTO state (the reference's RollButtonAutoRollVisual, Monopoly GO\n// style). It only draws: the parent owns the input and the auto-roll logic and passes the state in.\n\nconst SIZE = 112;\nconst RING_R = 52;\nconst RING_LEN = 2 * Math.PI * RING_R;\n\n// RollButtonAutoRollVisual colours.\nconst NORMAL = [\"#4f8cff\", \"#2a5fd6\"] as const;\nconst AUTO = [\"#38e1ff\", \"#16a9d6\"] as const;\nconst CHARGE = \"#ffb21e\";\n\nexport interface RollButtonProps {\n auto: boolean;\n /** Auto is on but waits (a pending action, out of dice). */\n paused: boolean;\n busy: boolean;\n disabled: boolean;\n /** Hold-to-auto progress, 0..1. */\n charge: number;\n pressed: boolean;\n onPointerDown: (e: PointerEvent<HTMLButtonElement>) => void;\n onPointerUp: (e: PointerEvent<HTMLButtonElement>) => void;\n onPointerLeave: (e: PointerEvent<HTMLButtonElement>) => void;\n}\n\nexport function RollButton(props: RollButtonProps): ReactNode {\n const { auto, paused, busy, disabled, charge, pressed } = props;\n const [top, deep] = auto ? AUTO : NORMAL;\n const button: CSSProperties = {\n position: \"relative\",\n width: SIZE,\n height: SIZE,\n borderRadius: \"50%\",\n // The kit's ink frame and glossy plate: a lighter crown, the colour, a darker lip.\n border: \"5px solid var(--idos-ui-ink, #17193a)\",\n padding: 0,\n background: `radial-gradient(circle at 50% 30%, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 40%, ${deep} 78%)`,\n boxShadow: `inset 0 -6px 0 rgba(0,0,0,.22), inset 0 3px 0 rgba(255,255,255,.45)
|
|
106
|
+
"content": "import type { CSSProperties, PointerEvent, ReactNode } from \"react\";\n\n// The big round ROLL button and its AUTO state (the reference's RollButtonAutoRollVisual, Monopoly GO\n// style). It only draws: the parent owns the input and the auto-roll logic and passes the state in.\n\nconst SIZE = 112;\nconst RING_R = 52;\nconst RING_LEN = 2 * Math.PI * RING_R;\n\n// RollButtonAutoRollVisual colours.\nconst NORMAL = [\"#4f8cff\", \"#2a5fd6\"] as const;\nconst AUTO = [\"#38e1ff\", \"#16a9d6\"] as const;\nconst CHARGE = \"#ffb21e\";\n\nexport interface RollButtonProps {\n auto: boolean;\n /** Auto is on but waits (a pending action, out of dice). */\n paused: boolean;\n busy: boolean;\n disabled: boolean;\n /** Hold-to-auto progress, 0..1. */\n charge: number;\n pressed: boolean;\n onPointerDown: (e: PointerEvent<HTMLButtonElement>) => void;\n onPointerUp: (e: PointerEvent<HTMLButtonElement>) => void;\n onPointerLeave: (e: PointerEvent<HTMLButtonElement>) => void;\n}\n\nexport function RollButton(props: RollButtonProps): ReactNode {\n const { auto, paused, busy, disabled, charge, pressed } = props;\n const [top, deep] = auto ? AUTO : NORMAL;\n const button: CSSProperties = {\n position: \"relative\",\n width: SIZE,\n height: SIZE,\n borderRadius: \"50%\",\n // The kit's ink frame and glossy plate: a lighter crown, the colour, a darker lip.\n border: \"5px solid var(--idos-ui-ink, #17193a)\",\n padding: 0,\n background: `radial-gradient(circle at 50% 30%, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 40%, ${deep} 78%)`,\n boxShadow: `inset 0 -6px 0 rgba(0,0,0,.22), inset 0 3px 0 rgba(255,255,255,.45)`,\n cursor: disabled ? \"default\" : \"pointer\",\n opacity: disabled ? 0.55 : paused ? 0.7 : 1,\n transform: pressed ? \"translateY(4px) scale(.95)\" : \"none\",\n transition: \"transform 90ms ease-out, background 200ms, opacity 200ms\",\n animation:\n !pressed && !disabled && !busy && !auto\n ? \"idos-board-breathe 1.8s ease-in-out infinite\"\n : undefined,\n pointerEvents: \"auto\",\n touchAction: \"none\",\n userSelect: \"none\",\n WebkitUserSelect: \"none\",\n overflow: \"visible\",\n fontFamily: \"inherit\",\n };\n return (\n <button\n type=\"button\"\n style={button}\n disabled={disabled}\n onPointerDown={props.onPointerDown}\n onPointerUp={props.onPointerUp}\n onPointerLeave={props.onPointerLeave}\n onContextMenu={(e) => e.preventDefault()}\n aria-label={auto ? \"Auto roll on — tap to stop\" : \"Roll — hold for auto\"}\n >\n {/* Rotating sheen while auto rolls. */}\n {auto ? (\n <span\n style={{\n position: \"absolute\",\n inset: 4,\n borderRadius: \"50%\",\n background:\n \"conic-gradient(from 0deg, transparent 0 70%, rgba(255,255,255,.55) 80%, transparent 90%)\",\n animation: paused\n ? undefined\n : \"idos-board-spin 1.6s linear infinite\",\n }}\n />\n ) : null}\n {/* Gloss. */}\n <span\n style={{\n position: \"absolute\",\n left: \"16%\",\n right: \"16%\",\n top: 6,\n height: \"38%\",\n borderRadius: \"50%\",\n background:\n \"linear-gradient(180deg, rgba(255,255,255,.55), rgba(255,255,255,0))\",\n pointerEvents: \"none\",\n }}\n />\n {/* Hold-to-auto charge ring. */}\n <svg\n width={SIZE}\n height={SIZE}\n viewBox={`0 0 ${SIZE} ${SIZE}`}\n style={{\n position: \"absolute\",\n left: -4,\n top: -4,\n transform: \"rotate(-90deg)\",\n pointerEvents: \"none\",\n }}\n >\n {charge > 0 ? (\n <circle\n cx={SIZE / 2}\n cy={SIZE / 2}\n r={RING_R}\n fill=\"none\"\n stroke={CHARGE}\n strokeWidth={7}\n strokeLinecap=\"round\"\n strokeDasharray={RING_LEN}\n strokeDashoffset={RING_LEN * (1 - charge)}\n style={{ filter: `drop-shadow(0 0 6px ${CHARGE})` }}\n />\n ) : null}\n </svg>\n <span\n style={{\n position: \"relative\",\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n gap: 2,\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 24,\n letterSpacing: 1,\n textShadow: \"0 0 2px rgba(0,0,0,.4)\",\n pointerEvents: \"none\",\n }}\n >\n <DiceGlyph />\n {busy && !auto ? \"…\" : auto ? \"AUTO\" : \"ROLL\"}\n </span>\n </button>\n );\n}\n\nfunction DiceGlyph(): ReactNode {\n return (\n <svg width={34} height={22} viewBox=\"0 0 34 22\" aria-hidden>\n <rect\n x=\"1\"\n y=\"3\"\n width=\"17\"\n height=\"17\"\n rx=\"4\"\n fill=\"#fff\"\n transform=\"rotate(-10 9.5 11.5)\"\n />\n <circle cx=\"6\" cy=\"9\" r=\"1.8\" fill=\"#1b1b24\" />\n <circle cx=\"12\" cy=\"14\" r=\"1.8\" fill=\"#1b1b24\" />\n <rect\n x=\"15\"\n y=\"2\"\n width=\"17\"\n height=\"17\"\n rx=\"4\"\n fill=\"#fff\"\n transform=\"rotate(12 23.5 10.5)\"\n />\n <circle cx=\"20\" cy=\"6\" r=\"1.8\" fill=\"#1b1b24\" />\n <circle cx=\"24\" cy=\"10.5\" r=\"1.8\" fill=\"#d0302b\" />\n <circle cx=\"28\" cy=\"15\" r=\"1.8\" fill=\"#1b1b24\" />\n </svg>\n );\n}\n"
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"path": "components/RollControls.tsx",
|
|
110
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type PointerEvent,\n type ReactNode,\n} from \"react\";\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { useBoardState, useUserState } from \"../react/hooks\";\nimport { useStatus } from \"../react/status\";\nimport {\n buildStepPath,\n readBoardConfig,\n resolveLandingCategory,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { RollButton } from \"./RollButton\";\nimport { setRolling, usePanelBusy } from \"../react/boardBusy\";\n\n// The roll: one tap rolls once; holding the button charges a ring and switches to AUTO, which keeps\n// rolling while there are dice and nothing waits for the player (RollMultiplierController). The\n// multiplier cycles through the title's AllowedRollMultipliers and steps down by itself when the dice\n// run short of it, with a flash and a shake so the player notices.\n\nconst HOLD_MS = 600;\nconst AUTO_GAP_MS = 350;\nconst FLOAT_MS = 1600;\n\ninterface Float {\n id: number;\n line: ResourceLine;\n offset: number;\n}\n\nconst wrap: CSSProperties = {\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 14,\n pointerEvents: \"none\",\n};\n\nconst sideChip: CSSProperties = {\n pointerEvents: \"auto\",\n minWidth: 64,\n height: 44,\n borderRadius: 22,\n border: \"3px solid #fff\",\n background: \"linear-gradient(180deg, #ffd85a 0%, #f0a020 100%)\",\n boxShadow: \"0 4px 0 #b06a08, 0 8px 14px rgba(0,0,0,.3)\",\n color: \"#5a2d00\",\n fontWeight: 900,\n fontSize: 20,\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n display: \"inline-flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n};\n\nconst diceChip: CSSProperties = {\n pointerEvents: \"auto\",\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n minWidth: 64,\n height: 40,\n padding: \"0 12px 0 6px\",\n borderRadius: 20,\n background: \"rgba(10, 30, 80, .72)\",\n border: \"2px solid rgba(255,255,255,.85)\",\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 17,\n boxShadow: \"0 6px 12px rgba(0,0,0,.25)\",\n};\n\nexport function RollControls({\n paused: pausedByPanel = false,\n onRollIntercept,\n}: {\n paused?: boolean;\n /** Called before a roll; `true` = it was used for something else (a pending panel reopened). */\n onRollIntercept?: () => boolean;\n}): ReactNode {\n const panelBusy = usePanelBusy();\n const paused = pausedByPanel || panelBusy;\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const board = useBoardState();\n const state = useUserState();\n const { setStatus } = useStatus();\n const catalog = useCatalog();\n const play = useSound();\n\n const cfg = readBoardConfig(client);\n const multipliers = (\n cfg.AllowedRollMultipliers?.length ? cfg.AllowedRollMultipliers : [1]\n )\n .slice()\n .sort((a, b) => a - b);\n const rollCurrency = cfg.RollCurrencyID;\n const dice = rollCurrency\n ? (state?.InventoryV2?.VirtualCurrencies?.[rollCurrency]?.Amount ?? 0)\n : Number.POSITIVE_INFINITY;\n\n const [mult, setMult] = useState<number>(multipliers[0] ?? 1);\n const [busy, setBusy] = useState(false);\n const [auto, setAuto] = useState(false);\n const [charge, setCharge] = useState(0);\n const [pressed, setPressed] = useState(false);\n const [flash, setFlash] = useState(0);\n const [floats, setFloats] = useState<Float[]>([]);\n const busyRef = useRef(false);\n const hold = useRef<{\n start: number;\n frame: number;\n enabledAuto: boolean;\n } | null>(null);\n const floatId = useRef(0);\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n\n // Not enough dice for the chosen multiplier: step down to the biggest one the dice cover.\n useEffect(() => {\n if (!rollCurrency || dice >= mult) return;\n const fit =\n [...multipliers].reverse().find((m) => m <= Math.max(1, dice)) ?? 1;\n if (fit !== mult) {\n setMult(fit);\n setFlash((n) => n + 1);\n }\n }, [dice, mult, multipliers, rollCurrency]);\n\n const showRewards = useCallback(\n (grant: ResourceGrant | null | undefined): void => {\n const lines = grantLines(grant).filter((l) => l.amount > 0);\n if (lines.length === 0) return;\n play(\"coin\");\n const born = lines.map((line, i) => ({\n id: ++floatId.current,\n line,\n offset: i,\n }));\n setFloats((f) => [...f, ...born]);\n setTimeout(\n () => {\n const ids = new Set(born.map((b) => b.id));\n setFloats((f) => f.filter((x) => !ids.has(x.id)));\n },\n FLOAT_MS + born.length * 120,\n );\n },\n [play],\n );\n\n const roll = useCallback(async (): Promise<void> => {\n if (busyRef.current) return;\n if (onRollIntercept?.()) return;\n busyRef.current = true;\n setBusy(true);\n setRolling(true);\n try {\n const from = client.data.user.state?.GameLoop?.Board?.Position ?? 0;\n const result = await client.gameLoop.boardLoopRoll(mult);\n if (!result.ok) {\n setAuto(false);\n setStatus(`Roll failed: ${result.error}`, \"error\");\n return;\n }\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, data.DiceValues);\n await controller.moveToken(path, {\n landing: resolveLandingCategory(data),\n });\n showRewards(data.Operation?.Grant);\n setStatus(\n `Rolled ${data.Steps ?? path.length} → tile ${data.NewPosition} (${data.LandedTileType ?? \"?\"})`,\n \"success\",\n );\n } finally {\n busyRef.current = false;\n setBusy(false);\n setRolling(false);\n }\n }, [\n client,\n controller,\n mult,\n tileCount,\n setStatus,\n showRewards,\n onRollIntercept,\n ]);\n\n // AUTO: the next roll as soon as the last one has played out — paused while the player has to act.\n useEffect(() => {\n if (!auto || busy || paused) return;\n if (dice < 1) {\n setAuto(false);\n return;\n }\n const t = setTimeout(() => void roll(), AUTO_GAP_MS);\n return () => clearTimeout(t);\n }, [auto, busy, paused, dice, roll]);\n\n const stopHold = (): void => {\n if (hold.current) cancelAnimationFrame(hold.current.frame);\n hold.current = null;\n setCharge(0);\n setPressed(false);\n };\n\n const onPointerDown = (e: PointerEvent<HTMLButtonElement>): void => {\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n try {\n e.currentTarget.setPointerCapture(e.pointerId);\n } catch {\n // The pointer is already gone (or synthetic) — the hold still works without capture.\n }\n setPressed(true);\n if (auto) {\n hold.current = { start: performance.now(), frame: 0, enabledAuto: false };\n return;\n }\n const h = { start: performance.now(), frame: 0, enabledAuto: false };\n hold.current = h;\n // AUTO switches on by a timer: frames only draw the ring, and a throttled tab has none.\n const timer = setTimeout(() => {\n if (hold.current !== h || h.enabledAuto) return;\n h.enabledAuto = true;\n setAuto(true);\n setCharge(0);\n play(\"claim\");\n }, HOLD_MS);\n const tick = (): void => {\n if (hold.current !== h || h.enabledAuto) {\n clearTimeout(timer);\n return;\n }\n setCharge(Math.min(1, (performance.now() - h.start) / HOLD_MS));\n h.frame = requestAnimationFrame(tick);\n };\n h.frame = requestAnimationFrame(tick);\n };\n\n const onPointerUp = (): void => {\n const h = hold.current;\n stopHold();\n if (!h) return;\n if (auto && !h.enabledAuto) {\n setAuto(false); // a tap while auto rolls stops it\n return;\n }\n if (h.enabledAuto) return;\n if (!auto && performance.now() - h.start >= HOLD_MS) {\n // Held long enough even if the timer was throttled (background tab): that is a hold.\n setAuto(true);\n play(\"claim\");\n return;\n }\n if (!busy && !paused) {\n play(\"click\");\n void roll();\n }\n };\n\n const cycleMultiplier = (): void => {\n const affordable = multipliers.filter(\n (m) => !rollCurrency || m <= Math.max(1, dice),\n );\n const i = affordable.indexOf(mult);\n setMult(affordable[(i + 1) % affordable.length] ?? 1);\n play(\"tab\");\n };\n\n const noDice = rollCurrency !== undefined && dice < 1;\n\n return (\n <div style={wrap}>\n {rollCurrency ? (\n <div style={diceChip} data-resource-counter={rollCurrency}>\n <Icon\n glyph={catalog.iconOf({\n kind: \"currency\",\n id: rollCurrency,\n amount: 0,\n })}\n size={26}\n />\n {formatAmount(dice)}\n </div>\n ) : (\n <div style={{ minWidth: 64 }} />\n )}\n\n <div style={{ position: \"relative\" }}>\n <RollButton\n auto={auto}\n paused={auto && (paused || busy)}\n busy={busy}\n disabled={noDice || (!auto && paused)}\n charge={charge}\n pressed={pressed}\n onPointerDown={onPointerDown}\n onPointerUp={onPointerUp}\n onPointerLeave={() =>\n hold.current && !hold.current.enabledAuto && stopHold()\n }\n />\n {floats.map((f) => (\n <div\n key={f.id}\n style={{\n position: \"absolute\",\n left: \"50%\",\n bottom: \"100%\",\n marginBottom: 8 + f.offset * 34,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: \"4px 12px 4px 6px\",\n borderRadius: 18,\n background: \"rgba(10, 30, 80, .78)\",\n border: \"2px solid #ffd85a\",\n color: \"#ffe27a\",\n fontWeight: 900,\n fontSize: 18,\n whiteSpace: \"nowrap\",\n pointerEvents: \"none\",\n animation: `idos-board-float ${FLOAT_MS}ms ease-out ${f.offset * 120}ms both`,\n }}\n >\n <Icon glyph={catalog.iconOf(f.line)} size={24} />+\n {formatAmount(f.line.amount)}\n </div>\n ))}\n </div>\n\n {multipliers.length > 1 ? (\n <button\n key={flash}\n type=\"button\"\n style={{\n ...sideChip,\n animation: flash > 0 ? \"idos-board-shake .45s ease-out\" : undefined,\n }}\n onClick={cycleMultiplier}\n disabled={busy && !auto}\n aria-label=\"Roll multiplier\"\n >\n <span\n style={{\n animation:\n flash > 0 ? \"idos-board-flash .6s ease-out\" : undefined,\n }}\n >\n ×{mult}\n </span>\n </button>\n ) : (\n <div style={{ minWidth: 64 }} />\n )}\n </div>\n );\n}\n"
|
|
110
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type PointerEvent,\n type ReactNode,\n} from \"react\";\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient, useBoardController } from \"../react/context\";\nimport { useBoardState, useUserState } from \"../react/hooks\";\nimport { useStatus } from \"../react/status\";\nimport {\n buildStepPath,\n readBoardConfig,\n resolveLandingCategory,\n resolveTiles,\n} from \"../data/boardConfig\";\nimport { RollButton } from \"./RollButton\";\nimport { setRolling, usePanelBusy } from \"../react/boardBusy\";\n\n// The roll: one tap rolls once; holding the button charges a ring and switches to AUTO, which keeps\n// rolling while there are dice and nothing waits for the player (RollMultiplierController). The\n// multiplier cycles through the title's AllowedRollMultipliers and steps down by itself when the dice\n// run short of it, with a flash and a shake so the player notices.\n\nconst HOLD_MS = 600;\nconst AUTO_GAP_MS = 350;\nconst FLOAT_MS = 1600;\n\ninterface Float {\n id: number;\n line: ResourceLine;\n offset: number;\n}\n\nconst wrap: CSSProperties = {\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 14,\n pointerEvents: \"none\",\n};\n\nconst sideChip: CSSProperties = {\n pointerEvents: \"auto\",\n minWidth: 64,\n height: 44,\n borderRadius: 22,\n border: \"3px solid #fff\",\n background: \"linear-gradient(180deg, #ffd85a 0%, #f0a020 100%)\",\n boxShadow: \"none\",\n color: \"#5a2d00\",\n fontWeight: 900,\n fontSize: 20,\n cursor: \"pointer\",\n fontFamily: \"inherit\",\n display: \"inline-flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n};\n\nconst diceChip: CSSProperties = {\n pointerEvents: \"auto\",\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n minWidth: 64,\n height: 40,\n padding: \"0 12px 0 6px\",\n borderRadius: 20,\n background: \"rgba(10, 30, 80, .72)\",\n border: \"2px solid rgba(255,255,255,.85)\",\n color: \"#fff\",\n fontWeight: 900,\n fontSize: 17,\n boxShadow: \"none\",\n};\n\nexport function RollControls({\n paused: pausedByPanel = false,\n onRollIntercept,\n}: {\n paused?: boolean;\n /** Called before a roll; `true` = it was used for something else (a pending panel reopened). */\n onRollIntercept?: () => boolean;\n}): ReactNode {\n const panelBusy = usePanelBusy();\n const paused = pausedByPanel || panelBusy;\n const client = useIDosGamesClient();\n const controller = useBoardController();\n const board = useBoardState();\n const state = useUserState();\n const { setStatus } = useStatus();\n const catalog = useCatalog();\n const play = useSound();\n\n const cfg = readBoardConfig(client);\n const multipliers = (\n cfg.AllowedRollMultipliers?.length ? cfg.AllowedRollMultipliers : [1]\n )\n .slice()\n .sort((a, b) => a - b);\n const rollCurrency = cfg.RollCurrencyID;\n const dice = rollCurrency\n ? (state?.InventoryV2?.VirtualCurrencies?.[rollCurrency]?.Amount ?? 0)\n : Number.POSITIVE_INFINITY;\n\n const [mult, setMult] = useState<number>(multipliers[0] ?? 1);\n const [busy, setBusy] = useState(false);\n const [auto, setAuto] = useState(false);\n const [charge, setCharge] = useState(0);\n const [pressed, setPressed] = useState(false);\n const [flash, setFlash] = useState(0);\n const [floats, setFloats] = useState<Float[]>([]);\n const busyRef = useRef(false);\n const hold = useRef<{\n start: number;\n frame: number;\n enabledAuto: boolean;\n } | null>(null);\n const floatId = useRef(0);\n\n const stage = board?.StageLevel ?? 1;\n const tileCount = resolveTiles(cfg, stage).length;\n\n // Not enough dice for the chosen multiplier: step down to the biggest one the dice cover.\n useEffect(() => {\n if (!rollCurrency || dice >= mult) return;\n const fit =\n [...multipliers].reverse().find((m) => m <= Math.max(1, dice)) ?? 1;\n if (fit !== mult) {\n setMult(fit);\n setFlash((n) => n + 1);\n }\n }, [dice, mult, multipliers, rollCurrency]);\n\n const showRewards = useCallback(\n (grant: ResourceGrant | null | undefined): void => {\n const lines = grantLines(grant).filter((l) => l.amount > 0);\n if (lines.length === 0) return;\n play(\"coin\");\n const born = lines.map((line, i) => ({\n id: ++floatId.current,\n line,\n offset: i,\n }));\n setFloats((f) => [...f, ...born]);\n setTimeout(\n () => {\n const ids = new Set(born.map((b) => b.id));\n setFloats((f) => f.filter((x) => !ids.has(x.id)));\n },\n FLOAT_MS + born.length * 120,\n );\n },\n [play],\n );\n\n const roll = useCallback(async (): Promise<void> => {\n if (busyRef.current) return;\n if (onRollIntercept?.()) return;\n busyRef.current = true;\n setBusy(true);\n setRolling(true);\n try {\n const from = client.data.user.state?.GameLoop?.Board?.Position ?? 0;\n const result = await client.gameLoop.boardLoopRoll(mult);\n if (!result.ok) {\n setAuto(false);\n setStatus(`Roll failed: ${result.error}`, \"error\");\n return;\n }\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, data.DiceValues);\n await controller.moveToken(path, {\n landing: resolveLandingCategory(data),\n });\n showRewards(data.Operation?.Grant);\n setStatus(\n `Rolled ${data.Steps ?? path.length} → tile ${data.NewPosition} (${data.LandedTileType ?? \"?\"})`,\n \"success\",\n );\n } finally {\n busyRef.current = false;\n setBusy(false);\n setRolling(false);\n }\n }, [\n client,\n controller,\n mult,\n tileCount,\n setStatus,\n showRewards,\n onRollIntercept,\n ]);\n\n // AUTO: the next roll as soon as the last one has played out — paused while the player has to act.\n useEffect(() => {\n if (!auto || busy || paused) return;\n if (dice < 1) {\n setAuto(false);\n return;\n }\n const t = setTimeout(() => void roll(), AUTO_GAP_MS);\n return () => clearTimeout(t);\n }, [auto, busy, paused, dice, roll]);\n\n const stopHold = (): void => {\n if (hold.current) cancelAnimationFrame(hold.current.frame);\n hold.current = null;\n setCharge(0);\n setPressed(false);\n };\n\n const onPointerDown = (e: PointerEvent<HTMLButtonElement>): void => {\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n try {\n e.currentTarget.setPointerCapture(e.pointerId);\n } catch {\n // The pointer is already gone (or synthetic) — the hold still works without capture.\n }\n setPressed(true);\n if (auto) {\n hold.current = { start: performance.now(), frame: 0, enabledAuto: false };\n return;\n }\n const h = { start: performance.now(), frame: 0, enabledAuto: false };\n hold.current = h;\n // AUTO switches on by a timer: frames only draw the ring, and a throttled tab has none.\n const timer = setTimeout(() => {\n if (hold.current !== h || h.enabledAuto) return;\n h.enabledAuto = true;\n setAuto(true);\n setCharge(0);\n play(\"claim\");\n }, HOLD_MS);\n const tick = (): void => {\n if (hold.current !== h || h.enabledAuto) {\n clearTimeout(timer);\n return;\n }\n setCharge(Math.min(1, (performance.now() - h.start) / HOLD_MS));\n h.frame = requestAnimationFrame(tick);\n };\n h.frame = requestAnimationFrame(tick);\n };\n\n const onPointerUp = (): void => {\n const h = hold.current;\n stopHold();\n if (!h) return;\n if (auto && !h.enabledAuto) {\n setAuto(false); // a tap while auto rolls stops it\n return;\n }\n if (h.enabledAuto) return;\n if (!auto && performance.now() - h.start >= HOLD_MS) {\n // Held long enough even if the timer was throttled (background tab): that is a hold.\n setAuto(true);\n play(\"claim\");\n return;\n }\n if (!busy && !paused) {\n play(\"click\");\n void roll();\n }\n };\n\n const cycleMultiplier = (): void => {\n const affordable = multipliers.filter(\n (m) => !rollCurrency || m <= Math.max(1, dice),\n );\n const i = affordable.indexOf(mult);\n setMult(affordable[(i + 1) % affordable.length] ?? 1);\n play(\"tab\");\n };\n\n const noDice = rollCurrency !== undefined && dice < 1;\n\n return (\n <div style={wrap}>\n {rollCurrency ? (\n <div style={diceChip} data-resource-counter={rollCurrency}>\n <Icon\n glyph={catalog.iconOf({\n kind: \"currency\",\n id: rollCurrency,\n amount: 0,\n })}\n size={26}\n />\n {formatAmount(dice)}\n </div>\n ) : (\n <div style={{ minWidth: 64 }} />\n )}\n\n <div style={{ position: \"relative\" }}>\n <RollButton\n auto={auto}\n paused={auto && (paused || busy)}\n busy={busy}\n disabled={noDice || (!auto && paused)}\n charge={charge}\n pressed={pressed}\n onPointerDown={onPointerDown}\n onPointerUp={onPointerUp}\n onPointerLeave={() =>\n hold.current && !hold.current.enabledAuto && stopHold()\n }\n />\n {floats.map((f) => (\n <div\n key={f.id}\n style={{\n position: \"absolute\",\n left: \"50%\",\n bottom: \"100%\",\n marginBottom: 8 + f.offset * 34,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: \"4px 12px 4px 6px\",\n borderRadius: 18,\n background: \"rgba(10, 30, 80, .78)\",\n border: \"2px solid #ffd85a\",\n color: \"#ffe27a\",\n fontWeight: 900,\n fontSize: 18,\n whiteSpace: \"nowrap\",\n pointerEvents: \"none\",\n animation: `idos-board-float ${FLOAT_MS}ms ease-out ${f.offset * 120}ms both`,\n }}\n >\n <Icon glyph={catalog.iconOf(f.line)} size={24} />+\n {formatAmount(f.line.amount)}\n </div>\n ))}\n </div>\n\n {multipliers.length > 1 ? (\n <button\n key={flash}\n type=\"button\"\n style={{\n ...sideChip,\n animation: flash > 0 ? \"idos-board-shake .45s ease-out\" : undefined,\n }}\n onClick={cycleMultiplier}\n disabled={busy && !auto}\n aria-label=\"Roll multiplier\"\n >\n <span\n style={{\n animation:\n flash > 0 ? \"idos-board-flash .6s ease-out\" : undefined,\n }}\n >\n ×{mult}\n </span>\n </button>\n ) : (\n <div style={{ minWidth: 64 }} />\n )}\n </div>\n );\n}\n"
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
"path": "components/SpecialPanel.tsx",
|
|
114
|
-
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { BoardPendingInteraction } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport { useBoardState } from \"../react/hooks\";\nimport { resolveSpecialChoices } from \"../data/boardConfig\";\nimport type { SpecialChoice } from \"../data/interactions\";\nimport { ChestArt } from \"./modal/art\";\nimport {\n Stage,\n at,\n easeOutBack,\n outlinedText,\n tween,\n type StageHandle,\n} from \"./modal/stage\";\n\n// The special tile (the reference's SpecialPanel, BoardSurvival BoardScene): the board goes dark,\n// a plum ribbon names the mode, one big green card offers the long run (PLAY) and \"Skip\" below takes\n// the instant prize. The web board has no run of its own, so a Timed choice whose payout is measured\n// by run statistics is not offered here — the card then takes the instant prize. After a Timed choice\n// the victory screen (Play_Result_Victory) shows the rewards with Claim and Claim ×3.\n\nconst FALLBACK: SpecialChoice[] = [\n { ChoiceID: \"Instant\", Mode: \"Instant\" },\n { ChoiceID: \"Timed\", Mode: \"Timed\" },\n];\n\nexport function SpecialPanel({\n pending,\n onDone,\n}: {\n pending: BoardPendingInteraction;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const catalog = useCatalog();\n const stage = useRef<StageHandle>(null);\n const card = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n const special = pending.Special;\n const timedChosen =\n special?.ChosenMode === \"Timed\" || special?.ChosenMode === 1;\n const board = useBoardState();\n const stashed = special?.Choices as SpecialChoice[] | null | undefined;\n const configured = resolveSpecialChoices(\n client,\n board?.StageLevel ?? 1,\n special?.ModeID,\n ) as SpecialChoice[];\n const choices = stashed?.length\n ? stashed\n : configured.length\n ? configured\n : FALLBACK;\n const instant = choices.find((c) => c.Mode === \"Instant\");\n const timedRaw = choices.find((c) => c.Mode === \"Timed\");\n // A run-stat payout needs the reference's survival run — not on the web board.\n const timed =\n timedRaw && !(timedRaw as { RunStats?: unknown }).RunStats\n ? timedRaw\n : undefined;\n const [phase, setPhase] = useState<\"choose\" | \"victory\">(\n timedChosen ? \"victory\" : \"choose\",\n );\n\n const linesOf = (c: SpecialChoice | undefined): ResourceLine[] =>\n grantLines(\n c?.Reward?.Operation?.Grant as Parameters<typeof grantLines>[0],\n ).filter((l) => l.amount > 0);\n\n const close = async (\n lines: ResourceLine[],\n from: Element | null,\n ): Promise<void> => {\n const rect = from?.getBoundingClientRect() ?? null;\n kit.fx.reserve(lines);\n const fade = stage.current?.fadeOut();\n kit.fx.fly(lines, () => rect);\n await fade;\n onDone();\n };\n\n const choose = async (choice: SpecialChoice | undefined): Promise<void> => {\n if (!choice || busy) return;\n setBusy(true);\n play(\"click\");\n const res = await client.gameLoop.boardSpecialChoose(\n choice.ChoiceID ?? choice.Mode ?? \"Instant\",\n );\n if (!res.ok) {\n // The reference's FailAndClose: say why and close — the pending, if alive, comes back on ROLL.\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n if (choice.Mode === \"Timed\") {\n setBusy(false);\n setPhase(\"victory\");\n return;\n }\n play(\"reward\");\n const lines = grantLines(res.data.Operation?.Grant).filter(\n (l) => l.amount > 0,\n );\n await close(lines.length ? lines : linesOf(choice), card.current);\n };\n\n const claim = async (withAd: boolean): Promise<void> => {\n if (busy) return;\n setBusy(true);\n play(\"claim\");\n if (withAd) {\n const applied = await client.gameLoop.boardSpecialApplyMultiplier();\n if (!applied.ok) {\n setBusy(false);\n toast(applied.error, \"error\");\n return;\n }\n }\n const res = await client.gameLoop.boardSpecialClaim();\n if (!res.ok) {\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const lines = grantLines(\n (res.data as { Operation?: { Grant?: Parameters<typeof grantLines>[0] } })\n .Operation?.Grant,\n ).filter((l) => l.amount > 0);\n await close(lines, document.querySelector(\"[data-board-victory-rewards]\"));\n };\n\n // The card: Timed (PLAY) when the web board can play it, else the instant prize.\n const cardChoice = timed ?? instant;\n const cardIsTimed = cardChoice === timed && timed !== undefined;\n\n return (\n <Stage ref={stage} dim=\"rgba(0,0,0,.72)\">\n {phase === \"choose\" ? (\n <>\n <Ribbon\n y={500}\n w={900}\n h={150}\n tone=\"plum\"\n text={\n special?.ModeID && special.ModeID !== \"default\"\n ? special.ModeID\n : \"SPECIAL\"\n }\n size={65}\n />\n <div\n ref={card}\n onClick={() => void choose(cardChoice)}\n style={{\n ...at(0, 0, 483, 630),\n cursor: busy ? \"default\" : \"pointer\",\n animation:\n \"idos-board-card-in .45s cubic-bezier(.34,1.56,.64,1) both, idos-board-breathe 2.4s ease-in-out .5s infinite\",\n }}\n >\n <GreenCard>\n <div style={{ ...abs(0, 85 - 192, 384, 384, 483, 630) }}>\n <ChestArt />\n </div>\n <div\n style={{\n ...abs(0, -130 - 55, 423, 110, 483, 630),\n display: \"grid\",\n placeItems: \"center\",\n textAlign: \"center\",\n ...outlinedText(42),\n }}\n >\n {cardIsTimed\n ? \"Bigger reward — play to multiply it.\"\n : \"Claim your reward instantly.\"}\n </div>\n <div\n style={{\n ...abs(0, -245 - 62, 412, 124, 483, 630),\n display: \"grid\",\n placeItems: \"center\",\n ...outlinedText(70, \"#fff36a\", \"#145a2c\"),\n }}\n >\n {cardIsTimed ? \"PLAY\" : \"CLAIM\"}\n </div>\n </GreenCard>\n </div>\n {cardIsTimed && instant ? (\n <div\n onClick={() => void choose(instant)}\n style={{\n ...at(0, -500, 300, 100),\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n ...outlinedText(58),\n }}\n >\n Skip\n </div>\n ) : null}\n </>\n ) : (\n <Victory\n lines={linesOf(timedRaw)}\n busy={busy}\n onClaim={() => void claim(false)}\n onClaimX3={() => void claim(true)}\n iconOf={catalog.iconOf}\n />\n )}\n </Stage>\n );\n}\n\n/** Position inside a parent box of `pw × ph` the Unity way (centre anchor, y up). */\nfunction abs(\n x: number,\n y: number,\n w: number,\n h: number,\n pw: number,\n ph: number,\n): CSSProperties {\n return {\n position: \"absolute\",\n left: pw / 2 + x - w / 2,\n top: ph / 2 - y - h,\n width: w,\n height: h,\n };\n}\n\n/** CardFrame03 (green): outer frame, lighter inner panel shifted up, a soft top light. */\nfunction GreenCard({ children }: { children: ReactNode }): ReactNode {\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 46,\n background:\n \"linear-gradient(180deg,#34d77c 0%,#2ac870 60%,#20a95b 100%)\",\n border: \"8px solid #17193a\",\n boxShadow:\n \"inset 0 8px 0 rgba(255,255,255,.45), 0 14px 0 rgba(0,0,0,.4)\",\n boxSizing: \"border-box\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 26,\n right: 26,\n top: 26,\n bottom: 120,\n borderRadius: 30,\n background:\n \"radial-gradient(circle at 50% 35%, #6af0a4 0%, #38e283 60%, #2fc673 100%)\",\n boxShadow: \"inset 0 -6px 0 rgba(0,0,0,.12)\",\n }}\n />\n {children}\n </div>\n );\n}\n\n/** Title_Ribbon01 (plum / sky): a raised middle plate with folded ends. */\nfunction Ribbon({\n y,\n w,\n h,\n tone,\n text,\n size,\n}: {\n y: number;\n w: number;\n h: number;\n tone: \"plum\" | \"sky\";\n text: string;\n size: number;\n}): ReactNode {\n const [face, fold, light] =\n tone === \"plum\"\n ? [\"#8a1fff\", \"#5a10b8\", \"#c27cff\"]\n : [\"#2fa8ff\", \"#1b6fc4\", \"#8fd4ff\"];\n return (\n <div\n style={{\n ...at(0, y, w, h),\n animation: \"idos-board-card-in .4s cubic-bezier(.34,1.56,.64,1) both\",\n }}\n >\n <svg\n viewBox=\"0 0 300 150\"\n preserveAspectRatio=\"none\"\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n }}\n aria-hidden\n >\n <g stroke=\"#17193a\" strokeWidth=\"6\" strokeLinejoin=\"round\">\n <path d=\"M4 40 L70 40 L70 120 L4 120 L28 80 Z\" fill={face} />\n <path d=\"M296 40 L230 40 L230 120 L296 120 L272 80 Z\" fill={face} />\n <path\n d=\"M60 50 L80 50 L80 130 L60 120 Z M240 50 L220 50 L220 130 L240 120 Z\"\n fill={fold}\n />\n <rect x=\"70\" y=\"14\" width=\"160\" height=\"112\" rx=\"14\" fill={face} />\n </g>\n <rect x=\"80\" y=\"22\" width=\"140\" height=\"16\" rx=\"8\" fill={light} />\n </svg>\n <div\n style={{\n position: \"absolute\",\n inset: \"0 15% 12% 15%\",\n display: \"grid\",\n placeItems: \"center\",\n ...outlinedText(size),\n whiteSpace: \"nowrap\",\n }}\n >\n {text}\n </div>\n </div>\n );\n}\n\n/** Play_Result_Victory: rotating rays, the winged badge, a sky ribbon, REWARDS, item frames, Claim / Claim ×3. */\nfunction Victory({\n lines,\n busy,\n onClaim,\n onClaimX3,\n iconOf,\n}: {\n lines: ResourceLine[];\n busy: boolean;\n onClaim: () => void;\n onClaimX3: () => void;\n iconOf: (line: ResourceLine) => string;\n}): ReactNode {\n const badge = useRef<HTMLDivElement>(null);\n useEffect(() => {\n const b = badge.current;\n void tween(0.5, (t) => {\n if (b) b.style.transform = `scale(${easeOutBack(t)})`;\n });\n }, []);\n return (\n <>\n <div\n style={{\n ...at(0, 437, 620, 620),\n animation: \"idos-board-spin 6s linear infinite\",\n pointerEvents: \"none\",\n }}\n >\n <svg\n viewBox=\"0 0 200 200\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n {Array.from({ length: 12 }, (_, i) => (\n <path\n key={i}\n d=\"M100 100 L92 0 L108 0 Z\"\n fill=\"rgba(255,240,150,.45)\"\n transform={`rotate(${i * 30} 100 100)`}\n />\n ))}\n </svg>\n </div>\n <div\n ref={badge}\n style={{ ...at(0, 533.8, 536, 449), transform: \"scale(0)\" }}\n >\n <svg\n viewBox=\"0 0 536 449\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke=\"#17193a\" strokeWidth=\"9\" strokeLinejoin=\"round\">\n <path\n d=\"M268 360 L60 250 Q20 160 70 90 Q150 160 268 170 Z\"\n fill=\"#ffd23a\"\n />\n <path\n d=\"M268 360 L476 250 Q516 160 466 90 Q386 160 268 170 Z\"\n fill=\"#ffd23a\"\n />\n <path\n d=\"M268 30 L380 90 L370 280 L268 400 L166 280 L156 90 Z\"\n fill=\"#2fa8ff\"\n />\n <path\n d=\"M268 70 L340 112 L334 262 L268 346 L202 262 L196 112 Z\"\n fill=\"#8fd4ff\"\n />\n <path d=\"M268 110 L282 250 L268 300 L254 250 Z\" fill=\"#fff\" />\n <rect\n x=\"226\"\n y=\"246\"\n width=\"84\"\n height=\"18\"\n rx=\"6\"\n fill=\"#ffd23a\"\n />\n </g>\n </svg>\n </div>\n <Ribbon y={291} w={690} h={143} tone=\"sky\" text=\"VICTORY!\" size={67} />\n <div\n style={{\n ...at(0, 11, 650, 67),\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 24,\n }}\n >\n <div\n style={{\n width: 174,\n height: 10,\n borderRadius: 5,\n background: \"linear-gradient(90deg, transparent, #ffe066)\",\n }}\n />\n <div style={outlinedText(40)}>REWARDS</div>\n <div\n style={{\n width: 174,\n height: 10,\n borderRadius: 5,\n background: \"linear-gradient(90deg, #ffe066, transparent)\",\n }}\n />\n </div>\n <div\n data-board-victory-rewards\n style={{\n ...at(0, -150, 915, 190),\n display: \"flex\",\n justifyContent: \"center\",\n gap: 24,\n }}\n >\n {(lines.length ? lines : []).map((l, i) => (\n <div\n key={i}\n style={{\n width: 190,\n height: 190,\n borderRadius: 34,\n background:\n \"radial-gradient(circle at 50% 40%, #35fbff 0%, #0b69d6 75%)\",\n border: \"7px solid #1d3fb1\",\n boxShadow: \"0 0 0 5px #17193a, 0 10px 0 rgba(0,0,0,.35)\",\n position: \"relative\",\n display: \"grid\",\n placeItems: \"center\",\n animation: `idos-board-card-in .4s cubic-bezier(.34,1.56,.64,1) ${0.3 + i * 0.1}s both`,\n }}\n >\n <Icon glyph={iconOf(l)} size={130} />\n <div\n style={{ position: \"absolute\", bottom: 6, ...outlinedText(38) }}\n >\n {formatAmount(l.amount)}\n </div>\n </div>\n ))}\n </div>\n <GameButton\n y={-644}\n w={350}\n h={130}\n tone=\"sky\"\n disabled={busy}\n onClick={onClaim}\n >\n Claim\n </GameButton>\n <GameButton\n y={-810}\n w={370}\n h={160}\n tone=\"green\"\n disabled={busy}\n onClick={onClaimX3}\n >\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 14 }}>\n <svg viewBox=\"0 0 40 40\" width={70} height={70} aria-hidden>\n <rect\n x=\"3\"\n y=\"7\"\n width=\"34\"\n height=\"26\"\n rx=\"6\"\n fill=\"#ff4d4d\"\n stroke=\"#17193a\"\n strokeWidth=\"3\"\n />\n <path d=\"M16 13 L27 20 L16 27 Z\" fill=\"#fff\" />\n </svg>\n Claim x3\n </span>\n </GameButton>\n </>\n );\n}\n\n/** Button01 (sky / green): glossy pill with a dark outline and a bottom lip. */\nfunction GameButton({\n y,\n w,\n h,\n tone,\n disabled,\n onClick,\n children,\n}: {\n y: number;\n w: number;\n h: number;\n tone: \"sky\" | \"green\";\n disabled?: boolean;\n onClick: () => void;\n children: ReactNode;\n}): ReactNode {\n const [top, bottom, lip] =\n tone === \"sky\"\n ? [\"#5cc8ff\", \"#1c8fe6\", \"#0d5aa8\"]\n : [\"#7be34a\", \"#33b31d\", \"#1f7a10\"];\n return (\n <button\n type=\"button\"\n disabled={disabled}\n onClick={onClick}\n style={{\n ...at(0, y, w, h),\n borderRadius: h / 2.6,\n border: \"7px solid #17193a\",\n background: `linear-gradient(180deg, ${top} 0%, ${bottom} 100%)`,\n boxShadow: `inset 0 8px 0 rgba(255,255,255,.4), 0 10px 0 ${lip}, 0 16px 0 rgba(0,0,0,.3)`,\n cursor: disabled ? \"default\" : \"pointer\",\n opacity: disabled ? 0.7 : 1,\n fontFamily: \"inherit\",\n ...outlinedText(48),\n }}\n >\n {children}\n </button>\n );\n}\n"
|
|
114
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { BoardPendingInteraction } from \"@idosgames/core\";\nimport {\n Icon,\n formatAmount,\n grantLines,\n useCatalog,\n useSound,\n useToast,\n useUiKit,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport { useIDosGamesClient } from \"../react/context\";\nimport { setPanelBusy } from \"../react/boardBusy\";\nimport { useBoardState } from \"../react/hooks\";\nimport { resolveSpecialChoices } from \"../data/boardConfig\";\nimport type { SpecialChoice } from \"../data/interactions\";\nimport { ChestArt } from \"./modal/art\";\nimport {\n Stage,\n at,\n easeOutBack,\n outlinedText,\n tween,\n type StageHandle,\n} from \"./modal/stage\";\n\n// The special tile (the reference's SpecialPanel, BoardSurvival BoardScene): the board goes dark,\n// a plum ribbon names the mode, one big green card offers the long run (PLAY) and \"Skip\" below takes\n// the instant prize. The web board has no run of its own, so a Timed choice whose payout is measured\n// by run statistics is not offered here — the card then takes the instant prize. After a Timed choice\n// the victory screen (Play_Result_Victory) shows the rewards with Claim and Claim ×3.\n\nconst FALLBACK: SpecialChoice[] = [\n { ChoiceID: \"Instant\", Mode: \"Instant\" },\n { ChoiceID: \"Timed\", Mode: \"Timed\" },\n];\n\nexport function SpecialPanel({\n pending,\n onDone,\n}: {\n pending: BoardPendingInteraction;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const play = useSound();\n const toast = useToast();\n const catalog = useCatalog();\n const stage = useRef<StageHandle>(null);\n const card = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n useEffect(() => {\n setPanelBusy(true);\n return () => setPanelBusy(false);\n }, []);\n\n const special = pending.Special;\n const timedChosen =\n special?.ChosenMode === \"Timed\" || special?.ChosenMode === 1;\n const board = useBoardState();\n const stashed = special?.Choices as SpecialChoice[] | null | undefined;\n const configured = resolveSpecialChoices(\n client,\n board?.StageLevel ?? 1,\n special?.ModeID,\n ) as SpecialChoice[];\n const choices = stashed?.length\n ? stashed\n : configured.length\n ? configured\n : FALLBACK;\n const instant = choices.find((c) => c.Mode === \"Instant\");\n const timedRaw = choices.find((c) => c.Mode === \"Timed\");\n // A run-stat payout needs the reference's survival run — not on the web board.\n const timed =\n timedRaw && !(timedRaw as { RunStats?: unknown }).RunStats\n ? timedRaw\n : undefined;\n const [phase, setPhase] = useState<\"choose\" | \"victory\">(\n timedChosen ? \"victory\" : \"choose\",\n );\n\n const linesOf = (c: SpecialChoice | undefined): ResourceLine[] =>\n grantLines(\n c?.Reward?.Operation?.Grant as Parameters<typeof grantLines>[0],\n ).filter((l) => l.amount > 0);\n\n const close = async (\n lines: ResourceLine[],\n from: Element | null,\n ): Promise<void> => {\n const rect = from?.getBoundingClientRect() ?? null;\n kit.fx.reserve(lines);\n const fade = stage.current?.fadeOut();\n kit.fx.fly(lines, () => rect);\n await fade;\n onDone();\n };\n\n const choose = async (choice: SpecialChoice | undefined): Promise<void> => {\n if (!choice || busy) return;\n setBusy(true);\n play(\"click\");\n const res = await client.gameLoop.boardSpecialChoose(\n choice.ChoiceID ?? choice.Mode ?? \"Instant\",\n );\n if (!res.ok) {\n // The reference's FailAndClose: say why and close — the pending, if alive, comes back on ROLL.\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n if (choice.Mode === \"Timed\") {\n setBusy(false);\n setPhase(\"victory\");\n return;\n }\n play(\"reward\");\n const lines = grantLines(res.data.Operation?.Grant).filter(\n (l) => l.amount > 0,\n );\n await close(lines.length ? lines : linesOf(choice), card.current);\n };\n\n const claim = async (withAd: boolean): Promise<void> => {\n if (busy) return;\n setBusy(true);\n play(\"claim\");\n if (withAd) {\n const applied = await client.gameLoop.boardSpecialApplyMultiplier();\n if (!applied.ok) {\n setBusy(false);\n toast(applied.error, \"error\");\n return;\n }\n }\n const res = await client.gameLoop.boardSpecialClaim();\n if (!res.ok) {\n toast(res.error, \"error\");\n await stage.current?.fadeOut();\n onDone();\n return;\n }\n const lines = grantLines(\n (res.data as { Operation?: { Grant?: Parameters<typeof grantLines>[0] } })\n .Operation?.Grant,\n ).filter((l) => l.amount > 0);\n await close(lines, document.querySelector(\"[data-board-victory-rewards]\"));\n };\n\n // The card: Timed (PLAY) when the web board can play it, else the instant prize.\n const cardChoice = timed ?? instant;\n const cardIsTimed = cardChoice === timed && timed !== undefined;\n\n return (\n <Stage ref={stage} dim=\"rgba(0,0,0,.72)\">\n {phase === \"choose\" ? (\n <>\n <Ribbon\n y={500}\n w={900}\n h={150}\n tone=\"plum\"\n text={\n special?.ModeID && special.ModeID !== \"default\"\n ? special.ModeID\n : \"SPECIAL\"\n }\n size={65}\n />\n <div\n ref={card}\n onClick={() => void choose(cardChoice)}\n style={{\n ...at(0, 0, 483, 630),\n cursor: busy ? \"default\" : \"pointer\",\n animation:\n \"idos-board-card-in .45s cubic-bezier(.34,1.56,.64,1) both, idos-board-breathe 2.4s ease-in-out .5s infinite\",\n }}\n >\n <GreenCard>\n <div style={{ ...abs(0, 85 - 192, 384, 384, 483, 630) }}>\n <ChestArt />\n </div>\n <div\n style={{\n ...abs(0, -130 - 55, 423, 110, 483, 630),\n display: \"grid\",\n placeItems: \"center\",\n textAlign: \"center\",\n ...outlinedText(42),\n }}\n >\n {cardIsTimed\n ? \"Bigger reward — play to multiply it.\"\n : \"Claim your reward instantly.\"}\n </div>\n <div\n style={{\n ...abs(0, -245 - 62, 412, 124, 483, 630),\n display: \"grid\",\n placeItems: \"center\",\n ...outlinedText(70, \"#fff36a\", \"#145a2c\"),\n }}\n >\n {cardIsTimed ? \"PLAY\" : \"CLAIM\"}\n </div>\n </GreenCard>\n </div>\n {cardIsTimed && instant ? (\n <div\n onClick={() => void choose(instant)}\n style={{\n ...at(0, -500, 300, 100),\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n ...outlinedText(58),\n }}\n >\n Skip\n </div>\n ) : null}\n </>\n ) : (\n <Victory\n lines={linesOf(timedRaw)}\n busy={busy}\n onClaim={() => void claim(false)}\n onClaimX3={() => void claim(true)}\n iconOf={catalog.iconOf}\n />\n )}\n </Stage>\n );\n}\n\n/** Position inside a parent box of `pw × ph` the Unity way (centre anchor, y up). */\nfunction abs(\n x: number,\n y: number,\n w: number,\n h: number,\n pw: number,\n ph: number,\n): CSSProperties {\n return {\n position: \"absolute\",\n left: pw / 2 + x - w / 2,\n top: ph / 2 - y - h,\n width: w,\n height: h,\n };\n}\n\n/** CardFrame03 (green): outer frame, lighter inner panel shifted up, a soft top light. */\nfunction GreenCard({ children }: { children: ReactNode }): ReactNode {\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 46,\n background:\n \"linear-gradient(180deg,#34d77c 0%,#2ac870 60%,#20a95b 100%)\",\n border: \"8px solid #17193a\",\n boxShadow: \"inset 0 8px 0 rgba(255,255,255,.45)\",\n boxSizing: \"border-box\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 26,\n right: 26,\n top: 26,\n bottom: 120,\n borderRadius: 30,\n background:\n \"radial-gradient(circle at 50% 35%, #6af0a4 0%, #38e283 60%, #2fc673 100%)\",\n boxShadow: \"inset 0 -6px 0 rgba(0,0,0,.12)\",\n }}\n />\n {children}\n </div>\n );\n}\n\n/** Title_Ribbon01 (plum / sky): a raised middle plate with folded ends. */\nfunction Ribbon({\n y,\n w,\n h,\n tone,\n text,\n size,\n}: {\n y: number;\n w: number;\n h: number;\n tone: \"plum\" | \"sky\";\n text: string;\n size: number;\n}): ReactNode {\n const [face, fold, light] =\n tone === \"plum\"\n ? [\"#8a1fff\", \"#5a10b8\", \"#c27cff\"]\n : [\"#2fa8ff\", \"#1b6fc4\", \"#8fd4ff\"];\n return (\n <div\n style={{\n ...at(0, y, w, h),\n animation: \"idos-board-card-in .4s cubic-bezier(.34,1.56,.64,1) both\",\n }}\n >\n <svg\n viewBox=\"0 0 300 150\"\n preserveAspectRatio=\"none\"\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n }}\n aria-hidden\n >\n <g stroke=\"#17193a\" strokeWidth=\"6\" strokeLinejoin=\"round\">\n <path d=\"M4 40 L70 40 L70 120 L4 120 L28 80 Z\" fill={face} />\n <path d=\"M296 40 L230 40 L230 120 L296 120 L272 80 Z\" fill={face} />\n <path\n d=\"M60 50 L80 50 L80 130 L60 120 Z M240 50 L220 50 L220 130 L240 120 Z\"\n fill={fold}\n />\n <rect x=\"70\" y=\"14\" width=\"160\" height=\"112\" rx=\"14\" fill={face} />\n </g>\n <rect x=\"80\" y=\"22\" width=\"140\" height=\"16\" rx=\"8\" fill={light} />\n </svg>\n <div\n style={{\n position: \"absolute\",\n inset: \"0 15% 12% 15%\",\n display: \"grid\",\n placeItems: \"center\",\n ...outlinedText(size),\n whiteSpace: \"nowrap\",\n }}\n >\n {text}\n </div>\n </div>\n );\n}\n\n/** Play_Result_Victory: rotating rays, the winged badge, a sky ribbon, REWARDS, item frames, Claim / Claim ×3. */\nfunction Victory({\n lines,\n busy,\n onClaim,\n onClaimX3,\n iconOf,\n}: {\n lines: ResourceLine[];\n busy: boolean;\n onClaim: () => void;\n onClaimX3: () => void;\n iconOf: (line: ResourceLine) => string;\n}): ReactNode {\n const badge = useRef<HTMLDivElement>(null);\n useEffect(() => {\n const b = badge.current;\n void tween(0.5, (t) => {\n if (b) b.style.transform = `scale(${easeOutBack(t)})`;\n });\n }, []);\n return (\n <>\n <div\n style={{\n ...at(0, 437, 620, 620),\n animation: \"idos-board-spin 6s linear infinite\",\n pointerEvents: \"none\",\n }}\n >\n <svg\n viewBox=\"0 0 200 200\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n {Array.from({ length: 12 }, (_, i) => (\n <path\n key={i}\n d=\"M100 100 L92 0 L108 0 Z\"\n fill=\"rgba(255,240,150,.45)\"\n transform={`rotate(${i * 30} 100 100)`}\n />\n ))}\n </svg>\n </div>\n <div\n ref={badge}\n style={{ ...at(0, 533.8, 536, 449), transform: \"scale(0)\" }}\n >\n <svg\n viewBox=\"0 0 536 449\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke=\"#17193a\" strokeWidth=\"9\" strokeLinejoin=\"round\">\n <path\n d=\"M268 360 L60 250 Q20 160 70 90 Q150 160 268 170 Z\"\n fill=\"#ffd23a\"\n />\n <path\n d=\"M268 360 L476 250 Q516 160 466 90 Q386 160 268 170 Z\"\n fill=\"#ffd23a\"\n />\n <path\n d=\"M268 30 L380 90 L370 280 L268 400 L166 280 L156 90 Z\"\n fill=\"#2fa8ff\"\n />\n <path\n d=\"M268 70 L340 112 L334 262 L268 346 L202 262 L196 112 Z\"\n fill=\"#8fd4ff\"\n />\n <path d=\"M268 110 L282 250 L268 300 L254 250 Z\" fill=\"#fff\" />\n <rect\n x=\"226\"\n y=\"246\"\n width=\"84\"\n height=\"18\"\n rx=\"6\"\n fill=\"#ffd23a\"\n />\n </g>\n </svg>\n </div>\n <Ribbon y={291} w={690} h={143} tone=\"sky\" text=\"VICTORY!\" size={67} />\n <div\n style={{\n ...at(0, 11, 650, 67),\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 24,\n }}\n >\n <div\n style={{\n width: 174,\n height: 10,\n borderRadius: 5,\n background: \"linear-gradient(90deg, transparent, #ffe066)\",\n }}\n />\n <div style={outlinedText(40)}>REWARDS</div>\n <div\n style={{\n width: 174,\n height: 10,\n borderRadius: 5,\n background: \"linear-gradient(90deg, #ffe066, transparent)\",\n }}\n />\n </div>\n <div\n data-board-victory-rewards\n style={{\n ...at(0, -150, 915, 190),\n display: \"flex\",\n justifyContent: \"center\",\n gap: 24,\n }}\n >\n {(lines.length ? lines : []).map((l, i) => (\n <div\n key={i}\n style={{\n width: 190,\n height: 190,\n borderRadius: 34,\n background:\n \"radial-gradient(circle at 50% 40%, #35fbff 0%, #0b69d6 75%)\",\n border: \"7px solid #1d3fb1\",\n boxShadow: \"0 0 0 5px #17193a\",\n position: \"relative\",\n display: \"grid\",\n placeItems: \"center\",\n animation: `idos-board-card-in .4s cubic-bezier(.34,1.56,.64,1) ${0.3 + i * 0.1}s both`,\n }}\n >\n <Icon glyph={iconOf(l)} size={130} />\n <div\n style={{ position: \"absolute\", bottom: 6, ...outlinedText(38) }}\n >\n {formatAmount(l.amount)}\n </div>\n </div>\n ))}\n </div>\n <GameButton\n y={-644}\n w={350}\n h={130}\n tone=\"sky\"\n disabled={busy}\n onClick={onClaim}\n >\n Claim\n </GameButton>\n <GameButton\n y={-810}\n w={370}\n h={160}\n tone=\"green\"\n disabled={busy}\n onClick={onClaimX3}\n >\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 14 }}>\n <svg viewBox=\"0 0 40 40\" width={70} height={70} aria-hidden>\n <rect\n x=\"3\"\n y=\"7\"\n width=\"34\"\n height=\"26\"\n rx=\"6\"\n fill=\"#ff4d4d\"\n stroke=\"#17193a\"\n strokeWidth=\"3\"\n />\n <path d=\"M16 13 L27 20 L16 27 Z\" fill=\"#fff\" />\n </svg>\n Claim x3\n </span>\n </GameButton>\n </>\n );\n}\n\n/** Button01 (sky / green): glossy pill with a dark outline and a bottom lip. */\nfunction GameButton({\n y,\n w,\n h,\n tone,\n disabled,\n onClick,\n children,\n}: {\n y: number;\n w: number;\n h: number;\n tone: \"sky\" | \"green\";\n disabled?: boolean;\n onClick: () => void;\n children: ReactNode;\n}): ReactNode {\n const [top, bottom] =\n tone === \"sky\"\n ? [\"#5cc8ff\", \"#1c8fe6\", \"#0d5aa8\"]\n : [\"#7be34a\", \"#33b31d\", \"#1f7a10\"];\n return (\n <button\n type=\"button\"\n disabled={disabled}\n onClick={onClick}\n style={{\n ...at(0, y, w, h),\n borderRadius: h / 2.6,\n border: \"7px solid #17193a\",\n background: `linear-gradient(180deg, ${top} 0%, ${bottom} 100%)`,\n boxShadow: `inset 0 8px 0 rgba(255,255,255,.4)`,\n cursor: disabled ? \"default\" : \"pointer\",\n opacity: disabled ? 0.7 : 1,\n fontFamily: \"inherit\",\n ...outlinedText(48),\n }}\n >\n {children}\n </button>\n );\n}\n"
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
"path": "components/StatusBar.tsx",
|
|
@@ -239,7 +239,7 @@
|
|
|
239
239
|
},
|
|
240
240
|
{
|
|
241
241
|
"path": "module.meta.json",
|
|
242
|
-
"content": "{\n \"id\": \"board-game\",\n \"type\": \"template\",\n \"summary\": \"3D tycoon board game: roll dice, move around the board, buy and upgrade city tiles.\",\n \"description\": \"A turn-based property board game rendered in Three.js with a React overlay. Players roll dice, move around a looping board, and buy, sell and upgrade tiles to build a city and earn income. The board layout, tiles and interactions are data-driven, so the theme and economy are easy to re-skin.\",\n \"provides\": [\n \"turn-based dice movement around a looping board\",\n \"buy / sell / upgrade board tiles\",\n \"player-vs-player property economy\",\n \"3D board rendering with a React HUD overlay\",\n \"data-driven board layout and tile configuration\"\n ],\n \"tags\": [\"board\", \"tycoon\", \"3d\", \"turn-based\", \"economy\", \"three\"],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/board-game/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/board-game/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/board-game/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.2.
|
|
242
|
+
"content": "{\n \"id\": \"board-game\",\n \"type\": \"template\",\n \"summary\": \"3D tycoon board game: roll dice, move around the board, buy and upgrade city tiles.\",\n \"description\": \"A turn-based property board game rendered in Three.js with a React overlay. Players roll dice, move around a looping board, and buy, sell and upgrade tiles to build a city and earn income. The board layout, tiles and interactions are data-driven, so the theme and economy are easy to re-skin.\",\n \"provides\": [\n \"turn-based dice movement around a looping board\",\n \"buy / sell / upgrade board tiles\",\n \"player-vs-player property economy\",\n \"3D board rendering with a React HUD overlay\",\n \"data-driven board layout and tile configuration\"\n ],\n \"tags\": [\"board\", \"tycoon\", \"3d\", \"turn-based\", \"economy\", \"three\"],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/board-game/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/board-game/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/board-game/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.2.3\",\n \"events\": {\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"shows an 'Idle RPG hero reached Lv N' news chip in the board HUD (optional — works without idle-rpg)\"\n }\n ]\n }\n}\n"
|
|
243
243
|
}
|
|
244
244
|
]
|
|
245
245
|
}
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"version": "0.1.1"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@idosgames/core": "0.
|
|
35
|
-
"@idosgames/module-sdk": "0.13.
|
|
36
|
-
"@idosgames/react": "0.6.
|
|
34
|
+
"@idosgames/core": "0.25.0",
|
|
35
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
36
|
+
"@idosgames/react": "0.6.3",
|
|
37
37
|
"react": "19.2.7"
|
|
38
38
|
},
|
|
39
39
|
"contentHash": "bb17d0684afb6135854bc4abdab52d3c0fcdbc01d45ac23fd1c66dd25fce5d47",
|