# @yagejs-addons/i18n — Localization

Translate every string a player reads through one service, and switch language
at any moment: a HUD label, an imperative menu, a React screen, the dialogue
line currently typing, an open choice menu, and an open inventory panel all
swap text in place. No event fires, no line replays.

Engine packages carry no localization code. The addon owns the message type,
the service contract, an i18next backend, and one localized class per engine
text surface. Each engine peer sits behind its own subpath, so the root entry
pulls neither pixi nor React.

## Install

```bash
npm install @yagejs-addons/i18n
```

Peers: `@yagejs/core` (required); `@yagejs/renderer`, `@yagejs/ui`,
`@yagejs/ui-react`, `react`, `@yagejs-addons/inventory` (optional, one per
subpath). `i18next` is bundled.

| Entry         | Needs                         | Contains                                                                                                |
| ------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------- |
| `.`           | `@yagejs/core`                | `msg`, `Message`, `Localization`, `LocalizationKey`, `createLocalization`, `LocalizationPlugin`         |
| `./renderer`  | + `@yagejs/renderer`          | `LocalizedTextComponent`, `LocalizedSplitTextComponent`                                                 |
| `./ui`        | + `@yagejs/ui`                | `LocalizedUISurface`, one localized class per text-bearing widget, `localizedTooltip`, `relocalizeTree` |
| `./ui-react`  | + `@yagejs/ui-react`, `react` | `useLocalization`, `<Trans>`, `<LocalizedPixiSelect>`                                                   |
| `./inventory` | + `@yagejs-addons/inventory`  | `localizeInventoryPanel` and the per-presenter wrappers                                                 |

## Boot

```ts yage-group="boot"
import { Engine } from "@yagejs/core";
import { createLocalization, LocalizationPlugin } from "@yagejs-addons/i18n";

const localization = await createLocalization({
  locale: "en",
  fallbackLocale: "en",
  catalogs: {
    en: {
      "hud.hp": "HP {hp}",
      apples_one: "{count} apple",
      apples_other: "{count} apples",
    },
    it: {
      "hud.hp": "PV {hp}",
      apples_one: "{count} mela",
      apples_other: "{count} mele",
    },
  },
});
const engine = new Engine();
engine.use(new LocalizationPlugin(localization)); // before RendererPlugin / UIPlugin is fine either way
```

`createLocalization(options): Promise<Localization>`. Catalogs are preloaded;
keys may nest (`{ hud: { hp } }` is `"hud.hp"`); `.` is the only key separator,
so a `:` is part of the key (`"line:abc"`, the form Yarn Spinner line ids take). A regional tag (`pt-BR`) uses
its parent's catalog when it has none of its own. Plural forms follow i18next:
`key_one` / `key_other`, chosen by a numeric `count` value. Creation throws on a
missing initial or fallback catalog; `setLocale` throws on an unknown locale
before changing anything.

```ts yage-group="boot"
localization.locale; // "en"
localization.setLocale("it"); // every localized text updates before this returns
localization.subscribe(() => console.log(localization.locale)); // after each change; returns the unsubscribe
```

## Messages

```ts
import { msg } from "@yagejs-addons/i18n";

const hp = msg("hud.hp", "HP {hp}", { hp: 55 }); // { key, fallback, values }
```

`msg(key, fallback, values?)` returns frozen data and translates nothing. The
fallback shows when no catalog has the key, or when no plugin is installed.
Values are strings, finite numbers, booleans, or `null`; `{name}` tokens in the
catalog entry (or the fallback) are replaced from them, unknown tokens stay as
written. `resolve(message, values)` merges call-time `values` over the
message's own.

`formatText(text, values)` interpolates a plain string; `isMessage(x)` guards.

## Service contract — any backend

```ts
import type {
  Localization as BaseLocalization,
  Message,
  MessageValues,
} from "@yagejs-addons/i18n";

interface Localization extends BaseLocalization {
  readonly locale: string;
  resolve(text: Message | string, values?: MessageValues): string;
  setLocale(locale: string): void;
  subscribe(listener: () => void): () => void;
}
```

Pass any object with this shape to `LocalizationPlugin` (FormatJS, Fluent, a
hand-written table). The plugin registers it under `LocalizationKey`
(`ServiceKey<Localization>("localization")`) and runs the update pass on each
`subscribe` notification; the pass runs inside the engine's error boundary, so a
backend never wraps anything itself.

## The update pass

On each locale change the plugin walks every scene in the stack (paused ones
included) and calls `relocalize(resolve)` on every component that implements
it. The localized classes below do; so can a game component:

```ts
import { Component } from "@yagejs/core";
import { TextComponent } from "@yagejs/renderer";
import {
  msg,
  type MessageResolver,
  type Relocalizable,
} from "@yagejs-addons/i18n";

class ScoreLabel extends Component implements Relocalizable {
  score = 0;
  private readonly label = this.sibling(TextComponent);

  relocalize(resolve: MessageResolver): void {
    this.label.setText(
      resolve(msg("hud.score", "Score {n}", { n: this.score })),
    );
  }
}
```

Nothing subscribes per text object, so there is nothing to unsubscribe. A
pass reaches components on inactive entities too, so their text is current
when the entity is enabled. `plugin.relocalizeAll()` runs the pass on demand.

## Renderer — `./renderer`

```ts yage-context="scene"
import { Transform } from "@yagejs/core";
import { msg } from "@yagejs-addons/i18n";
import {
  LocalizedTextComponent,
  LocalizedSplitTextComponent,
} from "@yagejs-addons/i18n/renderer";

const hud = scene.spawn("hud");
hud.add(new Transform());
const label = hud.add(
  new LocalizedTextComponent({
    message: msg("hud.hp", "HP {hp}", { hp: 55 }),
    style: { fontSize: 16, fill: 0xffffff },
  }),
);
label.setMessage(msg("hud.hp", "HP {hp}", { hp: 40 })); // resolved for the current locale
label.message; // the Message
label.content; // the string on screen (inherited)
```

Same options as `TextComponent` / `SplitTextComponent` with `message` in place
of `text`. The text resolves when the component joins a scene and on every
locale change; a split text re-splits.

## Imperative UI — `./ui`

```ts yage-context="entity,scene"
import { Anchor } from "@yagejs/ui";
import { msg } from "@yagejs-addons/i18n";
import { LocalizedUISurface, localizedTooltip } from "@yagejs-addons/i18n/ui";

declare function start(): void; // the game's start action

const menu = entity.add(
  new LocalizedUISurface({ anchor: Anchor.Center, padding: 12 }),
);
menu.text(msg("menu.title", "Observatory"), { fontSize: 24 });
const button = menu.button(msg("menu.start", "Start"), { onClick: start });
const settings = menu.panel({ direction: "row" });
menu.text(
  msg("menu.language", "Language"),
  undefined,
  { width: 120 },
  settings,
); // fourth argument: the parent panel

const tip = localizedTooltip(
  button,
  scene,
  msg("menu.start.tip", "Begin a new game"),
);
button.update({ onHover: tip.setActive });
```

`LocalizedUISurface` is a `UISurface` whose `text` and `button` take a
`Message` as well as a string. `text(content, style?, opts?, parent?)` keeps
`UISurface.text`'s `UITextBuilderProps` as its third argument; the optional fourth
argument selects a parent panel. A `Message` builds a `UILocalizedText` or a
`UILocalizedButton`. On each locale change the surface walks its whole element
tree (`relocalizeTree`) and calls `relocalize` on every element that implements
it, however deeply nested. Elements built before the surface joins a scene show
their fallback until then. Only a `LocalizedUISurface` walks its tree: a
localized element under a plain `UISurface` or a React `UIRoot` is never
updated.

Every `@yagejs/ui` widget that shows text has a localized counterpart, usable
on its own or under a surface. Each takes its text as a `Message`, an optional
`resolve` for the initial value, and implements `relocalize`. Read the text back
with `message` and replace it with `setMessage`; `LocalizedPixiSelect` and
`LocalizedPixiRadioGroup` use `messages` / `setMessages`. On the radio group
`setMessages` relabels the rows already there (one message per row) and
`setItems(items, selected?)` replaces them, views and all.

| Widget            | Localized class            | Message prop   | Kept across a locale change                    |
| ----------------- | -------------------------- | -------------- | ---------------------------------------------- |
| `UIText`          | `UILocalizedText`          | `message`      | —                                              |
| `UISplitText`     | `UILocalizedSplitText`     | `message`      | re-splits; take segments again from `onSplit`  |
| `UIButton`        | `UILocalizedButton`        | `message`      | the button's `textStyle`, `bitmap`, `truncate` |
| `UICheckbox`      | `UILocalizedCheckbox`      | `label`        | checked state                                  |
| `PixiSelect`      | `LocalizedPixiSelect`      | `items`        | selected row                                   |
| `PixiFancyButton` | `LocalizedPixiFancyButton` | `text`         | —                                              |
| `PixiCheckbox`    | `LocalizedPixiCheckbox`    | `text`         | checked state                                  |
| `PixiRadioGroup`  | `LocalizedPixiRadioGroup`  | `items[].text` | selected row                                   |
| `PixiInput`       | `LocalizedPixiInput`       | `placeholder`  | typed text; hidden while editing               |

Add one built by hand with `surface.addElement(element, parent?)`, which
resolves it at the current locale straight away. A panel's own
`addElement(element)` also works but leaves the fallback showing until the next
locale change.

`localizedTooltip` builds a bubble on `attachTooltip` and follows the locale
until `dispose` (or the anchor's destruction).

The update pass reaches inactive entities too, so text on a disabled entity is
already current when the entity is enabled.

## React — `./ui-react`

```tsx
import { Button, Panel } from "@yagejs/ui-react";
import { msg } from "@yagejs-addons/i18n";
import {
  Trans,
  useLocalization,
  LocalizedPixiSelect,
} from "@yagejs-addons/i18n/ui-react";

declare function next(): void; // switches to the next locale
declare function pick(index: number): void;

function Settings() {
  const { t, locale } = useLocalization(); // re-renders on each locale change
  return (
    <Panel direction="column" gap={8}>
      <Trans
        message={msg("settings.title", "Settings")}
        style={{ fontSize: 20 }}
      />
      <Button onClick={next}>
        {t(msg("settings.language", "Language: {l}", { l: locale }))}
      </Button>
      <LocalizedPixiSelect
        closedBG="select-closed" // texture keys, as on <PixiSelect>
        openBG="select-open"
        items={[msg("difficulty.easy", "Easy"), msg("difficulty.hard", "Hard")]}
        onSelect={pick}
      />
    </Panel>
  );
}
```

`useLocalization()` reads the service through `useEngine()` and subscribes the
component to locale changes; without a plugin, `t` formats fallbacks and
`locale` is `"und"`. `<Trans>` takes every `<Text>` prop plus `message` and
`values`. `<LocalizedPixiSelect>` keeps the selected row across a change. The
reconciler is untouched.

## Dialogue

Dialogue owns its adapter and needs no import from this addon. Its script
text fields (`text`, `disabledReason`, speaker `name`) accept `{ key,
fallback, values? }`, which is what `msg` returns, and `DialogueController`
finds the service registered under `"localization"` by itself:

```ts yage-context="scene"
import { defineScript, DialogueController } from "@yagejs-addons/dialogue";
import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";
import { msg } from "@yagejs-addons/i18n";

const SCRIPT = defineScript({
  id: "mira",
  start: "intro",
  speakers: { mira: { name: msg("speaker.mira", "Mira") } },
  nodes: {
    intro: {
      id: "intro",
      steps: [
        {
          kind: "say",
          speaker: "mira",
          text: msg("mira.greet", "Welcome, {name}.", { name: "Ari" }),
        },
      ],
    },
  },
});
const npc = scene.spawn("npc");
const dialogue = npc.add(new DialogueController({ ...createBoxDialogue() }));
dialogue.play(SCRIPT);
```

On a locale change the line on screen keeps its reveal progress and an open
choice menu keeps its highlighted row. Details: the dialogue doc, Localization.

## Inventory — `./inventory`

Item and action definitions keep plain strings: the string is the fallback,
the id picks the key.

```ts yage-context="entity"
import {
  defineItems,
  Inventory,
  InventoryController,
} from "@yagejs-addons/inventory";
import {
  createInventoryPanel,
  defaultInventoryTheme,
} from "@yagejs-addons/inventory/presenters";
import { localizeInventoryPanel } from "@yagejs-addons/i18n/inventory";

const ITEMS = defineItems({
  potion: { name: "Potion", description: "Restores health." },
});
const inventory = new Inventory({ catalog: ITEMS });
const theme = defaultInventoryTheme(); // or the game's own InventoryTheme
const bundle = localizeInventoryPanel(createInventoryPanel(theme), {
  item: (id) => `item.${id}.name`,
  description: (id) => `item.${id}.description`,
  action: (id) => `action.${id}`,
  title: "inventory.title", // the controller's `title` string is the fallback
});
entity.add(
  new InventoryController({ ...bundle, inventory, title: "Backpack" }),
);
```

Each wrapper (`localizedSlots`, `localizedDetail`, `localizedActionMenu`,
`localizedChrome`) resolves text at present time, keeps the last presented
view, subscribes at `mount`, re-presents on a locale change (an open action
menu is redrawn with new labels and keeps its highlighted row), and
unsubscribes at `dispose`. `itemNameMessage(keys, id, name)` gives the same
message for a game that draws names itself.

## Quests

Nothing to install. Key titles by quest id and render them through a
localized text class:

```tsx
import { msg } from "@yagejs-addons/i18n";
import { Trans } from "@yagejs-addons/i18n/ui-react";
import type { QuestCatalog } from "@yagejs-addons/quests";

declare const QUESTS: QuestCatalog; // the game's defineQuests(...) catalog

function QuestTitle({ id }: { id: string }) {
  return <Trans message={msg(`quest.${id}.title`, QUESTS.get(id).title)} />;
}
```
