chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖

This commit is contained in:
Liubing\LB
2026-09-28 18:14:59 +08:00
parent 0eb43b2b40
commit c4ae02def9
952 changed files with 70137 additions and 11 deletions

View File

@@ -0,0 +1,69 @@
#nullable enable
#if ULOOPMCP_HAS_INPUT_SYSTEM
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace io.github.hatayama.uLoopMCP
{
// Projects can process Input System events in dynamic, fixed, or manual mode.
// Keyboard simulation must follow that configured loop to avoid frame mismatches.
internal static class KeyboardInputUpdateTypeResolver
{
public static InputUpdateType Resolve()
{
InputSettings? settings = InputSystem.settings;
if (settings == null)
{
return InputUpdateType.Dynamic;
}
InputSettings.UpdateMode updateMode = settings.updateMode;
switch (updateMode)
{
case InputSettings.UpdateMode.ProcessEventsInFixedUpdate:
// Paused screens commonly set timeScale to 0, which stops fixed ticks entirely.
// Falling back to Dynamic keeps keyboard simulation responsive for those menus.
return IsPausedFixedUpdate(settings) ? InputUpdateType.Dynamic : InputUpdateType.Fixed;
case InputSettings.UpdateMode.ProcessEventsManually:
return InputUpdateType.Manual;
default:
return InputUpdateType.Dynamic;
}
}
public static bool IsMatch(InputUpdateType current, InputUpdateType expected)
{
return (current & expected) == expected;
}
public static bool RequiresExplicitManualUpdate(InputUpdateType updateType)
{
return updateType == InputUpdateType.Manual;
}
public static bool RequiresExplicitUpdate()
{
InputSettings? settings = InputSystem.settings;
if (settings == null)
{
return false;
}
if (settings.updateMode == InputSettings.UpdateMode.ProcessEventsManually)
{
return true;
}
return IsPausedFixedUpdate(settings);
}
private static bool IsPausedFixedUpdate(InputSettings settings)
{
return settings.updateMode == InputSettings.UpdateMode.ProcessEventsInFixedUpdate && Time.timeScale <= 0f;
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cb4b715627a724dee9863ab385cc6f89
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,119 @@
#if ULOOPMCP_HAS_INPUT_SYSTEM
#nullable enable
using System.Collections.Generic;
using UnityEditor;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace io.github.hatayama.uLoopMCP
{
// Tool instances are created fresh per invocation, so held-key state
// between KeyDown/KeyUp must be held statically.
[InitializeOnLoad]
internal static class KeyboardKeyState
{
private static readonly HashSet<Key> _heldKeys = new();
private static readonly HashSet<Key> _transientKeys = new();
public static bool IsKeyHeld(Key key) => _heldKeys.Contains(key);
public static IReadOnlyCollection<Key> HeldKeys => _heldKeys;
static KeyboardKeyState()
{
// Guard against duplicate subscriptions on domain reload
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
}
public static void SetKeyDown(Key key)
{
_heldKeys.Add(key);
}
public static void SetKeyUp(Key key)
{
_heldKeys.Remove(key);
}
public static void RegisterTransientKey(Key key)
{
_transientKeys.Add(key);
}
public static void UnregisterTransientKey(Key key)
{
_transientKeys.Remove(key);
}
public static void Clear()
{
_heldKeys.Clear();
_transientKeys.Clear();
}
// Keyboard keys are stored as a bitfield, so StateEvent.From captures
// the entire keyboard state. To support simultaneous key holds, we write
// ALL currently held keys into every event — not just the target key.
public static void SetKeyState(Keyboard keyboard, Key key, bool pressed)
{
InputUpdateType updateType = InputUpdateTypeResolver.Resolve();
using (StateEvent.From(keyboard, out InputEventPtr eventPtr))
{
foreach (Key heldKey in _heldKeys)
{
keyboard[heldKey].WriteValueIntoEvent(1f, eventPtr);
}
foreach (Key transientKey in _transientKeys)
{
keyboard[transientKey].WriteValueIntoEvent(1f, eventPtr);
}
keyboard[key].WriteValueIntoEvent(pressed ? 1f : 0f, eventPtr);
// Updating player state directly avoids editor focus-dependent routing.
InputState.Change(keyboard, eventPtr, updateType);
}
}
public static void ReleaseAllKeys()
{
Keyboard keyboard = Keyboard.current;
if (keyboard == null)
{
_heldKeys.Clear();
_transientKeys.Clear();
return;
}
// Single event with all keys released
using (StateEvent.From(keyboard, out InputEventPtr eventPtr))
{
foreach (Key key in _heldKeys)
{
keyboard[key].WriteValueIntoEvent(0f, eventPtr);
}
foreach (Key key in _transientKeys)
{
keyboard[key].WriteValueIntoEvent(0f, eventPtr);
}
InputUpdateType updateType = InputUpdateTypeResolver.Resolve();
InputState.Change(keyboard, eventPtr, updateType);
}
_heldKeys.Clear();
_transientKeys.Clear();
}
private static void OnPlayModeStateChanged(PlayModeStateChange state)
{
if (state == PlayModeStateChange.ExitingPlayMode)
{
ReleaseAllKeys();
SimulateKeyboardOverlayState.Clear();
}
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7dfe771ac58e34433a0f53e37467165b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
#nullable enable
namespace io.github.hatayama.uLoopMCP
{
public class SimulateKeyboardResponse : BaseToolResponse
{
public bool Success { get; set; }
public string Message { get; set; } = "";
public string Action { get; set; } = "";
public string? KeyName { get; set; }
public SimulateKeyboardResponse()
{
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f1a0b69b098ea4a18bcd8d3b8f1a663d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
using System.ComponentModel;
namespace io.github.hatayama.uLoopMCP
{
public class SimulateKeyboardSchema : BaseToolSchema
{
[Description("Keyboard action: Press(0) - one-shot key tap (Down then Up), KeyDown(1) - hold key down, KeyUp(2) - release held key")]
public KeyboardAction Action { get; set; } = KeyboardAction.Press;
[Description("Key name matching the Input System Key enum (e.g. \"W\", \"Space\", \"LeftShift\", \"A\", \"Enter\"). Case-insensitive.")]
public string Key { get; set; } = "";
[Description("Hold duration in seconds for Press action (default: 0 = one-shot tap). Ignored by KeyDown/KeyUp.")]
public float Duration { get; set; } = 0f;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 55a637b48f2234e4c91cc59d1b7dc629
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,317 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
#if ULOOPMCP_HAS_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
namespace io.github.hatayama.uLoopMCP
{
[McpTool(Description = "Simulate keyboard key input in PlayMode via Input System. Supports one-shot press, key-down hold, and key-up release for game controls (WASD, Space, etc.). Requires the Input System package (com.unity.inputsystem).")]
public class SimulateKeyboardTool : AbstractUnityTool<SimulateKeyboardSchema, SimulateKeyboardResponse>
{
public override string ToolName => "simulate-keyboard";
protected override
#if !ULOOPMCP_HAS_INPUT_SYSTEM
#pragma warning disable CS1998
#endif
async Task<SimulateKeyboardResponse> ExecuteAsync(
SimulateKeyboardSchema parameters,
CancellationToken ct)
#if !ULOOPMCP_HAS_INPUT_SYSTEM
#pragma warning restore CS1998
#endif
{
ct.ThrowIfCancellationRequested();
#if !ULOOPMCP_HAS_INPUT_SYSTEM
return new SimulateKeyboardResponse
{
Success = false,
Message = "simulate-keyboard requires the Input System package (com.unity.inputsystem). Install it via Package Manager and set Active Input Handling to 'Input System Package (New)' or 'Both' in Player Settings.",
Action = parameters.Action.ToString()
};
#else
string correlationId = McpConstants.GenerateCorrelationId();
if (!EditorApplication.isPlaying)
{
return new SimulateKeyboardResponse
{
Success = false,
Message = "PlayMode is not active. Use control-play-mode tool to start PlayMode first.",
Action = parameters.Action.ToString()
};
}
if (EditorApplication.isPaused)
{
return new SimulateKeyboardResponse
{
Success = false,
Message = "PlayMode is paused. Resume PlayMode before simulating keyboard input.",
Action = parameters.Action.ToString()
};
}
if (string.IsNullOrEmpty(parameters.Key))
{
return new SimulateKeyboardResponse
{
Success = false,
Message = "Key parameter is required. Examples: \"W\", \"Space\", \"LeftShift\", \"A\", \"Enter\".",
Action = parameters.Action.ToString()
};
}
string normalizedKey = NormalizeKeyName(parameters.Key);
if (!Enum.TryParse<Key>(normalizedKey, ignoreCase: true, out Key key) || key == Key.None)
{
return new SimulateKeyboardResponse
{
Success = false,
Message = $"Invalid key name: \"{parameters.Key}\". Use Input System Key enum names (e.g. \"W\", \"Space\", \"LeftShift\", \"A\", \"Enter\").",
Action = parameters.Action.ToString()
};
}
Keyboard keyboard = Keyboard.current;
if (keyboard == null)
{
return new SimulateKeyboardResponse
{
Success = false,
Message = "No keyboard device found in Input System. Ensure the Input System package is properly configured.",
Action = parameters.Action.ToString()
};
}
VibeLogger.LogInfo(
"simulate_keyboard_start",
"Keyboard simulation started",
new { Action = parameters.Action.ToString(), Key = parameters.Key },
correlationId: correlationId
);
EnsureOverlayExists();
SimulateKeyboardResponse response;
switch (parameters.Action)
{
case KeyboardAction.Press:
response = await ExecutePress(keyboard, key, parameters.Duration, ct);
break;
case KeyboardAction.KeyDown:
response = await ExecuteKeyDown(keyboard, key, ct);
break;
case KeyboardAction.KeyUp:
response = await ExecuteKeyUp(keyboard, key, ct);
break;
default:
throw new ArgumentException($"Unknown keyboard action: {parameters.Action}");
}
VibeLogger.LogInfo(
"simulate_keyboard_complete",
$"Keyboard simulation completed: {response.Message}",
new { Action = parameters.Action.ToString(), Success = response.Success },
correlationId: correlationId
);
return response;
#endif
}
#if ULOOPMCP_HAS_INPUT_SYSTEM
private static void EnsureOverlayExists()
{
OverlayCanvasFactory.EnsureExists();
}
private async Task<SimulateKeyboardResponse> ExecutePress(
Keyboard keyboard, Key key, float duration, CancellationToken ct)
{
if (duration < 0f || float.IsNaN(duration) || float.IsInfinity(duration))
{
return new SimulateKeyboardResponse
{
Success = false,
Message = $"Duration must be non-negative, got: {duration}",
Action = KeyboardAction.Press.ToString(),
KeyName = key.ToString()
};
}
string keyName = key.ToString();
if (KeyboardKeyState.IsKeyHeld(key))
{
return new SimulateKeyboardResponse
{
Success = false,
Message = $"Key '{keyName}' is already held down. Call KeyUp first.",
Action = KeyboardAction.Press.ToString(),
KeyName = keyName
};
}
SimulateKeyboardOverlayState.ShowPress(keyName);
KeyboardKeyState.RegisterTransientKey(key);
bool pressWasApplied = false;
try
{
await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, true), ct);
pressWasApplied = true;
await InputSystemUpdateHelper.WaitForPressLifetime(duration, ct);
}
finally
{
if (pressWasApplied)
{
await ReleaseKeyStateIfPossible(keyboard, key);
KeyboardKeyState.UnregisterTransientKey(key);
await FinalizePressOverlay(ct);
}
else
{
KeyboardKeyState.UnregisterTransientKey(key);
SimulateKeyboardOverlayState.ClearPress();
}
}
string durationText = duration > 0f ? $" for {duration:F1}s" : "";
return new SimulateKeyboardResponse
{
Success = true,
Message = $"Pressed '{keyName}'{durationText}",
Action = KeyboardAction.Press.ToString(),
KeyName = keyName
};
}
private async Task<SimulateKeyboardResponse> ExecuteKeyDown(Keyboard keyboard, Key key, CancellationToken ct)
{
string keyName = key.ToString();
if (KeyboardKeyState.IsKeyHeld(key))
{
return new SimulateKeyboardResponse
{
Success = false,
Message = $"Key '{keyName}' is already held down. Call KeyUp first.",
Action = KeyboardAction.KeyDown.ToString(),
KeyName = keyName
};
}
bool keyDownApplied = false;
bool committed = false;
try
{
await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, true), ct);
keyDownApplied = true;
KeyboardKeyState.SetKeyDown(key);
SimulateKeyboardOverlayState.AddHeldKey(keyName);
await InputSystemUpdateHelper.WaitForObservationFrames(ct);
committed = true;
}
finally
{
if (keyDownApplied && !committed)
{
await RollbackHeldKey(keyboard, key, keyName);
}
}
return new SimulateKeyboardResponse
{
Success = true,
Message = $"Key '{keyName}' held down",
Action = KeyboardAction.KeyDown.ToString(),
KeyName = keyName
};
}
private async Task<SimulateKeyboardResponse> ExecuteKeyUp(Keyboard keyboard, Key key, CancellationToken ct)
{
string keyName = key.ToString();
if (!KeyboardKeyState.IsKeyHeld(key))
{
return new SimulateKeyboardResponse
{
Success = false,
Message = $"Key '{keyName}' is not currently held. Call KeyDown first.",
Action = KeyboardAction.KeyUp.ToString(),
KeyName = keyName
};
}
await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), ct);
KeyboardKeyState.SetKeyUp(key);
SimulateKeyboardOverlayState.RemoveHeldKey(keyName);
await InputSystemUpdateHelper.WaitForObservationFrames(ct);
return new SimulateKeyboardResponse
{
Success = true,
Message = $"Key '{keyName}' released",
Action = KeyboardAction.KeyUp.ToString(),
KeyName = keyName
};
}
private static string NormalizeKeyName(string keyName)
{
if (string.Equals(keyName, "Return", StringComparison.OrdinalIgnoreCase))
{
return Key.Enter.ToString();
}
return keyName;
}
private static async Task FinalizePressOverlay(CancellationToken ct)
{
if (ct.IsCancellationRequested)
{
SimulateKeyboardOverlayState.ClearPress();
return;
}
SimulateKeyboardOverlayState.ReleasePress();
await EditorDelay.DelayFrame(1, CancellationToken.None);
}
private static async Task RollbackHeldKey(Keyboard keyboard, Key key, string keyName)
{
await ReleaseKeyStateIfPossible(keyboard, key);
KeyboardKeyState.SetKeyUp(key);
SimulateKeyboardOverlayState.RemoveHeldKey(keyName);
}
private static async Task ReleaseKeyStateIfPossible(Keyboard keyboard, Key key)
{
if (!CanInjectKeyboardState(keyboard))
{
return;
}
await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), CancellationToken.None);
}
private static bool CanInjectKeyboardState(Keyboard keyboard)
{
return EditorApplication.isPlaying && !EditorApplication.isPaused && Keyboard.current == keyboard;
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1b68e3441c77a41d4aa28bf746973676
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f2a2d235036f14d9d8b62acf37f6ed55
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,87 @@
---
name: uloop-simulate-keyboard
description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds, releases, and game controls such as WASD or Space."
context: fork
---
# Task
Simulate keyboard input on Unity PlayMode: $ARGUMENTS
## Workflow
1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not)
2. Execute the appropriate `uloop simulate-keyboard` command
3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering`
4. Report what happened
## Tool Reference
```bash
uloop simulate-keyboard --action <action> --key <key> [options]
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `--action` | enum | `Press` | `Press`, `KeyDown`, `KeyUp` |
| `--key` | string | (required) | Key name matching Input System Key enum (e.g. `W`, `Space`, `LeftShift`, `A`, `Enter`). Case-insensitive. |
| `--duration` | number | `0` | Hold duration in seconds for Press action (0 = one-shot tap). Ignored by KeyDown/KeyUp. |
### Actions
| Action | Behavior | Use Case |
|--------|----------|----------|
| `Press` | KeyDown → wait → KeyUp | One-shot tap (jump, use item) |
| `KeyDown` | KeyDown only (held until KeyUp) | Start continuous movement, hold sprint |
| `KeyUp` | KeyUp only (release held key) | Stop movement, release sprint |
### KeyDown/KeyUp Rules
- `KeyDown` fails if the key is already held
- `KeyUp` fails if the key is not currently held
- Multiple keys can be held simultaneously (e.g. W + LeftShift for sprint)
- All held keys are automatically released when PlayMode exits
- To hold a key for a fixed duration, prefer `--action Press --duration <seconds>` (one-shot, blocks until release). For multi-key holds (e.g. Shift+W), issue separate `KeyDown` calls, then `sleep <seconds>` between them and the `KeyUp` calls.
### Global Options
| Option | Description |
|--------|-------------|
| `--project-path <path>` | Optional. Use only when the target Unity project is not the current directory. |
## Examples
```bash
# One-shot key press (tap W once)
uloop simulate-keyboard --action Press --key W
# Jump (tap Space)
uloop simulate-keyboard --action Press --key Space
# Hold W for 2 seconds (move forward)
uloop simulate-keyboard --action Press --key W --duration 2.0
# Sprint forward (hold Shift + W, then release)
uloop simulate-keyboard --action KeyDown --key LeftShift
uloop simulate-keyboard --action KeyDown --key W
uloop screenshot --capture-mode rendering
uloop simulate-keyboard --action KeyUp --key W
uloop simulate-keyboard --action KeyUp --key LeftShift
```
## Output
Returns JSON with:
- `Success` (boolean): Whether the action succeeded (e.g. `KeyDown` on a not-yet-held key, `KeyUp` on a currently-held key, or `Press` round-trip)
- `Message` (string): Description of what happened or why it failed
- `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`)
- `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key
## Prerequisites
- Unity must be in **PlayMode**
- **Input System package** must be installed (`com.unity.inputsystem`)
- Use this only when the project already uses the New Input System.
- Game code must read input via Input System API (e.g. `Keyboard.current[Key.W].isPressed`), not legacy `Input.GetKey()`

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 42d87015d20c74a5891d205d1a19dfb2
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: