@idosgames/mcp 0.1.37 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/cli.js +2204 -27
- package/package.json +4 -1
- package/registry/host.json +3 -3
- package/registry/index.json +310 -156
- package/registry/modules/ai.json +7 -7
- package/registry/modules/board-game.json +14 -14
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +32 -15
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +7 -7
- package/registry/modules/coop-events.json +7 -7
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +7 -7
- package/registry/modules/events.json +8 -8
- package/registry/modules/friends.json +9 -9
- package/registry/modules/hero-run.json +498 -0
- package/registry/modules/idle-rpg.json +69 -17
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +7 -7
- package/registry/modules/leaderboard.json +10 -10
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +8 -8
- package/registry/modules/marketplace.json +8 -8
- package/registry/modules/mini-game.json +8 -8
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +7 -7
- package/registry/modules/profile.json +7 -7
- package/registry/modules/pvp.json +7 -7
- package/registry/modules/quests.json +8 -8
- package/registry/modules/rewards.json +8 -8
- package/registry/modules/season.json +7 -7
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/video-studio.json +228 -0
- package/registry/modules/voxelcraft.json +42 -125
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/hero-run.json +6 -0
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/video-studio.json +6 -0
- package/registry/video.json +9800 -0
|
@@ -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.2",
|
|
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.
|
|
50
|
-
"@idosgames/react": "0.
|
|
51
|
-
"@idosgames/wallet": "0.6.
|
|
48
|
+
"@idosgames/core": "0.23.0",
|
|
49
|
+
"@idosgames/module-sdk": "0.13.0",
|
|
50
|
+
"@idosgames/react": "0.6.0",
|
|
51
|
+
"@idosgames/wallet": "0.6.6",
|
|
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": "b0b1afb38157e7e98cee3e8c46cff02a83ee0a0f313284f83566c55b86852673",
|
|
59
59
|
"files": [
|
|
60
60
|
{
|
|
61
61
|
"path": "chrome.ts",
|
|
@@ -71,7 +71,7 @@
|
|
|
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 } 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:
|
|
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), 0 3px 0 color-mix(in srgb, ${v.purpleDeep} 70%, #000)`,\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",
|
|
@@ -79,7 +79,7 @@
|
|
|
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 #10172e\",\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 #10172e\",\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 #10172e\",\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 #10172e\",\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=\"#10172e\"\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: \"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"
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
"path": "components/InteractionPanel.tsx",
|
|
@@ -87,11 +87,11 @@
|
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
"path": "components/modal/art.tsx",
|
|
90
|
-
"content": "import type { CSSProperties, ReactNode } from \"react\";\n\n// The art of the board's full-screen panels, drawn in SVG after the reference's sprites (BoardSurvival\n// Content/Sprites: GameModes/Raid, GameModes/Attack, Buildings/Stages, the electric sword). The module\n// ships text only — the seeder skips binary files — so every picture is redrawn here: same shapes,\n// same palette, the same heavy dark outline and glossy highlight.\n\nconst INK = \"#10172e\";\n\n/** The stage backdrop of the attack window (Stages/{stage}/bg): sky, snowy peaks, a village, a meadow, a dirt road. */\nexport function StageBackdrop({ style }: { style?: CSSProperties }): ReactNode {\n return (\n <svg\n viewBox=\"0 0 1080 1920\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n ...style,\n }}\n aria-hidden\n >\n <defs>\n <linearGradient id=\"bd-sky\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#1f86e6\" />\n <stop offset=\"1\" stopColor=\"#8ccbf6\" />\n </linearGradient>\n <linearGradient id=\"bd-field\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#8dc53a\" />\n <stop offset=\"0.5\" stopColor=\"#9bcf2c\" />\n <stop offset=\"1\" stopColor=\"#79b52a\" />\n </linearGradient>\n <linearGradient id=\"bd-road\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#d9a24e\" />\n <stop offset=\"1\" stopColor=\"#c48a3a\" />\n </linearGradient>\n <radialGradient id=\"bd-light\" cx=\"0.5\" cy=\"0.55\" r=\"0.6\">\n <stop offset=\"0\" stopColor=\"#fff9c4\" stopOpacity=\"0.35\" />\n <stop offset=\"1\" stopColor=\"#fff9c4\" stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n <rect width=\"1080\" height=\"700\" fill=\"url(#bd-sky)\" />\n {[\n [130, 110, 1],\n [930, 100, 0.9],\n [800, 200, 0.8],\n [60, 250, 0.6],\n ].map(([x, y, s], i) => (\n <g key={i} transform={`translate(${x} ${y}) scale(${s})`} fill=\"#fff\">\n <ellipse cx=\"0\" cy=\"20\" rx=\"110\" ry=\"34\" />\n <circle cx=\"-40\" cy=\"0\" r=\"42\" />\n <circle cx=\"20\" cy=\"-18\" r=\"56\" />\n <circle cx=\"70\" cy=\"8\" r=\"36\" />\n </g>\n ))}\n {/* Far peaks */}\n <path\n d=\"M0 380 L90 300 L170 340 L290 190 L400 330 L510 210 L640 300 L770 330 L860 290 L990 220 L1080 300 L1080 470 L0 470 Z\"\n fill=\"#5b8fd6\"\n />\n <path\n d=\"M290 190 L250 250 L280 240 L310 270 L330 230 Z M510 210 L470 270 L505 255 L530 280 L555 250 Z M990 220 L950 280 L985 265 L1010 290 L1035 260 Z\"\n fill=\"#fff\"\n />\n <path\n d=\"M0 430 Q270 360 540 420 T1080 410 L1080 520 L0 520 Z\"\n fill=\"#4f9a3c\"\n />\n <path\n d=\"M0 470 Q300 430 600 470 T1080 460 L1080 560 L0 560 Z\"\n fill=\"#62ad3e\"\n />\n {/* Village on the horizon */}\n {[\n [220, 560, \"#e9dcc2\", \"#c4473a\"],\n [370, 520, \"#ece2cf\", \"#c4473a\"],\n [600, 530, \"#ece2cf\", \"#c4473a\"],\n [740, 500, \"#ece2cf\", \"#c4473a\"],\n ].map(([x, y, wall, band], i) => (\n <g\n key={i}\n transform={`translate(${x} ${y})`}\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n >\n <path\n d=\"M-70 0 L-70 -50 Q0 -110 70 -50 L70 0 Z\"\n fill={wall as string}\n />\n <path d=\"M-70 -40 L70 -40\" stroke={band as string} strokeWidth=\"9\" />\n <rect x=\"-16\" y=\"-36\" width=\"32\" height=\"36\" fill=\"#8a5a2b\" />\n </g>\n ))}\n <g\n transform=\"translate(870 560)\"\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n >\n <rect x=\"-90\" y=\"-70\" width=\"180\" height=\"70\" fill=\"#e8cf9c\" />\n <path d=\"M-110 -70 L-60 -130 L110 -130 L120 -70 Z\" fill=\"#c9442e\" />\n <rect x=\"-15\" y=\"-46\" width=\"30\" height=\"46\" fill=\"#8a5a2b\" />\n </g>\n {[80, 160, 300, 470, 690, 960, 1030].map((x, i) => (\n <g key={i} transform={`translate(${x} ${560 + (i % 2) * 20})`}>\n <circle\n cx=\"0\"\n cy=\"-40\"\n r={50 - (i % 3) * 8}\n fill=\"#3f8f2e\"\n stroke={INK}\n strokeWidth=\"5\"\n />\n <circle cx=\"-14\" cy=\"-56\" r=\"16\" fill=\"#5bb043\" />\n </g>\n ))}\n {/* Meadow */}\n <rect y=\"590\" width=\"1080\" height=\"1330\" fill=\"url(#bd-field)\" />\n <rect y=\"590\" width=\"1080\" height=\"1330\" fill=\"url(#bd-light)\" />\n <path\n d=\"M0 1520 Q260 1480 540 1530 T1080 1520 L1080 1700 Q800 1740 540 1700 T0 1720 Z\"\n fill=\"url(#bd-road)\"\n stroke=\"#b07a35\"\n strokeWidth=\"4\"\n />\n {[\n [40, 700, 1.1],\n [1030, 830, 0.9],\n [60, 1170, 1],\n [1020, 1180, 1.2],\n [120, 1840, 1.5],\n [960, 1830, 1.6],\n ].map(([x, y, s], i) => (\n <g\n key={i}\n transform={`translate(${x} ${y}) scale(${s})`}\n stroke={INK}\n strokeWidth=\"4\"\n >\n <circle cx=\"-30\" cy=\"0\" r=\"46\" fill=\"#58a83a\" />\n <circle cx=\"30\" cy=\"-10\" r=\"52\" fill=\"#4c9c33\" />\n <circle cx=\"0\" cy=\"-40\" r=\"40\" fill=\"#63b443\" />\n </g>\n ))}\n {[\n [90, 740],\n [140, 1190],\n [1000, 1490],\n [870, 1500],\n [260, 1880],\n [820, 1840],\n ].map(([x, y], i) => (\n <ellipse\n key={i}\n cx={x}\n cy={y}\n rx=\"38\"\n ry=\"24\"\n fill=\"#9aa1ad\"\n stroke={INK}\n strokeWidth=\"4\"\n />\n ))}\n {Array.from({ length: 40 }, (_, i) => (\n <circle\n key={i}\n cx={(i * 197) % 1080}\n cy={640 + ((i * 331) % 1200)}\n r=\"5\"\n fill={i % 3 === 0 ? \"#fff\" : i % 3 === 1 ? \"#ffd84a\" : \"#ff8fb0\"}\n />\n ))}\n </svg>\n );\n}\n\n/** The raid window's blue alcove (GameModes/Raid/background). */\nexport function RaidBackdrop(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 1080 1920\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <defs>\n <radialGradient id=\"rb-in\" cx=\"0.5\" cy=\"0.5\" r=\"0.7\">\n <stop offset=\"0\" stopColor=\"#2f80ea\" />\n <stop offset=\"1\" stopColor=\"#1b5fc9\" />\n </radialGradient>\n <linearGradient id=\"rb-out\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#0d48a8\" />\n <stop offset=\"1\" stopColor=\"#1557c2\" />\n </linearGradient>\n </defs>\n <rect width=\"1080\" height=\"1920\" fill=\"url(#rb-out)\" />\n <path\n d=\"M10 1750 L10 330 Q10 235 110 235 L970 235 Q1070 235 1070 330 L1070 1750 Z\"\n fill=\"#2a6fd8\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"8\"\n />\n <path\n d=\"M60 1760 L60 360 Q60 285 135 285 L945 285 Q1020 285 1020 360 L1020 1760 Z\"\n fill=\"url(#rb-in)\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"6\"\n />\n <path\n d=\"M40 245 L1040 245\"\n stroke=\"#6fb0ff\"\n strokeWidth=\"6\"\n opacity=\"0.6\"\n />\n <rect y=\"1760\" width=\"1080\" height=\"160\" fill=\"#174fb4\" />\n <path d=\"M0 1760 L1080 1760\" stroke=\"#0b3a8c\" strokeWidth=\"8\" />\n <path\n d=\"M0 1840 L80 1730 L80 1760 Z M1080 1840 L1000 1730 L1000 1760 Z\"\n fill=\"#2a6fd8\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"6\"\n />\n </svg>\n );\n}\n\n/** Roof colours per building slot — the same as the houses on the island (city.ts). */\nconst ROOFS = [\n \"#e8534e\",\n \"#4a90e2\",\n \"#9c5cff\",\n \"#f0a020\",\n \"#42c9c2\",\n \"#4caf50\",\n];\n\n/**\n * A building of the attack window (Buildings/Stages/{stage}/{slot}_{level}): an isometric cartoon\n * house that grows a storey every two levels. Level 0 is an empty plot under scaffolding, shown grey\n * like the reference's grayscale material; a damaged one is the level-1 art, scorched.\n */\nexport function BuildingArt({\n slot,\n level,\n damaged,\n}: {\n slot: number;\n level: number;\n damaged?: boolean;\n}): ReactNode {\n const roof = ROOFS[slot % ROOFS.length] ?? \"#e8534e\";\n const empty = level <= 0;\n const storeys = damaged\n ? 1\n : Math.min(3, 1 + Math.floor(Math.max(0, level - 1) / 2));\n const wallH = 70;\n const top = 300 - storeys * wallH;\n return (\n <svg\n viewBox=\"0 0 400 400\"\n style={{\n width: \"100%\",\n height: \"100%\",\n overflow: \"visible\",\n filter: empty\n ? \"grayscale(1) brightness(1.05)\"\n : damaged\n ? \"brightness(.72) saturate(.7)\"\n : undefined,\n }}\n aria-hidden\n >\n <g stroke={INK} strokeWidth=\"7\" strokeLinejoin=\"round\">\n {/* Plot */}\n <path d=\"M200 250 L360 320 L200 390 L40 320 Z\" fill=\"#e8c88f\" />\n <path\n d=\"M40 320 L40 336 L200 406 L360 336 L360 320 L200 390 Z\"\n fill=\"#c9a26a\"\n />\n <ellipse cx=\"70\" cy=\"300\" rx=\"28\" ry=\"20\" fill=\"#6cbf3d\" />\n <ellipse cx=\"335\" cy=\"305\" rx=\"30\" ry=\"22\" fill=\"#6cbf3d\" />\n {empty ? (\n <>\n <path\n d=\"M120 300 L120 160 M280 300 L280 160 M120 170 L280 170 M120 230 L280 230\"\n fill=\"none\"\n stroke=\"#d98c2b\"\n strokeWidth=\"12\"\n />\n <path\n d=\"M120 300 L120 160 M280 300 L280 160 M120 170 L280 170 M120 230 L280 230\"\n fill=\"none\"\n />\n <rect x=\"160\" y=\"280\" width=\"50\" height=\"40\" fill=\"#e09a3a\" />\n <rect x=\"225\" y=\"290\" width=\"40\" height=\"32\" fill=\"#e09a3a\" />\n </>\n ) : (\n <>\n {/* Walls: left face + right face */}\n <path\n d={`M90 ${top + 40} L200 ${top + 90} L200 340 L90 290 Z`}\n fill=\"#f4e7cc\"\n />\n <path\n d={`M200 ${top + 90} L310 ${top + 40} L310 290 L200 340 Z`}\n fill=\"#dccaa6\"\n />\n {Array.from({ length: storeys }, (_, i) => {\n const y = top + 70 + i * wallH;\n return (\n <g key={i} strokeWidth=\"5\">\n <path\n d={`M118 ${y} L150 ${y + 15} L150 ${y + 45} L118 ${y + 30} Z`}\n fill=\"#7fd0ff\"\n />\n <path\n d={`M232 ${y + 18} L268 ${y + 2} L268 ${y + 32} L232 ${y + 48} Z`}\n fill=\"#7fd0ff\"\n />\n </g>\n );\n })}\n <path\n d=\"M178 330 L178 290 L210 305 L210 344 Z\"\n fill=\"#8a5a2b\"\n strokeWidth=\"5\"\n />\n {/* Roof */}\n <path\n d={`M70 ${top + 48} L200 ${top - 20} L330 ${top + 48} L200 ${top + 108} Z`}\n fill={roof}\n />\n <path\n d={`M200 ${top - 20} L200 ${top + 108}`}\n stroke=\"rgba(0,0,0,.25)\"\n strokeWidth=\"6\"\n />\n <path\n d={`M95 ${top + 40} L200 ${top - 8}`}\n stroke=\"rgba(255,255,255,.55)\"\n strokeWidth=\"8\"\n fill=\"none\"\n />\n {level >= 5 ? (\n <path\n d={`M200 ${top - 20} L200 ${top - 70} L240 ${top - 58} L200 ${top - 46}`}\n fill=\"#ffd84a\"\n strokeWidth=\"5\"\n />\n ) : null}\n </>\n )}\n {damaged ? (\n <g strokeWidth=\"5\" fill=\"none\" stroke=\"#2a1a10\">\n <path d=\"M130 250 L150 270 L140 290\" />\n <path d=\"M250 240 L270 262 L258 282\" />\n </g>\n ) : null}\n </g>\n </svg>\n );\n}\n\n/** The red crosshair over every target building (GameModes/Attack/aim). */\nexport function AimArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 100 100\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke=\"#6a0d0d\" strokeWidth=\"3\" fill=\"#ef2b2b\" strokeLinejoin=\"round\">\n <path\n d=\"M50 14 A36 36 0 1 1 49.9 14 Z M50 26 A24 24 0 1 0 50.1 26 Z\"\n fillRule=\"evenodd\"\n />\n <rect x=\"45.5\" y=\"4\" width=\"9\" height=\"34\" rx=\"4.5\" />\n <rect x=\"45.5\" y=\"62\" width=\"9\" height=\"34\" rx=\"4.5\" />\n <rect x=\"4\" y=\"45.5\" width=\"34\" height=\"9\" rx=\"4.5\" />\n <rect x=\"62\" y=\"45.5\" width=\"34\" height=\"9\" rx=\"4.5\" />\n </g>\n <path\n d=\"M26 30 A30 30 0 0 1 44 18\"\n stroke=\"#ff9a9a\"\n strokeWidth=\"3\"\n fill=\"none\"\n strokeLinecap=\"round\"\n />\n </svg>\n );\n}\n\n/** The electric sword (BetterAttackPanel/ElectricSword): an ice-blue blade wrapped in lightning. */\nexport function SwordArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 600\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n aria-hidden\n >\n <defs>\n <linearGradient id=\"sw-blade\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n <stop offset=\"0\" stopColor=\"#bfe6ff\" />\n <stop offset=\"0.55\" stopColor=\"#ffffff\" />\n <stop offset=\"1\" stopColor=\"#8fd0ff\" />\n </linearGradient>\n <filter id=\"sw-glow\" x=\"-60%\" y=\"-20%\" width=\"220%\" height=\"140%\">\n <feGaussianBlur stdDeviation=\"9\" result=\"b\" />\n <feMerge>\n <feMergeNode in=\"b\" />\n <feMergeNode in=\"SourceGraphic\" />\n </feMerge>\n </filter>\n </defs>\n <g filter=\"url(#sw-glow)\">\n <path\n d=\"M100 8 L140 70 L132 420 L68 420 L60 70 Z\"\n fill=\"url(#sw-blade)\"\n stroke=\"#1f63d6\"\n strokeWidth=\"7\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M100 30 L104 410\"\n stroke=\"#e6f6ff\"\n strokeWidth=\"8\"\n opacity=\"0.9\"\n />\n <path\n d=\"M56 90 L40 140 L58 170 L38 230 L56 270 L40 330 L58 380\"\n stroke=\"#7cd4ff\"\n strokeWidth=\"5\"\n fill=\"none\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M144 110 L162 160 L142 200 L164 260 L144 300 L160 360 L140 400\"\n stroke=\"#7cd4ff\"\n strokeWidth=\"5\"\n fill=\"none\"\n strokeLinejoin=\"round\"\n />\n </g>\n <g stroke=\"#14306b\" strokeWidth=\"7\" strokeLinejoin=\"round\">\n <rect x=\"30\" y=\"418\" width=\"140\" height=\"34\" rx=\"8\" fill=\"#a9dcff\" />\n <rect x=\"80\" y=\"452\" width=\"40\" height=\"22\" fill=\"#cfeeff\" />\n <rect x=\"74\" y=\"472\" width=\"52\" height=\"92\" rx=\"12\" fill=\"#3e5f9c\" />\n <path\n d=\"M76 492 L124 492 M76 512 L124 512 M76 532 L124 532 M76 552 L124 552\"\n stroke=\"#7d9ad0\"\n strokeWidth=\"5\"\n />\n <rect x=\"66\" y=\"560\" width=\"68\" height=\"32\" rx=\"16\" fill=\"#2d4a85\" />\n </g>\n </svg>\n );\n}\n\n/** A jagged bolt from the sword to the struck building. */\nexport function LightningArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 100 300\"\n preserveAspectRatio=\"none\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <path\n d=\"M58 0 L36 92 L60 98 L30 190 L56 196 L42 300 L80 168 L52 162 L78 74 L52 68 Z\"\n fill=\"#e9f8ff\"\n stroke=\"#4fb6ff\"\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n );\n}\n\n/** The shield that blocks a strike (Abilities/ui_ab_shield). */\nexport function ShieldArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 220\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke={INK} strokeWidth=\"9\" strokeLinejoin=\"round\">\n <path\n d=\"M100 10 L180 40 Q182 150 100 210 Q18 150 20 40 Z\"\n fill=\"#3d8cf0\"\n />\n <path\n d=\"M100 32 L160 54 Q160 138 100 186 Q40 138 40 54 Z\"\n fill=\"#ffd23a\"\n />\n <path\n d=\"M100 52 L140 68 Q138 128 100 162 Q62 128 60 68 Z\"\n fill=\"#3d8cf0\"\n />\n </g>\n <path\n d=\"M48 60 Q60 50 86 42\"\n stroke=\"#fff\"\n strokeWidth=\"8\"\n fill=\"none\"\n strokeLinecap=\"round\"\n opacity=\"0.7\"\n />\n </svg>\n );\n}\n\n/** Fire on a hit building (Abilities/ui_ab_fireball). */\nexport function FireArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 240\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n aria-hidden\n >\n <g stroke=\"#7a1f05\" strokeWidth=\"7\" strokeLinejoin=\"round\">\n <path\n d=\"M100 8 Q150 70 160 120 Q176 200 100 230 Q24 200 40 120 Q56 150 74 150 Q60 90 100 8 Z\"\n fill=\"#ff5a1f\"\n />\n <path\n d=\"M100 70 Q130 120 132 160 Q132 210 100 214 Q66 210 70 166 Q80 180 92 178 Q86 130 100 70 Z\"\n fill=\"#ffb21f\"\n />\n <path\n d=\"M100 140 Q114 170 112 190 Q110 206 100 206 Q88 204 88 190 Q90 170 100 140 Z\"\n fill=\"#fff3a0\"\n />\n </g>\n </svg>\n );\n}\n\n/** A closed bank safe of the raid grid: the frame, the door with its gloss, and the tumbler on top. */\nexport function SafeFrameArt({ open }: { open: boolean }): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 200\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <rect\n x=\"8\"\n y=\"16\"\n width=\"184\"\n height=\"168\"\n rx=\"30\"\n fill=\"#b9cdf0\"\n stroke={INK}\n strokeWidth=\"7\"\n />\n <path\n d=\"M22 40 Q24 26 44 24\"\n stroke=\"#fff\"\n strokeWidth=\"7\"\n fill=\"none\"\n strokeLinecap=\"round\"\n />\n <rect\n x=\"26\"\n y=\"34\"\n width=\"148\"\n height=\"132\"\n rx=\"20\"\n fill={open ? \"#13286a\" : \"#2a4fa8\"}\n stroke={INK}\n strokeWidth=\"5\"\n />\n {open ? (\n <rect x=\"34\" y=\"42\" width=\"132\" height=\"116\" rx=\"16\" fill=\"#0f2258\" />\n ) : null}\n </svg>\n );\n}\n\nexport function SafeDoorArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 200\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <rect\n x=\"8\"\n y=\"16\"\n width=\"184\"\n height=\"168\"\n rx=\"30\"\n fill=\"#b9cdf0\"\n stroke={INK}\n strokeWidth=\"7\"\n />\n <rect\n x=\"26\"\n y=\"34\"\n width=\"148\"\n height=\"132\"\n rx=\"20\"\n fill=\"#c6d8f7\"\n stroke={INK}\n strokeWidth=\"5\"\n />\n <ellipse\n cx=\"48\"\n cy=\"52\"\n rx=\"13\"\n ry=\"8\"\n fill=\"#fff\"\n transform=\"rotate(-30 48 52)\"\n />\n <path\n d=\"M26 150 Q100 170 174 150\"\n stroke=\"#9db6e4\"\n strokeWidth=\"6\"\n fill=\"none\"\n />\n </svg>\n );\n}\n\nexport function TumblerArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 200\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <circle\n cx=\"100\"\n cy=\"104\"\n r=\"30\"\n fill=\"#4a6cb3\"\n stroke={INK}\n strokeWidth=\"5\"\n />\n <g stroke={INK} strokeWidth=\"5\" fill=\"#ffcf2e\" strokeLinejoin=\"round\">\n <rect x=\"91\" y=\"64\" width=\"18\" height=\"40\" rx=\"9\" />\n <rect\n x=\"91\"\n y=\"64\"\n width=\"18\"\n height=\"40\"\n rx=\"9\"\n transform=\"rotate(120 100 104)\"\n />\n <rect\n x=\"91\"\n y=\"64\"\n width=\"18\"\n height=\"40\"\n rx=\"9\"\n transform=\"rotate(240 100 104)\"\n />\n <circle cx=\"100\" cy=\"104\" r=\"17\" />\n </g>\n <circle\n cx=\"100\"\n cy=\"104\"\n r=\"8\"\n fill=\"none\"\n stroke=\"#c99a10\"\n strokeWidth=\"3\"\n />\n </svg>\n );\n}\n\nexport type BarsTone = \"gold\" | \"silver\" | \"bronze\";\nconst BARS: Record<BarsTone, [string, string, string]> = {\n gold: [\"#fff06a\", \"#ffcb1f\", \"#e98a0c\"],\n silver: [\"#ffffff\", \"#d7d9de\", \"#a3a7b0\"],\n bronze: [\"#ffd1a1\", \"#e2924c\", \"#a8571f\"],\n};\n\n/** Three stacked ingots (Raid/gold, silver, bronze) — the symbols of the raid. */\nexport function BarsArt({ tone }: { tone: BarsTone }): ReactNode {\n const [hi, mid, lo] = BARS[tone];\n const bar = (x: number, y: number): ReactNode => (\n <g\n transform={`translate(${x} ${y})`}\n stroke={INK}\n strokeWidth=\"6\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M0 40 L60 10 L130 30 L70 62 Z\" fill={hi} />\n <path d=\"M0 40 L70 62 L70 92 L4 70 Z\" fill={mid} />\n <path d=\"M70 62 L130 30 L128 60 L70 92 Z\" fill={lo} />\n </g>\n );\n return (\n <svg\n viewBox=\"0 0 220 180\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n {bar(8, 70)}\n {bar(84, 76)}\n {bar(44, 18)}\n </svg>\n );\n}\n\n/** The special card's treasure chest (Chest_Legendary_OpenEffect). */\nexport function ChestArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 240 220\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n aria-hidden\n >\n <defs>\n <radialGradient id=\"ch-glow\" cx=\"0.5\" cy=\"0.45\" r=\"0.55\">\n <stop offset=\"0\" stopColor=\"#fff6b0\" stopOpacity=\"0.95\" />\n <stop offset=\"1\" stopColor=\"#fff6b0\" stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n <circle cx=\"120\" cy=\"96\" r=\"120\" fill=\"url(#ch-glow)\" />\n <g stroke={INK} strokeWidth=\"8\" strokeLinejoin=\"round\">\n <path d=\"M30 70 Q120 -6 210 70 L210 100 L30 100 Z\" fill=\"#9c4dff\" />\n <rect x=\"30\" y=\"96\" width=\"180\" height=\"104\" rx=\"12\" fill=\"#7a2fe0\" />\n <path d=\"M30 100 L210 100\" />\n <rect x=\"104\" y=\"40\" width=\"32\" height=\"160\" fill=\"#ffd23a\" />\n <rect x=\"96\" y=\"104\" width=\"48\" height=\"44\" rx=\"8\" fill=\"#ffd23a\" />\n <circle cx=\"120\" cy=\"124\" r=\"8\" fill={INK} />\n </g>\n <path\n d=\"M46 72 Q90 30 140 34\"\n stroke=\"#fff\"\n strokeWidth=\"8\"\n fill=\"none\"\n strokeLinecap=\"round\"\n opacity=\"0.6\"\n />\n {[\n [40, 30],\n [210, 26],\n [222, 120],\n [18, 128],\n ].map(([x, y], i) => (\n <path\n key={i}\n d={`M${x} ${(y ?? 0) - 14} L${(x ?? 0) + 5} ${(y ?? 0) - 4} L${(x ?? 0) + 15} ${y} L${(x ?? 0) + 5} ${(y ?? 0) + 4} L${x} ${(y ?? 0) + 14} L${(x ?? 0) - 5} ${(y ?? 0) + 4} L${(x ?? 0) - 15} ${y} L${(x ?? 0) - 5} ${(y ?? 0) - 4} Z`}\n fill=\"#fff8c2\"\n />\n ))}\n </svg>\n );\n}\n"
|
|
90
|
+
"content": "import type { CSSProperties, ReactNode } from \"react\";\n\n// The art of the board's full-screen panels, drawn in SVG after the reference's sprites (BoardSurvival\n// Content/Sprites: GameModes/Raid, GameModes/Attack, Buildings/Stages, the electric sword). The module\n// ships text only — the seeder skips binary files — so every picture is redrawn here: same shapes,\n// same palette, the same heavy dark outline and glossy highlight.\n\nconst INK = \"#17193a\";\n\n/** The stage backdrop of the attack window (Stages/{stage}/bg): sky, snowy peaks, a village, a meadow, a dirt road. */\nexport function StageBackdrop({ style }: { style?: CSSProperties }): ReactNode {\n return (\n <svg\n viewBox=\"0 0 1080 1920\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n ...style,\n }}\n aria-hidden\n >\n <defs>\n <linearGradient id=\"bd-sky\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#1f86e6\" />\n <stop offset=\"1\" stopColor=\"#8ccbf6\" />\n </linearGradient>\n <linearGradient id=\"bd-field\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#8dc53a\" />\n <stop offset=\"0.5\" stopColor=\"#9bcf2c\" />\n <stop offset=\"1\" stopColor=\"#79b52a\" />\n </linearGradient>\n <linearGradient id=\"bd-road\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#d9a24e\" />\n <stop offset=\"1\" stopColor=\"#c48a3a\" />\n </linearGradient>\n <radialGradient id=\"bd-light\" cx=\"0.5\" cy=\"0.55\" r=\"0.6\">\n <stop offset=\"0\" stopColor=\"#fff9c4\" stopOpacity=\"0.35\" />\n <stop offset=\"1\" stopColor=\"#fff9c4\" stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n <rect width=\"1080\" height=\"700\" fill=\"url(#bd-sky)\" />\n {[\n [130, 110, 1],\n [930, 100, 0.9],\n [800, 200, 0.8],\n [60, 250, 0.6],\n ].map(([x, y, s], i) => (\n <g key={i} transform={`translate(${x} ${y}) scale(${s})`} fill=\"#fff\">\n <ellipse cx=\"0\" cy=\"20\" rx=\"110\" ry=\"34\" />\n <circle cx=\"-40\" cy=\"0\" r=\"42\" />\n <circle cx=\"20\" cy=\"-18\" r=\"56\" />\n <circle cx=\"70\" cy=\"8\" r=\"36\" />\n </g>\n ))}\n {/* Far peaks */}\n <path\n d=\"M0 380 L90 300 L170 340 L290 190 L400 330 L510 210 L640 300 L770 330 L860 290 L990 220 L1080 300 L1080 470 L0 470 Z\"\n fill=\"#5b8fd6\"\n />\n <path\n d=\"M290 190 L250 250 L280 240 L310 270 L330 230 Z M510 210 L470 270 L505 255 L530 280 L555 250 Z M990 220 L950 280 L985 265 L1010 290 L1035 260 Z\"\n fill=\"#fff\"\n />\n <path\n d=\"M0 430 Q270 360 540 420 T1080 410 L1080 520 L0 520 Z\"\n fill=\"#4f9a3c\"\n />\n <path\n d=\"M0 470 Q300 430 600 470 T1080 460 L1080 560 L0 560 Z\"\n fill=\"#62ad3e\"\n />\n {/* Village on the horizon */}\n {[\n [220, 560, \"#e9dcc2\", \"#c4473a\"],\n [370, 520, \"#ece2cf\", \"#c4473a\"],\n [600, 530, \"#ece2cf\", \"#c4473a\"],\n [740, 500, \"#ece2cf\", \"#c4473a\"],\n ].map(([x, y, wall, band], i) => (\n <g\n key={i}\n transform={`translate(${x} ${y})`}\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n >\n <path\n d=\"M-70 0 L-70 -50 Q0 -110 70 -50 L70 0 Z\"\n fill={wall as string}\n />\n <path d=\"M-70 -40 L70 -40\" stroke={band as string} strokeWidth=\"9\" />\n <rect x=\"-16\" y=\"-36\" width=\"32\" height=\"36\" fill=\"#8a5a2b\" />\n </g>\n ))}\n <g\n transform=\"translate(870 560)\"\n stroke={INK}\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n >\n <rect x=\"-90\" y=\"-70\" width=\"180\" height=\"70\" fill=\"#e8cf9c\" />\n <path d=\"M-110 -70 L-60 -130 L110 -130 L120 -70 Z\" fill=\"#c9442e\" />\n <rect x=\"-15\" y=\"-46\" width=\"30\" height=\"46\" fill=\"#8a5a2b\" />\n </g>\n {[80, 160, 300, 470, 690, 960, 1030].map((x, i) => (\n <g key={i} transform={`translate(${x} ${560 + (i % 2) * 20})`}>\n <circle\n cx=\"0\"\n cy=\"-40\"\n r={50 - (i % 3) * 8}\n fill=\"#3f8f2e\"\n stroke={INK}\n strokeWidth=\"5\"\n />\n <circle cx=\"-14\" cy=\"-56\" r=\"16\" fill=\"#5bb043\" />\n </g>\n ))}\n {/* Meadow */}\n <rect y=\"590\" width=\"1080\" height=\"1330\" fill=\"url(#bd-field)\" />\n <rect y=\"590\" width=\"1080\" height=\"1330\" fill=\"url(#bd-light)\" />\n <path\n d=\"M0 1520 Q260 1480 540 1530 T1080 1520 L1080 1700 Q800 1740 540 1700 T0 1720 Z\"\n fill=\"url(#bd-road)\"\n stroke=\"#b07a35\"\n strokeWidth=\"4\"\n />\n {[\n [40, 700, 1.1],\n [1030, 830, 0.9],\n [60, 1170, 1],\n [1020, 1180, 1.2],\n [120, 1840, 1.5],\n [960, 1830, 1.6],\n ].map(([x, y, s], i) => (\n <g\n key={i}\n transform={`translate(${x} ${y}) scale(${s})`}\n stroke={INK}\n strokeWidth=\"4\"\n >\n <circle cx=\"-30\" cy=\"0\" r=\"46\" fill=\"#58a83a\" />\n <circle cx=\"30\" cy=\"-10\" r=\"52\" fill=\"#4c9c33\" />\n <circle cx=\"0\" cy=\"-40\" r=\"40\" fill=\"#63b443\" />\n </g>\n ))}\n {[\n [90, 740],\n [140, 1190],\n [1000, 1490],\n [870, 1500],\n [260, 1880],\n [820, 1840],\n ].map(([x, y], i) => (\n <ellipse\n key={i}\n cx={x}\n cy={y}\n rx=\"38\"\n ry=\"24\"\n fill=\"#9aa1ad\"\n stroke={INK}\n strokeWidth=\"4\"\n />\n ))}\n {Array.from({ length: 40 }, (_, i) => (\n <circle\n key={i}\n cx={(i * 197) % 1080}\n cy={640 + ((i * 331) % 1200)}\n r=\"5\"\n fill={i % 3 === 0 ? \"#fff\" : i % 3 === 1 ? \"#ffd84a\" : \"#ff8fb0\"}\n />\n ))}\n </svg>\n );\n}\n\n/** The raid window's blue alcove (GameModes/Raid/background). */\nexport function RaidBackdrop(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 1080 1920\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <defs>\n <radialGradient id=\"rb-in\" cx=\"0.5\" cy=\"0.5\" r=\"0.7\">\n <stop offset=\"0\" stopColor=\"#2f80ea\" />\n <stop offset=\"1\" stopColor=\"#1b5fc9\" />\n </radialGradient>\n <linearGradient id=\"rb-out\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#0d48a8\" />\n <stop offset=\"1\" stopColor=\"#1557c2\" />\n </linearGradient>\n </defs>\n <rect width=\"1080\" height=\"1920\" fill=\"url(#rb-out)\" />\n <path\n d=\"M10 1750 L10 330 Q10 235 110 235 L970 235 Q1070 235 1070 330 L1070 1750 Z\"\n fill=\"#2a6fd8\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"8\"\n />\n <path\n d=\"M60 1760 L60 360 Q60 285 135 285 L945 285 Q1020 285 1020 360 L1020 1760 Z\"\n fill=\"url(#rb-in)\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"6\"\n />\n <path\n d=\"M40 245 L1040 245\"\n stroke=\"#6fb0ff\"\n strokeWidth=\"6\"\n opacity=\"0.6\"\n />\n <rect y=\"1760\" width=\"1080\" height=\"160\" fill=\"#174fb4\" />\n <path d=\"M0 1760 L1080 1760\" stroke=\"#0b3a8c\" strokeWidth=\"8\" />\n <path\n d=\"M0 1840 L80 1730 L80 1760 Z M1080 1840 L1000 1730 L1000 1760 Z\"\n fill=\"#2a6fd8\"\n stroke=\"#0b3a8c\"\n strokeWidth=\"6\"\n />\n </svg>\n );\n}\n\n/** Roof colours per building slot — the same as the houses on the island (city.ts). */\nconst ROOFS = [\n \"#e8534e\",\n \"#4a90e2\",\n \"#9c5cff\",\n \"#f0a020\",\n \"#42c9c2\",\n \"#4caf50\",\n];\n\n/**\n * A building of the attack window (Buildings/Stages/{stage}/{slot}_{level}): an isometric cartoon\n * house that grows a storey every two levels. Level 0 is an empty plot under scaffolding, shown grey\n * like the reference's grayscale material; a damaged one is the level-1 art, scorched.\n */\nexport function BuildingArt({\n slot,\n level,\n damaged,\n}: {\n slot: number;\n level: number;\n damaged?: boolean;\n}): ReactNode {\n const roof = ROOFS[slot % ROOFS.length] ?? \"#e8534e\";\n const empty = level <= 0;\n const storeys = damaged\n ? 1\n : Math.min(3, 1 + Math.floor(Math.max(0, level - 1) / 2));\n const wallH = 70;\n const top = 300 - storeys * wallH;\n return (\n <svg\n viewBox=\"0 0 400 400\"\n style={{\n width: \"100%\",\n height: \"100%\",\n overflow: \"visible\",\n filter: empty\n ? \"grayscale(1) brightness(1.05)\"\n : damaged\n ? \"brightness(.72) saturate(.7)\"\n : undefined,\n }}\n aria-hidden\n >\n <g stroke={INK} strokeWidth=\"7\" strokeLinejoin=\"round\">\n {/* Plot */}\n <path d=\"M200 250 L360 320 L200 390 L40 320 Z\" fill=\"#e8c88f\" />\n <path\n d=\"M40 320 L40 336 L200 406 L360 336 L360 320 L200 390 Z\"\n fill=\"#c9a26a\"\n />\n <ellipse cx=\"70\" cy=\"300\" rx=\"28\" ry=\"20\" fill=\"#6cbf3d\" />\n <ellipse cx=\"335\" cy=\"305\" rx=\"30\" ry=\"22\" fill=\"#6cbf3d\" />\n {empty ? (\n <>\n <path\n d=\"M120 300 L120 160 M280 300 L280 160 M120 170 L280 170 M120 230 L280 230\"\n fill=\"none\"\n stroke=\"#d98c2b\"\n strokeWidth=\"12\"\n />\n <path\n d=\"M120 300 L120 160 M280 300 L280 160 M120 170 L280 170 M120 230 L280 230\"\n fill=\"none\"\n />\n <rect x=\"160\" y=\"280\" width=\"50\" height=\"40\" fill=\"#e09a3a\" />\n <rect x=\"225\" y=\"290\" width=\"40\" height=\"32\" fill=\"#e09a3a\" />\n </>\n ) : (\n <>\n {/* Walls: left face + right face */}\n <path\n d={`M90 ${top + 40} L200 ${top + 90} L200 340 L90 290 Z`}\n fill=\"#f4e7cc\"\n />\n <path\n d={`M200 ${top + 90} L310 ${top + 40} L310 290 L200 340 Z`}\n fill=\"#dccaa6\"\n />\n {Array.from({ length: storeys }, (_, i) => {\n const y = top + 70 + i * wallH;\n return (\n <g key={i} strokeWidth=\"5\">\n <path\n d={`M118 ${y} L150 ${y + 15} L150 ${y + 45} L118 ${y + 30} Z`}\n fill=\"#7fd0ff\"\n />\n <path\n d={`M232 ${y + 18} L268 ${y + 2} L268 ${y + 32} L232 ${y + 48} Z`}\n fill=\"#7fd0ff\"\n />\n </g>\n );\n })}\n <path\n d=\"M178 330 L178 290 L210 305 L210 344 Z\"\n fill=\"#8a5a2b\"\n strokeWidth=\"5\"\n />\n {/* Roof */}\n <path\n d={`M70 ${top + 48} L200 ${top - 20} L330 ${top + 48} L200 ${top + 108} Z`}\n fill={roof}\n />\n <path\n d={`M200 ${top - 20} L200 ${top + 108}`}\n stroke=\"rgba(0,0,0,.25)\"\n strokeWidth=\"6\"\n />\n <path\n d={`M95 ${top + 40} L200 ${top - 8}`}\n stroke=\"rgba(255,255,255,.55)\"\n strokeWidth=\"8\"\n fill=\"none\"\n />\n {level >= 5 ? (\n <path\n d={`M200 ${top - 20} L200 ${top - 70} L240 ${top - 58} L200 ${top - 46}`}\n fill=\"#ffd84a\"\n strokeWidth=\"5\"\n />\n ) : null}\n </>\n )}\n {damaged ? (\n <g strokeWidth=\"5\" fill=\"none\" stroke=\"#2a1a10\">\n <path d=\"M130 250 L150 270 L140 290\" />\n <path d=\"M250 240 L270 262 L258 282\" />\n </g>\n ) : null}\n </g>\n </svg>\n );\n}\n\n/** The red crosshair over every target building (GameModes/Attack/aim). */\nexport function AimArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 100 100\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke=\"#6a0d0d\" strokeWidth=\"3\" fill=\"#ef2b2b\" strokeLinejoin=\"round\">\n <path\n d=\"M50 14 A36 36 0 1 1 49.9 14 Z M50 26 A24 24 0 1 0 50.1 26 Z\"\n fillRule=\"evenodd\"\n />\n <rect x=\"45.5\" y=\"4\" width=\"9\" height=\"34\" rx=\"4.5\" />\n <rect x=\"45.5\" y=\"62\" width=\"9\" height=\"34\" rx=\"4.5\" />\n <rect x=\"4\" y=\"45.5\" width=\"34\" height=\"9\" rx=\"4.5\" />\n <rect x=\"62\" y=\"45.5\" width=\"34\" height=\"9\" rx=\"4.5\" />\n </g>\n <path\n d=\"M26 30 A30 30 0 0 1 44 18\"\n stroke=\"#ff9a9a\"\n strokeWidth=\"3\"\n fill=\"none\"\n strokeLinecap=\"round\"\n />\n </svg>\n );\n}\n\n/** The electric sword (BetterAttackPanel/ElectricSword): an ice-blue blade wrapped in lightning. */\nexport function SwordArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 600\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n aria-hidden\n >\n <defs>\n <linearGradient id=\"sw-blade\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n <stop offset=\"0\" stopColor=\"#bfe6ff\" />\n <stop offset=\"0.55\" stopColor=\"#ffffff\" />\n <stop offset=\"1\" stopColor=\"#8fd0ff\" />\n </linearGradient>\n <filter id=\"sw-glow\" x=\"-60%\" y=\"-20%\" width=\"220%\" height=\"140%\">\n <feGaussianBlur stdDeviation=\"9\" result=\"b\" />\n <feMerge>\n <feMergeNode in=\"b\" />\n <feMergeNode in=\"SourceGraphic\" />\n </feMerge>\n </filter>\n </defs>\n <g filter=\"url(#sw-glow)\">\n <path\n d=\"M100 8 L140 70 L132 420 L68 420 L60 70 Z\"\n fill=\"url(#sw-blade)\"\n stroke=\"#1f63d6\"\n strokeWidth=\"7\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M100 30 L104 410\"\n stroke=\"#e6f6ff\"\n strokeWidth=\"8\"\n opacity=\"0.9\"\n />\n <path\n d=\"M56 90 L40 140 L58 170 L38 230 L56 270 L40 330 L58 380\"\n stroke=\"#7cd4ff\"\n strokeWidth=\"5\"\n fill=\"none\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M144 110 L162 160 L142 200 L164 260 L144 300 L160 360 L140 400\"\n stroke=\"#7cd4ff\"\n strokeWidth=\"5\"\n fill=\"none\"\n strokeLinejoin=\"round\"\n />\n </g>\n <g stroke=\"#14306b\" strokeWidth=\"7\" strokeLinejoin=\"round\">\n <rect x=\"30\" y=\"418\" width=\"140\" height=\"34\" rx=\"8\" fill=\"#a9dcff\" />\n <rect x=\"80\" y=\"452\" width=\"40\" height=\"22\" fill=\"#cfeeff\" />\n <rect x=\"74\" y=\"472\" width=\"52\" height=\"92\" rx=\"12\" fill=\"#3e5f9c\" />\n <path\n d=\"M76 492 L124 492 M76 512 L124 512 M76 532 L124 532 M76 552 L124 552\"\n stroke=\"#7d9ad0\"\n strokeWidth=\"5\"\n />\n <rect x=\"66\" y=\"560\" width=\"68\" height=\"32\" rx=\"16\" fill=\"#2d4a85\" />\n </g>\n </svg>\n );\n}\n\n/** A jagged bolt from the sword to the struck building. */\nexport function LightningArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 100 300\"\n preserveAspectRatio=\"none\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <path\n d=\"M58 0 L36 92 L60 98 L30 190 L56 196 L42 300 L80 168 L52 162 L78 74 L52 68 Z\"\n fill=\"#e9f8ff\"\n stroke=\"#4fb6ff\"\n strokeWidth=\"5\"\n strokeLinejoin=\"round\"\n />\n </svg>\n );\n}\n\n/** The shield that blocks a strike (Abilities/ui_ab_shield). */\nexport function ShieldArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 220\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke={INK} strokeWidth=\"9\" strokeLinejoin=\"round\">\n <path\n d=\"M100 10 L180 40 Q182 150 100 210 Q18 150 20 40 Z\"\n fill=\"#3d8cf0\"\n />\n <path\n d=\"M100 32 L160 54 Q160 138 100 186 Q40 138 40 54 Z\"\n fill=\"#ffd23a\"\n />\n <path\n d=\"M100 52 L140 68 Q138 128 100 162 Q62 128 60 68 Z\"\n fill=\"#3d8cf0\"\n />\n </g>\n <path\n d=\"M48 60 Q60 50 86 42\"\n stroke=\"#fff\"\n strokeWidth=\"8\"\n fill=\"none\"\n strokeLinecap=\"round\"\n opacity=\"0.7\"\n />\n </svg>\n );\n}\n\n/** Fire on a hit building (Abilities/ui_ab_fireball). */\nexport function FireArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 240\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n aria-hidden\n >\n <g stroke=\"#7a1f05\" strokeWidth=\"7\" strokeLinejoin=\"round\">\n <path\n d=\"M100 8 Q150 70 160 120 Q176 200 100 230 Q24 200 40 120 Q56 150 74 150 Q60 90 100 8 Z\"\n fill=\"#ff5a1f\"\n />\n <path\n d=\"M100 70 Q130 120 132 160 Q132 210 100 214 Q66 210 70 166 Q80 180 92 178 Q86 130 100 70 Z\"\n fill=\"#ffb21f\"\n />\n <path\n d=\"M100 140 Q114 170 112 190 Q110 206 100 206 Q88 204 88 190 Q90 170 100 140 Z\"\n fill=\"#fff3a0\"\n />\n </g>\n </svg>\n );\n}\n\n/** A closed bank safe of the raid grid: the frame, the door with its gloss, and the tumbler on top. */\nexport function SafeFrameArt({ open }: { open: boolean }): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 200\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <rect\n x=\"8\"\n y=\"16\"\n width=\"184\"\n height=\"168\"\n rx=\"30\"\n fill=\"#b9cdf0\"\n stroke={INK}\n strokeWidth=\"7\"\n />\n <path\n d=\"M22 40 Q24 26 44 24\"\n stroke=\"#fff\"\n strokeWidth=\"7\"\n fill=\"none\"\n strokeLinecap=\"round\"\n />\n <rect\n x=\"26\"\n y=\"34\"\n width=\"148\"\n height=\"132\"\n rx=\"20\"\n fill={open ? \"#13286a\" : \"#2a4fa8\"}\n stroke={INK}\n strokeWidth=\"5\"\n />\n {open ? (\n <rect x=\"34\" y=\"42\" width=\"132\" height=\"116\" rx=\"16\" fill=\"#0f2258\" />\n ) : null}\n </svg>\n );\n}\n\nexport function SafeDoorArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 200\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <rect\n x=\"8\"\n y=\"16\"\n width=\"184\"\n height=\"168\"\n rx=\"30\"\n fill=\"#b9cdf0\"\n stroke={INK}\n strokeWidth=\"7\"\n />\n <rect\n x=\"26\"\n y=\"34\"\n width=\"148\"\n height=\"132\"\n rx=\"20\"\n fill=\"#c6d8f7\"\n stroke={INK}\n strokeWidth=\"5\"\n />\n <ellipse\n cx=\"48\"\n cy=\"52\"\n rx=\"13\"\n ry=\"8\"\n fill=\"#fff\"\n transform=\"rotate(-30 48 52)\"\n />\n <path\n d=\"M26 150 Q100 170 174 150\"\n stroke=\"#9db6e4\"\n strokeWidth=\"6\"\n fill=\"none\"\n />\n </svg>\n );\n}\n\nexport function TumblerArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 200 200\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <circle\n cx=\"100\"\n cy=\"104\"\n r=\"30\"\n fill=\"#4a6cb3\"\n stroke={INK}\n strokeWidth=\"5\"\n />\n <g stroke={INK} strokeWidth=\"5\" fill=\"#ffcf2e\" strokeLinejoin=\"round\">\n <rect x=\"91\" y=\"64\" width=\"18\" height=\"40\" rx=\"9\" />\n <rect\n x=\"91\"\n y=\"64\"\n width=\"18\"\n height=\"40\"\n rx=\"9\"\n transform=\"rotate(120 100 104)\"\n />\n <rect\n x=\"91\"\n y=\"64\"\n width=\"18\"\n height=\"40\"\n rx=\"9\"\n transform=\"rotate(240 100 104)\"\n />\n <circle cx=\"100\" cy=\"104\" r=\"17\" />\n </g>\n <circle\n cx=\"100\"\n cy=\"104\"\n r=\"8\"\n fill=\"none\"\n stroke=\"#c99a10\"\n strokeWidth=\"3\"\n />\n </svg>\n );\n}\n\nexport type BarsTone = \"gold\" | \"silver\" | \"bronze\";\nconst BARS: Record<BarsTone, [string, string, string]> = {\n gold: [\"#fff06a\", \"#ffcb1f\", \"#e98a0c\"],\n silver: [\"#ffffff\", \"#d7d9de\", \"#a3a7b0\"],\n bronze: [\"#ffd1a1\", \"#e2924c\", \"#a8571f\"],\n};\n\n/** Three stacked ingots (Raid/gold, silver, bronze) — the symbols of the raid. */\nexport function BarsArt({ tone }: { tone: BarsTone }): ReactNode {\n const [hi, mid, lo] = BARS[tone];\n const bar = (x: number, y: number): ReactNode => (\n <g\n transform={`translate(${x} ${y})`}\n stroke={INK}\n strokeWidth=\"6\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M0 40 L60 10 L130 30 L70 62 Z\" fill={hi} />\n <path d=\"M0 40 L70 62 L70 92 L4 70 Z\" fill={mid} />\n <path d=\"M70 62 L130 30 L128 60 L70 92 Z\" fill={lo} />\n </g>\n );\n return (\n <svg\n viewBox=\"0 0 220 180\"\n style={{ width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n {bar(8, 70)}\n {bar(84, 76)}\n {bar(44, 18)}\n </svg>\n );\n}\n\n/** The special card's treasure chest (Chest_Legendary_OpenEffect). */\nexport function ChestArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 240 220\"\n style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n aria-hidden\n >\n <defs>\n <radialGradient id=\"ch-glow\" cx=\"0.5\" cy=\"0.45\" r=\"0.55\">\n <stop offset=\"0\" stopColor=\"#fff6b0\" stopOpacity=\"0.95\" />\n <stop offset=\"1\" stopColor=\"#fff6b0\" stopOpacity=\"0\" />\n </radialGradient>\n </defs>\n <circle cx=\"120\" cy=\"96\" r=\"120\" fill=\"url(#ch-glow)\" />\n <g stroke={INK} strokeWidth=\"8\" strokeLinejoin=\"round\">\n <path d=\"M30 70 Q120 -6 210 70 L210 100 L30 100 Z\" fill=\"#9c4dff\" />\n <rect x=\"30\" y=\"96\" width=\"180\" height=\"104\" rx=\"12\" fill=\"#7a2fe0\" />\n <path d=\"M30 100 L210 100\" />\n <rect x=\"104\" y=\"40\" width=\"32\" height=\"160\" fill=\"#ffd23a\" />\n <rect x=\"96\" y=\"104\" width=\"48\" height=\"44\" rx=\"8\" fill=\"#ffd23a\" />\n <circle cx=\"120\" cy=\"124\" r=\"8\" fill={INK} />\n </g>\n <path\n d=\"M46 72 Q90 30 140 34\"\n stroke=\"#fff\"\n strokeWidth=\"8\"\n fill=\"none\"\n strokeLinecap=\"round\"\n opacity=\"0.6\"\n />\n {[\n [40, 30],\n [210, 26],\n [222, 120],\n [18, 128],\n ].map(([x, y], i) => (\n <path\n key={i}\n d={`M${x} ${(y ?? 0) - 14} L${(x ?? 0) + 5} ${(y ?? 0) - 4} L${(x ?? 0) + 15} ${y} L${(x ?? 0) + 5} ${(y ?? 0) + 4} L${x} ${(y ?? 0) + 14} L${(x ?? 0) - 5} ${(y ?? 0) + 4} L${(x ?? 0) - 15} ${y} L${(x ?? 0) - 5} ${(y ?? 0) - 4} Z`}\n fill=\"#fff8c2\"\n />\n ))}\n </svg>\n );\n}\n"
|
|
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 = \"#10172e\",\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 #10172e, 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 #10172e\",\n borderBottom: \"7px solid #10172e\",\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 #10172e\",\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 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"
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
"path": "components/panelStyles.ts",
|
|
@@ -99,11 +99,11 @@
|
|
|
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 #10172e\",\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), 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"
|
|
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 border: \"
|
|
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), 0 6px 0 ${auto ? \"#0d7a9c\" : \"#1a3f99\"}, 0 12px 22px rgba(0,0,0,.35)`,\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 2px 0 rgba(0,0,0,.35), 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",
|
|
@@ -111,7 +111,7 @@
|
|
|
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 #10172e\",\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=\"#10172e\" 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=\"#10172e\" 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 #10172e, 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=\"#10172e\"\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 #10172e\",\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:\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"
|
|
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\": {
|
|
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.2\",\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
|
}
|