cleanup welcome.tsx

pull/118/head
Jon Eugster 3 years ago
parent e429946046
commit 61cba63b81

@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons' import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app'; import { GameIdContext } from '../app';
import Markdown from './markdown'; 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 { selectDifficulty, selectInventory } from '../state/progress';
import { store } from '../state/store'; import { store } from '../state/store';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
@ -126,3 +126,26 @@ export function Documentation({name, type, handleClose}) {
<Markdown>{doc.data?.content}</Markdown> <Markdown>{doc.data?.content}</Markdown>
</div> </div>
} }
/** 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 <div className="column inventory-panel">
{inventoryDoc ?
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/>
:
<Inventory levelInfo={inventory.data} openDoc={openInventoryDoc} enableAll={true}/>
}
</div>
}

@ -248,10 +248,17 @@ svg .disabled {
left: 0; left: 0;
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
padding-left: 0.3rem;
} }
.mobile-nav .btn-next { .mobile-nav .btn-next {
right: 0; right: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
padding-right: .3rem;
}
.mobile-nav .svg-inline--fa {
margin-left: 0.3rem;
margin-right: 0.3rem;
} }

@ -1,290 +1,117 @@
import * as React from 'react'; import * as React from 'react'
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'; import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import Split from 'react-split' import Split from 'react-split'
import { Box, Typography, CircularProgress, Slider } from '@mui/material'; import { Box, Typography, CircularProgress } from '@mui/material'
import cytoscape, { LayoutOptions } from 'cytoscape'
import klay from 'cytoscape-klay';
import './welcome.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faGlobe, faBook, faHome, faCircleInfo, faArrowRight, faArrowLeft, faShield, faRotateLeft } from '@fortawesome/free-solid-svg-icons' import { faGlobe, faBook, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app';
import { changedDifficulty, changedOpenedIntro, selectCompleted, selectDifficulty, selectOpenedIntro } from '../state/progress'; import { GameIdContext } from '../app'
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery } from '../state/api'; import { useAppDispatch } from '../hooks'
import Markdown from './markdown'; import { changedOpenedIntro, selectOpenedIntro } from '../state/progress'
import WorldSelectionMenu, { WelcomeMenu } from './world_selection_menu'; import { useGetGameInfoQuery } from '../state/api'
import {PrivacyPolicy} from './privacy_policy'; import { Button } from './button'
import { Button } from './button'; import { MobileContext } from './infoview/context'
import { Documentation, Inventory } from './inventory'; import { InventoryPanel } from './inventory'
import { store } from '../state/store'; import Markdown from './markdown'
import { useWindowDimensions } from '../window_width'; import {PrivacyPolicy} from './privacy_policy'
import { MobileContext } from './infoview/context'; import { WelcomeMenu, WorldTreePanel } from './world_selection_menu'
import { useAppDispatch } from '../hooks';
cytoscape.use( klay );
const N = 18 // max number of levels per world import './welcome.css'
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
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 gameId = React.useContext(GameIdContext)
const dispatch = useAppDispatch()
const difficulty = useSelector(selectDifficulty(gameId)) let prevText = {0 : null, 1: "Intro", 2: "Game"}[pageNumber]
let prevIcon = {0 : faGlobe, 1: null, 2: null}[pageNumber]
const x = s * position.x + Math.sin(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((levelId - 1)/N)) let prevTitle = {
const y = s * position.y - Math.cos(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((levelId - 1)/N)) 0: "back to games selection",
1: "back to introduction",
let levelDisabled = (difficulty >= 2 && !(available || completed)) 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 <div className="mobile-nav">
{(prevText || prevTitle || prevIcon) &&
<Button className="btn btn-previous" to={pageNumber == 0 ? "/" : ""} title={prevTitle}
onClick={() => {pageNumber == 0 ? null : setPageNumber(pageNumber - 1)}}>
<FontAwesomeIcon icon={faArrowLeft} />
{prevIcon && <FontAwesomeIcon icon={prevIcon} />}
{prevText && `${prevText}`}
</Button>
}
{(nextText || nextTitle || nextIcon) &&
<Button className="btn btn-next" to=""
title={nextTitle} onClick={() => {
console.log(`page number: ${pageNumber}`)
setPageNumber(pageNumber+1);
dispatch(changedOpenedIntro({game: gameId, openedIntro: true}))}}>
{nextText && `${nextText}`}
{nextIcon && <FontAwesomeIcon icon={nextIcon} />}
<FontAwesomeIcon icon={faArrowRight}/>
</Button>
}
</div>
}
// TODO: relative positioning? /** The panel showing the game's introduction text */
return ( function IntroductionPanel({introduction}: { introduction: string}) {
<Link to={levelDisabled ? '' : `/${gameId}/world/${worldId}/level/${levelId}`} const {mobile} = React.useContext(MobileContext)
className={`level${levelDisabled ? ' disabled' : ''}`}> return <div className="column">
<circle fill={completed ? "#139e13" : available? "#1976d2" : "#999"} cx={x} cy={y} r={r} /> <Typography variant="body1" component="div" className="welcome-text">
<foreignObject className="level-title-wrapper" x={x} y={y} {!mobile && <WelcomeMenu />}
width={1.42*r} height={1.42*r} transform={"translate("+ -.71*r +","+ -.71*r +")"}> <Markdown>{introduction}</Markdown>
<div> </Typography>
<p className="level-title" style={{fontSize: Math.floor(r) + "px"}}> </div>
{levelId}
</p>
</div>
</foreignObject>
</Link>
)
} }
/** main page of the game showing amoung others the tree of worlds/levels */
function Welcome() { function Welcome() {
const navigate = useNavigate();
const gameId = React.useContext(GameIdContext) 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)) const openedIntro = useSelector(selectOpenedIntro(gameId))
// On mobile, there are multiple pages to switch between
/** Only for mobile layout */
const [pageNumber, setPageNumber] = useState(openedIntro ? 1 : 0) const [pageNumber, setPageNumber] = useState(openedIntro ? 1 : 0)
const dispatch = useAppDispatch() // set the window title
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: []}
useEffect(() => { useEffect(() => {
if (gameInfo.data?.title) { if (gameInfo.data?.title) {
window.document.title = gameInfo.data.title window.document.title = gameInfo.data.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(
<line key={`pathway${i}`} x1={s*nodes[edge[0]].position.x} y1={s*nodes[edge[0]].position.y}
x2={s*nodes[edge[1]].position.x} y2={s*nodes[edge[1]].position.y}
stroke={unlocked ? "green" : "#bbb"} strokeWidth={s}/>
)
}
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(
<LevelIcon
key={`/${gameId}/world/${worldId}/level/${i}`}
position={position} worldId={worldId} levelId={i}
completed={completed[worldId][i]} available={completed[worldId][i-1]}/>
)
}
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(
<Link key={`world${worldId}`}
to={worldDisabled ? '' : `/${gameId}/world/${worldId}/level/${nextLevel}`}
className={worldDisabled ? 'disabled' : ''}>
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
fill={worldCompleted ? "green" : worldUnlocked ? "#1976d2": "#999"}/>
<foreignObject className="world-title-wrapper" x={s*position.x} y={s*position.y}
width={1.42*R} height={1.42*R} transform={"translate("+ -.71*R +","+ -.71*R +")"}>
<div className={worldUnlocked && !worldCompleted ? "playable-world" : ''}>
<p className="world-title" style={{fontSize: Math.floor(R/4) + "px"}}>
{nodes[worldId].data.title ? nodes[worldId].data.title : worldId}
</p>
</div>
</foreignObject>
</Link>
)
}
}
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 <div className="app-content"> return <div className="app-content">
{ gameInfo.isLoading? { gameInfo.isLoading?
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}> <Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
: mobile ? : mobile ?
(pageNumber == 0 ? <>
<div className="column"> <MobileNav pageNumber={pageNumber} setPageNumber={setPageNumber} />
<div className="mobile-nav"> {(pageNumber == 0 ?
<Button className="btn btn-previous" to="/" title="back to games selection"> <IntroductionPanel introduction={gameInfo.data?.introduction} />
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;<FontAwesomeIcon icon={faGlobe} />
</Button>
<Button className="btn btn-next" to=""
title="world tree" onClick={() => {
setPageNumber(1);
dispatch(changedOpenedIntro({game: gameId, openedIntro: true}))}}>
Game&nbsp;<FontAwesomeIcon icon={faArrowRight}/>
</Button>
</div>
<Typography variant="body1" component="div" className="welcome-text">
<Markdown>{gameInfo.data?.introduction}</Markdown>
</Typography>
</div>
: pageNumber == 1 ? : pageNumber == 1 ?
<div className="column"> <WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
<div className="mobile-nav">
<Button className="btn btn-previous" to=""
title="back to introduction" onClick={() => {setPageNumber(0)}}>
<FontAwesomeIcon icon={faArrowLeft}/>&nbsp;Intro
</Button>
<Button className="btn btn-next" to=""
title="show inventory" onClick={() => {setPageNumber(2)}}>
<FontAwesomeIcon icon={faBook}/>&nbsp;<FontAwesomeIcon icon={faArrowRight}/>
</Button>
</div>
<WorldSelectionMenu />
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
width={bounds ? `${ds * dx}` : ''}
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''}
className="world-selection"
>
{svgElements}
</svg>
</div>
: :
<div className="inventory-panel"> <InventoryPanel />
<div className="mobile-nav"> )}
<Button className="btn btn-previous" to="" </>
title="world tree" onClick={() => {setPageNumber(1)}}> :
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;Game
</Button>
</div>
{<>
{inventoryDoc ?
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/>
:
<Inventory levelInfo={inventory.data} openDoc={openInventoryDoc} enableAll={true}/>
}
</>}
</div>
)
:
<Split className="welcome" minSize={0} snapOffset={200} sizes={[40, 35, 25]}> <Split className="welcome" minSize={0} snapOffset={200} sizes={[40, 35, 25]}>
<div className="column"> <IntroductionPanel introduction={gameInfo.data?.introduction} />
<Typography variant="body1" component="div" className="welcome-text"> <WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
<WelcomeMenu /> <InventoryPanel />
<Markdown>{gameInfo.data?.introduction}</Markdown>
</Typography>
</div>
<div className="column">
<WorldSelectionMenu />
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
width={bounds ? `${ds * dx}` : ''}
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''}
className="world-selection"
>
{svgElements}
</svg>
</div>
<div className="inventory-panel">
{<>
{inventoryDoc ?
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/>
:
<Inventory levelInfo={inventory.data} openDoc={openInventoryDoc} enableAll={true}/>
}
</>}
</div>
</Split> </Split>
} }
<PrivacyPolicy /> <PrivacyPolicy />
@ -292,38 +119,3 @@ function Welcome() {
} }
export default 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 }
}

@ -2,27 +2,118 @@
* @fileOverview Define the menu displayed with the tree of worlds on the welcome page * @fileOverview Define the menu displayed with the tree of worlds on the welcome page
*/ */
import * as React from 'react' import * as React from 'react'
import { Link } from 'react-router-dom';
import { useStore, useSelector } from 'react-redux'; import { useStore, useSelector } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 './world_selection_menu.css'
import { Button } from './button' import { Button } from './button'
import { GameIdContext } from '../app'; import { GameIdContext } from '../app';
import { useAppDispatch, useAppSelector } from '../hooks'; import { useAppDispatch } from '../hooks';
import { deleteProgress, selectProgress, loadProgress, GameProgressState, selectDifficulty, changedDifficulty } from '../state/progress'; import { deleteProgress, selectProgress, loadProgress, GameProgressState, selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress';
import { Slider } from '@mui/material'; import { Slider } from '@mui/material';
import cytoscape, { LayoutOptions } from 'cytoscape'
import klay from 'cytoscape-klay';
/** Only to specify the types for `downloadFile` */ // Settings for the world tree
interface downloadFileParam { cytoscape.use( klay )
data: string const N = 18 // max number of levels per world
fileName: string const R = 64 // radius of a world
fileType: string 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 (
<Link to={levelDisabled ? '' : `/${gameId}/world/${world}/level/${level}`}
className={`level${levelDisabled ? ' disabled' : ''}`}>
<circle fill={completed ? lightgreen : unlocked? blue : grey} cx={x} cy={y} r={r} />
<foreignObject className="level-title-wrapper" x={x} y={y}
width={1.42*r} height={1.42*r} transform={"translate("+ -.71*r +","+ -.71*r +")"}>
<div>
<p className="level-title" style={{fontSize: Math.floor(r) + "px"}}>
{level}
</p>
</div>
</foreignObject>
</Link>
)
}
/** 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 <Link
to={playable ? `/${gameId}/world/${world}/level/${nextLevel}` : ''}
className={playable ? '' : 'disabled'}>
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
fill={completed ? green : unlocked ? blue : grey}/>
<foreignObject className="world-title-wrapper" x={s*position.x} y={s*position.y}
width={1.42*R} height={1.42*R} transform={"translate("+ -.71*R +","+ -.71*R +")"}>
<div className={unlocked && !completed ? "playable-world" : ''}>
<p className="world-title" style={{fontSize: Math.floor(R/4) + "px"}}>
{title ? title : world}
</p>
</div>
</foreignObject>
</Link>
}
/** 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 <line x1={s*source.position.x} y1={s*source.position.y}
x2={s*target.position.x} y2={s*target.position.y}
stroke={unlocked ? green : lightgrey} strokeWidth={s}/>
} }
/** Download a file containing `data` */ /** 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 blob = new Blob([data], { type: fileType })
const a = document.createElement('a') const a = document.createElement('a')
a.download = fileName a.download = fileName
@ -36,11 +127,6 @@ const downloadFile = ({ data, fileName, fileType } : downloadFileParam) => {
a.remove() a.remove()
} }
// <div><Button inverted="false" title="back to games selection" to="/">
// <FontAwesomeIcon icon={faArrowLeft} />&nbsp;<FontAwesomeIcon icon={faGlobe} />
// </Button>
// <Slider min={0} max={2}></Slider>
/** The menu that is shown next to the world selection graph */ /** The menu that is shown next to the world selection graph */
export function WelcomeMenu() { export function WelcomeMenu() {
@ -53,7 +139,7 @@ export function WelcomeMenu() {
} }
/** The menu that is shown next to the world selection graph */ /** The menu that is shown next to the world selection graph */
export function WorldSelectionMenu() { function WorldSelectionMenu() {
const [file, setFile] = React.useState<File>(); const [file, setFile] = React.useState<File>();
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
@ -178,4 +264,125 @@ export function WorldSelectionMenu() {
</nav> </nav>
} }
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(
<WorldPath source={nodes[edge[0]]} target={nodes[edge[1]]} unlocked={sourceCompleted}/>
)
}
// draw the worlds and levels
for (let worldId in nodes) {
let position: cytoscape.Position = nodes[worldId].position
svgElements.push(
<WorldIcon world={worldId}
title={nodes[worldId].data.title || worldId}
position={position}
completedLevels={completed[worldId]}
difficulty={difficulty}
key={`${gameId}-${worldId}`} />
)
for (let i = 1; i <= worldSize[worldId]; i++) {
svgElements.push(
<LevelIcon
world={worldId}
level={i}
position={position}
completed={completed[worldId][i]} unlocked={completed[worldId][i-1]}
key={`${gameId}-${worldId}-${i}`} />
)
}
}
}
let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null
return <div className="column">
<WorldSelectionMenu />
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
width={bounds ? `${ds * dx}` : ''}
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''}
className="world-selection"
>
{svgElements}
</svg>
</div>
}

Loading…
Cancel
Save