見出し画像

【個人開発】FPSのキャラごとにキーリマップできるWPFアプリを作ったらバカ売れ確定した話

これはno plan inc.の Advent Calendar 2024の22日目の記事です。


こんにちは。no plan inc. CTOのせりかわです。
最近ハマっているFPSゲーム「delta force」(無料のBFより完成度が高いFPS?)が面白いです。あびつんさんの動画なんかを見てたら視聴者が、「キャラごとのスキルのキー割り当てを自由に変えたい」とおっしゃってました。

たしかに。

delta forceではキャラごとに固有のスキルがあるのですが、いろんなキャラを使ってたらややこしいこと、、、


パッシブスキルも含めるとキャラごとに4個ある


しかもキャラごとにキーをガラッと変える機能が見当たらない!
これは作るしかないと勝手に使命感が燃え上がりました。

開発動機と構想

最初は「まぁ、ゲーム側で公式にサポートしてないなら難しいんじゃない?」と思いつつ、Windows側でキー入力をフックして、特定のキーを別のキーへ置き換えることはできるはず。キャラごとに違う設定? それならCtrl + F1〜F8あたりでアクティブキャラを切り替えるみたいなUIを作って、システム全体でリマップすればいけるんじゃないか、と。

  • キャラごとのタブUI … 8人のキャラ

  • ガジェット1〜3のキー … 例えば「キャラAのガジェット1を i と設定したら、実際には x を送る」みたいな仕組み

  • Ctrl + F1〜F8 でアクティブキャラを変更 … フックコールバック内で、押されたキーがF1〜F8なら activeCharacterIndex = 0〜7 に切り替える

  • SendInput で別のキーを擬似的に押す … いわゆる SetWindowsHookEx(WH_KEYBOARD_LL, …) + SendInput の王道パターン

これを聞くと「おお、なんか楽しそう!」って思いませんか? 実際、私のC# & WPF知識は皆無だったんですが、ChatGPT(※o1 proバージョン)に助けられてサクサク進みました。


こんな感じのUIにしました

使ったAPIと簡易構成

1. SetWindowsHookEx(WH_KEYBOARD_LL, …) でキーフック設定

  • 低レベルキーボードフック(グローバルフック)をかけると、全アプリのキー入力を自分のところで受け取れます。

  • コールバックでは KBDLLHOOKSTRUCT という構造体を受け取って、押されたキーの vkCode(仮想キーコード)が分かる。

2. アクティブキャラ切り替え

  • フック内で、もし押されたキーが F1(0x70)〜F8(0x77) なら activeCharacterIndex = kbStruct.vkCode - 0x70; という感じで切り替える。

  • 切り替えたら「今後押されたキーは、キャラXの設定表を参照してリマップする」ようになる。

3. トリガーキー → リマップ先キー

  • 「ユーザーが設定したトリガーキー(例:i)を押す → 実際には x をOSに送る」みたいにするために、小文字同士で比較しました。

    • 具体的には char pressed = VkCodeToLowerChar(kbStruct.vkCode); みたいにして 'a'〜'z' へ変換し、

    • ユーザー設定を .ToLower() して比較。合致したら SendInput で 'x'(VK_X)をキー押下/離しとして送る。

  • 「キャラAのガジェット1〜3」をタブUIで設定して、それぞれ txtA_gadget1.Text とかにユーザーが「i」「o」「p」と入力すると、「ガジェット1に合致 → x、ガジェット2に合致 → v、ガジェット3に合致 → g」みたいに固定マッピングを実装。

4. SendInput でキーを擬似的に押す

  • SendInput は配列に INPUT 構造体を詰めて呼び出すと、Win32が「キーを押した」(または離した)という仮想入力をシステムに注入してくれます。

  • KEYBDINPUT の wVk に 'x' なら 0x58 を入れ、押下なら dwFlags = 0;、離しなら dwFlags = KEYEVENTF_KEYUP; にする。

  • これでリアルキーを押していなくても「押されている」状態になるわけですね。

  • ついでに長押しにも対応させるために、フックでWM_KEYDOWN / WM_KEYUPのそれぞれで同じ処理を呼ぶように。KeyDownなら押下イベントを、KeyUpなら離しイベントを送る。

コードの工夫点

  1. ユーザーが8キャラ×3ガジェットのトリガーキーを設定できるように、config.json に保存・読み込みする仕様

    • Characters[8] の配列を持たせて、Gadget1, Gadget2, Gadget3をそれぞれ文字列で保存。

    • JSONで気軽に読み書きできるようにしておけば、「タブUIで入力 → 保存ボタン → 次回起動時も同じ設定」って流れが実装しやすい。

  2. 「小文字変換」問題

    • kbStruct.vkCode はASCIIで 'A'〜'Z' のVKコードと一致しますが、Shiftとか CapsLockとか絡むと面倒。

    • ここでは単純に 0x41(VK_A)〜0x5A(VK_Z) を 'a'〜'z' にマッピングしてました。

    • 大文字小文字以外のキー(数字や記号)は今回考慮しなかったけど、そこまでやろうとすると ToUnicode などが必要になって大変。

  3. ボタン一発でON/OFFトグル

    • 「フックを無効にして通常のキー入力に戻したい」というために、remapEnabledフラグを用意。

    • フック内の先頭で if (!remapEnabled) return CallNextHookEx(...); とやれば、簡単にリマップをスキップできます。


コード全文

using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace delta_force_remap
{
    public partial class MainWindow : Window
    {
        // ---- Win32定数 ----
        private const int WH_KEYBOARD_LL = 13;
        private const int WM_KEYDOWN = 0x0100;
        private const int WM_KEYUP = 0x0101;
        private const int WM_SYSKEYDOWN = 0x0104;
        private const int WM_SYSKEYUP = 0x0105;
        private const int HC_ACTION = 0;

        // ---- アクティブキャラ: 0~7 (F1~F8で切り替え) ----
        private static int activeCharacterIndex = 0;

        // ---- リマップ先 (ガジェット1/2/3) ----
        // 例: Gadget1 => 'x', Gadget2 => 'v', Gadget3 => 'g' で固定
        private const string REMAP_GADGET1 = "x";
        private const string REMAP_GADGET2 = "v";
        private const string REMAP_GADGET3 = "g";

        private const string CONFIG_FILE = "config.json";

        // ---- グローバルフック関連 ----
        private static IntPtr _hookID = IntPtr.Zero;
        private static LowLevelKeyboardProc _proc = HookCallback;

        // remap on/off flag
        private static bool remapEnabled = true;

        // ---- 設定データ構造 ----
        // 8キャラ分、それぞれに Gadget1, Gadget2, Gadget3 の「トリガーキー(小文字1文字)」を保持
        public class ConfigData
        {
            public CharacterConfig[] Characters { get; set; } = new CharacterConfig[8];
        }

        public class CharacterConfig
        {
            public string Gadget1 { get; set; } = "x"; // デフォルト
            public string Gadget2 { get; set; } = "v";
            public string Gadget3 { get; set; } = "g";
        }

        private ConfigData configData = new ConfigData();

        // ---- コンストラクタ ----
        public MainWindow()
        {
            InitializeComponent();

            // 1. config.json読み込み
            LoadConfig();
            // 2. 読み込んだ設定をUIに反映
            UpdateUIFromConfig();
            // 3. フック開始
            _hookID = SetHook(_proc);
        }

        // ---- 保存ボタン ----
        private void BtnSave_Click(object sender, RoutedEventArgs e)
        {
            // UI→config
            UpdateConfigFromUI();
            // ファイル保存
            SaveConfig();
            MessageBox.Show("設定を保存しました。");
        }

        #region JSON読み書き
        private void LoadConfig()
        {
            if (!File.Exists(CONFIG_FILE)) return;

            try
            {
                string json = File.ReadAllText(CONFIG_FILE);
                var loaded = JsonSerializer.Deserialize<ConfigData>(json);

                if (loaded != null && loaded.Characters != null && loaded.Characters.Length == 8)
                {
                    configData = loaded;
                }
            }
            catch
            {
                // 失敗時は何もしない(デフォルトのまま)
            }
        }

        private void SaveConfig()
        {
            try
            {
                string json = JsonSerializer.Serialize(configData, new JsonSerializerOptions
                {
                    WriteIndented = true
                });
                File.WriteAllText(CONFIG_FILE, json);
            }
            catch (Exception ex)
            {
                MessageBox.Show("設定保存に失敗: " + ex.Message);
            }
        }
        #endregion

        #region UIとconfigDataの相互反映
        private void UpdateUIFromConfig()
        {
            // 安全チェック(要素数8固定)
            if (configData.Characters.Length < 8) return;

            // キャラA(index0)
            txtA_gadget1.Text = configData.Characters[0].Gadget1;
            txtA_gadget2.Text = configData.Characters[0].Gadget2;
            txtA_gadget3.Text = configData.Characters[0].Gadget3;

            // キャラB(index1)
            txtB_gadget1.Text = configData.Characters[1].Gadget1;
            txtB_gadget2.Text = configData.Characters[1].Gadget2;
            txtB_gadget3.Text = configData.Characters[1].Gadget3;

            // キャラC(index2)
            txtC_gadget1.Text = configData.Characters[2].Gadget1;
            txtC_gadget2.Text = configData.Characters[2].Gadget2;
            txtC_gadget3.Text = configData.Characters[2].Gadget3;

            // キャラD(index3)
            txtD_gadget1.Text = configData.Characters[3].Gadget1;
            txtD_gadget2.Text = configData.Characters[3].Gadget2;
            txtD_gadget3.Text = configData.Characters[3].Gadget3;

            // キャラE(index4)
            txtE_gadget1.Text = configData.Characters[4].Gadget1;
            txtE_gadget2.Text = configData.Characters[4].Gadget2;
            txtE_gadget3.Text = configData.Characters[4].Gadget3;

            // キャラF(index5)
            txtF_gadget1.Text = configData.Characters[5].Gadget1;
            txtF_gadget2.Text = configData.Characters[5].Gadget2;
            txtF_gadget3.Text = configData.Characters[5].Gadget3;

            // キャラG(index6)
            txtG_gadget1.Text = configData.Characters[6].Gadget1;
            txtG_gadget2.Text = configData.Characters[6].Gadget2;
            txtG_gadget3.Text = configData.Characters[6].Gadget3;

            // キャラH(index7)
            txtH_gadget1.Text = configData.Characters[7].Gadget1;
            txtH_gadget2.Text = configData.Characters[7].Gadget2;
            txtH_gadget3.Text = configData.Characters[7].Gadget3;
        }

        private void UpdateConfigFromUI()
        {
            // キャラA(index0)
            configData.Characters[0].Gadget1 = txtA_gadget1.Text;
            configData.Characters[0].Gadget2 = txtA_gadget2.Text;
            configData.Characters[0].Gadget3 = txtA_gadget3.Text;

            // キャラB(index1)
            configData.Characters[1].Gadget1 = txtB_gadget1.Text;
            configData.Characters[1].Gadget2 = txtB_gadget2.Text;
            configData.Characters[1].Gadget3 = txtB_gadget3.Text;

            // キャラC(index2)
            configData.Characters[2].Gadget1 = txtC_gadget1.Text;
            configData.Characters[2].Gadget2 = txtC_gadget2.Text;
            configData.Characters[2].Gadget3 = txtC_gadget3.Text;

            // キャラD(index3)
            configData.Characters[3].Gadget1 = txtD_gadget1.Text;
            configData.Characters[3].Gadget2 = txtD_gadget2.Text;
            configData.Characters[3].Gadget3 = txtD_gadget3.Text;

            // キャラE(index4)
            configData.Characters[4].Gadget1 = txtE_gadget1.Text;
            configData.Characters[4].Gadget2 = txtE_gadget2.Text;
            configData.Characters[4].Gadget3 = txtE_gadget3.Text;

            // キャラF(index5)
            configData.Characters[5].Gadget1 = txtF_gadget1.Text;
            configData.Characters[5].Gadget2 = txtF_gadget2.Text;
            configData.Characters[5].Gadget3 = txtF_gadget3.Text;

            // キャラG(index6)
            configData.Characters[6].Gadget1 = txtG_gadget1.Text;
            configData.Characters[6].Gadget2 = txtG_gadget2.Text;
            configData.Characters[6].Gadget3 = txtG_gadget3.Text;

            // キャラH(index7)
            configData.Characters[7].Gadget1 = txtH_gadget1.Text;
            configData.Characters[7].Gadget2 = txtH_gadget2.Text;
            configData.Characters[7].Gadget3 = txtH_gadget3.Text;
        }
        #endregion

        #region フック&リマップ
        // フックコールバックのデリゲート
        private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn,
            IntPtr hMod, uint dwThreadId);

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern bool UnhookWindowsHookEx(IntPtr hhk);

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);

        [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern IntPtr GetModuleHandle(string lpModuleName);

        // キーボードフックで受け取る構造体
        [StructLayout(LayoutKind.Sequential)]
        private struct KBDLLHOOKSTRUCT
        {
            public uint vkCode;
            public uint scanCode;
            public uint flags;
            public uint time;
            public IntPtr dwExtraInfo;
        }

        private static IntPtr SetHook(LowLevelKeyboardProc proc)
        {
            using (Process curProcess = Process.GetCurrentProcess())
            using (ProcessModule curModule = curProcess.MainModule)
            {
                IntPtr moduleHandle = GetModuleHandle(curModule.ModuleName);
                return SetWindowsHookEx(WH_KEYBOARD_LL, proc, moduleHandle, 0);
            }
        }

        private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
        {
            if (nCode == HC_ACTION)
            {
                // **リマップ無効なら即Return**
                if (!remapEnabled)
                {
                    return CallNextHookEx(_hookID, nCode, wParam, lParam);
                }

                int wm = (int)wParam;
                // KeyDown/KeyUp
                if (wm == WM_KEYDOWN || wm == WM_SYSKEYDOWN ||
                    wm == WM_KEYUP || wm == WM_SYSKEYUP)
                {
                    var kbStruct = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
                    bool isDown = (wm == WM_KEYDOWN || wm == WM_SYSKEYDOWN);

                    // F1~F8 で activeCharacterIndex を変更
                    if (isDown && (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) && kbStruct.vkCode >= 0x70 && kbStruct.vkCode <= 0x77)
                    {
                        // F1=0x70, F2=0x71, ... F8=0x77
                        activeCharacterIndex = (int)(kbStruct.vkCode - 0x70); // 0~7
                                                                              // 通常はイベントキャンセルせず流す
                                                                              // return (IntPtr)1;  // キャンセルしたければコメント解除

                        // label更新
                        var main = (MainWindow)Application.Current.MainWindow;
                        // 現在の設定(F1 - F8)
                        main.lblActiveChar.Content =
                            "現在の設定(F" + (activeCharacterIndex + 1) + ")";
                            }
                    else
                    {
                        // 現在のキャラ設定を確認
                        var main = (MainWindow)Application.Current.MainWindow;
                        var charConfigs = main.configData.Characters[activeCharacterIndex];

                        // 3つのガジェットが持つトリガーキーを小文字で取得(例 "i","o","p"など)
                        string trig1 = (charConfigs.Gadget1 ?? "").ToLower();
                        string trig2 = (charConfigs.Gadget2 ?? "").ToLower();
                        string trig3 = (charConfigs.Gadget3 ?? "").ToLower();

                        // 押されたキーを小文字1文字に変換
                        char pressed = VkCodeToLowerChar(kbStruct.vkCode);
                        string pressedStr = pressed.ToString();

                        if (pressedStr == trig1 && !string.IsNullOrEmpty(trig1))
                        {
                            // ガジェット1 → 'x' にリマップ
                            SendRemapKey(REMAP_GADGET1, isDown);
                            return (IntPtr)1;
                        }
                        else if (pressedStr == trig2 && !string.IsNullOrEmpty(trig2))
                        {
                            // ガジェット2 → 'v'
                            SendRemapKey(REMAP_GADGET2, isDown);
                            return (IntPtr)1;
                        }
                        else if (pressedStr == trig3 && !string.IsNullOrEmpty(trig3))
                        {
                            // ガジェット3 → 'g'
                            SendRemapKey(REMAP_GADGET3, isDown);
                            return (IntPtr)1;
                        }
                    }
                }
            }
            return CallNextHookEx(_hookID, nCode, wParam, lParam);
        }
        #endregion

        #region SendInputと仮想キー変換 (小文字対応)
        [DllImport("user32.dll", SetLastError = true)]
        private static extern uint SendInput(uint nInputs, [In] INPUT[] pInputs, int cbSize);

        [StructLayout(LayoutKind.Sequential)]
        struct INPUT
        {
            public uint type;
            public InputUnion U;
        }

        [StructLayout(LayoutKind.Explicit)]
        struct InputUnion
        {
            [FieldOffset(0)] public MOUSEINPUT mi;
            [FieldOffset(0)] public KEYBDINPUT ki;
            [FieldOffset(0)] public HARDWAREINPUT hi;
        }

        [StructLayout(LayoutKind.Sequential)]
        struct KEYBDINPUT
        {
            public ushort wVk;
            public ushort wScan;
            public uint dwFlags;
            public uint time;
            public IntPtr dwExtraInfo;
        }

        [StructLayout(LayoutKind.Sequential)]
        struct MOUSEINPUT
        {
            public int dx;
            public int dy;
            public uint mouseData;
            public uint dwFlags;
            public uint time;
            public IntPtr dwExtraInfo;
        }

        [StructLayout(LayoutKind.Sequential)]
        struct HARDWAREINPUT
        {
            public uint uMsg;
            public ushort wParamL;
            public ushort wParamH;
        }

        private const int INPUT_KEYBOARD = 1;
        private const int KEYEVENTF_KEYUP = 0x0002;

        /// <summary>
        /// 'a'~'z' を VK_A(0x41)~VK_Z(0x5A) に変換してキー押下/離しを送信
        /// </summary>
        private static void SendRemapKey(string lowerChar, bool isDown)
        {
            ushort vk = LowerCharToVkCode(lowerChar);
            if (vk == 0) return;

            INPUT inp = new INPUT();
            inp.type = INPUT_KEYBOARD;
            inp.U.ki.wVk = vk;
            inp.U.ki.wScan = 0;
            inp.U.ki.dwFlags = isDown ? 0U : KEYEVENTF_KEYUP;
            inp.U.ki.time = 0;
            inp.U.ki.dwExtraInfo = IntPtr.Zero;

            var arr = new INPUT[] { inp };
            SendInput((uint)arr.Length, arr, Marshal.SizeOf(typeof(INPUT)));
        }

        /// <summary>
        /// 小文字1文字 -> VKコード
        /// </summary>
        private static ushort LowerCharToVkCode(string lowerChar)
        {
            if (string.IsNullOrEmpty(lowerChar) || lowerChar.Length != 1) return 0;
            char c = lowerChar[0];
            if (c >= 'a' && c <= 'z')
            {
                return (ushort)(0x41 + (c - 'a')); // 'a'→0x41(VK_A), 'b'→0x42, ...
            }
            return 0;
        }

        /// <summary>
        /// 仮想キーコード -> 小文字 'a'~'z'
        /// (VK_A=0x41)~(VK_Z=0x5A) のみ対応
        /// </summary>
        private static char VkCodeToLowerChar(uint vkCode)
        {
            if (vkCode >= 0x41 && vkCode <= 0x5A)
            {
                return (char)('a' + (vkCode - 0x41));
            }
            return '\0'; // 対応外
        }
        #endregion

        private void Button_Click(object sender, RoutedEventArgs e)
        {

        }

        private void BtnEnable_Click(object sender, RoutedEventArgs e)
        {
            remapEnabled = !remapEnabled;
            MessageBox.Show("リマップ " + (remapEnabled ? "有効" : "無効"));
            // button text update
            if (sender is Button button)
            {
                button.Content = remapEnabled ? "リマップ無効化する" : "リマップ有効化する";
            }
        }
    }
}

テスト段階の成果

「よっしゃ、これでデスクトップ上の他アプリでキーコードを出力するテストアプリを動かしてみよう」と思ったら、ちゃんとキーが変わってる!


「i」を押してるのにリマップによって「x」が押されている図
  • たとえば、キャラAのガジェット1=iに設定 → iを押す → xがOSに送られてる

  • ちゃんとKeyDown, KeyUpそれぞれに反応してくれるので、長押しで連打される感じも思い通り。

もうこれは「完璧やん!」と小躍りしました。ほんとにChatGPTすげえ。なんせWPFもWindows APIもど素人の私が、1時間ほどで最低限のプロトタイプが作れたんですから。

そして訪れた悲劇

しかし、満を持してdelta forceを起動すると全然効かない
どうやらアンチチートやDirectInput、Raw Inputなどの壁が存在するようです。
SendInputによるキーボード注入は「チート疑惑あり!」とみなされるか、あるいはゲームが低レベルAPI経由で直接キー入力を取得していてWindowsのメッセージを介した擬似イベントを無視している可能性が高い。
私も色々調べましたが、オンラインFPS系だと「チート対策でSendInputをブロック」なんて話もザラに出てきます。

「うーん、でもテストアプリでは完璧に動いてるんだよなぁ……ゲーム内だけ通らない……」
ここでようやく「公式にサポートされないリマップを勝手にやる難易度の高さ」を痛感しました。


結論

  1. リマップロジック自体は簡単 … SetWindowsHookEx + SendInputで基本実装。

  2. 8キャラ×3ガジェット + F1〜F8でアクティブキャラ切り替えも、UIとJSON保存で意外と楽しく作れる。

  3. FPSゲーム内部で確実に動かすのは別問題 … アンチチートやRaw Inputのせいで効かないケース多数。

  4. チート判定されるリスクもあるので、そこは自己責任。

「技術的にはできるけど、ゲーム側が受け付けないなら仕方ないよね」という結末でした。
個人的にはこの経験を通じて、ゲームやOSレベルでのキー入力の扱いの奥深さを学べたし、「やっぱりアンチチートの仕組みはすごいな」と妙に納得したり。

余談:やってよかったこと

  • ChatGPTに教わりながら短時間で開発

    • Windows APIなんて敷居高そうだったけど、「C#でWPF + SetWindowsHookExをどう書く?」みたいに聞けば、ほぼ自動でコード例が来る。

    • 「スキャンコードと仮想キーコードの対応は?」と聞けば細かく説明してくれる。下調べが激減して助かりました。

  • UIをタブでキャラごとに分けた

    • 「このキャラだけキー設定をいじりたい」「あのキャラだけ違うトリガーキーを割り当てたい」というニーズを想定し、タブUIにして大正解。

    • JSON保存を組み合わせれば、「リマップの設定のバックアップ」も超かんたん。

最後に

  • 作ってみると意外と「動いて感動!」ってなるけど、ゲーム内では動かないオチが大半。

  • 「チート疑惑でBANされたら怖い」という方は、くれぐれも注意。

  • どうしてもゲーム内で使いたいなら、公式やゲーム内設定でキーコンフィグをいじれるか確認するのがベターですね。

とはいえ、エンジニア的にはこういう「技術実験 × ゲーム」って面白いですよね。今回のアドベントカレンダー記事を通じて、「おお、キャラごとにキーリマップなんて発想があるのか」と誰かが刺激を受けてくれたら嬉しいなと思います。

以上、キャラ別リマップツールを作ったけど、大事なFPSゲームの内部では不発でバカ売れできなかったという開発秘話でした!
いつかアンチチートの壁を越える技術ができたら、また挑戦してみようと思います。気合いだ~!

no plan株式会社について


いいなと思ったら応援しよう!

serinuntius
シェアしていただけるだけでも励みになります!