From bf18b1ceb16ebd76e04b0e95802957a24caee2a4 Mon Sep 17 00:00:00 2001 From: Flashfyre Date: Tue, 13 Feb 2024 18:42:11 -0500 Subject: [PATCH] Add tutorial framework (WiP) --- src/battle-phases.ts | 13 +++--- src/battle-scene.ts | 1 + src/system/game-data.ts | 51 ++++++++++++++++++++-- src/system/settings.ts | 6 +++ src/tutorial.ts | 50 +++++++++++++++++++++ src/ui/settings-ui-handler.ts | 67 ++++++++++++++++++++++------- src/ui/starter-select-ui-handler.ts | 3 ++ src/ui/ui.ts | 34 +++++++++------ 8 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 src/tutorial.ts diff --git a/src/battle-phases.ts b/src/battle-phases.ts index 9e0d253bc..e3e600d54 100644 --- a/src/battle-phases.ts +++ b/src/battle-phases.ts @@ -50,6 +50,7 @@ import { SpeciesFormChangeActiveTrigger, SpeciesFormChangeManualTrigger, Species import { battleSpecDialogue } from "./data/dialogue"; import ModifierSelectUiHandler, { SHOP_OPTIONS_ROW_LIMIT } from "./ui/modifier-select-ui-handler"; import { Setting } from "./system/settings"; +import { Tutorial, handleTutorial } from "./tutorial"; export class LoginPhase extends BattlePhase { private showText: boolean; @@ -105,7 +106,7 @@ export class LoginPhase extends BattlePhase { end(): void { this.scene.ui.setMode(Mode.MESSAGE); - super.end(); + handleTutorial(this.scene, Tutorial.Intro).then(() => super.end()); } } @@ -585,7 +586,7 @@ export class EncounterPhase extends BattlePhase { message = this.scene.currentBattle.trainer.config.encounterMessages[trainer.female ? 1 : 0]; else this.scene.executeWithSeedOffset(() => message = Phaser.Math.RND.pick(this.scene.currentBattle.trainer.config.encounterMessages), this.scene.currentBattle.waveIndex); - this.scene.ui.showDialogue(message, trainer.getName(), null, doSummon, null, true); + this.scene.ui.showDialogue(message, trainer.getName(), null, doSummon); } } } @@ -643,7 +644,7 @@ export class EncounterPhase extends BattlePhase { this.scene.ui.showText(this.getEncounterMessage(), null, () => { this.scene.ui.showDialogue(battleSpecDialogue[BattleSpec.FINAL_BOSS].encounter, enemy.name, null, () => { this.doEncounterCommon(false); - }, null, true); + }); }, 1500, true); return true; } @@ -2504,7 +2505,7 @@ export class DamagePhase extends PokemonPhase { } super.end(); - }, null, true); + }); return; } break; @@ -2605,7 +2606,7 @@ export class FaintPhase extends PokemonPhase { if (!this.player) { const enemy = this.getPokemon(); if (enemy.formIndex) { - this.scene.ui.showDialogue(battleSpecDialogue[BattleSpec.FINAL_BOSS].secondStageWin, enemy.name, null, () => this.doFaint(), null, true); + this.scene.ui.showDialogue(battleSpecDialogue[BattleSpec.FINAL_BOSS].secondStageWin, enemy.name, null, () => this.doFaint()); return true; } } @@ -2759,7 +2760,7 @@ export class TrainerVictoryPhase extends BattlePhase { for (let p = messagePages.length - 1; p >= 0; p--) { const originalFunc = showMessageAndEnd; - showMessageAndEnd = () => this.scene.ui.showDialogue(messagePages[p], this.scene.currentBattle.trainer.getName(), null, originalFunc, null, true); + showMessageAndEnd = () => this.scene.ui.showDialogue(messagePages[p], this.scene.currentBattle.trainer.getName(), null, originalFunc); } } showMessageAndEnd(); diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 99f1a11bf..87c583fb1 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -94,6 +94,7 @@ export default class BattleScene extends Phaser.Scene { public seVolume: number = 1; public gameSpeed: integer = 1; public showLevelUpStats: boolean = true; + public enableTutorials: boolean = true; public windowType: integer = 1; public enableTouchControls: boolean = false; public enableVibration: boolean = false; diff --git a/src/system/game-data.ts b/src/system/game-data.ts index ce84da5c0..cd319be1c 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -22,13 +22,15 @@ import { Mode } from "../ui/ui"; import { loggedInUser, updateUserInfo } from "../account"; import { Nature } from "../data/nature"; import { GameStats } from "./game-stats"; +import { Tutorial } from "../tutorial"; const saveKey = 'x0i2O7WRiANTqPmZ'; // Temporary; secure encryption is not yet necessary export enum GameDataType { SYSTEM, SESSION, - SETTINGS + SETTINGS, + TUTORIALS } export enum PlayerGender { @@ -45,6 +47,8 @@ export function getDataTypeKey(dataType: GameDataType): string { return 'sessionData'; case GameDataType.SETTINGS: return 'settings'; + case GameDataType.TUTORIALS: + return 'tutorials'; } } @@ -129,6 +133,10 @@ export interface DexAttrProps { formIndex: integer; } +export interface TutorialFlags { + [key: string]: boolean +} + const systemShortKeys = { seenAttr: '$sa', caughtAttr: '$ca', @@ -366,6 +374,39 @@ export class GameData { setSetting(this.scene, setting as Setting, settings[setting]); } + public saveTutorialFlag(tutorial: Tutorial, flag: boolean): boolean { + let tutorials: object = {}; + if (localStorage.hasOwnProperty('tutorials')) + tutorials = JSON.parse(localStorage.getItem('tutorials')); + + Object.keys(Tutorial).map(t => t as Tutorial).forEach(t => { + const key = Tutorial[t]; + if (key === tutorial) + tutorials[key] = flag; + else + tutorials[key] ??= false; + }); + + localStorage.setItem('tutorials', JSON.stringify(tutorials)); + + return true; + } + + public getTutorialFlags(): TutorialFlags { + const ret: TutorialFlags = {}; + Object.values(Tutorial).map(tutorial => tutorial as Tutorial).forEach(tutorial => ret[Tutorial[tutorial]] = false); + + if (!localStorage.hasOwnProperty('tutorials')) + return ret; + + const tutorials = JSON.parse(localStorage.getItem('tutorials')); + + for (let tutorial of Object.keys(tutorials)) + ret[tutorial] = tutorials[tutorial]; + + return ret; + } + saveSession(scene: BattleScene, skipVerification?: boolean): Promise { return new Promise(resolve => { Utils.executeIf(!skipVerification, updateUserInfo).then(success => { @@ -582,7 +623,7 @@ export class GameData { link.click(); link.remove(); }; - if (!bypassLogin && dataType !== GameDataType.SETTINGS) { + if (!bypassLogin && dataType < GameDataType.SETTINGS) { Utils.apiFetch(`savedata/get?datatype=${dataType}`) .then(response => response.text()) .then(response => { @@ -629,6 +670,7 @@ export class GameData { valid = !!sessionData.party && !!sessionData.enemyParty && !!sessionData.timestamp; break; case GameDataType.SETTINGS: + case GameDataType.TUTORIALS: valid = true; break; } @@ -647,6 +689,9 @@ export class GameData { case GameDataType.SETTINGS: dataName = 'settings'; break; + case GameDataType.TUTORIALS: + dataName = 'tutorials'; + break; } const displayError = (error: string) => this.scene.ui.showText(error, null, () => this.scene.ui.showText(null, 0), Utils.fixedInt(1500)); @@ -655,7 +700,7 @@ export class GameData { return this.scene.ui.showText(`Your ${dataName} data could not be loaded. It may be corrupted.`, null, () => this.scene.ui.showText(null, 0), Utils.fixedInt(1500)); this.scene.ui.showText(`Your ${dataName} data will be overridden and the page will reload. Proceed?`, null, () => { this.scene.ui.setOverlayMode(Mode.CONFIRM, () => { - if (!bypassLogin && dataType !== GameDataType.SETTINGS) { + if (!bypassLogin && dataType < GameDataType.SETTINGS) { updateUserInfo().then(success => { if (!success) return displayError(`Could not contact the server. Your ${dataName} data could not be imported.`); diff --git a/src/system/settings.ts b/src/system/settings.ts index c7a97db1e..e6216ca06 100644 --- a/src/system/settings.ts +++ b/src/system/settings.ts @@ -9,6 +9,7 @@ export enum Setting { SE_Volume = "SE_VOLUME", Show_Stats_on_Level_Up = "SHOW_LEVEL_UP_STATS", Window_Type = "WINDOW_TYPE", + Tutorials = "TUTORIALS", Player_Gender = "PLAYER_GENDER", Touch_Controls = "TOUCH_CONTROLS", Vibration = "VIBRATION" @@ -29,6 +30,7 @@ export const settingOptions: SettingOptions = { [Setting.SE_Volume]: new Array(11).fill(null).map((_, i) => i ? (i * 10).toString() : 'Mute'), [Setting.Show_Stats_on_Level_Up]: [ 'Off', 'On' ], [Setting.Window_Type]: new Array(4).fill(null).map((_, i) => (i + 1).toString()), + [Setting.Tutorials]: [ 'Off', 'On' ], [Setting.Player_Gender]: [ 'Boy', 'Girl' ], [Setting.Touch_Controls]: [ 'Auto', 'Disabled' ], [Setting.Vibration]: [ 'Auto', 'Disabled' ] @@ -41,6 +43,7 @@ export const settingDefaults: SettingDefaults = { [Setting.SE_Volume]: 10, [Setting.Show_Stats_on_Level_Up]: 1, [Setting.Window_Type]: 0, + [Setting.Tutorials]: 1, [Setting.Player_Gender]: 0, [Setting.Touch_Controls]: 0, [Setting.Vibration]: 0 @@ -69,6 +72,9 @@ export function setSetting(scene: BattleScene, setting: Setting, value: integer) case Setting.Window_Type: updateWindowType(scene, parseInt(settingOptions[setting][value])); break; + case Setting.Tutorials: + scene.enableTutorials = settingOptions[setting][value] === 'On'; + break; case Setting.Player_Gender: if (scene.gameData) { const female = settingOptions[setting][value] === 'Girl'; diff --git a/src/tutorial.ts b/src/tutorial.ts new file mode 100644 index 000000000..d34c5a9dc --- /dev/null +++ b/src/tutorial.ts @@ -0,0 +1,50 @@ +import BattleScene from "./battle-scene"; + +export enum Tutorial { + Intro = "INTRO", + Menu = "MENU", + Starter_Select = "STARTER_SELECT", + Select_Item = "SELECT_ITEM", + Gacha = "GACHA", + Egg_List = "EGG_LIST" +} + +const tutorialHandlers = { + [Tutorial.Intro]: (scene: BattleScene) => { + return new Promise(resolve => { + scene.ui.showText(`Welcome to PokéRogue! This is a battle-focused Pokémon fangame with roguelite elements. + $This game is not monetized and we claim no ownership of Pokémon nor of the copyrighted assets used. + $The game is a work in progress, but fully playable.\nFor bug reports, please use the Discord community.`, null, () => resolve()); + }); + }, + [Tutorial.Menu]: (scene: BattleScene) => { + return new Promise(resolve => { + if (scene.enableTouchControls) + return resolve(); + scene.ui.showText(`To access the menu, press M or Escape. The menu contains settings and various features.`, null, () => resolve()); + }); + }, + [Tutorial.Starter_Select]: (scene: BattleScene) => { + return new Promise(resolve => { + scene.ui.showText(`From this screen, you can select the starters for your party. + $Each starter has a value. Your party can have up to 6 members as long as the total does not exceed 10. + $You can also select gender, ability, and form depending on the variants you've caught or hatched. + $The IVs for a species are also the best of every one you've caught, so try to get lots of the same species!`, null, () => resolve()); + }); + }, +}; + +export function handleTutorial(scene: BattleScene, tutorial: Tutorial): Promise { + return new Promise(resolve => { + if (!scene.enableTutorials) + return resolve(false); + + if (scene.gameData.getTutorialFlags()[tutorial]) + return resolve(false); + + tutorialHandlers[tutorial](scene).then(() => { + scene.gameData.saveTutorialFlag(tutorial, true); + resolve(true); + }); + }); +} \ No newline at end of file diff --git a/src/ui/settings-ui-handler.ts b/src/ui/settings-ui-handler.ts index 608de5dbb..65b75c22a 100644 --- a/src/ui/settings-ui-handler.ts +++ b/src/ui/settings-ui-handler.ts @@ -9,10 +9,13 @@ export default class SettingsUiHandler extends UiHandler { private settingsContainer: Phaser.GameObjects.Container; private optionsContainer: Phaser.GameObjects.Container; + private scrollCursor: integer; + private optionsBg: Phaser.GameObjects.NineSlice; private optionCursors: integer[]; + private settingLabels: Phaser.GameObjects.Text[]; private optionValueLabels: Phaser.GameObjects.Text[][]; private cursorObj: Phaser.GameObjects.NineSlice; @@ -40,14 +43,14 @@ export default class SettingsUiHandler extends UiHandler { this.optionsContainer = this.scene.add.container(0, 0); - const settingLabels = []; + this.settingLabels = []; this.optionValueLabels = []; Object.keys(Setting).forEach((setting, s) => { - settingLabels[s] = addTextObject(this.scene, 8, 28 + s * 16, setting.replace(/\_/g, ' '), TextStyle.SETTINGS_LABEL); - settingLabels[s].setOrigin(0, 0); + this.settingLabels[s] = addTextObject(this.scene, 8, 28 + s * 16, setting.replace(/\_/g, ' '), TextStyle.SETTINGS_LABEL); + this.settingLabels[s].setOrigin(0, 0); - this.optionsContainer.add(settingLabels[s]); + this.optionsContainer.add(this.settingLabels[s]); this.optionValueLabels.push(settingOptions[Setting[setting]].map((option, o) => { const valueLabel = addTextObject(this.scene, 0, 0, option, settingDefaults[Setting[setting]] === o ? TextStyle.SETTINGS_SELECTED : TextStyle.WINDOW); @@ -60,7 +63,7 @@ export default class SettingsUiHandler extends UiHandler { const totalWidth = this.optionValueLabels[s].map(o => o.width).reduce((total, width) => total += width, 0); - const labelWidth = Math.max(78, settingLabels[s].displayWidth + 8); + const labelWidth = Math.max(78, this.settingLabels[s].displayWidth + 8); const totalSpace = (300 - labelWidth) - totalWidth / 6; const optionSpacing = Math.floor(totalSpace / (this.optionValueLabels[s].length - 1)); @@ -68,7 +71,7 @@ export default class SettingsUiHandler extends UiHandler { let xOffset = 0; for (let value of this.optionValueLabels[s]) { - value.setPositionRelative(settingLabels[s], labelWidth + xOffset, 0); + value.setPositionRelative(this.settingLabels[s], labelWidth + xOffset, 0); xOffset += value.width / 6 + optionSpacing; } }); @@ -83,6 +86,7 @@ export default class SettingsUiHandler extends UiHandler { ui.add(this.settingsContainer); this.setCursor(0); + this.setScrollCursor(0); this.settingsContainer.setVisible(false); } @@ -113,22 +117,31 @@ export default class SettingsUiHandler extends UiHandler { success = true; this.scene.ui.revertMode(); } else { + const cursor = this.cursor + this.scrollCursor; switch (button) { case Button.UP: - if (this.cursor) - success = this.setCursor(this.cursor - 1); + if (cursor) { + if (this.cursor) + success = this.setCursor(this.cursor - 1); + else + success = this.setScrollCursor(this.scrollCursor - 1); + } break; case Button.DOWN: - if (this.cursor < this.optionValueLabels.length - 1) - success = this.setCursor(this.cursor + 1); + if (cursor < this.optionValueLabels.length) { + if (this.cursor < 8) + success = this.setCursor(this.cursor + 1); + else if (this.scrollCursor < this.optionValueLabels.length - 9) + success = this.setScrollCursor(this.scrollCursor + 1); + } break; case Button.LEFT: - if (this.optionCursors[this.cursor]) - success = this.setOptionCursor(this.cursor, this.optionCursors[this.cursor] - 1, true); + if (this.optionCursors[cursor]) + success = this.setOptionCursor(cursor, this.optionCursors[cursor] - 1, true); break; case Button.RIGHT: - if (this.optionCursors[this.cursor] < this.optionValueLabels[this.cursor].length - 1) - success = this.setOptionCursor(this.cursor, this.optionCursors[this.cursor] + 1, true); + if (this.optionCursors[cursor] < this.optionValueLabels[cursor].length - 1) + success = this.setOptionCursor(cursor, this.optionCursors[cursor] + 1, true); break; } } @@ -148,7 +161,7 @@ export default class SettingsUiHandler extends UiHandler { this.optionsContainer.add(this.cursorObj); } - this.cursorObj.setPositionRelative(this.optionsBg, 4, 4 + this.cursor * 16); + this.cursorObj.setPositionRelative(this.optionsBg, 4, 4 + (this.cursor + this.scrollCursor) * 16); return ret; } @@ -179,6 +192,30 @@ export default class SettingsUiHandler extends UiHandler { return true; } + setScrollCursor(scrollCursor: integer): boolean { + if (scrollCursor === this.scrollCursor) + return false; + + this.scrollCursor = scrollCursor; + + this.updateSettingsScroll(); + + this.setCursor(this.cursor); + + return true; + } + + updateSettingsScroll(): void { + this.optionsContainer.setY(-16 * this.scrollCursor); + + for (let s = 0; s < this.settingLabels.length; s++) { + const visible = s >= this.scrollCursor && s < this.scrollCursor + 9; + this.settingLabels[s].setVisible(visible); + for (let option of this.optionValueLabels[s]) + option.setVisible(visible); + } + } + clear() { super.clear(); this.settingsContainer.setVisible(false); diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index d3d6c6af1..7412cc6c6 100644 --- a/src/ui/starter-select-ui-handler.ts +++ b/src/ui/starter-select-ui-handler.ts @@ -17,6 +17,7 @@ import { addWindow } from "./window"; import { Nature, getNatureName } from "../data/nature"; import BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext"; import { pokemonFormChanges } from "../data/pokemon-forms"; +import { Tutorial, handleTutorial } from "../tutorial"; export type StarterSelectCallback = (starters: Starter[]) => void; @@ -365,6 +366,8 @@ export default class StarterSelectUiHandler extends MessageUiHandler { this.setGenMode(true); this.setCursor(0); + //handleTutorial(this.scene, Tutorial.Starter_Select); + return true; } diff --git a/src/ui/ui.ts b/src/ui/ui.ts index 5f6ba47e4..c785f71b1 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -178,28 +178,38 @@ export default class UI extends Phaser.GameObjects.Container { } showText(text: string, delay?: integer, callback?: Function, callbackDelay?: integer, prompt?: boolean, promptDelay?: integer): void { - const handler = this.getHandler(); - if (handler instanceof MessageUiHandler) - (handler as MessageUiHandler).showText(text, delay, callback, callbackDelay, prompt, promptDelay); - else - this.getMessageHandler().showText(text, delay, callback, callbackDelay, prompt, promptDelay); - } - - showDialogue(text: string, name: string, delay: integer = 0, callback: Function, callbackDelay?: integer, prompt?: boolean, promptDelay?: integer): void { - if (text.indexOf('$') > -1) { + if (prompt && text.indexOf('$') > -1) { const messagePages = text.split(/\$/g).map(m => m.trim()); let showMessageAndCallback = () => callback(); for (let p = messagePages.length - 1; p >= 0; p--) { const originalFunc = showMessageAndCallback; - showMessageAndCallback = () => this.showDialogue(messagePages[p], name, null, originalFunc, null, true); + showMessageAndCallback = () => this.showText(messagePages[p], null, originalFunc, null, true); } showMessageAndCallback(); } else { const handler = this.getHandler(); if (handler instanceof MessageUiHandler) - (handler as MessageUiHandler).showDialogue(text, name, delay, callback, callbackDelay, prompt, promptDelay); + (handler as MessageUiHandler).showText(text, delay, callback, callbackDelay, prompt, promptDelay); else - this.getMessageHandler().showDialogue(text, name, delay, callback, callbackDelay, prompt, promptDelay); + this.getMessageHandler().showText(text, delay, callback, callbackDelay, prompt, promptDelay); + } + } + + showDialogue(text: string, name: string, delay: integer = 0, callback: Function, callbackDelay?: integer, promptDelay?: integer): void { + if (text.indexOf('$') > -1) { + const messagePages = text.split(/\$/g).map(m => m.trim()); + let showMessageAndCallback = () => callback(); + for (let p = messagePages.length - 1; p >= 0; p--) { + const originalFunc = showMessageAndCallback; + showMessageAndCallback = () => this.showDialogue(messagePages[p], name, null, originalFunc); + } + showMessageAndCallback(); + } else { + const handler = this.getHandler(); + if (handler instanceof MessageUiHandler) + (handler as MessageUiHandler).showDialogue(text, name, delay, callback, callbackDelay, true, promptDelay); + else + this.getMessageHandler().showDialogue(text, name, delay, callback, callbackDelay, true, promptDelay); } }