/** * @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 { Slider } from '@mui/material' import cytoscape, { LayoutOptions } from 'cytoscape' import klay from 'cytoscape-klay' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faXmark, faCircleQuestion } from '@fortawesome/free-solid-svg-icons' import { GameIdContext } from '../app' import { useAppDispatch } from '../hooks' import { selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress' import { store } from '../state/store' import '../css/world_tree.css' import { PreferencesContext } from './infoview/context' import { useTranslation } from 'react-i18next' // Settings for the world tree cytoscape.use( klay ) const r = 16 // radius of a level const s = 10 // global scale const lineWidth = 10 // const ds = .75 // scale the resulting svg image const NMIN = 5 // min. worldsize const NLABEL = 8 // max. world size to display label below the world const NMAX = 16 // max. world size. Level icons start spiraling out if the world has more levels. const NSPIRAL = 12 // world size if NMAX has been passed and need to spiral. const MINFONT = 12 // colours const grey = '#999' const lightgrey = '#bbb' const green = 'green' // 118a11? const lightgreen = '#139e13' const blue = '#1976d2' const darkgrey = '#868686' const darkgreen = '#0e770e' const darkblue = '#1667b8' /** svg object for a level in the game tree */ export function LevelIcon({ world, level, position, completed, unlocked, worldSize }: { world: string, level: number, position: cytoscape.Position, completed: boolean, unlocked: boolean, worldSize: number }) { const N = Math.max(worldSize, NMIN) // divide circle into `N+2` equal pieces. // only for non-spiraling case const beta = 2 * Math.PI / Math.min(N+2, ((N < (NMAX+1) ? NMAX : NSPIRAL)+1)) // We want distance between two level icons to be `2.2*r`, therefore: // Sinus-Satz: (1.1*r) / sin(β/2) = R / sin(π/2) let R = 1.1 * r / Math.sin(beta/2) const gameId = React.useContext(GameIdContext) const difficulty = useSelector(selectDifficulty(gameId)) const levelDisabled = (difficulty >= 2 && !(unlocked || completed)) /** In the spiral, the angle `β` should decrease to avoid big gaps between levels. * This is a simplified function, which has little mathematical foundation, but * works fine in tests up to `N=30`. */ function betaSpiral(level) { return 2 * Math.PI / ((NSPIRAL+1) + 2 * Math.max(0, (level-2)) / (NSPIRAL+1)) } const x = N < (NMAX+1) ? // normal case s * position.x + Math.sin(level * beta) * R : // spiraling case s * position.x + Math.sin(level * betaSpiral(level)) * (R + 2*r*(level-1)/(NSPIRAL+1)) const y = N < (NMAX+1) ? // normal case s * position.y - Math.cos(level * beta) * R : // spiraling case s * position.y - Math.cos(level * betaSpiral(level)) * (R + 2*r*(level-1)/(NSPIRAL+1)) return (

{level}

) } /** svg object of one world in the game tree */ export function WorldIcon({world, title, position, completedLevels, difficulty, worldSize}: { world: string, title: string, position: cytoscape.Position, completedLevels: any, difficulty: number, worldSize: number }) { const { t } = useTranslation() // See level icons. Match radius computed there minus `1.2*r` const N = Math.max(worldSize, NMIN) const betaHalf = Math.PI / Math.min(N+2, ((N < (NMAX+1) ? NMAX : NSPIRAL) + 1)) let R = 1.1 * r / Math.sin(betaHalf) - 1.2 * r let fontSize = Math.floor(R/4) // Offset for the labels for small worlds let labelOffset = R + 2.5 * r // 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 { false ? // fontSize >= MINFONT ? // NOTE: This code would display the world names inside the bubble, but currently // it isn't used. // Label for large worlds

{title ? t(title, {ns: gameId}) : world}

: // Label for small worlds

{title ? t(title, {ns: gameId}) : 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` */ export 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 a.href = window.URL.createObjectURL(blob) const clickEvt = new MouseEvent('click', { view: window, bubbles: true, cancelable: true, }) a.dispatchEvent(clickEvt) a.remove() } /** The menu that is shown next to the world selection graph */ export function WorldSelectionMenu({rulesHelp, setRulesHelp}) { const { t, i18n } = useTranslation() const gameId = React.useContext(GameIdContext) const difficulty = useSelector(selectDifficulty(gameId)) const dispatch = useAppDispatch() const { mobile } = React.useContext(PreferencesContext) function label(x : number) { return x == 0 ? t("none") : x == 1 ? t("relaxed") : t("regular") } return } 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 }) 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, rulesHelp, setRulesHelp}: { worlds: any, worldSize: any, rulesHelp: boolean, setRulesHelp: 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 their 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( ${edge[1]}`} 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( ) for (let i = 1; i <= worldSize[worldId]; i++) { svgElements.push( ) } } } // See `LevelIcon` for calculation of the radius. Use the max. radius for calculating the padding // TODO: Is there a way to determine padding according to the drawn objects? let R = 1.1 * r / Math.sin(Math.PI / (NMAX+1)) const padding = R + 2.1*r let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null return
{svgElements}
}