diff --git a/.changeset/hip-pugs-tease.md b/.changeset/hip-pugs-tease.md new file mode 100644 index 0000000000..671b945b3a --- /dev/null +++ b/.changeset/hip-pugs-tease.md @@ -0,0 +1,5 @@ +--- +"create-mud": patch +--- + +Updated React ECS template with EntryKit for wallet support and a cleaned up app structure. diff --git a/packages/store-sync/src/stash/common.ts b/packages/store-sync/src/SyncProgress.ts similarity index 85% rename from packages/store-sync/src/stash/common.ts rename to packages/store-sync/src/SyncProgress.ts index 1aee0ceec2..49e0eb41b2 100644 --- a/packages/store-sync/src/stash/common.ts +++ b/packages/store-sync/src/SyncProgress.ts @@ -1,7 +1,13 @@ import { defineTable } from "@latticexyz/store/internal"; -import { SyncStep } from "../SyncStep"; import { getSchemaPrimitives, getValueSchema } from "@latticexyz/protocol-parser/internal"; +export enum SyncStep { + INITIALIZE = "initialize", + SNAPSHOT = "snapshot", + RPC = "rpc", + LIVE = "live", +} + export const SyncProgress = defineTable({ namespaceLabel: "syncToStash", label: "SyncProgress", diff --git a/packages/store-sync/src/index.ts b/packages/store-sync/src/index.ts index 7c68aeb447..fb3378545d 100644 --- a/packages/store-sync/src/index.ts +++ b/packages/store-sync/src/index.ts @@ -1,7 +1,7 @@ export * from "./common"; export * from "./configToTables"; export * from "./createStoreSync"; -export * from "./SyncStep"; +export * from "./SyncProgress"; export * from "./isTableRegistrationLog"; export * from "./logToTable"; export * from "./tablesWithRecordsToLogs"; diff --git a/packages/store-sync/src/recs/createSyncAdapter.ts b/packages/store-sync/src/recs/createSyncAdapter.ts index 697e5bd186..ee7cf93d70 100644 --- a/packages/store-sync/src/recs/createSyncAdapter.ts +++ b/packages/store-sync/src/recs/createSyncAdapter.ts @@ -48,6 +48,9 @@ export function createSyncAdapter({ latestBlockNumber, lastBlockNumberProcessed, message, + __staticData: undefined, + __encodedLengths: undefined, + __dynamicData: undefined, }); // when we switch to live, trigger update for all entities in all components diff --git a/packages/store-sync/src/recs/defineInternalComponents.ts b/packages/store-sync/src/recs/defineInternalComponents.ts index 4077917d57..691a398aee 100644 --- a/packages/store-sync/src/recs/defineInternalComponents.ts +++ b/packages/store-sync/src/recs/defineInternalComponents.ts @@ -1,19 +1,13 @@ -import { World, defineComponent, Type, Component, Schema, Metadata } from "@latticexyz/recs"; +import { World } from "@latticexyz/recs"; +import { tablesToComponents } from "./tablesToComponents"; +import { SyncProgress } from "../SyncProgress"; -export type InternalComponents = ReturnType; +export type InternalComponents = tablesToComponents<{ + SyncProgress: typeof SyncProgress; +}>; -export function defineInternalComponents(world: World) { - return { - SyncProgress: defineComponent( - world, - { - step: Type.String, - message: Type.String, - percentage: Type.Number, - latestBlockNumber: Type.BigInt, - lastBlockNumberProcessed: Type.BigInt, - }, - { metadata: { componentName: "SyncProgress" } }, - ), - } as const satisfies Record>; +export function defineInternalComponents(world: World): InternalComponents { + return tablesToComponents(world, { + SyncProgress, + }); } diff --git a/packages/store-sync/src/recs/syncToRecs.ts b/packages/store-sync/src/recs/syncToRecs.ts index e1b031de53..f5ff8b3e59 100644 --- a/packages/store-sync/src/recs/syncToRecs.ts +++ b/packages/store-sync/src/recs/syncToRecs.ts @@ -52,6 +52,9 @@ export async function syncToRecs + - a minimal MUD client + a MUD app
diff --git a/templates/react-ecs/packages/client/package.json b/templates/react-ecs/packages/client/package.json index dce94cb83e..8a1017262f 100644 --- a/templates/react-ecs/packages/client/package.json +++ b/templates/react-ecs/packages/client/package.json @@ -6,32 +6,39 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "wait-port localhost:8545 && vite", + "dev": "vite", "preview": "vite preview", "test": "tsc --noEmit" }, "dependencies": { "@latticexyz/common": "link:../../../../packages/common", - "@latticexyz/dev-tools": "link:../../../../packages/dev-tools", + "@latticexyz/entrykit": "link:../../../../packages/entrykit", + "@latticexyz/explorer": "link:../../../../packages/explorer", "@latticexyz/react": "link:../../../../packages/react", "@latticexyz/recs": "link:../../../../packages/recs", "@latticexyz/schema-type": "link:../../../../packages/schema-type", "@latticexyz/store-sync": "link:../../../../packages/store-sync", "@latticexyz/utils": "link:../../../../packages/utils", "@latticexyz/world": "link:../../../../packages/world", + "@tanstack/react-query": "^5.63.0", "contracts": "workspace:*", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "rxjs": "7.5.5", - "viem": "2.21.19" + "react": "18.2.0", + "react-dom": "18.2.0", + "react-error-boundary": "5.0.0", + "tailwind-merge": "^2.6.0", + "viem": "2.21.19", + "wagmi": "2.12.11" }, "devDependencies": { "@types/react": "18.2.22", "@types/react-dom": "18.2.7", - "@vitejs/plugin-react": "^3.1.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", "eslint-plugin-react": "7.31.11", "eslint-plugin-react-hooks": "4.6.0", - "vite": "^4.2.1", - "wait-port": "^1.0.4" + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7", + "vite-plugin-mud": "link:../../../../packages/vite-plugin-mud" } } diff --git a/templates/react-ecs/packages/client/postcss.config.cjs b/templates/react-ecs/packages/client/postcss.config.cjs new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/templates/react-ecs/packages/client/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/templates/react-ecs/packages/client/src/App.tsx b/templates/react-ecs/packages/client/src/App.tsx index a9660de463..0d4237aadc 100644 --- a/templates/react-ecs/packages/client/src/App.tsx +++ b/templates/react-ecs/packages/client/src/App.tsx @@ -1,29 +1,74 @@ -import { useComponentValue } from "@latticexyz/react"; -import { useMUD } from "./MUDContext"; -import { singletonEntity } from "@latticexyz/store-sync/recs"; +import { AccountButton } from "@latticexyz/entrykit/internal"; +import { Direction, Entity } from "./common"; +import mudConfig from "contracts/mud.config"; +import { useMemo } from "react"; +import { GameMap } from "./game/GameMap"; +import { useWorldContract } from "./mud/useWorldContract"; +import { Synced } from "./mud/Synced"; +import { useSync } from "@latticexyz/store-sync/react"; +import { components } from "./mud/recs"; +import { useEntityQuery } from "@latticexyz/react"; +import { Has, getComponentValueStrict } from "@latticexyz/recs"; +import { Address } from "viem"; -export const App = () => { - const { - components: { Counter }, - systemCalls: { increment }, - } = useMUD(); +export function App() { + const playerEntities = useEntityQuery([Has(components.Owner), Has(components.Position)]); + const players = useMemo( + () => + playerEntities.map((entity) => { + const owner = getComponentValueStrict(components.Owner, entity); + const position = getComponentValueStrict(components.Position, entity); + return { + entity: entity as Entity, + owner: owner.owner as Address, + x: position.x, + y: position.y, + }; + }), + [playerEntities], + ); + + const sync = useSync(); + const worldContract = useWorldContract(); + + const onMove = useMemo( + () => + sync.data && worldContract + ? async (entity: Entity, direction: Direction) => { + const tx = await worldContract.write.app__move([entity, mudConfig.enums.Direction.indexOf(direction)]); + await sync.data.waitForTransaction(tx); + } + : undefined, + [sync.data, worldContract], + ); - const counter = useComponentValue(Counter, singletonEntity); + const onSpawn = useMemo( + () => + sync.data && worldContract + ? async () => { + const tx = await worldContract.write.app__spawn(); + await sync.data.waitForTransaction(tx); + } + : undefined, + [sync.data, worldContract], + ); return ( <> -
- Counter: {counter?.value ?? "??"} +
+ ( +
+ {message} ({percentage.toFixed(1)}%)… +
+ )} + > + +
+
+
+
- ); -}; +} diff --git a/templates/react-ecs/packages/client/src/MUDContext.tsx b/templates/react-ecs/packages/client/src/MUDContext.tsx deleted file mode 100644 index 7b5637f6a6..0000000000 --- a/templates/react-ecs/packages/client/src/MUDContext.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, ReactNode, useContext } from "react"; -import { SetupResult } from "./mud/setup"; - -const MUDContext = createContext(null); - -type Props = { - children: ReactNode; - value: SetupResult; -}; - -export const MUDProvider = ({ children, value }: Props) => { - const currentValue = useContext(MUDContext); - if (currentValue) throw new Error("MUDProvider can only be used once"); - return {children}; -}; - -export const useMUD = () => { - const value = useContext(MUDContext); - if (!value) throw new Error("Must be used within a MUDProvider"); - return value; -}; diff --git a/templates/react-ecs/packages/client/src/Providers.tsx b/templates/react-ecs/packages/client/src/Providers.tsx new file mode 100644 index 0000000000..80ac80df1e --- /dev/null +++ b/templates/react-ecs/packages/client/src/Providers.tsx @@ -0,0 +1,29 @@ +import { WagmiProvider } from "wagmi"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { ReactNode } from "react"; +import { SyncProvider } from "@latticexyz/store-sync/react"; +import { defineConfig, EntryKitProvider } from "@latticexyz/entrykit/internal"; +import { wagmiConfig } from "./wagmiConfig"; +import { chainId, getWorldAddress, startBlock } from "./common"; +import { syncAdapter } from "./mud/recs"; + +const queryClient = new QueryClient(); + +export type Props = { + children: ReactNode; +}; + +export function Providers({ children }: Props) { + const worldAddress = getWorldAddress(); + return ( + + + + + {children} + + + + + ); +} diff --git a/templates/react-ecs/packages/client/src/common.ts b/templates/react-ecs/packages/client/src/common.ts new file mode 100644 index 0000000000..217ed8518f --- /dev/null +++ b/templates/react-ecs/packages/client/src/common.ts @@ -0,0 +1,27 @@ +import mudConfig from "contracts/mud.config"; +import { chains } from "./wagmiConfig"; +import { Chain, Hex } from "viem"; + +export const chainId = import.meta.env.CHAIN_ID; +export const worldAddress = import.meta.env.WORLD_ADDRESS; +export const startBlock = import.meta.env.START_BLOCK; + +export const url = new URL(window.location.href); + +export type Entity = Hex; +export type Direction = (typeof mudConfig.enums.Direction)[number]; + +export function getWorldAddress() { + if (!worldAddress) { + throw new Error("No world address configured. Is the world still deploying?"); + } + return worldAddress; +} + +export function getChain(): Chain { + const chain = chains.find((c) => c.id === chainId); + if (!chain) { + throw new Error(`No chain configured for chain ID ${chainId}.`); + } + return chain; +} diff --git a/templates/react-ecs/packages/client/src/game/GameMap.tsx b/templates/react-ecs/packages/client/src/game/GameMap.tsx new file mode 100644 index 0000000000..139a8c9b3a --- /dev/null +++ b/templates/react-ecs/packages/client/src/game/GameMap.tsx @@ -0,0 +1,112 @@ +import { serialize, useAccount } from "wagmi"; +import { useKeyboardMovement } from "./useKeyboardMovement"; +import { Address, Hex, hexToBigInt, keccak256 } from "viem"; +import { ArrowDownIcon } from "../ui/icons/ArrowDownIcon"; +import { twMerge } from "tailwind-merge"; +import { Direction, Entity } from "../common"; +import mudConfig from "contracts/mud.config"; +import { AsyncButton } from "../ui/AsyncButton"; +import { useAccountModal } from "@latticexyz/entrykit/internal"; +import { useMemo } from "react"; + +export type Props = { + readonly players?: { + readonly entity: Entity; + readonly owner: Address; + readonly x: number; + readonly y: number; + }[]; + readonly onMove?: (entity: Entity, direction: Direction) => Promise; + readonly onSpawn?: () => Promise; +}; + +const size = 40; +const scale = 100 / size; + +function getColorAngle(seed: Hex) { + return Number(hexToBigInt(keccak256(seed)) % 360n); +} + +const rotateClassName = { + North: "rotate-0", + East: "rotate-90", + South: "rotate-180", + West: "-rotate-90", +} as const satisfies Record; + +export function GameMap({ players = [], onMove, onSpawn }: Props) { + const { openAccountModal } = useAccountModal(); + const { address: userAddress } = useAccount(); + + const currentPlayer = players.find((player) => player.owner.toLowerCase() === userAddress?.toLowerCase()); + + useKeyboardMovement( + useMemo( + () => (onMove && currentPlayer ? (direction: Direction) => onMove(currentPlayer.entity, direction) : undefined), + [currentPlayer, onMove], + ), + ); + + return ( +
+
+ {currentPlayer && onMove + ? mudConfig.enums.Direction.map((direction) => ( + + )) + : null} + + {players.map((player) => ( +
+ {player === currentPlayer ?
: null} +
+ ))} + + {!currentPlayer ? ( + onSpawn ? ( +
+ onSpawn()} + > + Spawning… + +
+ ) : ( +
+ +
+ ) + ) : null} +
+
+ ); +} diff --git a/templates/react-ecs/packages/client/src/game/useKeyboardMovement.ts b/templates/react-ecs/packages/client/src/game/useKeyboardMovement.ts new file mode 100644 index 0000000000..7e9074290f --- /dev/null +++ b/templates/react-ecs/packages/client/src/game/useKeyboardMovement.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { Direction } from "../common"; + +const keys = new Map([ + ["ArrowUp", "North"], + ["ArrowRight", "East"], + ["ArrowDown", "South"], + ["ArrowLeft", "West"], +]); + +export const useKeyboardMovement = (move: undefined | ((direction: Direction) => void)) => { + useEffect(() => { + if (!move) return; + + const listener = (event: KeyboardEvent) => { + const direction = keys.get(event.key); + if (direction == null) return; + + event.preventDefault(); + move(direction); + }; + + window.addEventListener("keydown", listener); + return () => window.removeEventListener("keydown", listener); + }, [move]); +}; diff --git a/templates/react-ecs/packages/client/src/index.tsx b/templates/react-ecs/packages/client/src/index.tsx index 1b005dc561..995e6ec9fd 100644 --- a/templates/react-ecs/packages/client/src/index.tsx +++ b/templates/react-ecs/packages/client/src/index.tsx @@ -1,34 +1,19 @@ -import ReactDOM from "react-dom/client"; +import "tailwindcss/tailwind.css"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { Providers } from "./Providers"; import { App } from "./App"; -import { setup } from "./mud/setup"; -import { MUDProvider } from "./MUDContext"; -import mudConfig from "contracts/mud.config"; +import { Explorer } from "./mud/Explorer"; +import { ErrorBoundary } from "react-error-boundary"; +import { ErrorFallback } from "./ui/ErrorFallback"; -const rootElement = document.getElementById("react-root"); -if (!rootElement) throw new Error("React root not found"); -const root = ReactDOM.createRoot(rootElement); - -// TODO: figure out if we actually want this to be async or if we should render something else in the meantime -setup().then(async (result) => { - root.render( - - - , - ); - - // https://vitejs.dev/guide/env-and-mode.html - if (import.meta.env.DEV) { - const { mount: mountDevTools } = await import("@latticexyz/dev-tools"); - mountDevTools({ - config: mudConfig, - publicClient: result.network.publicClient, - walletClient: result.network.walletClient, - latestBlock$: result.network.latestBlock$, - storedBlockLogs$: result.network.storedBlockLogs$, - worldAddress: result.network.worldContract.address, - worldAbi: result.network.worldContract.abi, - write$: result.network.write$, - recsWorld: result.network.world, - }); - } -}); +createRoot(document.getElementById("react-root")!).render( + + + + + + + + , +); diff --git a/templates/react-ecs/packages/client/src/mud/Explorer.tsx b/templates/react-ecs/packages/client/src/mud/Explorer.tsx new file mode 100644 index 0000000000..fe59d58c57 --- /dev/null +++ b/templates/react-ecs/packages/client/src/mud/Explorer.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { getChain, getWorldAddress } from "../common"; +import { MUDIcon } from "../ui/icons/MUDIcon"; + +export function Explorer() { + const [open, setOpen] = useState(false); + + const chain = getChain(); + const worldAddress = getWorldAddress(); + + const explorerUrl = chain.blockExplorers?.worldsExplorer?.url; + if (!explorerUrl) return null; + + return ( +
+ + {open ?