前回の記事では、VerseのみでPINコード / パスコード入力パッドを実装する方法を紹介しました。
前回の記事はこちらです。
UEFN / VerseのみでPINコード・パスコード入力パッドを実装する
今回はその続編として、前回掲載した約400行のVerseコードを、UEFNを始めたばかりの方でも読みやすいように分解して解説していきます。
完成コードをそのまま貼るだけだと、どうしても
- どこがUI生成なのか
- どこが入力処理なのか
- どこがマルチプレイヤー対応なのか
- なぜ
playerごとにMapを持っているのか
が見えにくくなります。
そこで今回は、コード全体の役割を順番にほどいていく記事にしました。
記事の後半には、前回と同じソースコード全文もそのまま載せています。
この記事は前回記事の続編です
前回の記事では、以下をVerseのみで実装しました。
- PIN入力UIの生成
- 数字ボタン
- delete
- enter
- close
- プレイヤーごとのUI分離
- 同時操作対応
- 複数PIN対応
- 成功トリガー分岐
今回の記事では、そのコードを題材にして、なぜこの構造になっているのかを初心者向けに解説します。
まず、このPINパッドは何をしているのか
このVerseデバイスを一文で言うと、
「トリガーで開いて、プレイヤー専用のPIN入力UIをVerseだけで表示し、入力内容に応じて別々のTriggerを発火させる仕組み」
です。
たとえば以下のような用途に使えます。
- 金庫のロック
- 秘密の扉
- 謎解き
- セキュリティ端末
- 隠しコマンド入力
コード全体の見取り図
約400行ありますが、実際には大きく6ブロックです。
@editableの設定項目- プレイヤーごとの状態管理
- UIを開く処理
- 入力文字列を更新する処理
- PINの正誤判定
- UIレイアウトの構築
つまり、アルゴリズムが異常に複雑というより、UIをVerseで全部組み立てているので長いというのが実態です。
1. @editable は何を設定しているのか
最初に出てくるこの部分です。
@editable
OpenTrigger : trigger_device = trigger_device{}
@editable
FailedTrigger : trigger_device = trigger_device{}
@editable
PinCodes : []string = array{}
@editable
SuccessTriggers : []trigger_device = array{}
@editable
TitleText : string = "PIN PAD"
@editable
MaxDigits : int = 8OpenTrigger
UIを開くためのトリガーです。
ボタン、Interaction Device、Triggerなどから接続します。
FailedTrigger
PINが間違っていたときに発火するトリガーです。
PinCodes
正解として扱うPINコードの一覧です。複数設定できます。
SuccessTriggers
PIN成功時に発火するトリガーの一覧です。
PinCodes と同じインデックスで対応します。
例:
PinCodes[0]に一致したらSuccessTriggers[0]PinCodes[1]に一致したらSuccessTriggers[1]
TitleText
UI上部のタイトルです。
PIN PAD や SECURITY LOCK など自由に変更できます。
MaxDigits
最大入力桁数です。
2. このコードで一番大事なのはプレイヤーごとの状態分離
次の部分が、このデバイスの核です。
var PlayerRoots : [player]widget = map{}
var PlayerInputTexts : [player]text_block = map{}
var PlayerInputs : [player]string = map{}
var PlayerUIs : [player]player_ui = map{}これは全部、playerをキーにしたMapです。
なぜ必要なのか
マルチプレイヤー対応のUIで1つの変数しか持たないと、状態が混ざります。
たとえばこういう事故が起きます。
- Aさんの入力がBさんの画面に出る
- Aさんが閉じたらBさんのUIまで消える
- PINの入力状態が共有される
それを防ぐために、プレイヤーごとに別々の状態を保存しています。
各Mapの役割
PlayerRoots
そのプレイヤーに表示したルートWidgetです。
閉じるときに RemoveWidget するために使います。
PlayerInputTexts
入力中の文字を表示する text_block です。
UpdateDisplay で更新する相手になります。
PlayerInputs
現在の入力文字列です。
例:
- Aさん →
"1234" - Bさん →
"9999"
のように完全に分かれます。
PlayerUIs
そのプレイヤーの player_ui を保存しています。
3. UIはどうやって開いているのか
まず OnBegin で OpenTrigger にイベントを登録しています。
OnBegin<override>()<suspends>:void =
OpenTrigger.TriggeredEvent.Subscribe(OnOpenTriggered)つまり、
OpenTriggerが発火したら OnOpenTriggered を呼ぶ
という意味です。
次にこちらです。
OnOpenTriggered(MaybeAgent:?agent):void =
if (A := MaybeAgent?):
if (P := player[A]):
OpenForPlayer(P)Triggerイベントから渡ってくるのは ?agent なので、
- agentが存在するか確認
playerに変換- 変換できたら
OpenForPlayer(P)
という流れになっています。
4. OpenForPlayer が実際にUIを表示する
ここで大事なのは最初に CloseForPlayer(P) を呼んでいることです。
なぜ最初に閉じるのか
同じプレイヤーが何度も開いたときに
- UIが二重に重なる
- 前回のWidgetが残る
- 入力状態が中途半端に残る
といった事故を防ぐためです。
そのあとで
GetPlayerUI[P]を取るBuildRootWidget(P)でUIを作るAddWidgetで表示する- 入力文字列を空に初期化する
- 表示を更新する
という順番で進みます。
5. UIを閉じる処理はかなりシンプル
CloseForPlayer(P:player):void =
if (UI := PlayerUIs[P]):
if (Root := PlayerRoots[P]):
UI.RemoveWidget(Root)やっていることは、そのプレイヤーに紐づいているUIだけを消すことです。
全員分を消しているわけではありません。
6. 表示更新は UpdateDisplay
UpdateDisplay(P:player):void =
if (TB := PlayerInputTexts[P]):
if (Current := PlayerInputs[P]):
if (Current.Length > 0):
TB.SetText(ToMessage(Current))
else:
TB.SetText(ToMessage(""))これは、入力中の文字列を画面の表示欄へ反映する関数です。
数字を押すたびに
PlayerInputs[P]を更新UpdateDisplay(P)を呼ぶ
という流れになります。
7. ToMessage はなぜ必要なのか
ToMessage<localizes>(S:string) : message = "{S}"VerseのUIは string ではなく message を要求する場面があります。
そのため、小さな変換関数を1つ置いています。
UIをVerseで組むと、こういう補助関数はかなり便利です。
8. 数字追加処理は AppendDigit
AppendDigit(P:player, Digit:string):void =
if (Current := PlayerInputs[P]):
if (Current.Length >= MaxDigits):
return
NewValue : string = "{Current}{Digit}"
if (set PlayerInputs[P] = NewValue):
UpdateDisplay(P)ここでは
- 現在の文字列を取得
- 最大桁数チェック
- 新しい数字を後ろに追加
- 保存
- 表示更新
をしています。
たとえば "12" の状態で "3" を押せば "123" になります。
9. DELETEは末尾1文字を切るだけ
DeleteLastDigit(P:player):void =
if (Current := PlayerInputs[P]):
if (Current.Length <= 0):
return
NewValue : string = Substring(Current, 0, Current.Length - 1)
if (set PlayerInputs[P] = NewValue):
UpdateDisplay(P)これは末尾1文字を削除しています。
例:
"1234"→"123""9"→""
10. Substring を自前で書いている理由
DELETE処理のために、文字列の一部を切り出す関数を用意しています。
この関数は
- 開始位置と長さを受け取る
- 範囲チェックする
- 1文字ずつ連結する
- 結果を返す
という流れです。
Verseでは、他言語の感覚で便利な文字列操作を期待しすぎない方が安全なので、
確実に動く処理を自前で持っていると考えると分かりやすいです。
11. PIN判定の中心は SubmitPin
これがPIN判定の本体です。
流れ
- 現在の入力文字列を取得
PinCodesを順番に見る- 一致したら成功
- 対応する
SuccessTriggers[Index]を発火 - 一致しなければ
FailedTrigger - 最後にUIを閉じる
12. なぜ Index を使っているのか
この実装では
PinCodesSuccessTriggers
を同じ番号同士で対応させています。
例:
PinCodes[0] = "1234"→SuccessTriggers[0]PinCodes[1] = "4321"→SuccessTriggers[1]PinCodes[2] = "0000"→SuccessTriggers[2]
これにより、Verseコードを大きく書き換えなくても、Detailsから分岐を増やせます。
13. 400行の大半は BuildRootWidget です
コードが長く見える最大の理由はここです。
BuildRootWidget(P:player):canvas =この関数で
- 背景
- パネル
- タイトル
- 表示欄
- 数字ボタン
- DELETE
- ENTER
- CLOSE
- 配置座標
を全部Verseだけで組み立てています。
つまり、行数が多いのはVerseだけでUIを全部書いているからです。
14. BuildRootWidget の中身を分けて見る
背景の暗幕
Dimmer : color_block = color_block{
DefaultColor := color{R := 0.0, G := 0.0, B := 0.0},
DefaultOpacity := 0.60,
DefaultDesiredSize := vector2{X := 1920.0, Y := 1080.0}
}モーダルっぽく見せるための全画面の半透明背景です。
メインパネル
Panel : color_block = color_block{
DefaultOpacity := 0.98,
DefaultDesiredSize := vector2{X := 560.0, Y := 800.0},
DefaultColor := color{R := 0.0, G := 0.0, B := 1.0}
}中央の青いパネルです。
タイトル
Title : text_block = text_block{
DefaultText := ToMessage(TitleText),
DefaultTextColor := color{R := 1.0, G := 1.0, B := 1.0},
DefaultTextSize := 36.0,
DefaultJustification := text_justification.Center
}入力表示欄
DisplayBG : color_block = color_block{
DefaultColor := color{R := 0.0, G := 0.0, B := 0.0},
DefaultOpacity := 1.0,
DefaultDesiredSize := vector2{X := 420.0, Y := 74.0}
}
DisplayText : text_block = text_block{
DefaultText := ToMessage(""),
DefaultTextColor := color{R := 1.0, G := 1.0, B := 1.0},
DefaultTextSize := 34.0,
DefaultJustification := text_justification.Center
}表示欄は
- 背景
- 文字
の2つでできています。
15. DisplayText をMapに保存している意味
if (set PlayerInputTexts[P] = DisplayText):これをしておかないと、あとで UpdateDisplay(P) を呼んだときに、
どの text_block を更新すればいいか分かりません。
つまり、表示先のWidgetをプレイヤーごとに記録しているわけです。
16. ボタンは1個ずつ生成している
数字ボタン、DELETE、ENTER、CLOSEを全部Verseで生成しています。
ボタン種類を分けている理由
- 数字とENTERは目立たせたいので
button_loud - DELETEは普通の
button_regular - CLOSEは控えめな
button_quiet
という見た目上の使い分けです。
17. ボタン押下イベントは Subscribe でつなぐ
これは
「このボタンが押されたらこの関数を呼ぶ」
という接続です。
18. 数字ごとに関数を分けているのは初心者向けにはむしろ分かりやすい
後半にこういう関数が並んでいます。
OnDigit1(Msg:widget_message):void =
if (P := player[Msg.Player]):
AppendDigit(P, "1")これは見た目上は少し冗長ですが、初心者向けにはかなり分かりやすいです。
理由は、
- どのボタンが何をしているかすぐ読める
widget_messageからplayerを取る流れが見える- クリックイベントの構造が理解しやすい
からです。
19. canvas_slot が大量にあるのは配置情報だから
BuildRootWidget の後半では canvas_slot が大量に出てきますが、これはロジックではなく座標指定です。
たとえば
canvas_slot:
Anchors := anchors{Minimum := vector2{X := 0.5, Y := 0.5}, Maximum := vector2{X := 0.5, Y := 0.5}}
Offsets := margin{Left := -130.0, Top := -110.0, Right := 0.0, Bottom := 0.0}
Alignment := vector2{X := 0.5, Y := 0.5}
SizeToContent := true
Widget := Btn1これは
「画面中央基準で、少し左上にBtn1を置く」
という意味です。
最低限の見方
Anchorsは基準位置OffsetsはそこからのズレAlignmentはWidget自身の基準点
この程度で読めれば十分です。
20. このコードを読むおすすめ順序
400行を最初から最後まで追うと重いので、以下の順序がおすすめです。
@editablePlayerRootsなどのMapOnBeginOnOpenTriggeredOpenForPlayerAppendDigitDeleteLastDigitUpdateDisplaySubmitPinBuildRootWidget
この順番だと、意味の流れがつかみやすいです。
21. ソースコード全文
以下が前回の記事で掲載したPIN入力パッドのVerseコード全文です。
22. このコードのポイントを一言でまとめると
この実装で一番重要なのは、
UIそのものよりも、プレイヤーごとの状態をMapで分離していること
です。
ここができているからこそ、
- 複数プレイヤーが同時に開いても壊れない
- 入力が混ざらない
- 表示更新先がズレない
- UIの開閉が独立する
という、実戦投入できるPINパッドになります。
まとめ
前回の記事では、VerseのみでPIN入力パッドを完成形として紹介しました。
今回はその続編として、コード全体を初心者向けに分解して見ていきました。
約400行あると長く見えますが、実際には
- 設定
- 状態管理
- UI表示
- 入力更新
- 正誤判定
- レイアウト
に分かれているだけです。
特に BuildRootWidget が長いのは、VerseだけでUIを全部組み立てているからで、
複雑な演算が大量に入っているわけではありません。
Verseだけでもここまで作れるようになると、
- 金庫
- 秘密扉
- 謎解き
- セキュリティ端末
- 特殊な分岐ギミック
など、かなりゲームらしい仕掛けが作れるようになります。
前回の記事と合わせて読むと、完成コードと構造理解の両方が見えると思います。
ぜひ自分の島に合わせて拡張してみてください。
UEProg by milkc0de
