Merge branch 'dev'
commit
f5eb185eb2
@ -1,26 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Outlet, useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
import '@fontsource/roboto/300.css';
|
|
||||||
import '@fontsource/roboto/400.css';
|
|
||||||
import '@fontsource/roboto/500.css';
|
|
||||||
import '@fontsource/roboto/700.css';
|
|
||||||
|
|
||||||
import './reset.css';
|
|
||||||
import './app.css';
|
|
||||||
|
|
||||||
export const GameIdContext = React.createContext<string>(undefined);
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const params = useParams();
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<GameIdContext.Provider value={"g/" + params.owner + "/" + params.repo}>
|
|
||||||
<Outlet />
|
|
||||||
</GameIdContext.Provider>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import App from './App';
|
import App from './app';
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
test('renders learn react link', () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import '@fontsource/roboto/300.css';
|
||||||
|
import '@fontsource/roboto/400.css';
|
||||||
|
import '@fontsource/roboto/500.css';
|
||||||
|
import '@fontsource/roboto/700.css';
|
||||||
|
|
||||||
|
import './reset.css';
|
||||||
|
import './app.css';
|
||||||
|
import { MobileContext } from './components/infoview/context';
|
||||||
|
import { useWindowDimensions } from './window_width';
|
||||||
|
|
||||||
|
export const GameIdContext = React.createContext<string>(undefined);
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const params = useParams()
|
||||||
|
const gameId = "g/" + params.owner + "/" + params.repo
|
||||||
|
|
||||||
|
// TODO: Make mobileLayout be changeable in settings
|
||||||
|
// TODO: Handle resize Events
|
||||||
|
const {width, height} = useWindowDimensions()
|
||||||
|
const [mobile, setMobile] = React.useState(width < 800)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<GameIdContext.Provider value={gameId}>
|
||||||
|
<MobileContext.Provider value={{mobile, setMobile}}>
|
||||||
|
<Outlet />
|
||||||
|
</MobileContext.Provider>
|
||||||
|
</GameIdContext.Provider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Link, LinkProps } from "react-router-dom";
|
|
||||||
|
|
||||||
export interface ButtonProps extends LinkProps {
|
|
||||||
disabled?: boolean
|
|
||||||
inverted?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Button(props: ButtonProps) {
|
|
||||||
if (props.disabled) {
|
|
||||||
return <span className={`btn btn-disabled ${props.inverted ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
|
|
||||||
} else {
|
|
||||||
return <Link className={`btn ${props.inverted ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { Button } from './Button'
|
|
||||||
import { GameIdContext } from '../App';
|
|
||||||
import { useStore } from 'react-redux';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../hooks';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { faDownload, faUpload, faEraser } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
|
|
||||||
import { deleteProgress, selectProgress, loadProgress, GameProgressState } from '../state/progress';
|
|
||||||
|
|
||||||
const downloadFile = ({ data, fileName, fileType }) => {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
function GameMenu() {
|
|
||||||
|
|
||||||
const [file, setFile] = React.useState<File>();
|
|
||||||
|
|
||||||
const gameId = React.useContext(GameIdContext)
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const [eraseMenu, setEraseMenu] = React.useState(false);
|
|
||||||
const openEraseMenu = () => setEraseMenu(true);
|
|
||||||
const closeEraseMenu = () => setEraseMenu(false);
|
|
||||||
|
|
||||||
const [uploadMenu, setUploadMenu] = React.useState(false);
|
|
||||||
const openUploadMenu = () => setUploadMenu(true);
|
|
||||||
const closeUploadMenu = () => setUploadMenu(false);
|
|
||||||
|
|
||||||
const gameProgress = useSelector(selectProgress(gameId))
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
const downloadProgress = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
downloadFile({
|
|
||||||
data: JSON.stringify(gameProgress),
|
|
||||||
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
|
|
||||||
fileType: 'text/json',
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = (e) => {
|
|
||||||
if (e.target.files) {
|
|
||||||
setFile(e.target.files[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadProgress = (e) => {
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileReader = new FileReader();
|
|
||||||
fileReader.readAsText(file, "UTF-8");
|
|
||||||
fileReader.onload = (e) => {
|
|
||||||
const data = JSON.parse(e.target.result.toString()) as GameProgressState;
|
|
||||||
console.debug("Json Data", data);
|
|
||||||
|
|
||||||
dispatch(loadProgress({game: gameId, data: data}))
|
|
||||||
}
|
|
||||||
|
|
||||||
closeUploadMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const eraseProgress = () => {
|
|
||||||
dispatch(deleteProgress({game: gameId}))
|
|
||||||
closeEraseMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadAndErase = (e) => {
|
|
||||||
downloadProgress(e)
|
|
||||||
eraseProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
return <nav className="game-menu">
|
|
||||||
<Button onClick={downloadProgress} title="Download game progress" to=""><FontAwesomeIcon icon={faDownload} /></Button>
|
|
||||||
<Button title="Load game progress from JSON" onClick={openUploadMenu} to=""><FontAwesomeIcon icon={faUpload} /></Button>
|
|
||||||
<Button title="Clear game progress" to="" onClick={openEraseMenu}><FontAwesomeIcon icon={faEraser} /></Button>
|
|
||||||
|
|
||||||
{eraseMenu?
|
|
||||||
<div className="modal-wrapper">
|
|
||||||
<div className="modal-backdrop" onClick={closeEraseMenu} />
|
|
||||||
<div className="modal">
|
|
||||||
<div className="codicon codicon-close modal-close" onClick={closeEraseMenu}></div>
|
|
||||||
<h2>Delete Progress?</h2>
|
|
||||||
|
|
||||||
<p>Do you want to delete your saved progress irreversibly?</p>
|
|
||||||
<p>(This only affects your saved proofs, no levels are ever locked.
|
|
||||||
Saves from other games are not deleted.)</p>
|
|
||||||
|
|
||||||
<Button onClick={eraseProgress} to="">Delete</Button>
|
|
||||||
<Button onClick={downloadAndErase} to="">Download & Delete</Button>
|
|
||||||
<Button onClick={closeEraseMenu} to="">Cancel</Button>
|
|
||||||
</div>
|
|
||||||
</div> : null}
|
|
||||||
|
|
||||||
{uploadMenu ?
|
|
||||||
<div className="modal-wrapper">
|
|
||||||
<div className="modal-backdrop" onClick={closeUploadMenu} />
|
|
||||||
<div className="modal">
|
|
||||||
<div className="codicon codicon-close modal-close" onClick={closeUploadMenu}></div>
|
|
||||||
<h2>Upload Saved Progress</h2>
|
|
||||||
|
|
||||||
<p>Select a JSON file with the saved game progress to load your progress.</p>
|
|
||||||
|
|
||||||
<p><b>Warning:</b> This will delete your current game progress!
|
|
||||||
Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p>
|
|
||||||
|
|
||||||
<input type="file" onChange={handleFileChange}/>
|
|
||||||
|
|
||||||
<Button to="" onClick={uploadProgress}>Load selected file</Button>
|
|
||||||
</div>
|
|
||||||
</div> : null}
|
|
||||||
</nav>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GameMenu
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import './inventory.css'
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import Markdown from './Markdown';
|
|
||||||
import { useLoadDocQuery, InventoryTile, LevelInfo } from '../state/api';
|
|
||||||
import { GameIdContext } from '../App';
|
|
||||||
|
|
||||||
export function Inventory({levelInfo, setInventoryDoc } :
|
|
||||||
{
|
|
||||||
levelInfo: LevelInfo,
|
|
||||||
setInventoryDoc: (inventoryDoc: {name: string, type: string}) => void,
|
|
||||||
}) {
|
|
||||||
|
|
||||||
// TODO: This seems like a useless wrapper to me
|
|
||||||
function openDoc(name, type) {
|
|
||||||
setInventoryDoc({name, type})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inventory">
|
|
||||||
{/* TODO: Click on Tactic: show info
|
|
||||||
TODO: click on paste icon -> paste into command line */}
|
|
||||||
<h2>Tactics</h2>
|
|
||||||
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} />
|
|
||||||
|
|
||||||
<h2>Definitions</h2>
|
|
||||||
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} />
|
|
||||||
|
|
||||||
<h2>Lemmas</h2>
|
|
||||||
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc}
|
|
||||||
defaultTab={levelInfo?.lemmaTab} level={levelInfo}/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined} :
|
|
||||||
{
|
|
||||||
items: InventoryTile[],
|
|
||||||
docType: string,
|
|
||||||
openDoc(name: string, type: string): void,
|
|
||||||
defaultTab? : string,
|
|
||||||
level? : LevelInfo,
|
|
||||||
}) {
|
|
||||||
// TODO: `level` is only used in the `useEffect` below to check if a new level has
|
|
||||||
// been loaded. Is there a better way to observe this?
|
|
||||||
|
|
||||||
const categorySet = new Set<string>()
|
|
||||||
for (let item of items) {
|
|
||||||
categorySet.add(item.category)
|
|
||||||
}
|
|
||||||
const categories = Array.from(categorySet).sort()
|
|
||||||
|
|
||||||
const [tab, setTab] = useState(defaultTab);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
|
|
||||||
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
|
|
||||||
if (defaultTab) {
|
|
||||||
setTab(defaultTab)
|
|
||||||
}}, [level])
|
|
||||||
|
|
||||||
return <>
|
|
||||||
{categories.length > 1 &&
|
|
||||||
<div className="tab-bar">
|
|
||||||
{categories.map((cat) =>
|
|
||||||
<div className={`tab ${cat == (tab ?? categories[0]) ? "active": ""}`} onClick={() => { setTab(cat) }}>{cat}</div>)}
|
|
||||||
</div>}
|
|
||||||
<div className="inventory-list">
|
|
||||||
{ [...items].sort(
|
|
||||||
// Sort entries `available > disabled > locked`.
|
|
||||||
(x, y) => +x.locked - +y.locked || +x.disabled - +y.disabled
|
|
||||||
).map(item => {
|
|
||||||
if ((tab ?? categories[0]) == item.category) {
|
|
||||||
return <InventoryItem key={item.name} showDoc={() => {openDoc(item.name, docType)}}
|
|
||||||
name={item.name} displayName={item.displayName} locked={item.locked}
|
|
||||||
disabled={item.disabled} newly={item.new}/>
|
|
||||||
}
|
|
||||||
}) }
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
function InventoryItem({name, displayName, locked, disabled, newly, showDoc}) {
|
|
||||||
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
|
|
||||||
disabled ? <FontAwesomeIcon icon={faBan} /> : ""
|
|
||||||
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
|
|
||||||
const title = locked ? "Not unlocked yet" :
|
|
||||||
disabled ? "Not available in this level" : ""
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (!locked && !disabled) {
|
|
||||||
showDoc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={`item ${className}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Documentation({name, type}) {
|
|
||||||
const gameId = React.useContext(GameIdContext)
|
|
||||||
const doc = useLoadDocQuery({game: gameId, type: type, name: name})
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<h2 className="doc">{doc.data?.displayName}</h2>
|
|
||||||
<p><code>{doc.data?.statement}</code></p>
|
|
||||||
{/* <code>docstring: {doc.data?.docstring}</code> */}
|
|
||||||
<Markdown>{doc.data?.content}</Markdown>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
@ -1,429 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
|
||||||
import '@fontsource/roboto/300.css';
|
|
||||||
import '@fontsource/roboto/400.css';
|
|
||||||
import '@fontsource/roboto/500.css';
|
|
||||||
import '@fontsource/roboto/700.css';
|
|
||||||
import { InfoviewApi } from '@leanprover/infoview'
|
|
||||||
import { Link, useParams } from 'react-router-dom';
|
|
||||||
import { Box, CircularProgress, FormControlLabel, FormGroup, Switch, IconButton } from '@mui/material';
|
|
||||||
import MuiDrawer from '@mui/material/Drawer';
|
|
||||||
import Grid from '@mui/material/Unstable_Grid2';
|
|
||||||
import {Inventory, Documentation} from './Inventory';
|
|
||||||
import { LeanTaskGutter } from 'lean4web/client/src/editor/taskgutter';
|
|
||||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
|
|
||||||
import 'lean4web/client/src/editor/vscode.css';
|
|
||||||
import 'lean4web/client/src/editor/infoview.css';
|
|
||||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
|
|
||||||
import { InfoProvider } from 'lean4web/client/src/editor/infoview';
|
|
||||||
import 'lean4web/client/src/editor/infoview.css'
|
|
||||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
|
||||||
import './level.css'
|
|
||||||
import { Button } from './Button'
|
|
||||||
import { ConnectionContext, useLeanClient } from '../connection';
|
|
||||||
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api';
|
|
||||||
import { changedSelection, codeEdited, selectCode, selectSelections, progressSlice, selectCompleted } from '../state/progress';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../hooks';
|
|
||||||
import { useStore } from 'react-redux';
|
|
||||||
|
|
||||||
import { EditorContext, ConfigContext, ProgressContext, VersionContext } from '../../../node_modules/lean4-infoview/src/infoview/contexts';
|
|
||||||
import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-infoview/src/infoview/editorConnection';
|
|
||||||
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event';
|
|
||||||
import { Main } from './infoview/main'
|
|
||||||
import type { Location } from 'vscode-languageserver-protocol';
|
|
||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { faHome, faArrowRight, faArrowLeft, faRotateLeft } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
|
|
||||||
import { styled, useTheme, Theme, CSSObject } from '@mui/material/styles';
|
|
||||||
import Markdown from './Markdown';
|
|
||||||
|
|
||||||
import Split from 'react-split'
|
|
||||||
import { Alert } from '@mui/material';
|
|
||||||
import { GameIdContext } from '../App';
|
|
||||||
|
|
||||||
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(null as any);
|
|
||||||
|
|
||||||
export const InputModeContext = React.createContext<{
|
|
||||||
commandLineMode: boolean,
|
|
||||||
setCommandLineMode: React.Dispatch<React.SetStateAction<boolean>>,
|
|
||||||
commandLineInput: string,
|
|
||||||
setCommandLineInput: React.Dispatch<React.SetStateAction<string>>
|
|
||||||
}>({
|
|
||||||
commandLineMode: true,
|
|
||||||
setCommandLineMode: () => {},
|
|
||||||
commandLineInput: "",
|
|
||||||
setCommandLineInput: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
function Level() {
|
|
||||||
|
|
||||||
const params = useParams();
|
|
||||||
const levelId = parseInt(params.levelId)
|
|
||||||
const worldId = params.worldId
|
|
||||||
|
|
||||||
useLoadWorldFiles(worldId)
|
|
||||||
|
|
||||||
if (levelId == 0) {
|
|
||||||
return <Introduction worldId={worldId} />
|
|
||||||
} else {
|
|
||||||
return <PlayableLevel worldId={worldId} levelId={levelId} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function PlayableLevel({worldId, levelId}) {
|
|
||||||
const codeviewRef = useRef<HTMLDivElement>(null)
|
|
||||||
const introductionPanelRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const gameId = React.useContext(GameIdContext)
|
|
||||||
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
|
|
||||||
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
|
|
||||||
|
|
||||||
const [commandLineMode, setCommandLineMode] = useState(true)
|
|
||||||
const [commandLineInput, setCommandLineInput] = useState("")
|
|
||||||
const [canUndo, setCanUndo] = useState(initialCode.trim() !== "")
|
|
||||||
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Scroll to top when loading a new level
|
|
||||||
introductionPanelRef.current!.scrollTo(0,0)
|
|
||||||
// Reset command line input when loading a new level
|
|
||||||
setCommandLineInput("")
|
|
||||||
}, [levelId])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!commandLineMode) {
|
|
||||||
// Delete last input attempt from command line
|
|
||||||
editor.executeEdits("command-line", [{
|
|
||||||
range: editor.getSelection(),
|
|
||||||
text: "",
|
|
||||||
forceMoveMarkers: false
|
|
||||||
}]);
|
|
||||||
editor.focus()
|
|
||||||
}
|
|
||||||
}, [commandLineMode])
|
|
||||||
|
|
||||||
const handleUndo = () => {
|
|
||||||
const endPos = editor.getModel().getFullModelRange().getEndPosition()
|
|
||||||
let range
|
|
||||||
console.log(endPos.column)
|
|
||||||
if (endPos.column === 1) {
|
|
||||||
range = monaco.Selection.fromPositions(
|
|
||||||
new monaco.Position(endPos.lineNumber - 1, 1),
|
|
||||||
endPos
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
range = monaco.Selection.fromPositions(
|
|
||||||
new monaco.Position(endPos.lineNumber, 1),
|
|
||||||
endPos
|
|
||||||
)
|
|
||||||
}
|
|
||||||
editor.executeEdits("undo-button", [{
|
|
||||||
range,
|
|
||||||
text: "",
|
|
||||||
forceMoveMarkers: false
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
|
||||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
const onDidChangeContent = (code) => {
|
|
||||||
dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code}))
|
|
||||||
|
|
||||||
setCanUndo(code.trim() !== "")
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDidChangeSelection = (monacoSelections) => {
|
|
||||||
const selections = monacoSelections.map(
|
|
||||||
({selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}) =>
|
|
||||||
{return {selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}})
|
|
||||||
dispatch(changedSelection({game: gameId, world: worldId, level: levelId, selections}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
|
||||||
|
|
||||||
const {editor, infoProvider, editorConnection} =
|
|
||||||
useLevelEditor(worldId, levelId, codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection)
|
|
||||||
|
|
||||||
// Effect when command line mode gets enabled
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor && commandLineMode) {
|
|
||||||
let endPos = editor.getModel().getFullModelRange().getEndPosition()
|
|
||||||
if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") {
|
|
||||||
editor.executeEdits("command-line", [{
|
|
||||||
range: monaco.Selection.fromPositions(endPos, endPos),
|
|
||||||
text: "\n",
|
|
||||||
forceMoveMarkers: true
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
endPos = editor.getModel().getFullModelRange().getEndPosition()
|
|
||||||
let currPos = editor.getPosition()
|
|
||||||
if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) {
|
|
||||||
// This is not a position that would naturally occur from CommandLine, reset:
|
|
||||||
editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [editor, commandLineMode])
|
|
||||||
|
|
||||||
// if this is set to a pair `(name, type)` then the according doc will be open.
|
|
||||||
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
|
|
||||||
|
|
||||||
const levelTitle = <>{levelId && `Level ${levelId}`}{level?.data?.title && `: ${level?.data?.title}`}</>
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
|
||||||
<LevelAppBar isLoading={level.isLoading} levelTitle={levelTitle} worldId={worldId} levelId={levelId} />
|
|
||||||
<Split minSize={0} snapOffset={200} sizes={[50, 25, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
|
|
||||||
<div className="exercise-panel">
|
|
||||||
<div ref={introductionPanelRef} className="introduction-panel">
|
|
||||||
{level?.data?.introduction &&
|
|
||||||
<div className="message info">
|
|
||||||
<Markdown>{level?.data?.introduction}</Markdown>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div className="exercise">
|
|
||||||
<Markdown>
|
|
||||||
{(level?.data?.statementName ?
|
|
||||||
`**Theorem** \`${level?.data?.statementName}\`: `
|
|
||||||
:
|
|
||||||
level?.data?.descrText && "**Exercise**: ")
|
|
||||||
+ `${level?.data?.descrText}`
|
|
||||||
}
|
|
||||||
</Markdown>
|
|
||||||
<div className={`statement ${commandLineMode ? 'hidden' : ''}`}><code>{level?.data?.descrFormat}</code></div>
|
|
||||||
<div ref={codeviewRef} className={`codeview ${commandLineMode ? 'hidden' : ''}`}></div>
|
|
||||||
</div>
|
|
||||||
<div className="input-mode-switch">
|
|
||||||
{commandLineMode && <button className="btn" onClick={handleUndo} disabled={!canUndo}><FontAwesomeIcon icon={faRotateLeft} /> Undo</button>}
|
|
||||||
<FormGroup>
|
|
||||||
<FormControlLabel control={<Switch onChange={(ev) => { setCommandLineMode(!commandLineMode) }} />} label="Editor mode" />
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EditorContext.Provider value={editorConnection}>
|
|
||||||
<MonacoEditorContext.Provider value={editor}>
|
|
||||||
<InputModeContext.Provider value={{commandLineMode, setCommandLineMode, commandLineInput, setCommandLineInput}}>
|
|
||||||
{editorConnection && <Main key={`${worldId}/${levelId}`} world={worldId} level={levelId} />}
|
|
||||||
</InputModeContext.Provider>
|
|
||||||
</MonacoEditorContext.Provider>
|
|
||||||
</EditorContext.Provider>
|
|
||||||
|
|
||||||
{completed && <div className="conclusion">
|
|
||||||
{level?.data?.conclusion?.trim() &&
|
|
||||||
<div className="message info">
|
|
||||||
<Markdown>{level?.data?.conclusion}</Markdown>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{levelId >= gameInfo.data?.worldSize[worldId] ?
|
|
||||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
|
|
||||||
<Button to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}>
|
|
||||||
Next <FontAwesomeIcon icon={faArrowRight} /></Button>}
|
|
||||||
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
<div className="inventory-panel">
|
|
||||||
{!level.isLoading &&
|
|
||||||
<Inventory levelInfo={level?.data} setInventoryDoc={setInventoryDoc} />}
|
|
||||||
</div>
|
|
||||||
<div className="doc-panel">
|
|
||||||
{inventoryDoc && <Documentation name={inventoryDoc.name} type={inventoryDoc.type} />}
|
|
||||||
</div>
|
|
||||||
</Split>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Level
|
|
||||||
|
|
||||||
function Introduction({worldId}) {
|
|
||||||
const gameId = React.useContext(GameIdContext)
|
|
||||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<div style={gameInfo.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
|
||||||
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Einführung" worldId={worldId} levelId={0} />
|
|
||||||
<div style={gameInfo.isLoading ? {display: "none"} : null} className="exercise-panel">
|
|
||||||
<div className="introduction-panel">
|
|
||||||
<div className="message info">
|
|
||||||
<Markdown>
|
|
||||||
{gameInfo.data?.worlds.nodes[worldId].introduction}
|
|
||||||
</Markdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="conclusion">
|
|
||||||
{0 == gameInfo.data?.worldSize[worldId] ?
|
|
||||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
|
|
||||||
<Button to={`/${gameId}/world/${worldId}/level/1`}>
|
|
||||||
Start <FontAwesomeIcon icon={faArrowRight} />
|
|
||||||
</Button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
function LevelAppBar({isLoading, levelId, worldId, levelTitle}) {
|
|
||||||
const gameId = React.useContext(GameIdContext)
|
|
||||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
|
||||||
|
|
||||||
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
|
|
||||||
<div>
|
|
||||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button>
|
|
||||||
<span className="app-bar-title">
|
|
||||||
{gameInfo.data?.worlds.nodes[worldId].title && `World: ${gameInfo.data?.worlds.nodes[worldId].title}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="app-bar-title">
|
|
||||||
{levelTitle}
|
|
||||||
</span>
|
|
||||||
<Button disabled={levelId <= 0} inverted={true}
|
|
||||||
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
|
|
||||||
><FontAwesomeIcon icon={faArrowLeft} /> Previous</Button>
|
|
||||||
<Button disabled={levelId >= gameInfo.data?.worldSize[worldId]} inverted={true}
|
|
||||||
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}
|
|
||||||
>Next <FontAwesomeIcon icon={faArrowRight} /></Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function useLevelEditor(worldId: string, levelId: number, codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
|
|
||||||
|
|
||||||
const connection = React.useContext(ConnectionContext)
|
|
||||||
const gameId = React.useContext(GameIdContext)
|
|
||||||
|
|
||||||
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
|
|
||||||
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
|
|
||||||
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
|
|
||||||
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
|
|
||||||
|
|
||||||
// Create Editor
|
|
||||||
useEffect(() => {
|
|
||||||
const editor = monaco.editor.create(codeviewRef.current!, {
|
|
||||||
glyphMargin: true,
|
|
||||||
quickSuggestions: false,
|
|
||||||
lightbulb: {
|
|
||||||
enabled: true
|
|
||||||
},
|
|
||||||
unicodeHighlight: {
|
|
||||||
ambiguousCharacters: false,
|
|
||||||
},
|
|
||||||
automaticLayout: true,
|
|
||||||
minimap: {
|
|
||||||
enabled: false
|
|
||||||
},
|
|
||||||
lineNumbersMinChars: 3,
|
|
||||||
'semanticHighlighting.enabled': true,
|
|
||||||
theme: 'vs-code-theme-converted'
|
|
||||||
})
|
|
||||||
|
|
||||||
const infoProvider = new InfoProvider(connection.getLeanClient(gameId))
|
|
||||||
|
|
||||||
const editorApi = infoProvider.getApi()
|
|
||||||
|
|
||||||
const editorEvents: EditorEvents = {
|
|
||||||
initialize: new EventEmitter(),
|
|
||||||
gotServerNotification: new EventEmitter(),
|
|
||||||
sentClientNotification: new EventEmitter(),
|
|
||||||
serverRestarted: new EventEmitter(),
|
|
||||||
serverStopped: new EventEmitter(),
|
|
||||||
changedCursorLocation: new EventEmitter(),
|
|
||||||
changedInfoviewConfig: new EventEmitter(),
|
|
||||||
runTestScript: new EventEmitter(),
|
|
||||||
requestedAction: new EventEmitter(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Challenge: write a type-correct fn from `Eventify<T>` to `T` without using `any`
|
|
||||||
const infoviewApi: InfoviewApi = {
|
|
||||||
initialize: async l => editorEvents.initialize.fire(l),
|
|
||||||
gotServerNotification: async (method, params) => {
|
|
||||||
editorEvents.gotServerNotification.fire([method, params]);
|
|
||||||
},
|
|
||||||
sentClientNotification: async (method, params) => {
|
|
||||||
editorEvents.sentClientNotification.fire([method, params]);
|
|
||||||
},
|
|
||||||
serverRestarted: async r => editorEvents.serverRestarted.fire(r),
|
|
||||||
serverStopped: async serverStoppedReason => {
|
|
||||||
editorEvents.serverStopped.fire(serverStoppedReason)
|
|
||||||
},
|
|
||||||
changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc),
|
|
||||||
changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf),
|
|
||||||
requestedAction: async action => editorEvents.requestedAction.fire(action),
|
|
||||||
// See https://rollupjs.org/guide/en/#avoiding-eval
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
||||||
runTestScript: async script => new Function(script)(),
|
|
||||||
getInfoviewHtml: async () => document.body.innerHTML,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ec = new EditorConnection(editorApi, editorEvents);
|
|
||||||
setEditorConnection(ec)
|
|
||||||
|
|
||||||
editorEvents.initialize.on((loc: Location) => ec.events.changedCursorLocation.fire(loc))
|
|
||||||
|
|
||||||
setEditor(editor)
|
|
||||||
setInfoProvider(infoProvider)
|
|
||||||
setInfoviewApi(infoviewApi)
|
|
||||||
|
|
||||||
return () => { infoProvider.dispose(); editor.dispose() }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const {leanClient, leanClientStarted} = useLeanClient(gameId)
|
|
||||||
|
|
||||||
// Create model when level changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor && leanClientStarted) {
|
|
||||||
|
|
||||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
|
||||||
let model = monaco.editor.getModel(uri)
|
|
||||||
if (!model) {
|
|
||||||
model = monaco.editor.createModel(initialCode, 'lean4', uri)
|
|
||||||
}
|
|
||||||
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
|
|
||||||
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
|
|
||||||
editor.setModel(model)
|
|
||||||
if (initialSelections) {
|
|
||||||
editor.setSelections(initialSelections)
|
|
||||||
}
|
|
||||||
|
|
||||||
infoviewApi.serverRestarted(leanClient.initializeResult)
|
|
||||||
infoProvider.openPreview(editor, infoviewApi)
|
|
||||||
|
|
||||||
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
|
|
||||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
|
|
||||||
|
|
||||||
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
|
|
||||||
}
|
|
||||||
}, [editor, levelId, connection, leanClientStarted])
|
|
||||||
|
|
||||||
return {editor, infoProvider, editorConnection}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open all files in this world on the server so that they will load faster when accessed */
|
|
||||||
function useLoadWorldFiles(worldId) {
|
|
||||||
const gameId = React.useContext(GameIdContext)
|
|
||||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gameInfo.data) {
|
|
||||||
const models = []
|
|
||||||
for (let levelId = 1; levelId <= gameInfo.data.worldSize[worldId]; levelId++) {
|
|
||||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
|
||||||
let model = monaco.editor.getModel(uri)
|
|
||||||
if (model) {
|
|
||||||
models.push(model)
|
|
||||||
} else {
|
|
||||||
const code = selectCode(gameId, worldId, levelId)(store.getState())
|
|
||||||
models.push(monaco.editor.createModel(code, 'lean4', uri))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return () => { for (let model of models) { model.dispose() } }
|
|
||||||
}
|
|
||||||
}, [gameInfo.data, worldId])
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import { faShield } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
function PrivacyPolicyPopup ({handleClose}) {
|
|
||||||
return <div className="modal-wrapper">
|
|
||||||
<div className="modal-backdrop" onClick={handleClose} />
|
|
||||||
<div className="modal">
|
|
||||||
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
|
||||||
<h2>Privacy Policy & Impressum</h2>
|
|
||||||
|
|
||||||
<p>Our server collects metadata (such as IP address, browser, operating system)
|
|
||||||
and the data that the user enters into the editor. The data is used to
|
|
||||||
compute the Lean output and display it to the user. The information will be stored
|
|
||||||
as long as the user stays on our website and will be deleted immediately afterwards.
|
|
||||||
We keep logs to improve our software, but the contained data is anonymized.</p>
|
|
||||||
|
|
||||||
<p>We do not use cookies, but your game progress is stored in the browser
|
|
||||||
as site data. Your game progress is not saved on the server; if you delete
|
|
||||||
your browser storage, it is completely gone.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>Our server is located in Germany.</p>
|
|
||||||
|
|
||||||
<p><strong>Contact information:</strong><br />
|
|
||||||
Jon Eugster<br />
|
|
||||||
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
|
||||||
Universitätsstr. 1<br />
|
|
||||||
40225 Düsseldorf<br />
|
|
||||||
Germany<br />
|
|
||||||
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
|
||||||
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const PrivacyPolicy: React.FC = () => {
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
const handleOpen = () => setOpen(true);
|
|
||||||
const handleClose = () => setOpen(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
<div className="privacy" onClick={handleOpen} title="Privacy Policy & Impressum">
|
|
||||||
<FontAwesomeIcon icon={faShield} />
|
|
||||||
<p className="p1">legal</p>
|
|
||||||
<p className="p2">notes</p>
|
|
||||||
</div>
|
|
||||||
{open?
|
|
||||||
<PrivacyPolicyPopup handleClose={handleClose} />: null}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// export default PrivacyPolicy
|
|
||||||
|
|
||||||
export {
|
|
||||||
PrivacyPolicy,
|
|
||||||
PrivacyPolicyPopup,
|
|
||||||
}
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import './welcome.css'
|
|
||||||
import cytoscape, { LayoutOptions } from 'cytoscape'
|
|
||||||
import klay from 'cytoscape-klay';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import Split from 'react-split'
|
|
||||||
|
|
||||||
import GameMenu from './GameMenu';
|
|
||||||
import {PrivacyPolicy} from './PrivacyPolicy';
|
|
||||||
|
|
||||||
cytoscape.use( klay );
|
|
||||||
|
|
||||||
import { Box, Typography, CircularProgress } from '@mui/material';
|
|
||||||
import { useGetGameInfoQuery } from '../state/api';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Markdown from './Markdown';
|
|
||||||
import { selectCompleted } from '../state/progress';
|
|
||||||
import { GameIdContext } from '../App';
|
|
||||||
import { Button } from './Button';
|
|
||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { faDownload, faUpload, faEraser } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
|
|
||||||
|
|
||||||
const N = 24 // max number of levels per world
|
|
||||||
const R = 800 // radius of a world
|
|
||||||
const r = 110 // radius of a level
|
|
||||||
const s = 100 // global scale
|
|
||||||
const padding = 2000 // padding of the graphic (on a different scale)
|
|
||||||
|
|
||||||
function LevelIcon({ worldId, levelId, position }) {
|
|
||||||
const gameId = React.useContext(GameIdContext)
|
|
||||||
const completed = useSelector(selectCompleted(gameId, worldId,levelId))
|
|
||||||
|
|
||||||
const x = s * position.x + Math.sin(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2*Math.floor((levelId - 1)/N))
|
|
||||||
const y = s * position.y - Math.cos(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2*Math.floor((levelId - 1)/N))
|
|
||||||
|
|
||||||
// TODO: relative positioning?
|
|
||||||
return (
|
|
||||||
<Link to={`/${gameId}/world/${worldId}/level/${levelId}`}>
|
|
||||||
<circle fill={completed ? "green" :"#999"} cx={x} cy={y} r={r} />
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Welcome() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const gameId = React.useContext(GameIdContext)
|
|
||||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
|
||||||
|
|
||||||
const { nodes, bounds }: any = gameInfo.data ? computeWorldLayout(gameInfo.data?.worlds) : {nodes: []}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gameInfo.data?.title) {
|
|
||||||
window.document.title = gameInfo.data.title
|
|
||||||
}
|
|
||||||
}, [gameInfo.data?.title])
|
|
||||||
|
|
||||||
const svgElements = []
|
|
||||||
|
|
||||||
if (gameInfo.data) {
|
|
||||||
for (let i in gameInfo.data.worlds.edges) {
|
|
||||||
const edge = gameInfo.data.worlds.edges[i]
|
|
||||||
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="#1976d2" strokeWidth={s}/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let id in nodes) {
|
|
||||||
let position: cytoscape.Position = nodes[id].position
|
|
||||||
|
|
||||||
for (let i = 1; i <= gameInfo.data.worldSize[id]; i++) {
|
|
||||||
svgElements.push(
|
|
||||||
<LevelIcon
|
|
||||||
key={`/${gameId}/world/${id}/level/${i}`}
|
|
||||||
position={position} worldId={id} levelId={i} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
svgElements.push(
|
|
||||||
<Link key={`world${id}`} to={`/${gameId}/world/${id}/level/0`}>
|
|
||||||
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
|
|
||||||
fill="#1976d2"/>
|
|
||||||
<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>
|
|
||||||
<p className="world-title" style={{fontSize: Math.floor(R/4) + "px"}}>
|
|
||||||
{nodes[id].data.title ? nodes[id].data.title : id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</foreignObject>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="app-content ">
|
|
||||||
{ gameInfo.isLoading?
|
|
||||||
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
:
|
|
||||||
<Split className="welcome" minSize={200} sizes={[70, 30]}>
|
|
||||||
<div className="column">
|
|
||||||
<Typography variant="body1" component="div" className="welcome-text">
|
|
||||||
<Markdown>{gameInfo.data?.introduction}</Markdown>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<div className="column">
|
|
||||||
<GameMenu />
|
|
||||||
<Box textAlign='center' sx={{ m: 5 }}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
||||||
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${s*bounds.x2 - s*bounds.x1 + 2 * padding} ${s*bounds.y2 - s*bounds.y1 + 2 * padding}` : ''}>
|
|
||||||
{svgElements}
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
</Split>
|
|
||||||
}
|
|
||||||
<PrivacyPolicy />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Link, LinkProps } from "react-router-dom";
|
||||||
|
|
||||||
|
export interface ButtonProps extends LinkProps {
|
||||||
|
disabled?: boolean
|
||||||
|
inverted?: string // Apparently "inverted" in DOM cannot be `boolean` but must be `inverted`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button(props: ButtonProps) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return <span className={`btn btn-disabled ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
|
||||||
|
} else {
|
||||||
|
return <Link className={`btn ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { GameHint } from "./infoview/rpc_api";
|
||||||
|
import * as React from 'react';
|
||||||
|
import Markdown from './markdown';
|
||||||
|
|
||||||
|
export function Hint({hint, step, selected, toggleSelection} : {hint: GameHint, step: number, selected: number, toggleSelection: any}) {
|
||||||
|
return <div className={`message information step-${step}` + (step == selected ? ' selected' : '')} onClick={toggleSelection}>
|
||||||
|
<Markdown>{hint.text}</Markdown>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HiddenHint({hint, step, selected, toggleSelection} : {hint: GameHint, step: number, selected: number, toggleSelection: any}) {
|
||||||
|
return <div className={`message warning step-${step}` + (step == selected ? ' selected' : '')} onClick={toggleSelection}>
|
||||||
|
<Markdown>{hint.text}</Markdown>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hints({hints, showHidden, step, selected, toggleSelection} : {hints: GameHint[], showHidden: boolean, step: number, selected: number, toggleSelection: any}) {
|
||||||
|
|
||||||
|
const openHints = hints.filter(hint => !hint.hidden)
|
||||||
|
const hiddenHints = hints.filter(hint => hint.hidden)
|
||||||
|
|
||||||
|
// TODO: Should not use index as key.
|
||||||
|
return <>
|
||||||
|
{openHints.map((hint, j) => <Hint key={`hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection}/>)}
|
||||||
|
{showHidden && hiddenHints.map((hint, j) => <HiddenHint key={`hidden-hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection}/>)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeletedHint({hint} : {hint: GameHint}) {
|
||||||
|
return <div className="message information deleted-hint">
|
||||||
|
<Markdown>{hint.text}</Markdown>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeletedHints({hints} : {hints: GameHint[]}) {
|
||||||
|
|
||||||
|
const openHints = hints.filter(hint => !hint.hidden)
|
||||||
|
const hiddenHints = hints.filter(hint => hint.hidden)
|
||||||
|
|
||||||
|
// TODO: Should not use index as key.
|
||||||
|
return <>
|
||||||
|
{openHints.map((hint, i) => <DeletedHint key={`deleted-hint-${i}`} hint={hint} />)}
|
||||||
|
{hiddenHints.map((hint, i) => <DeletedHint key={`deleted-hidden-hint-${i}`} hint={hint}/>)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
@ -1,196 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { useRef, useState, useEffect } from 'react'
|
|
||||||
import { LspDiagnosticsContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
|
||||||
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
|
||||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
|
||||||
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
|
|
||||||
import { InputModeContext, MonacoEditorContext } from '../Level'
|
|
||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
|
|
||||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
|
|
||||||
|
|
||||||
import { Registry } from 'monaco-textmate' // peer dependency
|
|
||||||
import { wireTmGrammars } from 'monaco-editor-textmate'
|
|
||||||
import * as lightPlusTheme from 'lean4web/client/src/lightPlus.json'
|
|
||||||
import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
|
|
||||||
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
|
|
||||||
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
|
|
||||||
import languageConfig from 'lean4/language-configuration.json';
|
|
||||||
|
|
||||||
|
|
||||||
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
|
|
||||||
|
|
||||||
// register Monaco languages
|
|
||||||
monaco.languages.register({
|
|
||||||
id: 'lean4cmd',
|
|
||||||
extensions: ['.leancmd']
|
|
||||||
})
|
|
||||||
|
|
||||||
// map of monaco "language id's" to TextMate scopeNames
|
|
||||||
const grammars = new Map()
|
|
||||||
grammars.set('lean4', 'source.lean')
|
|
||||||
grammars.set('lean4cmd', 'source.lean')
|
|
||||||
|
|
||||||
const registry = new Registry({
|
|
||||||
getGrammarDefinition: async (scopeName) => {
|
|
||||||
if (scopeName === 'source.lean') {
|
|
||||||
return {
|
|
||||||
format: 'json',
|
|
||||||
content: JSON.stringify(leanSyntax)
|
|
||||||
}
|
|
||||||
} else if (scopeName === 'source.lean.markdown') {
|
|
||||||
return {
|
|
||||||
format: 'json',
|
|
||||||
content: JSON.stringify(leanMarkdownSyntax)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
format: 'json',
|
|
||||||
content: JSON.stringify(codeblockSyntax)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wireTmGrammars(monaco, registry, grammars)
|
|
||||||
|
|
||||||
let config: any = { ...languageConfig }
|
|
||||||
config.autoClosingPairs = config.autoClosingPairs.map(
|
|
||||||
pair => { return {'open': pair[0], 'close': pair[1]} }
|
|
||||||
)
|
|
||||||
monaco.languages.setLanguageConfiguration('lean4cmd', config);
|
|
||||||
|
|
||||||
export function CommandLine() {
|
|
||||||
|
|
||||||
/** Reference to the hidden multi-line editor */
|
|
||||||
const editor = React.useContext(MonacoEditorContext)
|
|
||||||
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
|
|
||||||
const [processing, setProcessing] = useState(false)
|
|
||||||
|
|
||||||
const { commandLineMode, commandLineInput, setCommandLineInput } = React.useContext(InputModeContext)
|
|
||||||
|
|
||||||
const inputRef = useRef()
|
|
||||||
|
|
||||||
// Run the command
|
|
||||||
const runCommand = React.useCallback(() => {
|
|
||||||
if (processing) return;
|
|
||||||
const pos = editor.getPosition()
|
|
||||||
editor.executeEdits("command-line", [{
|
|
||||||
range: monaco.Selection.fromPositions(
|
|
||||||
pos,
|
|
||||||
editor.getModel().getFullModelRange().getEndPosition()
|
|
||||||
),
|
|
||||||
text: commandLineInput + "\n",
|
|
||||||
forceMoveMarkers: false
|
|
||||||
}]);
|
|
||||||
editor.setPosition(pos)
|
|
||||||
setProcessing(true)
|
|
||||||
}, [commandLineInput, editor])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (oneLineEditor && oneLineEditor.getValue() !== commandLineInput) {
|
|
||||||
oneLineEditor.setValue(commandLineInput)
|
|
||||||
}
|
|
||||||
}, [commandLineInput])
|
|
||||||
|
|
||||||
// React when answer from the server comes back
|
|
||||||
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
|
|
||||||
if (params.uri == editor.getModel().uri.toString()) {
|
|
||||||
setProcessing(false)
|
|
||||||
if (!hasErrorsOrWarnings(params.diagnostics)) {
|
|
||||||
setCommandLineInput("")
|
|
||||||
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const myEditor = monaco.editor.create(inputRef.current!, {
|
|
||||||
value: commandLineInput,
|
|
||||||
language: "lean4cmd",
|
|
||||||
quickSuggestions: false,
|
|
||||||
lightbulb: {
|
|
||||||
enabled: true
|
|
||||||
},
|
|
||||||
unicodeHighlight: {
|
|
||||||
ambiguousCharacters: false,
|
|
||||||
},
|
|
||||||
automaticLayout: true,
|
|
||||||
minimap: {
|
|
||||||
enabled: false
|
|
||||||
},
|
|
||||||
lineNumbers: 'off',
|
|
||||||
glyphMargin: false,
|
|
||||||
folding: false,
|
|
||||||
lineDecorationsWidth: 0,
|
|
||||||
lineNumbersMinChars: 0,
|
|
||||||
'semanticHighlighting.enabled': true,
|
|
||||||
overviewRulerLanes: 0,
|
|
||||||
hideCursorInOverviewRuler: true,
|
|
||||||
scrollbar: {
|
|
||||||
vertical: 'hidden',
|
|
||||||
horizontalScrollbarSize: 3
|
|
||||||
},
|
|
||||||
overviewRulerBorder: false,
|
|
||||||
theme: 'vs-code-theme-converted',
|
|
||||||
contextmenu: false
|
|
||||||
})
|
|
||||||
|
|
||||||
setOneLineEditor(myEditor)
|
|
||||||
|
|
||||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor)
|
|
||||||
|
|
||||||
return () => {abbrevRewriter.dispose(); myEditor.dispose()}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!oneLineEditor) return
|
|
||||||
// Ensure that our one-line editor can only have a single line
|
|
||||||
const l = oneLineEditor.getModel().onDidChangeContent((e) => {
|
|
||||||
const value = oneLineEditor.getValue()
|
|
||||||
setCommandLineInput(value)
|
|
||||||
const newValue = value.replace(/[\n\r]/g, '')
|
|
||||||
if (value != newValue) {
|
|
||||||
oneLineEditor.setValue(newValue)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => { l.dispose() }
|
|
||||||
}, [oneLineEditor, setCommandLineInput])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!oneLineEditor) return
|
|
||||||
// Run command when pressing enter
|
|
||||||
const l = oneLineEditor.onKeyUp((ev) => {
|
|
||||||
if (ev.code === "Enter") {
|
|
||||||
runCommand()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => { l.dispose() }
|
|
||||||
}, [oneLineEditor, runCommand])
|
|
||||||
|
|
||||||
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
|
|
||||||
ev.preventDefault()
|
|
||||||
runCommand()
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="command-line">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="command-line-input-wrapper">
|
|
||||||
<div ref={inputRef} className="command-line-input" />
|
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={processing} className="btn btn-inverted"><FontAwesomeIcon icon={faWandMagicSparkles} /> Execute</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Checks whether the diagnostics contain any errors or warnings to check whether the level has
|
|
||||||
been completed.*/
|
|
||||||
function hasErrorsOrWarnings(diags) {
|
|
||||||
return diags.some(
|
|
||||||
(d) =>
|
|
||||||
!d.message.startsWith("unsolved goals") &&
|
|
||||||
(d.severity == DiagnosticSeverity.Error || d.severity == DiagnosticSeverity.Warning)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -0,0 +1,341 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { useRef, useState, useEffect } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||||
|
import { Registry } from 'monaco-textmate' // peer dependency
|
||||||
|
import { wireTmGrammars } from 'monaco-editor-textmate'
|
||||||
|
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
|
||||||
|
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||||
|
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
|
||||||
|
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
|
||||||
|
import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
|
||||||
|
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
|
||||||
|
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
|
||||||
|
import languageConfig from 'lean4/language-configuration.json';
|
||||||
|
import { InteractiveDiagnostic, getInteractiveDiagnostics } from '@leanprover/infoview-api';
|
||||||
|
import { Diagnostic } from 'vscode-languageserver-types';
|
||||||
|
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||||
|
import { useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
||||||
|
import { DeletedChatContext, InputModeContext, MonacoEditorContext, ProofContext, ProofStep } from './context'
|
||||||
|
import { goalsToString } from './goals'
|
||||||
|
import { GameHint, InteractiveGoals } from './rpc_api'
|
||||||
|
|
||||||
|
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
|
||||||
|
|
||||||
|
// register Monaco languages
|
||||||
|
monaco.languages.register({
|
||||||
|
id: 'lean4cmd',
|
||||||
|
extensions: ['.leancmd']
|
||||||
|
})
|
||||||
|
|
||||||
|
// map of monaco "language id's" to TextMate scopeNames
|
||||||
|
const grammars = new Map()
|
||||||
|
grammars.set('lean4', 'source.lean')
|
||||||
|
grammars.set('lean4cmd', 'source.lean')
|
||||||
|
|
||||||
|
const registry = new Registry({
|
||||||
|
getGrammarDefinition: async (scopeName) => {
|
||||||
|
if (scopeName === 'source.lean') {
|
||||||
|
return {
|
||||||
|
format: 'json',
|
||||||
|
content: JSON.stringify(leanSyntax)
|
||||||
|
}
|
||||||
|
} else if (scopeName === 'source.lean.markdown') {
|
||||||
|
return {
|
||||||
|
format: 'json',
|
||||||
|
content: JSON.stringify(leanMarkdownSyntax)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
format: 'json',
|
||||||
|
content: JSON.stringify(codeblockSyntax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wireTmGrammars(monaco, registry, grammars)
|
||||||
|
|
||||||
|
let config: any = { ...languageConfig }
|
||||||
|
config.autoClosingPairs = config.autoClosingPairs.map(
|
||||||
|
pair => { return {'open': pair[0], 'close': pair[1]} }
|
||||||
|
)
|
||||||
|
monaco.languages.setLanguageConfiguration('lean4cmd', config);
|
||||||
|
|
||||||
|
/** The input field */
|
||||||
|
export function CommandLine({proofPanelRef}: {proofPanelRef: React.MutableRefObject<HTMLDivElement>}) {
|
||||||
|
|
||||||
|
/** Reference to the hidden multi-line editor */
|
||||||
|
const editor = React.useContext(MonacoEditorContext)
|
||||||
|
const model = editor.getModel()
|
||||||
|
const uri = model.uri.toString()
|
||||||
|
|
||||||
|
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
|
||||||
|
const {commandLineInput, setCommandLineInput} = React.useContext(InputModeContext)
|
||||||
|
|
||||||
|
const inputRef = useRef()
|
||||||
|
|
||||||
|
// The context storing all information about the current proof
|
||||||
|
const {proof, setProof} = React.useContext(ProofContext)
|
||||||
|
|
||||||
|
// state to store the last batch of deleted messages
|
||||||
|
const {setDeletedChat} = React.useContext(DeletedChatContext)
|
||||||
|
|
||||||
|
// TODO: does the position matter at all?
|
||||||
|
const rpcSess = useRpcSessionAtPos({uri: uri, line: 1, character: 1})
|
||||||
|
|
||||||
|
/** Load all goals an messages of the current proof (line-by-line) and save
|
||||||
|
* the retrieved information into context (`ProofContext`)
|
||||||
|
*/
|
||||||
|
const loadAllGoals = React.useCallback(() => {
|
||||||
|
|
||||||
|
let goalCalls = []
|
||||||
|
let msgCalls = []
|
||||||
|
|
||||||
|
// For each line of code ask the server for the goals and the messages on this line
|
||||||
|
for (let i = 0; i < model.getLineCount(); i++) {
|
||||||
|
goalCalls.push(
|
||||||
|
rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri}))
|
||||||
|
)
|
||||||
|
msgCalls.push(
|
||||||
|
getInteractiveDiagnostics(rpcSess, {start: i, end: i+1})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all these requests to be processed before saving the results
|
||||||
|
Promise.all(goalCalls).then((steps : InteractiveGoals[]) => {
|
||||||
|
Promise.all(msgCalls).then((diagnostics : [InteractiveDiagnostic[]]) => {
|
||||||
|
let tmpProof : ProofStep[] = []
|
||||||
|
|
||||||
|
let goalCount = 0
|
||||||
|
|
||||||
|
steps.map((goals, i) => {
|
||||||
|
// The first step has an empty command and therefore also no error messages
|
||||||
|
// Usually there is a newline at the end of the editors content, so we need to
|
||||||
|
// display diagnostics from potentally two lines in the last step.
|
||||||
|
let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : []
|
||||||
|
|
||||||
|
// Filter out the 'unsolved goals' message
|
||||||
|
messages = messages.filter((msg) => {
|
||||||
|
return !("append" in msg.message &&
|
||||||
|
"text" in msg.message.append[0] &&
|
||||||
|
msg.message.append[0].text === "unsolved goals")
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeof goals == 'undefined') {
|
||||||
|
tmpProof.push({
|
||||||
|
command: i ? model.getLineContent(i) : '',
|
||||||
|
goals: [],
|
||||||
|
hints: [],
|
||||||
|
errors: messages
|
||||||
|
} as ProofStep)
|
||||||
|
console.debug('goals is undefined')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the number of goals reduce, show a message
|
||||||
|
if (goals.goals.length && goalCount > goals.goals.length) {
|
||||||
|
messages.unshift({
|
||||||
|
range: {
|
||||||
|
start: {
|
||||||
|
line: i-1,
|
||||||
|
character: 0,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
line: i-1,
|
||||||
|
character: 0,
|
||||||
|
}},
|
||||||
|
severity: DiagnosticSeverity.Information,
|
||||||
|
message: {
|
||||||
|
text: 'intermediate goal solved 🎉'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
goalCount = goals.goals.length
|
||||||
|
|
||||||
|
// with no goals there will be no hints.
|
||||||
|
let hints : GameHint[] = goals.goals.length ? goals.goals[0].hints : []
|
||||||
|
|
||||||
|
console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
|
||||||
|
console.debug(`Goals: (${i}): `, goalsToString(goals)) //
|
||||||
|
console.debug(`Hints: (${i}): `, hints)
|
||||||
|
console.debug(`Errors: (${i}): `, messages)
|
||||||
|
|
||||||
|
tmpProof.push({
|
||||||
|
// the command of the line above. Note that `getLineContent` starts counting
|
||||||
|
// at `1` instead of `zero`. The first ProofStep will have an empty command.
|
||||||
|
command: i ? model.getLineContent(i) : '',
|
||||||
|
// TODO: store correct data
|
||||||
|
goals: goals.goals,
|
||||||
|
// only need the hints of the active goals in chat
|
||||||
|
hints: hints,
|
||||||
|
// errors and messages from the server
|
||||||
|
errors: messages
|
||||||
|
} as ProofStep)
|
||||||
|
|
||||||
|
})
|
||||||
|
// Save the proof to the context
|
||||||
|
setProof(tmpProof)
|
||||||
|
console.debug('updated proof')
|
||||||
|
proofPanelRef.current?.lastElementChild?.scrollIntoView()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [editor, rpcSess, uri, model, proofPanelRef])
|
||||||
|
|
||||||
|
// Run the command
|
||||||
|
const runCommand = React.useCallback(() => {
|
||||||
|
if (processing) {return}
|
||||||
|
|
||||||
|
// TODO: Desired logic is to only reset this after a new *error-free* command has been entered
|
||||||
|
setDeletedChat([])
|
||||||
|
|
||||||
|
const pos = editor.getPosition()
|
||||||
|
if (commandLineInput) {
|
||||||
|
setProcessing(true)
|
||||||
|
editor.executeEdits("command-line", [{
|
||||||
|
range: monaco.Selection.fromPositions(
|
||||||
|
pos,
|
||||||
|
editor.getModel().getFullModelRange().getEndPosition()
|
||||||
|
),
|
||||||
|
text: commandLineInput.trim() + "\n",
|
||||||
|
forceMoveMarkers: false
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.setPosition(pos)
|
||||||
|
}, [commandLineInput, editor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (oneLineEditor && oneLineEditor.getValue() !== commandLineInput) {
|
||||||
|
oneLineEditor.setValue(commandLineInput)
|
||||||
|
}
|
||||||
|
}, [commandLineInput])
|
||||||
|
|
||||||
|
// React when answer from the server comes back
|
||||||
|
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
|
||||||
|
if (params.uri == uri) {
|
||||||
|
setProcessing(false)
|
||||||
|
loadAllGoals()
|
||||||
|
if (!hasErrors(params.diagnostics)) {
|
||||||
|
setCommandLineInput("")
|
||||||
|
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// console.debug(`expected uri: ${uri}, got: ${params.uri}`)
|
||||||
|
// console.debug(params)
|
||||||
|
}
|
||||||
|
// TODO: This is the wrong place apparently. Where do wee need to load them?
|
||||||
|
// TODO: instead of loading all goals every time, we could only load the last one
|
||||||
|
// loadAllGoals()
|
||||||
|
}, [uri]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const myEditor = monaco.editor.create(inputRef.current!, {
|
||||||
|
value: commandLineInput,
|
||||||
|
language: "lean4cmd",
|
||||||
|
quickSuggestions: false,
|
||||||
|
lightbulb: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
unicodeHighlight: {
|
||||||
|
ambiguousCharacters: false,
|
||||||
|
},
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
lineNumbers: 'off',
|
||||||
|
tabSize: 2,
|
||||||
|
glyphMargin: false,
|
||||||
|
folding: false,
|
||||||
|
lineDecorationsWidth: 0,
|
||||||
|
lineNumbersMinChars: 0,
|
||||||
|
'semanticHighlighting.enabled': true,
|
||||||
|
overviewRulerLanes: 0,
|
||||||
|
hideCursorInOverviewRuler: true,
|
||||||
|
scrollbar: {
|
||||||
|
vertical: 'hidden',
|
||||||
|
horizontalScrollbarSize: 3
|
||||||
|
},
|
||||||
|
overviewRulerBorder: false,
|
||||||
|
theme: 'vs-code-theme-converted',
|
||||||
|
contextmenu: false
|
||||||
|
})
|
||||||
|
|
||||||
|
setOneLineEditor(myEditor)
|
||||||
|
|
||||||
|
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor)
|
||||||
|
|
||||||
|
return () => {abbrevRewriter.dispose(); myEditor.dispose()}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!oneLineEditor) return
|
||||||
|
// Ensure that our one-line editor can only have a single line
|
||||||
|
const l = oneLineEditor.getModel().onDidChangeContent((e) => {
|
||||||
|
const value = oneLineEditor.getValue()
|
||||||
|
setCommandLineInput(value)
|
||||||
|
const newValue = value.replace(/[\n\r]/g, '')
|
||||||
|
if (value != newValue) {
|
||||||
|
oneLineEditor.setValue(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { l.dispose() }
|
||||||
|
}, [oneLineEditor, setCommandLineInput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!oneLineEditor) return
|
||||||
|
// Run command when pressing enter
|
||||||
|
const l = oneLineEditor.onKeyUp((ev) => {
|
||||||
|
if (ev.code === "Enter") {
|
||||||
|
runCommand()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { l.dispose() }
|
||||||
|
}, [oneLineEditor, runCommand])
|
||||||
|
|
||||||
|
// BUG: Causes `file closed` error
|
||||||
|
//TODO: Intention is to run once when loading, does that work?
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug(`time to update: ${uri} \n ${rpcSess}`)
|
||||||
|
console.debug(rpcSess)
|
||||||
|
loadAllGoals()
|
||||||
|
}, [rpcSess])
|
||||||
|
|
||||||
|
/** Process the entered command */
|
||||||
|
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
|
||||||
|
ev.preventDefault()
|
||||||
|
runCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="command-line">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="command-line-input-wrapper">
|
||||||
|
<div ref={inputRef} className="command-line-input" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={processing} className="btn btn-inverted">
|
||||||
|
<FontAwesomeIcon icon={faWandMagicSparkles} /> Execute
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks whether the diagnostics contain any errors or warnings to check whether the level has
|
||||||
|
been completed.*/
|
||||||
|
export function hasErrors(diags: Diagnostic[]) {
|
||||||
|
return diags.some(
|
||||||
|
(d) =>
|
||||||
|
!d.message.startsWith("unsolved goals") &&
|
||||||
|
(d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Didn't manage to unify this with the one above
|
||||||
|
export function hasInteractiveErrors (diags: InteractiveDiagnostic[]) {
|
||||||
|
return (typeof diags !== 'undefined') && diags.some(
|
||||||
|
(d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* @fileOverview This file contains the the react contexts used in the project.
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||||
|
import { InteractiveDiagnostic, InteractiveTermGoal } from '@leanprover/infoview-api';
|
||||||
|
import { GameHint, InteractiveGoal, InteractiveGoals } from './rpc_api';
|
||||||
|
|
||||||
|
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(
|
||||||
|
null as any)
|
||||||
|
|
||||||
|
export type InfoStatus = 'updating' | 'error' | 'ready';
|
||||||
|
|
||||||
|
/** One step of the proof */
|
||||||
|
export type ProofStep = {
|
||||||
|
/** The command in this step */
|
||||||
|
command : string
|
||||||
|
/** List of goals *after* this command */
|
||||||
|
goals: InteractiveGoal[] // TODO: Add correct type
|
||||||
|
/** Story relevant messages */
|
||||||
|
hints: GameHint[] // TODO: Add correct type
|
||||||
|
/** Errors and warnings */
|
||||||
|
errors: InteractiveDiagnostic[] // TODO: Add correct type
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The context storing the proof step-by-step for the command line mode */
|
||||||
|
export const ProofContext = React.createContext<{
|
||||||
|
/** The proof consists of multiple steps that are processed one after the other.
|
||||||
|
* In particular multi-line terms like `match`-statements will not be supported.
|
||||||
|
*
|
||||||
|
* Note that the first step will always have `null` as command
|
||||||
|
*/
|
||||||
|
proof: ProofStep[],
|
||||||
|
setProof: React.Dispatch<React.SetStateAction<Array<ProofStep>>>
|
||||||
|
}>({
|
||||||
|
proof: [],
|
||||||
|
setProof: () => {} // TODO: implement me
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ProofStateProps {
|
||||||
|
// pos: DocumentPosition;
|
||||||
|
status: InfoStatus;
|
||||||
|
messages: InteractiveDiagnostic[];
|
||||||
|
goals?: InteractiveGoals;
|
||||||
|
termGoal?: InteractiveTermGoal;
|
||||||
|
error?: string;
|
||||||
|
// userWidgets: UserWidgetInstance[];
|
||||||
|
// rpcSess: RpcSessionAtPos;
|
||||||
|
// triggerUpdate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProofStateContext = React.createContext<{
|
||||||
|
proofState : ProofStateProps,
|
||||||
|
setProofState: React.Dispatch<React.SetStateAction<ProofStateProps>>
|
||||||
|
}>({
|
||||||
|
proofState : {
|
||||||
|
status: 'updating',
|
||||||
|
messages: [],
|
||||||
|
goals: undefined,
|
||||||
|
termGoal: undefined,
|
||||||
|
error: undefined},
|
||||||
|
setProofState: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MobileContext = React.createContext<{
|
||||||
|
mobile : boolean,
|
||||||
|
setMobile: React.Dispatch<React.SetStateAction<Boolean>>
|
||||||
|
}>({
|
||||||
|
mobile : false,
|
||||||
|
setMobile: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const WorldLevelIdContext = React.createContext<{
|
||||||
|
worldId : string,
|
||||||
|
levelId: number
|
||||||
|
}>({
|
||||||
|
worldId : null,
|
||||||
|
levelId: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Context to keep highlight selected proof step and corresponding chat messages. */
|
||||||
|
export const SelectionContext = React.createContext<{
|
||||||
|
selectedStep : number,
|
||||||
|
setSelectedStep: React.Dispatch<React.SetStateAction<number>>
|
||||||
|
}>({
|
||||||
|
selectedStep : undefined,
|
||||||
|
setSelectedStep: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Context for deleted Hints that are visible just a bit after they've been deleted */
|
||||||
|
export const DeletedChatContext = React.createContext<{
|
||||||
|
deletedChat : GameHint[],
|
||||||
|
setDeletedChat: React.Dispatch<React.SetStateAction<Array<GameHint>>>
|
||||||
|
showHelp : Set<number>,
|
||||||
|
setShowHelp: React.Dispatch<React.SetStateAction<Set<number>>>
|
||||||
|
}>({
|
||||||
|
deletedChat: undefined,
|
||||||
|
setDeletedChat: () => {},
|
||||||
|
showHelp: undefined,
|
||||||
|
setShowHelp: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const InputModeContext = React.createContext<{
|
||||||
|
commandLineMode: boolean,
|
||||||
|
setCommandLineMode: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
commandLineInput: string,
|
||||||
|
setCommandLineInput: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
}>({
|
||||||
|
commandLineMode: true,
|
||||||
|
setCommandLineMode: () => {},
|
||||||
|
commandLineInput: "",
|
||||||
|
setCommandLineInput: () => {},
|
||||||
|
});
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import { GameHint } from "./rpcApi";
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Alert, FormControlLabel, Switch } from '@mui/material';
|
|
||||||
import Markdown from '../Markdown';
|
|
||||||
|
|
||||||
function Hint({hint} : {hint: GameHint}) {
|
|
||||||
return <div className="message info"><Markdown>{hint.text}</Markdown></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Hints({hints} : {hints: GameHint[]}) {
|
|
||||||
|
|
||||||
|
|
||||||
const [showHints, setShowHints] = React.useState(false);
|
|
||||||
|
|
||||||
const openHints = hints.filter(hint => !hint.hidden)
|
|
||||||
const hiddenHints = hints.filter(hint => hint.hidden)
|
|
||||||
|
|
||||||
return <>
|
|
||||||
{openHints.map(hint => <Hint hint={hint} />)}
|
|
||||||
{hiddenHints.length > 0 &&
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Switch checked={showHints} onChange={() => setShowHints((prev) => !prev)} />}
|
|
||||||
label="I need help!"
|
|
||||||
/>}
|
|
||||||
{showHints && hiddenHints.map(hint => <Hint hint={hint} />)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
@ -1,5 +1,8 @@
|
|||||||
/* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts ` */
|
/**
|
||||||
|
* @fileOverview Defines the interface for the communication with the server.
|
||||||
|
*
|
||||||
|
* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts`
|
||||||
|
*/
|
||||||
import { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api';
|
import { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api';
|
||||||
|
|
||||||
export interface GameHint {
|
export interface GameHint {
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import './inventory.css'
|
||||||
|
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, useLoadInventoryOverviewQuery } from '../state/api';
|
||||||
|
import { selectDifficulty, selectInventory } from '../state/progress';
|
||||||
|
import { store } from '../state/store';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
export function Inventory({levelInfo, openDoc, enableAll=false} :
|
||||||
|
{
|
||||||
|
levelInfo: LevelInfo|InventoryOverview,
|
||||||
|
openDoc: (props: {name: string, type: string}) => void,
|
||||||
|
enableAll?: boolean,
|
||||||
|
}) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inventory">
|
||||||
|
{/* TODO: Click on Tactic: show info
|
||||||
|
TODO: click on paste icon -> paste into command line */}
|
||||||
|
<h2>Tactics</h2>
|
||||||
|
{levelInfo?.tactics &&
|
||||||
|
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} enableAll={enableAll}/>
|
||||||
|
}
|
||||||
|
<h2>Definitions</h2>
|
||||||
|
{levelInfo?.definitions &&
|
||||||
|
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} enableAll={enableAll}/>
|
||||||
|
}
|
||||||
|
<h2>Lemmas</h2>
|
||||||
|
{levelInfo?.lemmas &&
|
||||||
|
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} defaultTab={levelInfo?.lemmaTab} level={levelInfo} enableAll={enableAll}/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined, enableAll=false} :
|
||||||
|
{
|
||||||
|
items: InventoryTile[],
|
||||||
|
docType: string,
|
||||||
|
openDoc(props: {name: string, type: string}): void,
|
||||||
|
defaultTab? : string,
|
||||||
|
level? : LevelInfo|InventoryOverview,
|
||||||
|
enableAll?: boolean,
|
||||||
|
}) {
|
||||||
|
// TODO: `level` is only used in the `useEffect` below to check if a new level has
|
||||||
|
// been loaded. Is there a better way to observe this?
|
||||||
|
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
|
||||||
|
const difficulty = useSelector(selectDifficulty(gameId))
|
||||||
|
|
||||||
|
const categorySet = new Set<string>()
|
||||||
|
for (let item of items) {
|
||||||
|
categorySet.add(item.category)
|
||||||
|
}
|
||||||
|
const categories = Array.from(categorySet).sort()
|
||||||
|
|
||||||
|
const [tab, setTab] = useState(defaultTab)
|
||||||
|
|
||||||
|
// Add inventory items from local store as unlocked.
|
||||||
|
// Items are unlocked if they are in the local store, or if the server says they should be
|
||||||
|
// given the dependency graph. (OR-connection) (TODO: maybe add different logic for different
|
||||||
|
// modi)
|
||||||
|
let inv: string[] = selectInventory(gameId)(store.getState())
|
||||||
|
let modifiedItems : InventoryTile[] = items.map(tile => inv.includes(tile.name) ? {...tile, locked: false} : tile)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
|
||||||
|
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
|
||||||
|
if (defaultTab) {
|
||||||
|
setTab(defaultTab)
|
||||||
|
}}, [level])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{categories.length > 1 &&
|
||||||
|
<div className="tab-bar">
|
||||||
|
{categories.map((cat) =>
|
||||||
|
<div key={`category-${cat}`} className={`tab ${cat == (tab ?? categories[0]) ? "active": ""}`}
|
||||||
|
onClick={() => { setTab(cat) }}>{cat}</div>)}
|
||||||
|
</div>}
|
||||||
|
<div className="inventory-list">
|
||||||
|
{[...modifiedItems].sort(
|
||||||
|
// For lemas, sort entries `available > disabled > locked`
|
||||||
|
// otherwise alphabetically
|
||||||
|
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled)
|
||||||
|
).filter(item => ((tab ?? categories[0]) == item.category)).map((item, i) => {
|
||||||
|
return <InventoryItem key={`${item.category}-${item.name}`}
|
||||||
|
showDoc={() => {openDoc({name: item.name, type: docType})}}
|
||||||
|
name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false}
|
||||||
|
disabled={item.disabled} newly={item.new} enableAll={enableAll}/>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryItem({name, displayName, locked, disabled, newly, showDoc, enableAll=false}) {
|
||||||
|
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
|
||||||
|
disabled ? <FontAwesomeIcon icon={faBan} /> : ""
|
||||||
|
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
|
||||||
|
const title = locked ? "Not unlocked yet" :
|
||||||
|
disabled ? "Not available in this level" : ""
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (enableAll || !locked) {
|
||||||
|
showDoc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Documentation({name, type, handleClose}) {
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
const doc = useLoadDocQuery({game: gameId, type: type, name: name})
|
||||||
|
|
||||||
|
return <div className="documentation">
|
||||||
|
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||||
|
<h1 className="doc">{doc.data?.displayName}</h1>
|
||||||
|
<p><code>{doc.data?.statement}</code></p>
|
||||||
|
{/* <code>docstring: {doc.data?.docstring}</code> */}
|
||||||
|
<Markdown>{doc.data?.content}</Markdown>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The panel (on the welcome page) showing the user's inventory with tactics, definitions, and lemmas */
|
||||||
|
export function InventoryPanel({visible = true}) {
|
||||||
|
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)
|
||||||
|
// Set `inventoryDoc` to `null` to close the doc
|
||||||
|
function closeInventoryDoc() {setInventoryDoc(null)}
|
||||||
|
|
||||||
|
return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}>
|
||||||
|
{inventoryDoc ?
|
||||||
|
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/>
|
||||||
|
:
|
||||||
|
<Inventory levelInfo={inventory.data} openDoc={setInventoryDoc} enableAll={true}/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -0,0 +1,685 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { useEffect, useState, useRef, useContext } from 'react'
|
||||||
|
import { useSelector, useStore } from 'react-redux'
|
||||||
|
import Split from 'react-split'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBars, faBook, faCode, faXmark, faHome, faCircleInfo, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { CircularProgress } from '@mui/material'
|
||||||
|
import type { Location } from 'vscode-languageserver-protocol'
|
||||||
|
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||||
|
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider'
|
||||||
|
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter'
|
||||||
|
import { InfoProvider } from 'lean4web/client/src/editor/infoview'
|
||||||
|
import { LeanTaskGutter } from 'lean4web/client/src/editor/taskgutter'
|
||||||
|
import { InfoviewApi } from '@leanprover/infoview'
|
||||||
|
import { EditorContext } from '../../../node_modules/lean4-infoview/src/infoview/contexts'
|
||||||
|
import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-infoview/src/infoview/editorConnection'
|
||||||
|
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event'
|
||||||
|
|
||||||
|
import { GameIdContext } from '../app'
|
||||||
|
import { ConnectionContext, connection, useLeanClient } from '../connection'
|
||||||
|
import { useAppDispatch, useAppSelector } from '../hooks'
|
||||||
|
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api'
|
||||||
|
import { changedSelection, codeEdited, selectCode, selectSelections, selectCompleted, helpEdited,
|
||||||
|
selectHelp, selectDifficulty, selectInventory } from '../state/progress'
|
||||||
|
import { store } from '../state/store'
|
||||||
|
import { Button } from './button'
|
||||||
|
import Markdown from './markdown'
|
||||||
|
import {InventoryPanel} from './inventory'
|
||||||
|
import { hasInteractiveErrors } from './infoview/command_line'
|
||||||
|
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext,
|
||||||
|
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
|
||||||
|
import { DualEditor } from './infoview/main'
|
||||||
|
import { GameHint } from './infoview/rpc_api'
|
||||||
|
import { DeletedHints, Hints } from './hints'
|
||||||
|
import { Impressum } from './privacy_policy'
|
||||||
|
|
||||||
|
import '@fontsource/roboto/300.css'
|
||||||
|
import '@fontsource/roboto/400.css'
|
||||||
|
import '@fontsource/roboto/500.css'
|
||||||
|
import '@fontsource/roboto/700.css'
|
||||||
|
import 'lean4web/client/src/editor/infoview.css'
|
||||||
|
import 'lean4web/client/src/editor/vscode.css'
|
||||||
|
import './level.css'
|
||||||
|
|
||||||
|
function Level() {
|
||||||
|
const params = useParams()
|
||||||
|
const levelId = parseInt(params.levelId)
|
||||||
|
const worldId = params.worldId
|
||||||
|
useLoadWorldFiles(worldId)
|
||||||
|
|
||||||
|
return <WorldLevelIdContext.Provider value={{worldId, levelId}}>
|
||||||
|
{levelId == 0 ? <Introduction /> : <PlayableLevel />}
|
||||||
|
</WorldLevelIdContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatPanel({lastLevel}) {
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null)
|
||||||
|
const {mobile} = useContext(MobileContext)
|
||||||
|
const gameId = useContext(GameIdContext)
|
||||||
|
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||||
|
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||||
|
const {proof, setProof} = useContext(ProofContext)
|
||||||
|
const {deletedChat, setDeletedChat, showHelp, setShowHelp} = useContext(DeletedChatContext)
|
||||||
|
const {selectedStep, setSelectedStep} = useContext(SelectionContext)
|
||||||
|
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
||||||
|
|
||||||
|
// If the last step has errors, we want to treat it as if it is part of the second-to-last step
|
||||||
|
let k = proof.length - 1
|
||||||
|
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
|
||||||
|
|
||||||
|
function toggleSelection(line: number) {
|
||||||
|
return (ev) => {
|
||||||
|
console.debug('toggled selection')
|
||||||
|
if (selectedStep == line) {
|
||||||
|
setSelectedStep(undefined)
|
||||||
|
} else {
|
||||||
|
setSelectedStep(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHiddenHints(i : number): boolean {
|
||||||
|
let step = proof[i]
|
||||||
|
// For example if the proof isn't loaded yet
|
||||||
|
if(!step) {return false}
|
||||||
|
return step.hints.some((hint) => hint.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activateHiddenHints = (ev) => {
|
||||||
|
// If the last step (`k`) has errors, we want the hidden hints from the
|
||||||
|
// second-to-last step to be affected
|
||||||
|
if (!(proof.length)) {return}
|
||||||
|
|
||||||
|
// state must not be mutated, therefore we need to clone the set
|
||||||
|
let tmp = new Set(showHelp)
|
||||||
|
if (tmp.has(k - withErr)) {
|
||||||
|
tmp.delete(k - withErr)
|
||||||
|
} else {
|
||||||
|
tmp.add(k - withErr)
|
||||||
|
}
|
||||||
|
setShowHelp(tmp)
|
||||||
|
console.debug(`help: ${Array.from(tmp.values())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// TODO: For some reason this is always called twice
|
||||||
|
console.debug('scroll chat')
|
||||||
|
if (!mobile) {
|
||||||
|
chatRef.current!.lastElementChild?.scrollIntoView() //scrollTo(0,0)
|
||||||
|
}
|
||||||
|
}, [proof, showHelp])
|
||||||
|
|
||||||
|
// Scroll to element if selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof selectedStep !== 'undefined') {
|
||||||
|
Array.from(chatRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => {
|
||||||
|
elem.scrollIntoView({block: "center"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [selectedStep])
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// // // Scroll to top when loading a new level
|
||||||
|
// // // TODO: Thats the wrong behaviour probably
|
||||||
|
// // chatRef.current!.scrollTo(0,0)
|
||||||
|
// }, [gameId, worldId, levelId])
|
||||||
|
|
||||||
|
return <div className="chat-panel">
|
||||||
|
<div ref={chatRef} className="chat">
|
||||||
|
{level?.data?.introduction &&
|
||||||
|
<div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelection(0)}>
|
||||||
|
<Markdown>{level?.data?.introduction}</Markdown>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{proof.map((step, i) => {
|
||||||
|
// It the last step has errors, it will have the same hints
|
||||||
|
// as the second-to-last step. Therefore we should not display them.
|
||||||
|
if (!(i == proof.length - 1 && withErr)) {
|
||||||
|
// TODO: Should not use index as key.
|
||||||
|
return <Hints key={`hints-${i}`}
|
||||||
|
hints={step.hints} showHidden={showHelp.has(i)} step={i}
|
||||||
|
selected={selectedStep} toggleSelection={toggleSelection(i)}/>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
<DeletedHints hints={deletedChat}/>
|
||||||
|
{completed &&
|
||||||
|
<>
|
||||||
|
<div className={`message information step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
|
||||||
|
Level completed! 🎉
|
||||||
|
</div>
|
||||||
|
{level?.data?.conclusion?.trim() &&
|
||||||
|
<div className={`message information step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
|
||||||
|
<Markdown>{level?.data?.conclusion}</Markdown>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
{completed && (lastLevel ?
|
||||||
|
<Button to={`/${gameId}`}>
|
||||||
|
<FontAwesomeIcon icon={faHome} /> Leave World
|
||||||
|
</Button> :
|
||||||
|
<Button to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}>
|
||||||
|
Next <FontAwesomeIcon icon={faArrowRight} />
|
||||||
|
</Button>)
|
||||||
|
}
|
||||||
|
{hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
|
||||||
|
<Button to="" onClick={activateHiddenHints}>
|
||||||
|
Show more help!
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExercisePanel({codeviewRef, impressum, closeImpressum, visible=true}) {
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||||
|
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||||
|
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||||
|
return <div className={`exercise-panel ${visible ? '' : 'hidden'}`}>
|
||||||
|
<div className="exercise">
|
||||||
|
<DualEditor level={level?.data} codeviewRef={codeviewRef} levelId={levelId} worldId={worldId} worldSize={gameInfo.data?.worldSize[worldId]}/>
|
||||||
|
</div>
|
||||||
|
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayableLevel() {
|
||||||
|
const codeviewRef = useRef<HTMLDivElement>(null)
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||||
|
const {mobile} = React.useContext(MobileContext)
|
||||||
|
|
||||||
|
const difficulty = useSelector(selectDifficulty(gameId))
|
||||||
|
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
|
||||||
|
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
|
||||||
|
const inventory: Array<String> = useSelector(selectInventory(gameId))
|
||||||
|
|
||||||
|
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||||
|
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||||
|
|
||||||
|
// The state variables for the `ProofContext`
|
||||||
|
const [proof, setProof] = useState<Array<ProofStep>>([])
|
||||||
|
// When deleting the proof, we want to keep to old messages around until
|
||||||
|
// a new proof has been entered. e.g. to consult messages coming from dead ends
|
||||||
|
const [deletedChat, setDeletedChat] = useState<Array<GameHint>>([])
|
||||||
|
// A set of row numbers where help is displayed
|
||||||
|
const [showHelp, setShowHelp] = useState<Set<number>>(new Set())
|
||||||
|
// Only for mobile layout
|
||||||
|
const [pageNumber, setPageNumber] = useState(0)
|
||||||
|
const [commandLineMode, setCommandLineMode] = useState(true)
|
||||||
|
const [commandLineInput, setCommandLineInput] = useState("")
|
||||||
|
const lastLevel = levelId >= gameInfo.data?.worldSize[worldId]
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
// impressum pop-up
|
||||||
|
const [impressum, setImpressum] = React.useState(false)
|
||||||
|
function closeImpressum() {setImpressum(false)}
|
||||||
|
function toggleImpressum() {setImpressum(!impressum)}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// Set `inventoryDoc` to `null` to close the doc
|
||||||
|
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
|
||||||
|
function closeInventoryDoc () {setInventoryDoc(null)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const onDidChangeContent = (code) => {
|
||||||
|
dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDidChangeSelection = (monacoSelections) => {
|
||||||
|
const selections = monacoSelections.map(
|
||||||
|
({selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}) =>
|
||||||
|
{return {selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}})
|
||||||
|
dispatch(changedSelection({game: gameId, world: worldId, level: levelId, selections}))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const {editor, infoProvider, editorConnection} =
|
||||||
|
useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection)
|
||||||
|
|
||||||
|
/** Unused. Was implementing an undo button, which has been replaced by `deleteProof` inside
|
||||||
|
* `CommandLineInterface`.
|
||||||
|
*/
|
||||||
|
const handleUndo = () => {
|
||||||
|
const endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||||
|
let range
|
||||||
|
console.log(endPos.column)
|
||||||
|
if (endPos.column === 1) {
|
||||||
|
range = monaco.Selection.fromPositions(
|
||||||
|
new monaco.Position(endPos.lineNumber - 1, 1),
|
||||||
|
endPos
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
range = monaco.Selection.fromPositions(
|
||||||
|
new monaco.Position(endPos.lineNumber, 1),
|
||||||
|
endPos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
editor.executeEdits("undo-button", [{
|
||||||
|
range,
|
||||||
|
text: "",
|
||||||
|
forceMoveMarkers: false
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select and highlight proof steps and corresponding hints
|
||||||
|
// TODO: with the new design, there is no difference between the introduction and
|
||||||
|
// a hint at the beginning of the proof...
|
||||||
|
const [selectedStep, setSelectedStep] = useState<number>()
|
||||||
|
|
||||||
|
// if the user inventory changes, notify the server
|
||||||
|
useEffect(() => {
|
||||||
|
let leanClient = connection.getLeanClient(gameId)
|
||||||
|
leanClient.sendNotification('$/game/setInventory', {inventory: inventory, checkEnabled: difficulty > 0})
|
||||||
|
}, [inventory])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// TODO: That's a problem if the saved proof contains an error
|
||||||
|
// Reset command line input when loading a new level
|
||||||
|
setCommandLineInput("")
|
||||||
|
|
||||||
|
// Load the selected help steps from the store
|
||||||
|
setShowHelp(new Set(selectHelp(gameId, worldId, levelId)(store.getState())))
|
||||||
|
}, [gameId, worldId, levelId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!commandLineMode) {
|
||||||
|
// Delete last input attempt from command line
|
||||||
|
editor.executeEdits("command-line", [{
|
||||||
|
range: editor.getSelection(),
|
||||||
|
text: "",
|
||||||
|
forceMoveMarkers: false
|
||||||
|
}]);
|
||||||
|
editor.focus()
|
||||||
|
}
|
||||||
|
}, [commandLineMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Forget whether hidden hints are displayed for steps that don't exist yet
|
||||||
|
if (proof.length) {
|
||||||
|
console.debug(Array.from(showHelp))
|
||||||
|
setShowHelp(new Set(Array.from(showHelp).filter(i => (i < proof.length))))
|
||||||
|
}
|
||||||
|
}, [proof])
|
||||||
|
|
||||||
|
// save showed help in store
|
||||||
|
useEffect(() => {
|
||||||
|
if (proof.length) {
|
||||||
|
console.debug(`showHelp:\n ${showHelp}`)
|
||||||
|
dispatch(helpEdited({game: gameId, world: worldId, level: levelId, help: Array.from(showHelp)}))
|
||||||
|
}
|
||||||
|
}, [showHelp])
|
||||||
|
|
||||||
|
// Effect when command line mode gets enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && commandLineMode) {
|
||||||
|
let endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||||
|
if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") {
|
||||||
|
editor.executeEdits("command-line", [{
|
||||||
|
range: monaco.Selection.fromPositions(endPos, endPos),
|
||||||
|
text: "\n",
|
||||||
|
forceMoveMarkers: true
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||||
|
let currPos = editor.getPosition()
|
||||||
|
if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) {
|
||||||
|
// This is not a position that would naturally occur from CommandLine, reset:
|
||||||
|
editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editor, commandLineMode])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||||
|
<DeletedChatContext.Provider value={{deletedChat, setDeletedChat, showHelp, setShowHelp}}>
|
||||||
|
<SelectionContext.Provider value={{selectedStep, setSelectedStep}}>
|
||||||
|
<InputModeContext.Provider value={{commandLineMode, setCommandLineMode, commandLineInput, setCommandLineInput}}>
|
||||||
|
<ProofContext.Provider value={{proof, setProof}}>
|
||||||
|
<EditorContext.Provider value={editorConnection}>
|
||||||
|
<MonacoEditorContext.Provider value={editor}>
|
||||||
|
<LevelAppBar
|
||||||
|
isLoading={level.isLoading}
|
||||||
|
levelTitle={`${mobile ? '' : 'Level '}${levelId} / ${gameInfo.data?.worldSize[worldId]}` +
|
||||||
|
(level?.data?.title && ` : ${level?.data?.title}`)}
|
||||||
|
toggleImpressum={toggleImpressum}
|
||||||
|
pageNumber={pageNumber} setPageNumber={setPageNumber} />
|
||||||
|
{mobile?
|
||||||
|
// TODO: This is copied from the `Split` component below...
|
||||||
|
<>
|
||||||
|
<div className={`app-content level-mobile ${level.isLoading ? 'hidden' : ''}`}>
|
||||||
|
<ExercisePanel
|
||||||
|
impressum={impressum}
|
||||||
|
closeImpressum={closeImpressum}
|
||||||
|
codeviewRef={codeviewRef}
|
||||||
|
visible={pageNumber == 0} />
|
||||||
|
<InventoryPanel visible={pageNumber == 1} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
|
||||||
|
<ChatPanel lastLevel={lastLevel}/>
|
||||||
|
<ExercisePanel
|
||||||
|
impressum={impressum}
|
||||||
|
closeImpressum={closeImpressum}
|
||||||
|
codeviewRef={codeviewRef} />
|
||||||
|
<InventoryPanel />
|
||||||
|
</Split>
|
||||||
|
}
|
||||||
|
</MonacoEditorContext.Provider>
|
||||||
|
</EditorContext.Provider>
|
||||||
|
</ProofContext.Provider>
|
||||||
|
</InputModeContext.Provider>
|
||||||
|
</SelectionContext.Provider>
|
||||||
|
</DeletedChatContext.Provider>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Level
|
||||||
|
|
||||||
|
/** The site with the introduction text of a world */
|
||||||
|
function Introduction() {
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
const {worldId} = useContext(WorldLevelIdContext)
|
||||||
|
|
||||||
|
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||||
|
|
||||||
|
const [impressum, setImpressum] = React.useState(false)
|
||||||
|
|
||||||
|
const closeImpressum = () => {
|
||||||
|
setImpressum(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleImpressum = () => {
|
||||||
|
setImpressum(!impressum)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div style={gameInfo.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||||
|
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Introduction" toggleImpressum={toggleImpressum}/>
|
||||||
|
<div style={gameInfo.isLoading ? {display: "none"} : null} className="introduction-panel">
|
||||||
|
<div className="content-wrapper">
|
||||||
|
<Markdown>
|
||||||
|
{gameInfo.data?.worlds.nodes[worldId].introduction}
|
||||||
|
</Markdown>
|
||||||
|
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
|
||||||
|
</div>
|
||||||
|
{gameInfo.data?.worldSize[worldId] == 0 ?
|
||||||
|
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
|
||||||
|
<Button to={`/${gameId}/world/${worldId}/level/1`}>
|
||||||
|
Start <FontAwesomeIcon icon={faArrowRight} />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The top-navigation bar */
|
||||||
|
function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber = undefined, setPageNumber = undefined}) {
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||||
|
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||||
|
|
||||||
|
const {mobile} = React.useContext(MobileContext)
|
||||||
|
|
||||||
|
const difficulty = useSelector(selectDifficulty(gameId))
|
||||||
|
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
||||||
|
|
||||||
|
const { commandLineMode, setCommandLineMode } = React.useContext(InputModeContext)
|
||||||
|
|
||||||
|
const [navOpen, setNavOpen] = React.useState(false)
|
||||||
|
|
||||||
|
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
|
||||||
|
{mobile ?
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<span className="app-bar-title">
|
||||||
|
{levelTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="nav-btns">
|
||||||
|
{mobile && pageNumber == 0 ?
|
||||||
|
<Button to=""
|
||||||
|
title="show inventory" inverted="true" onClick={() => {setPageNumber(1)}}>
|
||||||
|
<FontAwesomeIcon icon={faBook}/>
|
||||||
|
</Button>
|
||||||
|
: pageNumber == 1 &&
|
||||||
|
<Button to=""
|
||||||
|
title="show inventory" inverted="true" onClick={() => {setPageNumber(0)}}>
|
||||||
|
<FontAwesomeIcon icon={faArrowLeft}/>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
<Button to="" id="menu-btn" onClick={(ev) => {setNavOpen(!navOpen)}} >
|
||||||
|
{navOpen ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faBars} />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={'menu dropdown' + (navOpen ? '' : ' hidden')}>
|
||||||
|
{levelId < gameInfo.data?.worldSize[worldId] &&
|
||||||
|
<Button inverted="true"
|
||||||
|
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level"
|
||||||
|
disabled={difficulty >= 2 && !(completed || levelId == 0)}
|
||||||
|
onClick={() => setNavOpen(false)}>
|
||||||
|
<FontAwesomeIcon icon={faArrowRight} /> {levelId ? "Next" : "Start"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{levelId > 0 && <>
|
||||||
|
<Button disabled={levelId <= 0} inverted="true"
|
||||||
|
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
|
||||||
|
title="previous level"
|
||||||
|
onClick={() => setNavOpen(false)}>
|
||||||
|
<FontAwesomeIcon icon={faArrowLeft} /> Previous
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
<Button to={`/${gameId}`} inverted="true" title="back to world selection">
|
||||||
|
<FontAwesomeIcon icon={faHome} /> Home
|
||||||
|
</Button>
|
||||||
|
<Button disabled={levelId <= 0} inverted="true" to=""
|
||||||
|
onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
|
||||||
|
title="toggle Editor mode">
|
||||||
|
<FontAwesomeIcon icon={faCode} /> Toggle Editor
|
||||||
|
</Button>
|
||||||
|
<Button title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
|
||||||
|
<FontAwesomeIcon icon={faCircleInfo} /> Info & Impressum
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
|
||||||
|
<FontAwesomeIcon icon={faHome} />
|
||||||
|
</Button>
|
||||||
|
<span className="app-bar-title">
|
||||||
|
{gameInfo.data?.worlds.nodes[worldId].title && `World: ${gameInfo.data?.worlds.nodes[worldId].title}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="app-bar-title">
|
||||||
|
{levelTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="nav-btns">
|
||||||
|
{levelId > 0 && <>
|
||||||
|
<Button disabled={levelId <= 0} inverted="true"
|
||||||
|
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
|
||||||
|
title="previous level"
|
||||||
|
onClick={() => setNavOpen(false)}>
|
||||||
|
<FontAwesomeIcon icon={faArrowLeft} /> Previous
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
{levelId < gameInfo.data?.worldSize[worldId] &&
|
||||||
|
<Button inverted="true"
|
||||||
|
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level"
|
||||||
|
disabled={difficulty >= 2 && !(completed || levelId == 0)}
|
||||||
|
onClick={() => setNavOpen(false)}>
|
||||||
|
<FontAwesomeIcon icon={faArrowRight} /> {levelId ? "Next" : "Start"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
<Button disabled={levelId <= 0} inverted="true" to=""
|
||||||
|
onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
|
||||||
|
title="toggle Editor mode">
|
||||||
|
<FontAwesomeIcon icon={faCode} />
|
||||||
|
</Button>
|
||||||
|
<Button title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
|
||||||
|
<FontAwesomeIcon icon={faCircleInfo} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
|
||||||
|
|
||||||
|
const connection = React.useContext(ConnectionContext)
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||||
|
|
||||||
|
|
||||||
|
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
|
||||||
|
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
|
||||||
|
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
|
||||||
|
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
|
||||||
|
|
||||||
|
// Create Editor
|
||||||
|
useEffect(() => {
|
||||||
|
const editor = monaco.editor.create(codeviewRef.current!, {
|
||||||
|
glyphMargin: true,
|
||||||
|
quickSuggestions: false,
|
||||||
|
lightbulb: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
unicodeHighlight: {
|
||||||
|
ambiguousCharacters: false,
|
||||||
|
},
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
lineNumbersMinChars: 3,
|
||||||
|
'semanticHighlighting.enabled': true,
|
||||||
|
theme: 'vs-code-theme-converted'
|
||||||
|
})
|
||||||
|
|
||||||
|
const infoProvider = new InfoProvider(connection.getLeanClient(gameId))
|
||||||
|
|
||||||
|
const editorApi = infoProvider.getApi()
|
||||||
|
|
||||||
|
const editorEvents: EditorEvents = {
|
||||||
|
initialize: new EventEmitter(),
|
||||||
|
gotServerNotification: new EventEmitter(),
|
||||||
|
sentClientNotification: new EventEmitter(),
|
||||||
|
serverRestarted: new EventEmitter(),
|
||||||
|
serverStopped: new EventEmitter(),
|
||||||
|
changedCursorLocation: new EventEmitter(),
|
||||||
|
changedInfoviewConfig: new EventEmitter(),
|
||||||
|
runTestScript: new EventEmitter(),
|
||||||
|
requestedAction: new EventEmitter(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Challenge: write a type-correct fn from `Eventify<T>` to `T` without using `any`
|
||||||
|
const infoviewApi: InfoviewApi = {
|
||||||
|
initialize: async l => editorEvents.initialize.fire(l),
|
||||||
|
gotServerNotification: async (method, params) => {
|
||||||
|
editorEvents.gotServerNotification.fire([method, params]);
|
||||||
|
},
|
||||||
|
sentClientNotification: async (method, params) => {
|
||||||
|
editorEvents.sentClientNotification.fire([method, params]);
|
||||||
|
},
|
||||||
|
serverRestarted: async r => editorEvents.serverRestarted.fire(r),
|
||||||
|
serverStopped: async serverStoppedReason => {
|
||||||
|
editorEvents.serverStopped.fire(serverStoppedReason)
|
||||||
|
},
|
||||||
|
changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc),
|
||||||
|
changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf),
|
||||||
|
requestedAction: async action => editorEvents.requestedAction.fire(action),
|
||||||
|
// See https://rollupjs.org/guide/en/#avoiding-eval
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||||
|
runTestScript: async script => new Function(script)(),
|
||||||
|
getInfoviewHtml: async () => document.body.innerHTML,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ec = new EditorConnection(editorApi, editorEvents);
|
||||||
|
setEditorConnection(ec)
|
||||||
|
|
||||||
|
editorEvents.initialize.on((loc: Location) => ec.events.changedCursorLocation.fire(loc))
|
||||||
|
|
||||||
|
setEditor(editor)
|
||||||
|
setInfoProvider(infoProvider)
|
||||||
|
setInfoviewApi(infoviewApi)
|
||||||
|
|
||||||
|
return () => { infoProvider.dispose(); editor.dispose() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const {leanClient, leanClientStarted} = useLeanClient(gameId)
|
||||||
|
|
||||||
|
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||||
|
|
||||||
|
// Create model when level changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && leanClientStarted) {
|
||||||
|
|
||||||
|
let model = monaco.editor.getModel(uri)
|
||||||
|
if (!model) {
|
||||||
|
model = monaco.editor.createModel(initialCode, 'lean4', uri)
|
||||||
|
}
|
||||||
|
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
|
||||||
|
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
|
||||||
|
editor.setModel(model)
|
||||||
|
if (initialSelections) {
|
||||||
|
console.debug("Initial Selection: ", initialSelections)
|
||||||
|
// BUG: Somehow I get an `invalid arguments` bug here
|
||||||
|
// editor.setSelections(initialSelections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editor, levelId, connection, leanClientStarted])
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && leanClientStarted) {
|
||||||
|
|
||||||
|
let model = monaco.editor.getModel(uri)
|
||||||
|
infoviewApi.serverRestarted(leanClient.initializeResult)
|
||||||
|
|
||||||
|
infoProvider.openPreview(editor, infoviewApi)
|
||||||
|
|
||||||
|
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
|
||||||
|
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
|
||||||
|
|
||||||
|
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
|
||||||
|
}
|
||||||
|
}, [editor, connection, leanClientStarted])
|
||||||
|
|
||||||
|
return {editor, infoProvider, editorConnection}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open all files in this world on the server so that they will load faster when accessed */
|
||||||
|
function useLoadWorldFiles(worldId) {
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameInfo.data) {
|
||||||
|
const models = []
|
||||||
|
for (let levelId = 1; levelId <= gameInfo.data.worldSize[worldId]; levelId++) {
|
||||||
|
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||||
|
let model = monaco.editor.getModel(uri)
|
||||||
|
if (model) {
|
||||||
|
models.push(model)
|
||||||
|
} else {
|
||||||
|
const code = selectCode(gameId, worldId, levelId)(store.getState())
|
||||||
|
models.push(monaco.editor.createModel(code, 'lean4', uri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => { for (let model of models) { model.dispose() } }
|
||||||
|
}
|
||||||
|
}, [gameInfo.data, worldId])
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* @fileOverview The impressum/privacy policy
|
||||||
|
*/
|
||||||
|
import { faShield } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
/** Pop-up that is displayed when opening the privacy policy.
|
||||||
|
*
|
||||||
|
* `handleClose` is the function to close it again because it's open/closed state is
|
||||||
|
* controlled by the containing element.
|
||||||
|
*/
|
||||||
|
export function PrivacyPolicyPopup ({handleClose}: {handleClose: () => void}) {
|
||||||
|
return <div className="modal-wrapper">
|
||||||
|
<div className="modal-backdrop" onClick={handleClose} />
|
||||||
|
<div className="modal">
|
||||||
|
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||||
|
<h2>Privacy Policy & Impressum</h2>
|
||||||
|
<p>
|
||||||
|
Our server collects metadata (such as IP address, browser, operating system)
|
||||||
|
and the data that the user enters into the editor. The data is used to
|
||||||
|
compute the Lean output and display it to the user. The information will be stored
|
||||||
|
as long as the user stays on our website and will be deleted immediately afterwards.
|
||||||
|
We keep logs to improve our software, but the contained data is anonymized.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We do not use cookies, but your game progress is stored in the browser
|
||||||
|
as site data. Your game progress is not saved on the server; if you delete
|
||||||
|
your browser storage, it is completely gone.
|
||||||
|
</p>
|
||||||
|
<p>Our server is located in Germany.</p>
|
||||||
|
<p>
|
||||||
|
<strong>Contact information:</strong><br />
|
||||||
|
Jon Eugster<br />
|
||||||
|
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
||||||
|
Universitätsstr. 1<br />
|
||||||
|
40225 Düsseldorf<br />
|
||||||
|
Germany<br />
|
||||||
|
+49 211 81-12173<br />
|
||||||
|
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrivacyPolicy: React.FC = () => {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const handleOpen = () => setOpen(true)
|
||||||
|
const handleClose = () => setOpen(false)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="privacy" onClick={handleOpen} title="Privacy Policy & Impressum">
|
||||||
|
<FontAwesomeIcon icon={faShield} />
|
||||||
|
<p className="p1">legal</p>
|
||||||
|
<p className="p2">notes</p>
|
||||||
|
</div>
|
||||||
|
{open ? <PrivacyPolicyPopup handleClose={handleClose} /> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Impressum({handleClose}) {
|
||||||
|
return <div className="impressum">
|
||||||
|
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||||
|
<h2>Funding</h2>
|
||||||
|
<p>
|
||||||
|
This Lean game engine has been developed as part of the
|
||||||
|
project <a href="https://hhu-adam.github.io/" target="_blank">ADAM: Anticipating the Digital
|
||||||
|
Age of Mathematics</a> at
|
||||||
|
Heinrich-Heine-Universität Düsseldorf. It is funded by
|
||||||
|
the <i>Stiftung Innovation in der Hochschullehre</i> as part of project <i>Freiraum 2022</i>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Development</h2>
|
||||||
|
<p>
|
||||||
|
The source code is <a href="https://github.com/leanprover-community/lean4game" target="_blank">available on Github</a>.
|
||||||
|
If you experience any problems, please
|
||||||
|
file an <a href="https://github.com/leanprover-community/lean4game/issues" target="_blank">Issue on Github</a> or
|
||||||
|
get directly in contact.
|
||||||
|
</p>
|
||||||
|
<h2>Privacy Policy & Impressum</h2>
|
||||||
|
<p>
|
||||||
|
Our server collects metadata (such as IP address, browser, operating system)
|
||||||
|
and the data that the user enters into the editor. The data is used to
|
||||||
|
compute the Lean output and display it to the user. The information will be stored
|
||||||
|
as long as the user stays on our website and will be deleted immediately afterwards.
|
||||||
|
We keep logs to improve our software, but the contained data is anonymised.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We do not use cookies, but your game progress is stored in the browser storage
|
||||||
|
as site data. Your game progress is not saved on the server; if you delete
|
||||||
|
your browser storage, it is completely gone.
|
||||||
|
</p>
|
||||||
|
<p>Our server is located in Germany.</p>
|
||||||
|
<p>
|
||||||
|
<strong>Contact information:</strong><br />
|
||||||
|
Jon Eugster<br />
|
||||||
|
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
||||||
|
Universitätsstr. 1<br />
|
||||||
|
40225 Düsseldorf<br />
|
||||||
|
Germany<br />
|
||||||
|
+49 211 81-12173<br />
|
||||||
|
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import Split from 'react-split'
|
||||||
|
import { Box, Typography, CircularProgress } from '@mui/material'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
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_tree'
|
||||||
|
|
||||||
|
import './welcome.css'
|
||||||
|
|
||||||
|
/** navigation to switch between pages on mobile */
|
||||||
|
function MobileNav({pageNumber, setPageNumber}:
|
||||||
|
{ pageNumber: number,
|
||||||
|
setPageNumber: any}) {
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
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 <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>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The panel showing the game's introduction text */
|
||||||
|
function IntroductionPanel({introduction}: { introduction: string}) {
|
||||||
|
const {mobile} = React.useContext(MobileContext)
|
||||||
|
return <div className="column">
|
||||||
|
<Typography variant="body1" component="div" className="welcome-text">
|
||||||
|
{!mobile && <WelcomeMenu />}
|
||||||
|
<Markdown>{introduction}</Markdown>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** main page of the game showing amoung others the tree of worlds/levels */
|
||||||
|
function Welcome() {
|
||||||
|
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))
|
||||||
|
// On mobile, there are multiple pages to switch between
|
||||||
|
const [pageNumber, setPageNumber] = useState(openedIntro ? 1 : 0)
|
||||||
|
|
||||||
|
// set the window title
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameInfo.data?.title) {
|
||||||
|
window.document.title = gameInfo.data.title
|
||||||
|
}
|
||||||
|
}, [gameInfo.data?.title])
|
||||||
|
|
||||||
|
return <div className="app-content">
|
||||||
|
{ gameInfo.isLoading?
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
: mobile ?
|
||||||
|
<>
|
||||||
|
<MobileNav pageNumber={pageNumber} setPageNumber={setPageNumber} />
|
||||||
|
{(pageNumber == 0 ?
|
||||||
|
<IntroductionPanel introduction={gameInfo.data?.introduction} />
|
||||||
|
: pageNumber == 1 ?
|
||||||
|
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
|
||||||
|
:
|
||||||
|
<InventoryPanel />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<Split className="welcome" minSize={0} snapOffset={200} sizes={[40, 35, 25]}>
|
||||||
|
<IntroductionPanel introduction={gameInfo.data?.introduction} />
|
||||||
|
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
|
||||||
|
<InventoryPanel />
|
||||||
|
</Split>
|
||||||
|
}
|
||||||
|
<PrivacyPolicy />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Welcome
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
.world-selection-menu {
|
||||||
|
padding: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world-selection-menu .btn, .welcome .btn {
|
||||||
|
min-width: 5em;
|
||||||
|
text-align: center;
|
||||||
|
margin-left: .4em;
|
||||||
|
margin-right: .4em;
|
||||||
|
margin-bottom: .2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world-selection-menu .slider-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
/* min-width: 16em; */
|
||||||
|
padding-left: 0.5em;
|
||||||
|
padding-right: 3em;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.difficulty-label {
|
||||||
|
font-size: 0.875em;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test for mobile `title`s */
|
||||||
|
/* @media (pointer: coarse), (hover: none) {
|
||||||
|
[title] {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
[title]:focus::after {
|
||||||
|
content: attr(title);
|
||||||
|
position: absolute;
|
||||||
|
top: 90%;
|
||||||
|
color: #000;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
} */
|
||||||
@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* @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 { faDownload, faUpload, faEraser, faGlobe, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
import { GameIdContext } from '../app'
|
||||||
|
import { useAppDispatch } from '../hooks'
|
||||||
|
import { deleteProgress, selectProgress, loadProgress, GameProgressState,
|
||||||
|
selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'
|
||||||
|
import { store } from '../state/store'
|
||||||
|
import { Button } from './button'
|
||||||
|
|
||||||
|
import './world_tree.css'
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<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` */
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: I think this should be removed and that single button incorporated differently
|
||||||
|
/** Menu on desktop to go back to game server */
|
||||||
|
export function WelcomeMenu() {
|
||||||
|
|
||||||
|
return <nav className="world-selection-menu">
|
||||||
|
<Button inverted="false" title="back to games selection" to="/">
|
||||||
|
<FontAwesomeIcon icon={faArrowLeft} /> <FontAwesomeIcon icon={faGlobe} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The menu that is shown next to the world selection graph */
|
||||||
|
function WorldSelectionMenu() {
|
||||||
|
const [file, setFile] = React.useState<File>();
|
||||||
|
|
||||||
|
const gameId = React.useContext(GameIdContext)
|
||||||
|
const store = useStore()
|
||||||
|
const difficulty = useSelector(selectDifficulty(gameId))
|
||||||
|
|
||||||
|
|
||||||
|
/* state variables to toggle the pop-up menus */
|
||||||
|
const [eraseMenu, setEraseMenu] = React.useState(false);
|
||||||
|
const openEraseMenu = () => setEraseMenu(true);
|
||||||
|
const closeEraseMenu = () => setEraseMenu(false);
|
||||||
|
const [uploadMenu, setUploadMenu] = React.useState(false);
|
||||||
|
const openUploadMenu = () => setUploadMenu(true);
|
||||||
|
const closeUploadMenu = () => setUploadMenu(false);
|
||||||
|
|
||||||
|
const gameProgress = useSelector(selectProgress(gameId))
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
/** Download the current progress (i.e. what's saved in the browser store) */
|
||||||
|
const downloadProgress = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
downloadFile({
|
||||||
|
data: JSON.stringify(gameProgress, null, 2),
|
||||||
|
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
|
||||||
|
fileType: 'text/json',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
setFile(e.target.files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload progress from a */
|
||||||
|
const uploadProgress = (e) => {
|
||||||
|
if (!file) {return}
|
||||||
|
const fileReader = new FileReader()
|
||||||
|
fileReader.readAsText(file, "UTF-8")
|
||||||
|
fileReader.onload = (e) => {
|
||||||
|
const data = JSON.parse(e.target.result.toString()) as GameProgressState
|
||||||
|
console.debug("Json Data", data)
|
||||||
|
dispatch(loadProgress({game: gameId, data: data}))
|
||||||
|
}
|
||||||
|
closeUploadMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const eraseProgress = () => {
|
||||||
|
dispatch(deleteProgress({game: gameId}))
|
||||||
|
closeEraseMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadAndErase = (e) => {
|
||||||
|
downloadProgress(e)
|
||||||
|
eraseProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(x : number) {
|
||||||
|
return x == 0 ? 'playground' : x == 1 ? 'explorer' : 'regular'
|
||||||
|
}
|
||||||
|
|
||||||
|
return <nav className="world-selection-menu">
|
||||||
|
<Button onClick={downloadProgress} title="Download game progress" to=""><FontAwesomeIcon icon={faDownload} /></Button>
|
||||||
|
<Button title="Load game progress from JSON" onClick={openUploadMenu} to=""><FontAwesomeIcon icon={faUpload} /></Button>
|
||||||
|
<Button title="Clear game progress" to="" onClick={openEraseMenu}><FontAwesomeIcon icon={faEraser} /></Button>
|
||||||
|
<div className="slider-wrap">
|
||||||
|
<span className="difficulty-label">difficulty:</span>
|
||||||
|
<Slider
|
||||||
|
title="Difficulties: - regular: 🔐 levels, 🔐 tactics - explorer: 🔓 levels, 🔐 tactics - playground: 🔓 levels, 🔓 tactics"
|
||||||
|
min={0} max={2}
|
||||||
|
aria-label="Mode"
|
||||||
|
defaultValue={difficulty}
|
||||||
|
marks={[
|
||||||
|
{value: 0, label: label(0)},
|
||||||
|
{value: 1, label: label(1)},
|
||||||
|
{value: 2, label: label(2)}
|
||||||
|
]}
|
||||||
|
valueLabelFormat={label}
|
||||||
|
getAriaValueText={label}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
onChange={(ev, val: number) => {
|
||||||
|
dispatch(changedDifficulty({game: gameId, difficulty: val}))
|
||||||
|
}}
|
||||||
|
></Slider>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{eraseMenu?
|
||||||
|
<div className="modal-wrapper">
|
||||||
|
<div className="modal-backdrop" onClick={closeEraseMenu} />
|
||||||
|
<div className="modal">
|
||||||
|
<div className="codicon codicon-close modal-close" onClick={closeEraseMenu}></div>
|
||||||
|
<h2>Delete Progress?</h2>
|
||||||
|
|
||||||
|
<p>Do you want to delete your saved progress irreversibly?</p>
|
||||||
|
<p>
|
||||||
|
(This deletes your proofs and your collected inventory.
|
||||||
|
Saves from other games are not deleted.)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button onClick={eraseProgress} to="">Delete</Button>
|
||||||
|
<Button onClick={downloadAndErase} to="">Download & Delete</Button>
|
||||||
|
<Button onClick={closeEraseMenu} to="">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div> : null}
|
||||||
|
{uploadMenu ?
|
||||||
|
<div className="modal-wrapper">
|
||||||
|
<div className="modal-backdrop" onClick={closeUploadMenu} />
|
||||||
|
<div className="modal">
|
||||||
|
<div className="codicon codicon-close modal-close" onClick={closeUploadMenu}></div>
|
||||||
|
<h2>Upload Saved Progress</h2>
|
||||||
|
|
||||||
|
<p>Select a JSON file with the saved game progress to load your progress.</p>
|
||||||
|
|
||||||
|
<p><b>Warning:</b> This will delete your current game progress!
|
||||||
|
Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p>
|
||||||
|
|
||||||
|
<input type="file" onChange={handleFileChange}/>
|
||||||
|
|
||||||
|
<Button to="" onClick={uploadProgress}>Load selected file</Button>
|
||||||
|
</div>
|
||||||
|
</div> : null}
|
||||||
|
</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
|
||||||
|
})
|
||||||
|
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>
|
||||||
|
}
|
||||||
@ -1,19 +0,0 @@
|
|||||||
const KEY = "game_progress";
|
|
||||||
export function loadState() {
|
|
||||||
try {
|
|
||||||
const serializedState = localStorage.getItem(KEY);
|
|
||||||
if (!serializedState) return undefined;
|
|
||||||
return JSON.parse(serializedState);
|
|
||||||
} catch (e) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveState(state: any) {
|
|
||||||
try {
|
|
||||||
const serializedState = JSON.stringify(state);
|
|
||||||
localStorage.setItem(KEY, serializedState);
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @fileOverview Load/save the state to the local browser store
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEY = "game_progress";
|
||||||
|
|
||||||
|
/** Load from browser storage */
|
||||||
|
export function loadState() {
|
||||||
|
try {
|
||||||
|
const serializedState = localStorage.getItem(KEY);
|
||||||
|
if (!serializedState) return undefined;
|
||||||
|
let x = JSON.parse(serializedState);
|
||||||
|
// Complatibilty because `state.level` has been renamed to `x.games`.
|
||||||
|
// TODO: Does this work?
|
||||||
|
if (x.level) {
|
||||||
|
x.games = x.level
|
||||||
|
x.level = undefined
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
} catch (e) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save to browser storage */
|
||||||
|
export async function saveState(state: any) {
|
||||||
|
try {
|
||||||
|
const serializedState = JSON.stringify(state)
|
||||||
|
localStorage.setItem(KEY, serializedState);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import {useState, useEffect} from 'react'
|
||||||
|
|
||||||
|
function getWindowDimensions() {
|
||||||
|
const {innerWidth: width, innerHeight: height } = window
|
||||||
|
return {width, height}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWindowDimensions() {
|
||||||
|
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleResize() {
|
||||||
|
setWindowDimensions(getWindowDimensions())
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return windowDimensions
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1 +1 @@
|
|||||||
leanprover/lean4:nightly-2023-03-09
|
leanprover/lean4:nightly-2023-06-20
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
import GameServer.Commands
|
||||||
|
|
||||||
|
open Lean
|
||||||
|
|
||||||
|
-- E → A → B → C → A and
|
||||||
|
-- F → G → F
|
||||||
|
open HashMap in
|
||||||
|
def testArrows : HashMap Name (HashSet Name) :=
|
||||||
|
ofList [("a", (HashSet.empty.insert "b": HashSet Name).insert "d"),
|
||||||
|
("b", (HashSet.empty.insert "c": HashSet Name)),
|
||||||
|
("c", (HashSet.empty.insert "a": HashSet Name)),
|
||||||
|
("d", {}),
|
||||||
|
("f", (HashSet.empty.insert "g": HashSet Name)),
|
||||||
|
("g", (HashSet.empty.insert "f": HashSet Name)),
|
||||||
|
("e", (HashSet.empty.insert "a": HashSet Name).insert "f")]
|
||||||
|
|
||||||
|
-- some permutation of ``[`c, `a, `b]`` or ``[`f, `g]``
|
||||||
|
#eval findLoops testArrows
|
||||||
Loading…
Reference in New Issue