[QoL] Test dialogue menu option (#3725)

* Adding code to allow use of a testing dialogue translation menu

* Updated to include autocomplete functionality

* Added multiple inputs

* Added locales for other languages as well as checks to make this only available on local/beta

* Updated a few things to try get the dialogue to work for full length of the window

* Fixed issue with message box not taking up full length of the screen (thanks Moka!) and some minor bugs

* Whoops, forgot to stage a file

* Updated locale files

* Fixed broken tests and docs

* Removed keys from json

* Reordered and reorganised some things

* Put admin enum at end to match handlers

* Removed old unneeded line of code

* Updated to include the ability to handle cases where i18 keys are null in the locales json
This commit is contained in:
Opaque02 2024-08-30 11:38:46 +10:00 committed by GitHub
parent f7169868f3
commit d1132a5765
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 332 additions and 24 deletions

View File

@ -77,7 +77,21 @@ export default abstract class AbstractOptionSelectUiHandler extends UiHandler {
}
protected setupOptions() {
const options = this.config?.options || [];
const configOptions = this.config?.options ?? [];
let options: OptionSelectItem[];
// for performance reasons, this limits how many options we can see at once. Without this, it would try to make text options for every single options
// which makes the performance take a hit. If there's not enough options to do this (set to 10 at the moment) and the ui mode !== Mode.AUTO_COMPLETE,
// this is ignored and the original code is untouched, with the options array being all the options from the config
if (configOptions.length >= 10 && this.scene.ui.getMode() === Mode.AUTO_COMPLETE) {
const optionsScrollTotal = configOptions.length;
const optionStartIndex = this.scrollCursor;
const optionEndIndex = Math.min(optionsScrollTotal, optionStartIndex + (!optionStartIndex || this.scrollCursor + (this.config?.maxOptions! - 1) >= optionsScrollTotal ? this.config?.maxOptions! - 1 : this.config?.maxOptions! - 2));
options = configOptions.slice(optionStartIndex, optionEndIndex + 2);
} else {
options = configOptions;
}
if (this.optionSelectText) {
this.optionSelectText.destroy();
@ -192,6 +206,19 @@ export default abstract class AbstractOptionSelectUiHandler extends UiHandler {
} else {
ui.playError();
}
} else if (button === Button.SUBMIT && ui.getMode() === Mode.AUTO_COMPLETE) {
// this is here to differentiate between a Button.SUBMIT vs Button.ACTION within the autocomplete handler
// this is here because Button.ACTION is picked up as z on the keyboard, meaning if you're typing and hit z, it'll select the option you've chosen
success = true;
const option = this.config?.options[this.cursor + (this.scrollCursor - (this.scrollCursor ? 1 : 0))];
if (option?.handler()) {
if (!option.keepOpen) {
this.clear();
}
playSound = !option.overrideSound;
} else {
ui.playError();
}
} else {
switch (button) {
case Button.UP:

View File

@ -0,0 +1,45 @@
import { Button } from "#enums/buttons";
import BattleScene from "../battle-scene";
import AbstractOptionSelectUiHandler from "./abstact-option-select-ui-handler";
import { Mode } from "./ui";
export default class AutoCompleteUiHandler extends AbstractOptionSelectUiHandler {
modalContainer: Phaser.GameObjects.Container;
constructor(scene: BattleScene, mode: Mode = Mode.OPTION_SELECT) {
super(scene, mode);
}
getWindowWidth(): integer {
return 64;
}
show(args: any[]): boolean {
if (args[0].modalContainer) {
const { modalContainer } = args[0];
const show = super.show(args);
this.modalContainer = modalContainer;
this.setupOptions();
return show;
}
return false;
}
protected setupOptions() {
super.setupOptions();
if (this.modalContainer) {
this.optionSelectContainer.setSize(this.optionSelectContainer.height, Math.max(this.optionSelectText.displayWidth + 24, this.getWindowWidth()));
this.optionSelectContainer.setPositionRelative(this.modalContainer, this.optionSelectBg.width, this.optionSelectBg.height + 50);
}
}
processInput(button: Button): boolean {
// the cancel and action button are here because if you're typing, x and z are used for cancel/action. This means you could be typing something and accidentally cancel/select when you don't mean to
// the submit button is therefore used to select a choice (the enter button), though this does not work on my local dev testing for phones, as for my phone/keyboard combo, the enter and z key are both
// bound to Button.ACTION, which makes this not work on mobile
if (button !== Button.CANCEL && button !== Button.ACTION) {
return super.processInput(button);
}
return false;
}
}

View File

@ -21,6 +21,8 @@ export default class BattleMessageUiHandler extends MessageUiHandler {
public movesWindowContainer: Phaser.GameObjects.Container;
public nameBoxContainer: Phaser.GameObjects.Container;
public readonly wordWrapWidth: number = 1780;
constructor(scene: BattleScene) {
super(scene, Mode.MESSAGE);
}
@ -63,7 +65,7 @@ export default class BattleMessageUiHandler extends MessageUiHandler {
const message = addTextObject(this.scene, 0, 0, "", TextStyle.MESSAGE, {
maxLines: 2,
wordWrap: {
width: 1780
width: this.wordWrapWidth
}
});
messageContainer.add(message);
@ -129,7 +131,7 @@ export default class BattleMessageUiHandler extends MessageUiHandler {
this.commandWindow.setVisible(false);
this.movesWindowContainer.setVisible(false);
this.message.setWordWrapWidth(1780);
this.message.setWordWrapWidth(this.wordWrapWidth);
return true;
}
@ -161,7 +163,9 @@ export default class BattleMessageUiHandler extends MessageUiHandler {
}
showDialogue(text: string, name?: string, delay?: integer | null, callback?: Function, callbackDelay?: integer, prompt?: boolean, promptDelay?: integer) {
name && this.showNameText(name);
if (name) {
this.showNameText(name);
}
super.showDialogue(text, name, delay, callback, callbackDelay, prompt, promptDelay);
}

View File

@ -2,7 +2,7 @@ import BattleScene, { bypassLogin } from "../battle-scene";
import { TextStyle, addTextObject, getTextStyleOptions } from "./text";
import { Mode } from "./ui";
import * as Utils from "../utils";
import { addWindow } from "./ui-theme";
import { addWindow, WindowVariant } from "./ui-theme";
import MessageUiHandler from "./message-ui-handler";
import { OptionSelectConfig, OptionSelectItem } from "./abstact-option-select-ui-handler";
import { Tutorial, handleTutorial } from "../tutorial";
@ -11,6 +11,7 @@ import i18next from "i18next";
import { Button } from "#enums/buttons";
import { GameDataType } from "#enums/game-data-type";
import BgmBar from "#app/ui/bgm-bar";
import AwaitableUiHandler from "./awaitable-ui-handler";
enum MenuOptions {
GAME_SETTINGS,
@ -31,6 +32,10 @@ const githubUrl = "https://github.com/pagefaultgames/pokerogue";
const redditUrl = "https://www.reddit.com/r/pokerogue";
export default class MenuUiHandler extends MessageUiHandler {
private readonly textPadding = 8;
private readonly defaultMessageBoxWidth = 220;
private readonly defaultWordWrapWidth = 1224;
private menuContainer: Phaser.GameObjects.Container;
private menuMessageBoxContainer: Phaser.GameObjects.Container;
private menuOverlay: Phaser.GameObjects.Rectangle;
@ -46,11 +51,14 @@ export default class MenuUiHandler extends MessageUiHandler {
protected manageDataConfig: OptionSelectConfig;
protected communityConfig: OptionSelectConfig;
// Windows for the default message box and the message box for testing dialogue
private menuMessageBox: Phaser.GameObjects.NineSlice;
private dialogueMessageBox: Phaser.GameObjects.NineSlice;
protected scale: number = 0.1666666667;
public bgmBar: BgmBar;
constructor(scene: BattleScene, mode: Mode | null = null) {
super(scene, mode);
@ -131,20 +139,27 @@ export default class MenuUiHandler extends MessageUiHandler {
this.menuMessageBoxContainer = this.scene.add.container(0, 130);
this.menuMessageBoxContainer.setName("menu-message-box");
this.menuMessageBoxContainer.setVisible(false);
this.menuContainer.add(this.menuMessageBoxContainer);
const menuMessageBox = addWindow(this.scene, 0, -0, 220, 48);
menuMessageBox.setOrigin(0, 0);
this.menuMessageBoxContainer.add(menuMessageBox);
// Window for general messages
this.menuMessageBox = addWindow(this.scene, 0, 0, this.defaultMessageBoxWidth, 48);
this.menuMessageBox.setOrigin(0, 0);
this.menuMessageBoxContainer.add(this.menuMessageBox);
const menuMessageText = addTextObject(this.scene, 8, 8, "", TextStyle.WINDOW, { maxLines: 2 });
// Full-width window used for testing dialog messages in debug mode
this.dialogueMessageBox = addWindow(this.scene, -this.textPadding, 0, this.scene.game.canvas.width / 6 + this.textPadding * 2, 49, false, false, 0, 0, WindowVariant.THIN);
this.dialogueMessageBox.setOrigin(0, 0);
this.menuMessageBoxContainer.add(this.dialogueMessageBox);
const menuMessageText = addTextObject(this.scene, this.textPadding, this.textPadding, "", TextStyle.WINDOW, { maxLines: 2 });
menuMessageText.setName("menu-message");
menuMessageText.setWordWrapWidth(1224);
menuMessageText.setOrigin(0, 0);
this.menuMessageBoxContainer.add(menuMessageText);
this.message = menuMessageText;
// By default we use the general purpose message window
this.setDialogTestMode(false);
this.menuContainer.add(this.menuMessageBoxContainer);
const manageDataOptions: any[] = []; // TODO: proper type
@ -257,8 +272,55 @@ export default class MenuUiHandler extends MessageUiHandler {
return true;
},
keepOpen: true
});
if (Utils.isLocal || Utils.isBeta) { // this should make sure we don't have this option in live
manageDataOptions.push({
label: "Test Dialogue",
handler: () => {
ui.playSelect();
const prefilledText = "";
const buttonAction: any = {};
buttonAction["buttonActions"] = [
(sanitizedName: string) => {
ui.revertMode();
ui.playSelect();
const dialogueTestName = sanitizedName;
const dialogueName = decodeURIComponent(escape(atob(dialogueTestName)));
const handler = ui.getHandler() as AwaitableUiHandler;
handler.tutorialActive = true;
const interpolatorOptions: any = {};
const splitArr = dialogueName.split(" "); // this splits our inputted text into words to cycle through later
const translatedString = splitArr[0]; // this is our outputted i18 string
const regex = RegExp("\\{\\{(\\w*)\\}\\}", "g"); // this is a regex expression to find all the text between {{ }} in the i18 output
const matches = i18next.t(translatedString).match(regex) ?? [];
if (matches.length > 0) {
for (let match = 0; match < matches.length; match++) {
// we add 1 here because splitArr[0] is our first value for the translatedString, and after that is where the variables are
// the regex here in the replace (/\W/g) is to remove the {{ and }} and just give us all alphanumeric characters
if (typeof splitArr[match + 1] !== "undefined") {
interpolatorOptions[matches[match].replace(/\W/g, "")] = i18next.t(splitArr[match + 1]);
}
}
}
// Switch to the dialog test window
this.setDialogTestMode(true);
ui.showText(String(i18next.t(translatedString, interpolatorOptions)), null, () => this.scene.ui.showText("", 0, () => {
handler.tutorialActive = false;
// Go back to the default message window
this.setDialogTestMode(false);
}), null, true);
},
{
() => {
ui.revertMode();
}
];
ui.setMode(Mode.TEST_DIALOGUE, buttonAction, prefilledText);
return true;
},
keepOpen: true
});
}
manageDataOptions.push({
label: i18next.t("menuUiHandler:cancel"),
handler: () => {
this.scene.ui.revertMode();
@ -547,6 +609,21 @@ export default class MenuUiHandler extends MessageUiHandler {
return success || error;
}
/**
* Switch the message window style and size when we are replaying dialog for debug purposes
* In "dialog test mode", the window takes the whole width of the screen and the text
* is set up to wrap around the same way as the dialogue during the game
* @param isDialogMode whether to use the dialog test
*/
setDialogTestMode(isDialogMode: boolean) {
this.menuMessageBox.setVisible(!isDialogMode);
this.dialogueMessageBox.setVisible(isDialogMode);
// If we're testing dialog, we use the same word wrapping as the battle message handler
this.message.setWordWrapWidth(isDialogMode ? this.scene.ui.getMessageHandler().wordWrapWidth : this.defaultWordWrapWidth);
this.message.setX(isDialogMode ? this.textPadding + 1 : this.textPadding);
this.message.setY(isDialogMode ? this.textPadding + 0.4 : this.textPadding);
}
showText(text: string, delay?: number, callback?: Function, callbackDelay?: number, prompt?: boolean, promptDelay?: number): void {
this.menuMessageBoxContainer.setVisible(!!text);

View File

@ -0,0 +1,147 @@
import { FormModalUiHandler } from "./form-modal-ui-handler";
import { ModalConfig } from "./modal-ui-handler";
import i18next from "i18next";
import { PlayerPokemon } from "#app/field/pokemon";
import { OptionSelectItem } from "./abstact-option-select-ui-handler";
import { isNullOrUndefined } from "#app/utils";
import { Mode } from "./ui";
export default class TestDialogueUiHandler extends FormModalUiHandler {
keys: string[];
constructor(scene, mode) {
super(scene, mode);
}
setup() {
super.setup();
const flattenKeys = (object, topKey?: string, midleKey?: string[]): Array<any> => {
return Object.keys(object).map((t, i) => {
const value = Object.values(object)[i];
if (typeof value === "object" && !isNullOrUndefined(value)) { // we check for not null or undefined here because if the language json file has a null key, the typeof will still be an object, but that object will be null, causing issues
// If the value is an object, execute the same process
// si el valor es un objeto ejecuta el mismo proceso
return flattenKeys(value, topKey ?? t, topKey ? midleKey ? [...midleKey, t] : [t] : undefined).filter((t) => t.length > 0);
} else if (typeof value === "string" || isNullOrUndefined(value)) { // we check for null or undefined here as per above - the typeof is still an object but the value is null so we need to exit out of this and pass the null key
// Return in the format expected by i18next
return midleKey ? `${topKey}:${midleKey.map((m) => m).join(".")}.${t}` : `${topKey}:${t}`;
}
}).filter((t) => t);
};
const keysInArrays = flattenKeys(i18next.getDataByLanguage(String(i18next.resolvedLanguage))).filter((t) => t.length > 0); // Array of arrays
const keys = keysInArrays.flat(Infinity).map(String); // One array of string
this.keys = keys;
}
getModalTitle(config?: ModalConfig): string {
return "Test Dialogue";
}
getFields(config?: ModalConfig): string[] {
return [ "Dialogue" ];
}
getWidth(config?: ModalConfig): number {
return 300;
}
getMargin(config?: ModalConfig): [number, number, number, number] {
return [ 0, 0, 48, 0 ];
}
getButtonLabels(config?: ModalConfig): string[] {
return [ "Check", "Cancel" ];
}
getReadableErrorMessage(error: string): string {
const colonIndex = error?.indexOf(":");
if (colonIndex > 0) {
error = error.slice(0, colonIndex);
}
return super.getReadableErrorMessage(error);
}
show(args: any[]): boolean {
const ui = this.getUi();
const input = this.inputs[0];
input.setMaxLength(255);
input.on("keydown", (inputObject, evt: KeyboardEvent) => {
if (["escape", "space"].some((v) => v === evt.key.toLowerCase() || v === evt.code.toLowerCase()) && ui.getMode() === Mode.AUTO_COMPLETE) {
// Delete autocomplete list and recovery focus.
inputObject.on("blur", () => inputObject.node.focus(), { once: true });
ui.revertMode();
}
});
input.on("textchange", (inputObject, evt: InputEvent) => {
// Delete autocomplete.
if (ui.getMode() === Mode.AUTO_COMPLETE) {
ui.revertMode();
}
let options: OptionSelectItem[] = [];
const splitArr = inputObject.text.split(" ");
const filteredKeys = this.keys.filter((command) => command.toLowerCase().includes(splitArr[splitArr.length - 1].toLowerCase()));
if (inputObject.text !== "" && filteredKeys.length > 0) {
// if performance is required, you could reduce the number of total results by changing the slice below to not have all ~8000 inputs going
options = filteredKeys.slice(0).map((value) => {
return {
label: value,
handler: () => {
// this is here to make sure that if you try to backspace then enter, the last known evt.data (backspace) is picked up
// this is because evt.data is null for backspace, so without this, the autocomplete windows just closes
if (!isNullOrUndefined(evt.data) || evt.inputType?.toLowerCase() === "deletecontentbackward") {
const separatedArray = inputObject.text.split(" ");
separatedArray[separatedArray.length - 1] = value;
inputObject.setText(separatedArray.join(" "));
}
ui.revertMode();
return true;
}
};
});
}
if (options.length > 0) {
const modalOpts = {
options: options,
maxOptions: 5,
modalContainer: this.modalContainer
};
ui.setOverlayMode(Mode.AUTO_COMPLETE, modalOpts);
}
});
if (super.show(args)) {
const config = args[0] as ModalConfig;
this.inputs[0].resize(1150, 116);
this.inputContainers[0].list[0].width = 200;
if (args[1] && typeof (args[1] as PlayerPokemon).getNameToRender === "function") {
this.inputs[0].text = (args[1] as PlayerPokemon).getNameToRender();
} else {
this.inputs[0].text = args[1];
}
this.submitAction = (_) => {
if (ui.getMode() === Mode.TEST_DIALOGUE) {
this.sanitizeInputs();
const sanitizedName = btoa(unescape(encodeURIComponent(this.inputs[0].text)));
config.buttonActions[0](sanitizedName);
return true;
}
return false;
};
return true;
}
return false;
}
}

View File

@ -49,6 +49,8 @@ import RenameFormUiHandler from "./rename-form-ui-handler";
import AdminUiHandler from "./admin-ui-handler";
import RunHistoryUiHandler from "./run-history-ui-handler";
import RunInfoUiHandler from "./run-info-ui-handler";
import TestDialogueUiHandler from "#app/ui/test-dialogue-ui-handler";
import AutoCompleteUiHandler from "./autocomplete-ui-handler";
export enum Mode {
MESSAGE,
@ -89,6 +91,8 @@ export enum Mode {
RENAME_POKEMON,
RUN_HISTORY,
RUN_INFO,
TEST_DIALOGUE,
AUTO_COMPLETE,
ADMIN,
}
@ -127,6 +131,8 @@ const noTransitionModes = [
Mode.UNAVAILABLE,
Mode.OUTDATED,
Mode.RENAME_POKEMON,
Mode.TEST_DIALOGUE,
Mode.AUTO_COMPLETE,
Mode.ADMIN,
];
@ -191,6 +197,8 @@ export default class UI extends Phaser.GameObjects.Container {
new RenameFormUiHandler(scene),
new RunHistoryUiHandler(scene),
new RunInfoUiHandler(scene),
new TestDialogueUiHandler(scene, Mode.TEST_DIALOGUE),
new AutoCompleteUiHandler(scene),
new AdminUiHandler(scene),
];
}