Merge branch 'dev' into world_overviews

This commit is contained in:
Jon Eugster
2023-12-10 00:18:47 +01:00
61 changed files with 3357 additions and 1699 deletions
-39
View File
@@ -1,39 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lean Game Server</title>
<style>
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
</style>
</head>
<body>
<div id="root"></div>
<noscript>
<div class="landing-page">
<h1>Lean Game Server</h1>
<p>
<i>You need to enable JavaScript to use the Lean Game Server, as it is built
using React.</i>
</p>
<p>
<strong>Impressum:</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>
</noscript>
<script src="bundle.js"></script>
</body>
</html>
+7 -12
View File
@@ -6,33 +6,28 @@ import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import './reset.css';
import './app.css';
import './css/reset.css';
import './css/app.css';
import { MobileContext } from './components/infoview/context';
import { useWindowDimensions } from './window_width';
import { selectOpenedIntro } from './state/progress';
import { useSelector } from 'react-redux';
import { connection } from './connection';
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)
// On mobile, there are multiple pages on the welcome page to switch between
const openedIntro = useSelector(selectOpenedIntro(gameId))
// On mobile, there are multiple pages to switch between
const [pageNumber, setPageNumber] = React.useState(openedIntro ? 1 : 0)
React.useEffect(() => {
connection.startLeanClient(gameId);
}, [gameId])
return (
<div className="app">
<GameIdContext.Provider value={gameId}>
<MobileContext.Provider value={{mobile, setMobile, pageNumber, setPageNumber}}>
<MobileContext.Provider value={{mobile, setMobile}}>
<Outlet />
</MobileContext.Provider>
</GameIdContext.Provider>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

+190 -344
View File
@@ -1,45 +1,45 @@
/**
* @file contains the navigation bars of the app.
*/
import * as React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser, faBook, faBookOpen, faGlobe, faHome,
faArrowRight, faArrowLeft, faXmark, faBars, faCode,
faCircleInfo, faTerminal } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from "../app"
import { InputModeContext, MobileContext, WorldLevelIdContext } from "./infoview/context"
import { GameInfo, useGetGameInfoQuery } from '../state/api'
import { changedOpenedIntro, selectCompleted, selectDifficulty, selectProgress } from '../state/progress'
import { useSelector } from 'react-redux'
import { useAppDispatch, useAppSelector } from '../hooks'
import { Button } from './button'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser, faBook, faBookOpen, faGlobe, faHome, faArrowRight, faArrowLeft, faXmark, faBars, faCode, faCircleInfo, faTerminal } from '@fortawesome/free-solid-svg-icons'
import { PrivacyPolicyPopup } from './popup/privacy_policy'
import { WorldSelectionMenu, downloadFile } from './world_tree'
import { downloadProgress } from './popup/erase'
/** navigation to switch between pages on mobile */
function MobileNav({pageNumber, setPageNumber}:
/** navigation buttons for mobile welcome page to switch between intro/tree/inventory. */
function MobileNavButtons({pageNumber, setPageNumber}:
{ pageNumber: number,
setPageNumber: any}) {
const gameId = React.useContext(GameIdContext)
const dispatch = useAppDispatch()
let prevText = {0 : null, 1: "Intro", 2: null}[pageNumber]
let prevIcon = {0 : null, 1: null, 2: faBookOpen}[pageNumber]
let prevTitle = {
0: null,
1: "Game Introduction",
2: "World selection"}[pageNumber]
let nextText = {0 : "Start", 1: null, 2: null}[pageNumber]
let nextIcon = {0 : null, 1: faBook, 2: null}[pageNumber]
let nextTitle = {
0: "World selection",
1: "Inventory",
2: null}[pageNumber]
// if `prevText` or `prevIcon` is set, show a button to go back
let prevText = {0: null, 1: "Intro", 2: null}[pageNumber]
let prevIcon = {0: null, 1: null, 2: faBookOpen}[pageNumber]
let prevTitle = {0: null, 1: "Game Introduction", 2: "World selection"}[pageNumber]
// if `nextText` or `nextIcon` is set, show a button to go forward
let nextText = {0: "Start", 1: null, 2: null}[pageNumber]
let nextIcon = {0: null, 1: faBook, 2: null}[pageNumber]
let nextTitle = {0: "World selection", 1: "Inventory", 2: null}[pageNumber]
return <>
{(prevText || prevTitle || prevIcon) &&
<Button className="btn btn-inverted toggle-width" to={pageNumber == 0 ? "/" : ""} inverted="true" title={prevTitle}
{(prevText || prevIcon) &&
<Button className="btn btn-inverted toggle-width" to={pageNumber == 0 ? "/" : ""}
inverted="true" title={prevTitle}
onClick={() => {pageNumber == 0 ? null : setPageNumber(pageNumber - 1)}}>
{prevIcon && <FontAwesomeIcon icon={prevIcon} />}
{prevText && `${prevText}`}
</Button>
}
{(nextText || nextTitle || nextIcon) &&
{(nextText || nextIcon) &&
<Button className="btn btn-inverted toggle-width" to="" inverted="true"
title={nextTitle} onClick={() => {
console.log(`page number: ${pageNumber}`)
@@ -52,357 +52,203 @@ function MobileNav({pageNumber, setPageNumber}:
</>
}
export function WelcomeAppBar({gameInfo, toggleImpressum, openEraseMenu, openUploadMenu, toggleInfo} : {
gameInfo: GameInfo,
toggleImpressum: any,
openEraseMenu: any,
openUploadMenu: any,
toggleInfo: any
}) {
const gameId = React.useContext(GameIdContext)
const {mobile, pageNumber, setPageNumber} = React.useContext(MobileContext)
const [navOpen, setNavOpen] = React.useState(false)
/** Download the current progress (i.e. what's saved in the browser store) */
const gameProgress = useSelector(selectProgress(gameId))
const downloadProgress = (e) => {
e.preventDefault()
downloadFile({
data: JSON.stringify(gameProgress, null, 2),
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
fileType: 'text/json',
})
}
return <div className="app-bar" >
<>
<div>
<Button inverted="false" title="back to games selection" to="/">
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;<FontAwesomeIcon icon={faGlobe} />
</Button>
<span className="app-bar-title"></span>
</div>
<div>
<span className="app-bar-title">
{mobile ? '' : gameInfo?.title}
</span>
</div>
<div className="nav-btns">
{mobile && <>
{/* BUTTONS for MOBILE */}
<MobileNav pageNumber={pageNumber} setPageNumber={setPageNumber} />
</>}
<Button to="" className="btn toggle-width" 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} />&nbsp;{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} />&nbsp;Previous
</Button>
</>}
<Button to={`/${gameId}`} inverted="true" title="back to world selection">
<FontAwesomeIcon icon={faHome} />&nbsp;Home
</Button>
<Button disabled={levelId <= 0} inverted="true" to=""
onClick={(ev) => { setTypewriterMode(!typewriterMode); setNavOpen(false) }}
title="toggle Editor mode">
<FontAwesomeIcon icon={faCode} />&nbsp;Toggle Editor
</Button> */}
<Button title="Game Info & Credits" inverted="true" to="" onClick={(ev) => {toggleInfo(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Game Info
</Button>
<Button title="Clear Progress" inverted="true" to="" onClick={(ev) => {openEraseMenu(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faEraser} />&nbsp;Erase
</Button>
<Button title="Download Progress" inverted="true" to="" onClick={(ev) => {downloadProgress(ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={faDownload} />&nbsp;Download
</Button>
<Button title="Load Progress from JSON" inverted="true" to="" onClick={(ev) => {openUploadMenu(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faUpload} />&nbsp;Upload
</Button>
<Button title="Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Impressum
</Button>
</div>
</>
</div>
/** button to toggle dropdown menu. */
function MenuButton({navOpen, setNavOpen}) {
return <Button to="" className="btn toggle-width" id="menu-btn" onClick={(ev) => {setNavOpen(!navOpen)}}>
{navOpen ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faBars} />}
</Button>
}
// /** 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 ? 'none' : x == 1 ? 'lax' : '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">Game Rules:</span>
// <Slider
// title="Game Rules:&#10;- regular: 🔐 levels, 🔐 tactics&#10;- lax: 🔓 levels, 🔐 tactics&#10;- none: 🔓 levels, 🔓 tactics"
// min={0} max={2}
// aria-label="Game Rules"
// defaultValue={difficulty}
// marks={[
// {value: 0, label: label(0)},
// {value: 1, label: label(1)},
// {value: 2, label: label(2)}
// ]}
// valueLabelFormat={label}
// getAriaValueText={label}
// valueLabelDisplay="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>
// }
/** The top-navigation bar */
export function LevelAppBar({
isLoading, levelTitle, impressum, toggleImpressum,
pageNumber = undefined, setPageNumber = undefined, lockEditorMode=false}) {
/** button to go one level futher.
* for the last level, this button turns into a button going back to the welcome page.
*/
function NextButton({worldSize, difficulty, completed, setNavOpen}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
return (levelId < worldSize ?
<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} />&nbsp;{levelId ? "Next" : "Start"}
</Button>
:
<Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World
</Button>
)
}
const {mobile} = React.useContext(MobileContext)
/** button to go one level back.
* only renders if the current level id is > 0.
*/
function PreviousButton({setNavOpen}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
return (levelId > 0 && <>
<Button disabled={levelId <= 0} inverted="true"
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
title="previous level"
onClick={() => setNavOpen(false)}>
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;Previous
</Button>
</>)
}
const difficulty = useSelector(selectDifficulty(gameId))
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
/** button to toggle between editor and typewriter */
function InputModeButton({setNavOpen, isDropdown}) {
const {levelId} = React.useContext(WorldLevelIdContext)
const {typewriterMode, setTypewriterMode, lockInputMode} = React.useContext(InputModeContext)
const { typewriterMode, setTypewriterMode } = React.useContext(InputModeContext)
const [navOpen, setNavOpen] = React.useState(false)
function toggleEditor(ev) {
if (!lockEditorMode){
/** toggle input mode if allowed */
function toggleInputMode(ev: React.MouseEvent) {
if (!lockInputMode){
setTypewriterMode(!typewriterMode)
setNavOpen(false)
}
}
return <Button
className={`btn btn-inverted ${isDropdown? '' : 'toggle-width'}`} disabled={levelId <= 0 || lockInputMode}
inverted="true" to=""
onClick={(ev) => toggleInputMode(ev)}
title={lockInputMode ? "Editor mode is enforced!" : typewriterMode ? "Editor mode" : "Typewriter mode"}>
<FontAwesomeIcon icon={typewriterMode ? faCode : faTerminal} />
{isDropdown && (typewriterMode ? <>&nbsp;Editor mode</> : <>&nbsp;Typewriter mode</>)}
</Button>
}
/** button to toggle iimpressum popup */
function ImpressumButton({setNavOpen, toggleImpressum, isDropdown}) {
return <Button className="btn btn-inverted toggle-width"
title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />
{isDropdown && <>&nbsp;Info &amp; Impressum</>}
</Button>
}
/** button to go back to welcome page */
function HomeButton({isDropdown}) {
const gameId = React.useContext(GameIdContext)
return <Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
<FontAwesomeIcon icon={faHome} />
{isDropdown && <>&nbsp;Home</>}
</Button>
}
/** button in mobile level to toggle inventory.
* only displays a button if `setPageNumber` is set.
*/
function InventoryButton({pageNumber, setPageNumber}) {
return (setPageNumber &&
<Button to="" className="btn btn-inverted toggle-width"
title={pageNumber ? "close inventory" : "show inventory"}
inverted="true" onClick={() => {setPageNumber(pageNumber ? 0 : 1)}}>
<FontAwesomeIcon icon={pageNumber ? faBookOpen : faBook} />
</Button>
)
}
/** the navigation bar on the welcome page */
export function WelcomeAppBar({pageNumber, setPageNumber, gameInfo, toggleImpressum, toggleEraseMenu, toggleUploadMenu, toggleInfo} : {
pageNumber: number,
setPageNumber: any,
gameInfo: GameInfo,
toggleImpressum: any,
toggleEraseMenu: any,
toggleUploadMenu: any,
toggleInfo: any
}) {
const gameId = React.useContext(GameIdContext)
const gameProgress = useAppSelector(selectProgress(gameId))
const {mobile} = React.useContext(MobileContext)
const [navOpen, setNavOpen] = React.useState(false)
return <div className="app-bar">
<div>
<Button inverted="false" title="back to games selection" to="/">
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;<FontAwesomeIcon icon={faGlobe} />
</Button>
<span className="app-bar-title"></span>
</div>
<div>
{!mobile && <span className="app-bar-title">{gameInfo?.title}</span>}
</div>
<div className="nav-btns">
{mobile && <MobileNavButtons pageNumber={pageNumber} setPageNumber={setPageNumber} />}
<MenuButton navOpen={navOpen} setNavOpen={setNavOpen} />
</div>
<div className={'menu dropdown' + (navOpen ? '' : ' hidden')}>
<Button title="Game Info & Credits" inverted="true" to="" onClick={() => {toggleInfo(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Game Info
</Button>
<Button title="Clear Progress" inverted="true" to="" onClick={() => {toggleEraseMenu(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faEraser} />&nbsp;Erase
</Button>
<Button title="Download Progress" inverted="true" to="" onClick={(ev) => {downloadProgress(gameId, gameProgress, ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={faDownload} />&nbsp;Download
</Button>
<Button title="Load Progress from JSON" inverted="true" to="" onClick={() => {toggleUploadMenu(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faUpload} />&nbsp;Upload
</Button>
<Button title="Impressum, privacy policy" inverted="true" to="" onClick={() => {toggleImpressum(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Impressum
</Button>
</div>
</div>
}
/** the navigation bar in a level */
export function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber=undefined, setPageNumber=undefined} : {
isLoading: boolean,
levelTitle: string,
toggleImpressum: any,
pageNumber?: number,
setPageNumber?: any,
}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
const {mobile} = React.useContext(MobileContext)
const [navOpen, setNavOpen] = React.useState(false)
const gameInfo = useGetGameInfoQuery({game: gameId})
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
const difficulty = useAppSelector(selectDifficulty(gameId))
let worldTitle = gameInfo.data?.worlds.nodes[worldId].title
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
{mobile ?
<>
{/* MOBILE VERSION */}
<div>
<span className="app-bar-title">
{levelTitle}
</span>
<span className="app-bar-title">{levelTitle}</span>
</div>
<div className="nav-btns">
{mobile && pageNumber == 0 ?
<Button to="" className="btn btn-inverted toggle-width"
title="show inventory" inverted="true" onClick={() => {setPageNumber(1)}}>
<FontAwesomeIcon icon={faBook}/>
</Button>
: pageNumber == 1 &&
<Button className="btn btn-inverted toggle-width" to=""
title="close inventory" inverted="true" onClick={() => {setPageNumber(0)}}>
<FontAwesomeIcon icon={faBookOpen}/>
</Button>
}
<Button to="" className="btn toggle-width" id="menu-btn" onClick={(ev) => {setNavOpen(!navOpen)}} >
{navOpen ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faBars} />}
</Button>
<InventoryButton pageNumber={pageNumber} setPageNumber={setPageNumber}/>
<MenuButton navOpen={navOpen} setNavOpen={setNavOpen}/>
</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} />&nbsp;{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} />&nbsp;Previous
</Button>
</>}
<Button to={`/${gameId}`} inverted="true" title="back to world selection">
<FontAwesomeIcon icon={faHome} />&nbsp;Home
</Button>
<Button disabled={levelId <= 0} inverted="true" to=""
onClick={(ev) => { setTypewriterMode(!typewriterMode); setNavOpen(false) }}
title={typewriterMode ? "Editor mode" : "Typewriter mode"}>
<FontAwesomeIcon icon={typewriterMode ? faCode : faTerminal} />
&nbsp;{typewriterMode ? "Editor mode" : "Typewriter mode"}
</Button>
<Button title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Info &amp; Impressum
</Button>
<NextButton worldSize={gameInfo.data?.worldSize[worldId]} difficulty={difficulty} completed={completed} setNavOpen={setNavOpen} />
<PreviousButton setNavOpen={setNavOpen} />
<HomeButton isDropdown={true} />
<InputModeButton setNavOpen={setNavOpen} isDropdown={true}/>
<ImpressumButton setNavOpen={setNavOpen} toggleImpressum={toggleImpressum} isDropdown={true} />
</div>
</>
:
</> :
<>
{/* DESKTOP VERSION */}
<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>
<HomeButton isDropdown={false} />
<span className="app-bar-title">{worldTitle && `World: ${worldTitle}`}</span>
</div>
<div>
<span className="app-bar-title">
{levelTitle}
</span>
<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} />&nbsp;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} />&nbsp;{levelId ? "Next" : "Start"}
</Button>
:
<Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World
</Button>
}
<Button className="btn btn-inverted toggle-width" disabled={levelId <= 0 || lockEditorMode} inverted="true" to=""
onClick={(ev) => toggleEditor(ev)}
title={lockEditorMode ? "Editor mode is enforced!" : typewriterMode ? "Editor mode" : "Typewriter mode"}>
<FontAwesomeIcon icon={typewriterMode ? faCode : faTerminal} />
</Button>
<Button className="btn btn-inverted toggle-width" title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={impressum ? faXmark : faCircleInfo} />
</Button>
<PreviousButton setNavOpen={setNavOpen} />
<NextButton worldSize={gameInfo.data?.worldSize[worldId]} difficulty={difficulty} completed={completed} setNavOpen={setNavOpen} />
<InputModeButton setNavOpen={setNavOpen} isDropdown={false}/>
<ImpressumButton setNavOpen={setNavOpen} toggleImpressum={toggleImpressum} isDropdown={false} />
</div>
</>
}
+22
View File
@@ -1,6 +1,7 @@
import { GameHint } from "./infoview/rpc_api";
import * as React from 'react';
import Markdown from './markdown';
import { ProofStep } from "./infoview/context";
export function Hint({hint, step, selected, toggleSelection, lastLevel} : {hint: GameHint, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) {
return <div className={`message information step-${step}` + (step == selected ? ' selected' : '') + (lastLevel ? ' recent' : '')} onClick={toggleSelection}>
@@ -43,3 +44,24 @@ export function DeletedHints({hints} : {hints: GameHint[]}) {
{hiddenHints.map((hint, i) => <DeletedHint key={`deleted-hidden-hint-${i}`} hint={hint}/>)}
</>
}
/** Filter hints to not show consequtive identical hints twice.
*
* This function takes a `ProofStep[]` and extracts the hints in form of an
* element of type `GameHint[][]` where it removes hints that are identical to hints
* appearing in the previous step. Hidden hints are not filtered.
*
* This effectively means we prevent consequtive identical hints from being shown.
*/
export function filterHints(proof: ProofStep[]): GameHint[][] {
return proof.map((step, i) => {
if (i == 0){
return step.hints
} else {
// TODO: Writing all fields explicitely is somewhat fragile to changes, is there a
// good way to shallow-compare objects?
return step.hints.filter((hint) => hint.hidden ||
(proof[i-1].hints.find((x) => (x.text == hint.text && x.hidden == hint.hidden)) === undefined))
}
})
}
+5 -5
View File
@@ -65,13 +65,9 @@ export const ProofStateContext = React.createContext<{
export const MobileContext = React.createContext<{
mobile : boolean,
setMobile: React.Dispatch<React.SetStateAction<Boolean>>,
pageNumber: number,
setPageNumber: React.Dispatch<React.SetStateAction<Number>>
}>({
mobile : false,
setMobile: () => {},
pageNumber: 0,
setPageNumber: () => {}
})
export const WorldLevelIdContext = React.createContext<{
@@ -108,10 +104,14 @@ export const InputModeContext = React.createContext<{
typewriterMode: boolean,
setTypewriterMode: React.Dispatch<React.SetStateAction<boolean>>,
typewriterInput: string,
setTypewriterInput: React.Dispatch<React.SetStateAction<string>>
setTypewriterInput: React.Dispatch<React.SetStateAction<string>>,
lockInputMode: boolean,
setLockInputMode: React.Dispatch<React.SetStateAction<boolean>>,
}>({
typewriterMode: true,
setTypewriterMode: () => {},
typewriterInput: "",
setTypewriterInput: () => {},
lockInputMode: false,
setLockInputMode: () => {},
});
+19 -9
View File
@@ -6,7 +6,7 @@ import type { DidCloseTextDocumentParams, DidChangeTextDocumentParams, Location,
import 'tachyons/css/tachyons.css';
import '@vscode/codicons/dist/codicon.css';
import '../../../../node_modules/lean4-infoview/src/infoview/index.css';
import './infoview.css'
import '../../css/infoview.css'
import { LeanFileProgressParams, LeanFileProgressProcessingInfo, defaultInfoviewConfig, EditorApi, InfoviewApi } from '@leanprover/infoview-api';
import { useClientNotificationEffect, useServerNotificationEffect, useEventResult, useServerNotificationState } from '../../../../node_modules/lean4-infoview/src/infoview/util';
@@ -34,7 +34,7 @@ import { Button } from '../button';
import { CircularProgress } from '@mui/material';
import { GameHint } from './rpc_api';
import { store } from '../../state/store';
import { Hints } from '../hints';
import { Hints, filterHints } from '../hints';
/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is
* always present, or the monaco editor cannot start.
@@ -137,9 +137,7 @@ function ExerciseStatement({ data, showLeanStatement = false }) {
<div className="exercise-statement">
{data?.descrText &&
<Markdown>
{data?.displayName ? `**Theorem** \`${data?.displayName}\`: ` : "" // data?.descrText && "**Exercise**: "
+ data?.descrText
}
{(data?.displayName ? `**Theorem** \`${data?.displayName}\`: ` : '') + data?.descrText}
</Markdown>
}
{data?.descrFormat && showLeanStatement &&
@@ -158,7 +156,7 @@ export function Main(props: { world: string, level: number, data: LevelInfo}) {
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
console.debug(`template: ${props.data.template}`)
console.debug(`template: ${props.data?.template}`)
// React.useEffect (() => {
// if (props.data.template) {
@@ -349,6 +347,7 @@ export function TypewriterInterface({props}) {
const uri = model.uri.toString()
const [disableInput, setDisableInput] = React.useState<boolean>(false)
const [loadingProgress, setLoadingProgress] = React.useState<number>(0)
const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext)
const {mobile} = React.useContext(MobileContext)
const { proof } = React.useContext(ProofContext)
@@ -368,9 +367,9 @@ export function TypewriterInterface({props}) {
function deleteProof(line: number) {
return (ev) => {
let deletedChat: Array<GameHint> = []
proof.slice(line).map((step, i) => {
filterHints(proof).slice(line).map((hintsAtStep, i) => {
// Only add these hidden hints to the deletion stack which were visible
deletedChat = [...deletedChat, ...step.hints.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
deletedChat = [...deletedChat, ...hintsAtStep.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
})
setDeletedChat(deletedChat)
@@ -457,6 +456,17 @@ export function TypewriterInterface({props}) {
let lastStepErrors = proof.length ? hasInteractiveErrors(proof[proof.length - 1].errors) : false
useServerNotificationEffect("$/game/loading", (params : any) => {
if (params.kind == "loadConstants") {
setLoadingProgress(params.counter/100*50)
} else if (params.kind == "finalizeExtensions") {
setLoadingProgress(50 + params.counter/150*50)
} else {
console.error(`Unknown loading kind: ${params.kind}`)
}
})
return <div className="typewriter-interface">
<RpcContext.Provider value={rpcSess}>
<div className="content">
@@ -523,7 +533,7 @@ export function TypewriterInterface({props}) {
}
</div>
}
</> : <CircularProgress />
</> : <CircularProgress variant="determinate" value={loadingProgress} />
}
</div>
</div>
+2 -2
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import './inventory.css'
import '../css/inventory.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faBan } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app';
@@ -159,7 +159,7 @@ function InventoryList({items, docType, openDoc, defaultTab=null, level=undefine
{[...modifiedItems].sort(
// For lemas, sort entries `available > disabled > locked`
// otherwise alphabetically
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled)
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled) || x.displayName.localeCompare(y.displayName)
).filter(item => !item.hidden && ((tab ?? categories[0]) == item.category)).map((item, i) => {
return <InventoryItem key={`${item.category}-${item.name}`}
showDoc={() => {openDoc({name: item.name, type: docType})}}
+92 -68
View File
@@ -6,12 +6,13 @@ import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import './landing_page.css'
import coverRobo from '../assets/covers/formaloversum.png'
import '../css/landing_page.css'
import bgImage from '../assets/bg.jpg'
import Markdown from './markdown';
import {PrivacyPolicyPopup} from './popup/privacy_policy'
import { GameTile, useGetGameInfoQuery } from '../state/api'
import path from 'path';
const flag = {
'Dutch': '🇳🇱',
@@ -19,6 +20,7 @@ const flag = {
'French': '🇫🇷',
'German': '🇩🇪',
'Italian': '🇮🇹',
'Spanish': '🇪🇸',
}
function GithubIcon({url='https://github.com'}) {
@@ -32,47 +34,42 @@ function GithubIcon({url='https://github.com'}) {
</div>
}
function GameTile({
title,
gameId,
intro, // Catchy intro phrase.
image=null,
worlds='?',
levels='?',
prereq='&ndash;', // Optional list of games that this game builds on. Use markdown.
description, // Longer description. Supports Markdown.
language}) {
function Tile({gameId, data}: {gameId: string, data: GameTile|undefined}) {
let navigate = useNavigate();
const routeChange = () =>{
navigate(gameId);
}
if (typeof data === 'undefined') {
return <></>
}
return <div className="game" onClick={routeChange}>
<div className="wrapper">
<div className="title">{title}</div>
<div className="short-description">{intro}
<div className="title">{data.title}</div>
<div className="short-description">{data.short}
</div>
{ image ? <img className="image" src={image} alt="" /> : <div className="image"/> }
<div className="long description"><Markdown>{description}</Markdown></div>
{ data.image ? <img className="image" src={path.join("data", gameId, data.image)} alt="" /> : <div className="image"/> }
<div className="long description"><Markdown>{data.long}</Markdown></div>
</div>
<table className="info">
<tbody>
<tr>
<td title="consider playing these games first.">Prerequisites</td>
<td><Markdown>{prereq}</Markdown></td>
<td><Markdown>{data.prerequisites.join(', ')}</Markdown></td>
</tr>
<tr>
<td>Worlds</td>
<td>{worlds}</td>
<td>{data.worlds}</td>
</tr>
<tr>
<td>Levels</td>
<td>{levels}</td>
<td>{data.levels}</td>
</tr>
<tr>
<td>Language</td>
<td title={`in ${language}`}>{flag[language]}</td>
<td title={`in ${data.languages.join(', ')}`}>{data.languages.map((lan) => flag[lan]).join(', ')}</td>
</tr>
</tbody>
</table>
@@ -88,6 +85,58 @@ function LandingPage() {
const openImpressum = () => setImpressum(true);
const closeImpressum = () => setImpressum(false);
// const [allGames, setAllGames] = React.useState([])
// const [allTiles, setAllTiles] = React.useState([])
// const getTiles=()=>{
// fetch('featured_games.json', {
// headers : {
// 'Content-Type': 'application/json',
// 'Accept': 'application/json'
// }
// }
// ).then(function(response){
// return response.json()
// }).then(function(data) {
// setAllGames(data.featured_games)
// })
// }
// React.useEffect(()=>{
// getTiles()
// },[])
// React.useEffect(()=>{
// Promise.allSettled(
// allGames.map((gameId) => (
// fetch(`data/g/${gameId}/game.json`).catch(err => {return undefined})))
// ).then(responses =>
// responses.forEach((result) => console.log(result)))
// // Promise.all(responses.map(res => {
// // if (res.status == "fulfilled") {
// // console.log(res.value.json())
// // return res.value.json()
// // } else {
// // return undefined
// // }
// // }))
// // ).then(allData => {
// // setAllTiles(allData.map(data => data?.tile))
// // })
// },[allGames])
// TODO: I would like to read the supported games list form a JSON,
// Then load all these games in
//
let allGames = [
"leanprover-community/nng4",
"hhu-adam/robo",
"djvelleman/stg4",
"miguelmarco/STG4",
]
let allTiles = allGames.map((gameId) => (useGetGameInfoQuery({game: `g/${gameId}`}).data?.tile))
return <div className="landing-page">
<header style={{backgroundImage: `url(${bgImage})`}}>
@@ -104,49 +153,26 @@ function LandingPage() {
</div>
</header>
<div className="game-list">
<GameTile
title="Natural Number Game"
gameId="g/hhu-adam/NNG4"
intro="The classical introduction game for Lean."
description="In this game you recreate the natural numbers $\mathbb{N}$ from the Peano axioms,
learning the basics about theorem proving in Lean.
This is a good first introduction to Lean!"
worlds="4"
levels="30"
language="English"
/>
<GameTile
title="Formaloversum"
gameId="g/hhu-adam/Robo"
intro="Erkunde das Leansche Universum mit deinem Robo, welcher dir bei der Verständigung mit den Formalosophen zur Seite steht."
description="
Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet danach verschiedene Bereiche des Bachelorstudiums an.
(Das Spiel befindet sich noch in der Entstehungsphase.)"
image={coverRobo}
language="German"
/>
<GameTile
title="NNG (OLD)"
gameId="g/hhu-adam/nng4-old"
intro="The old version of the NNG copied from lean3."
description="This version is not maintained and might break at any point. You should play the new version instead"
worlds="9"
levels="72"
language="English"
/>
{allTiles.length == 0 ?
<p>No Games loaded. Use <a>http://localhost:3000/#/g/local/FOLDER</a> to open a
game directly from a local folder.
</p>
: allGames.map((id, i) => (
<Tile
key={id}
gameId={`g/${id}`}
data={allTiles[i]}
/>
))
}
</div>
<section>
<div className="wrapper">
<h2>Development notes</h2>
<p>
As this server runs lean on our university machines, it has a limited capacity.
Our current estimate is about 55 copies of the NNG or 25 copies of games importing
mathlib. We hope to address this limitation in the future.
Our current estimate is about 70 simultaneous games.
We hope to address and test this limitation better in the future.
</p>
<p>
Most aspects of the games and the infrastructure are still in development. Feel free to
@@ -160,18 +186,19 @@ Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet
<h2>Adding new games</h2>
<p>
If you are considering writing your own game, you should use
the <a target="_blank" href="https://github.com/hhu-adam/NNG4">NNG Github Repo</a> as
a template.
the <a target="_blank" href="https://github.com/hhu-adam/GameSkeleton">GameSkeleton Github Repo</a> as
a template and read <a target="_blank" href="https://github.com/leanprover-community/lean4game/">How to Create a Game</a>.
</p>
<p>
There is an option to load and run your own games direclty on the server,
instructions are in the NNG repo. Since this is still in development we'd like to
encourage you to contact us for support creating your own game. The documentation is
not polished yet.
You can directly load your games into the server and play it using
the correct URL. The <a target="_blank" href="https://github.com/leanprover-community/lean4game/">instructions above</a> also
explain the details for how to load your game to the server.
We'd like to encourage you to contact us if you have any questions.
</p>
<p>
To add games to this main page, you should get in contact as
games will need to be added manually.
Featured games on this page are added manually.
Please get in contact and we-ll happily add yours.
</p>
</div>
</section>
@@ -189,9 +216,6 @@ Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet
<a className="link" onClick={openImpressum}>Impressum</a>
{impressum? <PrivacyPolicyPopup handleClose={closeImpressum} />: null}
</footer>
{/* <PrivacyPolicy/> */}
</div>
}
+40 -45
View File
@@ -4,7 +4,7 @@ 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, faCode, faXmark, faHome, faCircleInfo, faArrowRight, faArrowLeft, faTerminal } from '@fortawesome/free-solid-svg-icons'
import { faHome, faArrowRight } 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'
@@ -22,7 +22,7 @@ import { ConnectionContext, connection, useLeanClient } from '../connection'
import { useAppDispatch, useAppSelector } from '../hooks'
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery, useLoadLevelQuery } from '../state/api'
import { changedSelection, codeEdited, selectCode, selectSelections, selectCompleted, helpEdited,
selectHelp, selectDifficulty, selectInventory } from '../state/progress'
selectHelp, selectDifficulty, selectInventory, selectTypewriterMode, changeTypewriterMode } from '../state/progress'
import { store } from '../state/store'
import { Button } from './button'
import Markdown from './markdown'
@@ -32,8 +32,9 @@ import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContex
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
import { DualEditor } from './infoview/main'
import { GameHint } from './infoview/rpc_api'
import { DeletedHints, Hint, Hints } from './hints'
import { DeletedHints, Hint, Hints, filterHints } from './hints'
import { PrivacyPolicyPopup } from './popup/privacy_policy'
import path from 'path';
import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css'
@@ -41,14 +42,13 @@ 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'
import '../css/level.css'
import { LevelAppBar } from './app_bar'
function Level() {
const params = useParams()
const levelId = parseInt(params.levelId)
const worldId = params.worldId
// useLoadWorldFiles(worldId)
const [impressum, setImpressum] = React.useState(false)
@@ -138,19 +138,24 @@ function ChatPanel({lastLevel}) {
let introText: Array<string> = level?.data?.introduction.split(/\n(\s*\n)+/)
// experimental: Remove all hints that appeared identically in the previous step
// This effectively prevent consequtive hints being shown.
let modifiedHints : GameHint[][] = filterHints(proof)
return <div className="chat-panel">
<div ref={chatRef} className="chat">
{introText?.filter(t => t.trim()).map(((t, i) =>
// Show the level's intro text as hints, too
<Hint key={`intro-p-${i}`}
hint={{text: t, hidden: false}} step={0} selected={selectedStep} toggleSelection={toggleSelection(0)} />
))}
{proof.map((step, i) => {
{modifiedHints.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}
hints={step} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelection(i)} lastLevel={i == proof.length - 1}/>
}
})}
@@ -204,11 +209,16 @@ function PlayableLevel({impressum, setImpressum}) {
const {worldId, levelId} = useContext(WorldLevelIdContext)
const {mobile} = React.useContext(MobileContext)
const dispatch = useAppDispatch()
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 typewriterMode = useSelector(selectTypewriterMode(gameId))
const setTypewriterMode = (newTypewriterMode: boolean) => dispatch(changeTypewriterMode({game: gameId, typewriterMode: newTypewriterMode}))
const gameInfo = useGetGameInfoQuery({game: gameId})
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
@@ -221,10 +231,11 @@ function PlayableLevel({impressum, setImpressum}) {
const [showHelp, setShowHelp] = useState<Set<number>>(new Set())
// Only for mobile layout
const [pageNumber, setPageNumber] = useState(0)
const [typewriterMode, setTypewriterMode] = useState(true)
// set to true to prevent switching between typewriter and editor
const [lockInputMode, setLockInputMode] = useState(false)
const [typewriterInput, setTypewriterInput] = useState("")
const lastLevel = levelId >= gameInfo.data?.worldSize[worldId]
const dispatch = useAppDispatch()
// impressum pop-up
function toggleImpressum() {setImpressum(!impressum)}
@@ -318,8 +329,6 @@ function PlayableLevel({impressum, setImpressum}) {
console.debug(`not inserting template.`)
}
}
} else {
setTypewriterMode(true)
}
}, [level, levelId, worldId, gameId, editor])
@@ -334,7 +343,7 @@ function PlayableLevel({impressum, setImpressum}) {
}, [gameId, worldId, levelId])
useEffect(() => {
if (!typewriterMode) {
if (!typewriterMode && editor) {
// Delete last input attempt from command line
editor.executeEdits("typewriter", [{
range: editor.getSelection(),
@@ -392,18 +401,16 @@ function PlayableLevel({impressum, setImpressum}) {
<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={{typewriterMode, setTypewriterMode, typewriterInput, setTypewriterInput}}>
<InputModeContext.Provider value={{typewriterMode, setTypewriterMode, typewriterInput, setTypewriterInput, lockInputMode, setLockInputMode}}>
<ProofContext.Provider value={{proof, setProof}}>
<EditorContext.Provider value={editorConnection}>
<MonacoEditorContext.Provider value={editor}>
<LevelAppBar
pageNumber={pageNumber} setPageNumber={setPageNumber}
isLoading={level.isLoading}
lockEditorMode={level.data?.template !== null}
levelTitle={`${mobile ? '' : 'Level '}${levelId} / ${gameInfo.data?.worldSize[worldId]}` +
(level?.data?.title && ` : ${level?.data?.title}`)}
impressum={impressum}
toggleImpressum={toggleImpressum}
pageNumber={pageNumber} setPageNumber={setPageNumber} />
toggleImpressum={toggleImpressum} />
{mobile?
// TODO: This is copied from the `Split` component below...
<>
@@ -466,12 +473,17 @@ function Introduction({impressum, setImpressum}) {
const gameInfo = useGetGameInfoQuery({game: gameId})
const {worldId} = useContext(WorldLevelIdContext)
let image: string = gameInfo.data?.worlds.nodes[worldId].image
const toggleImpressum = () => {
setImpressum(!impressum)
}
return <>
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Introduction" impressum={impressum} toggleImpressum={toggleImpressum}/>
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Introduction" toggleImpressum={toggleImpressum}/>
{gameInfo.isLoading ?
<div className="app-content loading"><CircularProgress /></div>
: mobile ?
@@ -479,8 +491,14 @@ function Introduction({impressum, setImpressum}) {
:
<Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level`}>
<IntroductionPanel gameInfo={gameInfo} />
<div className="world-image-container empty"></div>
<InventoryOverviewPanel data={inventory?.data} showOverview={false}/>
<div className="world-image-container empty">
{image &&
// TODO: Temporary for testing
<img className={worldId=="Proposition" ? "cover" : "contain"} src={path.join("data", gameId, image)} alt="" />
}
</div>
<InventoryOverviewPanel data={inventory?.data} />
</Split>
}
@@ -615,7 +633,8 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
return () => {
editorConnection.api.sendClientNotification(uriStr, "textDocument/didClose", {textDocument: {uri: uriStr}})
model.dispose(); }
model.dispose();
}
}
}, [editor, levelId, connection, leanClientStarted])
@@ -637,27 +656,3 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
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])
}
+11 -12
View File
@@ -9,6 +9,15 @@ import { deleteProgress, selectProgress } from '../../state/progress'
import { downloadFile } from '../world_tree'
import { Button } from '../button'
/** download the current progress (i.e. what's saved in the browser store) */
export function downloadProgress(gameId: string, gameProgress: any, ev: React.MouseEvent) {
ev.preventDefault()
downloadFile({
data: JSON.stringify(gameProgress, null, 2),
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
fileType: 'text/json',
})
}
/** Pop-up to delete game progress.
*
@@ -20,23 +29,13 @@ export function ErasePopup ({handleClose}) {
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 eraseProgress = () => {
dispatch(deleteProgress({game: gameId}))
handleClose()
}
const downloadAndErase = (e) => {
downloadProgress(e)
const downloadAndErase = (ev) => {
downloadProgress(gameId, gameProgress, ev)
eraseProgress()
}
+36 -40
View File
@@ -1,13 +1,13 @@
import * as React from 'react'
import { useState, useEffect } from 'react'
import { useEffect } from 'react'
import Split from 'react-split'
import { Box, CircularProgress } from '@mui/material'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faGlobe, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app'
import { useAppDispatch } from '../hooks'
import { changedOpenedIntro } from '../state/progress'
import { useAppDispatch, useAppSelector } from '../hooks'
import { changedOpenedIntro, selectOpenedIntro } from '../state/progress'
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery } from '../state/api'
import { Button } from './button'
import { MobileContext } from './infoview/context'
@@ -19,14 +19,14 @@ import { RulesHelpPopup } from './popup/rules_help'
import { UploadPopup } from './popup/upload'
import { WorldTreePanel } from './world_tree'
import './welcome.css'
import '../css/welcome.css'
import { WelcomeAppBar } from './app_bar'
import { Hint } from './hints'
/** The panel showing the game's introduction text */
function IntroductionPanel({introduction}: {introduction: string}) {
const {mobile, setPageNumber} = React.useContext(MobileContext)
/** the panel showing the game's introduction text */
function IntroductionPanel({introduction, setPageNumber}: {introduction: string, setPageNumber}) {
const {mobile} = React.useContext(MobileContext)
const gameId = React.useContext(GameIdContext)
const dispatch = useAppDispatch()
@@ -46,11 +46,6 @@ function IntroductionPanel({introduction}: {introduction: string}) {
: <></>
))}
</div>
{/* <Typography variant="body1" component="div" className="welcome-text">
<h1>{title}</h1>
<Markdown>{introduction}</Markdown>
</Typography>
*/}
{mobile &&
<div className="button-row">
<Button className="btn" to=""
@@ -65,34 +60,32 @@ function IntroductionPanel({introduction}: {introduction: string}) {
</div>
}
/** main page of the game showing amoung others the tree of worlds/levels */
/** main page of the game showing among others the tree of worlds/levels */
function Welcome() {
const gameId = React.useContext(GameIdContext)
const {mobile, pageNumber, setPageNumber} = React.useContext(MobileContext)
const {mobile} = React.useContext(MobileContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const inventory = useLoadInventoryOverviewQuery({game: gameId})
// impressum pop-up
// For mobile only
const openedIntro = useAppSelector(selectOpenedIntro(gameId))
const [pageNumber, setPageNumber] = React.useState(openedIntro ? 1 : 0)
// pop-ups
const [eraseMenu, setEraseMenu] = React.useState(false)
const [impressum, setImpressum] = React.useState(false)
const [rulesHelp, setRulesHelp] = React.useState(false)
function closeImpressum() {setImpressum(false)}
function toggleImpressum() {setImpressum(!impressum)}
function closeRulesHelp() {setRulesHelp(false)}
const [info, setInfo] = React.useState(false)
function closeInfo() {setInfo(false)}
function toggleInfo() {setInfo(!impressum)}
/* 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 [rulesHelp, setRulesHelp] = React.useState(false)
const [uploadMenu, setUploadMenu] = React.useState(false)
function closeEraseMenu() {setEraseMenu(false)}
function closeImpressum() {setImpressum(false)}
function closeInfo() {setInfo(false)}
function closeRulesHelp() {setRulesHelp(false)}
function closeUploadMenu() {setUploadMenu(false)}
function toggleEraseMenu() {setEraseMenu(!eraseMenu)}
function toggleImpressum() {setImpressum(!impressum)}
function toggleInfo() {setInfo(!info)}
function toggleUploadMenu() {setUploadMenu(!uploadMenu)}
// set the window title
useEffect(() => {
@@ -106,23 +99,26 @@ function Welcome() {
<CircularProgress />
</Box>
: <>
<WelcomeAppBar gameInfo={gameInfo.data} toggleImpressum={toggleImpressum} openEraseMenu={openEraseMenu}
openUploadMenu={openUploadMenu} toggleInfo={toggleInfo} />
<WelcomeAppBar pageNumber={pageNumber} setPageNumber={setPageNumber} gameInfo={gameInfo.data} toggleImpressum={toggleImpressum}
toggleEraseMenu={toggleEraseMenu} toggleUploadMenu={toggleUploadMenu}
toggleInfo={toggleInfo} />
<div className="app-content">
{ mobile ?
<div className="welcome mobile">
{(pageNumber == 0 ?
<IntroductionPanel introduction={gameInfo.data?.introduction} />
<IntroductionPanel introduction={gameInfo.data?.introduction} setPageNumber={setPageNumber} />
: pageNumber == 1 ?
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize}
rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
:
<InventoryOverviewPanel data={inventory?.data} />
)}
</div>
:
<Split className="welcome" minSize={0} snapOffset={200} sizes={[25, 50, 25]}>
<IntroductionPanel introduction={gameInfo.data?.introduction} />
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
<IntroductionPanel introduction={gameInfo.data?.introduction} setPageNumber={setPageNumber} />
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize}
rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
<InventoryOverviewPanel data={inventory?.data} />
</Split>
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { useAppDispatch } from '../hooks'
import { selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'
import { store } from '../state/store'
import './world_tree.css'
import '../css/world_tree.css'
// Settings for the world tree
cytoscape.use( klay )
@@ -42,12 +42,6 @@ body {
border-radius: .3em;
}
/* Hide monaco editor notifications */
.monaco-workbench > .notifications-toasts.visible {
display: none !important;
}
.loading {
margin: auto;
height: 100%;
@@ -108,6 +102,7 @@ em {
flex: 0;
background: var(--clr-primary);
display: flex;
position: relative;
flex-direction: row;
justify-content: space-between;
padding: 1.1em;
@@ -48,6 +48,7 @@ a {
border: 1px solid rgb(140, 140, 140);
border-radius: 20px;
box-shadow: 5px 5px 8px rgb(140, 140, 140);
width: 100%;
max-width: 500px;
display: flex;
flex-direction: column;
@@ -336,6 +336,21 @@ td code {
background-color: #eee;
}
.world-image-container {
display: flex;
flex-direction: column;
justify-content: center;
}
.world-image-container img.contain {
object-fit: contain;
}
.world-image-container img.cover {
height: 100%;
object-fit: cover;
}
.typewriter-interface .proof {
background-color: #fff;
}
+32 -20
View File
@@ -1,32 +1,44 @@
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './app';
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import App from './app'
import { ConnectionContext, connection } from './connection'
import { store } from './state/store';
import { Provider } from 'react-redux';
import {
createHashRouter,
RouterProvider,
Route,
} from "react-router-dom";
import ErrorPage from './components/error_page';
import Welcome from './components/welcome';
import LandingPage from './components/landing_page';
import Level from './components/level';
import { monacoSetup } from 'lean4web/client/src/monacoSetup';
import { redirect } from 'react-router-dom';
import { store } from './state/store'
import { Provider } from 'react-redux'
import type { RouteObject } from "react-router"
import { createHashRouter, RouterProvider, Route, redirect } from "react-router-dom"
import ErrorPage from './components/error_page'
import Welcome from './components/welcome'
import LandingPage from './components/landing_page'
import Level from './components/level'
import { monacoSetup } from 'lean4web/client/src/monacoSetup'
monacoSetup()
// If `VITE_LEAN4GAME_SINGLE` is set to true, then `/` should be redirected to
// `/g/local/game`. This is used for the devcontainer setup
let single_game = (import.meta.env.VITE_LEAN4GAME_SINGLE == "true")
let root_object: RouteObject = single_game ? {
path: "/",
loader: () => redirect("/g/local/game")
} : {
path: "/",
element: <LandingPage />,
}
const router = createHashRouter([
root_object,
{
path: "/",
element: <LandingPage />,
},
{
// For backwards compatibility
path: "/game/nng",
loader: () => redirect("/g/hhu-adam/NNG4")
},
{
// For backwards compatibility
path: "/g/hhu-adam/NNG4",
loader: () => redirect("/g/leanprover-community/NNG4")
},
{
path: "/g/:owner/:repo",
element: <App />,
-55
View File
@@ -1,55 +0,0 @@
// This file is a copy of `index.tsx` where the path "/" is redirected to "/g/local/game".
// It is used for the dev. setup where there is only one game in a folder called `game`.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './app';
import { ConnectionContext, connection } from './connection'
import { store } from './state/store';
import { Provider } from 'react-redux';
import {
createHashRouter,
RouterProvider,
Route,
} from "react-router-dom";
import ErrorPage from './components/error_page';
import Welcome from './components/welcome';
import LandingPage from './components/landing_page';
import Level from './components/level';
import { monacoSetup } from 'lean4web/client/src/monacoSetup';
import { redirect } from 'react-router-dom';
monacoSetup()
const router = createHashRouter([
{
path: "/",
loader: () => redirect("/g/local/game")
},
{
path: "/g/:owner/:repo",
element: <App />,
errorElement: <ErrorPage />,
children: [
{
path: "/g/:owner/:repo",
element: <Welcome />,
},
{
path: "/g/:owner/:repo/world/:worldId/level/:levelId",
element: <Level />,
},
],
},
]);
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(
<React.StrictMode>
<Provider store={store}>
<ConnectionContext.Provider value={connection}>
<RouterProvider router={router} />
</ConnectionContext.Provider>
</Provider>
</React.StrictMode>
);
+22 -25
View File
@@ -2,16 +2,29 @@
* @fileOverview Define API of the server-client communication
*/
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Connection } from '../connection'
export interface GameTile {
title: string
short: string
long: string
languages: Array<string>
prerequisites: Array<string>
worlds: number
levels: number
image: string
}
export interface GameInfo {
title: null|string,
introduction: null|string,
info: null|string,
worlds: null|{nodes: {[id:string]: {id: string, title: string, introduction: string}}, edges: string[][]},
worlds: null|{nodes: {[id:string]: {id: string, title: string, introduction: string, image: string}}, edges: string[][]},
worldSize: null|{[key: string]: number},
authors: null|string[],
conclusion: null|string,
tile: null|GameTile,
image: null|string
}
export interface InventoryTile {
@@ -37,7 +50,8 @@ export interface LevelInfo {
lemmaTab: null|string,
statementName: null|string,
displayName: null|string,
template: null|string
template: null|string,
image: null|string
}
/** Used to display the inventory on the welcome page */
@@ -64,39 +78,22 @@ export interface WorldOverview {
definitions: InventoryTile[]
}
const customBaseQuery = async (
args : {game: string, method: string, params?: any},
{ signal, dispatch, getState, extra },
extraOptions
) => {
try {
const connection : Connection = extra.connection
let leanClient = await connection.startLeanClient(args.game)
console.log(`Sending request ${args.method}`)
let res = await leanClient.sendRequest(args.method, args.params)
console.log('Received response') //, res)
return {'data': res}
} catch (e) {
return {'error': e}
}
}
// Define a service using a base URL and expected endpoints
export const apiSlice = createApi({
reducerPath: 'gameApi',
baseQuery: customBaseQuery,
baseQuery: fetchBaseQuery({ baseUrl: window.location.origin + "/data" }),
endpoints: (builder) => ({
getGameInfo: builder.query<GameInfo, {game: string}>({
query: ({game}) => {return {game, method: 'info', params: {}}},
query: ({game}) => `${game}/game.json`,
}),
loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({
query: ({game, world, level}) => {return {game, method: "loadLevel", params: {world, level}}},
query: ({game, world, level}) => `${game}/level__${world}__${level}.json`,
}),
loadInventoryOverview: builder.query<WorldOverview[], {game: string}>({
query: ({game}) => {return {game, method: "loadInventoryOverview", params: {}}},
query: ({game}) => `${game}/inventory.json`,
}),
loadDoc: builder.query<Doc, {game: string, name: string, type: "lemma"|"tactic"}>({
query: ({game, name, type}) => {return {game, method: "loadDoc", params: {name, type}}},
query: ({game, type, name}) => `${game}/doc__${type}__${name}.json`,
}),
}),
})
+15 -2
View File
@@ -26,7 +26,8 @@ export interface GameProgressState {
inventory: string[],
difficulty: number,
openedIntro: boolean,
data: WorldProgressState
data: WorldProgressState,
typewriterMode?: boolean
}
/**
@@ -126,6 +127,11 @@ export const progressSlice = createSlice({
addGameProgress(state, action)
state.games[action.payload.game].openedIntro = action.payload.openedIntro
},
/** set the typewriter mode */
changeTypewriterMode(state: ProgressState, action: PayloadAction<{game: string, typewriterMode: boolean}>) {
addGameProgress(state, action)
state.games[action.payload.game].typewriterMode = action.payload.typewriterMode
}
}
})
@@ -196,7 +202,14 @@ export function selectOpenedIntro(game: string) {
}
}
/** return typewriter mode for the current game if it exists */
export function selectTypewriterMode(game: string) {
return (state) => {
return state.progress.games[game]?.typewriterMode ?? true
}
}
/** Export actions to modify the progress */
export const { changedSelection, codeEdited, levelCompleted, deleteProgress,
deleteLevelProgress, loadProgress, helpEdited, changedInventory, changedOpenedIntro,
changedDifficulty } = progressSlice.actions
changedDifficulty, changeTypewriterMode} = progressSlice.actions