Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LIttle nicety since this expects an older Node version than the curren stable. It unfortunately doesn't work with the newer node (I checked), something about asserts. Most likely this go away if the packages are updated, which I have not done here (but can do).

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v18.20.4
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
"typescript": "5.4.2"
},
"engines": {
"node": "18.x",
"pnpm": "8.x"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works perfectly fine with the latest pnpm version, so removed this to avoid further usage headaches.

"node": "18.x"
}
}
1 change: 1 addition & 0 deletions packages/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Emojimon</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤠</text></svg>" />
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoids console error when doing favicon.ico lookup. Simply uses 🤠 as favicon.

</head>
<body class="bg-black text-white">
<div id="react-root"></div>
Expand Down
54 changes: 29 additions & 25 deletions packages/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,35 @@
import { useComponentValue } from "@latticexyz/react";
import { SyncStep } from "@latticexyz/store-sync";
import { singletonEntity } from "@latticexyz/store-sync/recs";
import { useMUD } from "./MUDContext";
import { GameBoard } from "./GameBoard";
import { useState } from "react";
import { ToastContainer } from "react-toastify";
import { LoadingWrapper } from "./LoadingWrapper";
import { createBurnerWalletClient } from "./mud/account";
import { useDevTools } from "./mud/devTools";
import { useFaucet } from "./mud/faucet";
import { useSetup } from "./mud/setup";
import { WalletClientWithAccount } from "./mud/types";
import { MUDProvider } from "./MUDContext";

const walletClient = createBurnerWalletClient()

export const App = () => {
const {
components: { SyncProgress },
} = useMUD();
const [wallet, setWallet] = useState<WalletClientWithAccount | undefined>();
const mud = useSetup(wallet);
useFaucet(wallet?.account.address);
useDevTools(mud);

const loadingState = useComponentValue(SyncProgress, singletonEntity, {
step: SyncStep.INITIALIZE,
message: "Connecting",
percentage: 0,
latestBlockNumber: 0n,
lastBlockNumberProcessed: 0n,
});
const connect = () => setWallet(walletClient);
const disconnect = () => setWallet(undefined);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obviously silly here since we have the burner from the get-go, but any login/wallet system can inject its own wallet client.


return (
<div className="w-screen h-screen flex items-center justify-center">
{loadingState.step !== SyncStep.LIVE ? (
<div>
{loadingState.message} ({loadingState.percentage.toFixed(2)}%)
return mud
? <MUDProvider value={mud}>
<div style={{ position: "absolute", top: "0", right: "0", padding: "20px" }}>
{!wallet
? <button onClick={connect} style={{ cursor: "pointer" }}>Connect</button>
: <button onClick={disconnect} style={{ cursor: "pointer" }}>Disconnect</button>}
</div>
) : (
<GameBoard />
)}
</div>
);
<LoadingWrapper/>;
<ToastContainer position="bottom-right" draggable={false} theme="dark"/>
</MUDProvider>
: <div className="w-screen h-screen flex items-center justify-center">
<div>Setting up ...</div>
</div>
};
8 changes: 7 additions & 1 deletion packages/client/src/EncounterScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ type Props = {
monsterEmoji: string;
};

// Throw errors if we are somehow disconnected on the encounter screen.
const noopActions = {
throwBall: async () => { throw Error("no user"); },
fleeEncounter: async () => { throw Error("no user"); },
};

export const EncounterScreen = ({ monsterName, monsterEmoji }: Props) => {
const {
systemCalls: { throwBall, fleeEncounter },
systemCalls: { throwBall, fleeEncounter } = noopActions,
} = useMUD();

const [appear, setAppear] = useState(false);
Expand Down
5 changes: 3 additions & 2 deletions packages/client/src/GameBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ export const GameBoard = () => {
const {
components: { Encounter, MapConfig, Monster, Player, Position },
network: { playerEntity },
systemCalls: { spawn },
systemCalls: { spawn } = {},
} = useMUD();

const canSpawn = useComponentValue(Player, playerEntity)?.value !== true;
const noPosition = useComponentValue(Player, playerEntity)?.value !== true;
const canSpawn = spawn && noPosition;

const players = useEntityQuery([Has(Player), Has(Position)]).map((entity) => {
const position = getComponentValueStrict(Position, entity);
Expand Down
31 changes: 31 additions & 0 deletions packages/client/src/LoadingWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useComponentValue } from "@latticexyz/react";
import { SyncStep } from "@latticexyz/store-sync";
import { singletonEntity } from "@latticexyz/store-sync/recs";
import { useMUD } from "./MUDContext";
import { GameBoard } from "./GameBoard";

export const LoadingWrapper = () => {
const {
components: { SyncProgress },
} = useMUD();

const loadingState = useComponentValue(SyncProgress, singletonEntity, {
step: SyncStep.INITIALIZE,
message: "Connecting",
percentage: 0,
latestBlockNumber: 0n,
lastBlockNumberProcessed: 0n,
});

return (
<div className="w-screen h-screen flex items-center justify-center">
{loadingState.step !== SyncStep.LIVE ? (
<div>
{loadingState.message} ({loadingState.percentage.toFixed(2)}%)
</div>
) : (
<GameBoard />
)}
</div>
);
};
6 changes: 3 additions & 3 deletions packages/client/src/MUDContext.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { createContext, ReactNode, useContext } from "react";
import { SetupResult } from "./mud/setup";
import { MUDInterface } from "./mud/types"

const MUDContext = createContext<SetupResult | null>(null);
const MUDContext = createContext<MUDInterface | null>(null);

type Props = {
children: ReactNode;
value: SetupResult;
value: MUDInterface;
};

export const MUDProvider = ({ children, value }: Props) => {
Expand Down
33 changes: 2 additions & 31 deletions packages/client/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,9 @@ import "tailwindcss/tailwind.css";
import "react-toastify/dist/ReactToastify.css";

import ReactDOM from "react-dom/client";
import { ToastContainer } from "react-toastify";
import { App } from "./App";
import { setup } from "./mud/setup";
import { MUDProvider } from "./MUDContext";
import mudConfig from "contracts/mud.config";
import { App } from "./App"

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(
<MUDProvider value={result}>
<App />
<ToastContainer position="bottom-right" draggable={false} theme="dark" />
</MUDProvider>
);

// 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,
});
}
});
root.render(<App />);
49 changes: 49 additions & 0 deletions packages/client/src/mud/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { networkConfig } from "./networkConfig";
import { ContractWrite, createBurnerAccount, transportObserver } from "@latticexyz/common";
import { transactionQueue, writeObserver } from "@latticexyz/common/actions";
import { Subject } from "rxjs";
import {
Account,
Address,
ClientConfig,
createPublicClient,
createWalletClient,
fallback,
Hex,
http,
Transport,
webSocket,
} from "viem";

const clientOptions = {
chain: networkConfig.chain,
transport: transportObserver(fallback([webSocket(), http()])),
pollingInterval: 1000,
} as const satisfies ClientConfig;

/*
* Create a viem public (read only) client
* (https://viem.sh/docs/clients/public.html)
*/
export const publicClient = createPublicClient(clientOptions);

/*
* Create an observable for contract writes that we can
* pass into MUD dev tools for transaction observability.
*/
export const write$ = new Subject<ContractWrite>();

export function createEmojimonWalletClient(account: Account | Address, transport?: Transport) {
return createWalletClient({
...clientOptions,
transport: transport ?? clientOptions.transport,
account,
})
.extend(transactionQueue())
.extend(writeObserver({ onWrite: (write) => write$.next(write) }));
}

export function createBurnerWalletClient() {
const burnerAccount = createBurnerAccount(networkConfig.burnerPrivateKey as Hex);
return createEmojimonWalletClient(burnerAccount);
}
4 changes: 2 additions & 2 deletions packages/client/src/mud/createClientComponents.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { overridableComponent } from "@latticexyz/recs";
import { SetupNetworkResult } from "./setupNetwork";
import { NetworkWithoutAccount } from "./setupNetwork"

export type ClientComponents = ReturnType<typeof createClientComponents>;

export function createClientComponents({ components }: SetupNetworkResult) {
export function createClientComponents({ components }: NetworkWithoutAccount) {
return {
...components,
Player: overridableComponent(components.Player),
Expand Down
10 changes: 8 additions & 2 deletions packages/client/src/mud/createSystemCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { Has, HasValue, getComponentValue, runQuery } from "@latticexyz/recs";
import { singletonEntity } from "@latticexyz/store-sync/recs";
import { uuid } from "@latticexyz/utils";
import { ClientComponents } from "./createClientComponents";
import { SetupNetworkResult } from "./setupNetwork";
import { type Network, WorldContractWrite } from "./setupNetwork"
import { Direction } from "../direction";
import { MonsterCatchResult } from "../monsterCatchResult";

export type SystemCalls = ReturnType<typeof createSystemCalls>;

export function createSystemCalls(
{ playerEntity, worldContract, waitForTransaction }: SetupNetworkResult,
{ playerEntity, worldContract: _worldContract, waitForTransaction }: Network,
{
Encounter,
MapConfig,
Expand All @@ -19,6 +19,8 @@ export function createSystemCalls(
Position,
}: ClientComponents
) {
const worldContract = _worldContract as WorldContractWrite;

const wrapPosition = (x: number, y: number) => {
const mapConfig = getComponentValue(MapConfig, singletonEntity);
if (!mapConfig) {
Expand Down Expand Up @@ -141,6 +143,10 @@ export function createSystemCalls(
};

const fleeEncounter = async () => {
if (!playerEntity) {
throw new Error("no player");
}

const tx = await worldContract.write.flee();
await waitForTransaction(tx);
};
Expand Down
48 changes: 48 additions & 0 deletions packages/client/src/mud/devTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import mudConfig from "contracts/mud.config"
import { useEffect, useRef } from "react"
import { Destructor, MUDInterface, noop } from "./types"

const noopPromise = Promise.resolve(noop);

/**
* Mounts the dev tools in development mode.
*/
export function useDevTools(mud?: MUDInterface) {
const unmountDevtoolsPromise = useRef(noopPromise);

useEffect(() => {
if (!mud) return noop;

unmountDevtoolsPromise.current.then(async (unmount) => {
unmount()
unmountDevtoolsPromise.current = mountDevTools(mud);
})

return () => {
unmountDevtoolsPromise.current.then(async (unmount) => {
unmount()
unmountDevtoolsPromise.current = noopPromise;
});
}
}, [mud]);
}

async function mountDevTools(mud: MUDInterface): Promise<Destructor> {
if (!import.meta.env.DEV) return noop;

// Avoid loading when not in dev mode.
const { mount } = await import("@latticexyz/dev-tools");

return await mount({
config: mudConfig,
publicClient: mud.network.publicClient,
walletClient: mud.network.walletClient!, // this will work even if undefined
latestBlock$: mud.network.latestBlock$,
storedBlockLogs$: mud.network.storedBlockLogs$,
worldAddress: mud.network.worldContract.address,
worldAbi: mud.network.worldContract.abi,
write$: mud.network.write$,
recsWorld: mud.network.world,
}) ?? noop;
}

47 changes: 47 additions & 0 deletions packages/client/src/mud/faucet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useEffect } from "react"
import { noop } from "rxjs"
import { Address, parseEther } from "viem"
import { publicClient } from "./account"
import { networkConfig } from "./networkConfig"
import { Destructor } from "./types"
import { createFaucetService } from "@latticexyz/services/faucet";

/*
* If there is a faucet, request (test) ETH if you have less than 1 ETH.
* Repeat every 20 seconds to ensure you don't run out.
*/
export function useFaucet(address?: Address) {
return useEffect(() => {
if (!address) return noop;
const stopFaucet = startFaucet(address);
return () => {
stopFaucet();
};
}, [address]);
}

function startFaucet(address: Address): Destructor {
if (!address || !networkConfig.faucetServiceUrl) return noop;

console.info("[Dev Faucet]: Player address -> ", address);

const faucet = createFaucetService(networkConfig.faucetServiceUrl);

const requestDrip = async () => {
const balance = await publicClient.getBalance({ address });
console.info(`[Dev Faucet]: Player balance -> ${balance}`);
const lowBalance = balance < parseEther("1");
if (lowBalance) {
console.info("[Dev Faucet]: Balance is low, dripping funds to player");
// Double drip
await faucet.dripDev({ address });
await faucet.dripDev({ address });
}
};

// Request a drip now, then every 20 seconds
void requestDrip();
const interval = setInterval(requestDrip, 20000);

return () => clearInterval(interval);
}
Loading