From a16c9bc2560707d5452f8beaa4c0d4a74cc4f9f3 Mon Sep 17 00:00:00 2001
From: ECWireless
Date: Mon, 15 Jul 2024 12:06:36 -0600
Subject: [PATCH 01/14] Add createMatch functionality to the client
---
packages/client/src/components/DevTools.ts | 2 +
.../src/components/InitiateCombatModal.tsx | 109 ++++++++++++
.../client/src/components/ItemEquipModal.tsx | 159 ++++++++----------
.../src/components/TileDetailsPanel.tsx | 65 ++++++-
.../client/src/lib/mud/createSystemCalls.ts | 53 +++++-
packages/client/src/pages/GameBoard.tsx | 1 +
packages/client/src/utils/theme.ts | 6 +
packages/client/src/utils/types.ts | 17 +-
8 files changed, 315 insertions(+), 97 deletions(-)
create mode 100644 packages/client/src/components/InitiateCombatModal.tsx
diff --git a/packages/client/src/components/DevTools.ts b/packages/client/src/components/DevTools.ts
index e8408c325..9fc7c312a 100644
--- a/packages/client/src/components/DevTools.ts
+++ b/packages/client/src/components/DevTools.ts
@@ -1,5 +1,6 @@
import mudConfig from 'contracts/mud.config';
import characterSystemAbi from 'contracts/out/CharacterSystem.sol/CharacterSystem.abi.json';
+import combatSystemAbi from 'contracts/out/CombatSystem.sol/CombatSystem.abi.json';
import mapSystemAbi from 'contracts/out/MapSystem.sol/MapSystem.abi.json';
import { useEffect, useMemo } from 'react';
@@ -13,6 +14,7 @@ export function DevTools(): null {
() => [
...network.worldContract.abi,
...characterSystemAbi,
+ ...combatSystemAbi,
...mapSystemAbi,
],
[network.worldContract.abi],
diff --git a/packages/client/src/components/InitiateCombatModal.tsx b/packages/client/src/components/InitiateCombatModal.tsx
new file mode 100644
index 000000000..18d01c576
--- /dev/null
+++ b/packages/client/src/components/InitiateCombatModal.tsx
@@ -0,0 +1,109 @@
+import {
+ Button,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+} from '@chakra-ui/react';
+import { useCallback, useState } from 'react';
+
+import { useCharacter } from '../contexts/CharacterContext';
+import { useMUD } from '../contexts/MUDContext';
+import { useToast } from '../hooks/useToast';
+import { EncounterType, type Monster } from '../utils/types';
+
+type InitiateCombatModalProps = Monster & {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+export const InitiateCombatModal: React.FC = ({
+ isOpen,
+ onClose,
+ ...monster
+}): JSX.Element => {
+ const { renderError, renderSuccess } = useToast();
+ const {
+ burnerBalance,
+ delegatorAddress,
+ systemCalls: { createMatch },
+ } = useMUD();
+ const { character } = useCharacter();
+
+ const [isInitiating, setIsInitiating] = useState(false);
+
+ const onInitiateCombat = useCallback(async () => {
+ try {
+ setIsInitiating(true);
+
+ if (!character) {
+ throw new Error('Character not found.');
+ }
+
+ if (burnerBalance === '0') {
+ throw new Error(
+ 'Insufficient funds. Please top off your session account.',
+ );
+ }
+
+ if (!delegatorAddress) {
+ throw new Error('Missing delegation.');
+ }
+
+ const success = await createMatch(
+ EncounterType.PvE,
+ [character.characterId],
+ [monster.monsterId],
+ );
+
+ if (!success) {
+ throw new Error('Contract call failed');
+ }
+
+ renderSuccess(`Battle has begun!`);
+ onClose();
+ } catch (e) {
+ renderError(e, 'Failed to initiate battle');
+ } finally {
+ setIsInitiating(false);
+ }
+ }, [
+ burnerBalance,
+ character,
+ createMatch,
+ delegatorAddress,
+ monster,
+ onClose,
+ renderError,
+ renderSuccess,
+ ]);
+
+ return (
+
+
+
+ Initiate Battle
+
+
+ Are you sure you want to initiate a battle with {monster.name}?
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/client/src/components/ItemEquipModal.tsx b/packages/client/src/components/ItemEquipModal.tsx
index 9fe5687b3..25ad09473 100644
--- a/packages/client/src/components/ItemEquipModal.tsx
+++ b/packages/client/src/components/ItemEquipModal.tsx
@@ -44,104 +44,89 @@ export const ItemEquipModal: React.FC = ({
[character, weapon.owner],
);
- const onEquipItem = useCallback(
- async (e: React.FormEvent) => {
- e.preventDefault();
-
- try {
- setIsEquipping(true);
-
- if (!character) {
- throw new Error('Character not found.');
- }
-
- if (burnerBalance === '0') {
- throw new Error(
- 'Insufficient funds. Please top off your session account.',
- );
- }
-
- if (!delegatorAddress) {
- throw new Error('Missing delegation.');
- }
-
- const success = await equipItems(character.characterId, [
- weapon.tokenId,
- ]);
-
- if (!success) {
- throw new Error('Contract call failed');
- }
-
- renderSuccess(`${weapon.name} equipped successfully!`);
- onClose();
- } catch (e) {
- renderError(e, 'Failed to equip item.');
- } finally {
- setIsEquipping(false);
+ const onEquipItem = useCallback(async () => {
+ try {
+ setIsEquipping(true);
+
+ if (!character) {
+ throw new Error('Character not found.');
}
- },
- [
- burnerBalance,
- character,
- delegatorAddress,
- equipItems,
- onClose,
- renderError,
- renderSuccess,
- weapon,
- ],
- );
- const onUnequipItem = useCallback(
- async (e: React.FormEvent) => {
- e.preventDefault();
+ if (burnerBalance === '0') {
+ throw new Error(
+ 'Insufficient funds. Please top off your session account.',
+ );
+ }
- try {
- setIsEquipping(true);
+ if (!delegatorAddress) {
+ throw new Error('Missing delegation.');
+ }
- if (!character) {
- throw new Error('Character not found.');
- }
+ const success = await equipItems(character.characterId, [weapon.tokenId]);
- if (burnerBalance === '0') {
- throw new Error(
- 'Insufficient funds. Please top off your session account.',
- );
- }
+ if (!success) {
+ throw new Error('Contract call failed');
+ }
- if (!delegatorAddress) {
- throw new Error('Missing delegation.');
- }
+ renderSuccess(`${weapon.name} equipped successfully!`);
+ onClose();
+ } catch (e) {
+ renderError(e, 'Failed to equip item.');
+ } finally {
+ setIsEquipping(false);
+ }
+ }, [
+ burnerBalance,
+ character,
+ delegatorAddress,
+ equipItems,
+ onClose,
+ renderError,
+ renderSuccess,
+ weapon,
+ ]);
+
+ const onUnequipItem = useCallback(async () => {
+ try {
+ setIsEquipping(true);
+
+ if (!character) {
+ throw new Error('Character not found.');
+ }
- const success = await unequipItem(
- character.characterId,
- weapon.tokenId,
+ if (burnerBalance === '0') {
+ throw new Error(
+ 'Insufficient funds. Please top off your session account.',
);
+ }
+
+ if (!delegatorAddress) {
+ throw new Error('Missing delegation.');
+ }
- if (!success) {
- throw new Error('Contract call failed');
- }
+ const success = await unequipItem(character.characterId, weapon.tokenId);
- renderSuccess(`${weapon.name} unequipped successfully!`);
- onClose();
- } catch (e) {
- renderError(e, 'Failed to unequip item.');
- } finally {
- setIsEquipping(false);
+ if (!success) {
+ throw new Error('Contract call failed');
}
- },
- [
- burnerBalance,
- character,
- delegatorAddress,
- onClose,
- renderError,
- renderSuccess,
- unequipItem,
- weapon,
- ],
- );
+
+ renderSuccess(`${weapon.name} unequipped successfully!`);
+ onClose();
+ } catch (e) {
+ renderError(e, 'Failed to unequip item.');
+ } finally {
+ setIsEquipping(false);
+ }
+ }, [
+ burnerBalance,
+ character,
+ delegatorAddress,
+ onClose,
+ renderError,
+ renderSuccess,
+ unequipItem,
+ weapon,
+ ]);
if (isEquipped) {
return (
diff --git a/packages/client/src/components/TileDetailsPanel.tsx b/packages/client/src/components/TileDetailsPanel.tsx
index 6819916a2..6f402526a 100644
--- a/packages/client/src/components/TileDetailsPanel.tsx
+++ b/packages/client/src/components/TileDetailsPanel.tsx
@@ -8,17 +8,45 @@ import {
Spinner,
Text,
useBreakpointValue,
+ useDisclosure,
+ VStack,
} from '@chakra-ui/react';
+import { useEntityQuery } from '@latticexyz/react';
+import { getComponentValue, Has } from '@latticexyz/recs';
+import { useState } from 'react';
+import { GiCrossedSwords } from 'react-icons/gi';
import { IoIosArrowForward } from 'react-icons/io';
import { useNavigate } from 'react-router-dom';
+import { useCharacter } from '../contexts/CharacterContext';
import { useMapNavigation } from '../contexts/MapNavigationContext';
+import { useMUD } from '../contexts/MUDContext';
import { type Character, type Monster } from '../utils/types';
+import { InitiateCombatModal } from './InitiateCombatModal';
const ROW_HEIGHT = { base: 5, md: 8, lg: 10 };
export const TileDetailsPanel = (): JSX.Element => {
const { isRefreshing, monsters, otherPlayers } = useMapNavigation();
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const {
+ components: { CombatEncounter },
+ } = useMUD();
+ const { character } = useCharacter();
+
+ const currentBattle = Array.from(useEntityQuery([Has(CombatEncounter)]))
+ .map(entity => {
+ const encounter = getComponentValue(CombatEncounter, entity);
+ return encounter;
+ })
+ .filter(
+ encounter =>
+ character &&
+ (encounter?.attackers.includes(character.characterId) ||
+ encounter?.defenders.includes(character.characterId)),
+ )[0];
+
+ const [selectedMonster, setSelectedMonster] = useState(null);
if (isRefreshing) {
return (
@@ -56,6 +84,10 @@ export const TileDetailsPanel = (): JSX.Element => {
{
+ setSelectedMonster(monster);
+ onOpen();
+ }}
/>
))}
{monsters.length === 0 && (
@@ -94,6 +126,30 @@ export const TileDetailsPanel = (): JSX.Element => {
)}
+ {selectedMonster && (
+
+ )}
+ {currentBattle && (
+
+
+
+ Combat in progress!
+
+
+
+
+ )}
);
};
@@ -104,7 +160,13 @@ const MONSTER_COLORS = {
[2]: 'green',
};
-const MonsterRow = ({ monster }: { monster: Monster }) => {
+const MonsterRow = ({
+ monster,
+ onClick,
+}: {
+ monster: Monster;
+ onClick: () => void;
+}) => {
const { level, name } = monster;
const isFighting = false;
@@ -116,6 +178,7 @@ const MonsterRow = ({ monster }: { monster: Monster }) => {
border="1px solid transparent"
h={ROW_HEIGHT}
justifyContent="space-between"
+ onClick={onClick}
px={{ base: 1, sm: 2, md: 4 }}
transition="all 0.3s ease"
w="100%"
diff --git a/packages/client/src/lib/mud/createSystemCalls.ts b/packages/client/src/lib/mud/createSystemCalls.ts
index b872e3bf2..294014f52 100644
--- a/packages/client/src/lib/mud/createSystemCalls.ts
+++ b/packages/client/src/lib/mud/createSystemCalls.ts
@@ -6,7 +6,13 @@
// import { getComponentValue } from '@latticexyz/recs';
// import { singletonEntity } from '@latticexyz/store-sync/recs';
-import { Entity, getComponentValue } from '@latticexyz/recs';
+import {
+ Entity,
+ getComponentValue,
+ Has,
+ HasValue,
+ runQuery,
+} from '@latticexyz/recs';
import { encodeEntity } from '@latticexyz/store-sync/recs';
import { uuid } from '@latticexyz/utils';
import {
@@ -18,7 +24,7 @@ import {
toBytes,
} from 'viem';
-import { StatsClasses } from '../../utils/types';
+import { EncounterType, StatsClasses } from '../../utils/types';
import { ClientComponents } from './createClientComponents';
import { SetupNetworkResult } from './setupNetwork';
@@ -46,8 +52,48 @@ export function createSystemCalls(
* (https://github.com/latticexyz/mud/blob/main/templates/react/packages/client/src/mud/setupNetwork.ts#L77-L83).
*/
{ publicClient, waitForTransaction, worldContract }: SetupNetworkResult,
- { CharacterEquipment, Characters, Position, Spawned }: ClientComponents,
+ {
+ CharacterEquipment,
+ Characters,
+ CombatEncounter,
+ Position,
+ Spawned,
+ }: ClientComponents,
) {
+ const createMatch = async (
+ encounterType: EncounterType,
+ attackers: string[],
+ defenders: string[],
+ ) => {
+ try {
+ const tx = await worldContract.write.UD__createMatch([
+ encounterType,
+ attackers as `0x${string}`[],
+ defenders as `0x${string}`[],
+ ]);
+
+ await waitForTransaction(tx);
+
+ const success = !!Array.from(
+ runQuery([
+ Has(CombatEncounter),
+ HasValue(CombatEncounter, { encounterType }),
+ ]),
+ ).filter(entity => {
+ const encounter = getComponentValue(CombatEncounter, entity);
+ return (
+ encounter &&
+ encounter.attackers.some(attacker => attackers.includes(attacker)) &&
+ encounter.defenders.some(defender => defenders.includes(defender))
+ );
+ })[0];
+
+ return success;
+ } catch (err) {
+ return false;
+ }
+ };
+
const enterGame = async (characterEntity: Entity) => {
try {
const tx = await worldContract.write.UD__enterGame([
@@ -235,6 +281,7 @@ export function createSystemCalls(
};
return {
+ createMatch,
enterGame,
equipItems,
mintCharacter,
diff --git a/packages/client/src/pages/GameBoard.tsx b/packages/client/src/pages/GameBoard.tsx
index 59f96eb2d..69017de9e 100644
--- a/packages/client/src/pages/GameBoard.tsx
+++ b/packages/client/src/pages/GameBoard.tsx
@@ -66,6 +66,7 @@ export const GameBoard = (): JSX.Element => {
colStart={{ base: 0, lg: 5 }}
overflowY="auto"
p={{ base: 2, lg: 4 }}
+ pos="relative"
rowSpan={{ base: 3, lg: 6 }}
rowStart={{ base: 0, lg: 0 }}
>
diff --git a/packages/client/src/utils/theme.ts b/packages/client/src/utils/theme.ts
index 11d856253..e1327de26 100644
--- a/packages/client/src/utils/theme.ts
+++ b/packages/client/src/utils/theme.ts
@@ -145,6 +145,12 @@ const Text = {
lg: {
fontSize: '18px',
},
+ xl: {
+ fontSize: '24px',
+ },
+ '2xl': {
+ fontSize: '32px',
+ },
},
};
diff --git a/packages/client/src/utils/types.ts b/packages/client/src/utils/types.ts
index f76d1cff5..7f53456c5 100644
--- a/packages/client/src/utils/types.ts
+++ b/packages/client/src/utils/types.ts
@@ -1,5 +1,16 @@
import { Entity } from '@latticexyz/recs';
+export enum EncounterType {
+ PvP,
+ PvE,
+}
+
+export enum StatsClasses {
+ Warrior,
+ Rogue,
+ Mage,
+}
+
export type Character = CharacterData & CharacterStats & Metadata;
export type CharacterData = {
@@ -20,12 +31,6 @@ export type CharacterStats = {
strength: string;
};
-export enum StatsClasses {
- Warrior,
- Rogue,
- Mage,
-}
-
export type Metadata = {
description: string;
image: string;
From 65f8a574d3579f885c8981bdfed20f1a85a93894 Mon Sep 17 00:00:00 2001
From: ECWireless
Date: Mon, 15 Jul 2024 19:27:02 -0600
Subject: [PATCH 02/14] Add CombatContext
---
.../client/src/components/ActionsPanel.tsx | 23 ++-
packages/client/src/components/DevTools.ts | 2 +
packages/client/src/components/MapPanel.tsx | 19 ++-
.../src/components/TileDetailsPanel.tsx | 22 +--
.../client/src/contexts/CombatContext.tsx | 79 ++++++++++
packages/client/src/pages/GameBoard.tsx | 143 +++++++++---------
packages/client/src/utils/types.ts | 11 ++
7 files changed, 199 insertions(+), 100 deletions(-)
create mode 100644 packages/client/src/contexts/CombatContext.tsx
diff --git a/packages/client/src/components/ActionsPanel.tsx b/packages/client/src/components/ActionsPanel.tsx
index 2a46e643b..18fc7b42f 100644
--- a/packages/client/src/components/ActionsPanel.tsx
+++ b/packages/client/src/components/ActionsPanel.tsx
@@ -1,4 +1,8 @@
import { Stack, Text } from '@chakra-ui/react';
+import { useMemo } from 'react';
+
+import { useCombat } from '../contexts/CombatContext';
+import { useMapNavigation } from '../contexts/MapNavigationContext';
// enum ActionEvents {
// Attack = 'attack',
@@ -43,12 +47,25 @@ import { Stack, Text } from '@chakra-ui/react';
// ];
export const ActionsPanel = (): JSX.Element => {
+ const { currentBattle, monster } = useCombat();
+ const { isSpawned } = useMapNavigation();
+
+ const actionText = useMemo(() => {
+ if (!isSpawned) {
+ return 'You must spawn on the map to start battling.';
+ }
+
+ if (currentBattle && monster) {
+ return `You are currently in a battle with a ${monster.name}.`;
+ }
+
+ return 'To initiate a battle, move into a new tile and click on a monster.';
+ }, [currentBattle, isSpawned, monster]);
+
return (
-
- You must spawn on the map to start battling.
-
+ {actionText}
{/*
{BATTLE_EVENTS.map((event, i) => (
diff --git a/packages/client/src/components/DevTools.ts b/packages/client/src/components/DevTools.ts
index 9fc7c312a..6b4e390c8 100644
--- a/packages/client/src/components/DevTools.ts
+++ b/packages/client/src/components/DevTools.ts
@@ -1,6 +1,7 @@
import mudConfig from 'contracts/mud.config';
import characterSystemAbi from 'contracts/out/CharacterSystem.sol/CharacterSystem.abi.json';
import combatSystemAbi from 'contracts/out/CombatSystem.sol/CombatSystem.abi.json';
+import equipmentSystemAbi from 'contracts/out/EquipmentSystem.sol/EquipmentSystem.abi.json';
import mapSystemAbi from 'contracts/out/MapSystem.sol/MapSystem.abi.json';
import { useEffect, useMemo } from 'react';
@@ -15,6 +16,7 @@ export function DevTools(): null {
...network.worldContract.abi,
...characterSystemAbi,
...combatSystemAbi,
+ ...equipmentSystemAbi,
...mapSystemAbi,
],
[network.worldContract.abi],
diff --git a/packages/client/src/components/MapPanel.tsx b/packages/client/src/components/MapPanel.tsx
index 53276a7bd..11792083d 100644
--- a/packages/client/src/components/MapPanel.tsx
+++ b/packages/client/src/components/MapPanel.tsx
@@ -8,6 +8,7 @@ import {
} from 'react-icons/io';
import { TbDirectionArrows } from 'react-icons/tb';
+import { useCombat } from '../contexts/CombatContext';
import { useMapNavigation } from '../contexts/MapNavigationContext';
const SAFE_ZONE_AREA = {
@@ -25,6 +26,7 @@ export const MapPanel = (): JSX.Element => {
otherPlayers,
position,
} = useMapNavigation();
+ const { currentBattle } = useCombat();
return (
{
{isSpawned ? (
-
+
) : (