pull/251/merge
Jon Eugster 9 months ago
parent 71fad5699e
commit 8e3dfdea30

@ -49,10 +49,18 @@ function App() {
// mobile only: game intro should only be shown once and skipped afterwards // mobile only: game intro should only be shown once and skipped afterwards
useEffect(() => { useEffect(() => {
if (openedIntro && !worldId && page == 0) { if (worldId) {
console.log('setting page to 1')
setPage(1) setPage(1)
} else {
if (openedIntro && page == 0) {
console.log('setting page to 1')
setPage(1)
} else {
// setPage(0)
} }
}, [openedIntro]) }
}, [openedIntro, worldId, levelId])
// option to pass language as `?lang=de` in the URL // option to pass language as `?lang=de` in the URL
useEffect(() => { useEffect(() => {
@ -96,11 +104,9 @@ function App() {
<PopupContext.Provider value={{popupContent, setPopupContent}}> <PopupContext.Provider value={{popupContent, setPopupContent}}>
<PageContext.Provider value={{typewriterMode, setTypewriterMode, typewriterInput, setTypewriterInput, lockEditorMode, setLockEditorMode, page, setPage}}> <PageContext.Provider value={{typewriterMode, setTypewriterMode, typewriterInput, setTypewriterInput, lockEditorMode, setLockEditorMode, page, setPage}}>
<Navigation /> <Navigation />
<React.Suspense>
<Outlet /> <Outlet />
</React.Suspense>
</PageContext.Provider>
{ popupContent && <Popup /> } { popupContent && <Popup /> }
</PageContext.Provider>
</PopupContext.Provider> </PopupContext.Provider>
</PreferencesContext.Provider> </PreferencesContext.Provider>
</GameIdContext.Provider> </GameIdContext.Provider>

@ -0,0 +1,104 @@
import * as React from 'react'
import { PageContext, PreferencesContext } from './infoview/context'
import { GameIdContext } from '../app'
import { useTranslation } from 'react-i18next'
import { useAppDispatch } from '../hooks'
import { Hint } from './hints'
import { Button } from './button'
import { changedOpenedIntro } from '../state/progress'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api'
import { useContext, useEffect, useRef, useState } from 'react'
import '../css/level.css'
import '../css/chat.css'
/** Split a string by double newlines and filters out empty segments. */
function splitIntro (intro : string) {
return intro.split(/\n(\s*\n)+/).filter(t => t.trim())
}
/** The buttons at the bottom of chat */
export function ChatButtons () {
const { gameId, worldId, levelId } = useContext(GameIdContext)
const {setPage} = useContext(PageContext)
const dispatch = useAppDispatch()
const gameInfo = useGetGameInfoQuery({game: gameId})
return <div className="button-row">
{(!worldId || !levelId) &&
// Start button appears only on world selection and level 0.
<Button className="btn"
title=""
to={worldId ? `/${gameId}/world/${worldId}/level/1` : ''}
onClick={() => {
if (!worldId) {
console.log('setting `openedIntro` to true')
setPage(1)
dispatch(changedOpenedIntro({game: gameId, openedIntro: true}))
}
}} >
Start&nbsp;<FontAwesomeIcon icon={faArrowRight}/>
</Button>
}
</div>
}
/** the panel showing the game's introduction text */
export function ChatPanel ({visible = true}) {
let { t } = useTranslation()
const chatRef = useRef<HTMLDivElement>(null)
const { mobile } = useContext(PreferencesContext)
const { gameId, worldId, levelId } = useContext(GameIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
let [chatMessages, setChatMessages] = useState<Array<string>>([])
// Effect to clear chat and display the correct intro text
useEffect(() => {
if (levelId > 0) {
// playable level: show the level's intro
if (levelInfo.data?.introduction) {
setChatMessages([t(levelInfo.data?.introduction, {ns : gameId})])
}
else {
setChatMessages([])
}
} else {
if (worldId) {
// Level 0: show the world's intro
if (gameInfo.data?.worlds.nodes[worldId].introduction) {
setChatMessages(splitIntro(t(gameInfo.data?.worlds.nodes[worldId].introduction, {ns: gameId})))
} else {
setChatMessages([])
}
} else {
// world overview: show the game's intro
if (gameInfo.data?.introduction) {
setChatMessages(splitIntro(t(gameInfo.data?.introduction, {ns : gameId})))
} else {
setChatMessages([])
}
}
}
}, [gameInfo, levelInfo, gameId, worldId, levelId])
return <div className={`column chat-panel${visible ? '' : ' hidden'}`}>
<div ref={chatRef} className="chat">
{chatMessages.map(((t, i) =>
t.trim() ?
<Hint key={`intro-p-${i}`}
hint={{text: t, hidden: false, rawText: t, varNames: []}}
step={0} selected={null} toggleSelection={undefined} />
: <></>
))}
</div>
{ mobile && <ChatButtons /> }
</div>
}

@ -1,16 +1,101 @@
import i18next from "i18next" import * as React from 'react'
import React from "react" import { useEffect, useRef } from 'react'
import { useParams } from "react-router-dom" import Split from 'react-split'
import { GameIdContext } from "../app" import { Box, CircularProgress } from '@mui/material'
import { useGetGameInfoQuery } from "../state/api" import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app'
import { useAppDispatch, useAppSelector } from '../hooks'
import { changedOpenedIntro, selectOpenedIntro } from '../state/progress'
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery, useLoadLevelQuery } from '../state/api'
import { Button } from './button'
import { PageContext, PreferencesContext } from './infoview/context'
import { InventoryPanel } from './inventory'
import { ErasePopup } from './popup/erase'
import { InfoPopup } from './popup/info'
import { PrivacyPolicyPopup } from './popup/privacy'
import { UploadPopup } from './popup/upload'
import { PreferencesPopup} from "./popup/preferences"
import { WorldTreePanel } from './world_tree'
import '../css/game.css'
import '../css/welcome.css'
import '../css/level.css'
import { Hint } from './hints'
import i18next from 'i18next'
import { useTranslation } from 'react-i18next'
import { LoadingIcon } from './utils'
import { ChatPanel } from './chat'
import { DualEditor } from './infoview/main'
import { Level } from './level'
/** main page of the game showing among others the tree of worlds/levels */
function Game() { function Game() {
const params = useParams()
const levelId = parseInt(params.levelId)
const worldId = params.worldId
return <div> const codeviewRef = useRef<HTMLDivElement>(null)
<GameIdContext.Provider value={gameId}></GameIdContext.Provider>
const { gameId, worldId, levelId } = React.useContext(GameIdContext)
// Load the namespace of the game
i18next.loadNamespaces(gameId)
const {mobile} = React.useContext(PreferencesContext)
const {isSavePreferences, language, setIsSavePreferences, setLanguage} = React.useContext(PreferencesContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const inventory = useLoadInventoryOverviewQuery({game: gameId})
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const {page, setPage} = React.useContext(PageContext)
// TODO: recover `openedIntro` functionality
// const [pageNumber, setPageNumber] = React.useState(openedIntro ? 1 : 0)
// pop-ups
const [eraseMenu, setEraseMenu] = React.useState(false)
const [impressum, setImpressum] = React.useState(false)
const [privacy, setPrivacy] = React.useState(false)
const [info, setInfo] = React.useState(false)
const [rulesHelp, setRulesHelp] = React.useState(false)
const [uploadMenu, setUploadMenu] = React.useState(false)
const [preferencesPopup, setPreferencesPopup] = React.useState(false)
// set the window title
useEffect(() => {
if (gameInfo.data?.title) {
window.document.title = gameInfo.data.title
}
}, [gameInfo.data?.title])
return mobile ?
<div className="app-content mobile">
{<>
<ChatPanel visible={worldId ? (levelId == 0 && page == 1) :(page == 0)} />
{ worldId ?
<Level visible={levelId > 0 && page == 1} /> :
<WorldTreePanel visible={page == 1} />
}
<InventoryPanel visible={page == 2} />
</>
}
</div> </div>
:
<Split className="app-content" minSize={0} snapOffset={200} sizes={[25, 50, 25]}>
<ChatPanel />
<div>
{/* Note: apparently without this `div` the split panel bugs out. */}
{worldId ? <Level /> : <WorldTreePanel /> }
</div>
<InventoryPanel />
</Split>
}
export default Game
function useLevelEditor(codeviewRef: React.MutableRefObject<HTMLDivElement>, initialCode: any, initialSelections: any, onDidChangeContent: any, onDidChangeSelection: any): { editor: any; infoProvider: any; editorConnection: any } {
throw new Error('Function not implemented.')
} }

@ -536,7 +536,7 @@ export function TypewriterInterface({props}) {
[DiagnosticSeverity.Hint]: 'hint', [DiagnosticSeverity.Hint]: 'hint',
}[diag.severity] : ''; }[diag.severity] : '';
return <div> return <div key={diag.message} >
<div className={`${severityClass} ml1 message`}> <div className={`${severityClass} ml1 message`}>
<p className="mv2">{t("Line")}&nbsp;{diag.range.start.line}, {t("Character")}&nbsp;{diag.range.start.character}</p> <p className="mv2">{t("Line")}&nbsp;{diag.range.start.line}, {t("Character")}&nbsp;{diag.range.start.character}</p>
<pre className="font-code pre-wrap"> <pre className="font-code pre-wrap">

@ -1,92 +1,122 @@
import * as React from 'react'; import * as React from 'react'
import { useState, useEffect } from 'react'; import { useState, useEffect, createContext, useContext } from 'react';
import '../css/inventory.css' import '../css/inventory.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faBan, faCheck } from '@fortawesome/free-solid-svg-icons' import { faLock, faBan, faCheck, faXmark } from '@fortawesome/free-solid-svg-icons'
import { faClipboard } from '@fortawesome/free-regular-svg-icons' import { faClipboard } from '@fortawesome/free-regular-svg-icons'
import { GameIdContext } from '../app'; import { GameIdContext } from '../app';
import Markdown from './markdown'; import Markdown from './markdown';
import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api'; import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery, useLoadLevelQuery } from '../state/api';
import { selectDifficulty, selectInventory } from '../state/progress'; import { selectDifficulty, selectInventory } from '../state/progress';
import { store } from '../state/store'; import { store } from '../state/store';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { t } from 'i18next'; import { t } from 'i18next';
import { WorldLevelIdContext } from './infoview/context'; import { WorldLevelIdContext } from './infoview/context';
import { NavButton } from './navigation';
import { LoadingIcon } from './utils';
/** Context which manages the inventory */
const InventoryContext = createContext<{
theoremTab: string,
setTheoremTab: React.Dispatch<React.SetStateAction<string>>,
categoryTab: "tactic"|"theorem"|"definition",
setCategoryTab: React.Dispatch<React.SetStateAction<"tactic"|"theorem"|"definition">>,
docTile: any,
setDocTile: React.Dispatch<React.SetStateAction<any>>
}>({
theoremTab: null,
setTheoremTab: () => {},
categoryTab: "tactic",
setCategoryTab: () => {},
docTile: null,
setDocTile: () => {}
})
/**
*/
function InventoryItem({item, name, displayName, locked, disabled, newly, showDoc, recent=false }) {
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
disabled ? <FontAwesomeIcon icon={faBan} /> : item.st
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
// Note: This is somewhat a hack as the statement of lemmas comes currently in the form
// `Namespace.statement_name (x y : Nat) : some type`
const title = locked ? t("Not unlocked yet") :
disabled ? t("Not available in this level") : (item.altTitle ? item.altTitle.substring(item.altTitle.indexOf(' ') + 1) : '')
export function Inventory({levelInfo, openDoc, lemmaTab, setLemmaTab, enableAll=false} : const { gameId } = React.useContext(GameIdContext)
{ const difficulty = useSelector(selectDifficulty(gameId))
levelInfo: LevelInfo|InventoryOverview,
openDoc: (props: {name: string, type: string}) => void,
lemmaTab: any,
setLemmaTab: any,
enableAll?: boolean,
}) {
const { t } = useTranslation()
// TODO: this state should be preserved when loading a different level.
const [tab, setTab] = useState<"tactic"|"theorem"|"definition">("theorem")
let newTheorems = levelInfo?.lemmas?.filter(item => item.new).length > 0 // local state to show checkmark after pressing the copy button
let newTactics = levelInfo?.tactics?.filter(item => item.new).length > 0 const [copied, setCopied] = useState(false)
let newDefinitions = levelInfo?.definitions?.filter(item => item.new).length > 0
return ( const handleClick = () => {
<div className="inventory"> // if ((difficulty == 0) || !locked) {
<div className="tab-bar major"> showDoc()
<div className={`tab${(tab == "theorem") ? " active": ""}${newTheorems ? " new" : ""}`} onClick={() => { setTab("theorem") }}>{t("Theorems")}</div> // }
<div className={`tab${(tab == "tactic") ? " active": ""}${newTactics ? " new" : ""}`} onClick={() => { setTab("tactic") }}>{t("Tactics")}</div>
<div className={`tab${(tab == "definition") ? " active": ""}${newDefinitions ? " new" : ""}`} onClick={() => { setTab("definition") }}>{t("Definitions")}</div>
</div>
{ (tab == "theorem") && levelInfo?.lemmas &&
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} level={levelInfo} enableAll={enableAll} tab={lemmaTab} setTab={setLemmaTab}/>
}
{ (tab == "tactic") && levelInfo?.tactics &&
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} enableAll={enableAll}/>
} }
{ (tab == "definition") && levelInfo?.definitions &&
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} enableAll={enableAll}/> const copyItemName = (ev) => {
navigator.clipboard.writeText(displayName)
setCopied(true)
setInterval(() => {
setCopied(false)
}, 3000);
ev.stopPropagation()
} }
return <div className={`item ${className}${(difficulty == 0) ? ' enabled' : ''}${recent ? ' recent' : ''}`} onClick={handleClick} title={title}>
{icon} {displayName}
<div className="copy-button" onClick={copyItemName}>
{copied ? <FontAwesomeIcon icon={faCheck} /> : <FontAwesomeIcon icon={faClipboard} />}
</div>
</div> </div>
)
} }
function InventoryList({items, docType, openDoc, tab=null, setTab=undefined, level=undefined, enableAll=false} :
function InventoryList({ items, tab=null, setTab=()=>{} } :
{ {
items: InventoryTile[], items: InventoryTile[],
docType: string, tab?: string,
openDoc(props: {name: string, type: string}): void, setTab?: React.Dispatch<React.SetStateAction<string>>
tab?: any,
setTab?: any,
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 { gameId, worldId, levelId } = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext) const { setDocTile, categoryTab, setCategoryTab } = useContext(InventoryContext)
const difficulty = useSelector(selectDifficulty(gameId)) const difficulty = useSelector(selectDifficulty(gameId))
const inventory: string[] = selectInventory(gameId)(store.getState())
const [categories, setCategories] = useState<Array<string>>([])
const [modifiedItems, setModifiedItems] = useState<Array<InventoryTile>>([])
const [recentItems, setRecentItems] = useState<Array<InventoryTile>>([])
useEffect(() => {
const categorySet = new Set<string>() const categorySet = new Set<string>()
if (!items) {return}
for (let item of items) { for (let item of items) {
categorySet.add(item.category) categorySet.add(item.category)
} }
const categories = Array.from(categorySet).sort() setCategories(Array.from(categorySet).sort())
// Add inventory items from local store as unlocked. // 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 // 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 // given the dependency graph. (OR-connection) (TODO: maybe add different logic for different
// modi) // modi)
let inv: string[] = selectInventory(gameId)(store.getState()) let _modifiedItems : InventoryTile[] = items?.map(tile => inventory.includes(tile.name) ? {...tile, locked: false} : tile)
let modifiedItems : InventoryTile[] = items.map(tile => inv.includes(tile.name) ? {...tile, locked: false} : tile) setModifiedItems(_modifiedItems)
// Item(s) proved in the preceeding level // Item(s) proved in the preceeding level
let recentItems = modifiedItems.filter(x => x.world == worldId && x.level == levelId - 1) setRecentItems(_modifiedItems.filter(x => x.world == worldId && x.level == levelId - 1))
}, [items, inventory])
return <> return <>
{categories.length > 1 && { categories.length > 1 &&
<div className="tab-bar"> <div className="tab-bar">
{categories.map((cat) => { {categories.map((cat) => {
let hasNew = modifiedItems.filter(item => item.new && (cat == item.category)).length > 0 let hasNew = modifiedItems.filter(item => item.new && (cat == item.category)).length > 0
@ -97,91 +127,109 @@ function InventoryList({items, docType, openDoc, tab=null, setTab=undefined, lev
{[...modifiedItems].sort( {[...modifiedItems].sort(
// For lemas, sort entries `available > disabled > locked` // For lemas, sort entries `available > disabled > locked`
// otherwise alphabetically // otherwise alphabetically
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled) || x.displayName.localeCompare(y.displayName) (x, y) => +(categoryTab == "theorem") * (+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) => { ).filter(item => !item.hidden && ((tab ?? categories[0]) == item.category)).map((item, i) => {
return <InventoryItem key={`${item.category}-${item.name}`} return <InventoryItem key={`${item.category}-${item.name}`}
item={item} item={item}
showDoc={() => {openDoc({name: item.name, type: docType})}} showDoc={() => {setDocTile(item)}}
name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false} name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false}
disabled={item.disabled} disabled={item.disabled}
recent={recentItems.map(x => x.name).includes(item.name)} recent={recentItems.map(x => x.name).includes(item.name)}
newly={item.new} enableAll={enableAll} /> newly={item.new} />
}) })
} }
</div> </div>
</> </>
} }
function InventoryItem({item, name, displayName, locked, disabled, newly, showDoc, recent=false, enableAll=false}) {
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
disabled ? <FontAwesomeIcon icon={faBan} /> : item.st
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
// Note: This is somewhat a hack as the statement of lemmas comes currently in the form
// `Namespace.statement_name (x y : Nat) : some type`
const title = locked ? t("Not unlocked yet") :
disabled ? t("Not available in this level") : (item.altTitle ? item.altTitle.substring(item.altTitle.indexOf(' ') + 1) : '')
const [copied, setCopied] = useState(false)
const handleClick = () => { /** The `Inventory` shows all items present in the game sorted by item type. */
if (enableAll || !locked) { export function Inventory () {
showDoc() const { t } = useTranslation()
}
}
const copyItemName = (ev) => { const { gameId, worldId, levelId } = React.useContext(GameIdContext)
navigator.clipboard.writeText(displayName) const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
setCopied(true)
setInterval(() => { let { theoremTab, setTheoremTab, categoryTab, setCategoryTab } = useContext(InventoryContext)
setCopied(false)
}, 3000); /** Helper function to find if a list of tiles comprises any new elements. */
ev.stopPropagation() function containsNew(tiles: InventoryTile[]) {
console.log(tiles)
return tiles?.filter(item => item.new).length > 0
} }
return <div className={`item ${className}${enableAll ? ' enabled' : ''}${recent ? ' recent' : ''}`} onClick={handleClick} title={title}> return (
{icon} {displayName} <div className="inventory">
<div className="copy-button" onClick={copyItemName}> { levelInfo.data ? <>
{copied ? <FontAwesomeIcon icon={faCheck} /> : <FontAwesomeIcon icon={faClipboard} />} <div className="tab-bar major">
<div className={`tab${(categoryTab == "theorem") ? " active": ""}${containsNew(levelInfo.data?.lemmas) ? " new" : ""}`} onClick={() => { setCategoryTab("theorem") }}>{t("Theorems")}</div>
<div className={`tab${(categoryTab == "tactic") ? " active": ""}${containsNew(levelInfo.data?.tactics) ? " new" : ""}`} onClick={() => { setCategoryTab("tactic") }}>{t("Tactics")}</div>
<div className={`tab${(categoryTab == "definition") ? " active": ""}${containsNew(levelInfo.data?.definitions) ? " new" : ""}`} onClick={() => { setCategoryTab("definition") }}>{t("Definitions")}</div>
</div> </div>
{ (categoryTab == "theorem") &&
<InventoryList items={levelInfo.data?.lemmas} tab={theoremTab} setTab={setTheoremTab} />
}
{ (categoryTab == "tactic") &&
<InventoryList items={levelInfo.data?.tactics} />
}
{ (categoryTab == "definition") &&
<InventoryList items={levelInfo.data?.definitions} />
}
</> : <LoadingIcon /> }
</div> </div>
)
} }
export function Documentation({name, type, handleClose}) { /** The `documentation` */
const {gameId} = React.useContext(GameIdContext) export function Documentation() {
const doc = useLoadDocQuery({game: gameId, type: type, name: name}) const { gameId } = React.useContext(GameIdContext)
// const docEntry = useLoadDocQuery({game: gameId, type: type, name: name})
let { docTile, setDocTile } = useContext(InventoryContext)
const docEntry = useLoadDocQuery({game: gameId, name: docTile.name})
// Set `inventoryDoc` to `null` to close the doc
function closeInventoryDoc() { setDocTile(null) }
return <div className="documentation"> return <div className="documentation">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div> <NavButton icon={faXmark}
<h1 className="doc">{doc.data?.displayName}</h1> onClick={closeInventoryDoc}
<p><code>{doc.data?.statement}</code></p> inverted={true} />
{/* <code>docstring: {doc.data?.docstring}</code> */} <h1 className="doc">{docTile.data?.displayName}</h1>
<Markdown>{t(doc.data?.content, {ns: gameId})}</Markdown> <p><code>{docEntry.data?.statement}</code></p>
<Markdown>{t(docEntry.data?.content, {ns: gameId})}</Markdown>
</div> </div>
} }
/** The panel (on the welcome page) showing the user's inventory with tactics, definitions, and lemmas */ /** The panel showing the user's inventory with tactics, definitions, and lemmas */
export function InventoryPanel({levelInfo, visible = true}) { export function InventoryPanel({visible = true}) {
const {gameId} = React.useContext(GameIdContext) const {gameId, worldId, levelId} = React.useContext(GameIdContext)
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const [lemmaTab, setLemmaTab] = useState(levelInfo?.lemmaTab) const inventory = useLoadInventoryOverviewQuery({game: gameId})
const [theoremTab, setTheoremTab] = useState<string>(null)
const [categoryTab, setCategoryTab] = useState<"tactic"|"theorem"|"definition">('tactic')
// The inventory is overlayed by the doc entry of a clicked item // The inventory is overlayed by the doc entry of a clicked item
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null) const [docTile, setDocTile] = useState<InventoryTile>(null)
// Set `inventoryDoc` to `null` to close the doc
function closeInventoryDoc() {setInventoryDoc(null)}
useEffect(() => { useEffect(() => {
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading. // 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. // `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
if (levelInfo?.lemmaTab) { if (levelInfo?.data?.lemmaTab) {
setLemmaTab(levelInfo?.lemmaTab) setTheoremTab(levelInfo?.data?.lemmaTab)
}}, [levelInfo]) }}, [levelInfo])
return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}> return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}>
{inventoryDoc ? <InventoryContext.Provider value={{theoremTab, setTheoremTab, categoryTab, setCategoryTab, docTile, setDocTile }}>
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/> {docTile ?
<Documentation />
: :
<Inventory levelInfo={levelInfo} openDoc={setInventoryDoc} enableAll={true} lemmaTab={lemmaTab} setLemmaTab={setLemmaTab}/> <Inventory />
} }
</InventoryContext.Provider>
</div> </div>
} }
// HERE: next up: locked items should not be disabled!

@ -109,7 +109,9 @@ function LandingPage() {
</p> </p>
</div> </div>
</header> </header>
<React.Suspense>
<div className="game-list"> <div className="game-list">
{allTiles.filter(x => x != null).length == 0 ? {allTiles.filter(x => x != null).length == 0 ?
<p> <p>
<Trans> <Trans>
@ -126,6 +128,7 @@ function LandingPage() {
)) ))
} }
</div> </div>
</React.Suspense>
<section> <section>
<div className="wrapper"> <div className="wrapper">
<h2>{t("Development notes")}</h2> <h2>{t("Development notes")}</h2>

@ -53,12 +53,13 @@ import { InfoPopup } from './popup/info'
import { PreferencesPopup } from './popup/preferences' import { PreferencesPopup } from './popup/preferences'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import i18next from 'i18next' import i18next from 'i18next'
import { ChatButtons } from './chat'
monacoSetup() monacoSetup()
function Level() { export function Level({visible = true}) {
const params = useParams() // const params = useParams()
// const levelId = parseInt(params.levelId) // const levelId = parseInt(params.levelId)
// const worldId = params.worldId // const worldId = params.worldId
@ -87,11 +88,11 @@ function Level() {
useEffect(() => {}, []) useEffect(() => {}, [])
return <WorldLevelIdContext.Provider value={{worldId, levelId}}> return <div className={visible?'':'hidden'}>
{levelId == 0 ? <WorldLevelIdContext.Provider value={{worldId, levelId}} >
<Introduction impressum={impressum} setImpressum={setImpressum} privacy={privacy} setPrivacy={setPrivacy} toggleInfo={toggleInfo} togglePreferencesPopup={togglePreferencesPopup} /> : <PlayableLevel key={`${worldId}/${levelId}`} />
<PlayableLevel key={`${worldId}/${levelId}`} impressum={impressum} setImpressum={setImpressum} privacy={privacy} setPrivacy={setPrivacy} toggleInfo={toggleInfo} togglePreferencesPopup={togglePreferencesPopup}/>}
</WorldLevelIdContext.Provider> </WorldLevelIdContext.Provider>
</div>
} }
function ChatPanel({lastLevel, visible = true}) { function ChatPanel({lastLevel, visible = true}) {
@ -199,7 +200,7 @@ function ChatPanel({lastLevel, visible = true}) {
} }
function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableRefObject<HTMLDivElement>, visible?: boolean}) { export function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableRefObject<HTMLDivElement>, visible?: boolean}) {
const {gameId} = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext) const {worldId, levelId} = useContext(WorldLevelIdContext)
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId}) const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
@ -211,7 +212,7 @@ function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableR
</div> </div>
} }
function PlayableLevel({impressum, setImpressum, privacy, setPrivacy, toggleInfo, togglePreferencesPopup}) { export function PlayableLevel() {
let { t } = useTranslation() let { t } = useTranslation()
const codeviewRef = useRef<HTMLDivElement>(null) const codeviewRef = useRef<HTMLDivElement>(null)
const {gameId} = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
@ -248,9 +249,9 @@ function PlayableLevel({impressum, setImpressum, privacy, setPrivacy, toggleInfo
const [typewriterInput, setTypewriterInput] = useState("") const [typewriterInput, setTypewriterInput] = useState("")
const lastLevel = levelId >= gameInfo.data?.worldSize[worldId] const lastLevel = levelId >= gameInfo.data?.worldSize[worldId]
// impressum pop-up // // impressum pop-up
function toggleImpressum() {setImpressum(!impressum)} // function toggleImpressum() {setImpressum(!impressum)}
function togglePrivacy() {setPrivacy(!privacy)} // function togglePrivacy() {setPrivacy(!privacy)}
// When clicking on an inventory item, the inventory is overlayed by the item's doc. // 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. // If this state is set to a pair `(name, type)` then the according doc will be open.
@ -424,24 +425,7 @@ function PlayableLevel({impressum, setImpressum, privacy, setPrivacy, toggleInfo
toggleInfo={toggleInfo} toggleInfo={toggleInfo}
togglePreferencesPopup={togglePreferencesPopup} togglePreferencesPopup={togglePreferencesPopup}
/> */} /> */}
{mobile? <ExercisePanel codeviewRef={codeviewRef} />
// TODO: This is copied from the `Split` component below...
<>
<div className={`app-content level-mobile ${level.isLoading ? 'hidden' : ''}`}>
<ExercisePanel
codeviewRef={codeviewRef}
visible={page == 0} />
<InventoryPanel levelInfo={level?.data} visible={page == 1} />
</div>
</>
:
<Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
<ChatPanel lastLevel={lastLevel}/>
<ExercisePanel
codeviewRef={codeviewRef} />
<InventoryPanel levelInfo={level?.data} />
</Split>
}
</MonacoEditorContext.Provider> </MonacoEditorContext.Provider>
</EditorContext.Provider> </EditorContext.Provider>
</ProofContext.Provider> </ProofContext.Provider>
@ -451,6 +435,11 @@ function PlayableLevel({impressum, setImpressum, privacy, setPrivacy, toggleInfo
</> </>
} }
// <Split minSize={0} snapOffset={200} sizes={[25, 75]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
// <ChatPanel lastLevel={lastLevel}/>
// <InventoryPanel />
// </Split>
function IntroductionPanel({gameInfo}) { function IntroductionPanel({gameInfo}) {
let { t } = useTranslation() let { t } = useTranslation()
const {gameId} = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
@ -466,21 +455,14 @@ function IntroductionPanel({gameInfo}) {
hint={{text: t, hidden: false, rawText: t, varNames: []}} step={0} selected={null} toggleSelection={undefined} /> hint={{text: t, hidden: false, rawText: t, varNames: []}} step={0} selected={null} toggleSelection={undefined} />
))} ))}
</div> </div>
<div className={`button-row${mobile ? ' mobile' : ''}`}> <ChatButtons />
{gameInfo.data?.worldSize[worldId] == 0 ?
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/${gameId}/world/${worldId}/level/1`}>
{t("Start")}&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>
}
</div>
</div> </div>
} }
export default Level export default Level
/** The site with the introduction text of a world */ /** The site with the introduction text of a world */
function Introduction({impressum, setImpressum, privacy, setPrivacy, toggleInfo, togglePreferencesPopup}) { function Introduction() {
let { t } = useTranslation() let { t } = useTranslation()
const {gameId} = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
@ -495,12 +477,12 @@ function Introduction({impressum, setImpressum, privacy, setPrivacy, toggleInfo,
let image: string = gameInfo.data?.worlds.nodes[worldId].image let image: string = gameInfo.data?.worlds.nodes[worldId].image
const toggleImpressum = () => { // const toggleImpressum = () => {
setImpressum(!impressum) // setImpressum(!impressum)
} // }
const togglePrivacy = () => { // const togglePrivacy = () => {
setPrivacy(!privacy) // setPrivacy(!privacy)
} // }
return <> return <>
{/* <LevelAppBar isLoading={gameInfo.isLoading} levelTitle={t("Introduction")} toggleImpressum={toggleImpressum} togglePrivacy={togglePrivacy} toggleInfo={toggleInfo} togglePreferencesPopup={togglePreferencesPopup}/> */} {/* <LevelAppBar isLoading={gameInfo.isLoading} levelTitle={t("Introduction")} toggleImpressum={toggleImpressum} togglePrivacy={togglePrivacy} toggleInfo={toggleInfo} togglePreferencesPopup={togglePreferencesPopup}/> */}
{gameInfo.isLoading ? {gameInfo.isLoading ?
@ -508,16 +490,16 @@ function Introduction({impressum, setImpressum, privacy, setPrivacy, toggleInfo,
: mobile ? : mobile ?
<IntroductionPanel gameInfo={gameInfo} /> <IntroductionPanel gameInfo={gameInfo} />
: :
<Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level`}> // <Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level`}>
<IntroductionPanel gameInfo={gameInfo} /> // <IntroductionPanel gameInfo={gameInfo} />
<div className="world-image-container empty center"> <div className="world-image-container empty center">
{image && {image &&
<img className="contain" src={path.join("data", gameId, image)} alt="" /> <img className="contain" src={path.join("data", gameId, image)} alt="" />
} }
</div> </div>
<InventoryPanel levelInfo={inventory?.data} /> // {/* <InventoryPanel /> */}
</Split> // </Split>
} }
</> </>

@ -12,9 +12,10 @@ import { useTranslation } from 'react-i18next'
import '../css/navigation.css' import '../css/navigation.css'
import { PopupContext } from './popup/popup' import { PopupContext } from './popup/popup'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { selectProgress } from '../state/progress' import { selectCompleted, selectDifficulty, selectProgress } from '../state/progress'
import lean4gameConfig from '../config.json' import lean4gameConfig from '../config.json'
import { Flag } from './flag' import { Flag } from './flag'
import { useAppSelector } from '../hooks'
/** SVG github icon */ /** SVG github icon */
function GithubIcon () { function GithubIcon () {
@ -119,6 +120,8 @@ function DesktopNavigationLevel () {
const { typewriterMode, setTypewriterMode, lockEditorMode } = useContext(PageContext) const { typewriterMode, setTypewriterMode, lockEditorMode } = useContext(PageContext)
const gameInfo = useGetGameInfoQuery({game: gameId}) const gameInfo = useGetGameInfoQuery({game: gameId})
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId}) const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const difficulty = useSelector(selectDifficulty(gameId))
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
/** toggle input mode if allowed */ /** toggle input mode if allowed */
function toggleInputMode(ev: React.MouseEvent) { function toggleInputMode(ev: React.MouseEvent) {
@ -163,10 +166,12 @@ function DesktopNavigationLevel () {
icon={faHome} icon={faHome}
text={t("Leave World")} text={t("Leave World")}
inverted={true} inverted={true}
disabled={difficulty == 0 || !completed}
href={`#/${gameId}`} /> : href={`#/${gameId}`} /> :
<NavButton <NavButton
icon={faArrowRight} icon={faArrowRight}
text={levelId == 0 ? t("Start") : t("Next")} inverted={true} text={levelId == 0 ? t("Start") : t("Next")} inverted={true}
disabled={difficulty == 0 || !completed}
href={`#/${gameId}/world/${worldId}/level/${levelId + 1}`} /> href={`#/${gameId}/world/${worldId}/level/${levelId + 1}`} />
} }
{ levelId > 0 && { levelId > 0 &&
@ -203,9 +208,9 @@ function MobileNavigationLevel () {
</div> </div>
<div className="nav-title-right"> <div className="nav-title-right">
<NavButton <NavButton
icon={page?faBookOpen:faBook} icon={(page == 1) ? faBook : faBookOpen}
onClick={() => setPage(page?0:1)} onClick={() => setPage((page == 1) ? 2 : 1)}
inverted={true}/> inverted={true} />
</div> </div>
</div> </div>
} }
@ -213,17 +218,29 @@ function MobileNavigationLevel () {
/** The skeleton of the navigation which is the same across all layouts. */ /** The skeleton of the navigation which is the same across all layouts. */
export function Navigation () { export function Navigation () {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { gameId, worldId } = useContext(GameIdContext) const { gameId, worldId, levelId } = useContext(GameIdContext)
const { mobile, language, setLanguage } = useContext(PreferencesContext) const { mobile, language, setLanguage } = useContext(PreferencesContext)
const { setPopupContent } = useContext(PopupContext) const { setPopupContent } = useContext(PopupContext)
const { typewriterMode, setTypewriterMode, lockEditorMode } = useContext(PageContext)
const gameProgress = useSelector(selectProgress(gameId)) const gameProgress = useSelector(selectProgress(gameId))
const gameInfo = useGetGameInfoQuery({game: gameId}) const gameInfo = useGetGameInfoQuery({game: gameId})
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const difficulty = useSelector(selectDifficulty(gameId))
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
const [navOpen, setNavOpen] = useState(false) const [navOpen, setNavOpen] = useState(false)
const [langNavOpen, setLangNavOpen] = useState(false) const [langNavOpen, setLangNavOpen] = useState(false)
function toggleNav () {setNavOpen(!navOpen); setLangNavOpen(false)} function toggleNav () {setNavOpen(!navOpen); setLangNavOpen(false)}
function toggleLangNav () {setLangNavOpen(!langNavOpen); setNavOpen(false)} function toggleLangNav () {setLangNavOpen(!langNavOpen); setNavOpen(false)}
/** toggle input mode if allowed */
function toggleInputMode(ev: React.MouseEvent) {
if (!lockEditorMode) {
setTypewriterMode(!typewriterMode)
console.log('test')
}
}
return <nav> return <nav>
<NavigationContext.Provider value={{navOpen, setNavOpen}}> <NavigationContext.Provider value={{navOpen, setNavOpen}}>
{ gameId && <> { gameId && <>
@ -280,6 +297,35 @@ export function Navigation () {
{ navOpen && { navOpen &&
<div className='dropdown' onClick={toggleNav} > <div className='dropdown' onClick={toggleNav} >
{ gameId && <> { gameId && <>
{ mobile && (levelId == gameInfo.data?.worldSize[worldId] ?
<NavButton
icon={faHome}
text={t("Leave World")}
inverted={true}
disabled={difficulty == 0 || !completed}
href={`#/${gameId}`} /> :
<NavButton
icon={faArrowRight}
text={levelId == 0 ? t("Start") : t("Next")} inverted={true}
disabled={difficulty == 0 || !completed}
href={`#/${gameId}/world/${worldId}/level/${levelId + 1}`} />
)}
{mobile && levelId > 0 &&
<NavButton
icon={faArrowLeft}
text={t("Previous")}
inverted={true}
href={`#/${gameId}/world/${worldId}/level/${levelId - 1}`} />
}
{ mobile && levelId > 0 &&
<NavButton
icon={(typewriterMode && !lockEditorMode) ? faCode : faTerminal}
inverted={true}
text={typewriterMode ? t("Editor Mode") : t("Typewriter Mode")}
disabled={levelId == 0 || lockEditorMode}
onClick={(ev) => toggleInputMode(ev)}
title={lockEditorMode ? t("Editor mode is enforced!") : typewriterMode ? t("Editor mode") : t("Typewriter mode")} />
}
<NavButton <NavButton
icon={faCircleInfo} icon={faCircleInfo}
text={t("Game Info")} text={t("Game Info")}

@ -4,10 +4,11 @@ import { GameIdContext } from '../../app'
import { useAppDispatch } from '../../hooks' import { useAppDispatch } from '../../hooks'
import { deleteProgress, selectProgress } from '../../state/progress' import { deleteProgress, selectProgress } from '../../state/progress'
import { downloadFile } from '../world_tree' import { downloadFile } from '../world_tree'
import { Button } from '../button' import { Button, Button2 } from '../button'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { useContext } from 'react' import { useContext } from 'react'
import { PopupContext } from './popup' import { PopupContext } from './popup'
import { PageContext } from '../infoview/context'
/** download the current progress (i.e. what's saved in the browser store) */ /** download the current progress (i.e. what's saved in the browser store) */
export function downloadProgress(gameId: string, gameProgress) { export function downloadProgress(gameId: string, gameProgress) {
@ -28,6 +29,7 @@ export function downloadProgress(gameId: string, gameProgress) {
export function ErasePopup () { export function ErasePopup () {
let { t } = useTranslation() let { t } = useTranslation()
const {gameId} = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
const {setPage} = useContext(PageContext)
const gameProgress = useSelector(selectProgress(gameId)) const gameProgress = useSelector(selectProgress(gameId))
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { setPopupContent } = useContext(PopupContext) const { setPopupContent } = useContext(PopupContext)
@ -35,7 +37,8 @@ export function ErasePopup () {
const eraseProgress = (ev) => { const eraseProgress = (ev) => {
dispatch(deleteProgress({game: gameId})) dispatch(deleteProgress({game: gameId}))
setPopupContent(null) setPopupContent(null)
ev.preventDefault() // TODO: this is a hack to prevent the buttons below from opening a link setPage(0)
// ev.preventDefault() // TODO: this is a hack to prevent the buttons below from opening a link
} }
const downloadAndErase = (ev) => { const downloadAndErase = (ev) => {
@ -52,8 +55,8 @@ export function ErasePopup () {
Saves from other games are not deleted.) Saves from other games are not deleted.)
</p> </p>
</Trans> </Trans>
<Button onClick={eraseProgress} to="">{t("Delete")}</Button> <Button onClick={eraseProgress} to={`/${gameId}/`}>{t("Delete")}</Button>
<Button onClick={downloadAndErase} to="">{t("Download & Delete")}</Button> <Button onClick={downloadAndErase} to={`/${gameId}/`}>{t("Download & Delete")}</Button>
<Button onClick={(ev) => {setPopupContent(null); ev.preventDefault()}} to="">{t("Cancel")}</Button> <Button onClick={(ev) => {setPopupContent(null); ev.preventDefault()}} to="">{t("Cancel")}</Button>
</> </>
} }

@ -8,6 +8,8 @@ import { PreferencesPopup } from './preferences'
import { UploadPopup } from './upload' import { UploadPopup } from './upload'
import { RulesPopup } from './rules' import { RulesPopup } from './rules'
import '../../css/popup.css' import '../../css/popup.css'
import { NavButton } from '../navigation'
import { faXmark } from '@fortawesome/free-solid-svg-icons'
/** The context which manages if a popup is shown. /** The context which manages if a popup is shown.
* If `popupContent` is `null`, the popup is closed. * If `popupContent` is `null`, the popup is closed.
@ -46,6 +48,9 @@ export function Popup () {
return <div className="modal-wrapper"> return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={closePopup} /> <div className="modal-backdrop" onClick={closePopup} />
<div className="modal"> <div className="modal">
<NavButton icon={faXmark}
onClick={closePopup}
inverted={true} />
<div className="codicon codicon-close modal-close" onClick={closePopup}></div> <div className="codicon codicon-close modal-close" onClick={closePopup}></div>
{Popups[popupContent]} {Popups[popupContent]}
</div> </div>

@ -0,0 +1,8 @@
import * as React from 'react';
import { Box, CircularProgress } from "@mui/material";
export function LoadingIcon () {
return <Box display="flex" alignItems="center" justifyContent="center" sx={{ flex: 1, height: "calc(100vh - 64px)" }}>
<CircularProgress />
</Box>
}

@ -133,7 +133,7 @@ function Welcome() {
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} <WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize}
rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} /> rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
: :
<InventoryPanel levelInfo={inventory?.data} /> <InventoryPanel />
)} )}
</div> </div>
: :
@ -141,7 +141,7 @@ function Welcome() {
<IntroductionPanel introduction={gameInfo.data?.introduction} setPageNumber={setPage} /> <IntroductionPanel introduction={gameInfo.data?.introduction} setPageNumber={setPage} />
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} <WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize}
rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} /> rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
<InventoryPanel levelInfo={inventory?.data} /> <InventoryPanel />
</Split> </Split>
} }
</div> </div>

@ -18,6 +18,8 @@ import { store } from '../state/store'
import '../css/world_tree.css' import '../css/world_tree.css'
import { PreferencesContext } from './infoview/context' import { PreferencesContext } from './infoview/context'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useGetGameInfoQuery } from '../state/api'
import { LoadingIcon } from './utils'
// Settings for the world tree // Settings for the world tree
cytoscape.use( klay ) cytoscape.use( klay )
@ -271,15 +273,14 @@ export function computeWorldLayout(worlds) {
} }
export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}: export function WorldTreePanel ({visible = true}) {
{ worlds: any,
worldSize: any,
rulesHelp: boolean,
setRulesHelp: any,
}) {
const {gameId} = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId)) const difficulty = useSelector(selectDifficulty(gameId))
const {nodes, bounds}: any = worlds ? computeWorldLayout(worlds) : {nodes: []} const gameInfo = useGetGameInfoQuery({game: gameId})
const {nodes, bounds}: any = gameInfo.data?.worlds ? computeWorldLayout(gameInfo.data?.worlds) : {nodes: []}
// scroll to playable world // scroll to playable world
React.useEffect(() => { React.useEffect(() => {
@ -292,7 +293,7 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
console.debug(`scrolling to ${elem.textContent}`) console.debug(`scrolling to ${elem.textContent}`)
elem.scrollIntoView({block: "center"}) elem.scrollIntoView({block: "center"})
} }
}, [worlds, worldSize]) }, [gameInfo])
let svgElements = [] let svgElements = []
@ -301,18 +302,18 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
// Indices `1, …, n` indicate if the corresponding level is completed // Indices `1, …, n` indicate if the corresponding level is completed
var completed = {} var completed = {}
if (worlds && worldSize) { if (gameInfo.data?.worlds && gameInfo.data?.worldSize) {
// Fill `completed` with the level data. // Fill `completed` with the level data.
for (let worldId in nodes) { for (let worldId in nodes) {
completed[worldId] = Array.from({ length: worldSize[worldId] + 1 }, (_, i) => { completed[worldId] = Array.from({ length: gameInfo.data?.worldSize[worldId] + 1 }, (_, i) => {
// index `0` starts off as `true` but can be set to `false` by any edge with non-completed source // 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()) return i == 0 || selectCompleted(gameId, worldId, i)(store.getState())
}) })
} }
// draw all connecting paths // draw all connecting paths
for (let i in worlds.edges) { for (let i in gameInfo.data?.worlds.edges) {
const edge = worlds.edges[i] const edge = gameInfo.data?.worlds.edges[i]
let sourceCompleted = completed[edge[0]].slice(1).every(Boolean) let sourceCompleted = completed[edge[0]].slice(1).every(Boolean)
// if the origin world is not completed, mark the target world as non-playable // if the origin world is not completed, mark the target world as non-playable
if (!sourceCompleted) {completed[edge[1]][0] = false} if (!sourceCompleted) {completed[edge[1]][0] = false}
@ -332,11 +333,11 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
completedLevels={completed[worldId]} completedLevels={completed[worldId]}
difficulty={difficulty} difficulty={difficulty}
key={`${gameId}-${worldId}`} key={`${gameId}-${worldId}`}
worldSize={worldSize[worldId]} worldSize={gameInfo.data?.worldSize[worldId]}
/> />
) )
for (let i = 1; i <= worldSize[worldId]; i++) { for (let i = 1; i <= gameInfo.data?.worldSize[worldId]; i++) {
svgElements.push( svgElements.push(
<LevelIcon <LevelIcon
world={worldId} world={worldId}
@ -345,7 +346,7 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
completed={completed[worldId][i]} completed={completed[worldId][i]}
unlocked={completed[worldId][i-1]} unlocked={completed[worldId][i-1]}
key={`${gameId}-${worldId}-${i}`} key={`${gameId}-${worldId}-${i}`}
worldSize={worldSize[worldId]} worldSize={gameInfo.data?.worldSize[worldId]}
/> />
) )
} }
@ -359,13 +360,14 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null
return <div className="column"> return <div className={`column${visible ? '' : ' hidden'}`}>
{/* <WorldSelectionMenu rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} /> */} {/* <WorldSelectionMenu rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} /> */}
{ gameInfo.data ?
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" <svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
width={bounds ? `${ds * dx}` : ''} width={bounds ? `${ds * dx}` : ''}
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''} viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''}
className="world-selection" > className="world-selection" >
{svgElements} {svgElements}
</svg> </svg> : <LoadingIcon/> }
</div> </div>
} }

@ -17,6 +17,10 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.hidden {
display: none !important;
}
.markdown code { .markdown code {
color: rgba(0, 32, 90, 0.87); color: rgba(0, 32, 90, 0.87);
background-color: rgba(223, 227, 234); background-color: rgba(223, 227, 234);
@ -126,14 +130,6 @@ em {
/* margin: 0 1em; */ /* margin: 0 1em; */
} }
.app-content {
height: 100%;
flex: 1;
min-height: 0;
display: flex;
}
.markdown li ul, .markdown li ol { .markdown li ul, .markdown li ol {
margin:0 1.5em; margin:0 1.5em;
} }

@ -0,0 +1,24 @@
.message {
margin: 10px 0;
padding: 5px 10px;
border-radius: 3px 3px 3px 3px;
}
.message.information, .message.info {
/* color: #059; */
color: #000;
background-color: #DDF6FF;
}
.message.warning {
color: #9F6000;
background-color: #FEEFB3;
}
.message.error {
color: #D8000C;
background-color: #FFBABA;
}
.message.deleted-hint {
background-color: #eee;
color: #777;
box-shadow: .0em .0em .5em .2em #eee;
}

@ -1,5 +1,5 @@
#error-page { #error-page {
height: 100%; height: 100vh;
background-image: url("/RoboSurprised.png"); background-image: url("/RoboSurprised.png");
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: contain; background-size: contain;

@ -0,0 +1,33 @@
.app-content {
height: 100%;
flex: 1;
min-height: 0;
display: flex;
}
.gutter {
background-color: #eee;
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-vertical {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=');
}
.gutter.gutter-horizontal {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
}
.column {
width: 100%;
}
.slider .column {
height: 100%;
overflow: auto;
position: relative;
scroll-behavior: smooth;
}

@ -1,28 +1,4 @@
.message {
margin: 10px 0;
padding: 5px 10px;
border-radius: 3px 3px 3px 3px;
}
.message.information, .message.info {
/* color: #059; */
color: #000;
background-color: #DDF6FF;
}
.message.warning {
color: #9F6000;
background-color: #FEEFB3;
}
.message.error {
color: #D8000C;
background-color: #FFBABA;
}
.message.deleted-hint {
background-color: #eee;
color: #777;
box-shadow: .0em .0em .5em .2em #eee;
}
.hyp-group { .hyp-group {
margin-bottom: 0.3em; margin-bottom: 0.3em;

@ -5,25 +5,6 @@
/* display: flex; */ /* display: flex; */
} }
.hidden {
display: none;
}
.gutter {
background-color: #eee;
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-vertical {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=');
}
.gutter.gutter-horizontal {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
}
.inventory-panel, .exercise-panel, .doc-panel, .introduction-panel { .inventory-panel, .exercise-panel, .doc-panel, .introduction-panel {
height: 100%; height: 100%;
width: 100%; width: 100%;
@ -213,17 +194,26 @@ td code {
.chat-panel .button-row { .chat-panel .button-row {
/* width:100%; */ /* width:100%; */
margin-left: .5em; /* margin-left: .5em;
margin-right: .5em; margin-right: .5em; */
min-height: 2.5em; min-height: 2.5em;
border-top: 0.1em solid #aaa; border-top: 0.1em solid #aaa;
display: flex;
padding-top: .2rem;
margin: .5rem;
} }
.chat-panel .btn { .chat-panel .btn {
margin-top: 1rem; flex: 1;
/* margin-top: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
margin-left: .5rem; margin-left: .5rem;
margin-right: .5rem; margin-right: .5rem; */
text-align: center;
margin: 0;
padding: .5em;
} }
/* .exercise-panel { /* .exercise-panel {
@ -232,7 +222,7 @@ td code {
height: 100%; height: 100%;
} */ } */
.button-row.mobile { /* .button-row.mobile {
margin: .5rem; margin: .5rem;
padding-top: .2rem; padding-top: .2rem;
} }
@ -243,7 +233,7 @@ td code {
width: 100%; width: 100%;
margin: 0; margin: 0;
text-align: center; text-align: center;
} } */
.typewriter-interface { .typewriter-interface {

@ -5,3 +5,21 @@
padding-right: 2rem; padding-right: 2rem;
padding-top: 1rem; padding-top: 1rem;
} }
.modal-close {
float: right;
scale: 2;
color: var(--vscode-breadcrumb-foreground);
cursor: pointer;
}
.modal-close:hover {
float: right;
scale: 2;
color: var(--vscode-breadcrumb-focusForeground);
}
.documentation .nav-button, .modal .nav-button {
float: right;
font-size: 1.5rem;
}

@ -14,9 +14,6 @@
width: 100%; width: 100%;
} }
.app-content {
height: 100%
}
.welcome .column { .welcome .column {
height: 100%; height: 100%;
@ -159,19 +156,6 @@ h5, h6 {
margin: 1em auto; margin: 1em auto;
} }
.modal-close {
float: right;
scale: 2;
color: var(--vscode-breadcrumb-foreground);
cursor: pointer;
}
.modal-close:hover {
float: right;
scale: 2;
color: var(--vscode-breadcrumb-focusForeground);
}
.modal table { .modal table {
width: 100%; width: 100%;
} }

@ -10,6 +10,7 @@ import Welcome from './components/welcome'
import LandingPage from './components/landing_page' import LandingPage from './components/landing_page'
import Level from './components/level' import Level from './components/level'
import './i18n'; import './i18n';
import Game from './components/game'
@ -49,11 +50,11 @@ const router = createHashRouter([
landing_page, landing_page,
{ {
path: "/g/:owner/:repo", path: "/g/:owner/:repo",
element: <Welcome />, element: <Game />,
}, },
{ {
path: "/g/:owner/:repo/world/:worldId/level/:levelId", path: "/g/:owner/:repo/world/:worldId/level/:levelId",
element: <Level />, element: <Game />,
}, },
], ],
}, },

@ -84,13 +84,19 @@ export const apiSlice = createApi({
query: ({game}) => `${game}/game.json`, query: ({game}) => `${game}/game.json`,
}), }),
loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({ loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({
query: ({game, world, level}) => `${game}/level__${world}__${level}.json`, query: ({game, world, level}) => {
if (world) {
return `${game}/level__${world}__${level}.json`
} else {
return `${game}/inventory.json`
}
},
}), }),
loadInventoryOverview: builder.query<InventoryOverview, {game: string}>({ loadInventoryOverview: builder.query<InventoryOverview, {game: string}>({
query: ({game}) => `${game}/inventory.json`, query: ({game}) => `${game}/inventory.json`,
}), }),
loadDoc: builder.query<Doc, {game: string, name: string, type: "lemma"|"tactic"}>({ loadDoc: builder.query<Doc, {game: string, name: string }>({
query: ({game, type, name}) => `${game}/doc__${type}__${name}.json`, query: ({game, name}) => `${game}/doc__${name}.json`,
}), }),
}), }),
}) })

@ -54,7 +54,7 @@ const initalLevelProgressState: LevelProgressState = {code: "", completed: false
/** Add an empty skeleton with progress for the current game */ /** Add an empty skeleton with progress for the current game */
function addGameProgress (state: ProgressState, action: PayloadAction<{game: string}>) { function addGameProgress (state: ProgressState, action: PayloadAction<{game: string}>) {
if (!state.games[action.payload.game.toLowerCase()]) { if (!state.games[action.payload.game.toLowerCase()]) {
state.games[action.payload.game.toLowerCase()] = {inventory: [], openedIntro: true, data: {}, difficulty: DEFAULT_DIFFICULTY} state.games[action.payload.game.toLowerCase()] = {inventory: [], openedIntro: false, data: {}, difficulty: DEFAULT_DIFFICULTY}
} }
if (!state.games[action.payload.game.toLowerCase()].data) { if (!state.games[action.payload.game.toLowerCase()].data) {
state.games[action.payload.game.toLowerCase()].data = {} state.games[action.payload.game.toLowerCase()].data = {}
@ -100,7 +100,7 @@ export const progressSlice = createSlice({
}, },
/** delete all progress for this game */ /** delete all progress for this game */
deleteProgress(state: ProgressState, action: PayloadAction<{game: string}>) { deleteProgress(state: ProgressState, action: PayloadAction<{game: string}>) {
state.games[action.payload.game.toLowerCase()] = {inventory: [], data: {}, openedIntro: true, difficulty: DEFAULT_DIFFICULTY} state.games[action.payload.game.toLowerCase()] = {inventory: [], data: {}, openedIntro: false, difficulty: DEFAULT_DIFFICULTY}
}, },
/** delete progress for this level */ /** delete progress for this level */
deleteLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) { deleteLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {

@ -25,7 +25,7 @@ def copyImages : IO Unit := do
namespace GameData namespace GameData
def gameDataPath : System.FilePath := ".lake" / "gamedata" def gameDataPath : System.FilePath := ".lake" / "gamedata"
def gameFileName := s!"game.json" def gameFileName := s!"game.json"
def docFileName := fun (inventoryType : InventoryType) (name : Name) => s!"doc__{inventoryType}__{name}.json" def docFileName := fun (inventoryType : InventoryType) (name : Name) => s!"doc__{name}.json"
def levelFileName := fun (worldId : Name) (levelId : Nat) => s!"level__{worldId}__{levelId}.json" def levelFileName := fun (worldId : Name) (levelId : Nat) => s!"level__{worldId}__{levelId}.json"
def inventoryFileName := s!"inventory.json" def inventoryFileName := s!"inventory.json"
end GameData end GameData

Loading…
Cancel
Save