【個人開発】FPSのキャラごとにキーリマップできるWPFアプリを作ったらバカ売れ確定した話
これはno plan inc.の Advent Calendar 2024の22日目の記事です。
こんにちは。no plan inc. CTOのせりかわです。
最近ハマっているFPSゲーム「delta force」(無料のBFより完成度が高いFPS?)が面白いです。あびつんさんの動画なんかを見てたら視聴者が、「キャラごとのスキルのキー割り当てを自由に変えたい」とおっしゃってました。
たしかに。
delta forceではキャラごとに固有のスキルがあるのですが、いろんなキャラを使ってたらややこしいこと、、、
しかもキャラごとにキーをガラッと変える機能が見当たらない!
これは作るしかないと勝手に使命感が燃え上がりました。
開発動機と構想
最初は「まぁ、ゲーム側で公式にサポートしてないなら難しいんじゃない?」と思いつつ、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バージョン)に助けられてサクサク進みました。
使った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なら離しイベントを送る。
コードの工夫点
ユーザーが8キャラ×3ガジェットのトリガーキーを設定できるように、config.json に保存・読み込みする仕様
Characters[8] の配列を持たせて、Gadget1, Gadget2, Gadget3をそれぞれ文字列で保存。
JSONで気軽に読み書きできるようにしておけば、「タブUIで入力 → 保存ボタン → 次回起動時も同じ設定」って流れが実装しやすい。
「小文字変換」問題
kbStruct.vkCode はASCIIで 'A'〜'Z' のVKコードと一致しますが、Shiftとか CapsLockとか絡むと面倒。
ここでは単純に 0x41(VK_A)〜0x5A(VK_Z) を 'a'〜'z' にマッピングしてました。
大文字小文字以外のキー(数字や記号)は今回考慮しなかったけど、そこまでやろうとすると ToUnicode などが必要になって大変。
ボタン一発で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 ? "リマップ無効化する" : "リマップ有効化する";
}
}
}
}
テスト段階の成果
「よっしゃ、これでデスクトップ上の他アプリでキーコードを出力するテストアプリを動かしてみよう」と思ったら、ちゃんとキーが変わってる!
たとえば、キャラAのガジェット1=iに設定 → iを押す → xがOSに送られてる
ちゃんとKeyDown, KeyUpそれぞれに反応してくれるので、長押しで連打される感じも思い通り。
もうこれは「完璧やん!」と小躍りしました。ほんとにChatGPTすげえ。なんせWPFもWindows APIもど素人の私が、1時間ほどで最低限のプロトタイプが作れたんですから。
そして訪れた悲劇
しかし、満を持してdelta forceを起動すると全然効かない。
どうやらアンチチートやDirectInput、Raw Inputなどの壁が存在するようです。
SendInputによるキーボード注入は「チート疑惑あり!」とみなされるか、あるいはゲームが低レベルAPI経由で直接キー入力を取得していてWindowsのメッセージを介した擬似イベントを無視している可能性が高い。
私も色々調べましたが、オンラインFPS系だと「チート対策でSendInputをブロック」なんて話もザラに出てきます。
「うーん、でもテストアプリでは完璧に動いてるんだよなぁ……ゲーム内だけ通らない……」
ここでようやく「公式にサポートされないリマップを勝手にやる難易度の高さ」を痛感しました。
結論
リマップロジック自体は簡単 … SetWindowsHookEx + SendInputで基本実装。
8キャラ×3ガジェット + F1〜F8でアクティブキャラ切り替えも、UIとJSON保存で意外と楽しく作れる。
FPSゲーム内部で確実に動かすのは別問題 … アンチチートやRaw Inputのせいで効かないケース多数。
チート判定されるリスクもあるので、そこは自己責任。
「技術的にはできるけど、ゲーム側が受け付けないなら仕方ないよね」という結末でした。
個人的にはこの経験を通じて、ゲームやOSレベルでのキー入力の扱いの奥深さを学べたし、「やっぱりアンチチートの仕組みはすごいな」と妙に納得したり。
余談:やってよかったこと
ChatGPTに教わりながら短時間で開発
Windows APIなんて敷居高そうだったけど、「C#でWPF + SetWindowsHookExをどう書く?」みたいに聞けば、ほぼ自動でコード例が来る。
「スキャンコードと仮想キーコードの対応は?」と聞けば細かく説明してくれる。下調べが激減して助かりました。
UIをタブでキャラごとに分けた
「このキャラだけキー設定をいじりたい」「あのキャラだけ違うトリガーキーを割り当てたい」というニーズを想定し、タブUIにして大正解。
JSON保存を組み合わせれば、「リマップの設定のバックアップ」も超かんたん。
最後に
作ってみると意外と「動いて感動!」ってなるけど、ゲーム内では動かないオチが大半。
「チート疑惑でBANされたら怖い」という方は、くれぐれも注意。
どうしてもゲーム内で使いたいなら、公式やゲーム内設定でキーコンフィグをいじれるか確認するのがベターですね。
とはいえ、エンジニア的にはこういう「技術実験 × ゲーム」って面白いですよね。今回のアドベントカレンダー記事を通じて、「おお、キャラごとにキーリマップなんて発想があるのか」と誰かが刺激を受けてくれたら嬉しいなと思います。
以上、キャラ別リマップツールを作ったけど、大事なFPSゲームの内部では不発でバカ売れできなかったという開発秘話でした!
いつかアンチチートの壁を越える技術ができたら、また挑戦してみようと思います。気合いだ~!
no plan株式会社について
no plan株式会社は、ブロックチェーン技術、Webサイト開発、ネイティブアプリ開発、チーム育成、などWebサービス全般の開発から運用や教育、支援などを行っています。よくわからない、ふわふわしたノープラン状態でも大丈夫!ご一緒にプランを立てていきましょう!