From 6748fa8b884dcf96174952c38d803320c6beba37 Mon Sep 17 00:00:00 2001 From: Erin Date: Mon, 8 Jun 2026 15:38:55 -0500 Subject: [PATCH 1/7] Functionality to create custom JBC challenges with the ability to add customized play areas that can trigger success/fail conditions --- src/App.tsx | 2 + src/components/Challenge/ChallengeMenu.tsx | 3 + src/components/Challenge/ExtraMenu.tsx | 9 +- src/components/Challenge/GoalList.tsx | 108 +- src/components/Challenge/SimMenu.tsx | 3 + src/components/Challenge/index.tsx | 37 +- .../Classrooms/ChallengeTabView.tsx | 149 +- .../ChallengeGoalsPreview.tsx | 68 + .../ChallengeItemSuggestions.tsx | 171 +++ .../CustomChallenges/ConditionGoalsEditor.tsx | 138 ++ .../CustomChallengeEditor.tsx | 327 +++++ .../CustomChallengeRulesSidePanel.tsx | 330 +++++ .../CustomChallengeWizardSidePanel.tsx | 179 +++ .../CustomChallenges/DefineAreaToolbar.tsx | 124 ++ .../CustomChallenges/EventListEditor.tsx | 161 +++ .../CustomChallenges/ItemSuccessWizard.tsx | 385 ++++++ .../JbcCatalogEventPicker.tsx | 129 ++ .../JbcCatalogGeometryPicker.tsx | 136 ++ .../CustomChallenges/JbcCatalogItemPicker.tsx | 146 ++ .../JbcCatalogSuccessGoalPicker.tsx | 148 ++ .../JbcMatPlayAreaOverlay.tsx | 304 +++++ .../CustomChallenges/MatItemRulesSummary.tsx | 70 + .../MatPlayZonesSceneOverlay.tsx | 46 + .../MatPlayZonesSidePanel.tsx | 578 ++++++++ .../CustomChallenges/MatZoneEditOverlay.tsx | 531 ++++++++ .../CustomChallenges/catalogPickerList.ts | 11 + .../Dialog/CustomChallengeSetupDialog.tsx | 1198 ++++++++++++++++ src/components/Dialog/OpenSceneDialog.tsx | 386 +++++- src/components/Dialog/index.ts | 3 +- src/components/Layout/Layout.ts | 6 + src/components/Layout/OverlayLayout.tsx | 18 +- src/components/Layout/SideLayout.tsx | 12 + src/components/Layout/SimulatorArea.tsx | 20 +- src/components/constants/defaultScene.ts | 7 +- src/pages/ChallengeRoot.tsx | 799 ++++++++++- src/pages/CustomChallengeCreator.tsx | 251 ++++ src/pages/Root.tsx | 262 +++- src/pages/sharedRoot/Modal.ts | 14 +- src/simulator/ScriptManager.ts | 180 ++- src/simulator/Space.ts | 143 +- src/simulator/babylonBindings/RobotBinding.ts | 139 +- src/simulator/babylonBindings/SceneBinding.ts | 567 +++++++- .../createRobotObjects/createWeight.ts | 12 +- .../createSceneObjects/createObjects.ts | 7 +- .../matPlayZoneSurfaceMeshes.ts | 250 ++++ .../playAreaRobotBoundsDebugMeshes.ts | 110 ++ .../babylonBindings/sensors/EtSensor.ts | 26 +- .../babylonBindings/sensors/LightSensor.ts | 11 +- .../sensors/ReflectanceSensor.ts | 32 +- src/state/State/Challenge/Expr.ts | 10 +- src/state/State/Classroom/index.ts | 11 + src/state/State/Scene/index.ts | 123 ++ src/state/reducer/challengeCompletions.ts | 3 +- src/state/reducer/challenges.ts | 190 ++- src/state/reducer/classrooms.ts | 31 +- src/state/reducer/scenes.ts | 72 +- src/state/reducer/util.ts | 7 + src/util/babylonMath.ts | 202 +++ src/util/challengeEventUpdates.ts | 170 +++ src/util/customChallengeClassroomShare.ts | 140 ++ src/util/customChallengeFactory.ts | 91 ++ src/util/customChallengeGoals.ts | 375 ++++++ src/util/customChallengePredicates.ts | 151 +++ src/util/customChallengeSceneScripts.ts | 976 ++++++++++++++ src/util/customChallengeStorage.ts | 72 + src/util/jbcChallengeCatalog.ts | 657 +++++++++ src/util/jbcChallengeSuggestions.ts | 996 ++++++++++++++ src/util/jbcMatPlayArea.ts | 1200 +++++++++++++++++ src/util/jbcReamStopNear.ts | 280 ++++ src/util/matZoneEditSession.ts | 82 ++ src/util/nativeScrollbarChrome.ts | 3 + src/util/playAreaRobotIntersection.ts | 211 +++ src/util/playAreaSuccessGoals.ts | 349 +++++ src/util/scenePropsRequireSimulatorReload.ts | 69 + 74 files changed, 14909 insertions(+), 308 deletions(-) create mode 100644 src/components/CustomChallenges/ChallengeGoalsPreview.tsx create mode 100644 src/components/CustomChallenges/ChallengeItemSuggestions.tsx create mode 100644 src/components/CustomChallenges/ConditionGoalsEditor.tsx create mode 100644 src/components/CustomChallenges/CustomChallengeEditor.tsx create mode 100644 src/components/CustomChallenges/CustomChallengeRulesSidePanel.tsx create mode 100644 src/components/CustomChallenges/CustomChallengeWizardSidePanel.tsx create mode 100644 src/components/CustomChallenges/DefineAreaToolbar.tsx create mode 100644 src/components/CustomChallenges/EventListEditor.tsx create mode 100644 src/components/CustomChallenges/ItemSuccessWizard.tsx create mode 100644 src/components/CustomChallenges/JbcCatalogEventPicker.tsx create mode 100644 src/components/CustomChallenges/JbcCatalogGeometryPicker.tsx create mode 100644 src/components/CustomChallenges/JbcCatalogItemPicker.tsx create mode 100644 src/components/CustomChallenges/JbcCatalogSuccessGoalPicker.tsx create mode 100644 src/components/CustomChallenges/JbcMatPlayAreaOverlay.tsx create mode 100644 src/components/CustomChallenges/MatItemRulesSummary.tsx create mode 100644 src/components/CustomChallenges/MatPlayZonesSceneOverlay.tsx create mode 100644 src/components/CustomChallenges/MatPlayZonesSidePanel.tsx create mode 100644 src/components/CustomChallenges/MatZoneEditOverlay.tsx create mode 100644 src/components/CustomChallenges/catalogPickerList.ts create mode 100644 src/components/Dialog/CustomChallengeSetupDialog.tsx create mode 100644 src/pages/CustomChallengeCreator.tsx create mode 100644 src/simulator/babylonBindings/matPlayZoneSurfaceMeshes.ts create mode 100644 src/simulator/babylonBindings/playAreaRobotBoundsDebugMeshes.ts create mode 100644 src/util/babylonMath.ts create mode 100644 src/util/challengeEventUpdates.ts create mode 100644 src/util/customChallengeClassroomShare.ts create mode 100644 src/util/customChallengeFactory.ts create mode 100644 src/util/customChallengeGoals.ts create mode 100644 src/util/customChallengePredicates.ts create mode 100644 src/util/customChallengeSceneScripts.ts create mode 100644 src/util/customChallengeStorage.ts create mode 100644 src/util/jbcChallengeCatalog.ts create mode 100644 src/util/jbcChallengeSuggestions.ts create mode 100644 src/util/jbcMatPlayArea.ts create mode 100644 src/util/jbcReamStopNear.ts create mode 100644 src/util/matZoneEditSession.ts create mode 100644 src/util/playAreaRobotIntersection.ts create mode 100644 src/util/playAreaSuccessGoals.ts create mode 100644 src/util/scenePropsRequireSimulatorReload.ts diff --git a/src/App.tsx b/src/App.tsx index 0df0b2a0..ced890c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import ClassroomsDashboard from './pages/ClassroomsDashboard'; import ClassroomLeaderboard from './pages/ClassroomLeaderboard'; import ClassroomTeacherView from './pages/ClassroomTeacherView'; import ClassroomStudentView from './pages/ClassroomStudentView'; +import CustomChallengeCreator from './pages/CustomChallengeCreator'; import { InterfaceMode } from './types/interfaceModes'; import { Settings } from 'components/constants/Settings'; import User, { AsyncUser } from 'state/State/User'; @@ -226,6 +227,7 @@ class App extends React.Component { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Challenge/ChallengeMenu.tsx b/src/components/Challenge/ChallengeMenu.tsx index c641b47b..6b91f308 100644 --- a/src/components/Challenge/ChallengeMenu.tsx +++ b/src/components/Challenge/ChallengeMenu.tsx @@ -66,6 +66,7 @@ export interface MenuProps extends StyleProps, ThemeProps { onDashboardClick: () => void; onLogoutClick: () => void; onAiClick: () => void; + onEditCustomChallengeClick?: () => void; onEndChallengeClick: () => void; simulatorState: SimulatorState; @@ -230,6 +231,7 @@ class ChallengeMenu extends React.PureComponent { onLogoutClick, onEndChallengeClick, onAiClick, + onEditCustomChallengeClick, simulatorState } = props; @@ -297,6 +299,7 @@ class ChallengeMenu extends React.PureComponent { onAboutClick={onAboutClick} onSettingsClick={onSettingsClick} onAiClick={onAiClick} + onEditCustomChallengeClick={onEditCustomChallengeClick} /> ) : undefined} diff --git a/src/components/Challenge/ExtraMenu.tsx b/src/components/Challenge/ExtraMenu.tsx index 6ccf6f18..97e64b59 100644 --- a/src/components/Challenge/ExtraMenu.tsx +++ b/src/components/Challenge/ExtraMenu.tsx @@ -4,7 +4,7 @@ import { styled } from 'styletron-react'; import { StyleProps } from '../../util/style'; import { FontAwesome } from '../FontAwesome'; import { ThemeProps } from '../constants/theme'; -import { faBook, faCogs, faCommentDots, faQuestion, faSignOutAlt, faRobot, faCircleInfo } from '@fortawesome/free-solid-svg-icons'; +import { faBook, faCogs, faCommentDots, faQuestion, faSignOutAlt, faRobot, faCircleInfo, faSliders } from '@fortawesome/free-solid-svg-icons'; import tr from '@i18n'; @@ -23,6 +23,7 @@ export interface ExtraMenuPublicProps extends StyleProps, ThemeProps { onSettingsClick: (event: React.MouseEvent) => void; onAboutClick: (event: React.MouseEvent) => void; onAiClick: (event: React.MouseEvent) => void; + onEditCustomChallengeClick?: (event: React.MouseEvent) => void; tourRegistry?: TourRegistry; onRetakeTourClick?: () => void; } @@ -109,6 +110,7 @@ class ExtraMenu extends React.PureComponent { onSettingsClick, locale, onAiClick, + onEditCustomChallengeClick, onRetakeTourClick } = props; @@ -117,6 +119,9 @@ class ExtraMenu extends React.PureComponent { const settingsItem_ = ( {LocalizedString.lookup(tr('Settings'), locale)}); const aboutItem_ = ( {LocalizedString.lookup(tr('About'), locale)}); const feedbackItem_ = ( {LocalizedString.lookup(tr('Feedback'), locale)}); + const editCustomChallengeItem_ = onEditCustomChallengeClick + ? ( {LocalizedString.lookup(tr('Edit challenge conditions & items'), locale)}) + : null; const logoutItem_ = ( {LocalizedString.lookup(tr('Logout'), locale)}); const retakeTourItem_ = ( {LocalizedString.lookup(tr(`Retake Tour`), locale)}); const tourContent_ = ( @@ -137,6 +142,7 @@ class ExtraMenu extends React.PureComponent { {feedbackItem_} } + {editCustomChallengeItem_} {retakeTourItem_} {logoutItem_} @@ -153,6 +159,7 @@ class ExtraMenu extends React.PureComponent { {onFeedbackClick && feedbackItem_ } + {editCustomChallengeItem_} {retakeTourItem_} {logoutItem_} diff --git a/src/components/Challenge/GoalList.tsx b/src/components/Challenge/GoalList.tsx index da08a4ad..476a46c3 100644 --- a/src/components/Challenge/GoalList.tsx +++ b/src/components/Challenge/GoalList.tsx @@ -3,10 +3,16 @@ import { styled } from 'styletron-react'; import { faCheck } from '@fortawesome/free-solid-svg-icons'; import LocalizedString from '../../util/LocalizedString'; +import Dict from '../../util/objectOps/Dict'; import { StyleProps } from '../../util/style'; import PredicateCompletion from '../../state/State/ChallengeCompletion/PredicateCompletion'; import { Goal } from '../../state/State/Challenge'; import { FontAwesome } from '../FontAwesome'; +import { + oppositePlayAreaEventId, + parsePlayAreaEventId, +} from '../../util/playAreaSuccessGoals'; +import { isCustomCanPoseChallengeEventId } from '../../util/customChallengeGoals'; const Container = styled('div', {}); @@ -18,9 +24,97 @@ const Item = styled('div', { const Label = styled('div', {}); +/** Base challenge event id from a goal row (strips optional Once latch suffix). */ +function eventIdFromGoal_(goal: Goal): string { + return goal.exprId.replace(/Once$/, ''); +} + +function exprStateTrue_( + exprId: string, + predicateCompletion: PredicateCompletion | undefined, + type: 'success' | 'failure' +): boolean { + if (!predicateCompletion) return false; + if (predicateCompletion.exprStates[exprId] === true) return true; + if (type === 'failure' && !exprId.endsWith('Once')) { + return predicateCompletion.exprStates[`${exprId}Once`] === true; + } + return false; +} + +function goalEventStateTrue_(goal: Goal, eventStates: Dict | undefined): boolean { + if (!eventStates) return false; + const baseId = eventIdFromGoal_(goal); + if (eventStates[baseId] === true) return true; + if (eventStates[goal.exprId] === true) return true; + return false; +} + +function goalHighlighted_( + goal: Goal, + predicateCompletion: PredicateCompletion | undefined, + eventStates: Dict | undefined, + type: 'success' | 'failure' +): boolean { + const baseEventId = eventIdFromGoal_(goal); + if (isCustomCanPoseChallengeEventId(baseEventId) && eventStates) { + if (goalEventStateTrue_(goal, eventStates)) return true; + if (exprStateTrue_(goal.exprId, predicateCompletion, type)) return true; + return false; + } + + const playAreaOnce = + !!parsePlayAreaEventId(baseEventId) && goal.exprId.endsWith('Once'); + if (playAreaOnce) { + if (exprStateTrue_(goal.exprId, predicateCompletion, type)) return true; + if (type === 'success' && goalEventStateTrue_(goal, eventStates)) return true; + return false; + } + if (exprStateTrue_(goal.exprId, predicateCompletion, type)) return true; + if (type === 'success' && goalEventStateTrue_(goal, eventStates)) return true; + if (type === 'failure' && goalEventStateTrue_(goal, eventStates)) return true; + return false; +} + +function oppositePlayAreaExprId_(goal: Goal): string | null { + const eventId = goal.exprId.replace(/Once$/, ''); + const oppositeEventId = oppositePlayAreaEventId(eventId); + if (!oppositeEventId) return null; + return goal.exprId.endsWith('Once') ? `${oppositeEventId}Once` : oppositeEventId; +} + +/** Play-area enter/leave (and item in/out) pairs share one zone — show at most one highlight. */ +function playAreaGoalHighlighted_( + goal: Goal, + predicateCompletion: PredicateCompletion | undefined, + eventStates: Dict | undefined, + otherPredicateCompletion: PredicateCompletion | undefined, + type: 'success' | 'failure' +): boolean { + if (!goalHighlighted_(goal, predicateCompletion, eventStates, type)) return false; + if (!parsePlayAreaEventId(goal.exprId)) return true; + + const oppositeExprId = oppositePlayAreaExprId_(goal); + if (!oppositeExprId) return true; + + const otherType = type === 'success' ? 'failure' : 'success'; + const baseEventId = eventIdFromGoal_(goal); + if (type === 'success' && eventStates?.[baseEventId] === true) { + return true; + } + if (type === 'success' && exprStateTrue_(oppositeExprId, otherPredicateCompletion, otherType)) { + return false; + } + return true; +} + export interface GoalListProps extends StyleProps { goals: Goal[]; predicateCompletion?: PredicateCompletion; + /** Live challenge event flags (used when predicate exprStates lag scene scripts). */ + eventStates?: Dict; + /** Opposite predicate completion (success ↔ failure) for play-area mutual exclusion. */ + otherPredicateCompletion?: PredicateCompletion; locale: LocalizedString.Language; /** * success goals are highlighted green when true; @@ -32,6 +126,8 @@ export interface GoalListProps extends StyleProps { const GoalList: React.FC = ({ goals, predicateCompletion, + eventStates, + otherPredicateCompletion, locale, type, style, @@ -39,12 +135,18 @@ const GoalList: React.FC = ({ }) => ( {goals.map(goal => { - const state = predicateCompletion ? predicateCompletion.exprStates[goal.exprId] : undefined; + const highlighted = playAreaGoalHighlighted_( + goal, + predicateCompletion, + eventStates, + otherPredicateCompletion, + type + ); const color = type === 'success' ? 'green' : 'red'; return ( - {state === true && } - diff --git a/src/components/Challenge/SimMenu.tsx b/src/components/Challenge/SimMenu.tsx index e46f5aed..97cbedd6 100644 --- a/src/components/Challenge/SimMenu.tsx +++ b/src/components/Challenge/SimMenu.tsx @@ -98,6 +98,7 @@ export interface MenuPublicProps extends StyleProps, ThemeProps { onAboutClick: () => void; onDocumentationClick: () => void; onAiClick: (event: React.MouseEvent) => void; + onEditCustomChallengeClick?: (event: React.MouseEvent) => void; onDashboardClick: () => void; onLogoutClick: () => void; @@ -345,6 +346,7 @@ class SimMenu extends React.PureComponent { onStartChallengeClick, onDeleteSceneClick, onAiClick, + onEditCustomChallengeClick, simulatorState, onRetakeTourClick, locale, @@ -475,6 +477,7 @@ class SimMenu extends React.PureComponent { onFeedbackClick={onFeedbackClick} onSettingsClick={onSettingsClick} onAiClick={onAiClick} + onEditCustomChallengeClick={onEditCustomChallengeClick} tourRegistry={this.props.tourRegistry} onRetakeTourClick={onRetakeTourClick} /> diff --git a/src/components/Challenge/index.tsx b/src/components/Challenge/index.tsx index 50c0693e..63bdcb57 100644 --- a/src/components/Challenge/index.tsx +++ b/src/components/Challenge/index.tsx @@ -13,9 +13,11 @@ import { connect } from 'react-redux'; import { State as ReduxState } from '../../state'; import Async from '../../state/State/Async'; +import Dict from '../../util/objectOps/Dict'; import LocalizedString from '../../util/LocalizedString'; import { AsyncChallenge } from '../../state/State/Challenge'; import { AsyncChallengeCompletion } from '../../state/State/ChallengeCompletion'; +import PredicateCompletion from '../../state/State/ChallengeCompletion/PredicateCompletion'; import PredicateEditor from './PredicateEditor'; import GoalList from './GoalList'; @@ -24,6 +26,10 @@ import tr from '@i18n'; export interface ChallengePublicProps extends StyleProps, ThemeProps { challenge: AsyncChallenge; challengeCompletion: AsyncChallengeCompletion; + /** Scene event flags updated synchronously from the sim loop (see ChallengeRoot). */ + liveEventStates?: Dict; + liveSuccessCompletion?: PredicateCompletion; + liveFailureCompletion?: PredicateCompletion; } interface ChallengePrivateProps { @@ -84,7 +90,7 @@ const SectionIcon = styled(FontAwesome, (props: ThemeProps) => ({ transition: 'opacity 0.2s' })); -class Challenge extends React.PureComponent { +class Challenge extends React.Component { constructor(props: Props) { super(props); @@ -107,7 +113,17 @@ class Challenge extends React.PureComponent { render() { const { props, state } = this; - const { style, className, theme, challenge, challengeCompletion, locale } = props; + const { + style, + className, + theme, + challenge, + challengeCompletion, + liveEventStates, + liveSuccessCompletion, + liveFailureCompletion, + locale, + } = props; const { collapsed, modal } = state; @@ -116,6 +132,15 @@ class Challenge extends React.PureComponent { const latestChallengeCompletion = Async.latestValue(challengeCompletion); + const goalEventStates: Dict = { + ...(latestChallengeCompletion?.eventStates ?? {}), + ...(liveEventStates ?? {}), + }; + + const successCompletion = + liveSuccessCompletion ?? latestChallengeCompletion?.success; + const failureCompletion = + liveFailureCompletion ?? latestChallengeCompletion?.failure; return ( <> @@ -125,7 +150,9 @@ class Challenge extends React.PureComponent {
@@ -142,7 +169,9 @@ class Challenge extends React.PureComponent {
diff --git a/src/components/Classrooms/ChallengeTabView.tsx b/src/components/Classrooms/ChallengeTabView.tsx index b39c95d7..95b219ff 100644 --- a/src/components/Classrooms/ChallengeTabView.tsx +++ b/src/components/Classrooms/ChallengeTabView.tsx @@ -88,7 +88,7 @@ type Props = ChallengeTabViewPublicProps & ChallengeTabViewPrivateProps & WithNa type State = ChallengeTabViewState; -const SidePanel = styled('div', (props: ThemeProps) => ({ +const SidePanel = styled('div', (props: ThemeProps & { $teacherView?: boolean }) => ({ display: 'flex', flexDirection: 'column', flexWrap: 'wrap', @@ -99,16 +99,32 @@ const SidePanel = styled('div', (props: ThemeProps) => ({ alignContent: 'center', width: '100%', gap: '10px', + ...(props.$teacherView ? { + flex: 1, + minHeight: 0, + minWidth: 0, + maxWidth: '100%', + overflow: 'hidden', + flexWrap: 'nowrap', + boxSizing: 'border-box', + } : {}), })); -const ChallengeViewContainer = styled('div', (props: ThemeProps) => ({ +const ChallengeViewContainer = styled('div', (props: ThemeProps & { $teacherView?: boolean }) => ({ left: '4%', height: '100%', width: '95%', margin: '1em', zIndex: 23, - backgroundColor: props.theme.backgroundColor + backgroundColor: props.theme.backgroundColor, + ...(props.$teacherView ? { + maxWidth: '100%', + minWidth: 0, + overflow: 'hidden', + boxSizing: 'border-box', + } : {}), })); + const SectionName = styled('span', (props: ThemeProps & SectionProps & { selected: boolean }) => ({ ':hover': { cursor: 'pointer', @@ -123,19 +139,47 @@ const SectionName = styled('span', (props: ThemeProps & SectionProps & { selecte userSelect: 'none', })); -const SectionsColumn = styled('div', (props: ThemeProps) => ({ +const SectionTabsRow = styled('div', { + display: 'flex', + flexDirection: 'row', + width: '100%', + flexShrink: 0, +}); + +const SectionNameTab = styled(SectionName, { + width: 'auto', + flex: 1, +}); + +const SectionsColumn = styled('div', (props: ThemeProps & { $teacherView?: boolean }) => ({ display: 'flex', flexDirection: 'column', alignItems: 'center', flexGrow: 1, border: `3px solid ${props.theme.borderColor}`, - // minHeight: '100%', height: '95%', paddingBottom: '3em', backgroundColor: props.theme.backgroundColor, - zIndex: '1' + zIndex: '1', + ...(props.$teacherView ? { + width: '100%', + maxWidth: '100%', + minWidth: 0, + overflow: 'hidden', + boxSizing: 'border-box', + } : {}), })); +/** Keeps the leaderboard at the section width so the table scrolls inside a fixed panel. */ +const TeacherLeaderboardSlot = styled('div', { + width: '100%', + maxWidth: '100%', + alignSelf: 'stretch', + minWidth: 0, + overflow: 'hidden', + boxSizing: 'border-box', +}); + export const IVYGATE_LANGUAGE_MAPPING: Dict = { 'ecmascript': 'javascript', 'python': 'customPython', @@ -198,28 +242,36 @@ class ChallengeTabView extends React.Component { const { props, state } = this; const { style, locale, theme, tourRegistry, view, currentSelectedClassroom } = props; const { selectedSection } = state; + const isTeacherView = view === 'teacherView'; const DefaultJBCChallengeSection = () => { const { currentStudentDisplayName } = this.state; const { theme, currentStudentClassroom, tourRegistry } = this.props; + const leaderboard = ( + + ); + + const leaderboardContent = isTeacherView + ? {leaderboard} + : leaderboard; const content = tourRegistry ? ( - - - + + + {leaderboardContent} ) - : ( - + : ( + {leaderboardContent} ); return (content); }; @@ -244,21 +296,23 @@ class ChallengeTabView extends React.Component { const tourContent_ = ( - + - this.onSectionSelect_("Default JBC Challenges")}> - {LocalizedString.lookup(tr('Default JBC Challenges'), locale)} - + + this.onSectionSelect_("Default JBC Challenges")}> + {LocalizedString.lookup(tr('Default JBC Challenges'), locale)} + - this.onSectionSelect_("Limited Challenges")} - > - - {LocalizedString.lookup(tr('Limited Challenges'), locale)} - - + this.onSectionSelect_("Limited Challenges")} + > + + {LocalizedString.lookup(tr('Limited Challenges'), locale)} + + + {selectedSection === 'Default JBC Challenges' && DefaultJBCChallengeSection()} {selectedSection === 'Limited Challenges' && LimitedChallengesSection()} @@ -268,20 +322,21 @@ class ChallengeTabView extends React.Component { ); const normalContent_ = ( - - - this.onSectionSelect_("Default JBC Challenges")}> - {LocalizedString.lookup(tr('Default JBC Challenges'), locale)} - + - this.onSectionSelect_("Limited Challenges")} - > - {LocalizedString.lookup(tr('Limited Challenges'), locale)} + + this.onSectionSelect_("Default JBC Challenges")}> + {LocalizedString.lookup(tr('Default JBC Challenges'), locale)} + - + this.onSectionSelect_("Limited Challenges")} + > + {LocalizedString.lookup(tr('Limited Challenges'), locale)} + + {selectedSection === 'Default JBC Challenges' && DefaultJBCChallengeSection()} {selectedSection === 'Limited Challenges' && LimitedChallengesSection()} @@ -289,7 +344,7 @@ class ChallengeTabView extends React.Component { ); return ( - + {tourRegistry ? tourContent_ : normalContent_} diff --git a/src/components/CustomChallenges/ChallengeGoalsPreview.tsx b/src/components/CustomChallenges/ChallengeGoalsPreview.tsx new file mode 100644 index 00000000..a4d83cb8 --- /dev/null +++ b/src/components/CustomChallenges/ChallengeGoalsPreview.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { styled } from 'styletron-react'; +import tr from '@i18n'; +import { ThemeProps } from '../constants/theme'; +import Section from '../interface/Section'; +import GoalList from '../Challenge/GoalList'; +import LocalizedString from '../../util/LocalizedString'; +import { Goal } from '../../state/State/Challenge'; + +const Container = styled('div', (props: ThemeProps) => ({ + display: 'flex', + flexDirection: 'column', + color: props.theme.color, + borderBottom: `1px solid ${props.theme.borderColor}`, + marginBottom: `${props.theme.itemPadding}px`, +})); + +const EmptyHint = styled('p', (props: ThemeProps) => ({ + margin: `0 ${props.theme.itemPadding * 2}px ${props.theme.itemPadding * 2}px`, + opacity: 0.85, + fontSize: '0.88em', + lineHeight: 1.45, +})); + +export interface ChallengeGoalsPreviewProps extends ThemeProps { + locale: LocalizedString.Language; + successGoals: Goal[]; + failureGoals: Goal[]; +} + +/** Same Success / Failure layout as the Challenge tab when starting a challenge. */ +const ChallengeGoalsPreview: React.FC = ({ + theme, + locale, + successGoals, + failureGoals, +}) => { + const hasSuccess = successGoals.length > 0; + const hasFailure = failureGoals.length > 0; + + if (!hasSuccess && !hasFailure) { + return ( + + {LocalizedString.lookup( + tr('No goals yet. Go back to add success rules.'), + locale + )} + + ); + } + + return ( + + {hasSuccess && ( +
+ +
+ )} + {hasFailure && ( +
+ +
+ )} +
+ ); +}; + +export default ChallengeGoalsPreview; diff --git a/src/components/CustomChallenges/ChallengeItemSuggestions.tsx b/src/components/CustomChallenges/ChallengeItemSuggestions.tsx new file mode 100644 index 00000000..e250d995 --- /dev/null +++ b/src/components/CustomChallenges/ChallengeItemSuggestions.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import { styled } from 'styletron-react'; +import { ThemeProps } from '../constants/theme'; +import LocalizedString from '../../util/LocalizedString'; +import tr from '@i18n'; +import { + ChallengeSuggestion, + PlayZoneSelectionSummary, + successGoalsForSuggestionKeys, +} from '../../util/jbcChallengeSuggestions'; +import { JbcCatalogSuccessGoal } from '../../util/jbcChallengeCatalog'; + +const Section = styled('div', (props: ThemeProps) => ({ + padding: `0 ${props.theme.itemPadding * 2}px ${props.theme.itemPadding * 2}px`, +})); + +const SectionTitle = styled('h4', (props: ThemeProps) => ({ + margin: `${props.theme.itemPadding * 2}px ${props.theme.itemPadding * 2}px ${props.theme.itemPadding}px`, + fontSize: '0.95em', +})); + +const SummaryCard = styled('div', (props: ThemeProps) => ({ + margin: `0 ${props.theme.itemPadding * 2}px ${props.theme.itemPadding * 2}px`, + padding: `${props.theme.itemPadding * 1.5}px`, + borderRadius: '4px', + border: `1px solid ${props.theme.borderColor}`, + backgroundColor: 'rgba(255,255,255,0.04)', + fontSize: '0.9em', + lineHeight: 1.45, +})); + +const ZoneRow = styled('div', { + marginTop: '8px', +}); + +const SuggestionCard = styled('div', (props: ThemeProps) => ({ + marginBottom: `${props.theme.itemPadding * 1.5}px`, + padding: `${props.theme.itemPadding * 1.5}px`, + borderRadius: '4px', + border: `1px solid ${props.theme.borderColor}`, + backgroundColor: 'rgba(255,255,255,0.03)', +})); + +const SuggestionTitle = styled('div', { + fontWeight: 600, + marginBottom: '6px', +}); + +const ScriptTip = styled('pre', (props: ThemeProps) => ({ + marginTop: `${props.theme.itemPadding}px`, + padding: `${props.theme.itemPadding}px`, + fontSize: '0.78em', + lineHeight: 1.35, + overflowX: 'auto', + borderRadius: '4px', + backgroundColor: 'rgba(0,0,0,0.35)', + border: `1px solid ${props.theme.borderColor}`, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', +})); + +const ActionButton = styled('button', (props: ThemeProps) => ({ + marginTop: `${props.theme.itemPadding}px`, + padding: '6px 10px', + borderRadius: '4px', + border: `1px solid ${props.theme.borderColor}`, + backgroundColor: 'rgba(76, 175, 80, 0.2)', + color: props.theme.color, + cursor: 'pointer', + fontSize: '0.85em', + fontWeight: 600, +})); + +export interface ChallengeItemSuggestionsProps extends ThemeProps { + locale: LocalizedString.Language; + summary: PlayZoneSelectionSummary; + suggestions: ChallengeSuggestion[]; + onAddSuccessGoals?: (goals: JbcCatalogSuccessGoal[]) => void; + showAddRules?: boolean; +} + +const ChallengeItemSuggestions: React.FC = ({ + theme, + locale, + summary, + suggestions, + onAddSuccessGoals, + showAddRules = true, +}) => { + const hasMatItems = + summary.matItemLabels.length > 0 || summary.matGeometryLabels.length > 0; + const hasZones = summary.zoneCount > 0; + + return ( + <> + + {LocalizedString.lookup(tr('What you placed'), locale)} + + + {hasMatItems ? ( + <> + {summary.matItemLabels.length > 0 && ( +
+ {LocalizedString.lookup(tr('Items on the mat'), locale)}:{' '} + {summary.matItemLabels.join(', ')} +
+ )} + {summary.matGeometryLabels.length > 0 && ( +
0 ? 8 : 0 }}> + + {LocalizedString.lookup(tr('Script geometries'), locale)} + + : {summary.matGeometryLabels.join(', ')} +
+ )} + + ) : ( + + {LocalizedString.lookup( + tr('No items on the mat yet. Go back to add World objects and script geometries.'), + locale + )} + + )} + {hasZones && ( +
+ {LocalizedString.lookup(tr('Play areas'), locale)}:{' '} + {summary.zones.map(z => z.name).join(', ')} + {LocalizedString.lookup( + tr(' (success rules for areas are set separately on the previous step)'), + locale + )} +
+ )} +
+ + + {LocalizedString.lookup(tr('Ideas for your challenge'), locale)} + +
+ {suggestions.map(suggestion => ( + + {suggestion.title} +

{suggestion.description}

+ {suggestion.scriptTip && ( + {suggestion.scriptTip} + )} + {showAddRules && + onAddSuccessGoals && + suggestion.relatedSuccessGoalKeys && + suggestion.relatedSuccessGoalKeys.length > 0 && ( + { + onAddSuccessGoals( + successGoalsForSuggestionKeys(suggestion.relatedSuccessGoalKeys) + ); + }} + > + {LocalizedString.lookup(tr('Add matching JBC success rules'), locale)} + + )} +
+ ))} +
+ + ); +}; + +export default ChallengeItemSuggestions; diff --git a/src/components/CustomChallenges/ConditionGoalsEditor.tsx b/src/components/CustomChallenges/ConditionGoalsEditor.tsx new file mode 100644 index 00000000..baef5cea --- /dev/null +++ b/src/components/CustomChallenges/ConditionGoalsEditor.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { styled } from 'styletron-react'; +import { ThemeProps } from '../constants/theme'; +import Dict from '../../util/objectOps/Dict'; +import Event from '../../state/State/Challenge/Event'; +import LocalizedString from '../../util/LocalizedString'; +import Input from '../interface/Input'; +import Field from '../interface/Field'; +import { FontAwesome } from '../FontAwesome'; +import { faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import tr from '@i18n'; +import { ConditionGoalInput } from '../../util/customChallengePredicates'; + +export interface ConditionGoalsEditorProps extends ThemeProps { + locale: LocalizedString.Language; + title: string; + helpText: string; + events: Dict; + goals: ConditionGoalInput[]; + onChange: (goals: ConditionGoalInput[]) => void; +} + +const Row = styled('div', (props: ThemeProps) => ({ + display: 'flex', + flexDirection: 'row', + gap: `${props.theme.itemPadding}px`, + alignItems: 'flex-end', + padding: `${props.theme.itemPadding}px 0`, + borderBottom: `1px solid ${props.theme.borderColor}`, +})); + +const IconButton = styled('button', (props: ThemeProps) => ({ + background: 'transparent', + border: 'none', + color: props.theme.color, + cursor: 'pointer', + padding: `${props.theme.itemPadding}px`, +})); + +const ConditionGoalsEditor: React.FC = ({ + theme, + locale, + title, + helpText, + events, + goals, + onChange, +}) => { + const eventIds = Object.keys(events); + + const addGoal = () => { + const eventId = eventIds[0]; + if (!eventId) return; + const event = events[eventId]; + onChange([ + ...goals, + { + eventId, + label: LocalizedString.lookup(event.name, LocalizedString.EN_US), + latchOnce: true, + }, + ]); + }; + + const updateGoal = (index: number, patch: Partial) => { + const next = [...goals]; + next[index] = { ...next[index], ...patch }; + if (patch.eventId && events[patch.eventId]) { + const current = next[index]; + if (!current.label || goals[index].eventId !== patch.eventId) { + current.label = LocalizedString.lookup( + events[patch.eventId].name, + LocalizedString.EN_US + ); + } + } + onChange(next); + }; + + const removeGoal = (index: number) => { + onChange(goals.filter((_, i) => i !== index)); + }; + + return ( +
+

{title}

+

{helpText}

+ {goals.map((goal, index) => ( + + + + + + ) => + updateGoal(index, { label: e.currentTarget.value }) + } + /> + + + removeGoal(index)}> + + + + ))} + + {LocalizedString.lookup(tr('Add condition'), locale)} + +
+ ); +}; + +export default ConditionGoalsEditor; diff --git a/src/components/CustomChallenges/CustomChallengeEditor.tsx b/src/components/CustomChallenges/CustomChallengeEditor.tsx new file mode 100644 index 00000000..cf6c16b3 --- /dev/null +++ b/src/components/CustomChallenges/CustomChallengeEditor.tsx @@ -0,0 +1,327 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; +import { styled } from 'styletron-react'; +import { ThemeProps } from '../constants/theme'; +import { State as ReduxState } from '../../state'; +import Async from '../../state/State/Async'; +import Challenge from '../../state/State/Challenge'; +import LocalizedString from '../../util/LocalizedString'; +import Input from '../interface/Input'; +import TextArea from '../interface/TextArea'; +import Field from '../interface/Field'; +import Section from '../interface/Section'; +import ScrollArea from '../interface/ScrollArea'; +import PredicateEditor from '../Challenge/PredicateEditor'; +import tr from '@i18n'; +import { ChallengeCompletionsAction, ChallengesAction, ScenesAction } from '../../state/reducer'; +import { isManagedCustomChallengeEventId } from '../../util/customChallengeGoals'; +import EventListEditor from './EventListEditor'; +import ConditionGoalsEditor from './ConditionGoalsEditor'; +import { + buildFailureGoals, + buildFailurePredicate, + buildSuccessGoals, + buildSuccessPredicate, + conditionGoalsFromChallenge, + ConditionGoalInput, +} from '../../util/customChallengePredicates'; +import Author from '../../db/Author'; +import { auth } from '../../firebase/firebase'; +import { withNavigate, WithNavigateProps } from '../../util/withNavigate'; + +export interface CustomChallengeEditorPublicProps extends ThemeProps { + challengeId: string; +} + +interface CustomChallengeEditorPrivateProps { + locale: LocalizedString.Language; + challenge: Challenge | null; + challengeAsyncType: Async.Type | undefined; +} + +type Props = CustomChallengeEditorPublicProps & +CustomChallengeEditorPrivateProps & +WithNavigateProps & { + dispatch: (action: unknown) => void; +}; + +const Container = styled('div', (props: ThemeProps) => ({ + display: 'flex', + flexDirection: 'column', + flex: '1 1', + minHeight: 0, + color: props.theme.color, +})); + +const Actions = styled('div', (props: ThemeProps) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: `${props.theme.itemPadding * 2}px`, + padding: `${props.theme.itemPadding * 2}px`, + borderBottom: `1px solid ${props.theme.borderColor}`, +})); + +const ActionButton = styled('button', (props: ThemeProps) => ({ + padding: '10px 16px', + backgroundColor: '#2196f3', + color: '#fff', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontWeight: 'bold', + ':disabled': { + opacity: 0.5, + cursor: 'not-allowed', + }, +})); + +const PredicatePreview = styled('div', (props: ThemeProps) => ({ + padding: `${props.theme.itemPadding * 2}px`, + border: `1px solid ${props.theme.borderColor}`, + borderRadius: '4px', + marginTop: `${props.theme.itemPadding}px`, +})); + +class CustomChallengeEditor extends React.PureComponent { + private successGoals_: ConditionGoalInput[] = []; + private failureGoals_: ConditionGoalInput[] = []; + + componentDidMount(): void { + this.syncGoalsFromChallenge_(); + const { challengeId, dispatch } = this.props; + dispatch(ChallengesAction.loadChallenge({ challengeId })); + dispatch(ScenesAction.loadScene({ sceneId: challengeId })); + } + + componentDidUpdate(prevProps: Readonly): void { + if (prevProps.challenge !== this.props.challenge && this.props.challenge) { + this.syncGoalsFromChallenge_(); + } + } + + private syncGoalsFromChallenge_ = () => { + const challenge = this.props.challenge; + if (!challenge) return; + this.successGoals_ = conditionGoalsFromChallenge(challenge.success, challenge.successGoals); + this.failureGoals_ = conditionGoalsFromChallenge(challenge.failure, challenge.failureGoals); + this.forceUpdate(); + }; + + private applyConditions_ = () => { + const { challenge, challengeId, dispatch } = this.props; + const success = buildSuccessPredicate(this.successGoals_); + const failure = buildFailurePredicate(this.failureGoals_); + dispatch( + ChallengesAction.applyChallengeConditions({ + challengeId, + success, + failure, + successGoals: success ? buildSuccessGoals(this.successGoals_) : undefined, + failureGoals: failure ? buildFailureGoals(this.failureGoals_) : undefined, + }) + ); + + if (!challenge) return; + const referencedEventIds = new Set([ + ...this.successGoals_.map(goal => goal.eventId), + ...this.failureGoals_.map(goal => goal.eventId), + ]); + for (const eventId of Object.keys(challenge.events)) { + if (referencedEventIds.has(eventId) || !isManagedCustomChallengeEventId(eventId)) { + continue; + } + dispatch(ChallengesAction.removeEvent({ challengeId, eventId })); + dispatch(ChallengeCompletionsAction.removeEventState({ challengeId, eventId })); + } + }; + + private syncEvents_ = (events: Challenge['events']) => { + const challenge = this.props.challenge; + if (!challenge) return; + const challengeId = this.props.challengeId; + for (const eventId of Object.keys(challenge.events)) { + if (!(eventId in events)) { + this.props.dispatch(ChallengesAction.removeEvent({ challengeId, eventId })); + } + } + for (const [eventId, event] of Object.entries(events)) { + this.props.dispatch(ChallengesAction.setEvent({ challengeId, eventId, event })); + } + }; + + private onSave_ = () => { + this.props.dispatch(ChallengesAction.saveChallenge({ challengeId: this.props.challengeId })); + this.props.dispatch(ScenesAction.saveScene({ sceneId: this.props.challengeId })); + }; + + private onEditWorld_ = () => { + this.onSave_(); + this.props.navigate(`/scene/${this.props.challengeId}`); + }; + + private onTest_ = () => { + this.onSave_(); + window.location.href = `/challenge/${this.props.challengeId}`; + }; + + render() { + const { theme, locale, challenge, challengeAsyncType } = this.props; + + if (!challenge) { + return ( + +

+ {challengeAsyncType === Async.Type.Loading + ? LocalizedString.lookup(tr('Loading challenge...'), locale) + : LocalizedString.lookup(tr('Challenge not found.'), locale)} +

+
+ ); + } + + const uid = auth.currentUser?.uid; + const isOwner = + challenge.author.type === Author.Type.User && challenge.author.id === uid; + + return ( + + + + {LocalizedString.lookup(tr('Save'), locale)} + + + {LocalizedString.lookup(tr('Edit world'), locale)} + + + {LocalizedString.lookup(tr('Test challenge'), locale)} + + + +
+
+ + ) => + this.props.dispatch( + ChallengesAction.setName({ + challengeId: this.props.challengeId, + name: { + ...challenge.name, + [LocalizedString.EN_US]: e.currentTarget.value, + }, + }) + ) + } + /> + + +