From 94abe6c2f30092ff7833ee427488ac5c533984e8 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 14 Jul 2024 11:02:48 -0600 Subject: [PATCH 1/7] Render items to character page --- .../components/Character/Card/ItemCard.tsx | 57 +--- .../src/contexts/MapNavigationContext.tsx | 15 +- packages/client/src/lib/mud/externalTables.ts | 24 +- packages/client/src/pages/Character.tsx | 279 +++++++++--------- .../client/src/pages/CharacterCreation.tsx | 65 ++-- packages/client/src/utils/types.ts | 9 +- 6 files changed, 214 insertions(+), 235 deletions(-) diff --git a/packages/client/src/components/Character/Card/ItemCard.tsx b/packages/client/src/components/Character/Card/ItemCard.tsx index 041dacc90..5589221be 100644 --- a/packages/client/src/components/Character/Card/ItemCard.tsx +++ b/packages/client/src/components/Character/Card/ItemCard.tsx @@ -6,38 +6,15 @@ import { Center, Text, } from '@chakra-ui/react'; -import { - FaBook, - FaBug, - FaDatabase, - FaDoorClosed, - FaFire, - FaPizzaSlice, - FaRoad, - FaScribd, - FaSearchLocation, - FaShieldAlt, - FaSocks, - FaStarAndCrescent, -} from 'react-icons/fa'; +import { GiRogue } from 'react-icons/gi'; + +import type { Weapon } from '../../../utils/types'; + +export const ItemCard = (weapon: Weapon): JSX.Element => { + const { agiModifier, intModifier, strModifier, name } = weapon; + + const disabled = false; -export const ItemCard = ({ - agi, - disabled, - icon, - image, - int, - name, - str, -}: { - agi: number; - disabled: boolean; - icon: string; - image: string; - int: number; - name: string; - str: number; -}): JSX.Element => { return (
- {image == 'book' && } - {image == 'bug' && } - {image == 'database' && } - {image == 'door-closed' && } - {image == 'pizza-slice' && } - {image == 'scribd' && } - {image == 'search' && } - {image == 'socks' && } - {image == 'star-crescent' && } + {name.slice(-3)}
- {name} + {name.slice(0, -3)} - STR+{str} AGI+{agi} INT+{int} + STR+{strModifier} AGI+{agiModifier} INT+{intModifier}
- {icon == 'fire' && } - {icon == 'road' && } - {icon == 'shield' && } +
diff --git a/packages/client/src/contexts/MapNavigationContext.tsx b/packages/client/src/contexts/MapNavigationContext.tsx index ac948bd38..522cb64ec 100644 --- a/packages/client/src/contexts/MapNavigationContext.tsx +++ b/packages/client/src/contexts/MapNavigationContext.tsx @@ -4,6 +4,7 @@ import { getComponentValueStrict, Has, HasValue, + Not, } from '@latticexyz/recs'; import { encodeEntity } from '@latticexyz/store-sync/recs'; import { @@ -95,9 +96,10 @@ export const MapNavigationProvider = ({ ), )?.spawned; - const allEntities = useEntityQuery([ + const allMonsterEntities = useEntityQuery([ Has(Spawned), Has(Stats), + Not(Characters), HasValue(Position, { x: position?.x, y: position?.y, @@ -106,6 +108,7 @@ export const MapNavigationProvider = ({ const allCharacterEntities = useEntityQuery([ Has(Characters), + Has(Spawned), Has(Stats), HasValue(Position, { x: position?.x, @@ -235,13 +238,7 @@ export const MapNavigationProvider = ({ useEffect(() => { (async (): Promise => { - if (!(allCharacterEntities && allEntities)) return; - - setIsFetchingEntities(true); - - const allMonsterEntities = allEntities.filter( - entity => !allCharacterEntities.includes(entity), - ); + if (!(allCharacterEntities && allMonsterEntities)) return; await getOtherCharacters(allCharacterEntities); await getMonsters(allMonsterEntities); @@ -250,7 +247,7 @@ export const MapNavigationProvider = ({ })(); }, [ allCharacterEntities, - allEntities, + allMonsterEntities, Characters, getMonsters, getOtherCharacters, diff --git a/packages/client/src/lib/mud/externalTables.ts b/packages/client/src/lib/mud/externalTables.ts index b35156aa6..339da4062 100644 --- a/packages/client/src/lib/mud/externalTables.ts +++ b/packages/client/src/lib/mud/externalTables.ts @@ -9,6 +9,7 @@ const CharactersBalancesTableId = resourceToHex({ namespace: CHARACTERS_NAMESPACE, name: 'Balances', }); + const CharactersTokenURITableId = resourceToHex({ type: 'table', namespace: CHARACTERS_NAMESPACE, @@ -21,6 +22,12 @@ const GoldBalancesTableId = resourceToHex({ name: 'Balances', }); +const ItemsBaseURITableId = resourceToHex({ + type: 'table', + namespace: ITEMS_NAMESPACE, + name: 'MetadataURI', +}); + const ItemsOwnersTableId = resourceToHex({ type: 'table', namespace: ITEMS_NAMESPACE, @@ -30,7 +37,7 @@ const ItemsOwnersTableId = resourceToHex({ const ItemsTokenURITableId = resourceToHex({ type: 'table', namespace: ITEMS_NAMESPACE, - name: 'MetadataURI', + name: 'URIStorage', }); export const externalTables = { @@ -67,6 +74,15 @@ export const externalTables = { value: { type: 'uint256' }, }, }, + ItemsBaseURI: { + namespace: ITEMS_NAMESPACE, + name: 'MetadataURI', + tableId: ItemsBaseURITableId, + keySchema: {}, + valueSchema: { + uri: { type: 'string' }, + }, + }, ItemsOwners: { namespace: ITEMS_NAMESPACE, name: 'Owners', @@ -81,9 +97,11 @@ export const externalTables = { }, ItemsTokenURI: { namespace: ITEMS_NAMESPACE, - name: 'MetadataURI', + name: 'URIStorage', tableId: ItemsTokenURITableId, - keySchema: {}, + keySchema: { + tokenId: { type: 'uint256' }, + }, valueSchema: { uri: { type: 'string' }, }, diff --git a/packages/client/src/pages/Character.tsx b/packages/client/src/pages/Character.tsx index 68c0978a7..0469b9d5d 100644 --- a/packages/client/src/pages/Character.tsx +++ b/packages/client/src/pages/Character.tsx @@ -13,8 +13,15 @@ import { Entity, getComponentValue, getComponentValueStrict, + Has, + NotValue, + runQuery, } from '@latticexyz/recs'; -import { encodeEntity, singletonEntity } from '@latticexyz/store-sync/recs'; +import { + decodeEntity, + encodeEntity, + singletonEntity, +} from '@latticexyz/store-sync/recs'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { formatEther, hexToString } from 'viem'; @@ -27,7 +34,7 @@ import { useCharacter } from '../contexts/CharacterContext'; import { useMUD } from '../contexts/MUDContext'; import { useToast } from '../hooks/useToast'; import { fetchMetadataFromUri, uriToHttp } from '../utils/helpers'; -import type { Character, CharacterStats } from '../utils/types'; +import type { Character, StatsClasses, Weapon } from '../utils/types'; export const CharacterPage = (): JSX.Element => { const { characterId } = useParams(); @@ -37,6 +44,9 @@ export const CharacterPage = (): JSX.Element => { Characters, CharactersTokenURI, GoldBalances, + ItemsBaseURI, + ItemsOwners, + ItemsTokenURI, Stats, UltimateDominionConfig, }, @@ -50,12 +60,10 @@ export const CharacterPage = (): JSX.Element => { singletonEntity, ); - const [character, setCharacter] = useState< - (Character & CharacterStats) | null - >(null); + const [character, setCharacter] = useState(null); const [isLoadingCharacter, setIsLoadingCharacter] = useState(true); - - const [, setIsLoadingItems] = useState(true); + const [items, setItems] = useState(null); + const [isLoadingItems, setIsLoadingItems] = useState(true); const fetchCharacter = useCallback(async () => { try { @@ -67,7 +75,7 @@ export const CharacterPage = (): JSX.Element => { worldContract ) ) - return; + return null; setIsLoadingCharacter(true); const characterData = getComponentValue( @@ -76,7 +84,7 @@ export const CharacterPage = (): JSX.Element => { ); const characterStats = getComponentValue(Stats, characterId as Entity); - if (!(characterData && characterStats)) return; + if (!(characterData && characterStats)) return null; const ownerEntity = encodeEntity( { address: 'address' }, @@ -98,7 +106,7 @@ export const CharacterPage = (): JSX.Element => { uriToHttp(`ipfs://${metadataURI}`)[0], ); - setCharacter({ + const _character = { ...fetachedMetadata, agility: characterStats.agility.toString(), baseHitPoints: characterStats.baseHitPoints.toString(), @@ -115,9 +123,13 @@ export const CharacterPage = (): JSX.Element => { owner: characterData.owner, strength: characterStats.strength.toString(), tokenId: characterData.tokenId.toString(), - }); + }; + + setCharacter(_character); + return _character; } catch (error) { renderError(error, 'Failed to fetch character data'); + return null; } finally { setIsLoadingCharacter(false); } @@ -133,22 +145,97 @@ export const CharacterPage = (): JSX.Element => { worldContract, ]); - const fetchCharacterItems = useCallback(async () => { - try { - // eslint-disable-next-line no-console - console.log('test'); - } catch (error) { - renderError(error, 'Failed to fetch character data'); - } finally { - setIsLoadingItems(false); - } - }, [renderError]); + const fetchCharacterItems = useCallback( + async (_character: Character) => { + try { + const _items = Array.from( + runQuery([ + Has(ItemsOwners), + NotValue(ItemsOwners, { balance: BigInt(0) }), + ]), + ) + .map(entity => { + const itemOwner = getComponentValueStrict(ItemsOwners, entity); + const { owner, tokenId } = decodeEntity( + { owner: 'address', tokenId: 'uint256' }, + entity, + ); + + return { + balance: itemOwner.balance.toString(), + itemId: entity, + owner, + tokenId: tokenId.toString(), + }; + }) + .filter(item => item.owner === _character.owner) + .sort((a, b) => { + return Number(a.tokenId) - Number(b.tokenId); + }); + + const fullItems = await Promise.all( + _items.map(async item => { + const itemTemplateStats = + await worldContract.read.UD__getWeaponStats([ + BigInt(item.tokenId), + ]); + + const tokenIdEntity = encodeEntity( + { tokenId: 'uint256' }, + { tokenId: BigInt(item.tokenId) }, + ); + + const baseURI = getComponentValueStrict( + ItemsBaseURI, + singletonEntity, + ).uri; + + const tokenURI = getComponentValueStrict( + ItemsTokenURI, + tokenIdEntity, + ).uri; + + const metadata = await fetchMetadataFromUri( + uriToHttp(`${baseURI}${tokenURI}`)[0], + ); + + return { + ...metadata, + agiModifier: itemTemplateStats.agiModifier.toString(), + balance: item.balance, + classRestrictions: itemTemplateStats.classRestrictions.map( + (classRestriction: number) => classRestriction as StatsClasses, + ), + hitPointModifier: itemTemplateStats.hitPointModifier.toString(), + intModifier: itemTemplateStats.intModifier.toString(), + itemId: item.itemId, + maxDamage: itemTemplateStats.maxDamage.toString(), + minDamage: itemTemplateStats.minDamage.toString(), + minLevel: itemTemplateStats.minLevel.toString(), + owner: item.owner, + strModifier: itemTemplateStats.strModifier.toString(), + tokenId: item.tokenId, + } as Weapon; + }), + ); + + setItems(fullItems); + } catch (error) { + renderError(error, 'Failed to fetch character data'); + } finally { + setIsLoadingItems(false); + } + }, + [ItemsBaseURI, ItemsOwners, ItemsTokenURI, renderError, worldContract], + ); useEffect(() => { if (!isSynced) return; (async (): Promise => { - await fetchCharacter(); - await fetchCharacterItems(); + const _character = await fetchCharacter(); + + if (!_character) return; + await fetchCharacterItems(_character); })(); }, [fetchCharacter, fetchCharacterItems, isSynced]); @@ -244,36 +331,38 @@ export const CharacterPage = (): JSX.Element => { rowSpan={{ base: 1, sm: 1, md: 1, lg: 1, xl: 1 }} rowStart={{ base: 4, sm: 4, md: 4, lg: 2, xl: 2 }} > - - Items 30 - 3/3 Equipped - - - {DUMMY_ITEMS.map(function (item, i) { - return ( - - {/* TODO: we should only use one general modal, which gets passed the item data when clicked */} - - - ); - })} - + {items ? ( + <> + + Items {items.length} - 1/{items.length} equipped + + + {items.map(function (item, i) { + return ( + + {/* TODO: we should only use one general modal, which gets passed the item data when clicked */} + + + ); + })} + + + ) : isLoadingItems ? ( +
+ +
+ ) : ( + Error loading items + )} ) : ( @@ -303,87 +392,3 @@ export const CharacterPage = (): JSX.Element => { ); }; - -const DUMMY_ITEMS = [ - { - agi: 3, - disabled: false, - icon: 'fire', - image: 'door-closed', - int: 4, - name: 'Rusty Dagger', - str: 1, - }, - { - agi: 3, - disabled: false, - icon: 'shield', - image: 'scribd', - int: 4, - name: 'Copper Knife', - str: 1, - }, - { - agi: 3, - disabled: false, - icon: 'road', - image: 'database', - int: 4, - name: 'Iron Axe', - str: 1, - }, - { - agi: 3, - disabled: true, - icon: 'fire', - image: 'search', - int: 4, - name: 'Rusty Dagger', - str: 1, - }, - { - agi: 3, - disabled: true, - icon: 'shield', - image: 'book', - int: 4, - name: 'Rusty Dagger', - str: 1, - }, - { - agi: 3, - disabled: true, - icon: 'road', - image: 'pizza-slice', - int: 4, - name: 'Rusty Dagger', - str: 1, - }, - { - agi: 3, - disabled: true, - icon: 'fire', - image: 'star-crescent', - int: 4, - name: 'Rusty Dagger', - str: 1, - }, - { - agi: 3, - disabled: true, - icon: 'shield', - image: 'bug', - int: 4, - name: 'Rusty Dagger', - str: 1, - }, - { - agi: 3, - disabled: true, - icon: 'road', - image: 'socks', - int: 4, - name: 'Rusty Dagger', - str: 1, - }, -]; diff --git a/packages/client/src/pages/CharacterCreation.tsx b/packages/client/src/pages/CharacterCreation.tsx index 679348905..50bb10d07 100644 --- a/packages/client/src/pages/CharacterCreation.tsx +++ b/packages/client/src/pages/CharacterCreation.tsx @@ -18,11 +18,11 @@ import { VStack, } from '@chakra-ui/react'; import { useComponentValue } from '@latticexyz/react'; -import { singletonEntity } from '@latticexyz/store-sync/recs'; +import { getComponentValueStrict } from '@latticexyz/recs'; +import { encodeEntity, singletonEntity } from '@latticexyz/store-sync/recs'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { FaLock } from 'react-icons/fa'; import { useNavigate } from 'react-router-dom'; -import { getContract } from 'viem'; import { useWalletClient } from 'wagmi'; import { useCharacter } from '../contexts/CharacterContext'; @@ -38,7 +38,7 @@ import { } from '../utils/helpers'; import { StatsClasses, Weapon } from '../utils/types'; -const STARTER_WEAPON_IDS = [BigInt(1), BigInt(2), BigInt(3)]; +const STARTER_WEAPON_TOKEN_IDS = [BigInt(1), BigInt(2), BigInt(3)]; export const CharacterCreation = (): JSX.Element => { const navigate = useNavigate(); @@ -47,10 +47,10 @@ export const CharacterCreation = (): JSX.Element => { const { data: externalWalletClient } = useWalletClient(); const { burnerBalance, - components: { UltimateDominionConfig }, + components: { ItemsBaseURI, ItemsTokenURI, UltimateDominionConfig }, delegatorAddress, isSynced, - network: { publicClient, worldContract }, + network: { worldContract }, systemCalls: { enterGame, mintCharacter, rollStats }, } = useMUD(); const { character, isRefreshing, refreshCharacter } = useCharacter(); @@ -87,43 +87,28 @@ export const CharacterCreation = (): JSX.Element => { const fetchStarterWeapons = useCallback(async () => { try { const _items: Weapon[] = await Promise.all( - STARTER_WEAPON_IDS.map(async itemId => { + STARTER_WEAPON_TOKEN_IDS.map(async tokenId => { const itemTemplateStats = await worldContract.read.UD__getWeaponStats( - [itemId], + [tokenId], ); - const itemsContractAddress = - await worldContract.read.UD__getItemsContract(); - - const itemsToken = getContract({ - address: itemsContractAddress, - abi: [ - { - constant: true, - inputs: [ - { - name: 'tokenId', - type: 'uint256', - }, - ], - name: 'uri', - outputs: [ - { - name: '', - type: 'string', - }, - ], - payable: false, - stateMutability: 'view', - type: 'function', - }, - ], - client: publicClient, - }); - - const metadataURI = await itemsToken.read.uri([itemId]); + const tokenIdEntity = encodeEntity( + { tokenId: 'uint256' }, + { tokenId: tokenId }, + ); + + const baseURI = getComponentValueStrict( + ItemsBaseURI, + singletonEntity, + ).uri; + + const tokenURI = getComponentValueStrict( + ItemsTokenURI, + tokenIdEntity, + ).uri; + const fetachedMetadata = await fetchMetadataFromUri( - uriToHttp(metadataURI)[0], + uriToHttp(`${baseURI}${tokenURI}`)[0], ); return { @@ -144,7 +129,7 @@ export const CharacterCreation = (): JSX.Element => { } catch (error) { renderError(error, 'Error fetching starter item.'); } - }, [publicClient, renderError, worldContract]); + }, [ItemsBaseURI, ItemsTokenURI, renderError, worldContract]); useEffect(() => { fetchStarterWeapons(); @@ -225,7 +210,7 @@ export const CharacterCreation = (): JSX.Element => { throw new Error('Contract call failed'); } - refreshCharacter(); + await refreshCharacter(); renderSuccess('Character created!'); } catch (e) { renderError(e, 'Failed to create character.'); diff --git a/packages/client/src/utils/types.ts b/packages/client/src/utils/types.ts index e612efc11..b0f8f8a54 100644 --- a/packages/client/src/utils/types.ts +++ b/packages/client/src/utils/types.ts @@ -39,7 +39,14 @@ export type Monster = Metadata & { monsterId: Entity; }; -export type Weapon = Metadata & { +export type Weapon = WeaponStats & + Metadata & { + balance: string; + itemId: Entity; + tokenId: string; + }; + +export type WeaponStats = { agiModifier: string; classRestrictions: StatsClasses[]; hitPointModifier: string; From 5d547fbbe2e4e65c23c3839fa72a2129a82b1fca Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 14 Jul 2024 11:16:47 -0600 Subject: [PATCH 2/7] Remove Card folder in components --- .../{Character/Card => }/ItemCard.tsx | 2 +- .../{Character/Card => }/ItemEquipModal.tsx | 49 +++++-------------- packages/client/src/pages/Character.tsx | 2 +- packages/client/src/utils/types.ts | 1 + 4 files changed, 15 insertions(+), 39 deletions(-) rename packages/client/src/components/{Character/Card => }/ItemCard.tsx (95%) rename packages/client/src/components/{Character/Card => }/ItemEquipModal.tsx (55%) diff --git a/packages/client/src/components/Character/Card/ItemCard.tsx b/packages/client/src/components/ItemCard.tsx similarity index 95% rename from packages/client/src/components/Character/Card/ItemCard.tsx rename to packages/client/src/components/ItemCard.tsx index 5589221be..35f097624 100644 --- a/packages/client/src/components/Character/Card/ItemCard.tsx +++ b/packages/client/src/components/ItemCard.tsx @@ -8,7 +8,7 @@ import { } from '@chakra-ui/react'; import { GiRogue } from 'react-icons/gi'; -import type { Weapon } from '../../../utils/types'; +import type { Weapon } from '../utils/types'; export const ItemCard = (weapon: Weapon): JSX.Element => { const { agiModifier, intModifier, strModifier, name } = weapon; diff --git a/packages/client/src/components/Character/Card/ItemEquipModal.tsx b/packages/client/src/components/ItemEquipModal.tsx similarity index 55% rename from packages/client/src/components/Character/Card/ItemEquipModal.tsx rename to packages/client/src/components/ItemEquipModal.tsx index cc09ad94e..45a31bc5c 100644 --- a/packages/client/src/components/Character/Card/ItemEquipModal.tsx +++ b/packages/client/src/components/ItemEquipModal.tsx @@ -10,29 +10,20 @@ import { ModalOverlay, useDisclosure, } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import { useCharacter } from '../contexts/CharacterContext'; +import type { Weapon } from '../utils/types'; import { ItemCard } from './ItemCard'; -export const ItemEquipModal = ({ - agi, - disabled, - icon, - image, - int, - isOwner, - name, - str, -}: { - agi: number; - disabled: boolean; - icon: string; - image: string; - int: number; - isOwner: boolean; - name: string; - str: number; -}): JSX.Element => { +export const ItemEquipModal = (weapon: Weapon): JSX.Element => { const { isOpen, onClose, onOpen } = useDisclosure(); + const { character } = useCharacter(); + + const isOwner = useMemo( + () => character?.owner === weapon.owner, + [character, weapon.owner], + ); return ( @@ -42,15 +33,7 @@ export const ItemEquipModal = ({ {isOwner ? 'Equip' : 'Make an offer'} - + - - - - - - - - + + + + {isOwner ? 'Equip Item' : 'Make an offer'} + + + Do you want to equip this item? + + + + + + + + ); }; diff --git a/packages/client/src/pages/Character.tsx b/packages/client/src/pages/Character.tsx index 63faacc74..2fc6759f1 100644 --- a/packages/client/src/pages/Character.tsx +++ b/packages/client/src/pages/Character.tsx @@ -7,6 +7,7 @@ import { GridItem, Spinner, Text, + useDisclosure, } from '@chakra-ui/react'; import { useComponentValue } from '@latticexyz/react'; import { @@ -30,6 +31,7 @@ import { Misc } from '../components/Character/Misc'; import { Profile } from '../components/Character/Profile'; import { Stats as StatsPanel } from '../components/Character/Stats'; import { ItemCard } from '../components/ItemCard'; +import { ItemEquipModal } from '../components/ItemEquipModal'; import { useCharacter } from '../contexts/CharacterContext'; import { useMUD } from '../contexts/MUDContext'; import { useToast } from '../hooks/useToast'; @@ -55,6 +57,8 @@ export const CharacterPage = (): JSX.Element => { } = useMUD(); const { character: userCharacter } = useCharacter(); + const { isOpen, onClose, onOpen } = useDisclosure(); + const ultimateDominionConfig = useComponentValue( UltimateDominionConfig, singletonEntity, @@ -64,6 +68,7 @@ export const CharacterPage = (): JSX.Element => { const [isLoadingCharacter, setIsLoadingCharacter] = useState(true); const [items, setItems] = useState(null); const [isLoadingItems, setIsLoadingItems] = useState(true); + const [selectedItem, setSelectedItem] = useState(null); const fetchCharacter = useCallback(async () => { try { @@ -349,8 +354,13 @@ export const CharacterPage = (): JSX.Element => { {items.map(function (item, i) { return ( - {/* TODO: we should only use one general modal, which gets passed the item data when clicked */} - + { + setSelectedItem(item); + onOpen(); + }} + /> ); })} @@ -389,6 +399,14 @@ export const CharacterPage = (): JSX.Element => { )} + { + onClose(); + setSelectedItem(null); + }} + {...(selectedItem as Weapon)} + /> ); }; From 64eed3150b51d0bd6eaac1b09dd405237f73c53f Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 14 Jul 2024 16:03:36 -0600 Subject: [PATCH 4/7] Add equipping functionality --- packages/client/src/components/ItemCard.tsx | 11 +-- .../client/src/components/ItemEquipModal.tsx | 74 ++++++++++++++++++- .../client/src/lib/mud/createSystemCalls.ts | 17 +++++ packages/client/src/pages/Character.tsx | 47 ++++++------ 4 files changed, 119 insertions(+), 30 deletions(-) diff --git a/packages/client/src/components/ItemCard.tsx b/packages/client/src/components/ItemCard.tsx index c869249fe..bc5fd7b78 100644 --- a/packages/client/src/components/ItemCard.tsx +++ b/packages/client/src/components/ItemCard.tsx @@ -11,30 +11,31 @@ import { GiRogue } from 'react-icons/gi'; import type { Weapon } from '../utils/types'; type ItemCardProps = Weapon & { + isEquipped?: boolean; onClick?: () => void; }; export const ItemCard: React.FC = ({ + isEquipped = false, onClick, ...weapon }): JSX.Element => { const { agiModifier, intModifier, strModifier, name } = weapon; - const disabled = false; - return ( diff --git a/packages/client/src/components/ItemEquipModal.tsx b/packages/client/src/components/ItemEquipModal.tsx index 942a82efb..60963e863 100644 --- a/packages/client/src/components/ItemEquipModal.tsx +++ b/packages/client/src/components/ItemEquipModal.tsx @@ -9,9 +9,11 @@ import { ModalOverlay, Text, } from '@chakra-ui/react'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useCharacter } from '../contexts/CharacterContext'; +import { useMUD } from '../contexts/MUDContext'; +import { useToast } from '../hooks/useToast'; import type { Weapon } from '../utils/types'; import { ItemCard } from './ItemCard'; @@ -25,13 +27,72 @@ export const ItemEquipModal: React.FC = ({ onClose, ...weapon }): JSX.Element => { - const { character } = useCharacter(); + const { renderError, renderSuccess } = useToast(); + const { + burnerBalance, + delegatorAddress, + systemCalls: { equipItems }, + } = useMUD(); + const { character, refreshCharacter } = useCharacter(); + + const [isEquipping, setIsEquipping] = useState(false); const isOwner = useMemo( () => character?.owner === weapon.owner, [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'); + } + + await refreshCharacter(); + renderSuccess(`${weapon.name} equipped successfully!`); + onClose(); + } catch (e) { + renderError(e, 'Failed to equip item.'); + } finally { + setIsEquipping(false); + } + }, + [ + burnerBalance, + character, + delegatorAddress, + equipItems, + onClose, + refreshCharacter, + renderError, + renderSuccess, + weapon, + ], + ); + return ( @@ -43,10 +104,15 @@ export const ItemEquipModal: React.FC = ({ - - diff --git a/packages/client/src/lib/mud/createSystemCalls.ts b/packages/client/src/lib/mud/createSystemCalls.ts index eb550561a..fae389373 100644 --- a/packages/client/src/lib/mud/createSystemCalls.ts +++ b/packages/client/src/lib/mud/createSystemCalls.ts @@ -63,6 +63,22 @@ export function createSystemCalls( } }; + const equipItems = async (characterEntity: Entity, itemIds: string[]) => { + try { + const tx = await worldContract.write.UD__equipItems([ + characterEntity.toString() as `0x${string}`, + itemIds.map(itemId => BigInt(itemId)), + ]); + + await waitForTransaction(tx); + + const success = !!getComponentValue(Characters, characterEntity); + return success; + } catch (e) { + return false; + } + }; + const mintCharacter = async (account: Address, name: string, uri: string) => { try { const nameHex = stringToHex(name, { size: 32 }); @@ -180,6 +196,7 @@ export function createSystemCalls( return { enterGame, + equipItems, mintCharacter, move, rollStats, diff --git a/packages/client/src/pages/Character.tsx b/packages/client/src/pages/Character.tsx index 2fc6759f1..c1d72043a 100644 --- a/packages/client/src/pages/Character.tsx +++ b/packages/client/src/pages/Character.tsx @@ -38,11 +38,14 @@ import { useToast } from '../hooks/useToast'; import { fetchMetadataFromUri, uriToHttp } from '../utils/helpers'; import type { Character, StatsClasses, Weapon } from '../utils/types'; +const MAX_EQUIPPED_WEAPONS = 3; + export const CharacterPage = (): JSX.Element => { const { characterId } = useParams(); const { renderError } = useToast(); const { components: { + CharacterEquipment, Characters, CharactersTokenURI, GoldBalances, @@ -50,7 +53,6 @@ export const CharacterPage = (): JSX.Element => { ItemsOwners, ItemsTokenURI, Stats, - UltimateDominionConfig, }, isSynced, network: { publicClient, worldContract }, @@ -59,28 +61,19 @@ export const CharacterPage = (): JSX.Element => { const { isOpen, onClose, onOpen } = useDisclosure(); - const ultimateDominionConfig = useComponentValue( - UltimateDominionConfig, - singletonEntity, - ); - const [character, setCharacter] = useState(null); const [isLoadingCharacter, setIsLoadingCharacter] = useState(true); const [items, setItems] = useState(null); const [isLoadingItems, setIsLoadingItems] = useState(true); const [selectedItem, setSelectedItem] = useState(null); + const equippedWeapons = + useComponentValue(CharacterEquipment, characterId as Entity | undefined) + ?.equippedWeapons ?? []; + const fetchCharacter = useCallback(async () => { try { - if ( - !( - characterId && - publicClient && - ultimateDominionConfig && - worldContract - ) - ) - return null; + if (!(characterId && publicClient && worldContract)) return null; setIsLoadingCharacter(true); const characterData = getComponentValue( @@ -146,7 +139,6 @@ export const CharacterPage = (): JSX.Element => { Stats, publicClient, renderError, - ultimateDominionConfig, worldContract, ]); @@ -249,6 +241,8 @@ export const CharacterPage = (): JSX.Element => { [character, userCharacter], ); + const maxItemsEquipped = equippedWeapons.length === MAX_EQUIPPED_WEAPONS; + if (isLoadingCharacter) { return (
@@ -339,8 +333,12 @@ export const CharacterPage = (): JSX.Element => { {items ? ( <> - Items {items.length} - 1/{items.length} equipped + Items {items.length} - {equippedWeapons.length}/ + {MAX_EQUIPPED_WEAPONS} equipped{' '} + {maxItemsEquipped && ( + (Max items equipped) + )} { { - setSelectedItem(item); - onOpen(); - }} + isEquipped={equippedWeapons.includes( + BigInt(item.tokenId), + )} + onClick={ + maxItemsEquipped + ? undefined + : () => { + setSelectedItem(item); + onOpen(); + } + } /> ); From 1c6511f9a705c35a64617674d45d22f78c51969d Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 14 Jul 2024 16:57:34 -0600 Subject: [PATCH 5/7] Add ability to unequip items --- .../client/src/components/ItemEquipModal.tsx | 87 ++++++++++++++++++- .../client/src/lib/mud/createSystemCalls.ts | 45 +++++++++- packages/client/src/pages/Character.tsx | 12 ++- 3 files changed, 134 insertions(+), 10 deletions(-) diff --git a/packages/client/src/components/ItemEquipModal.tsx b/packages/client/src/components/ItemEquipModal.tsx index 60963e863..e90f48a7e 100644 --- a/packages/client/src/components/ItemEquipModal.tsx +++ b/packages/client/src/components/ItemEquipModal.tsx @@ -18,11 +18,13 @@ import type { Weapon } from '../utils/types'; import { ItemCard } from './ItemCard'; type ItemEquipModalProps = Weapon & { + isEquipped: boolean; isOpen: boolean; onClose: () => void; }; export const ItemEquipModal: React.FC = ({ + isEquipped, isOpen, onClose, ...weapon @@ -31,9 +33,9 @@ export const ItemEquipModal: React.FC = ({ const { burnerBalance, delegatorAddress, - systemCalls: { equipItems }, + systemCalls: { equipItems, unequipItem }, } = useMUD(); - const { character, refreshCharacter } = useCharacter(); + const { character } = useCharacter(); const [isEquipping, setIsEquipping] = useState(false); @@ -71,7 +73,6 @@ export const ItemEquipModal: React.FC = ({ throw new Error('Contract call failed'); } - await refreshCharacter(); renderSuccess(`${weapon.name} equipped successfully!`); onClose(); } catch (e) { @@ -86,13 +87,91 @@ export const ItemEquipModal: React.FC = ({ delegatorAddress, equipItems, onClose, - refreshCharacter, renderError, renderSuccess, weapon, ], ); + const onUnequipItem = 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 unequipItem( + character.characterId, + weapon.tokenId, + ); + + if (!success) { + throw new Error('Contract call failed'); + } + + 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 ( + + + + Unequip Item + + + Do you want to unequip this item? + + + + + + + + + ); + } + return ( diff --git a/packages/client/src/lib/mud/createSystemCalls.ts b/packages/client/src/lib/mud/createSystemCalls.ts index fae389373..b872e3bf2 100644 --- a/packages/client/src/lib/mud/createSystemCalls.ts +++ b/packages/client/src/lib/mud/createSystemCalls.ts @@ -46,7 +46,7 @@ export function createSystemCalls( * (https://github.com/latticexyz/mud/blob/main/templates/react/packages/client/src/mud/setupNetwork.ts#L77-L83). */ { publicClient, waitForTransaction, worldContract }: SetupNetworkResult, - { Characters, Position, Spawned }: ClientComponents, + { CharacterEquipment, Characters, Position, Spawned }: ClientComponents, ) { const enterGame = async (characterEntity: Entity) => { try { @@ -72,7 +72,18 @@ export function createSystemCalls( await waitForTransaction(tx); - const success = !!getComponentValue(Characters, characterEntity); + const characterEquipment = getComponentValue( + CharacterEquipment, + characterEntity, + ); + + if (!characterEquipment) return false; + const { equippedArmor, equippedWeapons } = characterEquipment; + + const success = + equippedArmor.some(id => itemIds.includes(id.toString())) || + equippedWeapons.some(id => itemIds.includes(id.toString())); + return success; } catch (e) { return false; @@ -194,6 +205,35 @@ export function createSystemCalls( } }; + const unequipItem = async (characterEntity: Entity, itemId: string) => { + try { + const tx = await worldContract.write.UD__unequipItem([ + characterEntity.toString() as `0x${string}`, + BigInt(itemId), + ]); + + await waitForTransaction(tx); + + const characterEquipment = getComponentValue( + CharacterEquipment, + characterEntity, + ); + + if (!characterEquipment) return false; + + const { equippedArmor, equippedWeapons } = characterEquipment; + + const success = !( + equippedArmor.includes(BigInt(itemId)) || + equippedWeapons.includes(BigInt(itemId)) + ); + + return success; + } catch (e) { + return false; + } + }; + return { enterGame, equipItems, @@ -201,5 +241,6 @@ export function createSystemCalls( move, rollStats, spawn, + unequipItem, }; } diff --git a/packages/client/src/pages/Character.tsx b/packages/client/src/pages/Character.tsx index c1d72043a..cd8e92201 100644 --- a/packages/client/src/pages/Character.tsx +++ b/packages/client/src/pages/Character.tsx @@ -350,15 +350,16 @@ export const CharacterPage = (): JSX.Element => { mt={4} > {items.map(function (item, i) { + const isEquipped = equippedWeapons.includes( + BigInt(item.tokenId), + ); return ( { setSelectedItem(item); @@ -405,6 +406,9 @@ export const CharacterPage = (): JSX.Element => { )} { onClose(); From 63da505717e9222e699128757b11e492f227e4de Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 14 Jul 2024 18:44:51 -0600 Subject: [PATCH 6/7] Render items to StatsPanel --- packages/client/src/components/StatsPanel.tsx | 192 +++++++++++++++--- packages/client/src/pages/Character.tsx | 3 +- packages/client/src/utils/constants.ts | 2 + 3 files changed, 166 insertions(+), 31 deletions(-) diff --git a/packages/client/src/components/StatsPanel.tsx b/packages/client/src/components/StatsPanel.tsx index 6f66d90a8..f8145c06e 100644 --- a/packages/client/src/components/StatsPanel.tsx +++ b/packages/client/src/components/StatsPanel.tsx @@ -12,25 +12,42 @@ import { VStack, } from '@chakra-ui/react'; import { useComponentValue } from '@latticexyz/react'; -import { encodeEntity } from '@latticexyz/store-sync/recs'; -import { useMemo } from 'react'; +import { getComponentValue, getComponentValueStrict } from '@latticexyz/recs'; +import { encodeEntity, singletonEntity } from '@latticexyz/store-sync/recs'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { GiRogue } from 'react-icons/gi'; import { IoIosArrowForward } from 'react-icons/io'; import { useNavigate } from 'react-router-dom'; import { useCharacter } from '../contexts/CharacterContext'; import { useMUD } from '../contexts/MUDContext'; +import { useToast } from '../hooks/useToast'; +import { MAX_EQUIPPED_WEAPONS } from '../utils/constants'; +import { fetchMetadataFromUri, uriToHttp } from '../utils/helpers'; +import type { Character, StatsClasses, Weapon } from '../utils/types'; import { Level } from './Level'; const CURRENT_LEVEL = 1; export const StatsPanel = (): JSX.Element => { const navigate = useNavigate(); + const { renderError } = useToast(); const isDesktop = useBreakpointValue({ base: false, lg: true }); const { - components: { Levels }, + components: { + CharacterEquipment, + ItemsBaseURI, + ItemsOwners, + ItemsTokenURI, + Levels, + }, + isSynced, + network: { worldContract }, } = useMUD(); const { character } = useCharacter(); + const [items, setItems] = useState(null); + const nextLevelXpRequirement = useComponentValue( Levels, encodeEntity({ level: 'uint256' }, { level: BigInt(CURRENT_LEVEL + 1) }), @@ -43,7 +60,106 @@ export const StatsPanel = (): JSX.Element => { ); }, [character, nextLevelXpRequirement]); - if (!character) { + const fetchCharacterItems = useCallback( + async (_character: Character, _equippedWeapons: bigint[]) => { + try { + if (_equippedWeapons.length === 0) { + setItems([]); + return; + } + + const _items = _equippedWeapons + .map(tokenId => { + const tokenOwnersEntity = encodeEntity( + { owner: 'address', tokenId: 'uint256' }, + { + owner: _character.owner as `0x${string}`, + tokenId: BigInt(tokenId), + }, + ); + const itemOwner = getComponentValueStrict( + ItemsOwners, + tokenOwnersEntity, + ); + + return { + balance: itemOwner.balance.toString(), + itemId: tokenOwnersEntity, + owner: _character.owner, + tokenId: tokenId.toString(), + }; + }) + .filter(item => item.owner === _character.owner) + .sort((a, b) => { + return Number(a.tokenId) - Number(b.tokenId); + }); + + const fullItems = await Promise.all( + _items.map(async item => { + const itemTemplateStats = + await worldContract.read.UD__getWeaponStats([ + BigInt(item.tokenId), + ]); + + const tokenIdEntity = encodeEntity( + { tokenId: 'uint256' }, + { tokenId: BigInt(item.tokenId) }, + ); + + const baseURI = getComponentValueStrict( + ItemsBaseURI, + singletonEntity, + ).uri; + + const tokenURI = getComponentValueStrict( + ItemsTokenURI, + tokenIdEntity, + ).uri; + + const metadata = await fetchMetadataFromUri( + uriToHttp(`${baseURI}${tokenURI}`)[0], + ); + + return { + ...metadata, + agiModifier: itemTemplateStats.agiModifier.toString(), + balance: item.balance, + classRestrictions: itemTemplateStats.classRestrictions.map( + (classRestriction: number) => classRestriction as StatsClasses, + ), + hitPointModifier: itemTemplateStats.hitPointModifier.toString(), + intModifier: itemTemplateStats.intModifier.toString(), + itemId: item.itemId, + maxDamage: itemTemplateStats.maxDamage.toString(), + minDamage: itemTemplateStats.minDamage.toString(), + minLevel: itemTemplateStats.minLevel.toString(), + owner: item.owner, + strModifier: itemTemplateStats.strModifier.toString(), + tokenId: item.tokenId, + } as Weapon; + }), + ); + + setItems(fullItems); + } catch (error) { + renderError(error, 'Failed to fetch character data'); + } + }, + [ItemsBaseURI, ItemsOwners, ItemsTokenURI, renderError, worldContract], + ); + + useEffect(() => { + if (!isSynced) return; + (async (): Promise => { + if (!character) return; + const equippedWeapons = + getComponentValue(CharacterEquipment, character.characterId) + ?.equippedWeapons ?? []; + await fetchCharacterItems(character, equippedWeapons); + })(); + }, [character, CharacterEquipment, fetchCharacterItems, isSynced]); + + if (!(character && items)) { return ( @@ -129,32 +245,50 @@ export const StatsPanel = (): JSX.Element => { Active Items - 1/3 - - - Rusty Dagger - - - - Empty Slot - - - - Empty Slot - + + {items.length}/{MAX_EQUIPPED_WEAPONS} + + {items.map((item, index) => ( + + {item.name} + + + ))} + {Array.from({ + length: MAX_EQUIPPED_WEAPONS - items.length, + }).map((_, index) => ( + + Empty Slot + + + ))} diff --git a/packages/client/src/pages/Character.tsx b/packages/client/src/pages/Character.tsx index cd8e92201..42079d5fa 100644 --- a/packages/client/src/pages/Character.tsx +++ b/packages/client/src/pages/Character.tsx @@ -35,11 +35,10 @@ import { ItemEquipModal } from '../components/ItemEquipModal'; import { useCharacter } from '../contexts/CharacterContext'; import { useMUD } from '../contexts/MUDContext'; import { useToast } from '../hooks/useToast'; +import { MAX_EQUIPPED_WEAPONS } from '../utils/constants'; import { fetchMetadataFromUri, uriToHttp } from '../utils/helpers'; import type { Character, StatsClasses, Weapon } from '../utils/types'; -const MAX_EQUIPPED_WEAPONS = 3; - export const CharacterPage = (): JSX.Element => { const { characterId } = useParams(); const { renderError } = useToast(); diff --git a/packages/client/src/utils/constants.ts b/packages/client/src/utils/constants.ts index e6002d9d0..5a0dedc7b 100644 --- a/packages/client/src/utils/constants.ts +++ b/packages/client/src/utils/constants.ts @@ -1 +1,3 @@ export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'; + +export const MAX_EQUIPPED_WEAPONS = 3; From edffe2d4b9d83582df7b7484c80bf054c78cefa6 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 14 Jul 2024 18:53:31 -0600 Subject: [PATCH 7/7] Link to other players from TileDetailsPanel --- .../client/src/components/ItemEquipModal.tsx | 20 ++++++++++++++----- packages/client/src/components/MapPanel.tsx | 16 ++++++++++++--- .../src/components/TileDetailsPanel.tsx | 7 ++++++- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/client/src/components/ItemEquipModal.tsx b/packages/client/src/components/ItemEquipModal.tsx index e90f48a7e..9fe5687b3 100644 --- a/packages/client/src/components/ItemEquipModal.tsx +++ b/packages/client/src/components/ItemEquipModal.tsx @@ -148,10 +148,16 @@ export const ItemEquipModal: React.FC = ({ - Unequip Item + + {isOwner ? 'Unequip Item' : 'Make an offer'} + - Do you want to unequip this item? + {isOwner ? ( + Do you want to unequip this item? + ) : ( + Do you want to make an offer for this item? + )} @@ -159,7 +165,7 @@ export const ItemEquipModal: React.FC = ({ isLoading={isEquipping} loadingText="Unequipping..." mr={3} - onClick={onUnequipItem} + onClick={isOwner ? onUnequipItem : onClose} > Yes @@ -179,7 +185,11 @@ export const ItemEquipModal: React.FC = ({ {isOwner ? 'Equip Item' : 'Make an offer'} - Do you want to equip this item? + {isOwner ? ( + Do you want to equip this item? + ) : ( + Do you want to make an offer for this item? + )} @@ -187,7 +197,7 @@ export const ItemEquipModal: React.FC = ({ isLoading={isEquipping} loadingText="Equipping..." mr={3} - onClick={onEquipItem} + onClick={isOwner ? onEquipItem : onClose} > Yes diff --git a/packages/client/src/components/MapPanel.tsx b/packages/client/src/components/MapPanel.tsx index 8a60af12e..53276a7bd 100644 --- a/packages/client/src/components/MapPanel.tsx +++ b/packages/client/src/components/MapPanel.tsx @@ -16,8 +16,15 @@ const SAFE_ZONE_AREA = { }; export const MapPanel = (): JSX.Element => { - const { isRefreshing, isSpawned, isSpawning, onMove, onSpawn, position } = - useMapNavigation(); + const { + isRefreshing, + isSpawned, + isSpawning, + onMove, + onSpawn, + otherPlayers, + position, + } = useMapNavigation(); return ( { )} - Dark Cave - 2,344 Players + + Dark Cave - {otherPlayers.length + 1} Player + {otherPlayers.length + 1 > 1 ? 's' : ''} + {isSpawned ? ( diff --git a/packages/client/src/components/TileDetailsPanel.tsx b/packages/client/src/components/TileDetailsPanel.tsx index 4f30eb00b..6819916a2 100644 --- a/packages/client/src/components/TileDetailsPanel.tsx +++ b/packages/client/src/components/TileDetailsPanel.tsx @@ -10,6 +10,7 @@ import { useBreakpointValue, } from '@chakra-ui/react'; import { IoIosArrowForward } from 'react-icons/io'; +import { useNavigate } from 'react-router-dom'; import { useMapNavigation } from '../contexts/MapNavigationContext'; import { type Character, type Monster } from '../utils/types'; @@ -156,10 +157,14 @@ const PlayerRow = ({ player }: { player: Character }) => { }; const PlayerLevelRow = ({ player }: { player: Character }) => { + const navigate = useNavigate(); const isMobile = useBreakpointValue({ base: true, md: false }); return ( - + navigate(`/characters/${player.characterId}`)} + >