diff --git a/src/features/game/action/GameActionConditionChecker.ts b/src/features/game/action/GameActionConditionChecker.ts index f76f1e4a0f..a112a63202 100644 --- a/src/features/game/action/GameActionConditionChecker.ts +++ b/src/features/game/action/GameActionConditionChecker.ts @@ -42,6 +42,16 @@ export default class ActionConditionChecker { return GameGlobalAPI.getInstance().isObjectiveComplete(conditionParams.id) === boolean; case GameStateStorage.TasklistState: return GameGlobalAPI.getInstance().isTaskComplete(conditionParams.id) === boolean; + case GameStateStorage.AttemptedQuizState: + return GameGlobalAPI.getInstance().isQuizAttempted(conditionParams.id) === boolean; + case GameStateStorage.PassedQuizState: + return GameGlobalAPI.getInstance().isQuizComplete(conditionParams.id) === boolean; + case GameStateStorage.QuizScoreState: + return ( + GameGlobalAPI.getInstance().getQuizScore(conditionParams.id) >= + parseInt(conditionParams.score) === + boolean + ); default: return true; } diff --git a/src/features/game/action/GameActionExecuter.ts b/src/features/game/action/GameActionExecuter.ts index 717b5645ad..e964b1db26 100644 --- a/src/features/game/action/GameActionExecuter.ts +++ b/src/features/game/action/GameActionExecuter.ts @@ -102,6 +102,11 @@ export default class GameActionExecuter { case GameActionType.Delay: await sleep(actionParams.duration); return; + case GameActionType.ShowQuiz: + globalAPI.enableKeyboardInput(false); + await globalAPI.showQuiz(actionParams.id); + globalAPI.enableKeyboardInput(true); + return; default: return; } @@ -141,6 +146,7 @@ export default class GameActionExecuter { case GameActionType.PlaySFX: case GameActionType.ShowObjectLayer: case GameActionType.Delay: + case GameActionType.ShowQuiz: return false; } } diff --git a/src/features/game/action/GameActionTypes.ts b/src/features/game/action/GameActionTypes.ts index fcc2d9d0f3..7b282c3559 100644 --- a/src/features/game/action/GameActionTypes.ts +++ b/src/features/game/action/GameActionTypes.ts @@ -25,7 +25,8 @@ export enum GameActionType { ShowObjectLayer = 'ShowObjectLayer', NavigateToAssessment = 'NavigateToAssessment', UpdateAssessmentStatus = 'UpdateAssessmentStatus', - Delay = 'Delay' + Delay = 'Delay', + ShowQuiz = 'ShowQuiz' } /** diff --git a/src/features/game/dialogue/GameDialogueManager.ts b/src/features/game/dialogue/GameDialogueManager.ts index e6283e4bc6..8d6830eaf5 100644 --- a/src/features/game/dialogue/GameDialogueManager.ts +++ b/src/features/game/dialogue/GameDialogueManager.ts @@ -66,11 +66,12 @@ export default class DialogueManager { }); } - private async showNextLine(resolve: () => void) { + public async showNextLine(resolve: () => void) { GameGlobalAPI.getInstance().playSound(SoundAssets.dialogueAdvance.key); const { line, speakerDetail, actionIds, prompt } = await this.getDialogueGenerator().generateNextLine(); - const lineWithName = line.replace('{name}', this.getUsername()); + const lineWithQuizScores = this.makeLineWithQuizScores(line); + const lineWithName = lineWithQuizScores.replace('{name}', this.getUsername()); this.getDialogueRenderer().changeText(lineWithName); this.getSpeakerRenderer().changeSpeakerTo(speakerDetail); @@ -79,6 +80,7 @@ export default class DialogueManager { // Disable interactions while processing actions GameGlobalAPI.getInstance().enableSprite(this.getDialogueRenderer().getDialogueBox(), false); + this.getInputManager().enableKeyboardInput(false); if (prompt) { // disable keyboard input to prevent continue dialogue @@ -94,6 +96,7 @@ export default class DialogueManager { } await GameGlobalAPI.getInstance().processGameActionsInSamePhase(actionIds); GameGlobalAPI.getInstance().enableSprite(this.getDialogueRenderer().getDialogueBox(), true); + this.getInputManager().enableKeyboardInput(true); if (!line) { // clear keyboard listeners when dialogue ends @@ -102,6 +105,38 @@ export default class DialogueManager { } } + /** + * Hide all dialogue boxes, speaker boxes and speaker sprites + * */ + public async hideAll() { + await this.getDialogueRenderer().hide(); + await this.getSpeakerRenderer().hide(); + } + + /** + * Make all dialogue boxes, speaker boxes and speaker sprites visible + * */ + public async showAll() { + await this.getDialogueRenderer().show(); + await this.getSpeakerRenderer().show(); + } + + /** + * Find patterns of quiz score interpolation in a dialogue line, + * and replace them by actual scores. + * The pattern: "{.score}" + * + * @param line + * @returns {string} the given line with all quiz score interpolation replaced by actual scores. + */ + public makeLineWithQuizScores(line: string) { + const quizScores = line.matchAll(/\{(.+?)\.score\}/g); + for (const match of quizScores) { + line = line.replace(match[0], GameGlobalAPI.getInstance().getQuizScore(match[1]).toString()); + } + return line; + } + private getDialogueGenerator = () => this.dialogueGenerator as DialogueGenerator; private getDialogueRenderer = () => this.dialogueRenderer as DialogueRenderer; private getSpeakerRenderer = () => this.speakerRenderer as DialogueSpeakerRenderer; diff --git a/src/features/game/dialogue/GameDialogueRenderer.ts b/src/features/game/dialogue/GameDialogueRenderer.ts index 092296c5cf..45ba3e6f68 100644 --- a/src/features/game/dialogue/GameDialogueRenderer.ts +++ b/src/features/game/dialogue/GameDialogueRenderer.ts @@ -67,6 +67,24 @@ class DialogueRenderer { fadeAndDestroy(gameManager, this.getDialogueContainer()); } + /** + * Hide the dialoguebox + */ + public async hide() { + this.typewriter.container.setVisible(false); + this.dialogueBox.setVisible(false); + this.blinkingDiamond.container.setVisible(false); + } + + /** + * Make the dialoguebox visible + */ + public async show() { + this.typewriter.container.setVisible(true); + this.dialogueBox.setVisible(true); + this.blinkingDiamond.container.setVisible(true); + } + /** * Change the text written in the box */ diff --git a/src/features/game/dialogue/GameDialogueSpeakerRenderer.ts b/src/features/game/dialogue/GameDialogueSpeakerRenderer.ts index 3a58adbb05..385cfcfeae 100644 --- a/src/features/game/dialogue/GameDialogueSpeakerRenderer.ts +++ b/src/features/game/dialogue/GameDialogueSpeakerRenderer.ts @@ -16,6 +16,8 @@ import DialogueConstants, { speakerTextStyle } from './GameDialogueConstants'; */ export default class DialogueSpeakerRenderer { private currentSpeakerId?: string; + private speakerSprite?: Phaser.GameObjects.Image; + private speakerSpriteBox?: Phaser.GameObjects.Container; /** * Changes the speaker shown in the speaker box and the speaker rendered on screen @@ -63,6 +65,7 @@ export default class DialogueSpeakerRenderer { expression, speakerPosition ); + this.speakerSprite = speakerSprite; GameGlobalAPI.getInstance().addToLayer(Layer.Speaker, speakerSprite); } @@ -90,8 +93,27 @@ export default class DialogueSpeakerRenderer { container.add([rectangle, speakerText]); speakerText.text = StringUtils.capitalize(text); + this.speakerSpriteBox = container; return container; } + /** + * Hide the speaker box and sprite + */ + public async hide() { + this.getSpeakerSprite().setVisible(false); + this.getSpeakerSpriteBox().setVisible(false); + } + + /** + * Show the hidden speaker box and sprite + */ + public async show() { + this.getSpeakerSprite().setVisible(true); + this.getSpeakerSpriteBox().setVisible(true); + } + public getUsername = () => SourceAcademyGame.getInstance().getAccountInfo().name; + private getSpeakerSprite = () => this.speakerSprite as Phaser.GameObjects.Image; + private getSpeakerSpriteBox = () => this.speakerSpriteBox as Phaser.GameObjects.Container; } diff --git a/src/features/game/layer/GameLayerTypes.ts b/src/features/game/layer/GameLayerTypes.ts index 112c9ee0a5..55a7139cdc 100644 --- a/src/features/game/layer/GameLayerTypes.ts +++ b/src/features/game/layer/GameLayerTypes.ts @@ -12,7 +12,9 @@ export enum Layer { Escape, Selector, Dashboard, - WorkerMessage + WorkerMessage, + QuizSpeakerBox, + QuizSpeaker } // Back to Front @@ -23,9 +25,11 @@ export const defaultLayerSequence = [ Layer.BBox, Layer.Character, Layer.Speaker, + Layer.QuizSpeaker, Layer.PopUp, Layer.Dialogue, Layer.SpeakerBox, + Layer.QuizSpeakerBox, Layer.Effects, Layer.Dashboard, Layer.Escape, diff --git a/src/features/game/location/GameMap.ts b/src/features/game/location/GameMap.ts index 412fd1fbbb..b7d68cc94c 100644 --- a/src/features/game/location/GameMap.ts +++ b/src/features/game/location/GameMap.ts @@ -6,6 +6,7 @@ import { AssetKey, ItemId } from '../commons/CommonTypes'; import { Dialogue } from '../dialogue/GameDialogueTypes'; import { GameMode } from '../mode/GameModeTypes'; import { ObjectProperty } from '../objects/GameObjectTypes'; +import { Quiz } from '../quiz/GameQuizType'; import { mandatory } from '../utils/GameUtils'; import { AnyId, GameItemType, GameLocation, LocationId } from './GameMapTypes'; @@ -36,6 +37,7 @@ class GameMap { private actions: Map; private gameStartActions: ItemId[]; private checkpointCompleteActions: ItemId[]; + private quizzes: Map; constructor() { this.soundAssets = []; @@ -47,6 +49,7 @@ class GameMap { this.boundingBoxes = new Map(); this.characters = new Map(); this.actions = new Map(); + this.quizzes = new Map(); this.gameStartActions = []; this.checkpointCompleteActions = []; @@ -120,6 +123,10 @@ class GameMap { return this.actions; } + public getQuizMap(): Map { + return this.quizzes; + } + public getSoundAssets(): SoundAsset[] { return this.soundAssets; } diff --git a/src/features/game/location/GameMapTypes.ts b/src/features/game/location/GameMapTypes.ts index 21a36fd1b6..70be24f38d 100644 --- a/src/features/game/location/GameMapTypes.ts +++ b/src/features/game/location/GameMapTypes.ts @@ -48,5 +48,6 @@ export enum GameItemType { characters = 'characters', actions = 'actions', bgmKey = 'bgmKey', - collectibles = 'collectibles' + collectibles = 'collectibles', + quizzes = 'quizzes' } diff --git a/src/features/game/parser/ActionParser.ts b/src/features/game/parser/ActionParser.ts index 9cc216e21b..e233ba02f3 100644 --- a/src/features/game/parser/ActionParser.ts +++ b/src/features/game/parser/ActionParser.ts @@ -185,6 +185,11 @@ export default class ActionParser { case GameActionType.Delay: actionParamObj.duration = parseInt(actionParams[0]) * 1000; break; + + case GameActionType.ShowQuiz: + actionParamObj.id = actionParams[0]; + Parser.validator.assertItemType(GameItemType.quizzes, actionParams[0], actionType); + break; } const actionId = Parser.generateActionId(); diff --git a/src/features/game/parser/ConditionParser.ts b/src/features/game/parser/ConditionParser.ts index 3529025229..a8b2821497 100644 --- a/src/features/game/parser/ConditionParser.ts +++ b/src/features/game/parser/ConditionParser.ts @@ -1,4 +1,5 @@ import { ActionCondition } from '../action/GameActionTypes'; +import { GameItemType } from '../location/GameMapTypes'; import { GameStateStorage } from '../state/GameStateTypes'; import StringUtils from '../utils/StringUtils'; import Parser from './Parser'; @@ -51,6 +52,41 @@ export default class ConditionParser { }, boolean: !hasExclamation }; + + case GameStateStorage.AttemptedQuizState: + Parser.validator.assertItemType(GameItemType.quizzes, condParams[0]); + return { + state: GameStateStorage.AttemptedQuizState, + conditionParams: { + id: condParams[0] + }, + boolean: !hasExclamation + }; + + case GameStateStorage.PassedQuizState: + Parser.validator.assertItemType(GameItemType.quizzes, condParams[0]); + return { + state: GameStateStorage.PassedQuizState, + conditionParams: { + id: condParams[0] + }, + boolean: !hasExclamation + }; + + case GameStateStorage.QuizScoreState: + Parser.validator.assertItemType(GameItemType.quizzes, condParams[0]); + if (Number.isNaN(parseInt(condParams[1]))) { + throw new Error('Parsing error: quiz score condition requires number as second param'); + } + return { + state: GameStateStorage.QuizScoreState, + conditionParams: { + id: condParams[0], + score: condParams[1] + }, + boolean: !hasExclamation + }; + default: throw new Error('Parsing error: Invalid condition param'); } diff --git a/src/features/game/parser/DialogueParser.ts b/src/features/game/parser/DialogueParser.ts index 14d79ac4d4..e0436aec0a 100644 --- a/src/features/game/parser/DialogueParser.ts +++ b/src/features/game/parser/DialogueParser.ts @@ -1,4 +1,4 @@ -import { Dialogue, DialogueLine, PartName } from '../dialogue/GameDialogueTypes'; +import { Dialogue, DialogueLine, DialogueObject, PartName } from '../dialogue/GameDialogueTypes'; import { GameItemType } from '../location/GameMapTypes'; import { mapValues } from '../utils/GameUtils'; import StringUtils from '../utils/StringUtils'; @@ -143,6 +143,16 @@ export default class DialogueParser { } return dialogueLines; } + /** + * This function parses a diaglogue written in a quiz as reaction + * and returns a DialogueObject. + * Itis only called by the QuizParser. + * + * @param {Array} dialogueBody the lines inside a dialogue + */ + public static parseQuizReaction(dialogueBody: string[]): DialogueObject { + return this.parseDialogueContent(dialogueBody); + } } const isInteger = (line: string) => new RegExp(/^[0-9]+$/).test(line); diff --git a/src/features/game/parser/Parser.ts b/src/features/game/parser/Parser.ts index 0ee1843a4b..4def0edccd 100644 --- a/src/features/game/parser/Parser.ts +++ b/src/features/game/parser/Parser.ts @@ -8,6 +8,7 @@ import DialoguesParser from './DialogueParser'; import LocationsParser from './LocationDetailsParser'; import LocationParser from './LocationParser'; import ParserValidator, { GameEntityType } from './ParserValidator'; +import QuizParser from './QuizParser'; import TasksParser from './TasksParser'; /** @@ -94,6 +95,9 @@ class Parser { case 'dialogues': DialoguesParser.parse(body); break; + case 'quizzes': + QuizParser.parse(body); + break; default: return false; } diff --git a/src/features/game/parser/ParserConverter.ts b/src/features/game/parser/ParserConverter.ts index 921cdb45ce..3adae84223 100644 --- a/src/features/game/parser/ParserConverter.ts +++ b/src/features/game/parser/ParserConverter.ts @@ -59,13 +59,17 @@ const stringToActionTypeMap = { show_object_layer: GameActionType.ShowObjectLayer, navigate_to_assessment: GameActionType.NavigateToAssessment, update_assessment_status: GameActionType.UpdateAssessmentStatus, - delay: GameActionType.Delay + delay: GameActionType.Delay, + show_quiz: GameActionType.ShowQuiz }; const stringToGameStateStorageMap = { checklist: GameStateStorage.ChecklistState, tasklist: GameStateStorage.TasklistState, - userstate: GameStateStorage.UserState + userstate: GameStateStorage.UserState, + attemptedQuiz: GameStateStorage.AttemptedQuizState, + passedQuiz: GameStateStorage.PassedQuizState, + quizScore: GameStateStorage.QuizScoreState }; const stringToUserStateTypeMap = { diff --git a/src/features/game/parser/QuizParser.ts b/src/features/game/parser/QuizParser.ts new file mode 100644 index 0000000000..05db7bb019 --- /dev/null +++ b/src/features/game/parser/QuizParser.ts @@ -0,0 +1,132 @@ +import { GameItemType } from '../location/GameMapTypes'; +import { Option, Question, Quiz } from '../quiz/GameQuizType'; +import StringUtils from '../utils/StringUtils'; +import DialogueParser from './DialogueParser'; +import Parser from './Parser'; +import SpeakerParser from './SpeakerParser'; + +/** + * This class parses quizzes and creates Quiz Objects + * which can be read by the Quiz Manager. + */ +export default class QuizParser { + /** + * This function reads the entire text under the "quizzes" heading, + * converts quizzes listed underneath into Quiz entities, + * and stores these quizzes in the game map. + * + * @param quizText the entire quiz text beneath quizzes heading + */ + public static parse(quizText: string[]) { + const quizParagraphs = StringUtils.splitToParagraph(quizText); + quizParagraphs.forEach(([quizId, quizBody]: [string, string[]]) => { + if (quizBody.length === 0) { + throw new Error('Parsing error: No quiz content found for quizId'); + return; + } + this.parseQuiz(quizId, quizBody); + }); + } + + /** + * This function parses one quiz and stores it into the game map. + * + * @param quizId the string containing quiz Id + * @param quizBody the body of the dialogue containing its questions and options + */ + private static parseQuiz(quizId: string, quizBody: string[]) { + Parser.validator.registerId(quizId); + const rawQuestions: Map = StringUtils.mapByHeader(quizBody, isInteger); + const questions: Question[] = this.parseQuizQuestions(rawQuestions); + const quiz: Quiz = { questions: questions }; + Parser.checkpoint.map.setItemInMap(GameItemType.quizzes, quizId, quiz); + } + + /** + * This function parses the quiz's questions, + * converts each into a Question object, + * and store in an array. + * + * @param map The map of raw questions that map from index to question body + */ + private static parseQuizQuestions(map: Map): Question[] { + const questions: Question[] = new Array(map.size); + map.forEach((value: string[], key: string) => { + questions[parseInt(key)] = this.createQuestion(value); + }); + return questions; + } + + /** + * This function parses a question and + * create a Question object. + * + * @param questionText The text of a question body + * containing question text, correct answer, and options + */ + private static createQuestion(questionText: string[]): Question { + if (questionText.length < 2) { + throw new Error('Parsing error: Quiz missing question or answer'); + } + const question: Question = { + question: questionText[0], + speaker: SpeakerParser.parse('@narrator'), + answer: this.getQuizAnswer(questionText[1]), + options: this.parseOptions(questionText.slice(2)) + }; + return question; + } + + /** + * This function parses a answer string and + * converts it into Number. + * + * @param answer The string containing the correct answer of a question + */ + private static getQuizAnswer(answer: string): Number { + const ans = answer.split(':'); + if (ans.length < 2 || Number.isNaN(parseInt(ans[1]))) { + throw new Error('Parsing error: Invalid answer for Quiz'); + } + return parseInt(ans[1]); + } + + /** + * This function parses options of a question and + * store them in an array. + * + * @param optionsText An Array of string containing all options' content, + * including option text and reactions + */ + private static parseOptions(optionsText: string[]): Option[] { + const optionsParagraph = StringUtils.splitToParagraph(optionsText); + const options: Option[] = Array(optionsParagraph.length); + optionsParagraph.forEach(([header, content]: [string, string[]], index) => { + options[index] = this.createOption(content); + }); + return options; + } + + /** + * This function creates an Option object. + * + * @param content An Array of string containing an option's content, + * including option text and reaction + * @param [noReaction=false] Indicates whether this option provides a reaction + */ + private static createOption(content: string[], noReaction: boolean = false): Option { + if (!content) { + throw new Error('Parsing error: Quiz option not provided'); + } + if (content.length <= 1) { + noReaction = true; + } + const option: Option = { + text: content[0], + reaction: noReaction ? undefined : DialogueParser.parseQuizReaction(content.slice(1)) + }; + return option; + } +} + +const isInteger = (line: string) => new RegExp(/^[0-9]+$/).test(line); diff --git a/src/features/game/quiz/GameQuizConstants.ts b/src/features/game/quiz/GameQuizConstants.ts new file mode 100644 index 0000000000..9f5a34337b --- /dev/null +++ b/src/features/game/quiz/GameQuizConstants.ts @@ -0,0 +1,52 @@ +import FontAssets from '../assets/FontAssets'; +import { screenSize } from '../commons/CommonConstants'; +import { BitmapFontStyle } from '../commons/CommonTypes'; +import { Color } from '../utils/StyleUtils'; + +export const startPrompt = { + text: 'Start the quiz?', + options: ['Yes', 'No'] +}; + +export const questionPrompt = 'What is the correct answer?'; + +export const QuizConstants = { + textPad: 20, + textConfig: { x: 15, y: -15, oriX: 0.5, oriY: 0.5 }, + y: 100, + width: 450, + yInterval: 100, + headerOff: 60, + speakerTextConfig: { x: 320, y: 745, oriX: 0.5, oriY: 0.5 }, + optionsYOffSet: 75 +}; + +export const quizTextStyle = { + fontFamily: 'Verdana', + fontSize: '20px', + fill: Color.offWhite, + align: 'left', + lineSpacing: 10, + wordWrap: { width: QuizConstants.width - QuizConstants.textPad * 2 } +}; + +export const questionTextStyle = { + fontFamily: 'Verdana', + fontSize: '30px', + fill: Color.lightBlue, + align: 'left', + lineSpacing: 10, + wordWrap: { width: screenSize.x - 240 } +}; + +export const quizOptStyle: BitmapFontStyle = { + key: FontAssets.zektonFont.key, + size: 25, + align: Phaser.GameObjects.BitmapText.ALIGN_CENTER +}; + +export const speakerTextStyle: BitmapFontStyle = { + key: FontAssets.zektonFont.key, + size: 36, + align: Phaser.GameObjects.BitmapText.ALIGN_CENTER +}; diff --git a/src/features/game/quiz/GameQuizManager.ts b/src/features/game/quiz/GameQuizManager.ts new file mode 100644 index 0000000000..58b575b78c --- /dev/null +++ b/src/features/game/quiz/GameQuizManager.ts @@ -0,0 +1,213 @@ +import ImageAssets from '../assets/ImageAssets'; +import SoundAssets from '../assets/SoundAssets'; +import { Constants, screenSize } from '../commons/CommonConstants'; +import { ItemId } from '../commons/CommonTypes'; +import { createDialogueBox, createTypewriter } from '../dialogue/GameDialogueHelper'; +import { DialogueObject } from '../dialogue/GameDialogueTypes'; +import { fadeAndDestroy } from '../effects/FadeEffect'; +import { rightSideEntryTweenProps, rightSideExitTweenProps } from '../effects/FlyEffect'; +import { promptWithChoices } from '../effects/Prompt'; +import { Layer } from '../layer/GameLayerTypes'; +import GameGlobalAPI from '../scenes/gameManager/GameGlobalAPI'; +import SourceAcademyGame from '../SourceAcademyGame'; +import { createButton } from '../utils/ButtonUtils'; +import { sleep } from '../utils/GameUtils'; +import { calcListFormatPos, HexColor } from '../utils/StyleUtils'; +import { + questionPrompt, + questionTextStyle, + QuizConstants, + quizOptStyle, + quizTextStyle, + startPrompt +} from './GameQuizConstants'; +import GameQuizReactionManager from './GameQuizReactionManager'; +import { Question } from './GameQuizType'; + +export default class QuizManager { + private reactionManager?: GameQuizReactionManager; + + /** + * Rendering the quiz section inside a dialogue. + * + * @param quizId The Id of quiz that users will attempt inside a dialogue. + */ + public async showQuiz(quizId: ItemId) { + const quiz = GameGlobalAPI.getInstance().getQuizById(quizId); + const numOfQuestions = quiz.questions.length; + if (numOfQuestions === 0) { + return; + } + if (!(await this.showStartPrompt(GameGlobalAPI.getInstance().getGameManager()))) { + await GameGlobalAPI.getInstance().showNextLine(() => {}); + return; + } + await GameGlobalAPI.getInstance().getGameManager().getDialogueManager().hideAll(); + let numOfCorrect = 0; + for (let i = 0; i < numOfQuestions; i++) { + numOfCorrect += await this.showQuizQuestion( + GameGlobalAPI.getInstance().getGameManager(), + quiz.questions[i] + ); + } + GameGlobalAPI.getInstance().setQuizScore(quizId, numOfCorrect); + await GameGlobalAPI.getInstance().showNextLine(() => {}); + await GameGlobalAPI.getInstance().getGameManager().getDialogueManager().showAll(); + } + + /** + * Display a prompt before a quiz starts. + * Player can choose to proceed and do the quiz, + * or to not do the quiz and exit. + * + * @param scene The Game Manager. + * @returns true if the player chooses to start the quiz. + */ + private async showStartPrompt(scene: Phaser.Scene) { + const response = await promptWithChoices(scene, startPrompt.text, startPrompt.options); + return response === 0; + } + + /** + * Display the specific quiz question. + * + * @param scene The game manager. + * @param question The question to be displayed. + */ + public async showQuizQuestion(scene: Phaser.Scene, question: Question) { + const choices = question.options; + const quizContainer = new Phaser.GameObjects.Container(scene, 0, 0); + const selfQuestionPrompt = question.prompt ?? questionPrompt; + const quizPartitions = Math.ceil(choices.length / 5); + const quizHeight = choices.length; + + //Create quiz box contains quiz questions + const quizQuestionBox = createDialogueBox(scene); + + //Create text writer to display quiz questions + const quizQuestionWriter = createTypewriter(scene, questionTextStyle); + const lineWithName = question.question.replace('{name}', this.getUsername()); + quizQuestionWriter.changeLine(lineWithName); + + GameGlobalAPI.getInstance().storeDialogueLine(lineWithName, question.speaker); + + //Generate UI components for quizzes + const header = new Phaser.GameObjects.Text( + scene, + screenSize.x - QuizConstants.textPad, + QuizConstants.y, + selfQuestionPrompt, + quizTextStyle + ).setOrigin(1.0, 0.0); + + const quizHeaderBg = new Phaser.GameObjects.Rectangle( + scene, + screenSize.x, + QuizConstants.y - QuizConstants.textPad, + QuizConstants.width * quizPartitions, + header.getBounds().bottom * 0.5 + QuizConstants.textPad, + HexColor.darkBlue, + 0.8 + ).setOrigin(1.0, 0.0); + + const quizBg = new Phaser.GameObjects.Rectangle( + scene, + screenSize.x, + QuizConstants.y - QuizConstants.textPad, + QuizConstants.width * quizPartitions, + quizHeaderBg.getBounds().bottom * 0.5 + (quizHeight + 0.5) * QuizConstants.yInterval, + HexColor.lightBlue, + 0.2 + ).setOrigin(1.0, 0.0); + + quizContainer.add([ + quizBg, + quizHeaderBg, + header, + quizQuestionBox, + quizQuestionWriter.container + ]); + + const buttonPositions = calcListFormatPos({ + numOfItems: choices.length, + xSpacing: 0, + ySpacing: QuizConstants.yInterval + }); + + GameGlobalAPI.getInstance().addToLayer(Layer.Dialogue, quizContainer); + + //Create options for users to select + const activateQuizContainer: Promise = new Promise(resolve => { + quizContainer.add( + choices.map((response, index) => + createButton(scene, { + assetKey: ImageAssets.mediumButton.key, + message: response.text, + textConfig: QuizConstants.textConfig, + bitMapTextStyle: quizOptStyle, + onUp: async () => { + quizContainer.destroy(); + const isCorrect = index === question.answer ? 1 : 0; + if (response.reaction) { + await this.showReaction(response.reaction); + } + resolve(isCorrect); + } + }).setPosition( + screenSize.x - + QuizConstants.width / 2 - + QuizConstants.width * (quizPartitions - Math.floor(index / 5) - 1), + (buttonPositions[index][1] % (5 * QuizConstants.yInterval)) + + quizHeaderBg.getBounds().bottom + + QuizConstants.optionsYOffSet + ) + ) + ); + }); + + const response = await activateQuizContainer; + + // Animate in + quizContainer.setPosition(screenSize.x, 0); + SourceAcademyGame.getInstance().getSoundManager().playSound(SoundAssets.notifEnter.key); + scene.add.tween({ + targets: quizContainer, + alpha: 1, + ...rightSideEntryTweenProps + }); + await sleep(rightSideEntryTweenProps.duration); + + // Animate out + SourceAcademyGame.getInstance().getSoundManager().playSound(SoundAssets.notifExit.key); + scene.add.tween({ + targets: quizContainer, + alpha: 1, + ...rightSideExitTweenProps + }); + + fadeAndDestroy(scene, quizContainer, { fadeDuration: Constants.fadeDuration }); + return response; + } + + /** + * Display the reaction after users selecting an option. + * + * @param reaction The reaction to be displayed. + */ + private async showReaction(reaction: DialogueObject) { + this.reactionManager = new GameQuizReactionManager(reaction); + await this.reactionManager.showReaction(); + } + + /** + * Get the number of questions of a quiz. + * + * @param quizId The Id of quiz. + */ + public getNumOfQns(quizId: ItemId): number { + const quiz = GameGlobalAPI.getInstance().getQuizById(quizId); + return quiz.questions.length; + } + + private getUsername = () => SourceAcademyGame.getInstance().getAccountInfo().name; +} diff --git a/src/features/game/quiz/GameQuizReactionManager.ts b/src/features/game/quiz/GameQuizReactionManager.ts new file mode 100644 index 0000000000..2578c510ab --- /dev/null +++ b/src/features/game/quiz/GameQuizReactionManager.ts @@ -0,0 +1,91 @@ +import SoundAssets from '../assets/SoundAssets'; +import DialogueGenerator from '../dialogue/GameDialogueGenerator'; +import DialogueRenderer from '../dialogue/GameDialogueRenderer'; +import { DialogueObject } from '../dialogue/GameDialogueTypes'; +import { promptWithChoices } from '../effects/Prompt'; +import { Layer } from '../layer/GameLayerTypes'; +import GameGlobalAPI from '../scenes/gameManager/GameGlobalAPI'; +import SourceAcademyGame from '../SourceAcademyGame'; +import { questionTextStyle } from './GameQuizConstants'; +import { QuizSpeakerRenderer } from './GameQuizSpeakerRenderer'; + +/** + * A class that manages the reaction after a option is selected or the quiz is complete + * + */ +export default class GameQuizReactionManager { + private dialogue: DialogueObject; + private dialogueRenderer?: DialogueRenderer; + private dialogueGenerator?: DialogueGenerator; + private speakerRenderer?: QuizSpeakerRenderer; + + constructor(dialogue: DialogueObject) { + this.dialogue = dialogue; + } + + /** + * It renders the reaction after the selection of an option or the quiz ends + * @returns {Promise} the promise that resolves when the whole reaction is displayed + */ + public async showReaction(): Promise { + this.dialogueRenderer = new DialogueRenderer(questionTextStyle); + this.dialogueGenerator = new DialogueGenerator(this.dialogue); + this.speakerRenderer = new QuizSpeakerRenderer(); + + GameGlobalAPI.getInstance().addToLayer( + Layer.Dialogue, + this.dialogueRenderer.getDialogueContainer() + ); + + GameGlobalAPI.getInstance().fadeInLayer(Layer.Dialogue); + await new Promise(resolve => this.playWholeDialogue(resolve as () => void)); + this.getDialogueRenderer().destroy(); + this.getSpeakerRenderer().changeSpeakerTo(null); + } + + private async playWholeDialogue(resolve: () => void) { + await this.showNextLine(resolve); + this.getDialogueRenderer() + .getDialogueBox() + .on(Phaser.Input.Events.GAMEOBJECT_POINTER_UP, async () => { + await this.showNextLine(resolve); + }); + } + + private async showNextLine(resolve: () => void) { + GameGlobalAPI.getInstance().playSound(SoundAssets.dialogueAdvance.key); + const { line, speakerDetail, actionIds, prompt } = + await this.getDialogueGenerator().generateNextLine(); + const lineWithName = line.replace('{name}', this.getUsername()); + this.getDialogueRenderer().changeText(lineWithName); + this.getSpeakerRenderer().changeSpeakerTo(speakerDetail); + + // Store the current line into the storage + GameGlobalAPI.getInstance().storeDialogueLine(lineWithName, speakerDetail); + + // Disable interactions while processing actions + GameGlobalAPI.getInstance().enableSprite(this.getDialogueRenderer().getDialogueBox(), false); + + if (prompt) { + // disable keyboard input to prevent continue dialogue + const response = await promptWithChoices( + GameGlobalAPI.getInstance().getGameManager(), + prompt.promptTitle, + prompt.choices.map(choice => choice[0]) + ); + + this.getDialogueGenerator().updateCurrPart(prompt.choices[response][1]); + } + await GameGlobalAPI.getInstance().processGameActionsInSamePhase(actionIds); + GameGlobalAPI.getInstance().enableSprite(this.getDialogueRenderer().getDialogueBox(), true); + + if (!line) { + resolve(); + } + } + + private getDialogueGenerator = () => this.dialogueGenerator as DialogueGenerator; + private getDialogueRenderer = () => this.dialogueRenderer as DialogueRenderer; + private getSpeakerRenderer = () => this.speakerRenderer as QuizSpeakerRenderer; + public getUsername = () => SourceAcademyGame.getInstance().getAccountInfo().name; +} diff --git a/src/features/game/quiz/GameQuizSpeakerRenderer.ts b/src/features/game/quiz/GameQuizSpeakerRenderer.ts new file mode 100644 index 0000000000..29b212abc8 --- /dev/null +++ b/src/features/game/quiz/GameQuizSpeakerRenderer.ts @@ -0,0 +1,119 @@ +import ImageAssets from '../assets/ImageAssets'; +import { SpeakerDetail } from '../character/GameCharacterTypes'; +import { screenCenter, screenSize } from '../commons/CommonConstants'; +import { GamePosition, ItemId } from '../commons/CommonTypes'; +import { Layer } from '../layer/GameLayerTypes'; +import GameGlobalAPI from '../scenes/gameManager/GameGlobalAPI'; +import SourceAcademyGame from '../SourceAcademyGame'; +import StringUtils from '../utils/StringUtils'; +import { createBitmapText } from '../utils/TextUtils'; +import { QuizConstants, speakerTextStyle } from './GameQuizConstants'; + +/** + * Class that manages speaker box portion of the quiz speaker box + * render characters in QuizSpeaker Layer + * + */ +export class QuizSpeakerRenderer { + private currentSpeakerId?: string; + private speakerSprite?: Phaser.GameObjects.Image; + private speakerSpriteBox?: Phaser.GameObjects.Container; + + /** + * Changes the speaker shown in the speaker box and the speaker rendered on screen + * + * @param newSpeakerDetail the details about the new speaker, + * including his characaterId, expression and position. + * + * Undefined - if no speaker changes are involved in the dialogue line. + * Null - if there is no speaker for the line + */ + public changeSpeakerTo(newSpeakerDetail?: SpeakerDetail | null) { + if (newSpeakerDetail === undefined) return; + + this.currentSpeakerId && + GameGlobalAPI.getInstance().clearSeveralLayers([Layer.QuizSpeaker, Layer.QuizSpeakerBox]); + this.showNewSpeaker(newSpeakerDetail); + } + + private showNewSpeaker(newSpeakerDetail: SpeakerDetail | null) { + if (newSpeakerDetail) { + this.drawSpeakerSprite(newSpeakerDetail); + this.drawSpeakerBox(newSpeakerDetail.speakerId); + } + } + + private drawSpeakerBox(speakerId: ItemId) { + if (speakerId === 'narrator') return; + const speakerContainer = + speakerId === 'you' + ? this.createSpeakerBox(this.getUsername(), GamePosition.Right) + : this.createSpeakerBox( + GameGlobalAPI.getInstance().getCharacterById(speakerId).name, + GamePosition.Left + ); + GameGlobalAPI.getInstance().addToLayer(Layer.QuizSpeakerBox, speakerContainer); + } + + private drawSpeakerSprite({ speakerId, speakerPosition, expression }: SpeakerDetail) { + this.currentSpeakerId = speakerId; + if (speakerId === 'you' || speakerId === 'narrator') { + return; + } + const speakerSprite = GameGlobalAPI.getInstance().createCharacterSprite( + speakerId, + expression, + speakerPosition + ); + this.speakerSprite = speakerSprite; + GameGlobalAPI.getInstance().addToLayer(Layer.QuizSpeaker, speakerSprite); + } + + private createSpeakerBox(text: string, position: GamePosition) { + const gameManager = GameGlobalAPI.getInstance().getGameManager(); + const container = new Phaser.GameObjects.Container(gameManager, 0, 0); + const rectangle = new Phaser.GameObjects.Image( + gameManager, + screenCenter.x, + screenCenter.y, + ImageAssets.speakerBox.key + ).setAlpha(0.8); + + const speakerText = createBitmapText( + gameManager, + '', + QuizConstants.speakerTextConfig, + speakerTextStyle + ); + + if (position === GamePosition.Right) { + rectangle.displayWidth *= -1; + speakerText.x = screenSize.x - speakerText.x; + } + + container.add([rectangle, speakerText]); + speakerText.text = StringUtils.capitalize(text); + this.speakerSpriteBox = container; + return container; + } + + /** + * Hide the speaker box and sprite + */ + public async hide() { + this.getSpeakerSprite().setVisible(false); + this.getSpeakerSpriteBox().setVisible(false); + } + + /** + * Show the hidden speaker box and sprite + */ + public async show() { + this.getSpeakerSprite().setVisible(true); + this.getSpeakerSpriteBox().setVisible(true); + } + + public getUsername = () => SourceAcademyGame.getInstance().getAccountInfo().name; + private getSpeakerSprite = () => this.speakerSprite as Phaser.GameObjects.Image; + private getSpeakerSpriteBox = () => this.speakerSpriteBox as Phaser.GameObjects.Container; +} diff --git a/src/features/game/quiz/GameQuizType.ts b/src/features/game/quiz/GameQuizType.ts new file mode 100644 index 0000000000..34ee7c91ed --- /dev/null +++ b/src/features/game/quiz/GameQuizType.ts @@ -0,0 +1,19 @@ +import { SpeakerDetail } from '../character/GameCharacterTypes'; +import { DialogueObject } from '../dialogue/GameDialogueTypes'; + +export type Quiz = { + questions: Question[]; +}; + +export type Question = { + question: string; + prompt?: string; + speaker: SpeakerDetail; + answer: Number; + options: Option[]; +}; + +export type Option = { + text: string; + reaction?: DialogueObject; +}; diff --git a/src/features/game/save/GameSaveHelper.ts b/src/features/game/save/GameSaveHelper.ts index 307a39873b..c53facb172 100644 --- a/src/features/game/save/GameSaveHelper.ts +++ b/src/features/game/save/GameSaveHelper.ts @@ -34,7 +34,8 @@ export function gameStateToJson( completedTasks: gameStateManager.getCompletedTasks(), completedObjectives: gameStateManager.getCompletedObjectives(), triggeredInteractions: gameStateManager.getTriggeredInteractions(), - triggeredStateChangeActions: gameStateManager.getTriggeredStateChangeActions() + triggeredStateChangeActions: gameStateManager.getTriggeredStateChangeActions(), + quizScores: gameStateManager.getQuizScores() } }, userSaveState: { @@ -80,7 +81,8 @@ export const createEmptyGameSaveState = (): GameSaveState => { completedTasks: [], completedObjectives: [], triggeredInteractions: [], - triggeredStateChangeActions: [] + triggeredStateChangeActions: [], + quizScores: [] }; }; diff --git a/src/features/game/save/GameSaveManager.ts b/src/features/game/save/GameSaveManager.ts index 8932827064..f4854de030 100644 --- a/src/features/game/save/GameSaveManager.ts +++ b/src/features/game/save/GameSaveManager.ts @@ -150,6 +150,7 @@ export default class GameSaveManager { public getIncompleteTasks = () => this.getGameSaveState().incompleteTasks; public getLoadedPhase = () => this.getGameSaveState().currentPhase; public getChapterNewlyCompleted = () => this.getGameSaveState().chapterNewlyCompleted; + public getQuizScores = () => this.getGameSaveState().quizScores; public getChapterNum = () => mandatory(this.chapterNum); public getCheckpointNum = () => mandatory(this.checkpointNum); diff --git a/src/features/game/save/GameSaveTypes.ts b/src/features/game/save/GameSaveTypes.ts index 6eba2ff487..f520f39e17 100644 --- a/src/features/game/save/GameSaveTypes.ts +++ b/src/features/game/save/GameSaveTypes.ts @@ -20,6 +20,7 @@ export type FullSaveState = { * @prop {string[]} completedObjectives - list of objectives that have been completed by player * @prop {string[]} triggeredInteractions - list of itemIds that have been triggered by player * @prop {string[]} triggeredActions - list of actions that have been triggered by player + * @prop {[string, number][]} quizScores - list of quiz ids and the corresponding quiz scores for a user */ export type GameSaveState = { lastCheckpointPlayed: number; @@ -32,6 +33,7 @@ export type GameSaveState = { completedObjectives: string[]; triggeredInteractions: string[]; triggeredStateChangeActions: string[]; + quizScores: [string, number][]; }; /** diff --git a/src/features/game/scenes/gameManager/GameGlobalAPI.ts b/src/features/game/scenes/gameManager/GameGlobalAPI.ts index 0be52dd764..2b9e334c3f 100644 --- a/src/features/game/scenes/gameManager/GameGlobalAPI.ts +++ b/src/features/game/scenes/gameManager/GameGlobalAPI.ts @@ -14,6 +14,7 @@ import { AnyId, GameItemType, GameLocation, LocationId } from '../../location/Ga import { GameMode } from '../../mode/GameModeTypes'; import { ObjectProperty } from '../../objects/GameObjectTypes'; import { GamePhaseType } from '../../phase/GamePhaseTypes'; +import { Quiz } from '../../quiz/GameQuizType'; import { SettingsJson } from '../../save/GameSaveTypes'; import SourceAcademyGame from '../../SourceAcademyGame'; import { StateObserver, UserStateType } from '../../state/GameStateTypes'; @@ -283,6 +284,10 @@ class GameGlobalAPI { await this.getGameManager().getDialogueManager().showDialogue(dialogueId); } + public async showNextLine(resolve: () => void) { + await this.getGameManager().getDialogueManager().showNextLine(resolve); + } + ///////////////////// // Storage // ///////////////////// @@ -501,9 +506,42 @@ class GameGlobalAPI { public getBBoxById(bboxId: ItemId): BBoxProperty { return mandatory(this.getGameMap().getBBoxPropMap().get(bboxId)); } + + public getQuizById(quizId: ItemId): Quiz { + return mandatory(this.getGameMap().getQuizMap().get(quizId)); + } + public getAssetByKey(assetKey: AssetKey) { return this.getGameMap().getAssetByKey(assetKey); } + + ///////////////////// + // Quiz // + ///////////////////// + + public async showQuiz(quizId: ItemId) { + await this.getGameManager().getQuizManager().showQuiz(quizId); + } + + public getQuizLength(quizId: ItemId): number { + return this.getGameManager().getQuizManager().getNumOfQns(quizId); + } + + public isQuizAttempted(key: string): boolean { + return this.getGameManager().getStateManager().isQuizAttempted(key); + } + + public isQuizComplete(key: string): boolean { + return this.getGameManager().getStateManager().isQuizComplete(key); + } + + public setQuizScore(key: string, score: number): void { + this.getGameManager().getStateManager().setQuizScore(key, score); + } + + public getQuizScore(key: string): number { + return this.getGameManager().getStateManager().getQuizScore(key); + } } export default GameGlobalAPI; diff --git a/src/features/game/scenes/gameManager/GameManager.ts b/src/features/game/scenes/gameManager/GameManager.ts index 125e077fd1..d06f7151db 100644 --- a/src/features/game/scenes/gameManager/GameManager.ts +++ b/src/features/game/scenes/gameManager/GameManager.ts @@ -27,6 +27,7 @@ import GameObjectManager from '../../objects/GameObjectManager'; import GamePhaseManager from '../../phase/GamePhaseManager'; import { GamePhaseType } from '../../phase/GamePhaseTypes'; import GamePopUpManager from '../../popUp/GamePopUpManager'; +import GameQuizManager from '../../quiz/GameQuizManager'; import SourceAcademyGame from '../../SourceAcademyGame'; import GameStateManager from '../../state/GameStateManager'; import GameTaskLogManager from '../../task/GameTaskLogManager'; @@ -75,6 +76,7 @@ class GameManager extends Phaser.Scene { private toolbarManager?: GameToolbarManager; private taskLogManager?: GameTaskLogManager; private dashboardManager?: GameDashboardManager; + private quizManager?: GameQuizManager; constructor() { super('GameManager'); @@ -125,6 +127,7 @@ class GameManager extends Phaser.Scene { ], [this.logManager, this.taskLogManager, this.collectibleManager, this.achievementManager] ); + this.quizManager = new GameQuizManager(); } ////////////////////// @@ -432,6 +435,7 @@ class GameManager extends Phaser.Scene { public getToolbarManager = () => mandatory(this.toolbarManager); public getTaskLogManager = () => mandatory(this.taskLogManager); public getDashboardManager = () => mandatory(this.dashboardManager); + public getQuizManager = () => mandatory(this.quizManager); } export default GameManager; diff --git a/src/features/game/state/GameStateManager.ts b/src/features/game/state/GameStateManager.ts index 24c3264947..b9c708ad6d 100644 --- a/src/features/game/state/GameStateManager.ts +++ b/src/features/game/state/GameStateManager.ts @@ -33,6 +33,7 @@ class GameStateManager { private checkpointObjective: GameObjective; private checkpointTask: GameTask; private chapterNewlyCompleted: boolean; + private quizScores: Map; // Triggered Interactions private updatedLocations: Set; @@ -46,6 +47,7 @@ class GameStateManager { this.checkpointObjective = gameCheckpoint.objectives; this.checkpointTask = gameCheckpoint.tasks; this.chapterNewlyCompleted = false; + this.quizScores = new Map(); this.updatedLocations = new Set(this.gameMap.getLocationIds()); this.triggeredInteractions = new Map(); @@ -82,6 +84,8 @@ class GameStateManager { this.checkpointTask.showTask(task); }); + this.quizScores = new Map(this.getSaveManager().getQuizScores()); + this.chapterNewlyCompleted = this.getSaveManager().getChapterNewlyCompleted(); } @@ -454,6 +458,52 @@ class GameStateManager { return this.checkpointTask.getAllVisibleTaskData(); } + /////////////////////////////// + // Quiz // + /////////////////////////////// + + /** + * Checks whether a quiz has been obtained full marks. + * + * @param key quiz id + * @returns {boolean} + */ + public isQuizComplete(quizId: string): boolean { + return this.quizScores.get(quizId) === GameGlobalAPI.getInstance().getQuizLength(quizId); + } + + /** + * Checks whether a specific quiz has been played. + * + * @param key quiz id + * @returns {boolean} + */ + public isQuizAttempted(quizId: string): boolean { + return this.quizScores.has(quizId); + } + + /** + * Get the score of a quiz. + * Return 0 if the quiz has not been played. + * + * @param quizId + * @returns + */ + public getQuizScore(quizId: ItemId): number { + const score = this.quizScores.get(quizId); + return score ?? 0; + } + + /** + * Set the score of a quiz to a given number. + * + * @param quizId The id of the quiz. + * @param newScore The new score to be set. + */ + public setQuizScore(quizId: string, newScore: number) { + this.quizScores.set(quizId, newScore); + } + /////////////////////////////// // Saving // /////////////////////////////// @@ -504,6 +554,16 @@ class GameStateManager { return this.triggeredStateChangeActions; } + /** + * Return an array containing [string, number] pairs + * representing quizzes and the corresponding scores. + * + * @returns {[string, number][]} + */ + public getQuizScores(): [string, number][] { + return [...this.quizScores]; + } + public getGameMap = () => this.gameMap; public getCharacterAtId = (id: ItemId) => mandatory(this.gameMap.getCharacterMap().get(id)); diff --git a/src/features/game/state/GameStateTypes.ts b/src/features/game/state/GameStateTypes.ts index afb85031e5..b881e5ef34 100644 --- a/src/features/game/state/GameStateTypes.ts +++ b/src/features/game/state/GameStateTypes.ts @@ -3,7 +3,10 @@ import { ItemId } from '../commons/CommonTypes'; export enum GameStateStorage { UserState = 'UserState', ChecklistState = 'ChecklistState', - TasklistState = 'TasklistState' + TasklistState = 'TasklistState', + AttemptedQuizState = 'AttemptedQuizState', + PassedQuizState = 'PassedQuizState', + QuizScoreState = 'QuizScoreState' } /**