You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
/**
|
|
* @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 './world_tree.css'
|
|
|
|
// 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 (
|
|
<Link to={levelDisabled ? '' : `/${gameId}/world/${world}/level/${level == 1 ? 0 : level}`}
|
|
className={`level${levelDisabled ? ' disabled' : ''}`}>
|
|
<circle fill={completed ? lightgreen : unlocked? blue : lightgrey} 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, worldSize}:
|
|
{ world: string,
|
|
title: string,
|
|
position: cytoscape.Position,
|
|
completedLevels: any,
|
|
difficulty: number,
|
|
worldSize: number
|
|
}) {
|
|
|
|
// 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 <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}/>
|
|
{ false ? // fontSize >= MINFONT ?
|
|
// NOTE: This code would display the world names inside the bubble, but currently
|
|
// it isn't used.
|
|
// Label for large worlds
|
|
<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: fontSize + "px"}}>
|
|
{title ? title : world}
|
|
</p>
|
|
</div>
|
|
</foreignObject>
|
|
:
|
|
// Label for small worlds
|
|
<foreignObject x={s*position.x - 75} y={s*position.y + labelOffset}
|
|
width='150px' height='2em' style={{overflow: 'visible'}}
|
|
>
|
|
<div className='world-label' style={{backgroundColor: completed ? darkgreen : unlocked ? darkblue : darkgrey}}>
|
|
<p className='world-title' style={{fontSize: MINFONT + "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 : grey} strokeWidth={lineWidth} />
|
|
}
|
|
|
|
/** 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 gameId = React.useContext(GameIdContext)
|
|
const difficulty = useSelector(selectDifficulty(gameId))
|
|
const dispatch = useAppDispatch()
|
|
|
|
function label(x : number) {
|
|
return x == 0 ? 'none' : x == 1 ? 'relaxed' : 'regular'
|
|
}
|
|
|
|
|
|
return <nav className="world-selection-menu">
|
|
<div className="slider-wrap">
|
|
<span className="difficulty-label">Rules
|
|
<FontAwesomeIcon icon={rulesHelp ? faXmark : faCircleQuestion} className='helpButton' onClick={() => (setRulesHelp(!rulesHelp))}/>
|
|
</span>
|
|
<Slider
|
|
orientation="vertical"
|
|
title="Game Rules"
|
|
min={0} max={2}
|
|
aria-label="Game Rules"
|
|
defaultValue={difficulty}
|
|
marks={[
|
|
{value: 0, label: label(0)},
|
|
{value: 1, label: label(1)},
|
|
{value: 2, label: label(2)}
|
|
]}
|
|
valueLabelFormat={label}
|
|
getAriaValueText={label}
|
|
valueLabelDisplay="off"
|
|
onChange={(ev, val: number) => {
|
|
dispatch(changedDifficulty({game: gameId, difficulty: val}))
|
|
}}
|
|
></Slider>
|
|
</div>
|
|
</nav>
|
|
}
|
|
|
|
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(
|
|
<WorldPath key={`path_${edge[0]}-->${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(
|
|
<WorldIcon world={worldId}
|
|
title={nodes[worldId].data.title || worldId}
|
|
position={position}
|
|
completedLevels={completed[worldId]}
|
|
difficulty={difficulty}
|
|
key={`${gameId}-${worldId}`}
|
|
worldSize={worldSize[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}`}
|
|
worldSize={worldSize[worldId]}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 <div className="column">
|
|
<WorldSelectionMenu rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
|
|
<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>
|
|
}
|