From bfa70136b7fafbd3bff57059785db91356cbce1d Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 28 Jul 2024 15:07:58 -0600 Subject: [PATCH 1/5] Improve how errors are rendered to the frontend --- packages/client/src/components/ActionsPanel.tsx | 2 +- packages/client/src/components/DelegationButton.tsx | 2 +- packages/client/src/components/EditCharacterModal.tsx | 2 +- packages/client/src/components/ItemEquipModal.tsx | 4 ++-- packages/client/src/components/TileDetailsPanel.tsx | 2 +- packages/client/src/components/WalletDetailsModal.tsx | 4 ++-- packages/client/src/contexts/CharacterContext.tsx | 7 +++++-- packages/client/src/contexts/MapNavigationContext.tsx | 11 +++++++---- packages/client/src/pages/Character.tsx | 10 ++++++++-- packages/client/src/pages/CharacterCreation.tsx | 10 +++++----- packages/client/src/pages/Leaderboard.tsx | 5 ++++- 11 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/client/src/components/ActionsPanel.tsx b/packages/client/src/components/ActionsPanel.tsx index 9198500c2..6ddd91c44 100644 --- a/packages/client/src/components/ActionsPanel.tsx +++ b/packages/client/src/components/ActionsPanel.tsx @@ -250,7 +250,7 @@ export const ActionsPanel = (): JSX.Element => { await refreshCharacter(); } catch (e) { - renderError('Failed to attack.', e); + renderError((e as Error)?.message ?? 'Failed to attack.', e); } finally { setIsAttacking(false); } diff --git a/packages/client/src/components/DelegationButton.tsx b/packages/client/src/components/DelegationButton.tsx index c26b45fe7..ba273bcb0 100644 --- a/packages/client/src/components/DelegationButton.tsx +++ b/packages/client/src/components/DelegationButton.tsx @@ -47,7 +47,7 @@ export const DelegationButton = ({ getBurner(); navigate(CHARACTER_CREATION_PATH); } catch (e) { - renderError('Failed to delegate.', e); + renderError((e as Error)?.message ?? 'Failed to delegate.', e); } finally { setIsDelegating(false); } diff --git a/packages/client/src/components/EditCharacterModal.tsx b/packages/client/src/components/EditCharacterModal.tsx index 861cdbba4..73d54ccd1 100644 --- a/packages/client/src/components/EditCharacterModal.tsx +++ b/packages/client/src/components/EditCharacterModal.tsx @@ -233,7 +233,7 @@ export const EditCharacterModal: React.FC = ({ size="sm" type="button" > - Upload Avatar + Upload Avatar Image {showError && !(avatar || image) && ( diff --git a/packages/client/src/components/ItemEquipModal.tsx b/packages/client/src/components/ItemEquipModal.tsx index 075a889de..602c36397 100644 --- a/packages/client/src/components/ItemEquipModal.tsx +++ b/packages/client/src/components/ItemEquipModal.tsx @@ -67,7 +67,7 @@ export const ItemEquipModal: React.FC = ({ renderSuccess(`${weapon.name} equipped successfully!`); onClose(); } catch (e) { - renderError('Failed to equip item.', e); + renderError((e as Error)?.message ?? 'Failed to equip item.', e); } finally { setIsEquipping(false); } @@ -107,7 +107,7 @@ export const ItemEquipModal: React.FC = ({ renderSuccess(`${weapon.name} unequipped successfully!`); onClose(); } catch (e) { - renderError('Failed to unequip item.', e); + renderError((e as Error)?.message ?? 'Failed to unequip item.', e); } finally { setIsEquipping(false); } diff --git a/packages/client/src/components/TileDetailsPanel.tsx b/packages/client/src/components/TileDetailsPanel.tsx index 48349625b..f61915e75 100644 --- a/packages/client/src/components/TileDetailsPanel.tsx +++ b/packages/client/src/components/TileDetailsPanel.tsx @@ -76,7 +76,7 @@ export const TileDetailsPanel = (): JSX.Element => { renderSuccess('Battle has begun!'); } catch (e) { - renderError('Failed to initiate battle.', e); + renderError((e as Error)?.message ?? 'Failed to initiate battle.', e); } finally { setIsInitiating(false); } diff --git a/packages/client/src/components/WalletDetailsModal.tsx b/packages/client/src/components/WalletDetailsModal.tsx index e5e972c03..4648d5090 100644 --- a/packages/client/src/components/WalletDetailsModal.tsx +++ b/packages/client/src/components/WalletDetailsModal.tsx @@ -96,7 +96,7 @@ export const WalletDetailsModal = ({ setDepositAmount('0'); renderSuccess('Funds deposited successfully!'); } catch (e) { - renderError('Error depositing funds.', e); + renderError((e as Error)?.message ?? 'Error depositing funds.', e); } finally { setIsDepositing(false); } @@ -131,7 +131,7 @@ export const WalletDetailsModal = ({ setWithdrawAmount('0'); renderSuccess('Funds withdrawn successfully!'); } catch (e) { - renderError('Error withdrawing funds.', e); + renderError((e as Error)?.message ?? 'Error withdrawing funds.', e); } finally { setIsWithdrawing(false); } diff --git a/packages/client/src/contexts/CharacterContext.tsx b/packages/client/src/contexts/CharacterContext.tsx index 093d2b545..e3f4ebdfb 100644 --- a/packages/client/src/contexts/CharacterContext.tsx +++ b/packages/client/src/contexts/CharacterContext.tsx @@ -148,7 +148,7 @@ export const CharacterProvider = ({ try { await fetchCharacterData(); } catch (e) { - renderError('Error refreshing character.', e); + renderError((e as Error)?.message ?? 'Error refreshing character.', e); } finally { setIsRefreshing(false); } @@ -248,7 +248,10 @@ export const CharacterProvider = ({ setEquippedItems(fullItems); } catch (e) { - renderError('Failed to fetch character data.', e); + renderError( + (e as Error)?.message ?? 'Failed to fetch character data.', + e, + ); } }, [ItemsBaseURI, ItemsOwners, ItemsTokenURI, renderError, worldContract], diff --git a/packages/client/src/contexts/MapNavigationContext.tsx b/packages/client/src/contexts/MapNavigationContext.tsx index ed5bd3175..4e97c7f2f 100644 --- a/packages/client/src/contexts/MapNavigationContext.tsx +++ b/packages/client/src/contexts/MapNavigationContext.tsx @@ -185,7 +185,10 @@ export const MapNavigationProvider = ({ setOtherPlayers(characters.filter(c => c.owner !== delegatorAddress)); } catch (e) { - renderError('Failed to fetch other players.', e); + renderError( + (e as Error)?.message ?? 'Failed to fetch other players.', + e, + ); } }, [ @@ -252,7 +255,7 @@ export const MapNavigationProvider = ({ setMonsters(_monsters.filter(m => Number(m.currentHp) > 0)); } catch (e) { - renderError('Failed to fetch monsters.', e); + renderError((e as Error)?.message ?? 'Failed to fetch monsters.', e); } }, [MatchEntity, Mobs, renderError, Stats], @@ -295,7 +298,7 @@ export const MapNavigationProvider = ({ renderSuccess('Spawned!'); } catch (e) { - renderError('Failed to spawn.', e); + renderError((e as Error)?.message ?? 'Failed to spawn.', e); } finally { setIsSpawning(false); } @@ -359,7 +362,7 @@ export const MapNavigationProvider = ({ throw new Error(error); } } catch (e) { - renderError('Failed to move.', e); + renderError((e as Error)?.message ?? 'Failed to move.', e); } finally { setIsMoving(false); } diff --git a/packages/client/src/pages/Character.tsx b/packages/client/src/pages/Character.tsx index f1a552ec2..9a2f5ed91 100644 --- a/packages/client/src/pages/Character.tsx +++ b/packages/client/src/pages/Character.tsx @@ -163,7 +163,10 @@ export const CharacterPage = (): JSX.Element => { setCharacter(_character); return _character; } catch (e) { - renderError('Failed to fetch character data.', e); + renderError( + (e as Error)?.message ?? 'Failed to fetch character data.', + e, + ); return null; } finally { setIsLoadingCharacter(false); @@ -263,7 +266,10 @@ export const CharacterPage = (): JSX.Element => { setItems(fullItems); } catch (e) { - renderError('Failed to fetch character data.', e); + renderError( + (e as Error)?.message ?? 'Failed to fetch character items.', + e, + ); } finally { setIsLoadingItems(false); } diff --git a/packages/client/src/pages/CharacterCreation.tsx b/packages/client/src/pages/CharacterCreation.tsx index a18bee5e8..49a36895b 100644 --- a/packages/client/src/pages/CharacterCreation.tsx +++ b/packages/client/src/pages/CharacterCreation.tsx @@ -125,7 +125,7 @@ export const CharacterCreation = (): JSX.Element => { setStarterWeapons(_items); } catch (e) { - renderError('Error fetching starter item.', e); + renderError((e as Error)?.message ?? 'Error fetching starter item.', e); } }, [ItemsBaseURI, ItemsTokenURI, renderError, worldContract]); @@ -205,7 +205,7 @@ export const CharacterCreation = (): JSX.Element => { await refreshCharacter(); renderSuccess('Character created!'); } catch (e) { - renderError('Failed to create character.', e); + renderError((e as Error)?.message ?? 'Failed to create character.', e); } finally { setIsCreating(false); } @@ -247,7 +247,7 @@ export const CharacterCreation = (): JSX.Element => { await refreshCharacter(); renderSuccess('Stats rolled!'); } catch (e) { - renderError('Failed to roll stats.', e); + renderError((e as Error)?.message ?? 'Failed to roll stats.', e); } finally { setIsRollingStats(false); } @@ -290,7 +290,7 @@ export const CharacterCreation = (): JSX.Element => { renderSuccess('Your character has awakend!'); navigate(GAME_BOARD_PATH); } catch (e) { - renderError('Failed to enter game.', e); + renderError((e as Error)?.message ?? 'Failed to enter game.', e); } finally { setIsEnteringGame(false); } @@ -437,7 +437,7 @@ export const CharacterCreation = (): JSX.Element => { size="sm" type="button" > - Upload Avatar + Upload Avatar Image {showError && !avatar && ( diff --git a/packages/client/src/pages/Leaderboard.tsx b/packages/client/src/pages/Leaderboard.tsx index 418b11cd5..a8a47d2b6 100644 --- a/packages/client/src/pages/Leaderboard.tsx +++ b/packages/client/src/pages/Leaderboard.tsx @@ -119,7 +119,10 @@ export const Leaderboard = (): JSX.Element => { setCharacters(_characters); } catch (e) { - renderError('Failed to fetch other players.', e); + renderError( + (e as Error)?.message ?? 'Failed to fetch other players.', + e, + ); } finally { setIsFetchingCharacters(false); } From b7260b9640bf16090333f17ee38033c2c9e84e87 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 28 Jul 2024 15:11:17 -0600 Subject: [PATCH 2/5] Make sure system call returns error if check fails --- packages/client/src/lib/mud/createSystemCalls.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/client/src/lib/mud/createSystemCalls.ts b/packages/client/src/lib/mud/createSystemCalls.ts index 3bb7e4ea3..b2e94eee4 100644 --- a/packages/client/src/lib/mud/createSystemCalls.ts +++ b/packages/client/src/lib/mud/createSystemCalls.ts @@ -135,6 +135,7 @@ export function createSystemCalls( })[0]; return { + error: success ? undefined : 'Failed to create match.', success, }; } catch (e) { @@ -199,6 +200,7 @@ export function createSystemCalls( const success = currentTurn === BigInt(previousTurn) + BigInt(1); return { + error: success ? undefined : 'Failed to end turn.', success, }; } catch (e) { @@ -227,6 +229,7 @@ export function createSystemCalls( const success = !!getComponentValue(Characters, characterEntity)?.locked; return { + error: success ? undefined : 'Failed to enter game.', success, }; } catch (e) { @@ -272,6 +275,7 @@ export function createSystemCalls( equippedWeapons.some(id => itemIds.includes(id.toString())); return { + error: success ? undefined : 'Failed to equip items.', success, }; } catch (e) { @@ -322,6 +326,7 @@ export function createSystemCalls( ); return { + error: success ? undefined : 'Failed to mint character.', success, }; } catch (e) { @@ -367,6 +372,7 @@ export function createSystemCalls( const success = x === newX && y === newY; return { + error: success ? undefined : 'Failed to move.', success, }; } catch (e) { @@ -425,6 +431,7 @@ export function createSystemCalls( const success = !!getComponentValue(Stats, characterEntity); return { + error: success ? undefined : 'Failed to roll stats.', success, }; } catch (e) { @@ -454,6 +461,7 @@ export function createSystemCalls( const success = !!getComponentValue(Spawned, characterEntity)?.spawned; return { + error: success ? undefined : 'Failed to spawn.', success, }; } catch (e) { @@ -497,6 +505,7 @@ export function createSystemCalls( ); return { + error: success ? undefined : 'Failed to unequip item.', success, }; } catch (e) { @@ -541,6 +550,7 @@ export function createSystemCalls( const success = newMetadataURI === `ipfs://${characterMetadataCid}`; return { + error: success ? undefined : 'Failed to update token URI.', success, }; } catch (e) { From 072d677cc1efa99b6f1182917f7d16f7f27d0007 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Sun, 28 Jul 2024 15:26:16 -0600 Subject: [PATCH 3/5] Prevent keyboard movement if in battle --- packages/client/src/App.tsx | 5 +- .../client/src/components/ActionsPanel.tsx | 4 +- packages/client/src/components/MapPanel.tsx | 3 +- .../src/components/TileDetailsPanel.tsx | 10 ++- .../client/src/contexts/CombatContext.tsx | 84 ------------------- .../src/contexts/MapNavigationContext.tsx | 52 +++++++++++- .../client/src/lib/mud/createSystemCalls.ts | 10 ++- 7 files changed, 68 insertions(+), 100 deletions(-) delete mode 100644 packages/client/src/contexts/CombatContext.tsx diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 35dbfd7f0..9fd730be5 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -5,7 +5,6 @@ import { BrowserRouter as Router, useLocation } from 'react-router-dom'; import { Footer } from './components/Footer'; import { Header } from './components/Header'; import { WalletDetailsModal } from './components/WalletDetailsModal'; -import { CombatProvider } from './contexts/CombatContext'; import { MapNavigationProvider } from './contexts/MapNavigationContext'; import { useMUD } from './contexts/MUDContext'; import AppRoutes, { HOME_PATH } from './Routes'; @@ -14,9 +13,7 @@ export const App = (): JSX.Element => { return ( - - - + ); diff --git a/packages/client/src/components/ActionsPanel.tsx b/packages/client/src/components/ActionsPanel.tsx index 6ddd91c44..453be0fde 100644 --- a/packages/client/src/components/ActionsPanel.tsx +++ b/packages/client/src/components/ActionsPanel.tsx @@ -14,7 +14,6 @@ import Typist from 'react-typist'; import { formatUnits } from 'viem'; import { useCharacter } from '../contexts/CharacterContext'; -import { useCombat } from '../contexts/CombatContext'; import { useMapNavigation } from '../contexts/MapNavigationContext'; import { useMUD } from '../contexts/MUDContext'; import { useToast } from '../hooks/useToast'; @@ -76,12 +75,13 @@ export const ActionsPanel = (): JSX.Element => { refreshCharacter, } = useCharacter(); const { + currentBattle, isRefreshing: isRefreshingMap, isSpawned, + monsterOponent, monsters, position, } = useMapNavigation(); - const { currentBattle, monsterOponent } = useCombat(); const [isAttacking, setIsAttacking] = useState(false); diff --git a/packages/client/src/components/MapPanel.tsx b/packages/client/src/components/MapPanel.tsx index 11792083d..081032b3c 100644 --- a/packages/client/src/components/MapPanel.tsx +++ b/packages/client/src/components/MapPanel.tsx @@ -8,7 +8,6 @@ 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 = { @@ -18,6 +17,7 @@ const SAFE_ZONE_AREA = { export const MapPanel = (): JSX.Element => { const { + currentBattle, isRefreshing, isSpawned, isSpawning, @@ -26,7 +26,6 @@ export const MapPanel = (): JSX.Element => { otherPlayers, position, } = useMapNavigation(); - const { currentBattle } = useCombat(); return ( { systemCalls: { createMatch }, } = useMUD(); const { character } = useCharacter(); - const { isRefreshing, monsters, otherPlayers } = useMapNavigation(); - const { currentBattle, monsterOponent } = useCombat(); + const { + currentBattle, + isRefreshing, + monsterOponent, + monsters, + otherPlayers, + } = useMapNavigation(); const [isInitiating, setIsInitiating] = useState(false); const [isMonsterHit, setIsMonsterHit] = useState(false); diff --git a/packages/client/src/contexts/CombatContext.tsx b/packages/client/src/contexts/CombatContext.tsx deleted file mode 100644 index 76853cca1..000000000 --- a/packages/client/src/contexts/CombatContext.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useEntityQuery } from '@latticexyz/react'; -import { Entity, getComponentValue, Has, HasValue } from '@latticexyz/recs'; -import { createContext, ReactNode, useContext, useMemo } from 'react'; - -import type { CombatDetails, Monster } from '../utils/types'; -import { useCharacter } from './CharacterContext'; -import { useMapNavigation } from './MapNavigationContext'; -import { useMUD } from './MUDContext'; - -type CombatContextType = { - currentBattle: CombatDetails | null; - monsterOponent: Monster | null; -}; - -const CombatContext = createContext({ - currentBattle: null, - monsterOponent: null, -}); - -export type NavigationProviderProps = { - children: ReactNode; -}; - -export const CombatProvider = ({ - children, -}: NavigationProviderProps): JSX.Element => { - const { - components: { CombatEncounter }, - } = useMUD(); - const { character } = useCharacter(); - const { monsters } = useMapNavigation(); - - const currentBattle = - Array.from( - useEntityQuery([ - Has(CombatEncounter), - HasValue(CombatEncounter, { end: BigInt(0) }), - ]), - ) - .map(entity => { - const encounter = getComponentValue(CombatEncounter, entity); - if (!encounter) return null; - - return { - attackers: encounter.attackers as Entity[], - currentTurn: encounter.currentTurn.toString(), - defenders: encounter.defenders as Entity[], - encounterId: entity, - encounterType: encounter.encounterType, - end: encounter.end.toString(), - maxTurns: encounter.maxTurns.toString(), - start: encounter.start.toString(), - }; - }) - .filter( - encounter => - character && - (encounter?.attackers.includes(character.characterId) || - encounter?.defenders.includes(character.characterId)), - )[0] ?? null; - - const monsterOponent = useMemo(() => { - if (!currentBattle) return null; - - return ( - monsters.find(monster => - currentBattle.defenders.includes(monster.monsterId), - ) ?? null - ); - }, [currentBattle, monsters]); - - return ( - - {children} - - ); -}; - -export const useCombat = (): CombatContextType => useContext(CombatContext); diff --git a/packages/client/src/contexts/MapNavigationContext.tsx b/packages/client/src/contexts/MapNavigationContext.tsx index 4e97c7f2f..bbaa8d2ec 100644 --- a/packages/client/src/contexts/MapNavigationContext.tsx +++ b/packages/client/src/contexts/MapNavigationContext.tsx @@ -14,6 +14,7 @@ import { useCallback, useContext, useEffect, + useMemo, useState, } from 'react'; import { useLocation } from 'react-router-dom'; @@ -28,14 +29,16 @@ import { import { useToast } from '../hooks/useToast'; import { GAME_BOARD_PATH } from '../Routes'; import { fetchMetadataFromUri, uriToHttp } from '../utils/helpers'; -import type { Character, Monster } from '../utils/types'; +import type { Character, CombatDetails, Monster } from '../utils/types'; import { useCharacter } from './CharacterContext'; import { useMUD } from './MUDContext'; type MapNavigationContextType = { + currentBattle: CombatDetails | null; isRefreshing: boolean; isSpawned: boolean; isSpawning: boolean; + monsterOponent: Monster | null; monsters: Monster[]; onMove: (direction: 'up' | 'down' | 'left' | 'right') => void; onSpawn: () => void; @@ -44,9 +47,11 @@ type MapNavigationContextType = { }; const MapNavigationContext = createContext({ + currentBattle: null, isRefreshing: false, isSpawned: false, isSpawning: false, + monsterOponent: null, monsters: [], onMove: () => {}, onSpawn: () => {}, @@ -67,6 +72,7 @@ export const MapNavigationProvider = ({ components: { Characters, CharactersTokenURI, + CombatEncounter, GoldBalances, MatchEntity, Mobs, @@ -304,9 +310,39 @@ export const MapNavigationProvider = ({ } }, [character, delegatorAddress, renderError, renderSuccess, spawn]); + const currentBattle = + Array.from( + useEntityQuery([ + Has(CombatEncounter), + HasValue(CombatEncounter, { end: BigInt(0) }), + ]), + ) + .map(entity => { + const encounter = getComponentValue(CombatEncounter, entity); + if (!encounter) return null; + + return { + attackers: encounter.attackers as Entity[], + currentTurn: encounter.currentTurn.toString(), + defenders: encounter.defenders as Entity[], + encounterId: entity, + encounterType: encounter.encounterType, + end: encounter.end.toString(), + maxTurns: encounter.maxTurns.toString(), + start: encounter.start.toString(), + }; + }) + .filter( + encounter => + character && + (encounter?.attackers.includes(character.characterId) || + encounter?.defenders.includes(character.characterId)), + )[0] ?? null; + const onMove = useCallback( async (direction: 'up' | 'down' | 'left' | 'right') => { try { + if (currentBattle) return; setIsMoving(true); if (!delegatorAddress) { @@ -367,9 +403,19 @@ export const MapNavigationProvider = ({ setIsMoving(false); } }, - [character, delegatorAddress, move, position, renderError], + [character, currentBattle, delegatorAddress, move, position, renderError], ); + const monsterOponent = useMemo(() => { + if (!currentBattle) return null; + + return ( + monsters.find(monster => + currentBattle.defenders.includes(monster.monsterId), + ) ?? null + ); + }, [currentBattle, monsters]); + useEffect(() => { if (pathname !== GAME_BOARD_PATH) return; @@ -412,9 +458,11 @@ export const MapNavigationProvider = ({ return ( Date: Sun, 28 Jul 2024 16:22:34 -0600 Subject: [PATCH 4/5] Prevent attack flicker on reload --- .../client/src/components/ActionsPanel.tsx | 130 +--------------- .../src/components/TileDetailsPanel.tsx | 23 ++- .../src/contexts/MapNavigationContext.tsx | 139 +++++++++++++++++- 3 files changed, 156 insertions(+), 136 deletions(-) diff --git a/packages/client/src/components/ActionsPanel.tsx b/packages/client/src/components/ActionsPanel.tsx index 453be0fde..eeb70aa4f 100644 --- a/packages/client/src/components/ActionsPanel.tsx +++ b/packages/client/src/components/ActionsPanel.tsx @@ -1,23 +1,11 @@ import { Button, HStack, Stack, Text, VStack } from '@chakra-ui/react'; -import { useEntityQuery } from '@latticexyz/react'; -import { - getComponentValueStrict, - Has, - HasValue, - runQuery, -} from '@latticexyz/recs'; -import { decodeEntity } from '@latticexyz/store-sync/recs'; -import { useCallback, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Link } from 'react-router-dom'; // eslint-disable-next-line import/no-named-as-default import Typist from 'react-typist'; -import { formatUnits } from 'viem'; import { useCharacter } from '../contexts/CharacterContext'; import { useMapNavigation } from '../contexts/MapNavigationContext'; -import { useMUD } from '../contexts/MUDContext'; -import { useToast } from '../hooks/useToast'; -import { ActionType, type BattleActionOutcome } from '../utils/types'; // enum ActionEvents { // Attack = 'attack', @@ -62,69 +50,23 @@ import { ActionType, type BattleActionOutcome } from '../utils/types'; // ]; export const ActionsPanel = (): JSX.Element => { - const { renderError } = useToast(); - const { - components: { ActionOutcome, Actions }, - delegatorAddress, - systemCalls: { endTurn }, - } = useMUD(); const { isRefreshing: isRefreshingCharacter, character, equippedItems, - refreshCharacter, } = useCharacter(); const { + battleActionOutcomes, currentBattle, + isAttacking, isRefreshing: isRefreshingMap, isSpawned, monsterOponent, monsters, + onAttack, position, } = useMapNavigation(); - const [isAttacking, setIsAttacking] = useState(false); - - const battleActionOutcomes = useEntityQuery([ - Has(ActionOutcome), - HasValue(ActionOutcome, { attackerId: character?.characterId }), - ]) - .map(entity => { - const _actionOutcome = getComponentValueStrict(ActionOutcome, entity); - - const { encounterId, currentTurn, actionNumber } = decodeEntity( - { - encounterId: 'bytes32', - currentTurn: 'uint256', - actionNumber: 'uint256', - }, - entity, - ); - - return { - attackerDamageDelt: formatUnits( - _actionOutcome.attackerDamageDelt, - 5, - ).toString(), - attackerDied: _actionOutcome.attackerDied, - attackerId: _actionOutcome.attackerId.toString(), - actionId: _actionOutcome.actionId.toString(), - actionNumber: actionNumber.toString(), - blockNumber: _actionOutcome.blockNumber.toString(), - crit: _actionOutcome.crit, - currentTurn: currentTurn.toString(), - defenderDamageDelt: _actionOutcome.defenderDamageDelt.toString(), - defenderDied: _actionOutcome.defenderDied, - defenderId: _actionOutcome.defenderId.toString(), - encounterId: encounterId.toString(), - hit: _actionOutcome.hit, - miss: _actionOutcome.miss, - timestamp: _actionOutcome.timestamp.toString(), - weaponId: _actionOutcome.weaponId.toString(), - } as BattleActionOutcome; - }) - .filter(action => action.encounterId === currentBattle?.encounterId); - const actionText = useMemo(() => { if (isRefreshingCharacter || isRefreshingMap) return ''; @@ -203,70 +145,6 @@ export const ActionsPanel = (): JSX.Element => { return ''; }, [isRefreshingCharacter, isRefreshingMap, isSpawned, monsters, position]); - const onAttack = useCallback( - async (itemId: string) => { - try { - setIsAttacking(true); - - if (!delegatorAddress) { - throw new Error('Missing delegation.'); - } - - if (!character) { - throw new Error('Character not found.'); - } - - if (!currentBattle) { - throw new Error('Battle not found.'); - } - - if (!monsterOponent) { - throw new Error('Monster not found.'); - } - - const basicAttackId = Array.from( - runQuery([ - Has(Actions), - HasValue(Actions, { actionType: ActionType.PhysicalAttack }), - ]), - )[0]; - - if (!basicAttackId) { - throw new Error('Basic attack not found.'); - } - - const { error, success } = await endTurn( - currentBattle.encounterId, - character.characterId, - monsterOponent.monsterId, - basicAttackId, - itemId, - currentBattle.currentTurn, - ); - - if (error && !success) { - throw new Error(error); - } - - await refreshCharacter(); - } catch (e) { - renderError((e as Error)?.message ?? 'Failed to attack.', e); - } finally { - setIsAttacking(false); - } - }, - [ - Actions, - character, - currentBattle, - delegatorAddress, - endTurn, - monsterOponent, - refreshCharacter, - renderError, - ], - ); - return ( <> {currentBattle && equippedItems && monsterOponent && ( diff --git a/packages/client/src/components/TileDetailsPanel.tsx b/packages/client/src/components/TileDetailsPanel.tsx index bf56ac8a1..7722facad 100644 --- a/packages/client/src/components/TileDetailsPanel.tsx +++ b/packages/client/src/components/TileDetailsPanel.tsx @@ -35,6 +35,7 @@ export const TileDetailsPanel = (): JSX.Element => { } = useMUD(); const { character } = useCharacter(); const { + battleActionOutcomes, currentBattle, isRefreshing, monsterOponent, @@ -46,14 +47,30 @@ export const TileDetailsPanel = (): JSX.Element => { const [isMonsterHit, setIsMonsterHit] = useState(false); useEffect(() => { - if (!monsterOponent) return; - if (monsterOponent.currentHp === monsterOponent.baseHp) return; + if (!(battleActionOutcomes[0] && currentBattle)) return; + + const currentBattleTurnKey = 'current-battle-turn'; + const currentBattleTurn = localStorage.getItem(currentBattleTurnKey); + + if (currentBattleTurn) { + if (currentBattleTurn === currentBattle.currentTurn) { + return; + } + } + + if ( + battleActionOutcomes[battleActionOutcomes.length - 1] + .attackerDamageDelt === '0' + ) + return; setIsMonsterHit(true); setTimeout(() => { setIsMonsterHit(false); }, 700); - }, [monsterOponent, monsterOponent?.baseHp, monsterOponent?.currentHp]); + + localStorage.setItem(currentBattleTurnKey, currentBattle.currentTurn); + }, [battleActionOutcomes, currentBattle]); const onInitiateCombat = useCallback( async (monster: Monster) => { diff --git a/packages/client/src/contexts/MapNavigationContext.tsx b/packages/client/src/contexts/MapNavigationContext.tsx index bbaa8d2ec..a43910961 100644 --- a/packages/client/src/contexts/MapNavigationContext.tsx +++ b/packages/client/src/contexts/MapNavigationContext.tsx @@ -6,8 +6,9 @@ import { Has, HasValue, Not, + runQuery, } from '@latticexyz/recs'; -import { encodeEntity } from '@latticexyz/store-sync/recs'; +import { decodeEntity, encodeEntity } from '@latticexyz/store-sync/recs'; import { createContext, ReactNode, @@ -21,6 +22,7 @@ import { useLocation } from 'react-router-dom'; import { bytesToHex, formatEther, + formatUnits, hexToBytes, hexToString, zeroHash, @@ -29,17 +31,26 @@ import { import { useToast } from '../hooks/useToast'; import { GAME_BOARD_PATH } from '../Routes'; import { fetchMetadataFromUri, uriToHttp } from '../utils/helpers'; -import type { Character, CombatDetails, Monster } from '../utils/types'; +import { + ActionType, + type BattleActionOutcome, + type Character, + type CombatDetails, + type Monster, +} from '../utils/types'; import { useCharacter } from './CharacterContext'; import { useMUD } from './MUDContext'; type MapNavigationContextType = { + battleActionOutcomes: BattleActionOutcome[]; currentBattle: CombatDetails | null; + isAttacking: boolean; isRefreshing: boolean; isSpawned: boolean; isSpawning: boolean; monsterOponent: Monster | null; monsters: Monster[]; + onAttack: (itemId: string) => void; onMove: (direction: 'up' | 'down' | 'left' | 'right') => void; onSpawn: () => void; otherPlayers: Character[]; @@ -47,12 +58,15 @@ type MapNavigationContextType = { }; const MapNavigationContext = createContext({ + battleActionOutcomes: [], currentBattle: null, + isAttacking: false, isRefreshing: false, isSpawned: false, isSpawning: false, monsterOponent: null, monsters: [], + onAttack: () => {}, onMove: () => {}, onSpawn: () => {}, otherPlayers: [], @@ -70,6 +84,8 @@ export const MapNavigationProvider = ({ const { renderError, renderSuccess } = useToast(); const { components: { + ActionOutcome, + Actions, Characters, CharactersTokenURI, CombatEncounter, @@ -82,17 +98,19 @@ export const MapNavigationProvider = ({ }, delegatorAddress, network: { publicClient, worldContract }, - systemCalls: { move, spawn }, + systemCalls: { endTurn, move, spawn }, } = useMUD(); - const { character } = useCharacter(); - - const [otherPlayers, setOtherPlayers] = useState([]); - const [monsters, setMonsters] = useState([]); + const { character, refreshCharacter } = useCharacter(); const [isSpawning, setIsSpawning] = useState(false); const [isMoving, setIsMoving] = useState(false); const [isFetchingEntities, setIsFetchingEntities] = useState(true); + const [otherPlayers, setOtherPlayers] = useState([]); + const [monsters, setMonsters] = useState([]); + + const [isAttacking, setIsAttacking] = useState(false); + const position = useComponentValue( Position, encodeEntity( @@ -455,15 +473,122 @@ export const MapNavigationProvider = ({ return () => window.removeEventListener('keydown', listener); }, [onMove, pathname]); + const onAttack = useCallback( + async (itemId: string) => { + try { + setIsAttacking(true); + + if (!delegatorAddress) { + throw new Error('Missing delegation.'); + } + + if (!character) { + throw new Error('Character not found.'); + } + + if (!currentBattle) { + throw new Error('Battle not found.'); + } + + if (!monsterOponent) { + throw new Error('Monster not found.'); + } + + const basicAttackId = Array.from( + runQuery([ + Has(Actions), + HasValue(Actions, { actionType: ActionType.PhysicalAttack }), + ]), + )[0]; + + if (!basicAttackId) { + throw new Error('Basic attack not found.'); + } + + const { error, success } = await endTurn( + currentBattle.encounterId, + character.characterId, + monsterOponent.monsterId, + basicAttackId, + itemId, + currentBattle.currentTurn, + ); + + if (error && !success) { + throw new Error(error); + } + + await refreshCharacter(); + } catch (e) { + renderError((e as Error)?.message ?? 'Failed to attack.', e); + } finally { + setIsAttacking(false); + } + }, + [ + Actions, + character, + currentBattle, + delegatorAddress, + endTurn, + monsterOponent, + refreshCharacter, + renderError, + ], + ); + + const battleActionOutcomes = useEntityQuery([ + Has(ActionOutcome), + HasValue(ActionOutcome, { attackerId: character?.characterId }), + ]) + .map(entity => { + const _actionOutcome = getComponentValueStrict(ActionOutcome, entity); + + const { encounterId, currentTurn, actionNumber } = decodeEntity( + { + encounterId: 'bytes32', + currentTurn: 'uint256', + actionNumber: 'uint256', + }, + entity, + ); + + return { + attackerDamageDelt: formatUnits( + _actionOutcome.attackerDamageDelt, + 5, + ).toString(), + attackerDied: _actionOutcome.attackerDied, + attackerId: _actionOutcome.attackerId.toString(), + actionId: _actionOutcome.actionId.toString(), + actionNumber: actionNumber.toString(), + blockNumber: _actionOutcome.blockNumber.toString(), + crit: _actionOutcome.crit, + currentTurn: currentTurn.toString(), + defenderDamageDelt: _actionOutcome.defenderDamageDelt.toString(), + defenderDied: _actionOutcome.defenderDied, + defenderId: _actionOutcome.defenderId.toString(), + encounterId: encounterId.toString(), + hit: _actionOutcome.hit, + miss: _actionOutcome.miss, + timestamp: _actionOutcome.timestamp.toString(), + weaponId: _actionOutcome.weaponId.toString(), + } as BattleActionOutcome; + }) + .filter(action => action.encounterId === currentBattle?.encounterId); + return ( Date: Sun, 28 Jul 2024 16:24:10 -0600 Subject: [PATCH 5/5] Temporarily bring back DevTools for production --- packages/client/src/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client/src/index.tsx b/packages/client/src/index.tsx index e4ea6f209..6be302c5b 100644 --- a/packages/client/src/index.tsx +++ b/packages/client/src/index.tsx @@ -33,7 +33,9 @@ setup().then(async result => { - {import.meta.env.DEV && } + {/* TODO: Bring back dev check before release */} + {/* {import.meta.env.DEV && } */} + ,