unity-input-correctness について
Unity の新 Input System を使う開発者向けに、正しい実装パターンをまとめたスキルです。アクションの読み取り、入力値の有効期間、デバイス割り当て、リバインドの注意点を扱います。
主な機能
- 01単発入力と押し続ける入力の読み取りを選択
- 02アクションマップの切り替えと有効化
- 03プロセッサーとインタラクションを区別
- 04InputValue のコールバック内の有効期間に対応
- 05複数入力ソースとデバイス割り当てを扱う
- 06リバインドの保存とボタン表示
活用シーン
- 01押している間に一度しか動かないアクションを調査
- 02マップ切り替え後のコールバック停止を防止
- 03マルチタッチやローカルマルチプレイ入力を実装
- 04現在の操作方式に応じたボタン表示
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ nice-wolf-studio/ unity-claude-skills --skill unity-input-correctnessインストール後、Claude Code で/unity-input-correctnessを入力して読み込まれたか確認します。
同梱ファイルreferences/rebinding-and-multiplayer.md
詳細
Input System(New)-- 正確性のパターン
前提スキル:
unity-input(Input System API、アクション、バインディング、PlayerInput コンポーネント)
これらのパターンは、Input System でよく発生するバグを対象としています。アクションの種類に合わない読み取り方法、旧 API と新 API の混在、リバインドの消失、マルチプレイヤーでのデバイス割り当ての誤処理です。
パターン:入力の読み取り -- triggered と IsPressed と WasPressedThisFrame
WHEN:実行時にボタン/アクションの状態を読み取る場合
WRONG(Claude のデフォルト):
// Using .triggered for continuous input (only fires once per press)
if (fireAction.triggered)
rb.AddForce(Vector3.forward * force); // Only fires one frame, not while held
// Using .IsPressed() for one-shot actions (fires every frame while held)
if (jumpAction.IsPressed())
Jump(); // Jumps every frame the button is held!
RIGHT:
// One-shot actions (jump, interact, fire single bullet):
if (jumpAction.WasPressedThisFrame()) // True for exactly ONE frame
Jump();
// Or use .triggered (same as WasPressedThisFrame for Button actions with default interaction)
if (jumpAction.triggered)
Jump();
// Continuous actions (sprint, aim, hold to charge):
if (sprintAction.IsPressed()) // True every frame while held
moveSpeed = sprintSpeed;
// Value reading (stick, mouse delta):
Vector2 moveInput = moveAction.ReadValue<Vector2>(); // Continuous value
GOTCHA:.triggered は Interactions(Hold、Tap など)に従い、インタラクションが完了したときに発火します。.WasPressedThisFrame() は、インタラクションに関係なく、生の押下を検出して発火します。.IsPressed() は、押下しきい値を超えて作動している間、毎フレーム true を返します。インタラクションのない Button 型アクションでは、.triggered == .WasPressedThisFrame() です。Value 型アクションでは、値がゼロから非ゼロに変化したときに .triggered が発火します。
パターン:アクションマップの切り替え
WHEN:アクションマップを切り替える場合(例:Gameplay -> UI -> Vehicle)
WRONG(Claude のデフォルト):
// Forgetting that SwitchCurrentActionMap disables the previous map
playerInput.SwitchCurrentActionMap("UI");
// All "Gameplay" actions are now DISABLED -- callbacks won't fire
// If you cached Gameplay actions, they silently stop working
RIGHT:
// Option 1: Via PlayerInput (handles enable/disable automatically)
playerInput.SwitchCurrentActionMap("UI");
// Previous map disabled, new map enabled
// Option 2: Manual enable/disable (more control)
gameplayActions.Disable();
uiActions.Enable();
// Option 3: Keep both maps active simultaneously
// (useful for universal actions like Pause)
gameplayActions.Enable();
pauseActions.Enable(); // Both active at once
GOTCHA:PlayerInput.SwitchCurrentActionMap を使うと、切り替え前のマップは完全に無効になります。切り替え前のマップから取得してキャッシュした InputAction 参照は、再度有効にするまでコールバックを発火しなくなります。Pause など、すべてのマップで動作させる必要のあるアクションは、常に有効にしておく別のマップに配置するか、手動で有効/無効を切り替えてください。
パターン:プロセッサーとインタラクションの混同
WHEN:デッドゾーンを適用したり、入力値を変更したりする場合
WRONG(Claude のデフォルト):
// Adding a deadzone as an Interaction (Interactions modify TIMING, not values)
// In .inputactions: Action > Interactions > "Deadzone" -- this doesn't exist as an interaction
RIGHT:
// Deadzones are PROCESSORS -- they modify the input VALUE
// Set in .inputactions: Binding > Processors > "Stick Deadzone" or "Axis Deadzone"
// Processors modify the value stream: Raw Input -> Processor Chain -> Final Value
// Common processors:
// StickDeadzone -- applies radial deadzone to Vector2 (sticks)
// AxisDeadzone -- applies linear deadzone to float (triggers)
// Normalize -- normalizes Vector2 to 0-1 range
// Invert -- negates the value
// Scale -- multiplies by a factor
// Clamp -- clamps to min/max range
// Runtime processor override (if needed):
moveAction.ApplyBindingOverride(new InputBinding { overrideProcessors = "StickDeadzone(min=0.2,max=0.9)" });
GOTCHA:プロセッサーは値を変換します(デッドゾーン、正規化、スケーリング、反転)。インタラクションは started/performed/canceled が発火するタイミングを変更します(Press、Hold、Tap、SlowTap、MultiTap)。これらを混同すると、デッドゾーンが適用されない(プロセッサーがない)か、コールバックのタイミングが適切でなくなる(不要なインタラクションを追加している)結果になります。
パターン:SendMessages/BroadcastMessages における InputValue の有効期間
WHEN:動作モードを SendMessages または BroadcastMessages にして PlayerInput を使用する場合
WRONG(Claude のデフォルト):
private InputValue _cachedInput; // Storing the reference
void OnMove(InputValue value)
{
_cachedInput = value; // WRONG: InputValue is pooled and recycled
}
void Update()
{
Vector2 dir = _cachedInput.Get<Vector2>(); // May return stale or corrupt data
}
RIGHT:
private Vector2 _moveInput;
void OnMove(InputValue value)
{
// Copy the value immediately -- InputValue is only valid during the callback
_moveInput = value.Get<Vector2>();
}
void Update()
{
transform.Translate(_moveInput * speed * Time.deltaTime);
}
GOTCHA:InputValue は、コールバック間で再利用されるラッパーです。内部データが有効なのは、コールバックの実行中のみです。コールバック内で .Get<T>() を使って必ずコピーし、その結果を保存してください。これは SendMessages モードと BroadcastMessages モードに適用されます。UnityEvents モードと C# Events モードでは InputValue は使われず、同じ有効期間の制約がある InputAction.CallbackContext が渡されます。
パターン:複数の入力ソースに対する PassThrough と Value
WHEN:複数の入力ソースから同時に入力を処理する場合(マルチタッチ、複数のゲームパッドなど)
WRONG(Claude のデフォルト):
// Using "Value" action type for multi-touch
// Value type performs disambiguation -- picks the input with highest magnitude
// You only see ONE touch, even if multiple fingers are on screen
RIGHT:
// Use "PassThrough" action type for all-source input
// PassThrough does NOT disambiguate -- every input source triggers the action
// In .inputactions file: Set Action Type = "Pass Through"
// This is essential for:
// - Multi-touch (each finger fires separately)
// - Multiple gamepads sending the same action
// - Combining keyboard + mouse simultaneously
// Read which device triggered it:
void OnAction(InputAction.CallbackContext ctx)
{
var device = ctx.control.device;
var value = ctx.ReadValue<float>();
}
GOTCHA:Button:押下/解放時に発火し、float の 0 または 1 を返します。Value:値が変化したときに発火し、最も大きな値のソースを選択します(曖昧さの解消)。PassThrough:曖昧さの解消を行わず、すべてのソースからの変化ごとに発火します。ほとんどのゲームプレイ入力では、Value が適切です。デバイスまたは指ごとの追跡が必要な場合にのみ PassThrough を使ってください。
パターン:アクションの有効化/無効化の範囲
WHEN:個々のアクションとアクションマップ全体を有効/無効にする場合
WRONG(Claude のデフォルト):
// Enabling an action without enabling its map
fireAction.Enable(); // Works, BUT...
// If the map was disabled, this implicitly enables JUST this action
// Other actions in the same map remain disabled
RIGHT:
// Preferred: Enable/disable at the MAP level
playerActions.Enable(); // Enables all actions in the map
playerActions.Disable(); // Disables all actions
// Individual action enable/disable (advanced use only):
fireAction.Enable(); // Enables this action even if map is disabled
fireAction.Disable(); // Disables only this action
// Check state:
bool mapEnabled = playerActions.enabled;
bool actionEnabled = fireAction.enabled;
GOTCHA:アクションを含むマップが「無効」でも、そのアクションを有効にすることはでき、アクション自体は引き続き動作します。ただし、map.enabled が false を返し、action.enabled が true を返すという、混乱を招く状態になります。ベストプラクティスとして、常にマップ単位で有効/無効を切り替えてください。リロード中に射撃を一時的に無効にする場合のような特別なケースに限り、アクション単位で有効/無効を切り替えてください。
パターン:デバイス固有のボタンプロンプト
タイミング:プレイヤーに操作のヒントを表示するとき(例:「X を押して操作」)
誤り(Claude のデフォルト):
// Hardcoded button names
promptText.text = "Press A to Jump";
// Wrong on keyboard (should be "Space"), PS5 (should be "Cross"), etc.
正しい例:
// Get the display name for the current binding
InputAction jumpAction = inputActions.FindAction("Jump");
// Get display string for the active control scheme
string displayName = jumpAction.GetBindingDisplayString(
InputBinding.DisplayStringOptions.DontOmitDevice);
promptText.text = $"Press {displayName} to Jump";
// For a specific control scheme:
int bindingIndex = jumpAction.GetBindingIndex(
InputBinding.MaskByGroup("Gamepad"));
if (bindingIndex >= 0)
{
string gamepadPrompt = jumpAction.GetBindingDisplayString(bindingIndex);
// Returns "Button South" or device-specific name
}
注意点:GetBindingDisplayString() は人が読みやすい名前を返します。パラメーターを指定しない場合、最初のバインディングに対応する文字列が返されます。特定のコントロールスキームを対象にするには、バインディングマスクまたはインデックスを使います。アイコンを完全にサポートするには、コントロールパスをスプライト/アイコンの参照に対応付けるカスタムの InputBindingComposite またはアセットが必要です。Unity にはアイコンを対応付ける組み込み機能はありません。
パターン:ローカルマルチプレイヤーのデバイス割り当て
タイミング:同じマシンで、別々のコントローラーを使って複数のプレイヤーが遊べるようにするとき
誤り(Claude のデフォルト):
// Both players reading from the same static device reference
Vector2 p1Move = Gamepad.current.leftStick.ReadValue();
Vector2 p2Move = Gamepad.current.leftStick.ReadValue(); // Same gamepad!
正しい例:
// Use PlayerInputManager for automatic device assignment
// 1. Add PlayerInputManager component to a manager object
// 2. Set Join Behavior (e.g., JoinPlayersWhenButtonIsPressed)
// 3. Set Player Prefab (must have PlayerInput component)
// PlayerInputManager automatically assigns unique devices to each player
// In the player script:
public class PlayerController : MonoBehaviour
{
private PlayerInput _playerInput;
private InputAction _moveAction;
void Awake()
{
_playerInput = GetComponent<PlayerInput>();
_moveAction = _playerInput.actions["Move"];
}
void Update()
{
// Each PlayerInput instance reads from its ASSIGNED device only
Vector2 move = _moveAction.ReadValue<Vector2>();
transform.Translate(move * speed * Time.deltaTime);
}
}
// Listen for join/leave events:
void OnEnable()
{
PlayerInputManager.instance.onPlayerJoined += OnPlayerJoined;
PlayerInputManager.instance.onPlayerLeft += OnPlayerLeft;
}
注意点:Gamepad.current が返すのは最後に使用されたゲームパッドであり、特定のプレイヤーのゲームパッドではありません。マルチプレイヤーでは、デバイス割り当てを管理する PlayerInput コンポーネントを通じて、必ず入力を読み取ってください。PlayerInputManager.instance.maxPlayerCount でプレイヤー数を制限します。画面分割は PlayerInput.camera の割り当てによって処理され、各プレイヤーには異なるビューポート rect のカメラが割り当てられます。
パターン:コントロールスキームの自動切り替え
タイミング:プレイヤーがゲーム中にキーボードとゲームパッドを切り替えるとき
誤り(Claude のデフォルト):
// Assuming the control scheme is fixed after startup
// UI shows keyboard prompts even after player picks up a gamepad
正しい例:
public class ControlSchemeHandler : MonoBehaviour
{
private PlayerInput _playerInput;
void OnEnable()
{
_playerInput = GetComponent<PlayerInput>();
_playerInput.controlsChangedEvent.AddListener(OnControlsChanged);
// Initialize with current scheme
UpdatePrompts(_playerInput.currentControlScheme);
}
void OnDisable()
{
_playerInput.controlsChangedEvent.RemoveListener(OnControlsChanged);
}
void OnControlsChanged(PlayerInput input)
{
UpdatePrompts(input.currentControlScheme);
}
void UpdatePrompts(string schemeName)
{
bool isGamepad = schemeName == "Gamepad";
// Update UI prompts, button icons, etc.
promptIcon.sprite = isGamepad ? gamepadSprite : keyboardSprite;
}
}
注意点:PlayerInput は、異なる種類のデバイスからの入力を検出すると、コントロールスキームを自動的に切り替えます。切り替えのたびに controlsChangedEvent が発生します。currentControlScheme は、.inputactions のコントロールスキーム名に一致する文字列を返します。切り替えはデバイス接続時ではなく、次の入力イベントで発生します。両方のデバイスを同時に接続してテストしてください。
アンチパターンの早見表
| アンチパターン | 問題 | 修正方法 |
|---|---|---|
Input.GetKey と新しい Input System の併用 | 旧 API と新 API が競合し、両方のバックエンドを有効にする必要が生じる場合がある | 新しい Input System に完全に移行し、using UnityEngine.Input を削除する |
action.Enable() を呼び出さない | アクションが何もせず、エラーも発生しない | 値を読み取る前に、アクションマップまたは個々のアクションを有効にする |
.ReadValue<T>() を誤った型 T で読み取る | エラーなしで既定値が返される | T をアクションの Control Type に合わせる(スティックには Vector2、ボタンには float) |
PerformInteractiveRebinding の破棄を忘れる | メモリリークが発生する | .Start() の完了後またはキャンセル後に、必ず .Dispose() を呼び出す |
入力にレガシーな OnGUI を使う | IMGUI と Input System が混在する | UI Toolkit または Input System のコールバックを使う |
| リバインドのオーバーライドを保存しない | 再起動後にプレイヤーのカスタムバインディングが失われる | SaveBindingOverridesAsJson で保存し、Awake で読み込む |
関連スキル
- unity-input -- Input System API リファレンス、アクションの種類、バインディング構文、デバイスアクセス
- unity-ui -- UI Toolkit の入力処理、ナビゲーションイベント
- unity-multiplayer -- Netcode の入力権限、クライアント予測