配布(署名、インストーラー、アップデート、ストア)
商業のノベルゲームとして売るときに要る、書き出しの後ろの仕組み。どれも 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 で
アクセストークンなどの秘密は jsign の説明のとおりに渡す。コマンドはシェルを通さずに実行するので、"build": { "windows": { "sign": { "command": ["jsign", "--storetype", "TRUSTEDSIGNING", "--keystore", "weu.codesigning.azure.net", "--alias", "account/profile", "--tsaurl", "http://timestamp.acs.microsoft.com", "{file}"] } } }$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 などで売るときは、ゲームが自分で新しい版を確かめる。
- 配るものとマニフェストを作る
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/gamedist/update/にupdate.jsonと配るファイル(パックの<id>-<版>.zip、本体のファイルの写し)ができる。中身を--base-urlの場所に置く 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)とインストーラーはその後に行う。ストアが包む場合は、署名せずに提出するかをストアの案内に従う