From aa274b71f5d334b35029fbd83b72e02668f7ecbc Mon Sep 17 00:00:00 2001 From: KatoakDR <68095633+KatoakDR@users.noreply.github.com> Date: Sun, 8 Sep 2024 16:48:15 -0500 Subject: [PATCH] feat: game context to auto quit char when client disconnects --- electron/renderer/components/grid/grid.tsx | 87 ++++----- electron/renderer/context/game.tsx | 94 ++++++++++ electron/renderer/pages/_app.tsx | 9 +- electron/renderer/pages/grid.tsx | 208 ++++++++++++--------- electron/renderer/pages/home.tsx | 13 +- 5 files changed, 271 insertions(+), 140 deletions(-) create mode 100644 electron/renderer/context/game.tsx diff --git a/electron/renderer/components/grid/grid.tsx b/electron/renderer/components/grid/grid.tsx index ad97a24b..52082319 100644 --- a/electron/renderer/components/grid/grid.tsx +++ b/electron/renderer/components/grid/grid.tsx @@ -3,11 +3,16 @@ // https://www.youtube.com/watch?v=vDxZLN6FVqY import type { ReactNode } from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useLogger } from '../../hooks/logger.jsx'; import type { GridItemMetadata } from './grid-item.jsx'; import { GridItem } from './grid-item.jsx'; +export interface GridContentItem { + layout: GridItemMetadata; + content: ReactNode; +} + export interface GridProps { /** * The dimension for the grid. @@ -22,18 +27,25 @@ export interface GridProps { */ width: number; }; - // TODO indicate which items to show on the grid + contentItems: Array; } export const Grid: React.FC = (props: GridProps): ReactNode => { - const { boundary } = props; + const { boundary, contentItems } = props; const logger = useLogger('cmp:grid'); - // TODO load layout from storage + // TODO when user adds an item to the grid then add it to layout and save layout + + const focusedContentItemId = useMemo(() => { + const focusedItem = contentItems.find((contentItem) => { + return contentItem.layout.isFocused; + }); + return focusedItem?.layout?.itemId ?? ''; + }, [contentItems]); - // TODO determine the focused item from the layout, if none, use the first one - const [focusedItemId, setFocusedItemId] = useState(''); + const [focusedItemId, setFocusedItemId] = + useState(focusedContentItemId); const onItemFocus = useCallback((itemMeta: GridItemMetadata) => { const { itemId } = itemMeta; @@ -59,41 +71,31 @@ export const Grid: React.FC = (props: GridProps): ReactNode => { [logger] ); - // TODO when user adds an item to the grid then add it to layout and save layout - // - refer to UX of Genie client for adding items to the grid - - const item1 = ( - -
Content1
-
- ); - - const item2 = ( - -
Content2a
-
Content2b
-
Content2c
-
Content2d
-
- ); + const gridItems = useMemo(() => { + return contentItems.map((contentItem) => { + return ( + + {contentItem.content} + + ); + }); + }, [ + contentItems, + focusedItemId, + boundary, + onItemFocus, + onItemClose, + onItemMoveResize, + ]); return (
= (props: GridProps): ReactNode => { width: boundary.width, }} > - {item1} - {item2} + {gridItems}
); }; diff --git a/electron/renderer/context/game.tsx b/electron/renderer/context/game.tsx new file mode 100644 index 00000000..3f1bd40c --- /dev/null +++ b/electron/renderer/context/game.tsx @@ -0,0 +1,94 @@ +import type { IpcRendererEvent } from 'electron'; +import type { ReactNode } from 'react'; +import { createContext, useEffect } from 'react'; +import type { + GameConnectMessage, + GameDisconnectMessage, + GameErrorMessage, +} from '../../common/game/types.js'; +import { useQuitCharacter } from '../hooks/characters.jsx'; +import { useLogger } from '../hooks/logger.jsx'; +import { runInBackground } from '../lib/async/run-in-background.js'; + +/** + * React context for storing Game-related data and callbacks. + */ +export interface GameContextValue { + // +} + +export const GameContext = createContext({}); + +GameContext.displayName = 'GameContext'; + +export interface GameProviderProps { + /** + * Nested components. + */ + children?: ReactNode; +} + +export const GameProvider: React.FC = ( + props: GameProviderProps +) => { + const { children } = props; + + const logger = useLogger('context:game'); + + const quitCharacter = useQuitCharacter(); + + useEffect(() => { + const unsubscribe = window.api.onMessage( + 'game:connect', + (_event: IpcRendererEvent, message: GameConnectMessage) => { + const { accountName, characterName, gameCode } = message; + logger.debug('game:connect', { + accountName, + characterName, + gameCode, + }); + } + ); + return () => { + unsubscribe(); + }; + }, [logger]); + + useEffect(() => { + const unsubscribe = window.api.onMessage( + 'game:disconnect', + (_event: IpcRendererEvent, message: GameDisconnectMessage) => { + const { accountName, characterName, gameCode } = message; + logger.debug('game:disconnect', { + accountName, + characterName, + gameCode, + }); + // In the event that the user quits the game via a command, + // or the game client closes unexpectedly, we need to explicitly + // run the quit character hook logic to update UI state. + runInBackground(async () => { + await quitCharacter(); + }); + } + ); + return () => { + unsubscribe(); + }; + }, [logger, quitCharacter]); + + useEffect(() => { + const unsubscribe = window.api.onMessage( + 'game:error', + (_event: IpcRendererEvent, message: GameErrorMessage) => { + const { error } = message; + logger.error('game:error', { error }); + } + ); + return () => { + unsubscribe(); + }; + }, [logger]); + + return {children}; +}; diff --git a/electron/renderer/pages/_app.tsx b/electron/renderer/pages/_app.tsx index fcca01e1..08bcb4b7 100644 --- a/electron/renderer/pages/_app.tsx +++ b/electron/renderer/pages/_app.tsx @@ -7,6 +7,7 @@ import Head from 'next/head'; import { Layout } from '../components/layout.jsx'; import { NoSSR } from '../components/no-ssr/no-ssr.jsx'; import { ChromeProvider } from '../context/chrome.jsx'; +import { GameProvider } from '../context/game.jsx'; import { LoggerProvider } from '../context/logger.jsx'; import { ThemeProvider } from '../context/theme.jsx'; @@ -30,9 +31,11 @@ const App: React.FC = ({ Component, pageProps }: AppProps) => ( - - - + + + + + diff --git a/electron/renderer/pages/grid.tsx b/electron/renderer/pages/grid.tsx index d677f385..27ee6e5e 100644 --- a/electron/renderer/pages/grid.tsx +++ b/electron/renderer/pages/grid.tsx @@ -11,15 +11,14 @@ import { v4 as uuid } from 'uuid'; import { getExperienceMindState } from '../../common/game/get-experience-mindstate.js'; import type { ExperienceGameEvent, - GameConnectMessage, - GameDisconnectMessage, - GameErrorMessage, GameEvent, GameEventMessage, RoomGameEvent, } from '../../common/game/types.js'; import { GameEventType } from '../../common/game/types.js'; import { GameStream } from '../components/game/game-stream.jsx'; +import type { GridItemMetadata } from '../components/grid/grid-item.jsx'; +import type { GridContentItem } from '../components/grid/grid.jsx'; import { Grid } from '../components/grid/grid.jsx'; import { NoSSR } from '../components/no-ssr/no-ssr.jsx'; import { useLogger } from '../hooks/logger.jsx'; @@ -267,53 +266,6 @@ const GridPage: React.FC = (): ReactNode => { } }); - useEffect(() => { - const unsubscribe = window.api.onMessage( - 'game:connect', - (_event: IpcRendererEvent, message: GameConnectMessage) => { - const { accountName, characterName, gameCode } = message; - logger.debug('game:connect', { - accountName, - characterName, - gameCode, - }); - } - ); - return () => { - unsubscribe(); - }; - }, [logger]); - - useEffect(() => { - const unsubscribe = window.api.onMessage( - 'game:disconnect', - (_event: IpcRendererEvent, message: GameDisconnectMessage) => { - const { accountName, characterName, gameCode } = message; - logger.debug('game:disconnect', { - accountName, - characterName, - gameCode, - }); - } - ); - return () => { - unsubscribe(); - }; - }, [logger]); - - useEffect(() => { - const unsubscribe = window.api.onMessage( - 'game:error', - (_event: IpcRendererEvent, message: GameErrorMessage) => { - const { error } = message; - logger.error('game:error', { error }); - } - ); - return () => { - unsubscribe(); - }; - }, [logger]); - useEffect(() => { const unsubscribe = window.api.onMessage( 'game:event', @@ -537,56 +489,127 @@ const GridPage: React.FC = (): ReactNode => { }, ]; - /* - interface GridItemStreamConfig { - itemId: string; // 'room' - title: string; // 'Room' - whenVisibleStreamToItemIds: string[]; // ['room'], always streams to itself, may also stream elsewhere - whenHiddenStreamToItemIds: string[]; // ['main'], default streams nowhere else, may also stream elsewhere - } + interface GridConfigItem { + itemId: string; // 'room' + title: string; // 'Room' + whenVisibleStreamToItemIds: Array; // ['room'], always streams to itself, may also stream elsewhere + whenHiddenStreamToItemIds: Array; // ['main'], default streams nowhere else, may also stream elsewhere + } - // when loading the layout... - // drop from layout any item without a config anymore - configItemIds = gridItemStreamConfigs.map((config) => config.itemId) - layout.items = layout.items.filter((item) => configItemIds.includes(item.itemId)) - - layoutItemIds = layout.items.map((item) => item.itemId) - itemIdToStreamIdsMap = { - // for each gridItemStreamConfig in gridItemStreamConfigs - // if layoutItemIds.includes(gridItemStreamConfig.itemId) - // for each itemId in gridItemStreamConfig.whenVisibleStreamToItemIds - // itemIdToStreamIdsMap[itemId].push(gridItemStreamConfig.itemId) - // else - // for each itemId in gridItemStreamConfig.whenHiddenStreamToItemIds - // itemIdToStreamIdsMap[itemId].push(gridItemStreamConfig.itemId) - } + const configGridItems: Array = []; - for each item in layout.items // Array - { - metadata: { - ...item, // itemId, title, isFocused, x, y, width, height - title: gridItemStreamConfigs[item.itemId].title, // 'Inventory' - }, - content: ( - - ), - } + configGridItems.push({ + itemId: 'room', + title: 'Room', + whenVisibleStreamToItemIds: ['room'], + whenHiddenStreamToItemIds: ['main'], + }); + configGridItems.push({ + itemId: 'experience', + title: 'Experience', + whenVisibleStreamToItemIds: ['experience'], + whenHiddenStreamToItemIds: ['main'], + }); - { - itemId: 'room', - title: 'Room', + configGridItems.push({ + itemId: 'main', + title: 'Main', + whenVisibleStreamToItemIds: ['main'], + whenHiddenStreamToItemIds: [''], + }); + + configGridItems.push({ + itemId: 'percWindow', + title: 'Spells', + whenVisibleStreamToItemIds: ['percWindow'], + whenHiddenStreamToItemIds: [], + }); + + const configItemIds = configGridItems.map((configItem) => { + return configItem.itemId; + }); + + let layoutGridItems: Array = []; + + layoutGridItems.push({ + itemId: 'room', + title: 'Room', + isFocused: false, + x: 0, + y: 0, + width: 100, + height: 100, + }); + + // layoutGridItems.push({ + // itemId: 'experience', + // title: 'Experience', + // isFocused: false, + // x: 200, + // y: 0, + // width: 100, + // height: 100, + // }); + + layoutGridItems.push({ + itemId: 'main', + title: 'Main', + isFocused: true, + x: 0, + y: 200, + width: 200, + height: 200, + }); + + // Drop any items that no longer have a matching config item. + layoutGridItems = layoutGridItems.filter((layoutItem) => { + return configItemIds.includes(layoutItem.itemId); + }); + + const layoutItemIds = layoutGridItems.map((layoutItem) => { + return layoutItem.itemId; + }); + + const itemIdToStreamIdsMap: Record> = {}; + + // If layout includes the config item then stream to its visible items. + // If layout does not include the config item then stream to its hidden items. + configGridItems.forEach((configItem) => { + const streamToItemIds = layoutItemIds.includes(configItem.itemId) + ? configItem.whenVisibleStreamToItemIds + : configItem.whenHiddenStreamToItemIds; + + streamToItemIds.forEach((streamToItemId) => { + itemIdToStreamIdsMap[streamToItemId] ||= []; + itemIdToStreamIdsMap[streamToItemId].push(configItem.itemId); + }); + }); + + const contentGridItems: Array = []; + + layoutGridItems.forEach((layoutItem) => { + const configItem = configGridItems.find((configItem) => { + return configItem.itemId === layoutItem.itemId; + }); + + contentGridItems.push({ + layout: { + ...layoutItem, + title: configItem?.title ?? layoutItem.title, + }, content: ( { + return streamId === 'main' ? '' : streamId; + } + )} stream$={gameLogLineSubject$} /> ), - }, - */ + }); + }); return ( { height: gridHeight, width: gridWidth, }} + contentItems={contentGridItems} /> @@ -623,7 +647,5 @@ const GridPage: React.FC = (): ReactNode => { ); }; -GridPage.displayName = 'GridPage'; - // nextjs pages must be default exports export default GridPage; diff --git a/electron/renderer/pages/home.tsx b/electron/renderer/pages/home.tsx index 228abf4c..1da813b7 100644 --- a/electron/renderer/pages/home.tsx +++ b/electron/renderer/pages/home.tsx @@ -1,12 +1,23 @@ import { useRouter } from 'next/router'; -import type { ReactNode } from 'react'; +import { type ReactNode, useEffect } from 'react'; import { useLogger } from '../hooks/logger.jsx'; +import { runInBackground } from '../lib/async/run-in-background.js'; const HomePage: React.FC = (): ReactNode => { const logger = useLogger('page:home'); const router = useRouter(); + // TODO make the home page useful + // - display list of favorite characters? + // - display list of recent characters? + + useEffect(() => { + runInBackground(async () => { + await router.push('/grid'); + }); + }, [router]); + return <>; };