#Creating a custom language editor UI mod

1 messages · Page 1 of 1 (latest)

kindred pivot
#

some discussion on steam before: https://steamcommunity.com/workshop/discussions/18446744073709551615/5514142341086719313/?appid=379210&tscn=1686799055

Some of implementation can be refer with debug tools, though not so easy to read all of them; but some more can not found in wiki for now

The base struct I suppose is:

Main -> listen to init event and register a button on main menu(for ex. under "multi player", as a individual row group)
Here: which obj should I use to access and modify the menu

When click the button, create and jump to a new empty screen, with some UI components
Here: How screen and change works. I guess it should use ui/screen/ScreenManager, but not found which instance should I reuse here. And exit to main menu should use which one

Functional as a simple excel.
Read default language file as base, lang file loaded by other mods for compared ones, create a copy and edit/import/export in-game.
Here: Can lang instance edit in-game and loaded without re-start the game?
Do not sure if can give message preview as test case without actually start a game, reuse the game's test case if have.

spice panther
#

Yep UI mods are pretty rare so I haven’t had much demand for guides on them. It’s bedtime for me right now so what I’ll probably do is when I wake up tomorrow I’ll go through the stuff you’ve listed above and get you a simple working example for each.
And regarding refreshing language without reloading the whole game, I’ll add support for that on the development branch tomorrow too, that sounds like a hugely beneficial feature that would honestly help the wayward team’s development too haha

#

Creating a custom language editor UI mod

#

(Making the post title one people can keep track of better)

spice panther
#

Alrighty taking a look at this stuff now

spice panther
#

The following guides assume you've created an empty mod from a template. Most of where you'll be working in is your generated Mod.ts file — your main class that extends Mod. Also note that for completeness's sake I'm going to go through probably more steps than you need. Bare with me:

Setting up custom texts

Mods can register a custom "dictionary" which has a bunch of entries that can be translated the same way as anything in the base game's language system.

Creating a dictionary enum

This enum will be filled with an entry for each translatable text in your mod

enum MyModTranslation {
    MenuButtonMainOpenLanguageMenu,
}

Registering the enum as a dictionary

// inside your mod class
@Register.dictionary("myModDictionary", MyModTranslation)
public readonly myModDictionary: Dictionary;

Translating the dictionary entry

In your case your mod can just extend your "简体中文" or "繁體中文" languages if you want — should be the same thing you're used to there but using a dictionary key of "mod<your mod name><your dictionary name>" so like in my example it'd be something like "modMyModNameMyModDictionary"

#

Adding a button to the main menu

By default, event handlers that you use on your mod are only "registered" into the game's event bus when the mod is loaded. That means, when the user has loaded up a save slot, so they're in the game world. In your case though, your mod is going to function in the menus outside of the actual game, so you want to manually register them early:

public override onInitialize(): void {
    this.registerEventHandlers("uninitialize");
}

The above registers the event handlers on "initialize", ie when the mod is enabled. The parameter passed to registerEventHandlers is the event that will cause event handlers to be deregistered. In this case we're using "uninitialize" because that's when the mod is disabled.

Actually adding the button

All screens have a "menu manager", and can therefore show any menu. This event handler doesn't actually care which screen is showing the main menu, just that the main menu was shown. It receives the menu shown as a parameter.

@EventHandler(MenuManager, "show")
protected onShowMenu(manager: MenuManager, menu: Menu) {
    if (menu.menuId !== MenuId.Main)
        // not main menu, don't do anything
        return;

    // this is the main menu, so let's add a new button to it!
    new Button()
        .setText(Translation.get(this.myModDictionary, MyModTranslation.MenuButtonMainOpenLanguageMenu))
        .appendTo(menu.content);
}

We then append the custom button to the menu's content. That just makes it the last button in the main menu. If need help adjusting its position we can go over that later.

#

Making the button go to a new menu

I know you said you wanted a new screen, but it's probably simpler for now to go to a new menu instead — the modding support for custom screens is not great. (IE it'll be more like the existing menus Load Game, Options, Mods, About, etc, rather than being an empty sandbox for you to do anything in.)

Making a custom menu

class MyMenu extends Menu {
    public constructor() {
        super("my custom language menu");
        // add custom language content here like buttons and inputs and stuff
    }
}

Making the button added previously go to your custom menu

The "activate" event is emitted by all buttons when they're clicked or the enter or space keys are pressed when the button has focus. In this case we add an event listener that shows your custom menu

new Button()
    .setText(Translation.get(this.myModDictionary, MyModTranslation.MenuButtonMainOpenLanguageMenu))
        // vvv new! vvv
    .event.subscribe("activate", () => menu.getScreen()?.menus.show(new MyMenu()))
    .appendTo(menu.content);
#

When going back from the custom menu, looks like the button is getting re-added...

Preventing the custom button from getting injected into the main menu multiple times

This can be fixed by simply adding a class to the main menu to mark it as having been injected into.

@EventHandler(MenuManager, "show")
protected onShowMenu(manager: MenuManager, menu: Menu) {
    if (menu.menuId !== MenuId.Main)
        // not main menu, don't do anything
        return;

    if (menu.classes.has("added-language-button"))
      // already added the language button, so don't do anything
      return;

    menu.classes.add("added-language-button");

    // this is the main menu, so let's add a new button to it!
    new Button()
        .setText(Translation.get(this.myModDictionary, MyModTranslation.MenuButtonMainOpenLanguageMenu))
        .event.subscribe("activate", () => menu.getScreen()?.menus.show(new MyMenu()))
        .appendTo(menu.content);
}
#

Using translations from your custom dictionary in your custom menu

On your mod class:

@Mod.instance()
public static INSTANCE: MyModName;

In your menu constructor, let's, for example, set the title:

this.setTitle(title => title
    .setText(Translation.get(MyModName.INSTANCE.myModDictionary, MyModTranslation.MenuLanguageMenuTitle)));

The INSTANCE field is injected with the single instance of your mod that the game creates, which is useful so that you can access your registrations from other places. In this case, we needed to reference the ID of myModDictionary

#

Accessing language files & stuff

You have a couple options here — loading the JSON files manually, or looking through the already-deserialised "translation providers" that we turn the JSON files into.

  1. Look through the mods. game.modManager.mods/loadedMods/etc
  2. For each mod, look through the mod.languages array
  3. Each language in the mod is an object containing the path — the JSON file that stores the language — and the instance, which is the deserialised "translation provider" if the mod is enabled.
  • If you want to load the JSON file yourself, you can use Files.getJson<ISerializedLanguage | ISerializedLanguageExtension>(language.path!)
kindred pivot
#

Quite helpful! I'll try some more later have time.

spice panther
#

If you want import/export buttons like you described let me know when you get to that point, because that bit is kinda annoyingly complicated

#

Never set up a nice way of doing that for some reason

kindred pivot
kindred pivot
#

Some more thoughts about inner test cases mentioned in my last line

I see there is TARS mod and Debug Tools mod. It can be someone combine the both.
Just like, make a new panel named "Test run" in debug tools, with some buttons to make player itself do some more actions(including "cheat" ones provided by debug tools).

For example, Test case "Test Do Action"

  • Select "Test Attack" and click run, then will auto summon a creature and attack them by given times (such as 10 by default)
  • Some options available, like:
  • Make the creature health full/summon a new one if killed before next attack
  • Make weapon can not be destroyed
  • Walk through all creatures by auto.

This way can held for most cases I think, but if want to check & simulate for some special cases like connection failed, multiplayer, mod loader, this will break. But still quite useful enough.

I try to go through the idea with github projects before, but notice it seems quite difficult for me with lack information. Go through the TS code not good at also quite annoying, as I use java/python for more - _ -

For more, the test case list can just be similar with quick action. What the tool do is:

  1. Check and set for some settings just in tool's UI panel, with no need to open related menu again and again
  2. Define a auto run process like tars robot do, run with only a press
  3. Extends tars robot, let it do some "gods" command given in debug tools, like kill/summon/give a new item/give a specific item/change the terrain of the world.

This idea come with my work of translation. Some messages given in lang files are not found in game even after hundreds hours of gaming. Each time I check how the translation works in game, I want a tool, which can generate all or most messages in game by just a click.

kindred pivot
#

Is this the only way using "EventHandler" catch the "MenuManager" to obtain the manager or main menu object?
I think the process should be, obtain manager by some static variables, then access its add/remove method to edit menu when init/un-init event calls.

spice panther
#

I think this is probably the best way of doing this

#

You could try @EventHandler(MainMenu, "append"), but I'm not sure it will work right and it's functionally the same thing

#

There's no init event

kindred pivot
spice panther
#

That would be very fragile

#

Due to the way menus are initialised

#

That would mean that if someone loads a save slot, then exits back to main menu, the button wouldn't appear the second time

kindred pivot
spice panther
#

Yeah nearly all UI components are instantiated as needed and discarded when not

kindred pivot
#

Alright, so I guess for this case is to extend some things for inner main menu, but directly use logic related with start screen is not so easy for mods. Also there is no easy ways to inject something when menu is created by manager, then finally choose listen for drawing events as instead.
Is it correct?

spice panther
#

Oh the menumanager emits an init event

#

Uhh try the same thing I originally suggested but use "init" instead of "show"

#

You can inject code into any method in the entire game, even private ones, but it's kinda complicated

#

To do that you need to use @Inject

kindred pivot
#

OK. Use "init" make the process looks much more pretty.
I do not know much about ts/js inject, but all things comes much more complex in my java/python experience. I hate that ermmmm... I think my case may not really need that.

spice panther
#

That's not a ts/js thing, that injection functionality is something I made to allow for people to do more with their mods chiritongue

kindred pivot
#

Oh I make a mistake. I think it as something that inject codes dynamic when program is running

spice panther
#

Well it kinda does? When your mod is initialised @Inject replaces the method you're injecting with a replacement function that will call your method and any other injections and the original method depending on how they're configured

#

It's not ideal but like it's not really possible to know every single place in the game where someone might need an event emitted for their mod

kindred pivot
#

I have do some test;
Notice that main menu itself will not be re-init by menu manager after mod enabled, make handle event "init" only take effect after start game then exit to start screen...

spice panther
#

Ahhh right

#

Yeah the ugly "show" stuff is probably best then chiritongue

kindred pivot
#

I guess toomana

#

If do the logic in "show", "init" turns to useless then.

#

Feel quite shame about that.

kindred pivot
#

Some more test yet.

#

Wondering what happens when back from mod menu to main menu, since log catching all of the 3 event give nothing about it

spice panther
#

You'd want to listen for another "show" event on the same MenuManager

#

Oh wait I think I misunderstood

#

Hmm

kindred pivot
#

Which may means, add button for main menu MUST be done in onInitialize() method, ever else it will not add the button if just enable the mod then back to main menu, without start game.

spice panther
#

I think in that case since "show" doesn't catch everything, best bet is listening for MenuManager "init" and on first onInitialize, checking to see if any screens have the main menu and if so doing it there too

#
    public override onInitialize (): void {
        for (const menu of ui.screens.all().flatMap(screen => Object.values(screen.menus.all)).toArray()) {
            this.handleMenuInit(undefined, menu);
        }
    }
    
    @EventHandler(MenuManager, "init")
    public handleMenuInit (manager: MenuManager | undefined, menu: Menu) {
        if (menu.menuId === MenuId.Main) {
            // apply to menu
        }
    }
#

Sorry this is so ugly chiriaaaaaaaa

#

To make it remove any button or input or whatever that you injected into other menus when your mod is disabled, you'd have to save a reference to all the components you appended to the menus and call .remove() on each of them

kindred pivot
#

emmmm.... Yeah of course the key point is things happened for mod initialize process.

#

Is there a way to obtain "MenuManager" or other manager instance to get active menu objects? The code seems go through all...

spice panther
#

that's what that code above is

#
ui.screens.all().flatMap(screen => Object.values(screen.menus.all)).toArray()
#

returns an array of every active Menu object

kindred pivot
#

No map/dict for this?

spice panther
#

If you want only MainMenu you'd want

ui.screens.all().map(screen => screen.menus.all[MenuId.Main]).filterNullish().toArray()
#

In the code above that wasn't necessary cuz it gets filtered out already by handleMenuInit

kindred pivot
#

So the current working/showing screen can not got directly?

spice panther
#

you could do that if you want, but it's a bit more fragile

#

that would then just be ui.screens.getTop()?.menus.all[MenuId.Main]

kindred pivot
#

Or @inject for main menu seems more easily for this?

spice panther
#

It's fragile because getTop can also return the interrupt screen

#

Like, when the game asks you "are you sure you want to x? y/n"

#

that's a different screen type

#

so for example if there was somehow a race condition for enabling the mod and showing an interrupt screen getTop could return that screen instead of the one you want

#

I don't see why it's bad to just loop over all screens?

#

Catches all instances of MainMenu

#

No matter what

kindred pivot
#

Here we just need to check for

  • When click enable at mod menu
  • When start program with mod enabled for last exit
spice panther
#
  • when going back to main menu from a save game
kindred pivot
spice panther
#

"init" should cover that and at start

kindred pivot
#

Well good news for thatwaywardSpike

#

Alright it comes much more complex than I supposed.
I guess things should run as:

  • A singleton instance manage all menus, all others will call that when need to create/show a menu
  • Main menu is the only instance of that, all other catch a reference when using
  • When enable the mod, edit the created instance; at the same time append the process to create case
spice panther
#

Yeah it's a bit more complex than that because rather than the UI components managing themselves, they also are themselves, so if one exists, it generally also exists in the tree along with everything inside it and that can get pretty memory intensive esp. for things like the options menu

#

That said I do want to rework most of the screen/menu stuff because it itself is pretty fragile right now

#

It's just been low priority for a long time

kindred pivot
#

Notice there is a menu manager, is that singleton instance like screen manager? Things will be better if using that I guess

spice panther
#

Each screen contains its own MenuManager

#

that's what screen.menus is

#

(so no it's not a singleton)

kindred pivot
#

Oh, that should belong to game but not screen I think.

#

(Pretty shame)

spice panther
#

That wouldn't work

#

Because interrupt screens are the same as the main menu screen

#

both of them can have their own layered menus

#

You know how when in-game you can press Escape to open the pause menu, and from that go to the options menu? That's in the InterruptScreen, using its own menu manager, because that chain of menus is isolated to that screen

#

If it was all the same, in like for example the options menu in the main menu, when you do something that is like "are you sure you want to x? y/n", it would hide the options menu and go to a new menu instead of opening it up as an interrupt

kindred pivot
#

So that means some menu may created multiple times, different screen may have the same menu but with different instance, instead of just hide/show for same instance?
So if we need to modify one of them, a traversal is necessary?

spice panther
#

Not sure what you mean by traversal being necessary, but yeah, multiple screens could have multiple instances of the same menu at the same time

#

tada i just forcibly showed one instance of the options menu over another lmao

kindred pivot
#

Alright, I think this is not a good design...

#

I think I have understood most of that, will think and do some more test again later

spice panther
#

chirishrug tbh to me it sounds needlessly complex to try to replicate the current functionality with a singleton controlling the lifecycle of each menu and preventing the menu from being duplicated

#

the current structure is very loose and therefore allows basically anything

#

which is pretty useful because it means i don't have to touch it very often when i'm changing what the menus do

kindred pivot
#

Yeah, it is ok and functional enough if just let it running for most case.
But for some extends cases may not very convenience.
As for me, I will think the same and lack for power to refactor if face the finished code. Take too much time but give too little things back.

kindred pivot
#

I have did some more work these days, and change some of my idea.

#

It seems not so necessary to just add a menu in game, I just made a simple UI runner by python itself. For some advanced functions, maybe use excel as mid-editor is a better way(I guess game itself not implement editor UI functions needed in that case), or just edit json with vscode good enough for most cases

#

If integration the editor with game itself, the KEY object should be "do test". If not, just use py-qt-gui or excel or other tools is quite much more easy, simple and efficiency for development.