diff --git a/client/src/components/inventory.tsx b/client/src/components/inventory.tsx index c89e043..f19566f 100644 --- a/client/src/components/inventory.tsx +++ b/client/src/components/inventory.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons' import { GameIdContext } from '../app'; import Markdown from './markdown'; -import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview } from '../state/api'; +import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api'; import { selectDifficulty, selectInventory } from '../state/progress'; import { store } from '../state/store'; import { useSelector } from 'react-redux'; @@ -126,3 +126,26 @@ export function Documentation({name, type, handleClose}) { {doc.data?.content} } + +/** The panel (on the welcome page) showing the user's inventory with tactics, definitions, and lemmas */ +export function InventoryPanel() { + const gameId = React.useContext(GameIdContext) + const inventory = useLoadInventoryOverviewQuery({game: gameId}) + // The inventory is overlayed by the doc entry of a clicked item + const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null) + + // Open the doc of the clicked inventory item + function openInventoryDoc(name: string, type: string) { + setInventoryDoc({name, type}) + } + // Set `inventoryDoc` to `null` to close the doc + function closeInventoryDoc() {setInventoryDoc(null)} + + return
+ {inventoryDoc ? + + : + + } +
+} diff --git a/client/src/components/welcome.css b/client/src/components/welcome.css index f4fa156..b80daf1 100644 --- a/client/src/components/welcome.css +++ b/client/src/components/welcome.css @@ -248,10 +248,17 @@ svg .disabled { left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; + padding-left: 0.3rem; } .mobile-nav .btn-next { right: 0; border-top-right-radius: 0; border-bottom-right-radius: 0; + padding-right: .3rem; +} + +.mobile-nav .svg-inline--fa { + margin-left: 0.3rem; + margin-right: 0.3rem; } diff --git a/client/src/components/welcome.tsx b/client/src/components/welcome.tsx index 8669070..a27fa0e 100644 --- a/client/src/components/welcome.tsx +++ b/client/src/components/welcome.tsx @@ -1,290 +1,117 @@ -import * as React from 'react'; -import { useState, useEffect, useRef } from 'react'; -import { Link } from 'react-router-dom'; -import { useNavigate } from 'react-router-dom'; -import { useSelector } from 'react-redux'; +import * as React from 'react' +import { useState, useEffect } from 'react' +import { useSelector } from 'react-redux' import Split from 'react-split' -import { Box, Typography, CircularProgress, Slider } from '@mui/material'; -import cytoscape, { LayoutOptions } from 'cytoscape' -import klay from 'cytoscape-klay'; -import './welcome.css' +import { Box, Typography, CircularProgress } from '@mui/material' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faGlobe, faBook, faHome, faCircleInfo, faArrowRight, faArrowLeft, faShield, faRotateLeft } from '@fortawesome/free-solid-svg-icons' -import { GameIdContext } from '../app'; -import { changedDifficulty, changedOpenedIntro, selectCompleted, selectDifficulty, selectOpenedIntro } from '../state/progress'; -import { useGetGameInfoQuery, useLoadInventoryOverviewQuery } from '../state/api'; -import Markdown from './markdown'; -import WorldSelectionMenu, { WelcomeMenu } from './world_selection_menu'; -import {PrivacyPolicy} from './privacy_policy'; -import { Button } from './button'; -import { Documentation, Inventory } from './inventory'; -import { store } from '../state/store'; -import { useWindowDimensions } from '../window_width'; -import { MobileContext } from './infoview/context'; -import { useAppDispatch } from '../hooks'; - -cytoscape.use( klay ); +import { faGlobe, faBook, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons' + +import { GameIdContext } from '../app' +import { useAppDispatch } from '../hooks' +import { changedOpenedIntro, selectOpenedIntro } from '../state/progress' +import { useGetGameInfoQuery } from '../state/api' +import { Button } from './button' +import { MobileContext } from './infoview/context' +import { InventoryPanel } from './inventory' +import Markdown from './markdown' +import {PrivacyPolicy} from './privacy_policy' +import { WelcomeMenu, WorldTreePanel } from './world_selection_menu' -const N = 18 // max number of levels per world -const R = 64 // radius of a world -const r = 12 // radius of a level -const s = 10 // global scale -const padding = R + 2*r // padding of the graphic (on a different scale) -const ds = .75 // scale the resulting svg image +import './welcome.css' -function LevelIcon({ worldId, levelId, position, completed, available }) { +/** navigation to switch between pages on mobile */ +function MobileNav({pageNumber, setPageNumber}: + { pageNumber: number, + setPageNumber: any}) { const gameId = React.useContext(GameIdContext) + const dispatch = useAppDispatch() - const difficulty = useSelector(selectDifficulty(gameId)) - - const x = s * position.x + Math.sin(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((levelId - 1)/N)) - const y = s * position.y - Math.cos(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((levelId - 1)/N)) - - let levelDisabled = (difficulty >= 2 && !(available || completed)) + let prevText = {0 : null, 1: "Intro", 2: "Game"}[pageNumber] + let prevIcon = {0 : faGlobe, 1: null, 2: null}[pageNumber] + let prevTitle = { + 0: "back to games selection", + 1: "back to introduction", + 2: "game tree"}[pageNumber] + let nextText = {0 : "Game", 1: null, 2: null}[pageNumber] + let nextIcon = {0 : null, 1: faBook, 2: null}[pageNumber] + let nextTitle = { + 0: "game tree", + 1: "inventory", + 2: null}[pageNumber] + + return
+ {(prevText || prevTitle || prevIcon) && + + } + {(nextText || nextTitle || nextIcon) && + + } +
+} - // TODO: relative positioning? - return ( - - - -
-

- {levelId} -

-
-
- - ) +/** The panel showing the game's introduction text */ +function IntroductionPanel({introduction}: { introduction: string}) { + const {mobile} = React.useContext(MobileContext) + return
+ + {!mobile && } + {introduction} + +
} +/** main page of the game showing amoung others the tree of worlds/levels */ function Welcome() { - const navigate = useNavigate(); - const gameId = React.useContext(GameIdContext) - + const {mobile} = React.useContext(MobileContext) + const gameInfo = useGetGameInfoQuery({game: gameId}) + // On mobile, the intro page should only be shown the first time const openedIntro = useSelector(selectOpenedIntro(gameId)) - - /** Only for mobile layout */ + // On mobile, there are multiple pages to switch between const [pageNumber, setPageNumber] = useState(openedIntro ? 1 : 0) - const dispatch = useAppDispatch() - - const gameInfo = useGetGameInfoQuery({game: gameId}) - - const {mobile} = React.useContext(MobileContext) - - const inventory = useLoadInventoryOverviewQuery({game: gameId}) - - const difficulty = useSelector(selectDifficulty(gameId)) - - // When clicking on an inventory item, the inventory is overlayed by the item's doc. - // If this state is set to a pair `(name, type)` then the according doc will be open. - const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null) - - // Open the doc of the clicked inventory item - function openInventoryDoc(name, type) { - setInventoryDoc({name, type}) - } - - // Set `inventoryDoc` to `null` to close the doc - const closeInventoryDoc = () => setInventoryDoc(null); - - const { nodes, bounds }: any = gameInfo.data ? computeWorldLayout(gameInfo.data?.worlds) : {nodes: []} - + // set the window title useEffect(() => { if (gameInfo.data?.title) { window.document.title = gameInfo.data.title } }, [gameInfo.data?.title]) - // Scroll to playable world - useEffect(() => { - let elems = Array.from(document.getElementsByClassName("playable-world")) - if (elems.length) { - // It seems that the last element is the one furthest up in the tree - // TODO: I think they appear in random order. Check there position and select the lowest one - // of these positions to scroll to. - let elem = elems[0] - console.debug(`scrolling to ${elem.textContent}`) - elem.scrollIntoView({block: "center"}) - } - }, [gameInfo]) - - const svgElements = [] - - // For each `worldId` as index, this contains a list of booleans with indices - // 0, 1, …, n. Index `0` will be set to `false` if any dependency is not completely solved. - // Indices `1, …, n` indicate if the corresponding level is completed - var completed = {} - - if (gameInfo.data) { - - // Fill `completed` with the level data. - for (let worldId in nodes) { - let position: cytoscape.Position = nodes[worldId].position - let state = store.getState() - - completed[worldId] = Array.from({ length: gameInfo.data.worldSize[worldId] + 1 }, (_, i) => { - // Index `0` might be set to `false` in the loop over the edges - if (!i) {return true} - return selectCompleted(gameId, worldId, i)(state) - }) - } - - for (let i in gameInfo.data.worlds.edges) { - const edge = gameInfo.data.worlds.edges[i] - - // If the origin world is not completed, mark the target world as non-playable - let unlocked = completed[edge[0]].slice(1).every(Boolean) - if (!unlocked) {completed[edge[1]][0] = false} - - // Draw the connection edges - svgElements.push( - - ) - } - - for (let worldId in nodes) { - // Draw the level bubbles - let position: cytoscape.Position = nodes[worldId].position - for (let i = 1; i <= gameInfo.data.worldSize[worldId]; i++) { - svgElements.push( - - ) - } - - let worldUnlocked = completed[worldId][0] - let worldCompleted = completed[worldId].slice(1).every(Boolean) - - // This selects the first uncompleted level - let nextLevel: number = completed[worldId].findIndex(c => !c) - if (nextLevel <= 1) { - // This uses the fact that `findIndex` returns `-1` if it does not find an uncompleted entry - // so `-1, 0, 1` are all the indices where we want to show the introduction. - nextLevel = 0 - } - - let worldDisabled = (difficulty >= 2 && !(worldUnlocked || worldCompleted)) - - // Draw the worlds - svgElements.push( - - - -
-

- {nodes[worldId].data.title ? nodes[worldId].data.title : worldId} -

-
-
- - ) - } - } - - let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null - - // TODO: Pack the three columns into components, so we dont need to - // copy them for mobile layout return
{ gameInfo.isLoading? : mobile ? - (pageNumber == 0 ? -
-
- - -
- - {gameInfo.data?.introduction} - -
+ <> + + {(pageNumber == 0 ? + : pageNumber == 1 ? -
-
- - -
- - - {svgElements} - -
+ : -
-
- -
- {<> - {inventoryDoc ? - - : - - } - } -
- ) - : + + )} + + : -
- - - {gameInfo.data?.introduction} - -
-
- - - {svgElements} - -
-
- {<> - {inventoryDoc ? - - : - - } - } -
+ + +
} @@ -292,38 +119,3 @@ function Welcome() { } export default Welcome - -function computeWorldLayout(worlds) { - - let elements = [] - for (let id in worlds.nodes) { - elements.push({ data: { id: id, title: worlds.nodes[id].title } }) - } - for (let edge of worlds.edges) { - elements.push({ - data: { - id: edge[0] + " --edge-to--> " + edge[1], - source: edge[0], - target: edge[1] - } - }) - } - - const cy = cytoscape({ - container: null, - elements, - headless: true, - styleEnabled: false - }) - - const layout = cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run() - let nodes = {} - cy.nodes().forEach((node, id) => { - nodes[node.id()] = { - position: node.position(), - data: node.data() - } - }) - const bounds = cy.nodes().boundingBox() - return { nodes, bounds } -} diff --git a/client/src/components/world_selection_menu.tsx b/client/src/components/world_selection_menu.tsx index 7d71090..2e755e9 100644 --- a/client/src/components/world_selection_menu.tsx +++ b/client/src/components/world_selection_menu.tsx @@ -2,27 +2,118 @@ * @fileOverview Define the menu displayed with the tree of worlds on the welcome page */ import * as React from 'react' +import { Link } from 'react-router-dom'; import { useStore, useSelector } from 'react-redux'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faDownload, faUpload, faEraser, faGlobe, faHome, faArrowLeft } from '@fortawesome/free-solid-svg-icons' +import { faDownload, faUpload, faEraser, faGlobe, faArrowLeft } from '@fortawesome/free-solid-svg-icons' +import { store } from '../state/store'; import './world_selection_menu.css' import { Button } from './button' import { GameIdContext } from '../app'; -import { useAppDispatch, useAppSelector } from '../hooks'; -import { deleteProgress, selectProgress, loadProgress, GameProgressState, selectDifficulty, changedDifficulty } from '../state/progress'; +import { useAppDispatch } from '../hooks'; +import { deleteProgress, selectProgress, loadProgress, GameProgressState, selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'; import { Slider } from '@mui/material'; +import cytoscape, { LayoutOptions } from 'cytoscape' +import klay from 'cytoscape-klay'; -/** Only to specify the types for `downloadFile` */ -interface downloadFileParam { - data: string - fileName: string - fileType: string +// Settings for the world tree +cytoscape.use( klay ) +const N = 18 // max number of levels per world +const R = 64 // radius of a world +const r = 12 // radius of a level +const s = 10 // global scale +const padding = R + 2*r // padding of the graphic (on a different scale) +const ds = .75 // scale the resulting svg image + +// colours +const grey = '#999' +const lightgrey = '#bbb' +const green = 'green' +const lightgreen = '#139e13' +const blue = '#1976d2' + +/** svg object for a level in the game tree */ +export function LevelIcon({ world, level, position, completed, unlocked }: + { world: string, + level: number, + position: cytoscape.Position, + completed: boolean, + unlocked: boolean, + }) { + const gameId = React.useContext(GameIdContext) + const difficulty = useSelector(selectDifficulty(gameId)) + const x = s * position.x + Math.sin(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N)) + const y = s * position.y - Math.cos(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N)) + const levelDisabled = (difficulty >= 2 && !(unlocked || completed)) + return ( + + + +
+

+ {level} +

+
+
+ + ) +} + +/** svg object of one world in the game tree */ +export function WorldIcon({world, title, position, completedLevels, difficulty}: + { world: string, + title: string, + position: cytoscape.Position, + completedLevels: any, + difficulty: number }) { + + // index `0` indicates that all prerequisites are completed + let unlocked = completedLevels[0] + // indices `1`-`n` indicate that the corresponding level is completed + let completed = completedLevels.slice(1).every(Boolean) + // select the first non-completed level + let nextLevel: number = completedLevels.findIndex(c => !c) + if (nextLevel <= 1) { + // note: `findIndex` returns `-1` on failure, therefore the indices + // `-1, 0, 1` indicate all that the introduction should be shown + nextLevel = 0 + } + let playable = difficulty <= 1 || completed || unlocked + + const gameId = React.useContext(GameIdContext) + + return + + +
+

+ {title ? title : world} +

+
+
+ +} + +/** svg object for a connection path between worlds in the game tree */ +export function WorldPath({source, target, unlocked} : {source: any, target: any, unlocked: boolean}) { + return } /** Download a file containing `data` */ -const downloadFile = ({ data, fileName, fileType } : downloadFileParam) => { +const downloadFile = ({ data, fileName, fileType } : + { data: string + fileName: string + fileType: string}) => { const blob = new Blob([data], { type: fileType }) const a = document.createElement('a') a.download = fileName @@ -36,11 +127,6 @@ const downloadFile = ({ data, fileName, fileType } : downloadFileParam) => { a.remove() } -//
-// - /** The menu that is shown next to the world selection graph */ export function WelcomeMenu() { @@ -53,7 +139,7 @@ export function WelcomeMenu() { } /** The menu that is shown next to the world selection graph */ -export function WorldSelectionMenu() { +function WorldSelectionMenu() { const [file, setFile] = React.useState(); const gameId = React.useContext(GameIdContext) @@ -178,4 +264,125 @@ export function WorldSelectionMenu() { } -export default WorldSelectionMenu +export function computeWorldLayout(worlds) { + + let elements = [] + for (let id in worlds.nodes) { + elements.push({ data: { id: id, title: worlds.nodes[id].title } }) + } + for (let edge of worlds.edges) { + elements.push({ + data: { + id: edge[0] + " --edge-to--> " + edge[1], + source: edge[0], + target: edge[1] + } + }) + } + + const cy = cytoscape({ + container: null, + elements, + headless: true, + styleEnabled: false + }) + + const layout = cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run() + let nodes = {} + cy.nodes().forEach((node, id) => { + nodes[node.id()] = { + position: node.position(), + data: node.data() + } + }) + const bounds = cy.nodes().boundingBox() + return { nodes, bounds } +} + + +export function WorldTreePanel({worlds, worldSize}: + { worlds: any, + worldSize: any}) { + const gameId = React.useContext(GameIdContext) + const difficulty = useSelector(selectDifficulty(gameId)) + + const {nodes, bounds}: any = worlds ? computeWorldLayout(worlds) : {nodes: []} + + // scroll to playable world + React.useEffect(() => { + let elems = Array.from(document.getElementsByClassName("playable-world")) + if (elems.length) { + // it seems that the last element is the one furthest up in the tree + // TODO: I think they appear in random order. Check there position and select the lowest one + // of these positions to scroll to. + let elem = elems[0] + console.debug(`scrolling to ${elem.textContent}`) + elem.scrollIntoView({block: "center"}) + } + }, [worlds, worldSize]) + + + let svgElements = [] + + // for each `worldId` as index, this contains a list of booleans with indices + // 0, 1, …, n. Index `0` will be set to `false` if any dependency is not completely solved. + // Indices `1, …, n` indicate if the corresponding level is completed + var completed = {} + + if (worlds && worldSize) { + // Fill `completed` with the level data. + for (let worldId in nodes) { + completed[worldId] = Array.from({ length: worldSize[worldId] + 1 }, (_, i) => { + // index `0` starts off as `true` but can be set to `false` by any edge with non-completed source + return i == 0 || selectCompleted(gameId, worldId, i)(store.getState()) + }) + } + + // draw all connecting paths + for (let i in worlds.edges) { + const edge = worlds.edges[i] + let sourceCompleted = completed[edge[0]].slice(1).every(Boolean) + // if the origin world is not completed, mark the target world as non-playable + if (!sourceCompleted) {completed[edge[1]][0] = false} + svgElements.push( + + ) + } + + // draw the worlds and levels + for (let worldId in nodes) { + let position: cytoscape.Position = nodes[worldId].position + svgElements.push( + + ) + for (let i = 1; i <= worldSize[worldId]; i++) { + svgElements.push( + + ) + } + } + } + + let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null + + return
+ + + {svgElements} + +
+ }