Skip to content

Commit

Permalink
Game: Create a new quiz feature (#2919)
Browse files Browse the repository at this point in the history
* Add new action show_quiz

* Add quiz parser

* Add quiz manager

* Create quiz result type

* Hide dialogue box while showing quiz

* Modify character hidding feature

* Track and display quiz results

* Debugging for speaker displaying

* Modify UI for quiz displaying

* Update UI for quiz display

* Solve quiz speaker issue

* Remove default reaction in QuizParser and handle no reaction situation in QuizManager

* Add a new property (boolean array) in Quiz type to record quiz result

* Rearrange quiz result

* Save quiz status as attempted/completed arrays

* Add conditions to check whether a quiz is attempted/completed

* Disable keyboard input in a quiz

* Move question prompt text to QuizConstants

* QuizParser error handling & add new "speaker" property to QuizType & store quiz questions to dialogue log

* Display quiz result message to dialogue log

* Move quiz result message to QuizConstant

* Change quiz condition names

* Add a prompt before a quiz & proceed to the next dialogue line when a quiz ends

* Add saving quizzes score

* Add validation for quiz conditions parameters

* Add new condition quizScoreAtLeast to check the status of quiz scores

* Support Interpolation of player's name in quiz questions

* Support interpolation of quiz scores in dialogue lines

* Refactor the logic of makeLineQuizScores method

Co-authored-by: reginateh <e1312290@u.nus.edu>
Co-authored-by: CZX <CZX123@users.noreply.github.com>
  • Loading branch information
3 people authored Aug 6, 2024
1 parent 5b54dc1 commit dfe3114
Show file tree
Hide file tree
Showing 27 changed files with 910 additions and 11 deletions.
10 changes: 10 additions & 0 deletions src/features/game/action/GameActionConditionChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions src/features/game/action/GameActionExecuter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -141,6 +146,7 @@ export default class GameActionExecuter {
case GameActionType.PlaySFX:
case GameActionType.ShowObjectLayer:
case GameActionType.Delay:
case GameActionType.ShowQuiz:
return false;
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/features/game/action/GameActionTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export enum GameActionType {
ShowObjectLayer = 'ShowObjectLayer',
NavigateToAssessment = 'NavigateToAssessment',
UpdateAssessmentStatus = 'UpdateAssessmentStatus',
Delay = 'Delay'
Delay = 'Delay',
ShowQuiz = 'ShowQuiz'
}

/**
Expand Down
39 changes: 37 additions & 2 deletions src/features/game/dialogue/GameDialogueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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: "{<quizId>.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;
Expand Down
18 changes: 18 additions & 0 deletions src/features/game/dialogue/GameDialogueRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
22 changes: 22 additions & 0 deletions src/features/game/dialogue/GameDialogueSpeakerRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,6 +65,7 @@ export default class DialogueSpeakerRenderer {
expression,
speakerPosition
);
this.speakerSprite = speakerSprite;
GameGlobalAPI.getInstance().addToLayer(Layer.Speaker, speakerSprite);
}

Expand Down Expand Up @@ -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;
}
6 changes: 5 additions & 1 deletion src/features/game/layer/GameLayerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ export enum Layer {
Escape,
Selector,
Dashboard,
WorkerMessage
WorkerMessage,
QuizSpeakerBox,
QuizSpeaker
}

// Back to Front
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/features/game/location/GameMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -36,6 +37,7 @@ class GameMap {
private actions: Map<ItemId, GameAction>;
private gameStartActions: ItemId[];
private checkpointCompleteActions: ItemId[];
private quizzes: Map<ItemId, Quiz>;

constructor() {
this.soundAssets = [];
Expand All @@ -47,6 +49,7 @@ class GameMap {
this.boundingBoxes = new Map<ItemId, BBoxProperty>();
this.characters = new Map<ItemId, Character>();
this.actions = new Map<ItemId, GameAction>();
this.quizzes = new Map<ItemId, Quiz>();

this.gameStartActions = [];
this.checkpointCompleteActions = [];
Expand Down Expand Up @@ -120,6 +123,10 @@ class GameMap {
return this.actions;
}

public getQuizMap(): Map<ItemId, Quiz> {
return this.quizzes;
}

public getSoundAssets(): SoundAsset[] {
return this.soundAssets;
}
Expand Down
3 changes: 2 additions & 1 deletion src/features/game/location/GameMapTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ export enum GameItemType {
characters = 'characters',
actions = 'actions',
bgmKey = 'bgmKey',
collectibles = 'collectibles'
collectibles = 'collectibles',
quizzes = 'quizzes'
}
5 changes: 5 additions & 0 deletions src/features/game/parser/ActionParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
36 changes: 36 additions & 0 deletions src/features/game/parser/ConditionParser.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
}
Expand Down
12 changes: 11 additions & 1 deletion src/features/game/parser/DialogueParser.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string>} 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);
Expand Down
4 changes: 4 additions & 0 deletions src/features/game/parser/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -94,6 +95,9 @@ class Parser {
case 'dialogues':
DialoguesParser.parse(body);
break;
case 'quizzes':
QuizParser.parse(body);
break;
default:
return false;
}
Expand Down
8 changes: 6 additions & 2 deletions src/features/game/parser/ParserConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading

0 comments on commit dfe3114

Please sign in to comment.