diff --git a/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-1-project-setup-and-providers.md b/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-1-project-setup-and-providers.md index 71eac0c..20ded8b 100644 --- a/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-1-project-setup-and-providers.md +++ b/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-1-project-setup-and-providers.md @@ -26,32 +26,43 @@ bun add seismic-react@1.1.1 seismic-viem@1.1.1 viem@^2.22.3 \ @tanstack/react-query@^5.55.3 \ @mui/material@^6.4.3 @emotion/react @emotion/styled \ framer-motion@^12.7.3 react-router-dom@^7.1.4 \ - react-toastify@^11.0.5 use-sound@^5.0.0 + react-toastify@^11.0.5 use-sound@^5.0.0 \ + react-redux@^9.2.0 @reduxjs/toolkit@^2.5.1 \ + @tailwindcss/vite tailwindcss@^4 ``` +### Copy public assets + +Copy the `public/` folder from the [seismic-starter](https://github.com/SeismicSystems/seismic-starter) repo into `packages/web/public/`. This includes the clown sprites, button images, background, logo, and audio files used by the game UI. + ### Configure Vite -Update `vite.config.ts` to add a path alias for cleaner imports: +Update `vite.config.ts`: ```typescript +import { resolve } from "path"; +import { defineConfig } from "vite"; + import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react-swc"; -import path from "path"; -import { defineConfig } from "vite"; +// https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + envDir: resolve(__dirname, "../.."), resolve: { alias: { - "@": path.resolve(__dirname, "./src"), + "@": resolve(__dirname, "src"), }, }, }); ``` +The `envDir` points to the monorepo root so that `.env` files at the top level are available to the web package. + ### Environment variables -Create a `.env` file in `packages/web`: +Create a `.env` file at the monorepo root: ```properties VITE_CHAIN_ID=31337 @@ -68,42 +79,80 @@ The key architectural pattern in a seismic-react app is the **provider stack**. Create `src/App.tsx`: ```typescript +import React from 'react' +import { PropsWithChildren, useCallback } from 'react' +import { BrowserRouter, Route, Routes } from 'react-router-dom' import { - RainbowKitProvider, - darkTheme, - getDefaultConfig, -} from '@rainbow-me/rainbowkit' -import '@rainbow-me/rainbowkit/styles.css' + type OnAddressChangeParams, + ShieldedWalletProvider, +} from 'seismic-react' +import { sanvil, seismicTestnet } from 'seismic-react/rainbowkit' +import { http } from 'viem' +import { Config, WagmiProvider } from 'wagmi' + +import { AuthProvider } from '@/components/chain/WalletConnectButton' +import Home from '@/pages/Home' +import NotFound from '@/pages/NotFound' +import { getDefaultConfig } from '@rainbow-me/rainbowkit' +import { RainbowKitProvider } from '@rainbow-me/rainbowkit' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ShieldedWalletProvider } from 'seismic-react' -import { sanvil, seismicTestnet } from 'seismic-viem' -import { http, WagmiProvider, useAccount } from 'wagmi' -import { AuthProvider } from './components/chain/WalletConnectButton' -import Home from './pages/Home' +import './App.css' -// Select chain based on environment variable -const chainConfig = - import.meta.env.VITE_CHAIN_ID === String(sanvil.id) ? sanvil : seismicTestnet +const configuredChainId = String(import.meta.env.VITE_CHAIN_ID ?? '') +const isSanvilConfig = + configuredChainId === 'sanvil' || configuredChainId === String(sanvil.id) +const CHAIN = isSanvilConfig ? sanvil : seismicTestnet +const CHAINS = [CHAIN] -// Configure wagmi with RainbowKit defaults const config = getDefaultConfig({ - appName: 'Clown Beatdown', - projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', - chains: [chainConfig], - transports: { - [chainConfig.id]: http(import.meta.env.VITE_RPC_URL), - }, + appName: 'Seismic Starter', + projectId: 'd705c8eaf9e6f732e1ddb8350222cdac', + // @ts-expect-error: this is fine + chains: CHAINS, + ssr: false, }) -const queryClient = new QueryClient() +const client = new QueryClient() + +const Providers: React.FC> = ({ + config, + children, +}) => { + const publicChain = CHAINS[0] + const publicTransport = http(publicChain.rpcUrls.default.http[0]) + const handleAddressChange = useCallback( + async ({ publicClient, address }: OnAddressChangeParams) => { + if (publicClient.chain.id !== sanvil.id) return + + const existingBalance = await publicClient.getBalance({ address }) + if (existingBalance > 0n) return + + const setBalance = publicClient.request as unknown as (args: { + method: string + params?: unknown[] + }) => Promise + + await setBalance({ + method: 'anvil_setBalance', + params: [address, `0x${(10_000n * 10n ** 18n).toString(16)}`], + }) + }, + [] + ) -function Providers({ children }: { children: React.ReactNode }) { return ( - - - + + + {children} @@ -112,13 +161,20 @@ function Providers({ children }: { children: React.ReactNode }) { ) } -export default function App() { +const App: React.FC = () => { return ( - - - + + + + } /> + } /> + + + ) } + +export default App ``` ### What's happening here? @@ -128,6 +184,36 @@ The provider stack nests four layers, each adding functionality: 1. **WagmiProvider** — manages wallet connections and chain state via wagmi hooks (`useAccount`, `useConnect`, etc.) 2. **QueryClientProvider** — provides React Query for caching and background data fetching 3. **RainbowKitProvider** — adds a polished wallet connect modal UI -4. **ShieldedWalletProvider** — the Seismic-specific layer from `seismic-react` that derives a shielded wallet client from the connected wagmi account, enabling shielded reads and writes +4. **ShieldedWalletProvider** — the Seismic-specific layer from `seismic-react` that derives a shielded wallet client from the connected wagmi account, enabling shielded reads and writes. It takes `config` and `options` — the options include `publicTransport`, `publicChain`, and an `onAddressChange` callback. + +The `onAddressChange` handler auto-funds new wallets when running on `sanvil` (local dev), so you don't need to manually send ETH to test accounts. + +### Entry point: main.tsx -This is the same pattern you'd use for any Seismic dApp. The `ShieldedWalletProvider` is the only Seismic-specific addition to a standard wagmi + RainbowKit setup. +Create `src/main.tsx` to bootstrap the app with theme and state management: + +```typescript +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { Provider } from 'react-redux' +import { ToastContainer } from 'react-toastify' +import 'react-toastify/dist/ReactToastify.css' + +import App from '@/App.tsx' +import { store } from '@/store/store' +import theme from '@/theme.ts' +import { ThemeProvider } from '@mui/material/styles' + +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + + + + + + +) +``` diff --git a/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-2-contract-hooks.md b/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-2-contract-hooks.md index 27626fd..369bc3e 100644 --- a/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-2-contract-hooks.md +++ b/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-2-contract-hooks.md @@ -16,12 +16,22 @@ cp packages/contracts/out/ClownBeatdown.sol/ClownBeatdown.json \ packages/web/src/abis/contracts/ClownBeatdown.json ``` -You'll also need to add the deployed contract address and chain ID. Create a wrapper that exports the ABI with deployment info: +You'll also need to add the deployed contract address and chain ID to the JSON file. The ABI file should contain `abi`, `address`, and `chainId` fields. + +### Contract type definition + +Create `src/types/contract.ts`: ```typescript -// src/abis/contracts/ClownBeatdown.json -// This file contains the ABI output from sforge build, -// plus the deployed address and chainId fields +export type ContractInterface = { + chainId: number; + abi: Array>; + methodIdentifiers: Record; +}; + +export type DeployedContract = ContractInterface & { + address: `0x${string}`; +}; ``` ### useContract hook @@ -31,14 +41,11 @@ This hook creates a shielded contract instance using `seismic-react`. Create `sr ```typescript import { useShieldedContract } from "seismic-react"; +import * as contractJson from "@/abis/contracts/ClownBeatdown.json" with { type: "json" }; import type { DeployedContract } from "@/types/contract"; -import ClownBeatdownABI from "@/abis/contracts/ClownBeatdown.json"; - -const deployedContract = ClownBeatdownABI as unknown as DeployedContract; -export function useAppContract() { - return useShieldedContract(deployedContract); -} +export const useAppContract = () => + useShieldedContract(contractJson as DeployedContract); ``` The `useShieldedContract` hook from `seismic-react` returns a contract instance that supports both shielded writes and signed reads — the same interface you used in the CLI with `getShieldedContract`, but integrated with React's lifecycle. @@ -48,60 +55,129 @@ The `useShieldedContract` hook from `seismic-react` returns a contract instance This hook wraps the contract methods into callable functions with proper error handling. Create `src/hooks/useContractClient.ts`: ```typescript -import { useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useShieldedWallet } from "seismic-react"; +import { + ShieldedPublicClient, + ShieldedWalletClient, + addressExplorerUrl, + txExplorerUrl, +} from "seismic-viem"; +import { type Hex, hexToString } from "viem"; import { useAppContract } from "./useContract"; -export function useContractClient() { +export const useContractClient = () => { + const [loaded, setLoaded] = useState(false); const { walletClient, publicClient } = useShieldedWallet(); const { contract } = useAppContract(); - const loaded = !!(walletClient && publicClient && contract); + useEffect(() => { + if (walletClient && publicClient && contract) { + setLoaded(true); + } else { + setLoaded(false); + } + }, [walletClient, publicClient, contract]); + + const wallet = useCallback((): ShieldedWalletClient => { + if (!walletClient) { + throw new Error("Wallet client not found"); + } + return walletClient; + }, [walletClient]); - const readClownStamina = useCallback(async (): Promise => { - return (await contract!.tread.getClownStamina()) as bigint; + const pubClient = useCallback((): ShieldedPublicClient => { + if (!publicClient) { + throw new Error("Public client not found"); + } + return publicClient; + }, [publicClient]); + + const walletAddress = useCallback((): Hex => { + return wallet().account.address; + }, [wallet]); + + const appContract = useCallback((): ReturnType< + typeof useAppContract + >["contract"] => { + if (!contract) { + throw new Error("Contract not found"); + } + return contract; }, [contract]); - const hit = useCallback(async (): Promise => { - return (await contract!.twrite.hit()) as string; - }, [contract]); + /* + function getClownStamina() external view returns (uint256); + function rob() external view returns (bytes32); + function hit() external; + function reset() external; + */ - const reset = useCallback(async (): Promise => { - return (await contract!.twrite.reset()) as string; - }, [contract]); + const clownStamina = useCallback(async (): Promise => { + return appContract().tread.getClownStamina(); + }, [appContract]); const rob = useCallback(async (): Promise => { - return (await contract!.read.rob()) as string; - }, [contract]); + const result = (await appContract().read.rob()) as Hex; + return hexToString(result); + }, [appContract]); + + const hit = useCallback(async (): Promise => { + return appContract().twrite.hit(); + }, [appContract]); + + const reset = useCallback(async (): Promise => { + return appContract().twrite.reset(); + }, [appContract]); + + const txUrl = useCallback( + (txHash: Hex): string | null => { + return txExplorerUrl({ chain: pubClient().chain, txHash }); + }, + [pubClient], + ); + + const addressUrl = useCallback( + (address: Hex): string | null => { + return addressExplorerUrl({ chain: pubClient().chain, address }); + }, + [pubClient], + ); const waitForTransaction = useCallback( - async (hash: string) => { - return publicClient!.waitForTransactionReceipt({ - hash: hash as `0x${string}`, - }); + async (hash: Hex) => { + return await pubClient().waitForTransactionReceipt({ hash }); }, - [publicClient], + [pubClient], ); return { loaded, - readClownStamina, + walletClient, + publicClient, + walletAddress, + appContract, + pubClient, + wallet, + clownStamina, + rob, hit, reset, - rob, + txUrl, + addressUrl, waitForTransaction, }; -} +}; ``` ### What's happening here? Notice the different contract namespaces used for each method: -- **`contract.twrite.hit()`** and **`contract.twrite.reset()`** — these are **shielded write** transactions. The `twrite` namespace sends a Seismic transaction (type 0x70) that encrypts calldata. -- **`contract.read.rob()`** — this is a **signed read**. The `read` namespace performs a `signed_call` that proves the caller's identity to the contract, allowing `onlyContributor` to verify access. -- **`contract.tread.getClownStamina()`** — this is a **transparent read**. The `tread` namespace performs a standard `eth_call` since stamina is public state. +- **`appContract().twrite.hit()`** and **`appContract().twrite.reset()`** — these are **shielded write** transactions. The `twrite` namespace sends a Seismic transaction (type 0x70) that encrypts calldata. +- **`appContract().read.rob()`** — this is a **signed read**. The `read` namespace performs a `signed_call` that proves the caller's identity to the contract, allowing `onlyContributor` to verify access. The result comes back as `Hex` and is decoded with `hexToString()`. +- **`appContract().tread.getClownStamina()`** — this is a **transparent read**. The `tread` namespace performs a standard `eth_call` since stamina is public state. This distinction between `twrite`, `read`, and `tread` is the key difference from a standard Ethereum dApp. @@ -111,92 +187,186 @@ This hook orchestrates the game logic, managing state and coordinating contract ```typescript import { useCallback, useEffect, useState } from "react"; -import useSound from "use-sound"; - -import { useContractClient } from "./useContractClient"; +import React from "react"; +import { useSound } from "use-sound"; -export function useGameActions() { - const { loaded, readClownStamina, hit, reset, rob, waitForTransaction } = - useContractClient(); +import { ExplorerToast } from "@/components/chain/ExplorerToast"; +import { useContractClient } from "@/hooks/useContractClient"; +import { useToastNotifications } from "@/hooks/useToastNotifications"; +export const useGameActions = () => { const [clownStamina, setClownStamina] = useState(null); + const [currentRoundId] = useState(1); + + const { + loaded, + hit, + rob, + reset, + txUrl, + waitForTransaction, + clownStamina: readClownStamina, + } = useContractClient(); + + const { notifySuccess, notifyError, notifyInfo } = useToastNotifications(); const [isHitting, setIsHitting] = useState(false); const [isResetting, setIsResetting] = useState(false); const [isRobbing, setIsRobbing] = useState(false); const [robResult, setRobResult] = useState(null); const [punchCount, setPunchCount] = useState(0); + const [playHit] = useSound("/audio/hit_sfx.wav", { volume: 0.1 }); + const [playReset] = useSound("/audio/reset_sfx.wav", { volume: 0.1 }); + const [playRob] = useSound("/audio/rob_sfx.wav", { volume: 0.1 }); - const [playHitSound] = useSound("/hit_sfx.wav", { volume: 0.1 }); - const [playResetSound] = useSound("/reset_sfx.wav"); - const [playRobSound] = useSound("/rob_sfx.wav"); - - // Fetch stamina on load and after actions - const fetchStamina = useCallback(async () => { + const fetchGameRounds = useCallback(() => { if (!loaded) return; - const stamina = await readClownStamina(); - setClownStamina(Number(stamina)); + readClownStamina() + .then((stamina) => { + setClownStamina(Number(stamina)); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error("Error fetching clown stamina:", message); + }); }, [loaded, readClownStamina]); + // Fetch initial state when contract is loaded useEffect(() => { - fetchStamina(); - }, [fetchStamina]); + fetchGameRounds(); + }, [fetchGameRounds]); - const handleHit = useCallback(async () => { + const resetGameState = useCallback(() => { + setRobResult(null); + setPunchCount(0); + }, [punchCount]); + + const handleHit = async () => { + playHit(); + if (!loaded || isHitting) return; setIsHitting(true); - try { - playHitSound(); - const txHash = await hit(); - await waitForTransaction(txHash); - setPunchCount((prev) => Math.min(prev + 1, 3)); - await fetchStamina(); - } finally { - setIsHitting(false); - } - }, [hit, waitForTransaction, fetchStamina, playHitSound]); + hit() + .then((hash) => { + const url = txUrl(hash); + if (url) { + notifyInfo( + React.createElement(ExplorerToast, { + url: url, + text: "Sent punch tx: ", + hash: hash, + }), + ); + } else { + notifyInfo(`Sent punch tx: ${hash}`); + } + if (clownStamina && clownStamina > 0) { + setPunchCount((prev) => { + const newCount = Math.min(prev + 1, 3); + return newCount; + }); + } + return waitForTransaction(hash); + }) + .then((receipt) => { + if (receipt.status === "success") { + notifySuccess("Punch successful"); + // Re-read stamina from contract after successful hit + fetchGameRounds(); + } else { + notifyError("Punch failed"); + } + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + notifyError(`Error punching clown: ${message}`); + }) + .finally(() => { + setIsHitting(false); + }); + }; - const handleReset = useCallback(async () => { - setIsResetting(true); - try { - playResetSound(); - const txHash = await reset(); - await waitForTransaction(txHash); - setPunchCount(0); - setRobResult(null); - await fetchStamina(); - } finally { - setIsResetting(false); + const handleReset = async () => { + playReset(); + if (!loaded || isResetting) return; + if (clownStamina !== 0) { + notifyError("Clown must be KO to reset"); + return; } - }, [reset, waitForTransaction, fetchStamina, playResetSound]); + setIsResetting(true); + reset() + .then((hash) => { + const url = txUrl(hash); + if (url) { + notifyInfo( + React.createElement(ExplorerToast, { + url: url, + text: "Sent reset tx: ", + hash: hash, + }), + ); + } else { + notifyInfo(`Sent reset tx: ${hash}`); + } + setPunchCount(0); + return waitForTransaction(hash); + }) + .then((receipt) => { + if (receipt.status === "success") { + notifySuccess("Reset successful"); + setRobResult(null); + // Re-read stamina from contract after successful reset + fetchGameRounds(); + } else { + notifyError("Reset failed"); + } + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + notifyError(`Error resetting clown: ${message}`); + }) + .finally(() => { + setIsResetting(false); + }); + }; - const handleRob = useCallback(async () => { + const handleRob = async () => { + playRob(); + if (!loaded || isRobbing) return; setIsRobbing(true); - try { - playRobSound(); - const result = await rob(); - setRobResult(result); - } finally { - setIsRobbing(false); - } - }, [rob, playRobSound]); + rob() + .then((result) => { + setRobResult(result); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + notifyError(`Error robbing clown: ${message}`); + }) + .finally(() => { + setIsRobbing(false); + }); + }; return { loaded, clownStamina, + currentRoundId, isHitting, isResetting, isRobbing, robResult, punchCount, + fetchGameRounds, + resetGameState, handleHit, handleReset, handleRob, - setRobResult, }; -} +}; ``` This hook manages the full game lifecycle: -- **`handleHit`** — sends a shielded write, waits for the transaction receipt, increments the punch count (for sprite animation), and refetches stamina -- **`handleReset`** — sends a shielded write to reset the clown, clears the punch count and rob result, and refetches stamina -- **`handleRob`** — performs a signed read to decrypt and reveal a secret from the clown's pool +- **`fetchGameRounds`** — reads the current stamina from the contract via `tread.getClownStamina()` +- **`handleHit`** — sends a shielded write via `twrite.hit()`, shows toast notifications with explorer links, waits for the receipt, increments punch count, and refetches stamina +- **`handleReset`** — validates the clown is KO, sends a shielded write via `twrite.reset()`, clears punch count and rob result, and refetches stamina +- **`handleRob`** — performs a signed read via `read.rob()` to decrypt and reveal a secret from the clown's pool +- **`resetGameState`** — clears the rob result and punch count when the round changes diff --git a/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-3-game-ui-components.md b/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-3-game-ui-components.md index fd5899d..bdd2abe 100644 --- a/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-3-game-ui-components.md +++ b/docs/gitbook/tutorials/clown-beatdown/building-the-frontend/chapter-3-game-ui-components.md @@ -11,44 +11,99 @@ In this chapter, you'll build the game interface — the clown sprite with punch The clown sprite changes appearance based on how many times it's been hit. Create `src/components/game/ShowClown.tsx`: ```typescript -import { motion } from 'framer-motion' +import { motion, useAnimation } from 'framer-motion' +import { useEffect, useMemo } from 'react' -interface ShowClownProps { +import { Box } from '@mui/material' + +type ClownProps = { isKO: boolean isShakingAnimation: boolean isHittingAnimation: boolean punchCount: number } -export default function ShowClown({ +const ShowClown: React.FC = ({ isKO, isShakingAnimation, isHittingAnimation, punchCount, -}: ShowClownProps) { - // Select sprite based on game state - const getClownImage = () => { - if (isKO) return '/clownko.png' - if (punchCount >= 2) return '/clown3.png' - if (punchCount === 1) return '/clown2.png' - return '/clown1.png' - } +}) => { + const controls = useAnimation() + + useEffect(() => { + if (isShakingAnimation) { + controls.start({ + rotate: [0, -5, 5, -5, 5, 0], + transition: { duration: 0.5 }, + }) + } else if (isHittingAnimation) { + controls.start({ + scale: [1, 0.9, 1.1, 1], + transition: { duration: 0.3 }, + }) + } + }, [isShakingAnimation, isHittingAnimation, controls]) + + // Select the appropriate clown image based on punch count and KO state + // Using useMemo to prevent recalculating on every render + const clownImage = useMemo(() => { + // If shaking, show the shaking clown image + if (isShakingAnimation) { + return '/clown_shaking.png' + } + + if (isKO) { + return '/clownko.png' + } + + let imagePath + switch (punchCount) { + case 0: + imagePath = '/clown1.png' + break + case 1: + imagePath = '/clown2.png' + break + case 2: + case 3: + imagePath = '/clown3.png' + break + default: + imagePath = '/clown1.png' + } + + return imagePath + }, [isKO, isShakingAnimation, punchCount]) return ( - + > +
+ + Clown + +
+ ) } + +export default ShowClown ``` The sprite progression creates visual feedback as the clown takes damage: @@ -57,8 +112,9 @@ The sprite progression creates visual feedback as the clown takes damage: - **1 hit** — `clown2.png` (damaged) - **2–3 hits** — `clown3.png` (heavily damaged) - **KO** — `clownko.png` (knocked out) +- **Shaking** — `clown_shaking.png` (mid-animation) -Framer Motion handles two animations: a **shake** (rotation) when the clown gets hit, and a **scale punch** effect for impact feedback. +Framer Motion's `useAnimation` hook controls two animations: a **shake** (rotation) when the clown gets hit, and a **scale punch** effect for impact feedback. ### ButtonContainer: Action buttons @@ -67,207 +123,495 @@ The button container renders the game's action buttons — hit, rob, and reset. ```typescript import { useState } from 'react' -interface ButtonContainerProps { - position: 'left' | 'right' | 'mobile' +import { Box, type SxProps, type Theme } from '@mui/material' + +type ButtonContainerProps = { clownStamina: number | null isHitting: boolean - isRobbing: boolean isResetting: boolean - onHit: () => void - onRob: () => void - onReset: () => void + isRobbing: boolean + handleHit: () => void + handleReset: () => void + handleRob: () => void + position?: 'left' | 'right' | 'mobile' } -function ActionButton({ - onClick, - defaultImg, - activeImg, - isActive, - alt, -}: { +type ActionButtonProps = { onClick: () => void - defaultImg: string - activeImg: string - isActive: boolean + active: boolean + src: string alt: string -}) { - const [pressed, setPressed] = useState(false) + className: string + sx?: SxProps +} - return ( +const ActionButton = ({ + onClick, + active, + src, + alt, + className, + sx, +}: ActionButtonProps) => ( + {alt} setPressed(true)} - onMouseUp={() => setTimeout(() => setPressed(false), 200)} - style={{ - cursor: 'pointer', - width: '12rem', - userSelect: 'none', - }} + className={className} + style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> - ) -} + +) export default function ButtonContainer({ - position, clownStamina, isHitting, - isRobbing, isResetting, - onHit, - onRob, - onReset, + isRobbing, + handleHit, + handleReset, + handleRob, + position = 'mobile', }: ButtonContainerProps) { - const isKO = clownStamina === 0 + const [showRobActive, setShowRobActive] = useState(false) + const [showResetActive, setShowResetActive] = useState(false) + + const handleRobClick = () => { + if (!isRobbing) { + setShowRobActive(true) + setTimeout(() => { + setShowRobActive(false) + handleRob() + }, 200) + } + } + + const handleResetClick = () => { + if (!isResetting) { + setShowResetActive(true) + setTimeout(() => { + setShowResetActive(false) + handleReset() + }, 200) + } + } + + const isStanding = clownStamina !== null && clownStamina > 0 + + const robBtn = { + onClick: handleRobClick, + active: isRobbing, + src: showRobActive ? '/rob_active.png' : '/rob_btn.png', + alt: 'Rob', + className: 'look-btn', + } + + const hitBtn = { + onClick: handleHit, + active: isHitting, + src: isHitting ? '/punch_active.png' : '/punch_btn.png', + alt: 'Punch', + className: 'punch-btn', + } + + const resetBtn = { + onClick: handleResetClick, + active: isResetting, + src: showResetActive ? '/reset_active.png' : '/reset_btn.png', + alt: 'Reset', + className: 'reset-btn', + } - if (position === 'left' || (position === 'mobile' && !isKO)) { - // Rob button (left side on desktop, shown on mobile when standing) + const rightBtn = isStanding ? hitBtn : resetBtn + + if (position === 'left') { return ( - + + + ) } - // Right side: Hit when standing, Reset when KO - if (isKO) { + if (position === 'right') { return ( - + + + ) } + // Mobile layout — both buttons side by side + const MOBILE_SIZE = { + xs: '12rem', + sm: '20rem', + md: '20rem', + lg: '30rem', + xl: '30rem', + } + return ( - + + + + ) } ``` -The button layout adapts based on the game state: +The button layout adapts based on game state and screen size: -- **Clown standing** — show Hit button (right) and Rob button (left, will revert if called) -- **Clown KO** — show Reset button (right) and Rob button (left, now callable) +- **Desktop** — rob button on the left, hit/reset on the right +- **Mobile** — both buttons side by side below the clown +- **Clown standing** — show Hit button (right) +- **Clown KO** — show Reset button (right), Rob button now callable (left) ### ClownPuncher: Main game component This component ties everything together. Create `src/components/game/ClownPuncher.tsx`: ```typescript -import { Box, CircularProgress, Typography } from '@mui/material' +'use client' + +import { useEffect, useRef, useState } from 'react' + +import { useGameActions } from '@/hooks/useGameActions' +import { + Backdrop, + Box, + CircularProgress, + Container, + Fade, + Typography, +} from '@mui/material' import { useAuth } from '../chain/WalletConnectButton' import ButtonContainer from './ButtonContainer' +import EntryScreen from './EntryScreen' import ShowClown from './ShowClown' -import { useGameActions } from '@/hooks/useGameActions' -export default function ClownPuncher() { +const ClownPuncher: React.FC = () => { const { isAuthenticated } = useAuth() + const [showGame, setShowGame] = useState(false) + const [showSecretSplash, setShowSecretSplash] = useState(false) + const [showRobRefused, setShowRobRefused] = useState(false) + const prevRoundIdRef = useRef(null) const { loaded, + currentRoundId, clownStamina, isHitting, isResetting, isRobbing, robResult, punchCount, + fetchGameRounds, + resetGameState, handleHit, handleReset, handleRob, - setRobResult, } = useGameActions() - if (!isAuthenticated) { - return + useEffect(() => { + // Only fetch data if authenticated and game is shown + if (isAuthenticated && showGame) { + fetchGameRounds() + } + }, [fetchGameRounds, isAuthenticated, showGame]) + + useEffect(() => { + // Only reset game state when first showing the game or when the round actually changes + if ( + showGame && + (prevRoundIdRef.current === null || + (currentRoundId !== null && prevRoundIdRef.current !== currentRoundId)) + ) { + console.log( + 'Round changed from', + prevRoundIdRef.current, + 'to', + currentRoundId, + '- resetting game state' + ) + resetGameState() + } + // Update the ref to the current round ID + prevRoundIdRef.current = currentRoundId + }, [currentRoundId, resetGameState, showGame]) + + // Show splash screen when lookResult changes to a non-null value + useEffect(() => { + if (robResult !== null) { + setShowSecretSplash(true) + } + }, [robResult]) + + // If not showing the game yet, show entry screen + if (!showGame) { + return setShowGame(true)} /> } - if (!loaded || clownStamina === null) { - return + const onRob = () => { + if (clownStamina !== null && clownStamina > 0) { + setShowRobRefused(true) + return + } + handleRob() } - const isKO = clownStamina === 0 + const buttonProps = { + clownStamina, + isHitting, + isResetting, + isRobbing, + handleHit, + handleReset, + handleRob: onRob, + } as const return ( - - {/* Secret revealed splash */} - {robResult && ( - setRobResult(null)} - sx={{ - position: 'fixed', - inset: 0, - zIndex: 50, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - bgcolor: 'rgba(0,0,0,0.85)', - cursor: 'pointer', - }} - > - SECRET REVEALED! - - {robResult} - - - )} - - {/* Game layout */} + - - - - - - + + {/* Splash Screen — secret revealed or rob refused */} + theme.zIndex.drawer + 1, + backgroundColor: 'rgba(0, 0, 0, 0.85)', + }} + open={(showSecretSplash && robResult !== null) || showRobRefused} + onClick={() => { + setShowSecretSplash(false) + setShowRobRefused(false) + }} + > + + + {showRobRefused ? ( + <> + + NOT SO FAST! + + + The clown isn't giving up that easily. + + + Knock him out first! + + + ) : ( + <> + + SECRET REVEALED! + + + {robResult} + + + )} + + (Click anywhere to close) + + + + + + {loaded ? ( + + {/* Desktop: left buttons */} + + + + + {/* Clown — rendered once, responsive positioning */} + + + + + {/* Desktop: right buttons */} + + + + + {/* Mobile: all buttons below clown */} + + + + + ) : ( + + )} + ) } + +export default ClownPuncher ``` ### EntryScreen: Wallet connection @@ -275,87 +619,226 @@ export default function ClownPuncher() { The entry screen prompts the user to connect their wallet before playing. Create `src/components/game/EntryScreen.tsx`: ```typescript -import { Box, Typography } from '@mui/material' +import React, { useEffect, useState } from 'react' + +import { Box, Container } from '@mui/material' import { useAuth } from '../chain/WalletConnectButton' -export default function EntryScreen() { - const { isLoading, openConnectModal } = useAuth() +type EntryScreenProps = { + onEnter: () => void +} + +const EntryScreen: React.FC = ({ onEnter }) => { + const { isAuthenticated, isLoading, openConnectModal } = useAuth() + const [isAnimating, setIsAnimating] = useState(false) + + // Automatically enter when user becomes authenticated + useEffect(() => { + if (isAuthenticated) { + setIsAnimating(true) + setTimeout(() => { + setIsAnimating(false) + onEnter() + }, 500) + } + }, [isAuthenticated, onEnter]) + + const handleLogoClick = () => { + setIsAnimating(true) + + // If not authenticated, open wallet connect modal + if (!isAuthenticated) { + setTimeout(() => { + setIsAnimating(false) + openConnectModal() + }, 300) + } + } return ( - - Clown Beatdown - - {isLoading ? '...Loading...' : 'CLICK TO CONNECT'} - - + + Clown Beatdown Logo + + {isLoading ? '...Loading...' : 'CLICK TO CONNECT'} + + + ) } + +export default EntryScreen ``` -Clicking anywhere on the entry screen opens the RainbowKit wallet connection modal. Once authenticated, the `ClownPuncher` component takes over. +Clicking the logo opens the RainbowKit wallet connection modal. Once authenticated, the `onEnter` callback fires and the `ClownPuncher` component takes over. ### WalletConnectButton: Auth context Create the auth context and wallet button used throughout the app. Create `src/components/chain/WalletConnectButton.tsx`: ```typescript -import { createContext, useContext, useState, useEffect } from 'react' +import React, { createContext, useContext, useEffect, useState } from 'react' import { useAccount } from 'wagmi' + +import { ConnectButton } from '@rainbow-me/rainbowkit' import { useConnectModal } from '@rainbow-me/rainbowkit' -interface AuthContextType { +// Create authentication context +type AuthContextType = { isAuthenticated: boolean isLoading: boolean - openConnectModal: (() => void) | undefined - accountName: string | null + openConnectModal: () => void + accountName?: string } const AuthContext = createContext({ isAuthenticated: false, isLoading: true, - openConnectModal: undefined, - accountName: null, + openConnectModal: () => {}, }) export const useAuth = () => useContext(AuthContext) -export function AuthProvider({ children }: { children: React.ReactNode }) { - const { address, isConnected } = useAccount() - const { openConnectModal } = useConnectModal() - const [isLoading, setIsLoading] = useState(true) +// Wallet icon component using SVG for better quality +const WalletIcon = () => ( + + + +) + +const WalletButton: React.FC< + React.PropsWithChildren< + { onClick: () => void } & React.HTMLAttributes + > +> = ({ children, onClick, ...props }) => { + return ( + + ) +} + +export const AuthProvider: React.FC = ({ + children, +}) => { + const { openConnectModal } = useConnectModal() || { + openConnectModal: () => {}, + } + const { address, isConnecting, isConnected, isDisconnected } = useAccount() + const [authState, setAuthState] = useState({ + isAuthenticated: false, + isLoading: true, + openConnectModal: openConnectModal || (() => {}), + }) useEffect(() => { - setIsLoading(false) - }, [address]) + setAuthState({ + isAuthenticated: isConnected, + isLoading: isConnecting, + openConnectModal: openConnectModal || (() => {}), + accountName: address + ? `${address.slice(0, 6)}...${address.slice(-4)}` + : undefined, + }) + }, [isConnected, isConnecting, isDisconnected, address, openConnectModal]) - const accountName = address - ? `${address.slice(0, 6)}...${address.slice(-4)}` - : null + return ( + {children} + ) +} +const WalletConnectButton = () => { return ( - + {({ + account, openConnectModal, - accountName, + chain, + openAccountModal, + openChainModal, + mounted, + authenticationStatus, + }) => { + if (!mounted || authenticationStatus === 'loading') { + return <> + } + if (!account || authenticationStatus === 'unauthenticated') { + return ( + + CONNECT WALLET + + + + + ) + } + if (chain?.unsupported) { + return ( + + Unsupported chain + + + + + ) + } + return ( + + + + + + ) }} - > - {children} - + ) } + +export default WalletConnectButton ``` ### Running the frontend