ボタンを押したら、3秒待ってから扉が開いてほしい。押した瞬間にカウントダウンが始まって、0になったら何かが起きる。
「少し待ってから」という処理は、ワールドの演出でよく出てきます。素直にやるなら Update() で毎フレーム秒数を足していく方法が思いつきますが、待っているあいだずっと計算し続けることになります。
この記事では、「その時刻に1回呼んで」と予約する 書き方を解説します。Updateを使わずに済み、コードも短くなります。
この記事でわかること
- 時間差の処理を予約する書き方
- Updateで数えるより軽く、読みやすい理由
- カウントダウンを画面に出す方法
- 連打されたときにどうなるか
メソッドとカスタムイベント を試した状態から始めます。
待つのではなく、予約する
「3秒後に何かをする」には、2つのやり方があります。
1つ目は、毎フレーム数える方法 です。Update() の中で経過時間を足していき、3秒を超えたら実行します。動きますが、待っているあいだずっと計算が走ります。90fpsなら1秒に90回、3秒で270回です。
2つ目は、予約する方法 です。「3秒後にこのメソッドを呼んで」とVRChatに頼んで、あとは何もしません。

使うのはこの1行です。
SendCustomEventDelayedSeconds(nameof(OpenDoor), 3f);
「OpenDoor というメソッドを、3秒後に呼んでください」という意味です。予約したあと、この行の下の処理はすぐに続きます。待って止まるわけではありません。
Updateで数える方法と比べると、こうなります。
| Updateで数える | 遅延イベント | |
|---|---|---|
| 待っているあいだ | 毎フレーム計算する | 何もしない |
| コードの長さ | 変数と判定が必要 | 1行 |
| 読みやすさ | 経過時間を追う必要がある | 「3秒後に呼ぶ」と読める |
押されたときだけ動く処理なら、遅延イベントのほうが向いています。 Updateが向くのは、回転し続ける飾りのように「常に動かし続けるもの」です(イベントと実行順 で扱いました)。
呼ばれる側のメソッドには条件があります。public で、引数を取らない ことです。SendCustomEvent と同じ条件です。
実践:押して3秒後に扉を開く
ボタンを押すと3秒待ってから板が下がり、通れるようになります。
1. ボタンと扉を置く
床のあるシーンに2つ置きます。
| 名前 | 作り方と設定 |
|---|---|
DelayButton | Cube。(0, 1, -1)、Scale (0.4, 0.4, 0.4) |
SlideDoor | Cube。(0, 1, 2)、Scale (1.6, 2, 0.15) |

SlideDoor は、床から立った板です。これが下がって、通れるようになります。
2. コードを書く
Assets/Scripts で「Create → U# Script」を選び、DelayedDoor を作ります。
using UdonSharp;
using UnityEngine;
[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class DelayedDoor : UdonSharpBehaviour
{
[SerializeField] private Transform doorTransform;
[SerializeField] private float delaySeconds = 3f;
[Header("扉の位置")]
[SerializeField] private float closedY = 1f;
[SerializeField] private float openY = -1.2f;
private bool isMoving; // いま予約中か
public override void Interact()
{
if (doorTransform == null) return;
// すでに動いているなら、何もしない
if (isMoving) return;
isMoving = true;
Debug.Log("[DelayedDoor] " + delaySeconds + "秒後に開きます");
// 3秒後に OpenDoor を呼んでもらう
SendCustomEventDelayedSeconds(nameof(OpenDoor), delaySeconds);
}
// 予約されて呼ばれる。public で、引数なし
public void OpenDoor()
{
Vector3 p = doorTransform.localPosition;
doorTransform.localPosition = new Vector3(p.x, openY, p.z);
Debug.Log("[DelayedDoor] 開きました");
// 5秒後に閉じる予約を、続けて入れる
SendCustomEventDelayedSeconds(nameof(CloseDoor), 5f);
}
public void CloseDoor()
{
Vector3 p = doorTransform.localPosition;
doorTransform.localPosition = new Vector3(p.x, closedY, p.z);
isMoving = false; // ここでまた押せるようになる
Debug.Log("[DelayedDoor] 閉じました");
}
}
見どころが3つあります。
isMoving で二重に予約されるのを防いでいます。 これがないと、連打した回数だけ扉が開閉します。あとで詳しく説明します。
OpenDoor() の中から、さらに予約を入れています。 「3秒後に開いて、その5秒後に閉じる」という流れが、予約の連鎖で書けます。Updateで秒数を数える方法より、順番がそのままコードに現れます。
isMoving を戻すのは CloseDoor() です。 一連の動きが終わったところで、また押せる状態に戻します。
3. 割り当てて確かめる
DelayButton に Udon Behaviour を追加し、DelayedDoor を指定します。
| 欄 | 指定するもの |
|---|---|
| Door Transform | Hierarchyの SlideDoor |
| Delay Seconds | 3 |
| Closed Y | 1 |
| Open Y | -1.2 |
| Interaction Text | 扉を開ける |

Playして押してみてください。
| タイミング | 期待する結果 |
|---|---|
| 押した瞬間 | Consoleに 3秒後に開きます。扉はまだ動かない |
| 3秒後 | 扉が下がる。開きました |
| さらに5秒後 | 扉が戻る。閉じました |
| 待っているあいだに押す | 何も起きない(isMoving で弾いている) |
押してすぐ動かない、という間があること を確かめてください。押した瞬間にログが出るのに、扉は3秒後に動きます。これが予約の動きです。
カウントダウンを表示する
待っているあいだ、何も起きないのは不安です。残り時間を表示してみましょう。
SlideDoor の上あたりに、UIのテキストを置きます。「UI → Text - TextMeshPro」を作り、World Space の Canvas に入れて、扉の前に配置します。
コードに、1秒ごとの予約を足します。
using TMPro;
// ...
[SerializeField] private TextMeshProUGUI countdownText;
private int remaining;
public override void Interact()
{
if (doorTransform == null || isMoving) return;
isMoving = true;
remaining = (int)delaySeconds;
UpdateCountdown();
SendCustomEventDelayedSeconds(nameof(Tick), 1f);
SendCustomEventDelayedSeconds(nameof(OpenDoor), delaySeconds);
}
// 1秒ごとに自分を呼び直す
public void Tick()
{
remaining--;
UpdateCountdown();
if (remaining > 0)
{
SendCustomEventDelayedSeconds(nameof(Tick), 1f);
}
}
private void UpdateCountdown()
{
if (countdownText != null)
{
countdownText.text = remaining > 0 ? remaining.ToString() : "";
}
}
Tick() が 自分自身をもう一度予約している ところがポイントです。「1秒後にまた自分を呼ぶ」を繰り返すと、1秒ごとの処理になります。残りが0になったら予約をやめるので、勝手に止まります。
これも Update() で毎フレーム秒数を見るより軽く、そして読みやすくなります。1秒に1回しか動きません。
連打されるとどうなるか
大事な性質があります。予約した処理は、取り消せません。

isMoving の判定を外して、ボタンを5回連打してみてください。5回ぶんの予約が入り、扉は5回開いて5回閉じます。押した回数だけ律儀に実行されます。
対処は、実行される側か、呼ぶ側で弾く ことです。
- 呼ぶ側で弾く: いまの例のように
isMovingを見て、動いていたら予約しない - 実行される側で弾く:
OpenDoor()の先頭で「すでに開いているなら何もしない」と判定する
どちらでも構いませんが、呼ぶ側で弾くほうが分かりやすい ことが多いです。ボタンの反応がなくなるので、押した人にも「いま受け付けていない」ことが伝わります。
親切にするなら、待っているあいだ Interaction Text を変えたり、ボタンの色を変えたりします。
うまくいかないときは
- 予約した処理が呼ばれない → メソッドが
publicか、引数を取っていないかを確認します。privateでは呼ばれません - メソッド名を間違えた →
nameof()を使っていれば、コンパイルの時点でエラーになります。文字列で直接書いていると、実行するまで気づけません - 連打で何度も動く → 予約は取り消せません。
isMovingのような判定で弾きます - 待っているあいだに退出すると → その予約は実行されません。長い時間を1回で予約するより、短く区切るほうが安全です
おまけ:先に知っておくと良いこと
- フレーム単位で待つ方法もある:
SendCustomEventDelayedFramesは、指定したフレーム数だけ待ちます。「次のフレームで実行したい」といった細かい制御に使います - 他のUdonにも予約できる:
other.SendCustomEventDelayedSeconds(...)のように、別のスクリプトのメソッドを予約することもできます - これはローカルです: 予約も実行も、押した人のPCだけで起きます。全員の扉を開けたいなら、同期が必要です。ネットワーク同期入門 で扱います
- 滑らかに動かすには別の仕組みが要る: この記事の扉は、瞬間的に位置が変わりま す。ゆっくり開く動きにしたいなら AnimatorとUdonで扉を動かす を使います
- 長い予約は当てにしない: 数分先の予約は、その前に人が退出したりインスタンスが終わったりします。短い予約を積み重ねるほうが確実です
まとめ
時間差の処理は、待つのではなく予約します。
SendCustomEventDelayedSeconds(nameof(メソッド名), 秒数)の1行- 呼ばれるメソッドは
publicで引数なし - 予約のあとの行は、すぐに続けて実行される
- 予約は取り消せない。連打対策は自分で入れる
- 自分自身を予約し直すと、繰り返し処理になる
書く前の問いかけは、「これは常に動き続けるのか、それとも1回だけ後で動くのか」 です。後者なら、Updateではなく予約を使います。
滑らかに動く扉を作るなら AnimatorとUdonで扉を動かす、全員で同じ扉を開けるなら ネットワーク同期入門 へ進んでください。