├── .gitignore ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── frontend ├── README.md ├── components │ ├── Board │ │ ├── Board.tsx │ │ ├── Pixel │ │ │ ├── Pixel.tsx │ │ │ └── index.ts │ │ ├── PlayerTurnSoundEffect │ │ │ ├── PlayerTurnSoundEffect.tsx │ │ │ └── index.ts │ │ ├── ScoreItem │ │ │ ├── ScoreItem.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── Button │ │ ├── Button.tsx │ │ └── index.ts │ ├── CodeBlock │ │ ├── CodeBlock.tsx │ │ └── index.ts │ ├── ConnectWallet │ │ ├── ConnectWallet.tsx │ │ └── index.ts │ ├── ConnectWalletModal │ │ ├── ConnectWalletModal.tsx │ │ ├── PlayerSelect │ │ │ ├── PlayerSelect.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── ExternalLink │ │ ├── ExternalLink.tsx │ │ └── index.ts │ ├── GameStatus │ │ ├── FinishedStatus │ │ │ ├── FinishedStatus.tsx │ │ │ └── index.ts │ │ ├── FormingStatus │ │ │ ├── FormingStatus.tsx │ │ │ └── index.ts │ │ ├── GameStatus.tsx │ │ ├── RunningStatus │ │ │ ├── RunningStatus.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── Layout │ │ ├── GameLogs │ │ │ ├── GameLogs.tsx │ │ │ └── index.ts │ │ ├── Layout.tsx │ │ ├── Nav │ │ │ ├── Nav.tsx │ │ │ └── index.ts │ │ ├── Notifications │ │ │ ├── Notifications.tsx │ │ │ └── index.ts │ │ ├── Rules │ │ │ ├── Rules.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── LottieEntity │ │ ├── LottieEntity.tsx │ │ └── index.ts │ ├── Modal │ │ ├── Modal.tsx │ │ └── index.ts │ ├── NumberInput │ │ ├── NumberInput.tsx │ │ └── index.ts │ ├── Sidebar │ │ ├── Sidebar.tsx │ │ └── index.ts │ ├── SimpleWidget │ │ ├── SimpleWidget.tsx │ │ └── index.ts │ ├── Snackbar │ │ ├── Snackbar.tsx │ │ └── index.ts │ ├── ToggleSwitch │ │ ├── ToggleSwitch.tsx │ │ └── index.ts │ ├── ToggleSwitchLabel │ │ ├── ToggleSwitchLabel.tsx │ │ └── index.ts │ └── pg-game │ │ ├── GameBoard │ │ ├── GameBoard.tsx │ │ ├── Settings │ │ │ ├── LanguageSelect │ │ │ │ ├── LanguageSelect.tsx │ │ │ │ └── index.ts │ │ │ ├── Settings.tsx │ │ │ ├── TrackSelect │ │ │ │ ├── TrackSelect.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ └── index.ts │ │ ├── GamePage.tsx │ │ └── index.ts ├── constants │ └── game │ │ └── metadata.json ├── contexts │ ├── AudioSettingsContext.tsx │ ├── GameContext.tsx │ ├── LanguageContext.tsx │ └── UIContext.tsx ├── hooks │ ├── useAudioSettings.ts │ ├── useGameContract │ │ ├── data.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── useGameContract.ts │ ├── useGameEvents │ │ ├── index.ts │ │ ├── types.ts │ │ └── useGameEvents.ts │ └── useLanguageSettings.ts ├── lib │ └── useInk │ │ ├── InkProvider.tsx │ │ ├── constants.ts │ │ ├── hooks │ │ ├── index.ts │ │ ├── useApi.ts │ │ ├── useBlockHeader.ts │ │ ├── useConfig.ts │ │ ├── useContract.ts │ │ ├── useContractCall.ts │ │ ├── useContractCallDecoded.ts │ │ ├── useContractEvents.ts │ │ ├── useContractTx.ts │ │ ├── useExtension.ts │ │ ├── useInterval.ts │ │ ├── useIsMounted.ts │ │ ├── useLogs.ts │ │ ├── useNotifications.ts │ │ └── useProvider.ts │ │ ├── index.ts │ │ ├── models │ │ └── extrinsics │ │ │ └── model.ts │ │ ├── providers │ │ ├── api │ │ │ ├── context.ts │ │ │ ├── model.ts │ │ │ └── provider.tsx │ │ ├── blockHeader │ │ │ ├── context.ts │ │ │ ├── model.ts │ │ │ └── provider.tsx │ │ ├── config │ │ │ ├── context.ts │ │ │ ├── model.ts │ │ │ └── provider.tsx │ │ ├── contractEvents │ │ │ ├── context.ts │ │ │ ├── model.ts │ │ │ ├── provider.tsx │ │ │ └── reducer.ts │ │ ├── extension │ │ │ ├── context.ts │ │ │ ├── model.ts │ │ │ └── provider.tsx │ │ ├── logs │ │ │ ├── context.ts │ │ │ └── provider.tsx │ │ └── notifications │ │ │ ├── context.ts │ │ │ ├── model.ts │ │ │ ├── provider.tsx │ │ │ └── reducer.ts │ │ ├── types │ │ ├── contracts.ts │ │ ├── index.ts │ │ ├── notifications.ts │ │ ├── result.ts │ │ └── substrate.ts │ │ └── utils │ │ ├── contractFunctionUtils.ts │ │ ├── contracts │ │ ├── callContract.ts │ │ ├── callContractDecoded.ts │ │ ├── decodeContractExecResult.ts │ │ ├── index.ts │ │ ├── toContractAbiMessage.ts │ │ └── toRegistryErrorDecoded.ts │ │ ├── getExpiredItem.ts │ │ ├── index.ts │ │ ├── parseUnits.ts │ │ └── substrate │ │ ├── index.ts │ │ └── toWeightV2.ts ├── next-i18next.config.js ├── next.config.js ├── package.json ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── gm.ts │ ├── game │ │ └── [address].tsx │ └── index.tsx ├── postcss.config.js ├── public │ ├── audio │ │ ├── coin.mp3 │ │ ├── failure-fx.mp3 │ │ ├── failure.mp3 │ │ ├── migration-of-the-jellyfish.mp3 │ │ ├── mysteries-of-the-deep.mp3 │ │ ├── secret-agent.mp3 │ │ ├── squink-jazz.mp3 │ │ ├── squinks-adventure.mp3 │ │ ├── squinks-tune.mp3 │ │ ├── success.mp3 │ │ ├── the-angry-crab.mp3 │ │ ├── the-submarine.mp3 │ │ └── whip.mp3 │ ├── board.svg │ ├── dark-sea-creatures.json │ ├── favicon.ico │ ├── favicon.png │ ├── fish-1.svg │ ├── fish-2.svg │ ├── jelly-fish.svg │ ├── locales │ │ ├── en │ │ │ ├── common.json │ │ │ ├── events.json │ │ │ └── rules.json │ │ ├── es │ │ │ ├── common.json │ │ │ ├── events.json │ │ │ └── rules.json │ │ └── fr │ │ │ ├── common.json │ │ │ ├── events.json │ │ │ └── rules.json │ ├── plant-1.svg │ ├── plant-2.svg │ ├── sea-bg.svg │ ├── sea-creatures.json │ ├── sea-horse-silhuoette.svg │ ├── sea-horse.svg │ ├── spider-crab.svg │ ├── squink.json │ ├── star-fish.svg │ └── sub.svg ├── styles │ └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── utils │ ├── index.ts │ ├── types.ts │ └── utils.ts └── yarn.lock ├── game ├── .gitignore ├── .rustfmt.toml ├── Cargo.toml ├── drive-game.sh └── lib.rs ├── node_modules └── .yarn-integrity ├── stresstest ├── Cargo.toml ├── README.md ├── create-player.sh ├── lib.rs └── stresstest.sh └── test-player ├── .gitignore ├── .rustfmt.toml ├── Cargo.toml └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore build artifacts from the local tests sub-crate. 2 | /target/ 3 | /stresstest/target/ 4 | /core/target/ 5 | /model/target/ 6 | /lang/target/ 7 | /intelligent-player/target/ 8 | /primitives/target/ 9 | /examples/**/target/ 10 | /design/ 11 | 12 | 13 | # Ignore backup files creates by cargo fmt. 14 | **/*.rs.bk 15 | 16 | # Remove Cargo.lock when creating an executable, leave it for libraries 17 | # More information here http://doc.crates.io/guide.html#cargotoml-vs-cargolock 18 | Cargo.lock 19 | 20 | # Ignore history files. 21 | **/.history/** 22 | 23 | 24 | # frontend 25 | 26 | /frontend/node_modules 27 | /frontend/.next 28 | 29 | # IDE 30 | .vscode/ 31 | *.swap 32 | 33 | # debug 34 | npm-debug.log* 35 | yarn-debug.log* 36 | yarn-error.log* 37 | .pnpm-debug.log* 38 | 39 | yarn.lock 40 | package-lock.json 41 | 42 | .vercel 43 | 44 | # typescript 45 | *.tsbuildinfo 46 | next-env.d.ts 47 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | yarn.lock 4 | package-lock.json 5 | public 6 | typechain 7 | artifacts 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "tabWidth": 2, 7 | "overrides": [ 8 | { 9 | "files": "*.sol", 10 | "options": { 11 | "tabWidth": 4, 12 | "useTabs": false, 13 | "singleQuote": false, 14 | "bracketSpacing": false, 15 | "explicitTypes": "always" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ### Facilitation, Not Strongarming 26 | 27 | We recognise that this software is merely a tool for users to create and maintain their blockchain of preference. We see that blockchains are naturally community platforms with users being the ultimate decision makers. We assert that good software will maximise user agency by facilitate user-expression on the network. As such: 28 | 29 | - This project will strive to give users as much choice as is both reasonable and possible over what protocol they adhere to; but 30 | - use of the project's technical forums, commenting systems, pull requests and issue trackers as a means to express individual protocol preferences is forbidden. 31 | 32 | ## Our Responsibilities 33 | 34 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 35 | 36 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 37 | 38 | ## Scope 39 | 40 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 41 | 42 | ## Enforcement 43 | 44 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at ink@use.ink. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 45 | 46 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 47 | 48 | ## Attribution 49 | 50 | This Code of Conduct is adapted from the http://contributor-covenant.org[Contributor Covenant], version 1.4, available at http://contributor-covenant.org/version/1/4 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🦑 ink! 4.0 Workshop 2 | 3 | This repository contains an interactive ink! workshop. 4 | We created it as a way of gamifying the experience of learning ink!. 5 | 6 | The workshop is a game, in which students write a smart contract 7 | that plays on their behalf – an agent. 8 | The score function of the game was chosen in a way that it favors 9 | contracts that are using as little as gas possible to play the game. 10 | This can be done using smart contract best practices. 11 | 12 | This repository contains: 13 | 14 | * `game/`: A smart contract that runs the game. Workshop participants 15 | have to register their player with the game contract. 16 | * `basic-player`: Example of a player contract. 17 | * `frontend/`: The Game UI, which the workshop instructor can put 18 | on a big screen, so that participants can see live how their agents 19 | are doing. 20 | 21 | The idea is that anyone who wants can give this workshop can use the slides and 22 | instructions which will be provided here. 23 | We'll add slides on how to conduct the workshop soon! 24 | 25 | There are two other relevant repositories: 26 | 27 | * [use-ink/squink-splash-beginner](https://github.com/use-ink/squink-splash-beginner): 28 | Contains setup instructions for the workshop participants and a 29 | basic player to participate in the game. 30 | * [use-ink/squink-splash-advanced](https://github.com/use-ink/squink-splash-advanced): 31 | Contains pointers to advanced ideas for playing the game. -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Squink Splash! 2 | 3 | Compete to win your place on the board. 4 | 5 | [Installation instructions](https://github.com/use-ink/ink-workshop/blob/main/workshop/1_SETUP.md) 6 | 7 | By default the page will connect to the Contracts Rococo network. 8 | 9 | ### Running the Dapp locally 10 | 11 | ```bash 12 | yarn dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser. 16 | 17 | An example game can be found using this address. Enter it into the input on the home page. 18 | 19 | ### Completed Game 20 | 21 | ```5CWnF6YqFXLaMQ1MddNQ76MfLyEK9mvL2nEECvpd3bRTGbWM``` 22 | ```5GG9r6pVFpFbd2ita4FKSN15jYgofBgG8BRKY78SHqbMRh1g``` 23 | 24 | If you would like to deploy your own game here are the steps: 25 | 26 | 1. deploy your game contract here https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Frococo-contracts-rpc.polkadot.io#/contracts 27 | 2. deploy one or more player contracts and register them in the game contract 28 | 3. copy the game contract address and enter it in to the input on the home page of the frontend app. Submit! 29 | 4. In polkadot.js you can now submit your turns. 30 | 31 | Note: The frontend currently does not update in real time. We need to tweak the subscriptions. Just refresh the page for now! 32 | -------------------------------------------------------------------------------- /frontend/components/Board/Pixel/Pixel.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useEffect, useState } from 'react'; 3 | import { animated, config, useSpring } from 'react-spring'; 4 | import { useUI } from '../../../contexts/UIContext'; 5 | import { useAudioSettings } from '../../../hooks/useAudioSettings'; 6 | import { TurnEvent } from '../../../hooks/useGameEvents'; 7 | 8 | type Props = { 9 | x: number; 10 | y: number; 11 | owner?: string | null; 12 | color?: string; 13 | events?: TurnEvent[]; 14 | }; 15 | 16 | export const Pixel: React.FC = ({ owner, color, x, y, events }) => { 17 | const { showGrid, showCoordinates } = useUI(); 18 | const [pulse, setPulse] = useState(owner ? 1 : 0); 19 | const props = useSpring({ x: pulse, config: config.default }); 20 | 21 | useEffect(() => { 22 | if (owner) setPulse(1); 23 | }, [owner]); 24 | 25 | return ( 26 | `scale(${x})`), 34 | backgroundColor: 'rgba(0,0,0,0.035)', 35 | background: color, 36 | boxShadow: showGrid || owner ? 'inset 0 0 0 0.5px rgba(0,0,0,0.075)' : '', 37 | }} 38 | className={classNames('transition duration-100 w-full h-full', !owner && 'flex items-center justify-center')} 39 | > 40 | {showCoordinates && ( 41 |

42 | {x}, {y} 43 |

44 | )} 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /frontend/components/Board/Pixel/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Pixel'; 2 | -------------------------------------------------------------------------------- /frontend/components/Board/PlayerTurnSoundEffect/PlayerTurnSoundEffect.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useUI } from '../../../contexts/UIContext'; 3 | import { useAudioSettings } from '../../../hooks/useAudioSettings'; 4 | import { TurnEvent } from '../../../hooks/useGameEvents'; 5 | 6 | type Props = { 7 | turn: TurnEvent; 8 | }; 9 | 10 | export const PlayerTurnSoundEffect: React.FC = ({ turn }) => { 11 | const { finalizedEffect, failureEffect } = useAudioSettings(); 12 | const { player } = useUI(); 13 | 14 | useEffect(() => { 15 | if (turn.name === 'BrokenPlayer') failureEffect?.play(); 16 | if (turn.name === 'OutOfBounds') failureEffect?.play(); 17 | if (turn.name === 'Success' && player === turn.player) { 18 | setTimeout(() => finalizedEffect?.play(), 500); 19 | } 20 | }, [turn, failureEffect, finalizedEffect]); 21 | 22 | return null; 23 | }; 24 | -------------------------------------------------------------------------------- /frontend/components/Board/PlayerTurnSoundEffect/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PlayerTurnSoundEffect'; 2 | -------------------------------------------------------------------------------- /frontend/components/Board/ScoreItem/ScoreItem.tsx: -------------------------------------------------------------------------------- 1 | import { PlayerScore } from '../../../hooks/useGameContract'; 2 | import { GiGasPump, GiCube } from 'react-icons/gi'; 3 | 4 | export type PlayerScoreUI = { 5 | player: PlayerScore; 6 | rank: number; 7 | }; 8 | 9 | type Props = PlayerScoreUI; 10 | 11 | export const ScoreItem: React.FC = ({ player, rank }) => { 12 | return ( 13 |
14 | 15 | 16 |
{rank ? `#${rank}` : '--'}
17 |
{player.name}
18 |
19 | 20 | 21 |

{player.score}

22 | 23 |
24 |
25 | 26 | 27 | 28 |

{player.gasLeft}

29 | 30 |
31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/components/Board/ScoreItem/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ScoreItem'; 2 | -------------------------------------------------------------------------------- /frontend/components/Board/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Board'; 2 | -------------------------------------------------------------------------------- /frontend/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | className?: string; 6 | disabled?: boolean; 7 | onClick: () => any; 8 | }; 9 | 10 | export const Button: React.FC = ({ onClick, className, children, disabled }) => { 11 | return ( 12 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/components/Button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Button'; 2 | -------------------------------------------------------------------------------- /frontend/components/CodeBlock/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | className?: string; 6 | }; 7 | 8 | export const CodeBlock: React.FC = ({ className, children }) => { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/components/CodeBlock/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CodeBlock'; 2 | -------------------------------------------------------------------------------- /frontend/components/ConnectWallet/ConnectWallet.tsx: -------------------------------------------------------------------------------- 1 | import { useUI } from '../../contexts/UIContext'; 2 | import { Button } from '../Button'; 3 | import classNames from 'classnames'; 4 | import { truncateHash } from '../../utils'; 5 | import { GiScrollUnfurled, GiWallet } from 'react-icons/gi'; 6 | import { RiRefreshLine } from 'react-icons/ri'; 7 | import { SimpleWidget } from '../SimpleWidget'; 8 | import { useMemo } from 'react'; 9 | import { usePlayerScores, useSubmitTurnFunc } from '../../hooks/useGameContract'; 10 | import { isBroadcasting, isInBlock, isPendingSignature, shouldDisableStrict } from '../../lib/useInk/utils'; 11 | import { useExtension } from '../../lib/useInk/hooks'; 12 | import { useTranslation } from 'next-i18next'; 13 | 14 | type Props = { 15 | className?: string; 16 | }; 17 | 18 | export const ConnectWallet: React.FC = ({ className }) => { 19 | const { setShowWalletConnect, player } = useUI(); 20 | const scores = usePlayerScores(); 21 | const { activeAccount } = useExtension(); 22 | const submitTurn = useSubmitTurnFunc(); 23 | const { t } = useTranslation('common'); 24 | 25 | const playerName = useMemo(() => { 26 | const p = scores.find((s) => s.id === player); 27 | return p?.name || truncateHash(player || ''); 28 | }, [scores, player]); 29 | 30 | const buttonTitle = () => { 31 | if (isPendingSignature(submitTurn)) return t('pendingSignature'); 32 | if (isBroadcasting(submitTurn)) return t('broadcasting'); 33 | if (isInBlock(submitTurn)) return t('inBlock'); 34 | return t('submitTurn'); 35 | }; 36 | 37 | if (!activeAccount || !player) { 38 | return ( 39 | 45 | ); 46 | } 47 | 48 | return ( 49 | 50 |
51 |
52 | 55 | 56 |
57 | 58 | 59 | {activeAccount.meta.name} 60 | 61 | 62 | 63 | {playerName} 64 | 65 |
66 |
67 |
68 | 75 |
76 |
77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /frontend/components/ConnectWallet/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ConnectWallet'; 2 | -------------------------------------------------------------------------------- /frontend/components/ConnectWalletModal/PlayerSelect/PlayerSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Listbox, Transition } from '@headlessui/react'; 2 | import { usePlayerScores } from '../../../hooks/useGameContract'; 3 | import { useUI } from '../../../contexts/UIContext'; 4 | import { RiArrowDownSFill, RiCheckFill } from 'react-icons/ri'; 5 | import { Fragment } from 'react'; 6 | import classNames from 'classnames'; 7 | import { t } from 'i18next'; 8 | import { useTranslation } from 'react-i18next'; 9 | 10 | type Props = { 11 | className?: string; 12 | }; 13 | 14 | export const PlayerSelect: React.FC = ({ className }) => { 15 | const players = usePlayerScores(); 16 | const { player, setPlayer } = useUI(); 17 | const { t } = useTranslation('common'); 18 | 19 | return ( 20 |
21 | 22 |
23 | 24 | {player ? player : t('selectPlayer')} 25 | 26 | 28 | 29 | 30 | {players.length === 0 ? ( 31 |

Loading...

32 | ) : ( 33 | 34 | {players.map((player, playerIndex) => ( 35 | 38 | `relative cursor-default select-none py-4 px-10 transition duration-75 text-brand-600 ${ 39 | active && 'bg-players-4/30' 40 | } hover:cursor-pointer` 41 | } 42 | value={player.id} 43 | > 44 | {({ selected }) => ( 45 | <> 46 | 47 | {player.name} 48 | 49 | {selected ? ( 50 | 51 | 53 | ) : null} 54 | 55 | )} 56 | 57 | ))} 58 | 59 | )} 60 |
61 |
62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /frontend/components/ConnectWalletModal/PlayerSelect/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PlayerSelect'; 2 | -------------------------------------------------------------------------------- /frontend/components/ConnectWalletModal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ConnectWalletModal'; 2 | -------------------------------------------------------------------------------- /frontend/components/ExternalLink/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | 3 | type Props = { 4 | href: string; 5 | className?: string; 6 | secondary?: boolean; 7 | underline?: boolean; 8 | children?: React.ReactNode; 9 | }; 10 | 11 | export const ExternalLink: React.FC = ({ className, children, href, secondary, underline }) => { 12 | return ( 13 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/components/ExternalLink/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ExternalLink'; 2 | -------------------------------------------------------------------------------- /frontend/components/GameStatus/FinishedStatus/FinishedStatus.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { Finished, usePlayerScores } from '../../../hooks/useGameContract'; 4 | import { truncateHash } from '../../../utils'; 5 | 6 | type Props = { 7 | finished: Finished; 8 | }; 9 | 10 | export const FinishedStatus: React.FC = ({ finished }) => { 11 | const categoryClass = 'mr-1'; 12 | const scores = usePlayerScores(); 13 | const { t } = useTranslation('common'); 14 | 15 | const winnerName = useMemo(() => { 16 | const p = scores.find((s) => s.id === finished.winner); 17 | return p?.name || truncateHash(finished.winner); 18 | }, [scores, finished.winner]); 19 | 20 | return ( 21 | <> 22 |
23 | {t('winner')}: 24 | {winnerName} 25 |
26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/components/GameStatus/FinishedStatus/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FinishedStatus'; 2 | -------------------------------------------------------------------------------- /frontend/components/GameStatus/FormingStatus/FormingStatus.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { GiCube } from 'react-icons/gi'; 3 | import { Forming } from '../../../hooks/useGameContract'; 4 | 5 | type Props = { 6 | forming: Forming; 7 | }; 8 | 9 | export const FormingStatus: React.FC = ({ forming }) => { 10 | const categoryClass = 'mr-1'; 11 | const { t } = useTranslation('common'); 12 | 13 | return ( 14 | <> 15 |
16 | {t('status')}: 17 | {t('ready')} 18 |
19 |
20 | {forming.startingIn > 0 && ( 21 |
22 | {t('startingIn', { blocks: forming.startingIn })} 23 |
24 | )} 25 |
26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/components/GameStatus/FormingStatus/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FormingStatus'; 2 | -------------------------------------------------------------------------------- /frontend/components/GameStatus/GameStatus.tsx: -------------------------------------------------------------------------------- 1 | import { GiBackup } from 'react-icons/gi'; 2 | import { useGameState, usePlayers } from '../../hooks/useGameContract'; 3 | import { SimpleWidget } from '../SimpleWidget'; 4 | import { FinishedStatus } from './FinishedStatus'; 5 | import { FormingStatus } from './FormingStatus'; 6 | import { RunningStatus } from './RunningStatus'; 7 | 8 | type RunningStatus = { 9 | status: 'Running'; 10 | totalRounds: number; 11 | currentRound: number; 12 | willStart: boolean; 13 | isActive: boolean; 14 | hasEnded: boolean; 15 | startingIn: number; 16 | }; 17 | 18 | type Status = RunningStatus; 19 | 20 | export const GameStatus: React.FC = () => { 21 | const gameState = useGameState(); 22 | const players = usePlayers(); 23 | 24 | return ( 25 | 26 | {'Forming' === gameState?.status && } 27 | {'Running' === gameState?.status && } 28 | {'Finished' === gameState?.status && } 29 | 30 | 31 | 32 | 33 | 34 |
{Object.keys(players).length}
35 |
36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/components/GameStatus/RunningStatus/RunningStatus.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { useContractCallDecoded } from '../../../lib/useInk/hooks/useContractCallDecoded'; 3 | import { Running } from '../../../hooks/useGameContract'; 4 | import { useGame } from '../../../contexts/GameContext'; 5 | import { useEffect, useState } from 'react'; 6 | 7 | type Props = { 8 | running: Running; 9 | }; 10 | 11 | export const RunningStatus: React.FC = ({ running }) => { 12 | const categoryClass = 'mr-1'; 13 | const { t } = useTranslation('common'); 14 | const { game, roundsPlayed } = useGame(); 15 | const decoded = useContractCallDecoded(game, 'isRunning'); 16 | const isRunning = decoded && decoded.ok ? decoded.value.result : false; 17 | const [latestRound, setLatestRound] = useState(running.currentRound); 18 | 19 | // useContractCallDecoded() reads the contract on every block change via a subscription, 20 | // but events can be emitted prior to this. In order to keep the painted pixel changes, 21 | // which are driven by events, and the block number changes in sync 22 | // we first check to see if roundsPlayed has been updated from an event, then resort to 23 | // the value fetched from useContracCallDecoded() if it hasn't (used on page load). 24 | useEffect(() => { 25 | if (running.currentRound > latestRound) setLatestRound(running.currentRound); 26 | if (roundsPlayed > latestRound) setLatestRound(roundsPlayed); 27 | }, [running.currentRound, roundsPlayed]); 28 | 29 | return ( 30 | <> 31 |
32 | {t('status')}: 33 | 34 | {isRunning ? t('play') : t('complete')} 35 | 36 |
37 |
38 | {t('block')}: 39 | 40 | {`${latestRound}/${running.totalRounds}`} 41 | 42 |
43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/components/GameStatus/RunningStatus/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RunningStatus'; 2 | -------------------------------------------------------------------------------- /frontend/components/GameStatus/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GameStatus'; 2 | -------------------------------------------------------------------------------- /frontend/components/Layout/GameLogs/GameLogs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useLogs } from '../../../lib/useInk/hooks/useLogs'; 3 | import { Sidebar } from '../../Sidebar'; 4 | import ReactJson from 'react-json-view'; 5 | import { useUI } from '../../../contexts/UIContext'; 6 | 7 | export const GameLogs = () => { 8 | const logs = useLogs(); 9 | const { showLogs, setShowLogs } = useUI(); 10 | 11 | return ( 12 | setShowLogs(false)}> 13 |
14 |

Game Event Logs

15 |
    16 | {!logs.length && ( 17 |
  • 18 |

    No logs...

    19 |
  • 20 | )} 21 | 22 | {logs.map((log) => ( 23 |
  • 24 | 35 |
  • 36 | ))} 37 |
38 |
39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /frontend/components/Layout/GameLogs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GameLogs'; 2 | -------------------------------------------------------------------------------- /frontend/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import dynamic from 'next/dynamic'; 3 | import { useUI } from '../../contexts/UIContext'; 4 | import { LottieEntity } from '../LottieEntity'; 5 | import { Notifications } from './Notifications'; 6 | import { Rules } from './Rules'; 7 | 8 | const ConnectWalletModal = dynamic(() => import('../ConnectWalletModal').then((mod) => mod.ConnectWalletModal), { 9 | ssr: false, 10 | }); 11 | 12 | const GameLogs = dynamic(() => import('./GameLogs').then((mod) => mod.GameLogs), { 13 | ssr: false, 14 | }); 15 | 16 | const Nav = dynamic(() => import('./Nav').then((mod) => mod.Nav), { 17 | ssr: false, 18 | }); 19 | 20 | type Props = { 21 | children?: React.ReactNode; 22 | }; 23 | 24 | export const Layout: React.FC = ({ children }) => { 25 | const { darkMode, showNotifications } = useUI(); 26 | return ( 27 |
33 | 34 | 38 | 39 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /frontend/components/Layout/Nav/Nav.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import classNames from 'classnames'; 3 | import React from 'react'; 4 | 5 | import { useRouter } from 'next/router'; 6 | import dynamic from 'next/dynamic'; 7 | 8 | const GameStatus = dynamic(() => import('../../GameStatus').then((mod) => mod.GameStatus), { 9 | ssr: false, 10 | }); 11 | 12 | export const Nav = () => { 13 | const classes = classNames('transition ease-in-out py-1 w-full z-11 flex items-center justify-between fixed top-0'); 14 | const router = useRouter(); 15 | 16 | return ( 17 |
18 | 40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/components/Layout/Nav/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Nav'; 2 | -------------------------------------------------------------------------------- /frontend/components/Layout/Notifications/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useGame } from '../../../contexts/GameContext'; 4 | import { usePlayers } from '../../../hooks/useGameContract'; 5 | import { PlayerRegistered, TurnEvent } from '../../../hooks/useGameEvents'; 6 | import { useLanguageSettings } from '../../../hooks/useLanguageSettings'; 7 | import { useNotifications } from '../../../lib/useInk/hooks/useNotifications'; 8 | import { Status } from '../../../lib/useInk/types'; 9 | import { Snackbar, SnackbarType } from '../../Snackbar'; 10 | 11 | type NotificationKind = Status | 'WalletConnected'; 12 | 13 | const NOTIFICATION_TYPES: { [k in NotificationKind]: SnackbarType } = { 14 | WalletConnected: 'info', 15 | None: 'info', 16 | PreFlight: 'info', 17 | PendingSignature: 'info', 18 | Broadcast: 'info', 19 | InBlock: 'success', 20 | Finalized: 'success', 21 | Errored: 'error', 22 | Future: 'info', 23 | Ready: 'info', 24 | Retracted: 'error', 25 | FinalityTimeout: 'error', 26 | Usurped: 'warning', 27 | Dropped: 'warning', 28 | Invalid: 'error', 29 | }; 30 | 31 | export const Notifications = () => { 32 | const { notifications } = useNotifications(); 33 | const { playerTurnEvents, playerRegisteredEvents } = useGame(); 34 | const names = usePlayers(); 35 | const eventTranslation = useTranslation('events'); 36 | const { 37 | languageTrack: { locale }, 38 | } = useLanguageSettings(); 39 | const { t } = eventTranslation; 40 | const resouces = 41 | useMemo(() => eventTranslation.i18n.getResourceBundle(locale, 'events'), [locale, eventTranslation]) || {}; 42 | 43 | const toEventMessage = (event: TurnEvent): string => { 44 | const player = names[event.player] ? names[event.player] : ''; 45 | 46 | switch (event.name) { 47 | case 'Success': 48 | const successIndex = Math.floor(Math.random() * (Object.values(resouces?.playerScored).length - 1)); 49 | return t(`playerScored.${successIndex}`, { player }); 50 | case 'BrokenPlayer': 51 | const brokenIndex = Math.floor(Math.random() * (Object.values(resouces?.brokenPlayer).length - 1)); 52 | return t(`brokenPlayer.${brokenIndex}`, { player }); 53 | case 'Occupied': 54 | const occupiedIndex = Math.floor(Math.random() * (Object.values(resouces?.playerCollision).length - 1)); 55 | return t(`playerCollision.${occupiedIndex}`, { player, x: event.turn.x, y: event.turn.y }); 56 | case 'OutOfBounds': 57 | const outOfBoundsIndex = Math.floor(Math.random() * (Object.values(resouces?.playerOutOfBounds).length - 1)); 58 | return t(`playerOutOfBounds.${outOfBoundsIndex}`, { player, x: event.turn.x, y: event.turn.y }); 59 | default: 60 | return ''; 61 | } 62 | }; 63 | 64 | const toPlayerRegisteredMessage = (event: PlayerRegistered): string => { 65 | const player = names[event.player] ? names[event.player] : ''; 66 | const successIndex = Math.floor(Math.random() * (Object.values(resouces?.playerScored).length - 1)); 67 | return t(`playerJoined.${successIndex}`, { player }); 68 | }; 69 | 70 | return ( 71 |
    72 | {playerRegisteredEvents.map((registeredEvent) => ( 73 |
  • 74 | 75 |
  • 76 | ))} 77 | 78 | {playerTurnEvents.map((turnEvent) => ( 79 |
  • 80 | 85 |
  • 86 | ))} 87 | 88 | {notifications.map((n) => ( 89 |
  • 90 | 91 |
  • 92 | ))} 93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /frontend/components/Layout/Notifications/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Notifications'; 2 | -------------------------------------------------------------------------------- /frontend/components/Layout/Rules/Rules.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import React from 'react'; 3 | import { Modal } from '../../Modal'; 4 | import { useUI } from '../../../contexts/UIContext'; 5 | import { CodeBlock } from '../../CodeBlock'; 6 | import { FiUsers, FiClock } from 'react-icons/fi'; 7 | import { ExternalLink } from '../../ExternalLink'; 8 | import { useTranslation } from 'react-i18next'; 9 | 10 | export const Rules = () => { 11 | const { showRules, setShowRules } = useUI(); 12 | const { t } = useTranslation('rules'); 13 | 14 | return ( 15 | setShowRules(false)}> 16 |
17 |

{t('rules.title')}

18 |

{t('objective')}

19 |

20 | {t('rules.desc1')} ink! . 21 |

22 |

23 | {t('rules.desc2')} 24 | {t('rules.gas')} 25 | {t('rules.desc3')}. 26 |

27 |
28 | 29 | {t('playersNumber')} 30 | 31 | 32 | 33 | {t('playTime')} 34 | 35 |
36 |

{t('instructions')}

37 |
    38 |
  1. 39 | 44 | {t('setUp')} 45 | 46 |
  2. 47 |
48 |

{t('gamePlay')}

49 |
    50 |
  1. 51 |

    {t('formingStage.title')}

    52 |

    53 | {t('formingStage.desc')} 54 | {t('formingStage.camelCase')} 55 | {t('formingStage.or')} 56 | {t('formingStage.pascalCase')}. 57 |

    58 |
  2. 59 | 60 |
  3. 61 |

    {t('gameStart.title')}

    62 |

    63 | {t('gameStart.desc1')} 64 | {t('gameStart.n')} 65 | {t('gameStart.desc2')} {t('gameStart.bold')} 66 |

    67 |
  4. 68 | 69 |
  5. 70 |

    {t('completion.title')}

    71 |

    72 | {t('completion.desc1')} end_game() {t('completion.desc2')} 73 |

    74 |
  6. 75 |
76 |
77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /frontend/components/Layout/Rules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Rules'; 2 | -------------------------------------------------------------------------------- /frontend/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Layout'; 2 | -------------------------------------------------------------------------------- /frontend/components/LottieEntity/LottieEntity.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { Player } from '@lottiefiles/react-lottie-player'; 3 | 4 | type Props = { 5 | src: string; 6 | className?: string; 7 | }; 8 | 9 | export const LottieEntity: React.FC = ({ src, className }) => { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/components/LottieEntity/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LottieEntity'; 2 | -------------------------------------------------------------------------------- /frontend/components/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, ReactNode } from 'react'; 2 | import { Dialog, Transition } from '@headlessui/react'; 3 | import classNames from 'classnames'; 4 | 5 | type Props = { 6 | open: boolean; 7 | handleClose?: () => void; 8 | className?: string; 9 | children: ReactNode; 10 | }; 11 | 12 | export const Modal: React.FC = ({ open, handleClose, children, className }) => { 13 | const containerClasses = classNames( 14 | 'inline-block bg-brand-500 border border-white/20 rounded-2xl overflow-scroll shadow-xl transform transition-all w-full max-w-3xl', 15 | className, 16 | ); 17 | 18 | return ( 19 | 20 | handleClose && handleClose()} 26 | > 27 |
28 | 37 | 38 | 39 | 48 |
{children}
49 |
50 |
51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /frontend/components/Modal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Modal'; 2 | -------------------------------------------------------------------------------- /frontend/components/NumberInput/NumberInput.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import classnames from 'classnames'; 3 | import React from 'react'; 4 | import { RiSubtractLine, RiAddLine } from 'react-icons/ri'; 5 | 6 | type Props = { 7 | onChange: (v: number) => any; 8 | value: number; 9 | placeholder?: string; 10 | className?: string; 11 | disabled?: boolean; 12 | max: number; 13 | min?: number; 14 | }; 15 | 16 | export const NumberInput: React.FC = ({ value, disabled, onChange, placeholder, max, min = 0, className }) => { 17 | const commonClasses = 18 | 'bg-white/30 border-none focuse:outline-none focus-visible:outline-none focus:outline-none disabled:text-gray-500 py-4 flex items-center justify-center'; 19 | const buttonClasses = 'hover:bg-white/40 disabled:bg-white/20 disabled:cursor-not-allowed'; 20 | 21 | const handleChange = (v: number) => { 22 | const val = v || min; 23 | if (val < min) return; 24 | if (val > max) return; 25 | onChange(val); 26 | }; 27 | 28 | return ( 29 | 30 | 41 | { 53 | handleChange(parseInt(e.target.value) || 0); 54 | }} 55 | /> 56 | 63 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /frontend/components/NumberInput/index.ts: -------------------------------------------------------------------------------- 1 | export * from './NumberInput'; 2 | -------------------------------------------------------------------------------- /frontend/components/Sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { ReactNode } from 'react'; 3 | import { RiCloseLine } from 'react-icons/ri'; 4 | import { animated, useSpring } from 'react-spring'; 5 | 6 | type Props = { 7 | children: ReactNode; 8 | show: boolean; 9 | className?: string; 10 | onClose: () => any; 11 | }; 12 | 13 | export const Sidebar: React.FC = ({ show, children, onClose, className }) => { 14 | const width = 800; 15 | const transactionPanelProps = useSpring({ 16 | from: { translateX: -width, width }, 17 | to: { translateX: show ? 0 : -width }, 18 | }); 19 | 20 | return ( 21 | 28 | 31 | {children} 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/components/Sidebar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Sidebar'; 2 | -------------------------------------------------------------------------------- /frontend/components/SimpleWidget/SimpleWidget.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { ReactNode } from 'react'; 3 | 4 | type Props = { 5 | children: ReactNode; 6 | className?: string; 7 | }; 8 | 9 | export const SimpleWidget: React.FC = ({ children, className }) => { 10 | return ( 11 |
17 | {children} 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/components/SimpleWidget/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SimpleWidget'; 2 | -------------------------------------------------------------------------------- /frontend/components/Snackbar/Snackbar.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from '@headlessui/react'; 2 | import classNames from 'classnames'; 3 | import { Fragment } from 'react'; 4 | 5 | export type SnackbarType = 'success' | 'error' | 'warning' | 'info'; 6 | 7 | type Props = { 8 | className?: string; 9 | message: string; 10 | show: boolean; 11 | type: SnackbarType; 12 | Icon?: React.FC; 13 | }; 14 | 15 | const BG_COLORS = { 16 | success: 'bg-players-3', 17 | error: 'bg-players-2', 18 | warning: 'bg-players-8', 19 | info: 'bg-players-4', 20 | }; 21 | 22 | const BORDER_COLORS = { 23 | success: 'border-b-players-3 border-l-players-3', 24 | error: ' border-b-players-2 border-l-players-2', 25 | warning: 'border-b-players-8 border-l-players-8', 26 | info: 'border-b-players-4 border-l-players-4', 27 | }; 28 | 29 | export const Snackbar: React.FC = ({ show, message, type, Icon }) => { 30 | return ( 31 | 41 |
42 |
43 | {Icon && } 44 | {message} 45 |
46 |
52 |
53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /frontend/components/Snackbar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Snackbar'; 2 | -------------------------------------------------------------------------------- /frontend/components/ToggleSwitch/ToggleSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from '@headlessui/react'; 2 | import classNames from 'classnames'; 3 | import React from 'react'; 4 | 5 | interface Props { 6 | enabled: boolean; 7 | handleClick: () => void; 8 | screenReader?: string; 9 | } 10 | 11 | export const ToggleSwitch: React.FC = ({ enabled, handleClick: handleClose, screenReader }) => ( 12 |
13 | 22 | {screenReader && {screenReader}} 23 | 29 |
30 | ); 31 | -------------------------------------------------------------------------------- /frontend/components/ToggleSwitch/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ToggleSwitch'; 2 | -------------------------------------------------------------------------------- /frontend/components/ToggleSwitchLabel/ToggleSwitchLabel.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { ToggleSwitch } from '../ToggleSwitch'; 3 | 4 | type Props = { 5 | handleClick: () => any; 6 | label: string; 7 | isOn: boolean; 8 | className?: string; 9 | }; 10 | 11 | export const ToggleSwitchLabel: React.FC = ({ handleClick, label, isOn, className }) => { 12 | return ( 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/components/ToggleSwitchLabel/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ToggleSwitchLabel'; 2 | -------------------------------------------------------------------------------- /frontend/components/pg-game/GameBoard/GameBoard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDimensions, useBoard, useGameState } from '../../../hooks/useGameContract'; 3 | import { Board } from '../../Board'; 4 | import { useAudioSettings } from '../../../hooks/useAudioSettings'; 5 | import { Settings } from './Settings'; 6 | import { PlayerTurnSoundEffect } from '../../Board/PlayerTurnSoundEffect'; 7 | import { useGame } from '../../../contexts/GameContext'; 8 | import { useTranslation } from 'next-i18next'; 9 | 10 | export const GameBoard: React.FC = () => { 11 | const dim = useDimensions(); 12 | const board = useBoard(); 13 | const { status } = useGameState() || {}; 14 | const { trackPlayer, playTrack } = useAudioSettings(); 15 | const { successEffect } = useAudioSettings(); 16 | const { playerTurnEvents } = useGame(); 17 | const { t } = useTranslation('common'); 18 | const [previousPaintedCount, setPrevPaintedCount] = useState(0); 19 | 20 | useEffect(() => { 21 | const paintedCount = board.filter((b) => b.owner !== undefined).length; 22 | if (paintedCount > previousPaintedCount) { 23 | if (!successEffect?.playing()) successEffect?.play(); 24 | setPrevPaintedCount(paintedCount); 25 | } 26 | }, [board]); 27 | 28 | useEffect(() => { 29 | if (playTrack && trackPlayer) { 30 | trackPlayer.play(); 31 | } 32 | 33 | return () => { 34 | trackPlayer && trackPlayer.stop(); 35 | }; 36 | }, [trackPlayer, playTrack]); 37 | 38 | if (!dim) { 39 | return ( 40 |
41 |

{t('contractNotFound')}

42 |

{t('contractCheckDesc')}

43 |
44 | ); 45 | } 46 | 47 | return ( 48 | <> 49 |
50 |

{t('largerScreenDesc')}

51 |
52 | 53 |
54 | {playerTurnEvents.map((turn) => ( 55 | 56 | ))} 57 | 58 | 59 | 60 |
61 | 62 |
63 |
64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /frontend/components/pg-game/GameBoard/Settings/LanguageSelect/LanguageSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Listbox, Transition } from '@headlessui/react'; 2 | import { RiArrowDownSFill, RiCheckFill } from 'react-icons/ri'; 3 | import { Fragment } from 'react'; 4 | import classNames from 'classnames'; 5 | import { useLanguageSettings } from '../../../../../hooks/useLanguageSettings'; 6 | import { ALL_LANGUAGES, Language } from '../../../../../contexts/LanguageContext'; 7 | import { useRouter } from 'next/router'; 8 | 9 | type Props = { 10 | className?: string; 11 | }; 12 | 13 | export const LanguageSelect: React.FC = ({ className }) => { 14 | const { languageTrack, setLanguageTrack } = useLanguageSettings(); 15 | const router = useRouter(); 16 | 17 | const onToggleLanguageClick = (lang: Language) => { 18 | const { pathname, asPath, query } = router; 19 | router.push({ pathname, query }, asPath, { locale: lang.locale }); 20 | setLanguageTrack(lang); 21 | }; 22 | 23 | return ( 24 |
25 | 26 |
27 | 28 | {languageTrack.name} 29 | 30 | 32 | 33 | 34 | {ALL_LANGUAGES.length === 0 ? ( 35 |

Loading...

36 | ) : ( 37 | 38 | {ALL_LANGUAGES.map((track) => ( 39 | 42 | `relative cursor-default select-none py-4 px-10 transition duration-75 text-brand-600 ${ 43 | active && 'bg-players-4/30' 44 | } hover:cursor-pointer` 45 | } 46 | value={track} 47 | > 48 | {({ selected }) => ( 49 | <> 50 | 51 | {track.name} 52 | 53 | {selected ? ( 54 | 55 | 57 | ) : null} 58 | 59 | )} 60 | 61 | ))} 62 | 63 | )} 64 |
65 |
66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /frontend/components/pg-game/GameBoard/Settings/LanguageSelect/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LanguageSelect'; 2 | -------------------------------------------------------------------------------- /frontend/components/pg-game/GameBoard/Settings/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { GiBigGear } from 'react-icons/gi'; 2 | import { useUI } from '../../../../contexts/UIContext'; 3 | import { useAudioSettings } from '../../../../hooks/useAudioSettings'; 4 | import { Modal } from '../../../Modal'; 5 | import { ToggleSwitchLabel } from '../../../ToggleSwitchLabel'; 6 | import { TrackSelect } from './TrackSelect'; 7 | import { useTranslation } from 'react-i18next'; 8 | import { LanguageSelect } from './LanguageSelect'; 9 | 10 | export const Settings: React.FC = () => { 11 | const { 12 | darkMode, 13 | setDarkMode, 14 | showSettings, 15 | setShowSettings, 16 | showGrid, 17 | setShowGrid, 18 | showNotifications, 19 | setShowNotifications, 20 | showCoordinates, 21 | setShowCoordinates, 22 | showLogs, 23 | setShowLogs, 24 | } = useUI(); 25 | const { setPlayTrack, playTrack } = useAudioSettings(); 26 | const { t } = useTranslation('common'); 27 | 28 | return ( 29 | <> 30 | setShowSettings(false)}> 31 |
32 |
33 |

{t('chooseLanguage')}

34 | 35 |
36 |
37 |

{t('gameAudio')}

38 | setPlayTrack(!playTrack)} 41 | isOn={playTrack} 42 | label={t('playGameTrack')} 43 | /> 44 |
45 | 46 |
47 |
48 | 49 |
50 |

{t('visualSettings')}

51 | setDarkMode(!darkMode)} 54 | isOn={darkMode} 55 | label={t('darkMode')} 56 | /> 57 | setShowNotifications(!showNotifications)} 60 | isOn={showNotifications} 61 | label={t('showNotifications')} 62 | /> 63 | setShowGrid(!showGrid)} 66 | isOn={showGrid} 67 | label={t('showPixelGrid')} 68 | /> 69 | setShowCoordinates(!showCoordinates)} 72 | isOn={showCoordinates} 73 | label={t('showPixelCoordinates')} 74 | /> 75 | setShowLogs(!showLogs)} 78 | isOn={showLogs} 79 | label={t('showGameLogs')} 80 | /> 81 |
82 |
83 |
84 | 85 |
86 | 89 |
90 | 91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /frontend/components/pg-game/GameBoard/Settings/TrackSelect/TrackSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Listbox, Transition } from '@headlessui/react'; 2 | import { RiArrowDownSFill, RiCheckFill } from 'react-icons/ri'; 3 | import { Fragment } from 'react'; 4 | import classNames from 'classnames'; 5 | import { useAudioSettings } from '../../../../../hooks/useAudioSettings'; 6 | import { ALL_TRACKS } from '../../../../../contexts/AudioSettingsContext'; 7 | 8 | type Props = { 9 | className?: string; 10 | }; 11 | 12 | export const TrackSelect: React.FC = ({ className }) => { 13 | const { gameTrack, setGameTrack } = useAudioSettings(); 14 | 15 | return ( 16 |
17 | 18 |
19 | 20 | {gameTrack.name} 21 | 22 | 24 | 25 | 26 | {ALL_TRACKS.length === 0 ? ( 27 |

Loading...

28 | ) : ( 29 | 30 | {ALL_TRACKS.map((track, trackIndex) => ( 31 | 34 | `relative cursor-default select-none py-4 px-10 transition duration-75 text-brand-600 ${ 35 | active && 'bg-players-4/30' 36 | } hover:cursor-pointer` 37 | } 38 | value={track} 39 | > 40 | {({ selected }) => ( 41 | <> 42 | 43 | {track.name} 44 | 45 | {selected ? ( 46 | 47 | 49 | ) : null} 50 | 51 | )} 52 | 53 | ))} 54 | 55 | )} 56 |
57 |
58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /frontend/components/pg-game/GameBoard/Settings/TrackSelect/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TrackSelect'; 2 | -------------------------------------------------------------------------------- /frontend/components/pg-game/GameBoard/Settings/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Settings'; 2 | -------------------------------------------------------------------------------- /frontend/components/pg-game/GameBoard/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GameBoard'; 2 | -------------------------------------------------------------------------------- /frontend/components/pg-game/GamePage.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import React, { useEffect } from 'react'; 3 | import { useGame } from '../../contexts/GameContext'; 4 | import { GameBoard } from './GameBoard'; 5 | 6 | export const GamePage: React.FC = () => { 7 | const { address } = useRouter().query; 8 | const { setGameAddress } = useGame(); 9 | 10 | useEffect(() => { 11 | setGameAddress((address as string) || undefined); 12 | }, [address, setGameAddress]); 13 | 14 | return ( 15 |
16 | 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/components/pg-game/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GamePage'; 2 | -------------------------------------------------------------------------------- /frontend/contexts/AudioSettingsContext.tsx: -------------------------------------------------------------------------------- 1 | import { Howl } from 'howler'; 2 | import React, { createContext, ReactNode, useEffect, useState } from 'react'; 3 | 4 | export type GameTrack = { 5 | name: string; 6 | url: string; 7 | }; 8 | 9 | const TRACKS: { [k: string]: GameTrack } = { 10 | SQUINK_JAZZ: { 11 | name: 'Squink Jazz', 12 | url: '/audio/squink-jazz.mp3', 13 | }, 14 | SQUINKS_TUNE: { 15 | name: `Squink's Tune`, 16 | url: '/audio/squinks-tune.mp3', 17 | }, 18 | THE_SUBMARINE: { 19 | name: `The Submarine`, 20 | url: '/audio/the-submarine.mp3', 21 | }, 22 | SECRET_AGENT: { 23 | name: 'Double O Squink', 24 | url: '/audio/secret-agent.mp3', 25 | }, 26 | MIGRATION_OF_THE_JELLYFISH: { 27 | name: `Migration of the Jellyfish`, 28 | url: '/audio/migration-of-the-jellyfish.mp3', 29 | }, 30 | SQUINKS_ADVENTURE: { 31 | name: `Squink's Adventure`, 32 | url: '/audio/squinks-adventure.mp3', 33 | }, 34 | MYSTERIES_OF_THE_DEEP: { 35 | name: `Mysteries of the Deep`, 36 | url: '/audio/mysteries-of-the-deep.mp3', 37 | }, 38 | ANGRY_CRAB: { 39 | name: `The Angry Crab`, 40 | url: '/audio/the-angry-crab.mp3', 41 | }, 42 | }; 43 | 44 | export const ALL_TRACKS = Object.values(TRACKS); 45 | 46 | export const EFFECTS = { 47 | SUCCESS: { 48 | url: '/audio/success.mp3', 49 | }, 50 | FAILURE: { 51 | url: '/audio/failure-fx.mp3', 52 | }, 53 | COIN: { 54 | url: '/audio/coin.mp3', 55 | }, 56 | SEND: { 57 | url: '/audio/whip.mp3', 58 | }, 59 | }; 60 | 61 | type AudioSettings = { 62 | gameTrack: GameTrack; 63 | setGameTrack: (track: GameTrack) => void; 64 | trackPlayer: Howl | undefined; 65 | successEffect: Howl | undefined; 66 | finalizedEffect: Howl | undefined; 67 | failureEffect: Howl | undefined; 68 | sendEffect: Howl | undefined; 69 | playTrack: boolean; 70 | setPlayTrack: (_: boolean) => void; 71 | }; 72 | 73 | const DEFAULT_AUDIO_SETTINGS: AudioSettings = { 74 | gameTrack: TRACKS.SECRET_AGENT, 75 | setGameTrack: (_: GameTrack) => null, 76 | trackPlayer: undefined, 77 | successEffect: undefined, 78 | finalizedEffect: undefined, 79 | failureEffect: undefined, 80 | sendEffect: undefined, 81 | playTrack: false, 82 | setPlayTrack: (_: boolean) => null, 83 | }; 84 | 85 | export const AudioSettingsContext = createContext(DEFAULT_AUDIO_SETTINGS); 86 | 87 | export const AudioSettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 88 | const [gameTrack, setGameTrack] = useState(ALL_TRACKS[0]); 89 | const [successEffect, setEffect] = useState(undefined); 90 | const [failureEffect, setFailureEffect] = useState(undefined); 91 | const [finalizedEffect, setFinalizedEffect] = useState(undefined); 92 | const [sendEffect, setSendEffect] = useState(undefined); 93 | const [playTrack, setPlayTrack] = useState(false); 94 | const [trackPlayer, setTrackPlayer] = useState(undefined); 95 | 96 | useEffect(() => { 97 | const gt: Howl = new Howl({ 98 | src: gameTrack.url, 99 | loop: true, 100 | volume: 0.2, 101 | html5: true, 102 | }).on('load', () => setTrackPlayer(gt)); 103 | }, [gameTrack]); 104 | 105 | useEffect(() => { 106 | const success: Howl = new Howl({ 107 | src: EFFECTS.SUCCESS.url, 108 | loop: false, 109 | volume: 0.5, 110 | html5: true, 111 | }).on('load', () => setEffect(success)); 112 | 113 | const finalizedSoundPlayer: Howl = new Howl({ 114 | src: EFFECTS.COIN.url, 115 | loop: false, 116 | volume: 0.3, 117 | html5: true, 118 | }).on('load', () => setFinalizedEffect(finalizedSoundPlayer)); 119 | 120 | const failureSoundPlayer: Howl = new Howl({ 121 | src: EFFECTS.FAILURE.url, 122 | loop: false, 123 | volume: 0.3, 124 | html5: true, 125 | }).on('load', () => setFailureEffect(failureSoundPlayer)); 126 | 127 | const sendSoundPlayer: Howl = new Howl({ 128 | src: EFFECTS.SEND.url, 129 | loop: false, 130 | volume: 0.4, 131 | html5: true, 132 | }).on('load', () => setSendEffect(sendSoundPlayer)); 133 | }, []); 134 | 135 | const value: AudioSettings = { 136 | gameTrack, 137 | playTrack, 138 | setGameTrack, 139 | successEffect, 140 | finalizedEffect, 141 | failureEffect, 142 | sendEffect, 143 | trackPlayer, 144 | setPlayTrack, 145 | }; 146 | 147 | return {children}; 148 | }; 149 | -------------------------------------------------------------------------------- /frontend/contexts/GameContext.tsx: -------------------------------------------------------------------------------- 1 | import { Abi, ContractPromise } from '@polkadot/api-contract'; 2 | import React, { createContext, ReactNode, useContext, useMemo, useState } from 'react'; 3 | import METADATA from '../constants/game/metadata.json'; 4 | import { EventName, Field, PlayerRegistered, TurnData, TurnEvent } from '../hooks/useGameEvents'; 5 | import { useContract } from '../lib/useInk/hooks'; 6 | import { useContractEvents } from '../lib/useInk/hooks/useContractEvents'; 7 | import { ContractEvent } from '../lib/useInk/providers/contractEvents/model'; 8 | import { stringNumberToBN } from '../lib/useInk/utils'; 9 | 10 | export const ABI = new Abi(METADATA); 11 | 12 | type Game = { 13 | gameAddress: string | undefined; 14 | setGameAddress: (add: string | undefined) => void; 15 | game: ContractPromise | undefined; 16 | events: ContractEvent[]; 17 | turnData: TurnData; 18 | playerTurnEvents: TurnEvent[]; 19 | playerRegisteredEvents: PlayerRegistered[]; 20 | roundsPlayed: number; 21 | }; 22 | 23 | const DEFAULT_GAME: Game = { 24 | setGameAddress: (_: string | undefined) => null, 25 | gameAddress: undefined, 26 | game: undefined, 27 | events: [], 28 | turnData: {}, 29 | playerTurnEvents: [], 30 | playerRegisteredEvents: [], 31 | roundsPlayed: 0, 32 | }; 33 | 34 | const useGameValues = (): Game => { 35 | const [gameAddress, setGameAddress] = useState(); 36 | const game = useContract(gameAddress || '', METADATA); 37 | const events = useContractEvents(gameAddress || '', ABI, true); 38 | 39 | const [turnData, playerTurnEvents, playerRegisteredEvents, roundsPlayed] = useMemo(() => { 40 | let results: TurnData = {}; 41 | const playerTurns: TurnEvent[] = []; 42 | const registeredEvents: PlayerRegistered[] = []; 43 | let roundsPlayed = 0; 44 | 45 | try { 46 | for (let i = 0; i < events.length; i++) { 47 | const event = events[i]; 48 | 49 | if (EventName.PlayerRegistered === event.name) { 50 | registeredEvents.push({ name: EventName.PlayerRegistered, player: event.args[0] as any as string }); 51 | } 52 | 53 | if (EventName.RoundIncremented === event.name && typeof event.args?.[0] === 'string') { 54 | roundsPlayed = stringNumberToBN(event.args[0] || '0').toNumber(); 55 | } 56 | 57 | if (EventName.TurnTaken !== event.name) continue; 58 | 59 | const eventPlayer = event.args[0] as any as string; 60 | 61 | const base = { player: eventPlayer, id: event.id }; 62 | const turn = (Object.values(event.args[1] as {})[0] as { turn?: Field })?.turn || { x: '', y: '' }; 63 | const coordinates = `(${turn.x},${turn.y})`; 64 | 65 | if (!results[coordinates]) results[coordinates] = []; 66 | 67 | const outcomeType = Object.keys(event.args[1] as {})[0]; 68 | switch (outcomeType) { 69 | case 'Success': 70 | let successEvent: TurnEvent = { ...base, name: 'Success', turn }; 71 | playerTurns.push(successEvent); 72 | results[coordinates].push(successEvent); 73 | break; 74 | 75 | case 'Occupied': 76 | const occupiedEvent: TurnEvent = { ...base, name: 'Occupied', turn }; 77 | playerTurns.push(occupiedEvent); 78 | results[coordinates].push(occupiedEvent); 79 | break; 80 | 81 | case 'OutOfBounds': 82 | const outOfBoundesEvent: TurnEvent = { ...base, name: 'OutOfBounds', turn }; 83 | playerTurns.push(outOfBoundesEvent); 84 | results[coordinates].push(outOfBoundesEvent); 85 | break; 86 | 87 | case 'BrokenPlayer': 88 | const brokenPlayerEvent: TurnEvent = { ...base, name: 'BrokenPlayer' }; 89 | playerTurns.push(brokenPlayerEvent); 90 | results[coordinates].push(); 91 | break; 92 | } 93 | } 94 | } catch (e) { 95 | console.error('Error converting useTurnTakenEvents'); 96 | } 97 | 98 | return [results, playerTurns, registeredEvents, roundsPlayed]; 99 | }, [events]); 100 | 101 | return { 102 | gameAddress, 103 | setGameAddress, 104 | game, 105 | events, 106 | turnData, 107 | playerTurnEvents, 108 | playerRegisteredEvents, 109 | roundsPlayed, 110 | }; 111 | }; 112 | 113 | export const GameContext = createContext(DEFAULT_GAME); 114 | 115 | export const useGame = () => useContext(GameContext); 116 | 117 | export const GameProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 118 | const value = useGameValues(); 119 | return {children}; 120 | }; 121 | -------------------------------------------------------------------------------- /frontend/contexts/LanguageContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, ReactNode, useState } from 'react'; 2 | 3 | export type Language = { 4 | name: string; 5 | locale: string; 6 | }; 7 | 8 | const LANGUAGES: { [k: string]: Language } = { 9 | ENGLISH_LANGUAGE: { 10 | name: 'English', 11 | locale: 'en', 12 | }, 13 | FRENCH_LANGUAGE: { 14 | name: 'French', 15 | locale: 'fr', 16 | }, 17 | SPANISH_LANGUAGE: { 18 | name: 'Spanish', 19 | locale: 'es', 20 | }, 21 | }; 22 | 23 | export const ALL_LANGUAGES = Object.values(LANGUAGES); 24 | 25 | type LanguageSettings = { 26 | languageTrack: Language; 27 | setLanguageTrack: (lang: Language) => void; 28 | }; 29 | 30 | const DEFAULT_LANGUAGE_SETTINGS = { 31 | languageTrack: LANGUAGES.ENGLISH_LANGUAGE, 32 | setLanguageTrack: (_: Language) => null, 33 | }; 34 | export const LanguageContext = createContext(DEFAULT_LANGUAGE_SETTINGS); 35 | 36 | export const LanguageSettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 37 | const [languageTrack, setLanguageTrack] = useState(ALL_LANGUAGES[0]); 38 | 39 | const value: LanguageSettings = { 40 | languageTrack, 41 | setLanguageTrack, 42 | }; 43 | return {children}; 44 | }; 45 | -------------------------------------------------------------------------------- /frontend/contexts/UIContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, ReactNode, useContext, useState } from 'react'; 2 | 3 | type UI = { 4 | showAccounts: boolean; 5 | showWalletConnect: boolean; 6 | showRules: boolean; 7 | setShowRules: (_: boolean) => void; 8 | showGrid: boolean; 9 | setShowGrid: (_: boolean) => void; 10 | showNotifications: boolean; 11 | setShowNotifications: (_: boolean) => void; 12 | showLogs: boolean; 13 | setShowLogs: (_: boolean) => void; 14 | darkMode: boolean; 15 | setDarkMode: (_: boolean) => void; 16 | showCoordinates: boolean; 17 | setShowCoordinates: (_: boolean) => void; 18 | setShowWalletConnect: (_: boolean) => void; 19 | setPlayer: (_: string | null) => void; 20 | player: string | null; 21 | showSettings: boolean; 22 | setShowSettings: (_: boolean) => void; 23 | }; 24 | 25 | const DEFAULT_UI: UI = { 26 | showAccounts: false, 27 | showWalletConnect: false, 28 | showRules: false, 29 | showGrid: true, 30 | setShowGrid: (_: boolean) => null, 31 | showNotifications: true, 32 | setShowNotifications: (_: boolean) => null, 33 | showLogs: false, 34 | setShowLogs: (_: boolean) => null, 35 | showCoordinates: true, 36 | setDarkMode: (_: boolean) => null, 37 | darkMode: false, 38 | setShowCoordinates: (_: boolean) => null, 39 | setShowRules: (_: boolean) => null, 40 | setShowWalletConnect: (_: boolean) => null, 41 | setPlayer: (_: string | null) => null, 42 | player: null, 43 | setShowSettings: (_: boolean) => null, 44 | showSettings: false, 45 | }; 46 | 47 | const useUIValues = (): UI => { 48 | const [showWalletConnect, setShowWalletConnect] = useState(false); 49 | const [showRules, setShowRules] = useState(false); 50 | const [showGrid, setShowGrid] = useState(false); 51 | const [showNotifications, setShowNotifications] = useState(false); 52 | const [showLogs, setShowLogs] = useState(false); 53 | const [darkMode, setDarkMode] = useState(false); 54 | const [showCoordinates, setShowCoordinates] = useState(false); 55 | const [showSettings, setShowSettings] = useState(false); 56 | const [player, setPlayer] = useState(null); 57 | return { 58 | ...DEFAULT_UI, 59 | showWalletConnect, 60 | setShowWalletConnect, 61 | setShowRules, 62 | showRules, 63 | darkMode, 64 | setDarkMode, 65 | player, 66 | setPlayer, 67 | showSettings, 68 | setShowSettings, 69 | showGrid, 70 | setShowGrid, 71 | showNotifications, 72 | setShowNotifications, 73 | showLogs, 74 | setShowLogs, 75 | showCoordinates, 76 | setShowCoordinates, 77 | }; 78 | }; 79 | 80 | export const UIContext = createContext(DEFAULT_UI); 81 | 82 | export const useUI = () => useContext(UIContext); 83 | 84 | const UIProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 85 | const value = useUIValues(); 86 | return {children}; 87 | }; 88 | 89 | export default UIProvider; 90 | -------------------------------------------------------------------------------- /frontend/hooks/useAudioSettings.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { AudioSettingsContext } from '../contexts/AudioSettingsContext'; 3 | 4 | export const useAudioSettings = () => useContext(AudioSettingsContext); 5 | -------------------------------------------------------------------------------- /frontend/hooks/useGameContract/data.ts: -------------------------------------------------------------------------------- 1 | export const PLAYER_COLORS = [ 2 | '#6effdb', 3 | '#ff705e', 4 | '#5ea4ff', 5 | '#f3ff73', 6 | '#f9e3ff', 7 | '#f694ff', 8 | '#ffc32b', 9 | '#50d985', 10 | '#3853ff', 11 | '#bba4c2', 12 | '#3957ff', 13 | '#d3fe14', 14 | '#c9080a', 15 | '#fec7f8', 16 | '#0b7b3e', 17 | '#0bf0e9', 18 | '#c203c8', 19 | '#fd9b39', 20 | '#888593', 21 | '#906407', 22 | '#98ba7f', 23 | '#fe6794', 24 | '#10b0ff', 25 | '#ac7bff', 26 | '#fee7c0', 27 | '#964c63', 28 | '#1da49c', 29 | '#0ad811', 30 | '#bbd9fd', 31 | '#fe6cfe', 32 | '#297192', 33 | '#d1a09c', 34 | '#78579e', 35 | '#81ffad', 36 | '#739400', 37 | '#ca6949', 38 | '#d9bf01', 39 | '#646a58', 40 | '#d5097e', 41 | '#bb73a9', 42 | '#ccf6e9', 43 | '#9cb4b6', 44 | '#b6a7d4', 45 | '#9e8c62', 46 | '#6e83c8', 47 | '#01af64', 48 | '#a71afd', 49 | '#cfe589', 50 | '#d4ccd1', 51 | '#fd4109', 52 | '#bf8f0e', 53 | '#2f786e', 54 | '#4ed1a5', 55 | '#d8bb7d', 56 | '#a54509', 57 | '#6a9276', 58 | '#a4777a', 59 | '#fc12c9', 60 | '#606f15', 61 | '#3cc4d9', 62 | '#f31c4e', 63 | '#73616f', 64 | '#f097c6', 65 | '#fc8772', 66 | '#92a6fe', 67 | '#875b44', 68 | '#699ab3', 69 | '#94bc19', 70 | '#7d5bf0', 71 | '#d24dfe', 72 | '#c85b74', 73 | '#68ff57', 74 | '#b62347', 75 | '#994b91', 76 | '#646b8c', 77 | '#977ab4', 78 | '#d694fd', 79 | '#c4d5b5', 80 | '#fdc4bd', 81 | '#1cae05', 82 | '#7bd972', 83 | '#e9700a', 84 | '#d08f5d', 85 | '#8bb9e1', 86 | '#fde945', 87 | '#a29d98', 88 | '#1682fb', 89 | '#9ad9e0', 90 | '#d6cafe', 91 | '#8d8328', 92 | '#b091a7', 93 | '#647579', 94 | '#1f8d11', 95 | '#e7eafd', 96 | '#b9660b', 97 | '#a4a644', 98 | '#fec24c', 99 | '#b1168c', 100 | '#188cc1', 101 | '#7ab297', 102 | '#4468ae', 103 | '#c949a6', 104 | '#d48295', 105 | '#eb6dc2', 106 | '#d5b0cb', 107 | '#ff9ffb', 108 | '#fdb082', 109 | '#af4d44', 110 | '#a759c4', 111 | '#a9e03a', 112 | '#0d906b', 113 | '#9ee3bd', 114 | '#5b8846', 115 | '#0d8995', 116 | '#f25c58', 117 | '#70ae4f', 118 | '#847f74', 119 | '#9094bb', 120 | '#ffe2f1', 121 | '#a67149', 122 | '#936c8e', 123 | '#d04907', 124 | '#c3b8a6', 125 | '#cef8c4', 126 | '#7a9293', 127 | '#fda2ab', 128 | '#2ef6c5', 129 | '#807242', 130 | '#cb94cc', 131 | '#b6bdd0', 132 | '#b5c75d', 133 | '#fde189', 134 | '#b7ff80', 135 | '#fa2d8e', 136 | '#839a5f', 137 | '#28c2b5', 138 | '#e5e9e1', 139 | '#bc79d8', 140 | '#7ed8fe', 141 | '#9f20c3', 142 | '#4f7a5b', 143 | '#f511fd', 144 | '#09c959', 145 | '#bcd0ce', 146 | '#8685fd', 147 | '#98fcff', 148 | '#afbff9', 149 | '#6d69b4', 150 | '#5f99fd', 151 | '#aaa87e', 152 | '#b59dfb', 153 | '#5d809d', 154 | '#d9a742', 155 | '#ac5c86', 156 | '#9468d5', 157 | '#a4a2b2', 158 | '#b1376e', 159 | '#d43f3d', 160 | '#05a9d1', 161 | '#c38375', 162 | '#24b58e', 163 | '#6eabaf', 164 | '#66bf7f', 165 | '#92cbbb', 166 | ]; 167 | -------------------------------------------------------------------------------- /frontend/hooks/useGameContract/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useGameContract'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /frontend/hooks/useGameContract/types.ts: -------------------------------------------------------------------------------- 1 | export type AccountId = string; 2 | 3 | export type Field = { x: string; y: string }; 4 | 5 | export type Dimensions = { 6 | x: number; 7 | y: number; 8 | }; 9 | 10 | export type Forming = { 11 | status: 'Forming'; 12 | earliestStart: number; 13 | startingIn: number; 14 | }; 15 | 16 | export type Running = { 17 | status: 'Running'; 18 | totalRounds: number; 19 | currentRound: number; 20 | }; 21 | 22 | export type Finished = { 23 | status: 'Finished'; 24 | winner: AccountId; 25 | }; 26 | 27 | export type GameStatus = 'Forming' | 'Running' | 'Finished'; 28 | 29 | export type GameState = Forming | Running | Finished; 30 | 31 | export type Player = { 32 | id: AccountId; 33 | name: string; 34 | gasUsed: string; 35 | score: number; 36 | }; 37 | 38 | export type PlayerList = { [accountId: string]: string }; 39 | 40 | export type Color = string; 41 | export type PlayerColors = { 42 | [id: AccountId]: Color; 43 | }; 44 | 45 | export type Score = string; 46 | 47 | export type PlayerScore = Player & { 48 | color: Color; 49 | gasLeft: string; 50 | }; 51 | 52 | export type XYCoordinate = [number, number]; 53 | 54 | export type BoardPositionRaw = null | [XYCoordinate, AccountId | null | undefined]; 55 | export type BoardPosition = { 56 | x: number; 57 | y: number; 58 | owner?: AccountId | null; 59 | color?: Color; 60 | }; 61 | -------------------------------------------------------------------------------- /frontend/hooks/useGameEvents/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useGameEvents'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /frontend/hooks/useGameEvents/types.ts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '../../lib/useInk/types'; 2 | 3 | export type Field = { 4 | x: string; 5 | y: string; 6 | }; 7 | 8 | export type PlayerRegistered = { 9 | name: EventName.PlayerRegistered; 10 | player: AccountId; 11 | }; 12 | 13 | export type RoundIncremented = { 14 | name: EventName.RoundIncremented; 15 | roundsPlayed: string; 16 | }; 17 | 18 | export type SuccessfulTurn = { 19 | name: 'Success'; 20 | player: string; 21 | turn: Field; 22 | }; 23 | 24 | export type OutOfBoundsTurn = { 25 | name: 'OutOfBounds'; 26 | player: string; 27 | turn: Field; 28 | }; 29 | 30 | export type Occupied = { 31 | name: 'Occupied'; 32 | player: string; 33 | turn: Field; 34 | }; 35 | 36 | export type BrokenPlayer = { 37 | name: 'BrokenPlayer'; 38 | player: string; 39 | }; 40 | 41 | export type Turn = SuccessfulTurn | OutOfBoundsTurn | Occupied | BrokenPlayer; 42 | 43 | export type TurnEvent = Turn & { 44 | id: string; 45 | }; 46 | 47 | export type TurnData = { 48 | [coordinates: string]: TurnEvent[]; 49 | }; 50 | 51 | export enum TurnOutcome { 52 | Success = 'Success', 53 | OutOfBounds = 'OutOfBounds', 54 | Occupied = 'Occupied', 55 | BrokenPlayer = 'BrokenPlayer', 56 | } 57 | 58 | export enum EventName { 59 | PlayerRegistered = 'PlayerRegistered', 60 | GameStarted = 'GameStarted', 61 | TurnTaken = 'TurnTaken', 62 | GameEnded = 'GameEnded', 63 | GameDestroyed = 'GameDestroyed', 64 | RoundIncremented = 'RoundIncremented', 65 | } 66 | -------------------------------------------------------------------------------- /frontend/hooks/useGameEvents/useGameEvents.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useGame } from '../../contexts/GameContext'; 3 | import { ContractEvent } from '../../lib/useInk/providers/contractEvents/model'; 4 | import { EventName } from './types'; 5 | 6 | export const useGameEvents = (eventName?: EventName): ContractEvent[] => { 7 | const { events } = useGame(); 8 | if (!eventName) return events; 9 | 10 | return useMemo(() => events.filter((e) => e.name === eventName), [events, eventName]); 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/hooks/useLanguageSettings.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { LanguageContext } from '../contexts/LanguageContext'; 3 | 4 | export const useLanguageSettings = () => useContext(LanguageContext); 5 | -------------------------------------------------------------------------------- /frontend/lib/useInk/InkProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Config } from './providers/config/model'; 3 | import { ConfigProvider } from './providers/config/provider'; 4 | import { NotificationsProvider } from './providers/notifications/provider'; 5 | import { ExtensionProvider } from './providers/extension/provider'; 6 | import { APIProvider } from './providers/api/provider'; 7 | import { BlockHeaderProvider } from './providers/blockHeader/provider'; 8 | import { ContractEventsProvider } from './providers/contractEvents/provider'; 9 | import { LogsProvider } from './providers/logs/provider'; 10 | 11 | type InkConfig = { 12 | config?: Config; 13 | children?: React.ReactNode; 14 | }; 15 | 16 | const InkProvider: React.FC = ({ children, config }) => { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | {children} 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default InkProvider; 35 | -------------------------------------------------------------------------------- /frontend/lib/useInk/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_RPC_URL = 'wss://rococo-contracts-rpc.polkadot.io'; 2 | 3 | export const FIVE_SECONDS = 5000; 4 | export const HALF_A_SECOND = 500; 5 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useApi'; 2 | export * from './useBlockHeader'; 3 | export * from './useConfig'; 4 | export * from './useContract'; 5 | export * from './useContractEvents'; 6 | export * from './useContractTx'; 7 | export * from './useExtension'; 8 | export * from './useInterval'; 9 | export * from './useIsMounted'; 10 | export * from './useNotifications'; 11 | export * from './useProvider'; 12 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useApi.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { APIContext } from '../providers/api/context'; 3 | 4 | export type { API } from '../providers/api/model'; 5 | 6 | export const useApi = () => useContext(APIContext); 7 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useBlockHeader.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { BlockHeaderContext } from '../providers/blockHeader/context'; 3 | 4 | export type { BlockHeader } from '../providers/blockHeader/model'; 5 | 6 | export const useBlockHeader = () => useContext(BlockHeaderContext); 7 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useConfig.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { ConfigContext } from '../providers/config/context'; 3 | 4 | export type { Config } from '../providers/config/model'; 5 | 6 | export const useConfig = () => useContext(ConfigContext); 7 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useContract.ts: -------------------------------------------------------------------------------- 1 | import { Abi, ContractPromise } from '@polkadot/api-contract'; 2 | import { useEffect, useState } from 'react'; 3 | import { useApi } from './useApi'; 4 | 5 | export type ContractAbi = string | Record | Abi; 6 | 7 | export function useContract( 8 | address: string, 9 | ABI: ContractAbi, 10 | ): T | undefined { 11 | const [contract, setContract] = useState(); 12 | const { api } = useApi(); 13 | 14 | useEffect(() => { 15 | try { 16 | api && setContract(new ContractPromise(api, ABI, address) as T); 17 | } catch (err) { 18 | console.error("Couldn't create contract instance: ", err); 19 | } 20 | }, [ABI, address, api]); 21 | 22 | return contract; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useContractCall.ts: -------------------------------------------------------------------------------- 1 | import { ContractPromise } from '@polkadot/api-contract'; 2 | import { useEffect, useMemo, useState } from 'react'; 3 | import { ContractCallResult, ContractExecResult, ContractOptions, Result } from '../types'; 4 | import { callContract, toContractAbiMessage } from '../utils'; 5 | import { useBlockHeader } from './useBlockHeader'; 6 | import { useExtension } from './useExtension'; 7 | 8 | export function useContractCall( 9 | contract: ContractPromise | undefined, 10 | message: string, 11 | args = [] as unknown[], 12 | options?: ContractOptions, 13 | ): Result | null { 14 | const [callResult, setCallResult] = useState(null); 15 | const { blockNumber } = useBlockHeader(); 16 | const { activeAccount } = useExtension(); 17 | 18 | const abiMsgResult = useMemo(() => { 19 | if (!contract) return null; 20 | return toContractAbiMessage(contract, message); 21 | }, [contract, message]); 22 | 23 | useEffect(() => { 24 | abiMsgResult && 25 | abiMsgResult.ok && 26 | contract && 27 | callContract(contract, abiMsgResult.value, activeAccount?.address, args, options).then((r) => { 28 | setCallResult(r); 29 | }); 30 | }, [contract?.address, blockNumber, activeAccount?.address, abiMsgResult]); 31 | 32 | if (!abiMsgResult || !callResult) return null; 33 | if (!abiMsgResult.ok) return abiMsgResult; 34 | 35 | return { 36 | ok: true, 37 | value: { 38 | callResult, 39 | abiMessage: abiMsgResult.value, 40 | }, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useContractCallDecoded.ts: -------------------------------------------------------------------------------- 1 | import { ContractPromise } from '@polkadot/api-contract'; 2 | import { useMemo } from 'react'; 3 | import { ContractExecResultDecoded, ContractOptions, Result } from '../types'; 4 | import { decodeContractExecResult } from '../utils'; 5 | import { useContractCall } from './useContractCall'; 6 | 7 | export function useContractCallDecoded( 8 | contract: ContractPromise | undefined, 9 | message: string, 10 | args = [] as unknown[], 11 | options?: ContractOptions, 12 | ): Result, string> | null { 13 | const rawResult = useContractCall(contract, message, args, options); 14 | 15 | return useMemo(() => { 16 | if (!rawResult || !contract) return null; 17 | if (!rawResult.ok) return rawResult; 18 | 19 | const { callResult, abiMessage } = rawResult.value; 20 | 21 | const result = decodeContractExecResult(callResult.result, abiMessage, contract.abi.registry); 22 | if (!result.ok) return result; 23 | 24 | return { 25 | ok: true, 26 | value: { 27 | ...callResult, 28 | debugMessage: rawResult.value.callResult.debugMessage.toHuman(), 29 | result: result.value, 30 | }, 31 | }; 32 | }, [rawResult]); 33 | } 34 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useContractEvents.ts: -------------------------------------------------------------------------------- 1 | import { Abi } from '@polkadot/api-contract'; 2 | import { Bytes } from '@polkadot/types'; 3 | import { useContext, useEffect } from 'react'; 4 | import { HALF_A_SECOND } from '../constants'; 5 | import { ContractEventsContext } from '../providers/contractEvents/context'; 6 | import { ContractEvent } from '../providers/contractEvents/model'; 7 | import { LogsContext } from '../providers/logs/context'; 8 | import { getExpiredItem } from '../utils/getExpiredItem'; 9 | import { useApi } from './useApi'; 10 | import { useBlockHeader } from './useBlockHeader'; 11 | import { useConfig } from './useConfig'; 12 | import { useInterval } from './useInterval'; 13 | 14 | export const useContractEvents = (address: string, abi: Abi, withLogs?: boolean): ContractEvent[] => { 15 | const { events, addContractEvent, removeContractEvent } = useContext(ContractEventsContext); 16 | const { addLog } = useContext(LogsContext); 17 | const { api } = useApi(); 18 | const { blockNumber, header } = useBlockHeader(); 19 | const config = useConfig(); 20 | 21 | const eventsForAddress = events[address] || []; 22 | 23 | useEffect(() => { 24 | address && 25 | abi && 26 | api && 27 | header?.hash && 28 | api.at(header?.hash).then((apiAt) => { 29 | apiAt.query.system.events((encodedEvent: any[]) => { 30 | encodedEvent.forEach(({ event }) => { 31 | if (api.events.contracts.ContractEmitted.is(event)) { 32 | const [contractAddress, contractEvent] = event.data; 33 | if (address && contractAddress.toString().toLowerCase() === address.toLowerCase()) { 34 | try { 35 | const decodedEvent = abi.decodeEvent(contractEvent as Bytes); 36 | 37 | const eventItem = { 38 | address, 39 | event: { 40 | name: decodedEvent.event.identifier, 41 | args: decodedEvent.args.map((v) => v.toHuman()), 42 | }, 43 | }; 44 | 45 | addContractEvent(eventItem); 46 | if (withLogs) addLog(JSON.stringify(eventItem)); 47 | } catch (e) { 48 | console.error(e); 49 | } 50 | } 51 | } 52 | }); 53 | }); 54 | }); 55 | }, [api, blockNumber]); 56 | 57 | useInterval(() => { 58 | const expiredEvents = getExpiredItem(eventsForAddress, config.notifications?.expiration); 59 | for (const contractEvent of expiredEvents) { 60 | removeContractEvent({ eventId: contractEvent.id, address }); 61 | } 62 | }, config.notifications?.checkInterval || HALF_A_SECOND); 63 | 64 | return eventsForAddress; 65 | }; 66 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useContractTx.ts: -------------------------------------------------------------------------------- 1 | import { ContractPromise } from '@polkadot/api-contract'; 2 | import { useMemo, useState } from 'react'; 3 | import { ContractExecResultResult, ContractOptions, ContractTxFunc, ISubmittableResult, Status } from '../types'; 4 | import { callContract, toContractAbiMessage, toRegistryErrorDecoded } from '../utils'; 5 | import { useConfig } from './useConfig'; 6 | import { useExtension } from './useExtension'; 7 | import { useNotifications } from './useNotifications'; 8 | 9 | type ContractTxOptions = { 10 | notificationsOff?: boolean; 11 | notifications?: { 12 | broadcastMessage?: (result: ContractExecResultResult) => string; 13 | finalizedMessage?: (result: ContractExecResultResult) => string; 14 | inBlockMessage?: (result: ContractExecResultResult) => string; 15 | unknownErrorMessage?: (e?: unknown) => string; 16 | }; 17 | }; 18 | 19 | export function useContractTx( 20 | contract: ContractPromise | undefined, 21 | message: string, 22 | options?: ContractTxOptions, 23 | ): ContractTxFunc { 24 | const { activeAccount, activeSigner } = useExtension(); 25 | const { addNotification } = useNotifications(); 26 | const { notifications } = useConfig(); 27 | const withNotifications = !options?.notificationsOff && !notifications?.off; 28 | const [status, setStatus] = useState('None'); 29 | const [result, setResult] = useState(); 30 | const [error, setError] = useState(null); 31 | const { broadcastMessage, inBlockMessage, finalizedMessage, unknownErrorMessage } = options?.notifications || {}; 32 | 33 | const send: (args: unknown[], o?: ContractOptions) => void = useMemo( 34 | () => (args, options) => { 35 | if (!activeAccount || !contract || !activeSigner) return; 36 | 37 | error && setError(null); 38 | setStatus('PendingSignature'); 39 | 40 | const abiMessage = toContractAbiMessage(contract, message); 41 | 42 | if (!abiMessage.ok) { 43 | setError(abiMessage.error); 44 | setStatus('Errored'); 45 | return; 46 | } 47 | 48 | callContract(contract, abiMessage.value, activeAccount.address, args, options) 49 | .then((response) => { 50 | const { gasRequired, result } = response; 51 | 52 | try { 53 | const dispatchError = toRegistryErrorDecoded(contract.abi.registry, result); 54 | if (result.isErr && dispatchError) { 55 | setError(dispatchError.docs.join(', ')); 56 | console.error('dispatch error', dispatchError); 57 | 58 | withNotifications && 59 | addNotification({ 60 | notification: { 61 | type: 'Errored', 62 | message: dispatchError.docs.join(', '), 63 | }, 64 | }); 65 | 66 | return; 67 | } 68 | } catch (e) { 69 | console.error('errored', e); 70 | } 71 | 72 | contract.tx[message]({ gasLimit: gasRequired, ...(options || {}) }, ...args) 73 | .signAndSend(activeAccount.address, { signer: activeSigner.signer }, (response) => { 74 | setResult(response); 75 | if (response.status.isBroadcast) { 76 | setStatus('Broadcast'); 77 | 78 | withNotifications && 79 | addNotification({ 80 | notification: { 81 | type: 'Broadcast', 82 | message: broadcastMessage ? broadcastMessage(result) : 'Broadcast', 83 | }, 84 | }); 85 | } 86 | 87 | if (response.status.isInBlock) { 88 | setStatus('InBlock'); 89 | 90 | withNotifications && 91 | addNotification({ 92 | notification: { 93 | type: 'InBlock', 94 | message: inBlockMessage ? inBlockMessage(result) : 'In Block', 95 | }, 96 | }); 97 | } 98 | 99 | if (response.status.isFinalized) { 100 | setStatus('Finalized'); 101 | 102 | withNotifications && 103 | addNotification({ 104 | notification: { 105 | type: 'Finalized', 106 | message: finalizedMessage ? finalizedMessage(result) : 'Finalized', 107 | }, 108 | }); 109 | } 110 | }) 111 | .catch((e) => { 112 | setStatus('None'); 113 | const err = JSON.stringify(e); 114 | const message = 115 | err === '{}' ? (unknownErrorMessage ? unknownErrorMessage(e) : 'Something went wrong') : err; 116 | setError(message); 117 | console.error('tx error', message); 118 | 119 | withNotifications && 120 | addNotification({ 121 | notification: { 122 | type: 'Errored', 123 | message, 124 | }, 125 | }); 126 | }); 127 | }) 128 | .catch((e) => { 129 | setStatus('None'); 130 | const err = JSON.stringify(e); 131 | 132 | const message = err === '{}' ? (unknownErrorMessage ? unknownErrorMessage(e) : 'Something went wrong') : err; 133 | setError(message); 134 | 135 | console.log('raw-error', e); 136 | console.error('pre-flight error:', message); 137 | 138 | withNotifications && 139 | addNotification({ 140 | notification: { 141 | type: 'Errored', 142 | message, 143 | }, 144 | }); 145 | }); 146 | }, 147 | [activeAccount, activeSigner, contract], 148 | ); 149 | 150 | return { 151 | send, 152 | status, 153 | error, 154 | result, 155 | resetState: () => { 156 | setStatus('None'); 157 | setError(null); 158 | }, 159 | }; 160 | } 161 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useExtension.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { ExtensionContext } from '../providers/extension/context'; 3 | 4 | export type { Extension } from '../providers/extension/model'; 5 | 6 | export const useExtension = () => useContext(ExtensionContext); 7 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function useInterval(callback: () => void, delay: number | null) { 4 | const savedCallback = useRef(callback); 5 | 6 | useEffect(() => { 7 | savedCallback.current = callback; 8 | }, [callback]); 9 | 10 | useEffect(() => { 11 | if (delay === null) { 12 | return; 13 | } 14 | 15 | const id = setInterval(() => savedCallback.current(), delay); 16 | 17 | return () => clearInterval(id); 18 | }, [delay]); 19 | } 20 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useIsMounted.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useCallback } from 'react'; 2 | 3 | export function useIsMounted() { 4 | const isMounted = useRef(false); 5 | 6 | useEffect(() => { 7 | isMounted.current = true; 8 | 9 | return () => { 10 | isMounted.current = false; 11 | }; 12 | }, []); 13 | 14 | return useCallback(() => isMounted.current, []); 15 | } 16 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useLogs.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { LogsContext } from '../providers/logs/context'; 3 | 4 | export const useLogs = (): string[] => useContext(LogsContext).logs; 5 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useNotifications.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react'; 2 | import { HALF_A_SECOND } from '../constants'; 3 | import { NotificationsContext } from '../providers/notifications/context'; 4 | import { Notification } from '../providers/notifications/model'; 5 | import { getExpiredItem } from '../utils/getExpiredItem'; 6 | import { useConfig } from './useConfig'; 7 | import { useExtension } from './useExtension'; 8 | import { useInterval } from './useInterval'; 9 | 10 | export function useNotifications() { 11 | const { activeAccount: account } = useExtension(); 12 | const { addNotification, notifications, removeNotification } = useContext(NotificationsContext); 13 | const config = useConfig(); 14 | 15 | const parachainNotifications = useMemo(() => { 16 | if (!account) return []; 17 | return notifications ?? []; 18 | }, [notifications, account]); 19 | 20 | useInterval(() => { 21 | const expiredNotifications = getExpiredItem(parachainNotifications, config.notifications?.expiration); 22 | for (const notification of expiredNotifications) { 23 | removeNotification({ notificationId: notification.id }); 24 | } 25 | }, config.notifications?.checkInterval || HALF_A_SECOND); 26 | 27 | return { 28 | notifications: parachainNotifications, 29 | addNotification, 30 | removeNotification, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /frontend/lib/useInk/hooks/useProvider.ts: -------------------------------------------------------------------------------- 1 | import { ProviderInterface } from '@polkadot/rpc-provider/types'; 2 | import { useApi } from './useApi'; 3 | 4 | export const useProvider = (): ProviderInterface | undefined => useApi().provider; 5 | -------------------------------------------------------------------------------- /frontend/lib/useInk/index.ts: -------------------------------------------------------------------------------- 1 | export * from './InkProvider'; 2 | -------------------------------------------------------------------------------- /frontend/lib/useInk/models/extrinsics/model.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '../../types'; 2 | 3 | export type Response = { 4 | status: Status; 5 | }; 6 | 7 | export const isNone = (func: { status: Status }): boolean => func.status === 'None'; 8 | 9 | export const isPreFlight = (func: { status: Status }): boolean => func.status === 'PreFlight'; 10 | 11 | export const isPendingSignature = (func: { status: Status }): boolean => func.status === 'PendingSignature'; 12 | 13 | export const isBroadcasting = (func: { status: Status }): boolean => func.status === 'Broadcast'; 14 | 15 | export const isInBlock = (func: { status: Status }): boolean => func.status === 'InBlock'; 16 | 17 | export const hasAny = (func: { status: Status }, ...statuses: Status[]): boolean => statuses.includes(func.status); 18 | 19 | export const isFinalized = (func: { status: Status }): boolean => func.status === 'Finalized'; 20 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/api/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { API } from './model'; 3 | 4 | export const APIContext = createContext({ 5 | api: undefined, 6 | provider: undefined, 7 | }); 8 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/api/model.ts: -------------------------------------------------------------------------------- 1 | import { ApiPromise } from '@polkadot/api'; 2 | import { ProviderInterface } from '@polkadot/rpc-provider/types'; 3 | 4 | export type API = { 5 | api?: ApiPromise; 6 | provider?: ProviderInterface; 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/api/provider.tsx: -------------------------------------------------------------------------------- 1 | import { APIContext } from './context'; 2 | import { ApiPromise, WsProvider } from '@polkadot/api'; 3 | import { ReactNode, useEffect, useMemo, useState } from 'react'; 4 | import { useConfig } from '../../hooks'; 5 | 6 | interface Props { 7 | children: ReactNode; 8 | } 9 | 10 | export const APIProvider: React.FC = ({ children }) => { 11 | const { providerUrl } = useConfig(); 12 | const provider = useMemo(() => new WsProvider(providerUrl), [providerUrl]); 13 | const [api, setApi] = useState(); 14 | 15 | useEffect(() => { 16 | ApiPromise.create({ provider }).then((api) => { 17 | setApi(api); 18 | }); 19 | }, [providerUrl, provider]); 20 | 21 | return {children}; 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/blockHeader/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { BlockHeader, BLOCK_HEADER_DEFAULTS } from './model'; 3 | 4 | export const BlockHeaderContext = createContext({ 5 | ...BLOCK_HEADER_DEFAULTS, 6 | }); 7 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/blockHeader/model.ts: -------------------------------------------------------------------------------- 1 | import { Header } from '@polkadot/types/interfaces'; 2 | 3 | export type BlockHeader = { 4 | blockNumber: number | undefined; 5 | header: Header | undefined; 6 | }; 7 | 8 | export const BLOCK_HEADER_DEFAULTS: BlockHeader = { 9 | blockNumber: undefined, 10 | header: undefined, 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/blockHeader/provider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react'; 2 | import { useApi } from '../../hooks/useApi'; 3 | import { BlockHeaderContext } from './context'; 4 | import { BlockHeader } from './model'; 5 | 6 | interface Props { 7 | children: ReactNode; 8 | } 9 | 10 | const toBlockNumber = (valWithComma: string | undefined): number => parseInt(`${valWithComma?.split(',').join('')}`); 11 | 12 | export const BlockHeaderProvider: React.FC = ({ children }) => { 13 | const { api } = useApi(); 14 | const [blockHeader, setBlockHeader] = useState({ 15 | blockNumber: undefined, 16 | header: undefined, 17 | }); 18 | 19 | useEffect(() => { 20 | async function listenToBlocks() { 21 | return api?.rpc.chain.subscribeNewHeads((header) => { 22 | try { 23 | const blockNumber = toBlockNumber(header.number.toHuman()?.toString()); 24 | blockNumber && setBlockHeader({ blockNumber, header }); 25 | } catch (e) { 26 | console.error(e); 27 | } 28 | }); 29 | } 30 | let cleanUp: VoidFunction | undefined; 31 | listenToBlocks() 32 | .then((unsub) => (cleanUp = unsub)) 33 | .catch(console.error); 34 | 35 | return () => cleanUp && cleanUp(); 36 | }, [api]); 37 | 38 | return {children}; 39 | }; 40 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/config/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { Config, DEFAULT_CONFIG } from './model'; 3 | 4 | export const ConfigContext = createContext(DEFAULT_CONFIG); 5 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/config/model.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_RPC_URL, FIVE_SECONDS, HALF_A_SECOND } from '../../constants'; 2 | 3 | export type Config = { 4 | providerUrl: string; 5 | notifications?: { 6 | off: boolean; 7 | expiration?: number; 8 | checkInterval?: number; 9 | }; 10 | contractEvents?: { 11 | expiration?: number; 12 | checkInterval?: number; 13 | }; 14 | }; 15 | 16 | export const DEFAULT_CONFIG: Config = { 17 | providerUrl: DEFAULT_RPC_URL, 18 | notifications: { 19 | off: false, 20 | expiration: FIVE_SECONDS, 21 | checkInterval: HALF_A_SECOND, 22 | }, 23 | contractEvents: { 24 | expiration: FIVE_SECONDS, 25 | checkInterval: HALF_A_SECOND, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/config/provider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { ConfigContext } from './context'; 3 | import { Config, DEFAULT_CONFIG } from './model'; 4 | 5 | interface Props { 6 | children: ReactNode; 7 | config?: Config; 8 | } 9 | 10 | export const ConfigProvider: React.FC = ({ children, config }) => { 11 | return {children}; 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/contractEvents/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { DEFAULT_CONTRACT_EVENTS, AddContractEventPayload, RemoveContractEventPayload, ContractEvents } from './model'; 3 | 4 | export const ContractEventsContext = createContext<{ 5 | events: ContractEvents; 6 | addContractEvent: (payload: AddContractEventPayload) => void; 7 | removeContractEvent: (payload: RemoveContractEventPayload) => void; 8 | }>({ 9 | events: DEFAULT_CONTRACT_EVENTS, 10 | addContractEvent: () => null, 11 | removeContractEvent: () => null, 12 | }); 13 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/contractEvents/model.ts: -------------------------------------------------------------------------------- 1 | // @public 2 | export type ContractEventPayload = { 3 | createdAt: number; 4 | name: string; 5 | args: unknown[]; 6 | }; 7 | 8 | // @internal 9 | export type AddContractEventPayload = { 10 | event: Omit; 11 | address: string; 12 | }; 13 | 14 | // @internal 15 | export type RemoveContractEventPayload = { 16 | eventId: string; 17 | address: string; 18 | }; 19 | 20 | // @public 21 | export type ContractEvent = { id: string } & ContractEventPayload; 22 | 23 | // @public 24 | export type ContractEvents = { 25 | [address: string]: ContractEvent[]; 26 | }; 27 | 28 | export const DEFAULT_CONTRACT_EVENTS: ContractEvents = {}; 29 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/contractEvents/provider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useCallback, useReducer } from 'react'; 2 | import { ContractEventsContext } from './context'; 3 | import { AddContractEventPayload, DEFAULT_CONTRACT_EVENTS, RemoveContractEventPayload } from './model'; 4 | import { useIsMounted } from '../../hooks/useIsMounted'; 5 | import { nanoid } from 'nanoid'; 6 | import { contractEventReducer } from './reducer'; 7 | 8 | interface Props { 9 | children: ReactNode; 10 | } 11 | 12 | // @internal 13 | export const ContractEventsProvider = ({ children }: Props) => { 14 | const [events, dispatch] = useReducer(contractEventReducer, DEFAULT_CONTRACT_EVENTS); 15 | const isMounted = useIsMounted(); 16 | 17 | const addContractEvent = useCallback( 18 | ({ event, address }: AddContractEventPayload) => { 19 | if (isMounted()) { 20 | dispatch({ 21 | type: 'ADD_CONTRACT_EVENT', 22 | address, 23 | event: { ...event, id: nanoid(), createdAt: Date.now() }, 24 | }); 25 | } 26 | }, 27 | [dispatch], 28 | ); 29 | 30 | const removeContractEvent = useCallback( 31 | ({ eventId, address }: RemoveContractEventPayload) => { 32 | if (isMounted()) { 33 | dispatch({ 34 | type: 'REMOVE_CONTRACT_EVENT', 35 | address, 36 | eventId, 37 | }); 38 | } 39 | }, 40 | [dispatch], 41 | ); 42 | 43 | return ( 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/contractEvents/reducer.ts: -------------------------------------------------------------------------------- 1 | import { ContractEvent, ContractEvents } from './model'; 2 | 3 | interface AddContractEvent { 4 | type: 'ADD_CONTRACT_EVENT'; 5 | event: ContractEvent; 6 | address: string; 7 | } 8 | 9 | interface RemoveContractEvent { 10 | type: 'REMOVE_CONTRACT_EVENT'; 11 | eventId: string; 12 | address: string; 13 | } 14 | 15 | type Action = AddContractEvent | RemoveContractEvent; 16 | 17 | export function contractEventReducer(state: ContractEvents, action: Action): ContractEvents { 18 | switch (action.type) { 19 | case 'ADD_CONTRACT_EVENT': 20 | return { 21 | ...state, 22 | [action.address]: [...(state[action.address] || []), action.event], 23 | }; 24 | case 'REMOVE_CONTRACT_EVENT': { 25 | const contractEvents = state[action.address]; 26 | if (!contractEvents) return state; 27 | 28 | const idx = contractEvents.findIndex((e) => e.id === action.eventId); 29 | if (idx < 0) return state; 30 | 31 | const newContractState: ContractEvent[] = [ 32 | ...contractEvents.slice(0, idx), 33 | ...contractEvents.slice(idx + 1, contractEvents.length), 34 | ]; 35 | 36 | return { 37 | ...state, 38 | [action.address]: newContractState, 39 | }; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/extension/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { Extension, EXTENSION_DEFAULTS } from './model'; 3 | 4 | export const ExtensionContext = createContext({ 5 | ...EXTENSION_DEFAULTS, 6 | }); 7 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/extension/model.ts: -------------------------------------------------------------------------------- 1 | import { InjectedAccountWithMeta, InjectedExtension } from '@polkadot/extension-inject/types'; 2 | 3 | export type Extension = { 4 | accounts: InjectedAccountWithMeta[] | null; 5 | setActiveAccount: (account: InjectedAccountWithMeta | null) => void; 6 | activeAccount?: InjectedAccountWithMeta | null; 7 | activeSigner?: InjectedExtension | null; 8 | fetchAccounts: () => void; 9 | }; 10 | 11 | export const EXTENSION_DEFAULTS: Extension = { 12 | accounts: null, 13 | setActiveAccount: (_: InjectedAccountWithMeta | null) => null, 14 | activeAccount: null, 15 | fetchAccounts: () => null, 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/extension/provider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react'; 2 | import { InjectedAccountWithMeta, InjectedExtension } from '@polkadot/extension-inject/types'; 3 | import { ExtensionContext } from './context'; 4 | import { web3Accounts, web3Enable, web3FromAddress } from '@polkadot/extension-dapp'; 5 | 6 | interface Props { 7 | children: ReactNode; 8 | } 9 | 10 | // @internal 11 | export const ExtensionProvider: React.FC = ({ children }) => { 12 | const [web3OriginName, setWeb3OriginName] = useState(null); 13 | const [accounts, setAccounts] = useState([]); 14 | const [activeAccount, setActiveAccount] = useState(null); 15 | const [activeSigner, setActiveSigner] = useState(null); 16 | 17 | useEffect(() => { 18 | activeAccount && web3FromAddress(activeAccount?.address || '').then((v) => setActiveSigner(v)); 19 | }, [activeAccount?.address]); 20 | 21 | const fetchAccounts = async () => { 22 | try { 23 | const extensions = await web3Enable(web3OriginName || 'Extension'); 24 | if (extensions.length === 0) return; 25 | await web3Accounts().then((list) => { 26 | setAccounts(list); 27 | const first = list.length && list[0]; 28 | first && !activeAccount && setActiveAccount(first); 29 | }); 30 | } catch (err) { 31 | console.error('Extension setup failed', err); 32 | } 33 | }; 34 | 35 | const value = { 36 | accounts, 37 | activeAccount, 38 | activeSigner, 39 | fetchAccounts, 40 | setActiveAccount, 41 | setWeb3OriginName, 42 | }; 43 | 44 | return {children}; 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/logs/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const LogsContext = createContext<{ 4 | logs: string[]; 5 | addLog: (log: string) => void; 6 | }>({ 7 | logs: [], 8 | addLog: () => null, 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/logs/provider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from 'react'; 2 | import { LogsContext } from './context'; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | } 7 | 8 | // @internal 9 | export const LogsProvider = ({ children }: Props) => { 10 | const [logs, setLogs] = useState([]); 11 | 12 | const addLog = (log: string) => { 13 | setLogs([log, ...logs]); 14 | }; 15 | 16 | return ; 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/notifications/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { Notifications, DEFAULT_NOTIFICATIONS, AddNotificationPayload, RemoveNotificationPayload } from './model'; 3 | 4 | export const NotificationsContext = createContext<{ 5 | notifications: Notifications; 6 | addNotification: (payload: AddNotificationPayload) => void; 7 | removeNotification: (payload: RemoveNotificationPayload) => void; 8 | }>({ 9 | notifications: DEFAULT_NOTIFICATIONS, 10 | addNotification: () => null, 11 | removeNotification: () => null, 12 | }); 13 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/notifications/model.ts: -------------------------------------------------------------------------------- 1 | import { Codec } from '@polkadot/types-codec/types'; 2 | import type { ISubmittableResult } from '@polkadot/types/types'; 3 | import { NotificationType } from '../../types'; 4 | 5 | // @public 6 | export type NotificationPayload = { 7 | createdAt: number; 8 | type: NotificationType; 9 | result?: Codec | ISubmittableResult; 10 | message: string; 11 | }; 12 | 13 | // @internal 14 | export type AddNotificationPayload = { 15 | notification: Omit; 16 | }; 17 | 18 | // @internal 19 | export type RemoveNotificationPayload = { 20 | notificationId: string; 21 | }; 22 | 23 | // @public 24 | export type Notification = { id: string } & NotificationPayload; 25 | 26 | // @public 27 | export type Notifications = Notification[]; 28 | 29 | export const DEFAULT_NOTIFICATIONS: Notifications = []; 30 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/notifications/provider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useCallback, useEffect, useReducer } from 'react'; 2 | import { NotificationsContext } from './context'; 3 | import { AddNotificationPayload, DEFAULT_NOTIFICATIONS, RemoveNotificationPayload } from './model'; 4 | import { useIsMounted } from '../../hooks/useIsMounted'; 5 | import { nanoid } from 'nanoid'; 6 | import { notificationReducer } from './reducer'; 7 | import { useExtension } from '../../hooks/useExtension'; 8 | import { useConfig } from '../../hooks'; 9 | 10 | interface Props { 11 | children: ReactNode; 12 | } 13 | 14 | // @internal 15 | export const NotificationsProvider = ({ children }: Props) => { 16 | const [notifications, dispatch] = useReducer(notificationReducer, DEFAULT_NOTIFICATIONS); 17 | const isMounted = useIsMounted(); 18 | const config = useConfig(); 19 | const { activeAccount } = useExtension(); 20 | 21 | const addNotification = useCallback( 22 | ({ notification }: AddNotificationPayload) => { 23 | if (isMounted()) { 24 | dispatch({ 25 | type: 'ADD_NOTIFICATION', 26 | notification: { ...notification, id: nanoid(), createdAt: Date.now() }, 27 | }); 28 | } 29 | }, 30 | [dispatch], 31 | ); 32 | 33 | useEffect(() => { 34 | if (activeAccount && !config.notifications?.off) { 35 | addNotification({ 36 | notification: { 37 | message: `${activeAccount.meta.name || activeAccount.address} Connected`, 38 | type: 'WalletConnected', 39 | }, 40 | }); 41 | } 42 | }, [activeAccount?.address]); 43 | 44 | const removeNotification = useCallback( 45 | ({ notificationId }: RemoveNotificationPayload) => { 46 | if (isMounted()) { 47 | dispatch({ 48 | type: 'REMOVE_NOTIFICATION', 49 | notificationId, 50 | }); 51 | } 52 | }, 53 | [dispatch], 54 | ); 55 | 56 | return ( 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /frontend/lib/useInk/providers/notifications/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Notification, Notifications } from './model'; 2 | 3 | interface AddNotification { 4 | type: 'ADD_NOTIFICATION'; 5 | notification: Notification; 6 | } 7 | 8 | interface RemoveNotification { 9 | type: 'REMOVE_NOTIFICATION'; 10 | notificationId: string; 11 | } 12 | 13 | type Action = AddNotification | RemoveNotification; 14 | 15 | export function notificationReducer(state: Notifications, action: Action): Notifications { 16 | const chainState = state ?? []; 17 | 18 | switch (action.type) { 19 | case 'ADD_NOTIFICATION': 20 | return [...state, action.notification]; 21 | case 'REMOVE_NOTIFICATION': { 22 | return chainState.filter((n) => n.id !== action.notificationId); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/lib/useInk/types/contracts.ts: -------------------------------------------------------------------------------- 1 | import { AbiMessage, ContractOptions } from '@polkadot/api-contract/types'; 2 | import { ContractExecResult } from '@polkadot/types/interfaces'; 3 | import { Result } from './result'; 4 | import { ISubmittableResult, Status, StorageDeposit, Weight } from './substrate'; 5 | 6 | export type { ContractExecResult, ContractExecResultResult } from '@polkadot/types/interfaces'; 7 | export type { AbiMessage, ContractOptions } from '@polkadot/api-contract/types'; 8 | 9 | export type AccountId = string; 10 | 11 | export type DecodedResult = Result; 12 | 13 | export interface ContractCallResult { 14 | readonly callResult: ContractExecResult; 15 | readonly abiMessage: AbiMessage; 16 | } 17 | 18 | export interface ContractExecResultDecoded { 19 | readonly gasConsumed: Weight; 20 | readonly gasRequired: Weight; 21 | readonly storageDeposit: StorageDeposit; 22 | readonly debugMessage: string; 23 | readonly result: T; 24 | } 25 | 26 | export type ContractTxFunc = { 27 | send: (args: unknown[], options?: ContractOptions) => any; 28 | status: Status; 29 | error?: string | null; 30 | result: ISubmittableResult | undefined; 31 | resetState: () => void; 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/lib/useInk/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './contracts'; 2 | export * from './notifications'; 3 | export * from './result'; 4 | export * from './substrate'; 5 | -------------------------------------------------------------------------------- /frontend/lib/useInk/types/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Status } from './substrate'; 2 | 3 | export type NotificationType = Status | 'WalletConnected'; 4 | -------------------------------------------------------------------------------- /frontend/lib/useInk/types/result.ts: -------------------------------------------------------------------------------- 1 | export type Result = { ok: true; value: T } | { ok: false; error: E }; 2 | -------------------------------------------------------------------------------- /frontend/lib/useInk/types/substrate.ts: -------------------------------------------------------------------------------- 1 | export type { AnyJson, Codec, Registry, RegistryError, TypeDef, ISubmittableResult } from '@polkadot/types/types'; 2 | export type { Weight, WeightV2, Balance, StorageDeposit, ExtrinsicStatus } from '@polkadot/types/interfaces'; 3 | 4 | import { ExtrinsicStatus } from '@polkadot/types/interfaces'; 5 | 6 | export type Status = 'None' | 'PreFlight' | 'PendingSignature' | ExtrinsicStatus['type'] | 'Errored'; 7 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/contractFunctionUtils.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '../types'; 2 | 3 | export type Response = { 4 | status: Status; 5 | }; 6 | 7 | export const isNone = (func: { status: Status }): boolean => func.status === 'None'; 8 | 9 | export const isPreFlight = (func: { status: Status }): boolean => func.status === 'PreFlight'; 10 | 11 | export const isPendingSignature = (func: { status: Status }): boolean => func.status === 'PendingSignature'; 12 | 13 | export const isBroadcasting = (func: { status: Status }): boolean => func.status === 'Broadcast'; 14 | 15 | export const isInBlock = (func: { status: Status }): boolean => func.status === 'InBlock'; 16 | 17 | export const hasAny = (func: { status: Status }, ...statuses: Status[]): boolean => statuses.includes(func.status); 18 | 19 | export const isFinalized = (func: { status: Status }): boolean => func.status === 'Finalized'; 20 | 21 | export const isErrored = (func: { status: Status }): boolean => func.status === 'Errored'; 22 | 23 | export const shouldDisable = (func: { status: Status }): boolean => 24 | hasAny(func, 'PreFlight', 'PendingSignature', 'Broadcast'); 25 | 26 | export const shouldDisableStrict = (func: { status: Status }): boolean => shouldDisable(func) || isInBlock(func); 27 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/contracts/callContract.ts: -------------------------------------------------------------------------------- 1 | import { ContractPromise } from '@polkadot/api-contract'; 2 | import BN from 'bn.js'; 3 | import { AbiMessage, AccountId, ContractExecResult, ContractOptions } from '../../types'; 4 | 5 | export async function callContract( 6 | contract: ContractPromise, 7 | abiMessage: AbiMessage, 8 | caller: AccountId | undefined, 9 | args = [] as unknown[], 10 | options?: ContractOptions, 11 | ): Promise { 12 | const { value, gasLimit, storageDepositLimit } = options || {}; 13 | 14 | return await contract.api.call.contractsApi.call( 15 | caller, 16 | contract.address, 17 | value ?? new BN(0), 18 | gasLimit ?? null, 19 | storageDepositLimit ?? null, 20 | abiMessage.toU8a(args), 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/contracts/callContractDecoded.ts: -------------------------------------------------------------------------------- 1 | import { ContractPromise } from '@polkadot/api-contract'; 2 | import { AbiMessage, AccountId, ContractExecResultDecoded, Result } from '../../types'; 3 | import { callContract } from './callContract'; 4 | import { decodeContractExecResult } from './decodeContractExecResult'; 5 | 6 | export async function callContractDecoded( 7 | contract: ContractPromise, 8 | abiMessage: AbiMessage, 9 | caller: AccountId, 10 | args = [] as unknown[], 11 | ): Promise, string>> { 12 | const callResult = await callContract(contract, abiMessage, caller, args); 13 | 14 | const decoded = decodeContractExecResult(callResult.result, abiMessage, contract.abi.registry); 15 | if (!decoded.ok) return decoded; 16 | 17 | const { gasConsumed, gasRequired, storageDeposit, debugMessage } = callResult; 18 | 19 | return { 20 | ok: true, 21 | value: { 22 | gasConsumed, 23 | gasRequired, 24 | storageDeposit, 25 | debugMessage: debugMessage.toHuman(), 26 | result: decoded.value, 27 | }, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/contracts/decodeContractExecResult.ts: -------------------------------------------------------------------------------- 1 | import { AbiMessage, ContractExecResult, DecodedResult, Registry } from '../../types'; 2 | 3 | function decodeRawData(raw: any, isError: boolean): DecodedResult { 4 | if (isError) { 5 | const errorOutput = typeof raw.Err === 'string' ? raw.Err : JSON.stringify(raw.Err, null, 2); 6 | return { error: errorOutput, ok: false }; 7 | } 8 | 9 | const successOutput = typeof raw === 'object' ? raw.Ok : raw?.toString(); 10 | 11 | return { value: successOutput as T, ok: true }; 12 | } 13 | 14 | export function decodeContractExecResult( 15 | result: ContractExecResult['result'], 16 | message: AbiMessage, 17 | registry: Registry, 18 | ): DecodedResult { 19 | const raw = 20 | result.isOk && message.returnType 21 | ? registry.createTypeUnsafe( 22 | message.returnType.lookupName || message.returnType.type, 23 | [result.asOk.data.toU8a(true)], 24 | { isPedantic: true }, 25 | ) 26 | : result.asErr; 27 | 28 | return decodeRawData(raw.toHuman(), result.isErr); 29 | } 30 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/contracts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './callContract'; 2 | export * from './callContractDecoded'; 3 | export * from './decodeContractExecResult'; 4 | export * from './toContractAbiMessage'; 5 | export * from './toRegistryErrorDecoded'; 6 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/contracts/toContractAbiMessage.ts: -------------------------------------------------------------------------------- 1 | import { ContractPromise } from '@polkadot/api-contract'; 2 | import { AbiMessage, Result } from '../../types'; 3 | 4 | export const toContractAbiMessage = (contract: ContractPromise, message: string): Result => { 5 | const value = contract.abi.messages.find((m) => m.method === message); 6 | 7 | if (!value) { 8 | const messages = contract?.abi.messages.map((m) => m.method).join(', '); 9 | 10 | const error = `"${message}" not found in metadata.spec.messages: [${messages}]`; 11 | console.error(error); 12 | 13 | return { ok: false, error }; 14 | } 15 | 16 | return { ok: true, value }; 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/contracts/toRegistryErrorDecoded.ts: -------------------------------------------------------------------------------- 1 | import { ContractExecResultResult, Registry, RegistryError } from '../../types'; 2 | 3 | export const toRegistryErrorDecoded = ( 4 | registry: Registry, 5 | result: ContractExecResultResult, 6 | ): RegistryError | undefined => { 7 | try { 8 | return result.isErr && result.asErr.isModule ? registry.findMetaError(result.asErr.asModule) : undefined; 9 | } catch (e) { 10 | console.error(e); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/getExpiredItem.ts: -------------------------------------------------------------------------------- 1 | import { FIVE_SECONDS } from '../constants'; 2 | 3 | export type CreatedItem = { createdAt: number }; 4 | 5 | export function getExpiredItem(items: CreatedItem[], expirationPeriod?: number): T[] { 6 | if (expirationPeriod === 0) return []; 7 | 8 | const timeFromCreation = (creationTime: number) => Date.now() - creationTime; 9 | 10 | return items.filter((item) => timeFromCreation(item.createdAt) >= (expirationPeriod || FIVE_SECONDS)) as T[]; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parseUnits'; 2 | export * from './contractFunctionUtils'; 3 | export * from './contracts'; 4 | export * from './substrate'; 5 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/parseUnits.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | import BN from 'bn.js'; 3 | 4 | const DECIMALS_ROC = 12; 5 | 6 | export const parseUnits = (value: string, decimals = DECIMALS_ROC): BN => { 7 | const n = ethers.utils.parseUnits(value, decimals); 8 | return new BN(n.toString()); 9 | }; 10 | 11 | export const stringNumberToBN = (valWithCommas: string): BN => new BN(valWithCommas.split(',').join('')); 12 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/substrate/index.ts: -------------------------------------------------------------------------------- 1 | export * from './toWeightV2'; 2 | -------------------------------------------------------------------------------- /frontend/lib/useInk/utils/substrate/toWeightV2.ts: -------------------------------------------------------------------------------- 1 | import BN from 'bn.js'; 2 | import { Registry, WeightV2 } from '../../types'; 3 | 4 | export const toWeightV2 = (registry: Registry, refTime: BN, proofSize: BN): WeightV2 => 5 | registry.createType('WeightV2', { refTime, proofSize }); 6 | -------------------------------------------------------------------------------- /frontend/next-i18next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | i18n: { 3 | locales: ['en', 'es', 'fr'], 4 | defaultLocale: 'en', 5 | }, 6 | } -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const { i18n } = require('./next-i18next.config') 4 | 5 | const nextConfig = { 6 | reactStrictMode: true, 7 | swcMinify: true, 8 | i18n 9 | }; 10 | 11 | module.exports = nextConfig; 12 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ink-workshop", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^1.7.2", 13 | "@lottiefiles/react-lottie-player": "^3.4.7", 14 | "@polkadot/api": "^9.10.2", 15 | "@polkadot/api-contract": "^9.10.2", 16 | "@polkadot/extension-dapp": "^0.44.6", 17 | "@polkadot/react-identicon": "^2.9.10", 18 | "bn.js": "^5.2.1", 19 | "classnames": "^2.3.2", 20 | "ethers": "^5.7.2", 21 | "howler": "^2.2.3", 22 | "i18next": "^22.4.6", 23 | "next": "12.3.1", 24 | "next-i18next": "^13.0.2", 25 | "react": "18.3.1", 26 | "react-dom": "18.3.1", 27 | "react-i18next": "^12.1.1", 28 | "react-icons": "^4.4.0", 29 | "react-json-view": "^1.21.3", 30 | "react-spring": "^9.7.3" 31 | }, 32 | "devDependencies": { 33 | "@types/howler": "^2.2.7", 34 | "@types/node": "18.7.18", 35 | "@types/react": "18.3.1", 36 | "@types/react-dom": "18.3.0", 37 | "autoprefixer": "^10.4.11", 38 | "eslint": "8.23.1", 39 | "eslint-config-next": "12.3.1", 40 | "eslint-config-prettier": "^8.5.0", 41 | "postcss": "^8.4.16", 42 | "tailwindcss": "^3.1.8", 43 | "typescript": "4.8.3" 44 | }, 45 | "overrides": { 46 | "react-json-view": { 47 | "react": "18.3.1", 48 | "react-dom": "18.3.1" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import type { AppProps } from 'next/app'; 3 | import React from 'react'; 4 | import dynamic from 'next/dynamic'; 5 | import UIProvider from '../contexts/UIContext'; 6 | import { AudioSettingsProvider } from '../contexts/AudioSettingsContext'; 7 | import { LanguageSettingsProvider } from '../contexts/LanguageContext'; 8 | import { appWithTranslation } from 'next-i18next'; 9 | 10 | const InkProvider = dynamic(() => import('../lib/useInk/InkProvider'), { 11 | ssr: false, 12 | }); 13 | 14 | const GameProvider = dynamic(() => import('../contexts/GameContext').then(({ GameProvider }) => GameProvider), { 15 | ssr: false, 16 | }); 17 | 18 | function MyApp({ Component, pageProps }: AppProps) { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | export default appWithTranslation(MyApp); 35 | -------------------------------------------------------------------------------- /frontend/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript, DocumentContext, DocumentInitialProps } from 'next/document'; 2 | 3 | class MyDocument extends Document { 4 | static async getInitialProps(ctx: DocumentContext): Promise { 5 | const initialProps = await Document.getInitialProps(ctx); 6 | return { ...initialProps }; 7 | } 8 | 9 | render(): JSX.Element { 10 | return ( 11 | 12 | 13 |