アクセシビリティ (スクリーンリーダー) 対応 設計 — 吉里吉里Z 接続¶
状態: Phase A / B 実装済み (2026-10-04)。入力欄の文字・単語・行単位の読み上げ (Elements 側 Phase 5) も入っている。Windows (SDL3 / WINVER)・macOS・Linux の実機で確認した。下の「使い方」が実際の口。以降の節は設計時の記録で、細部は実装と違うところがある (違いは「使い方」の下に列挙)。Elements 側の設計は external/elements/docs/accessibility.md にある (この文書では「本体設計」と呼ぶ)。この文書では、krkrz 側の接続と REPL / Agent の拡張だけを扱う。パスは src/core/ 起点。
使い方のガイド: 読み上げ (スクリーンリーダー)。動く例はコアデモ data/a11y。
API リファレンス: ElementsDialog (announce / setGameA11y / a11yLayers ほか)、Agent (a11yTree / a11yLog / a11yAction)、Layer (Layer に生やすメンバ)。
使い方 (実装済み)¶
| 口 | 内容 |
|---|---|
| ビルド | KRKRZ_USE_A11Y (デスクトップ = Windows / macOS / Linux の通常ビルドは既定 ON)。Elements の elements_a11y_accesskit をリンクし、KRKRZ_HAS_A11Y を立てる。OFF でもツリーの取得 / 操作 / 読み上げログは使える (OS へ出ないだけ) |
| ウィンドウ | 最初の PaintOverlay (メインウィンドウのデバイス) でメインウィンドウに付く。SDL3 版は accesskit_host::attach_sdl、WINVER は HWND に attach (どちらも SetWindowSubclass / 表示後の後付け)。スクリーンリーダーが繋がるまでは何もしない |
| ゲーム本体の slot | OS への口を開いた時点で置く (常設)。ダイアログより下で、モーダルなダイアログの表示中は隠れる。空の live region (status) を最初から置いておき、announce で中身を変える (文字の入った live region が後から現れると、スクリーンリーダーは読まないことがある) |
| ダイアログ | ElementsDialogManager の各インスタンスが slot になる。重なり順・モーダル・位置 (present_scale / present_off_x/y) は PaintOverlay の末尾で同期する。画面 JSON の "a11y" (本体設計 §4) がそのまま効く |
ElementsDialog.announce(text[, assertive]) |
読み上げさせる (最前面ダイアログの live region。ダイアログが無ければゲーム本体の slot の live region)。assertive=true は割り込みの指定 (読み上げ側がどう扱うかはスクリーンリーダー次第)。同じ文を続けて渡しても読む。スクリーンリーダーが繋がっていなくても受け付ける (繋がったら最新の状態が渡る) |
ElementsDialog.setGameA11y(nodes[, focus]) |
ゲーム本体 (Layer に描いた選択肢 / メニュー / 設定画面) を読み上げツリーに載せる。下の「ゲーム本体のノード」を参照 |
ElementsDialog.clearGameA11y() |
ゲーム本体のノードを全て外す (slot と live region は残る) |
ElementsDialog.onGameA11yAction(id, action, arg) |
ゲーム本体のノードへの AT の操作を受けるイベント (スクリプトで関数を代入する) |
ElementsDialog.a11yLayers |
メインウィンドウの Layer を読み上げツリーに自動で載せる (既定 false)。下の「Layer の自動」を参照 |
ElementsDialog.a11yActive |
OS のスクリーンリーダー等が接続中か (読み取り専用)。スクリーンリーダーが初めてツリーを問い合わせた時点で真になる (Windows はウィンドウが前面 / フォーカスになったとき。起動直後はまだ偽) |
ElementsDialog.onA11yActiveChanged(active) |
a11yActive が変わったときに呼ばれる (スクリプトで関数を代入する。描画の外、WINVER の静止画面でも即時)。「繋がっていたら本文を読ませる」判定はキャッシュせず、これか読む直前の a11yActive で行う |
ElementsDialog.a11yMode |
"auto" (既定) / "off" (OS へ出さない) |
ElementsDialog.a11yLabel |
読み上げツリーの根 (ウィンドウ) の名前。空なら最前面画面の名前 |
Agent.a11yTree() |
読み上げツリー (JSON 文字列)。{"dialogs":[{"index","screen","modal","tree"}],"game":{"hidden","tree"}}。game はゲーム本体の slot (無ければ null、hidden はモーダルなダイアログの下で隠れているか) |
Agent.a11yLog([since]) |
読み上げログ %[lines, next]。REPL が動いているときだけ溜まる。announce もここに残る |
Agent.a11yAction(node, action[, arg]) |
スクリーンリーダーと同じ経路で操作する (click / focus / increment / decrement / set_value)。最前面のダイアログから探し、モーダルなダイアログが無ければゲーム本体のノードも探す |
| REPL | .a11y / .a11ylog [N] / .a11ydo <node> <action> [arg] / .say <text> (file / web / socket / console 共通) |
入力欄¶
ダイアログの入力欄 (input_box / text_box) は、スクリーンリーダーが文字・単語・行の単位で読め、キャレットの移動も追える (UIA の TextPattern / AT-SPI の Text・EditableText / macOS の AXSelectedTextRange・AXStringForRange)。スクリーンリーダーからキャレットや選択範囲を動かすこともできる。スクリプト側ですることは無い。
- 読み上げログには、値が変わらずにキャレットだけ動いたとき
[caret] x(行末は(line end)、末尾は(end))、選択したとき[selected] 文字列が出る。 - IME の変換中の文字列 (確定前) は扱わない。変換中の文字や候補は OS の IME 自体がスクリーンリーダーへ伝えるので、アプリ側でも出すと二重に読まれる。入力欄が持つのは確定した文字だけ (本体設計 §6.5)。
- ゲーム本体の
EditLayer(a11yLayers) とsetGameA11yのtext_inputは値を読むだけで、文字単位の読み上げは無い。
ゲーム本体のノード (setGameA11y)¶
Layer に描いた UI は Elements の外なので、スクリプトがノードの表を渡す。呼ぶたびに丸ごと差し替え、差分は本体が取る。
ElementsDialog.onGameA11yAction = function(id, action, arg) {
// action: "click" / "focus" / "increment" / "decrement" / "set_value" (arg が値)
// focus を受けたら、 ゲーム側のフォーカスを動かして setGameA11y を呼び直す
};
ElementsDialog.setGameA11y([
%[ id:"choices", role:"list", name:"選択肢" ],
%[ id:"c1", role:"list_item", name:"北へ行く", parent:"choices", rect:[100,400,600,48] ],
%[ id:"c2", role:"list_item", name:"南へ行く", parent:"choices", rect:[100,460,600,48] ],
%[ id:"vol", role:"slider", name:"音量", value:"50%", num_value:50, num_min:0, num_max:100 ],
], "c1");
| キー | 内容 |
|---|---|
id |
必須。一意な文字列。Agent.a11yAction / .a11ydo もこの id で指す |
role |
button / toggle_button / check_box / radio_button / tab / menu_item / list / list_item / slider / spin_button / text_input / label / heading / image / status / group (省略で group) |
name / value / description |
読み上げる名前 / 値 / 補足説明 |
states |
配列か "focusable,checked"。focusable / disabled / checked / selected / expanded / read_only。focused は第 2 引数で決める |
rect |
primary layer の座標 [x, y, w, h]。省略可 (省略したノードは子を囲む矩形になる)。ウィンドウの拡縮とレターボックスは本体が換算する |
parent |
親の id。省略すると最上位 |
num_value / num_min / num_max / num_step |
slider / spin_button の数値 |
- 操作はロールから決まる。押せるもの (button / check_box / list_item …) は click、slider / spin_button は increment / decrement / set_value、text_input は set_value。どれも focus を受け、
disabledなら focus だけ。 - AT の操作は描画の外で
onGameA11yActionに届く (どのスレッドから来ても、メインスレッドで呼ぶ)。本体はフォーカスや値を自分では動かさない。スクリプトがゲームの状態を変えてsetGameA11yを呼び直す。 - 重なり順: ゲーム本体の slot はダイアログより下。ただし、キーを受けているダイアログが無く、ゲーム側にフォーカスがあるときは最前面に上げる (スクリーンリーダーのフォーカスはいちばん上の slot のものが採られるため)。モーダルなダイアログの表示中は隠れる。
- メインウィンドウのみ。
Layer の自動 (a11yLayers)¶
ElementsDialog.a11yLayers = true にすると、メインウィンドウの primary layer 以下を辿って、ゲーム本体の slot にノードを足す。既定は false (何もしなければ動作は変わらない)。既定を true に変える場合は、この機能を使わずに setGameA11y で組んでいる利用側へ事前に知らせる。
載るもの:
- フォーカス連鎖に入っている Layer (
focusableかつjoinFocusChain)。名前はhint、ロールは button (CheckBoxLayerは check_box、EditLayerは text_input) a11yNameかa11yRoleを持つ Layer (フォーカスできなくてもよい。見出しや説明文に)- ElementsPanel を描いている Layer。パネルの中の部品がその下に並び、操作もパネルへ届く
載らないもの: visible が false の Layer とその子。a11yHidden が真の Layer とその子 (自前で setGameA11y に載せるレイヤに立てる)。
Layer に生やして使うメンバ (どれも任意):
| メンバ | 内容 |
|---|---|
a11yName / a11yRole / a11yValue / a11yDescription |
名前 / ロール (setGameA11y と同じ名前) / 値 / 補足説明 |
a11yStates |
配列か "checked,selected"。disabled は enabled からも付く |
a11yHidden |
真なら部分木ごと外す |
onA11yAction(action, arg) |
AT の操作を受ける。false を返すと既定の処理も行う |
- 既定の処理: focus は
layer.focus()、click はフォーカスしてから Enter キーを送る (キー操作できる部品はこれで押せる)。値の変更 (increment / set_value など) はonA11yActionでしか受けない。 - フォーカス:
window.focusedLayer。ただしsetGameA11yに focus を渡しているときはそちらが優先する。 - 並び順:
setGameA11yのノードが先、自動の Layer が後 (Layer の重なり順)。id はlayer:<Layer.name>(同名は#2,#3…)。 - 読む頻度: 150ms に 1 回まで (フォーカス中の Layer が変わったら即時)。読むのは AT が繋がっている間か、REPL の読み上げログを取っている間だけで、描画の外 (continuous イベント) で読む。
a11y*を property の getter にしているなら軽く保つこと。Agent.a11yTree/Agent.a11yActionはその場で読み直す。 - WINVER で AT が繋がっている間は、この読み直しのために continuous イベントが回り続ける (アイドル時も起きる)。
設計時からの変更:
- TJS の口は新しい
Accessibilityクラスではなく、既存のElementsDialogの静的メンバにした (languageなどと同じ形)。 - WINVER も
TTVPWindowForm::Procを触らず、HWND を後からサブクラス化する (Elements 側のaccesskit_host::attach)。 - 座標: 描画面 (renderer の surface) の座標を、描画面とウィンドウの実寸の比で OS の単位へ換算する。macOS はポイント ×
backingScaleFactor(SDL のウィンドウが高解像度でなくても Retina なら 2。Elements のaccesskit_host::native_scale)、Linux はウィンドウ座標。 - Phase B のゲーム本体の source は 2 段にした。スクリプトがノードの表を渡す
setGameA11yと、Layer のフォーカス連鎖を自動で読むa11yLayers(ElementsPanel を含む)。Phase C は未着手。KAG3 の拡張 (下の「KAG 連携」) は行わない (KAG3 自体を今後拡張しない方針のため。KAG 系の作品は、作品側のスクリプトからsetGameA11y/announceを呼んで対応する)。
確認 (Layer の自動): Windows (SDL3 / WINVER) / macOS / Linux で、フォーカス連鎖の Layer、a11yName / a11yRole の上書き、a11yHidden と非表示の除外、ElementsPanel の中身、setGameA11y との併用 (並び順・focus の優先)、AT / Agent.a11yAction からの click (Enter) と onA11yAction を確認した。
確認: SDL3 版 / WINVER 版とも、data/elements_gallery を開いて UI Automation の外部クライアントでツリー (名前・ロール・値・状態)、REPL の .a11ydo / .say / .a11ylog を確認した。ゲーム本体のノードも同じく、ツリーと座標 (letterbox 込み)、UIA の Invoke / SelectionItem.Select / RangeValue.SetValue → onGameA11yAction、モーダル表示中に隠れること、ダイアログが無いときの announce、WINVER の描画が止まった画面での操作を確認した。
1. 現状 (調査結果)¶
- アクセシビリティ、UIA、TTS 関連のコードはない。どの WndProc も
WM_GETOBJECTを処理せず、DefWindowProc に流している。 - Elements の UI はすべてオフスクリーンで描かれる。
ElementsDialogはtTVPElementsDialogManagerがoverlay_sessionを z 順に管理し、PaintOverlay(common/visual/elements/ElementsDialogManager.cpp) でゲームのフレームに合成する。ElementsPanelは Layer のビットマップに描く。- 例外は
showModalJson(json,title,w,h)だけで、これは専用の OS ウィンドウを作る (SDL:SDLElementsModalRunner.cpp、WINVER:WinElementsModalRunner.cppのModalWndProc)。 - ネイティブウィンドウへの接続口:
- WINVER:
TTVPWindowForm::Proc(win32/environ/WindowFormUnit.cpp) が最初にDeliverMessageToReceiverを呼ぶ。ここでWM_GETOBJECTを捕まえられる。 - SDL3:
sdl3/environ/form.cppでウィンドウを作っている。HWND はSDL_PROP_WINDOW_WIN32_HWND_POINTERで取得できる (同じファイルに前例あり)。macOS / Linux 用のプロパティは未使用。 - 既存の内部構造の観察 (introspection) は、ツリーの代わりとしては不足している。
Agent.dialogs()/Agent.dialogTree(i)はoverlay_session::list_widgets()を使う。返るのはidを持つウィジェットの平坦な(id,type,value)だけで、名前・矩形・階層がない。- 本体設計の
a11y_snapshot()で置き換える。 - Layer には
focusable/joinFocusChain/focused/hint/onFocusからなる完全なフォーカス連鎖がある。KAG のButtonLayer/MessageLayerのリンク /CheckBoxLayer/EditLayerが使っている。ここは Phase B の対象にする。 - REPL は
common/utils/REPL.cppのtTVPReplThread::ProcessLineで、file / web / socket / console の各チャネルに共通。.dlg/.clickはAgent.*への文字列書き換えで実装されている。
2. 構成¶
┌──────────── tTVPAccessibility (新規, common/visual/accessibility/) ─────────────┐
OS AT ⇄ accesskit_host (Elements L3) │
│ slot 0 : ゲーム本体 source (tTVPGameA11ySource: 場面名・Layer フォーカス連鎖) │
│ slot 1..N : ElementsDialogManager の各 overlay_session (z 順, modal 連動) │
└──────────────────────────────────────────────────────────────────────────────┘
TJS: Accessibility クラス (announce / mode / active / onActive)
REPL: Agent.a11yTree / a11yLog / a11yAction, .a11y / .a11ylog / .say
KRKRZ_USE_A11Y(既定はKRKRZ_USE_ELEMENTSと同じ) と Elements のELEMENTS_A11Y_ACCESSKITを連動させる。LIB 変種と NX などでは OFF。- OFF の場合でも、
Agent.a11yTree()だけは Elements の L0〜L2 で動く (AccessKit なしで検証できる)。
3. ウィンドウへの接続¶
| ビルド | 接続 |
|---|---|
| SDL3 (Win / mac / Linux) | form.cpp でウィンドウを作った直後に accesskit_host::attach_sdl(window)。Linux 向けには SDL_AppEvent のウィンドウイベントで focus と bounds を通知する |
| WINVER | TTVPWindowForm::Proc で WM_GETOBJECT を受けたら on_wm_getobject()。WM_ACTIVATE で on_window_focus()。プラグインの message receiver ではなく、コア内で直接処理する |
showModalJson の専用ウィンドウ |
Phase C。専用ウィンドウにも別の accesskit_host を付ける (run_modal 側で本体の Win32 / SDL ホストと同じ処理を使う) |
- メインウィンドウは複数ありうる (
Windowを複数生成できる)。そこでaccesskit_hostは Window ごとに持ち、Elements の各インスタンスは「そのインスタンスが描かれる Window」の host に登録する。 - root ノードの名前は
Window.captionを使う。
4. ElementsDialog の source 化 (ElementsDialogManager)¶
- インスタンスの生成・破棄で
add_source/remove_sourceを呼ぶ。z 順の入れ替え、modal、activeが変わったらset_modalと z を同期する。 - 座標:
PaintOverlayがサーフェス論理座標のlast_rectを確定させた直後に、サーフェス → クライアントピクセルの変換でset_transformを呼ぶ。この変換はGetTextInputAreaの IME 用変換と同じものを共有する。 overlay_session::set_a11y_dirty_callbackをinvalidate()につなぐ。ElementsPanel(Layer に描く) は Phase B で扱う。Layer の座標と可視性に従って、ゲーム本体の source の子として接ぐ。
5. TJS API¶
class Accessibility // static
{
property mode; // "auto"(既定: AT 接続時のみ) / "on" / "off"
property active; // read-only: OS の AT が接続中か
property gameLabel; // ゲーム本体ノードの名前 (場面名など)
function announce(text, assertive=false, window=void);
// event
onActiveChanged(active); // System.addEventListener 相当で受ける (TVPPostEvent)
}
- ElementsDialog の JSON / Dictionary に書いた
"a11y"キー (本体設計 §4.1) は、何もしなくてもそのまま効く。 activeが false のときは、announceは読み上げログへの記録だけ行い、すぐ戻る。
KAG 連携 (opt-in スクリプト) — 行わない¶
設計時の案。KAG3 は今後拡張しない方針になったので、
script/KAG3には入れない。KAG 系の作品は、作品側のスクリプトで本文をElementsDialog.announceに渡し、選択肢などをsetGameA11yで載せる (実際にそうしている作品がある)。
KAG 本体の .tjs は変更しない。拡張 system/A11yKAG.tjs を用意し、kag.a11y.messages = true で有効にする。
MessageLayerで 1 行または 1 ページの表示が終わったら、蓄積した文字列をAccessibility.announce(name + "「" + text + "」")に渡す。HistoryLayerに積む文字列と同じものを使うので、ルビとタグは除いた状態になる。- 選択肢 (リンク) にフォーカスが移ったら、その
hintを読ませる。Phase B で Layer の連鎖を source にすれば自動で読むようになるので、それまでの暫定措置。 - ボイスがある行は、設定によって読まない。
kag.a11y.skipVoiced
6. REPL / Agent¶
既存の dialogTree はそのまま残す。
| 追加 | 実装 |
|---|---|
Agent.a11yTree([window]) |
Window の host が持つ合成ツリー (全 slot) を本体設計 §5 の形式の Dictionary で返す。index を指定するとその dialog の source だけを返す |
Agent.a11yLog([since]) |
読み上げログ (focus、value の変化、live、announce の近似 1 行) の配列 |
Agent.a11yAction(nodeId, action[, arg]) |
AT からのアクションと同じ経路 (accesskit_host → source::perform) で実行する |
.a11y / .a11ylog / .say <text> |
ProcessLine の書き換えブロックに追加し、ヘルプ行にも足す。file / web / socket / console の全チャネルで使える |
web /a11y、SSE /sub/a11y |
ReplWebServer のネイティブ route。panel 系の UI はツリー表示と読み上げログ表示を elements_console の A11y タブと共有する |
Agent.a11yAction は Agent.dialogClick と違ってキー合成を介さずに実行する。そのため、AT の操作経路そのものを検証できる。
7. 段階¶
| Phase | 内容 |
|---|---|
| A (済) | SDL3 / WINVER のメインウィンドウへの接続、ElementsDialog の source 化、TJS の口 (ElementsDialog の静的メンバ)、REPL の追加 |
| B (済) | ゲーム本体の source (setGameA11y と、Layer フォーカス連鎖 / hint / focusedLayer / ElementsPanel を読む a11yLayers)。KAG 拡張スクリプトは行わない |
| C | showModalJson の専用ウィンドウ、複数 Window (macOS / Linux 実機での確認は A / B と一緒に済んだ) |
入力欄の文字・単語・行単位の読み上げは Elements 側 (本体設計 §6.5) で済んでいる。IME の変換中文字列は扱わない方針 (上の「入力欄」)。残りは Phase C。索引は umbrella の TODO.md。