yktsr / Text2Frame-MV

テキストファイル(.txtファイルなど)から「文章の表示」イベントコマンドに簡単に変換するための、RPGツクールMV・MZ用の開発支援プラグインです。/ Plugin for RPG Maker MV/MZ to convert text to event command
MIT License
9 stars 1 forks source link

要望:JSON 変換部のモジュール化 #117

Closed katai5plate closed 5 months ago

katai5plate commented 7 months ago

要望です。 外部ツール開発や、派生プラグイン開発などをしやすくするために、

JSON 変換部と埋め込み処理部を切り離し、JSON 変換部をモジュールとして使えるようにしてほしいです。

以下は使用イメージのデモコードです。

// NW 限定プラグインでも、通常の Node.js でも読み込める。
// 可能なら、ES Module の import 文でも読み込めると有難い
const TF = require("Text2Frame.js");

// JS から動的に生成されたテキスト
const place = ["城", "宿屋", "洞窟の前"][Math.floor(Math.random() * 3)];
const date = new Date().toLocaleString();
const text = `長老に会って挨拶は済ませてきたかい?
<ShowChoices>
<When: はい>
そうか。それならよかった。
早速長老の依頼のとおり、${place}に向かってくれないかい。
<When: いいえ>
それはいけない。
長老は君のような若者を探しているんだ。
挨拶に行って話を聞いてくれないかい。
<End>
<comment>
出力日時: ${date}
</comment>`;

// list 形式で出力される
const list = TF.parse(text);

// 挿入場所によって indent を変更したり、条件によって内容を変えたりできる
const result = list.map(({ indent, ...rest }) => ({
  ...rest,
  indent: indent + 2,
}));

console.log(result);
// [
//   { code: 101, parameters: [ '', 0, 0, 2, '' ], indent: 2 },
//   { code: 401, parameters: [ '長老に会って挨拶は済ませてきたかい?' ], indent: 2 },
//   { code: 102, parameters: [ [...], 1, 0, 2, 0 ], indent: 2 },
//   { code: 402, parameters: [ 0, 'はい' ], indent: 2 },
// ...
//   { code: 404, parameters: [], indent: 2 },
//   { code: 108, parameters: [ '出力日時: 2023/12/12 5:48:20' ], indent: 2 },
//   { code: 0, parameters: [], indent: 2 }
// ]

// 例えばこういう、特定のコモンイベントの指定されたラベル部分に挿入する処理とかに使えるようにしたい
myLib.injectToCommonEvent({ id: 3, labelName: "挿入:たのみごと" }, result);

これが実現すれば、次のようなことが可能になると思っています。

ぜひ、ご検討お願いします!

katai5plate commented 7 months ago

一応可能であればモジュール化までしていただきたいですが、難しい場合は Game_Interpreter.prototype.pluginCommandText2Frame の内部処理を分離して、

Game_Interpreter.prototype.pluginCommandText2FrameParser(text: string)Game_Interpreter.prototype.pluginCommandText2FrameInjecter(options: {}) のように処理を分けていただき、

Game_Interpreter.prototype.pluginCommandText2FrameParser をコンソール上から単体で実行できるようにしていただければ、それだけでも一応最低限の目的は達成できます。

そうすれば上記デモコードも以下のように書くことで実現できると思うので

// Text2Frame を実行し、関数定義
require("Text2Frame.js");

// JS から動的に生成されたテキスト
const date = new Date().toLocaleString();
const text = `<comment>
出力日時: ${date}
</comment>`;

// 実行
const list = Game_Interpreter.prototype.pluginCommandText2FrameParser(text);

console.log(list);
yktsr commented 7 months ago

@katai5plate コメントありがとうございます。 いただいた要望ですが、実際にコードを用いながら内容の検討を行ったほうが良いと思いましたので、検討用の branch を作成し、私なりに要望の内容を反映した draft を push しました。(RPG Maker MV / MZ での動作は未確認です) https://github.com/yktsr/Text2Frame-MV/tree/117-forlib

const TF = require("./Text2Frame.js");

// JS から動的に生成されたテキスト
const place = ["城", "宿屋", "洞窟の前"][Math.floor(Math.random() * 3)];
const date = new Date().toLocaleString();
const text = `長老に会って挨拶は済ませてきたかい?
<ShowChoices>
<When: はい>
そうか。それならよかった。
早速長老の依頼のとおり、${place}に向かってくれないかい。
<When: いいえ>
それはいけない。
長老は君のような若者を探しているんだ。
挨拶に行って話を聞いてくれないかい。
<End>
<comment>
出力日時: ${date}
</comment>`;

// list 形式で出力される
const list = TF.convert(text);

// 挿入場所によって indent を変更したり、条件によって内容を変えたりできる
const result = list.map(({ indent, ...rest }) => ({
  ...rest,
  indent: indent + 2,
}));

console.log(result);

※↑大したことではありませんが、parse -> convert に変わっています。

こちらのテストファイルを実行しますと

$ node test.js 
[
  { code: 101, parameters: [ '', 0, 0, 2, '' ], indent: 2 },
  { code: 401, parameters: [ '長老に会って挨拶は済ませてきたかい?' ], indent: 2 },
  { code: 102, parameters: [ [Array], 1, 0, 2, 0 ], indent: 2 },
  { code: 402, parameters: [ 0, 'はい' ], indent: 2 },
  { code: 101, parameters: [ '', 0, 0, 2, '' ], indent: 3 },
  { code: 401, parameters: [ 'そうか。それならよかった。' ], indent: 3 },
  {
    code: 401,
    parameters: [ '早速長老の依頼のとおり、宿屋に向かってくれないかい。' ],
    indent: 3
  },
  { code: 0, parameters: [], indent: 3 },
  { code: 402, parameters: [ 1, 'いいえ' ], indent: 2 },
  { code: 101, parameters: [ '', 0, 0, 2, '' ], indent: 3 },
  { code: 401, parameters: [ 'それはいけない。' ], indent: 3 },
  { code: 401, parameters: [ '長老は君のような若者を探しているんだ。' ], indent: 3 },
  { code: 401, parameters: [ '挨拶に行って話を聞いてくれないかい。' ], indent: 3 },
  { code: 0, parameters: [], indent: 3 },
  { code: 404, parameters: [], indent: 2 },
  {
    code: 108,
    parameters: [ '出力日時: 12/13/2023, 2:24:25 AM' ],
    indent: 2
  }
]

になるかと思います。

検討ポイント

  1. モジュールとしての出し方 RPG Makerのプラグインとして取り込む関係上、グローバルの名前空間を汚染する可能性は避けたいと考えています。したがって、あくまで Game_Interpreter.prototype.pluginCommandText2Frame の中からは出さない方針です。 一方で、Game_Interpreter.prototype.pluginCommandText2Frame のプロパティとして変換関数を割り当てるのはありかな、と思って検討しましたが、Game_Interpreter が特殊な値のため、この方法を取るにはもう少し RPG Maker の仕様を調べる必要がありそう、と思って断念しました。実は詳しくご存知でしたらコメントください。
  2. require ではなくて、 import で読み込めるか こちらの要望も検討しましたが、IOにrequireを使っている箇所があり、それらをimport に置換する必要があります。このプラグインが最初にコミットされた当時は、RPG Maker 側がES5(とES6の一部)しか動作せず、import 記法が使えなかった、という時代背景があり、CommonJS スタイルが現在も残っています。現在では、おそらく大丈夫なのですが、既存ユーザへの変更リスクが大きく、検証コストも大きいため、申し訳ありませんが、こちらの要望はお受けできません。 参考:https://qiita.com/triacontane/items/c696f314e70edb383202
katai5plate commented 7 months ago

@yktsr

ありがとうございます! コードを拝見しました。一応最低限欲しい機能は、作って頂いた実装で問題なく得られたと思います。

ESModule 化ですが、確かに内部で require を使っている以上、どうやっても import 文は使えませんね・・・

一応代替策として、 ビルドツールを用いて、ソースコードを ESModule で扱えるようにトランスパイルする という方法を提案してみます。

まず以下をインストール

npm install --save-dev rollup @rollup/plugin-commonjs @rollup/plugin-node-resolve

ESModule にしたときに発生する問題を解決する

Text2Frame.js を以下のように改修

  if (typeof PluginManager === 'undefined') {
    // for test
    /* eslint-disable no-global-assign */
-   Game_Interpreter = {}
-   Game_Interpreter.prototype = {}
-   $gameMessage = {}
-   $gameMessage.add = function () {}
+   globalThis.Game_Interpreter = {}
+   globalThis.Game_Interpreter.prototype = {}
+   globalThis.$gameMessage = {}
+   globalThis.$gameMessage.add = function () {}
    /* eslint-enable no-global-assign */
  }
  (function () {
    'use strict'
    const fs = require('fs')
    const path = require('path')
    const PATH_SEP = path.sep
-   const BASE_PATH = path.dirname(process.mainModule.filename)
+   let BASE_PATH = "./";
+   if (globalThis.process !== undefined) {
+     if (globalThis.process.mainModule) {
+       BASE_PATH = path.dirname(process.mainModule.filename)
+     }
+   }

VSCode でコメントを確認できるように、TypeScript 型定義を書く。

package.json に次を追加

"types": "esm.d.ts",

ルートに esm.d.ts を作成(説明文の内容はお任せします)

declare module "Text2Frame-MV/esm.mjs" {
  /**
   * ここに関数の説明文を書く
   * @param text コマンドテキスト
   */
  export function convert(
    text: string
  ): { code: number; parameters: any[]; indent: number }[];
}

ビルドツールの準備

ルートに rollup.config.js を作成

import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";

export default {
  input: "./Text2Frame.js",
  output: {
    file: "./esm.mjs",
    format: "esm",
    sourcemap: true,
  },
  plugins: [
    {
      name: "replace-require-main",
      transform(code, _id) {
        return {
          // `require.main` を `undefined` に置換する
          code: code.replace(/require\.main/g, "undefined"),
          map: null,
        };
      },
    },
    resolve(),
    commonjs(),
  ],
};

あとはコマンド実行でビルド。

必要に応じて、 CI のビルドプロセスにも追記してください

npx rollup -c

これで、ルートに esm.mjs が出力されるはずです。


こうすれば、以下のように書けるようになります。

import TF from "./esm.mjs"

const date = new Date().toLocaleString();
const text = `<comment>
出力日時: ${date}
</comment>`;
console.log(TF.convert(text));

自分が使う場合、おそらく node_modules にこのリポジトリを入れて使うと思うので、 実際にはこうやって読み込むことになるかと。

npm i yktsr/Text2Frame-MV
import TF from "Text2Frame-MV/esm.mjs";
katai5plate commented 7 months ago

これは補足です。 どうしても、ES2020 の機能である globalThis を使いたくないという場合は、ポリフィルを次のように書くことができます。

var globalThis =
  (typeof globalThis !== "undefined" ? globalThis : null) ||
  (typeof global === "object" && global && global.Math === Math && global.Array === Array ? global : null) ||
  (typeof self !== "undefined" ? self : null) ||
  (typeof window !== "undefined" ? window : null) ||
  Function("return this")();
yktsr commented 7 months ago

コメント&提案ありがとうございます。 いただいたコメントを元に、全体的にコードを調整し、requireの影響を小さくした上でES module へトランスパイルして、117-forlibにpushしました。 こちらで所望の処理になるかと思います。

# npm install "yktsr/Text2Frame-MV#117-forlib"
import TF from "Text2Frame-MV/Text2Frame.mjs"

const date = new Date().toLocaleString();
const text = `<comment>
出力日時: ${date}
</comment>`;
console.log(TF.convert(text));

検討ポイント

  1. まず、根本的にrequireの箇所を限定し、ES ModuleとCommonJSの両方で実行できるコードにならないか検討したのですが、export が特殊なキーワードのため、うまくいきませんでした。両方の実行環境でエラーにならないexport方法が見つからず、したがって、全体としてはやはりCommonJSとして記述し、トランスパイルすることにしました。
  2. globalThis についてですが、こちらはリファクタの過程で取り込みました。globalThis が必要なケースはCIでの自動テストの場合だけで、こちらは実行環境が固定できるため、テストのみで実行されるブロックに取り込んでいます。
  3. require.mainの除去ですが、require.main自体を使わない形に変換できないか考えたのですが、よい方法が見つかりませんでした。やりたいこととしては、ターミナルから node Text2Frame.js として直接実行されたときだけ実行される判定コードにしたいのですが、もしよい知見をご存知でしたらコメントいただけると大変助かります。
    // developer mode
    //
    // $ node Text2Frame.js
    if (typeof require !== 'undefined' && typeof require.main !== 'undefined' && require.main === module) {
katai5plate commented 7 months ago

@yktsr require.main を使わずにターミナル実行を検出する方法として globalThis.process.argv を使うのはどうでしょうか。 これで、ターミナル実行時の Node.js 本体のパス、実行スクリプトのパス、指定された引数を取ることができるので、 どのようにターミナル実行されたかどうかまで取ることができます。

それとこれは別解なのですが、developer mode は別ファイルに分けるのはどうでしょうか。 そもそも Node.js で使用する場合は、単体の JS をダウンロードしてきて実行するよりも、 npm install などで持ってきて使用することのほうが多いはずですので、npx などで実行できるようにしたり、 そこまでしなくても README に node node_modules/Text2Frame/node.js を実行するように書いておくだけでもいいような気がします。 もちろん、Rollup でターミナル実行専用のビルドを書くのもいいでしょうし。

現代のWEB開発では、「ファイルを分けて開発し、ビルドで繋げる」という思想が一般的なので、 即戦力のために最初から1つのファイルで開発するよりも、 機能をバラで管理して、目的に合わせたビルドを行うほうがいいんじゃないかな、と思っていたりします。

katai5plate commented 7 months ago

それと、esm.d.ts が正しくリンクされていませんでした。 この画像のように types または typings を設定しないと、d.ts ファイルが関連付けられませんので注意です。 image

正しくリンクされているかどうかは、VSCode が認識しているかどうかで確認可能です。 VSCode を再起動して、問題なく認識するか確認できれば OK です。 (別タブで d.ts ファイルを開いていると、package.json の設定有無にかかわらず VSCode が勝手に認識してしまうので注意)

認識していない場合 image

認識している場合 image

yktsr commented 7 months ago

ありがとうございます。

esm.d.ts

普通にコミット忘れていました。あとでやっておきます。(先行してコードだけ出したので)

それとこれは別解なのですが、developer mode は別ファイルに分けるのはどうでしょうか。 機能をバラで管理して、目的に合わせたビルドを行うほうがいいんじゃないかな、と思っていたりします。

これもそのとおりですね。 RPG Makerの制約で1ファイルにする必要がありましたが、本来はバラしてビルドして組み上げたほうが良かったかもしれません。最初はこれほどの構想ではなかったのもあります。

katai5plate commented 7 months ago

@yktsr

後から気づいたのですが、ビルドツールは Rollup ではなく、Vite のほうが良かったかもです。 Vite のライブラリモードならば、フォーマット指定でより洗練された形で CJS, ES モジュールに出力できるので。 一応、現状から Vite に移行する方法を書いておきます。

npm rm rollup
npm i -D vite
import { defineConfig } from "vite";
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";

export default defineConfig({
  build: {
    // ルートディレクトリに出力
    outDir: ".",
    lib: {
      // ビルド元ファイル
      entry: "./Text2Frame.js",
      // ライブラリ名(umd 出力時のグローバル変数名)
      name: "Text2Frame",
      // 出力フォーマット
      formats: [
        "es", // ESM
        "cjs", // CommonJS
        "umd", // ブラウザ向け
      ],
      // フォーマットごとのファイル命名方法
      fileName: (format) => `Text2Frame.${format}.js`,
    },
    // rollup のプラグインを流用
    rollupOptions: {
      plugins: [resolve(), commonjs({ transformMixedEsModules: true })],
    },
  },
});
"script": {
  "build": "vite build"
}
npm run build

これで、Text2Frame.cjs.jsText2Frame.es.jsText2Frame.umd.js が出力されます。

namespace Text2FrameMV {
  /**
   * Text2Frameの文法で書かれた文字列をRPG Maker MV/MZのイベントコマンドリストに変換します。
   * 戻り値にはMapの定義は含まれず、イベントコマンドリストのみが返るため、Mapへの組み込みは各自で行なってください。
   * Converts strings written in Text2Frame syntax into RPG Maker MV/MZ event command lists.
   * The return value only includes the event command list and does not contain the Map definition. Therefore, integration into the Map should be done individually.
   * @param text Text2Frameの文法に従って書かれた文字列
   */
  export function convert(
    text: string
  ): { code: number; parameters: any[]; indent: number }[];
}

declare module "Text2Frame-MV" {
  export = Text2FrameMV;
}
declare module "Text2Frame-MV/Text2Frame.cjs.js" {
  export = Text2FrameMV;
}
declare module "Text2Frame-MV/Text2Frame.es.js" {
  export = Text2FrameMV;
}
declare module "Text2Frame-MV/Text2Frame.umd.js" {
  export = Text2FrameMV;
}
katai5plate commented 7 months ago

一応これは報告ですが、分離していただいた JSON 変換部を使って、 イベントコマンドを JS で書けるようにするライブラリ・プラグインを作りました。

https://github.com/katai5plate/Text2Frame-JS https://twitter.com/katai5plate/status/1738543507995054310

現状まだマージされていないので、github:yktsr/Text2Frame-MV#117-forlib を直接インポートして使っています。

yktsr commented 7 months ago

ありがとうございます。お待たせしていてすみません。 年末年始で検証作業するのでしばし、お時間ください!

yktsr commented 6 months ago

@katai5plate おまたせしました。下記の変更を加えたのものを 117-forlib に push しました。

const date = new Date().toLocaleString(); const text = <comment> 出力日時: ${date} </comment>; console.log(TF.compile(text));

実行結果

SyntaxError: Unexpected token 'export'


* 変換関数の名前をconvertからcompileへ変更
  * こちら、大変申し訳無いのですが、名前を変えました。現在、Text2Frameの逆変換(既存のRPG Maker プロジェクトからText2Frameの構文に沿ったテキストを出力する)がunder review中で、こちらを decompile としたいためです。
* コマンドラインでも標準入出力を使って compile を呼び出せるように変更 
* 不具合修正。グローバル変数、グローバル関数との依存を切りました。
yktsr commented 6 months ago

RPG Maker MV 本体での動作確認は取れているため、MZでの確認をしてリリースになります。

katai5plate commented 6 months ago

@yktsr 拝見しましたが、これは commander が悪さしていますね・・・ やはり、ESMなどツクール向けのビルドでない場合、使わないコードは分離したほうが良さそうです。 もしくは commander を使わない代替策を使うとかですかね。


自分が試した原因特定方法

まず package.json に "type": "module", を追記します。こうしないとNode.jsで import 記法が使えないためです。 次に、vite.config.js で build.minifyfalse にします。こうすることで、ビルド後のコードが読みやすくなります。 あとはビルドしなおして、デモコードを書いた test.js を作成し、 node test を実行します。

すると、以下のようなエラーが出ました。

class Command extends EventEmitter {
                      ^

TypeError: Class extends value undefined is not a constructor or null
    at file:///.../Text2Frame-MV/Text2Frame.es.js:980:23
    at ModuleJob.run (node:internal/modules/esm/module_job:193:25)
    at async Promise.all (index 0)
    at async ESMLoader.import (node:internal/modules/esm/loader:530:24)
    at async loadESM (node:internal/process/esm_loader:91:5)
    at async handleMainPromise (node:internal/modules/run_main:65:12)

この EventEmitter を使用するライブラリを洗い出してみると、commander が原因であることがわかります。

今度は、Text2Frame.js の commander 使用部を全部コメントアウトし、ビルドしなおしてみます。 すると、問題なく実行することができるようになりました。

katai5plate commented 6 months ago

一応、process.argv を使って似たようなこともできるので参考にどうぞ

if (
  typeof globalThis?.process !== "undefined" &&
  typeof globalThis?.window?.nw === "undefined"
) {
  // 引数
  const args = process.argv.slice(2);

  // 初期値
  const options = {
    mode: null,
    text_path: null,
    output_path: null,
    event_id: null,
    page_id: null,
    common_event_id: null,
    overwrite: "false",
    verbose: false,
    version: "2.2.1",
  };

  // 引数とオプション名
  const modes = new Set(["map", "common", "compile", "test"]);
  const optionMap = {
    "-m": "mode",
    "--mode": "mode",
    "-t": "text_path",
    "--text_path": "text_path",
    "-o": "output_path",
    "--output_path": "output_path",
    "-e": "event_id",
    "--event_id": "event_id",
    "-p": "page_id",
    "--page_id": "page_id",
    "-c": "common_event_id",
    "--common_event_id": "common_event_id",
    "-w": "overwrite",
    "--overwrite": "overwrite",
    "-v": "verbose",
    "--verbose": "verbose",
  };

  // 解析
  args.forEach((arg, index, array) => {
    const option = optionMap[arg];
    if (option) {
      if (option === "verbose") {
        options[option] = true;
      } else if (option === "overwrite") {
        options[option] = array[index + 1] === "true";
      } else if (
        option === "mode" &&
        !modes.has(array[index + 1].toLowerCase())
      ) {
        console.error("mode の値が不正です: map, common, compile, test");
        process.exit(1);
      } else {
        options[option] = array[index + 1];
      }
    }
  });

  console.log(options);
}
yktsr commented 5 months ago

おまたせしました。一連の対応を本線に取り込みました。 結果的には、Text2Frame.js をビルドする際に、コマンドラインの部分を削除するようにしました。(各ビルド結果に不要なので) 本線に取り込んだ Text2Frame.es.js でお試ししてみてください!