Liminalia

配布(署名、インストーラー、アップデート、ストア)

商業のノベルゲームとして売るときに要る、書き出しの後ろの仕組み。どれも liminalia.json の build と環境変数で設定し、 書かなければ今までどおりに書き出す(秘密の鍵やパスワードは環境変数で渡し、liminalia.json には環境変数の名前だけを書く)。

コード署名と公証

Mac(Developer ID と公証)

build.mac.signIdentity(環境変数 LIMINALIA_MAC_SIGN_IDENTITY が優先)に、キーチェーンにある署名の証明書の名前か SHA-1 を書くと、 アプリの内側(ヘルパーのアプリ、フレームワーク、ネイティブのライブラリ)から順に、hardened runtime と entitlements とタイムスタンプを付けて 署名する(@electron/osx-sign)。書かなければ、または - なら、今までどおりアドホック署名(自分の Mac で動かすだけのもの)。

"build": { "mac": { "signIdentity": "Developer ID Application: Example Inc. (ABCDE12345)", "notaryProfile": "liminalia-notary" } }
  • entitlements は、省くと書き出しが Electron に要るもの(JIT とその実行のメモリ)を作る。Steam につなぐ版では、Valve が署名した Steam のライブラリを読むため、ライブラリの検証も外す。自分で用意するなら build.mac.entitlements に plist のパスを書く
  • 公証は、資格があればする(build.mac.notarize: false か LIMINALIA_NOTARIZE=0 で署名だけ)。資格は次のどれか
    • キーチェーンのプロファイル: build.mac.notaryProfile か LIMINALIA_NOTARY_PROFILE(xcrun notarytool store-credentials <名前> で作る。勧める)
    • App Store Connect の API キー: LIMINALIA_NOTARY_KEY(.p8 のパス)、LIMINALIA_NOTARY_KEY_ID、LIMINALIA_NOTARY_ISSUER
    • Apple ID: LIMINALIA_NOTARY_APPLE_ID、LIMINALIA_NOTARY_PASSWORD(アプリ用パスワード)、LIMINALIA_NOTARY_TEAM_ID
  • 公証は zip にして xcrun notarytool submit --wait で送り、通ったら xcrun stapler staple し、spctl で Gatekeeper が通すかを確かめる。 通らなければ notarytool log の理由を出して止める(数分かかる)
  • 署名と公証は Mac でだけできる。証明書がキーチェーンに無ければ、組み立てる前に止める

Windows(Authenticode)

build.windows.sign に、証明書(PFX)とパスワードの環境変数か、外部の署名コマンドを書く。実行ファイルと、インストーラー (build.windows.installer)に署名する。

"build": { "windows": { "sign": { "pfx": "certs/code-signing.pfx", "passwordEnv": "LIMINALIA_WIN_PFX_PASSWORD" } } }
  • PFX は Windows では signtool(Windows SDK)、Mac と Linux では osslsigncode(brew install osslsigncode、apt install osslsigncode)で署名する。 タイムスタンプ(RFC 3161)は timestampUrl(既定 http://timestamp.digicert.com)。パスワードはコマンドの引数に出さず、本人だけが読めるファイルで渡す
  • 環境変数 LIMINALIA_WIN_PFX(PFX のパス)と LIMINALIA_WIN_SIGN_COMMAND は設定より優先する。道具の場所は LIMINALIA_OSSLSIGNCODE と LIMINALIA_SIGNTOOL
  • 2023 年以降、OV と EV の証明書は鍵をハードウェア(トークンやクラウドの HSM)に置くので、PFX で渡せないことが多い。その場合は外部の署名コマンドを使う。 {file} が署名するファイルのパスに置き換わる。例: Azure Trusted Signing を Mac や Linux から jsign で
    "build": { "windows": { "sign": { "command": ["jsign", "--storetype", "TRUSTEDSIGNING", "--keystore", "weu.codesigning.azure.net", "--alias", "account/profile", "--tsaurl", "http://timestamp.acs.microsoft.com", "{file}"] } } }
    アクセストークンなどの秘密は jsign の説明のとおりに渡す。コマンドはシェルを通さずに実行するので、$VAR のような書き方は展開されない (秘密は liminalia.json に書かず、そのコマンドが環境変数やファイルから読む形にする)
  • 道具(osslsigncode、signtool)や証明書、パスワードが無いときは、組み立てる前に理由と入れ方を出して止める。 ifMissing: "skip" なら警告だけ出して、署名せずに書き出す(手元での試しの書き出しなど)

Windows のインストーラー

build.windows.installer を true(か設定)にすると、lim build windows がフォルダと zip に加えて <名前>_<バージョン>_Setup.exe を作る (NSIS の makensis。署名の設定があればインストーラーにも署名する)。

"build": { "windows": { "installer": { "perMachine": false, "desktopShortcut": true, "languages": ["ja", "en"] } } }
  • 画面は日本語と英語(Windows の表示の言語で選ぶ。languages で絞れる)。インストール先を選べ、終わりの画面で起動とデスクトップのショートカットを選べる
  • スタートメニューにショートカットを作り、「アプリと機能」にアンインストールを登録する。アンインストールは入れたファイルだけを消す (インストール先に遊ぶ人が置いたものと、セーブデータは残す)。前の版が入っていれば、その版のアンインストーラーで消してから入れる
  • 既定は入れる人だけ(%LOCALAPPDATA%\Programs\<名前>。管理者の権限が要らない)。perMachine: true ならすべてのユーザー(Program Files。管理者の権限が要る)
  • makensis が要る。Mac は brew install makensis、Linux は apt install nsis など、Windows は https://nsis.sourceforge.io/Download 。 LIMINALIA_MAKENSIS で場所を指定できる。見つからなければ、組み立てる前に入れ方を出して止める(LIMINALIA_INSTALLER=0 ならインストーラーを作らずに書き出す)
  • アンインストーラー(Uninstall.exe)はインストールするときに作られるので、署名は付かない

アップデート(Steam 以外で売るデスクトップ版)

Steam は更新を自分で配るが、自分のサイトや BOOTH などで売るときは、ゲームが自分で新しい版を確かめる。

  1. 配るものとマニフェストを作る
    lim update-manifest --base-url https://example.com/game/updates/ --pack packs/patch1 --notes-file notes.json
    lim update-manifest --base-url https://example.com/game/updates/ --app windows=dist/windows/…/Game_1.1.0_Setup.exe,mac=dist/Game-mac.zip --page https://example.com/game
    dist/update/ に update.json と配るファイル(パックの <id>-<版>.zip、本体のファイルの写し)ができる。中身を --base-url の場所に置く
  2. liminalia.json に "build": { "update": { "url": "https://example.com/game/updates/update.json" } } を書いて書き出す (環境変数 LIMINALIA_UPDATE_URL が優先。off なら入れない)

ゲームの側の動き(尋ね方と画面)は player.md の「アップデート」。

  • パック(--pack): 作品の packs/<id>/(lim pack と同じに書き出してから)、lim pack で書き出したフォルダ、またはその zip。pack.json の version で 新しいかを比べるので、version は必須。requires(どの本体の版に入れるか)は pack.json の requires、無ければ今の version
  • 本体(--app): OS(windows、mac、linux)ごとのインストーラーや zip。--page はダウンロードのページ(OS のファイルが無いときに開く)。 版は liminalia.json の version(--version で替えられる)
  • 説明は --notes "…" か --notes-file(テキスト、または {"ja": "…", "en": "…"} の JSON。遊ぶ人の言語のものを出す)
  • dist/update/update.json がすでにあれば、今回作らなかったほかの本体の版向けのパックは残す(古い本体を遊ぶ人にも修正を配れる)。--fresh なら残さない
  • Steam につなぐ版には入れない(build.update.steam: true なら入れる)。書き出しの自動確認では確かめない

マニフェストの形:

{
  "format": "liminalia-update", "formatVersion": 1,
  "app": { "version": "1.1.0", "page": "https://example.com/game", "notes": { "ja": "…", "en": "…" },
           "files": { "windows": { "url": "Game_1.1.0_Setup.exe", "sha256": "…", "size": 123 } } },
  "packs": [ { "id": "patch1", "version": "1.0.1", "requires": "1.0.0", "title": "修正 1", "url": "patch1-1.0.1.zip", "sha256": "…", "size": 456 } ]
}
  • URL はマニフェストからの相対でもよい。https だけ(手元の確かめのために http://localhost は受ける)。sha256 は必須で、取り寄せたものが合わなければ使わない
  • パックは保存先の packs/<id>/ に入れる(Mac のアプリの中や Program Files は、署名や権限で書き換えられないため)。別の名前で展開してから入れ替えるので、 途中で止まっても前のものが残る。zip の中は直下か、ただ一つのフォルダの中に pack.json を置き、その id がマニフェストと同じであること
  • 本体の更新は、Windows の .exe、.msi なら取り寄せて起動し、ゲームを終了する(インストーラーが前の版を消して入れ直す)。ほかは取り寄せたファイルを開く

パッチ(前の書き出しとの差分)

lim patch [プロジェクト] --base <前の書き出し>(制作画面の書き出しの画面の「パッチを作る…」も同じ)は、前に書き出したもの (ブラウザ版、デスクトップ版のフォルダ、Mac のアプリ、その zip)と今の作品を比べ、変わったものだけを入れたパック(パッチ)を作る。

  • 比べるのは書き出しの中身(シナリオ、データ、キャスト、訳、素材)。今の作品は作業用の場所にブラウザ版として書き出して比べる。 パックはキャスト(cast.json)と訳(lang/)も差し替えられる
  • 本体(プレイヤーのプログラムと liminalia.json の ui、extra、items などの設定)が変わっていたら止める。エンジンを更新したときや設定を 変えたときは、本体を配り直す。--force(書き出しの画面ではもう一度押す)なら中身だけで作る
  • 版(version)はプログラムに埋めず、書き出しの project/build.json に入れるので、版を上げてもパッチを作れる。 パックの requires は前の書き出しの版(その版の本体にだけ入る)、order は作った時刻(後に作ったパッチが勝つ)
  • 消したファイルはパッチでは消せない(本体に残る。一覧に出す)
  • 前の書き出しが中身を暗号化していれば(build.encrypt)、そのアプリの中の鍵で戻して比べ、パッチも同じ鍵で暗号化する
  • 作る zip は前の書き出しに合わせる(Windows 版なら Windows の、ブラウザ版ならブラウザの。Mac 版はパックのフォルダだけ)

dist/patches/ に作るもの:

もの配り方
<id>/lim update-manifest --pack dist/patches/<id> で、デスクトップ版のアップデートの確認から配る(Mac はこれで配る。アプリの中を書き換えると署名が壊れるため)
<id>-windows.zip、<id>-linux.zip(前の書き出しが Windows 版、Linux 版のとき)ゲームのフォルダに展開して上書きする(resources/packs/<id>/ に入る)
<id>-browser.zip(前の書き出しがブラウザ版のとき)ブラウザ版の置き場に展開して上書きする(packs/<id>/ と、足した packs/index.json)

ストアとアプリ内課金

商品と、シナリオからの使い方

build.store.products に、買える商品(ゲームの中の名前 → ストアごとの商品 ID)を書く。今は買い切りの商品(製品版の解放、追加シナリオなど)だけを扱う (消耗型と定期購入は扱わない)。

"build": { "store": { "products": {
  "full": { "ios": "com.example.game.full", "android": "full_unlock", "steam": 1234560, "gog": "1207666" }
} } }
@if (!system.purchases.full)
  @purchase full
@end
@if (system.purchases.full)
  @jump chapter2
@end
  • @purchase <商品> で買う(ストアの画面が閉じるまで待つ)。買えたら system.purchases.<商品> が true になり、全セーブ共通の変数としてすぐ残る。 結果(購入しました、キャンセルしました、手続き中です など)はプレイヤーに短く知らせる
  • @restore-purchases で購入を復元する(機種変更したときなど。iOS の審査では復元のボタンが要る)
  • 起動したときと、ゲームに戻ったとき(ストアのページから戻ったときなど)に、持っているものをストアに確かめ直す。ストアにつながらないときは前に確かめたものを使う
  • ストアの無い環境(ブラウザ版、ストアにつながらない書き出し)では「この環境では購入できません」と知らせ、何もしない

iOS(StoreKit 2)

商品に ios があれば、lim build ios がアプリに StoreKit 2 の口を入れる。App Store Connect で同じ Product ID の非消耗型の商品を作る。

  • 持っているものは Transaction.currentEntitlements(返金されたものは除く)、購入は署名を確かめた取引だけを買ったものとする。復元は AppStore.sync()
  • 承認待ち(ファミリー共有の「承認と購入のリクエスト」など)は「手続き中」と知らせ、済んだら次に確かめたときに反映する
  • 手元で試すときは、Xcode で StoreKit Configuration File を作り、スキームの Run → Options → StoreKit Configuration に指定する

Android(Google Play Billing)

Google Play の課金は Play Billing Library(AAR)が要る。書き出しは Gradle を使わないので、AAR を自分で用意して build.store.android.libraries に書く (無ければ警告を出して、課金なしで書き出す)。

"build": { "store": { "android": { "libraries": ["libs/billing-8.0.0.aar"] } } }
  • AAR は Google の Maven(com.android.billingclient:billing)から取り、依存の AAR と JAR もすべて並べる(Maven の pom の dependencies を見るか、 Android Studio のプロジェクトで ./gradlew :app:dependencies を確かめる)。Play Billing Library 7 と 8 のどちらでも組み立てられる
  • AAR の classes.jar と libs/*.jar を組み込み、AndroidManifest.xml の権限(com.android.vending.BILLING)、queries、activity、meta-data を足す。 AAR の資源(res/)は組み込まないので、資源を使うライブラリは動かないことがある
  • 買ったもの(購入済みの状態)は確認(acknowledge)する(しないと 3 日で返金される)。Google Play は購入をアカウントに結ぶので、復元は持っているものを聞き直すことと同じ
  • Play Console で同じ商品 ID のアプリ内アイテムを作る。課金は Play ストアから入れたアプリ(内部テストのトラックなど)でしか試せない

Steam の DLC

商品に steam(DLC の App ID)があれば、Steam につなぐデスクトップ版で、持っているかを isDlcInstalled で確かめる。@purchase は Steam の オーバーレイで DLC のストアのページを開く(買ったかは、ゲームに戻ったときに確かめ直す)。DLC の中身はパック(Resources/packs/ に置くデポ)で配れる。

Steam ワークショップ

build.steam.workshop: true なら、Steam につないだデスクトップ版が、ワークショップで購読しているアイテムのうち入っているものを、パックとして読み込む (steamworks.js の workshop.getSubscribedItems と installInfo)。

  • アイテムの中身は lim pack で書き出したパックのフォルダ(直下か、ただ一つのフォルダの中に pack.json)。id は pack.json の id(英数字、_、-。 使えなければ workshop-<アイテムの ID>)。同じ id の公式のパック(保存先の packs/、Resources/packs/)があれば、そちらを使う
  • まだ入っていない購読は取り寄せを頼み、次の起動から読み込む。requires(本体のバージョン)が合わないものは読み込まない
  • アイテムを作って上げる道具は Steam の側(Steamworks の SteamCMD や、作品が自分で用意するアップローダー)を使う。Liminalia は読み込む側だけを持つ
  • ワークショップのアイテムは作品の作り手以外も作れるので、パックの中身(シナリオとデータと素材)だけが読み込まれ、プラグインのコードは読み込まれない

Steam Input

build.steam.input で、Steam Input のアクションをゲームの操作に結ぶ。アクションの定義(Game Action File)は Steamworks の管理画面の Steam Input に上げる。

"build": { "steam": { "appId": 123456, "input": { "actionSet": "InGameControls", "actions": {
  "confirm": "a", "cancel": "b", "menu": "start", "hide_window": "x", "backlog": "y", "skip_hold": "rb", "auto_toggle": "auto", "quick_save": "quick-save"
} } } }
  • 結べる操作は、ゲームパッドのボタン(a、b、x、y、lb、rb、lt、rt、select、start、up、down、left、right。advance は a、back は b)と、 システム画面の操作(auto、skip、quick-save、quick-load、save、load、rollback、rollforward、prev-choice、next-choice、next-unread、 backlog、config、flowchart、screenshot、hide、menu、title)
  • ボタンは標準のゲームパッドと同じ動き(a は決定と送り、b は戻る、start はメニュー、rb は押している間スキップ)。押したときと離したときをゲームに渡す。 システム画面の操作は押したときに行う
  • actionSet は省くと InGameControls。アクションセットはゲームの間ずっと同じものを使う

ほかのストア(GOG Galaxy、Epic Games Store)

GOG Galaxy と Epic Online Services には公式の Node.js の部品が無いので、作品のプラグインがストアの口を差し込む。Steam につながない書き出しで、 build.store.desktop.module に CommonJS のファイルを書くと、書き出しがそのファイルのフォルダごとアプリの store/ に入れ、起動したときに読み込む (ネイティブのライブラリ(.node、.dll、.dylib)も同じフォルダに置けば、asar の外に出す)。

"build": { "store": { "desktop": { "module": "plugins/gog-store/store.cjs", "options": { "clientId": "…" } } } }
// plugins/gog-store/store.cjs(デスクトップ版のメインプロセスで動く)
module.exports = {
  name: "gog", // build.store.products の、このストアの ID の鍵
  create(ctx) {
    // ctx.game(title、version)、ctx.options(build.store.desktop.options)、ctx.log、ctx.dir(このファイルのフォルダ)
    const galaxy = require("./galaxy-binding.node"); // 例: 自分で用意した GOG Galaxy SDK のつなぎ
    galaxy.init(ctx.options.clientId);
    return {
      achievement(id) { galaxy.unlockAchievement(id); },            // @achieve
      presence(text) { galaxy.setRichPresence("status", text); },     // @presence(空なら消す)
      async owned(ids) { return ids.filter((id) => galaxy.isDlcInstalled(id)); },
      async buy(id) { galaxy.openStorePage(id); return "opened"; },  // "purchased" | "pending" | "cancelled" | "failed" | "opened"
      async restore(ids) { return this.owned(ids); },
      checkLicense() { return galaxy.isLoggedOn(); },                 // false なら知らせて終了する
      shutdown() { galaxy.shutdown(); },
    };
  },
};
  • 口はどれも省ける(無いものは使わない)。ほかに gamepadText(o)(画面のキーボード)、isDeck()、packs()(ストアが配る追加の中身のフォルダ)、 licenseMessage(checkLicense が false のときに出す文)を持てる。読み込めないときや create が投げたときは、ストアの機能を使わずに遊べる
  • Steam につなぐ書き出し(build.steam)では、Steam の口を使う

DLsite、DMM GAMES など

DLsite や DMM GAMES は、作品のファイルを受け取ったストアの側で包むか、ストアが配る道具で実行ファイルを包む方式で、ゲームから呼ぶ SDK は公開されていない。 そのための差し込み口だけを用意している。

  • build.windows.wrap: 書き出した実行ファイルを包むコマンド({file} が実行ファイルのパス。署名の前に実行する。環境変数 LIMINALIA_WIN_WRAP_COMMAND が優先)
  • ストアの口の checkLicense(): 起動したときにライセンスを確かめる処理を差し込む
  • 包んだあとは実行ファイルが変わるので、署名(build.windows.sign)とインストーラーはその後に行う。ストアが包む場合は、署名せずに提出するかをストアの案内に従う