diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 55b1271..bea0897 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -9,7 +9,12 @@ on: jobs: build: - runs-on: windows-latest + strategy: + fail-fast: false + matrix: + os: [ windows-latest, macos-latest, ubuntu-latest ] + + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/ConsoleEngine.sln b/ConsoleEngine.sln index 76e8fb4..a0b46c8 100644 --- a/ConsoleEngine.sln +++ b/ConsoleEngine.sln @@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerraForM.Tests", "Examples EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples.Tests", "Examples.Tests", "{106CCD57-88BD-4B0A-A67F-A5DA7D8FCA3C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleEngine.CrossPlatform", "Libs\ConsoleEngine.CrossPlatform\ConsoleEngine.CrossPlatform.csproj", "{F5F065A9-F0B1-45B6-B4BC-5912BAB09803}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Release|Any CPU = Release|Any CPU @@ -82,6 +84,10 @@ Global {58BE9E1E-1B58-423F-B2BF-9DB66E0CFACA}.Release|Any CPU.Build.0 = Release|Any CPU {58BE9E1E-1B58-423F-B2BF-9DB66E0CFACA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58BE9E1E-1B58-423F-B2BF-9DB66E0CFACA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5F065A9-F0B1-45B6-B4BC-5912BAB09803}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5F065A9-F0B1-45B6-B4BC-5912BAB09803}.Release|Any CPU.Build.0 = Release|Any CPU + {F5F065A9-F0B1-45B6-B4BC-5912BAB09803}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5F065A9-F0B1-45B6-B4BC-5912BAB09803}.Debug|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {99309207-1847-4EB9-A009-C7B92BC2CE64} = {CA22425D-FB6C-45C6-A14C-561DF6D969B6} @@ -96,5 +102,6 @@ Global {A4209E56-1ECD-4616-BB13-DF77864F462C} = {12D2B48F-E7F6-4BEC-8315-9EC8F41A059A} {0CDE38D4-12F4-426B-9252-E57820FF8D9B} = {028D7499-6A31-4A9F-868A-908AD53A8BD0} {58BE9E1E-1B58-423F-B2BF-9DB66E0CFACA} = {106CCD57-88BD-4B0A-A67F-A5DA7D8FCA3C} + {F5F065A9-F0B1-45B6-B4BC-5912BAB09803} = {CA22425D-FB6C-45C6-A14C-561DF6D969B6} EndGlobalSection EndGlobal diff --git a/ConsoleEngine/ConsoleEngine.csproj b/ConsoleEngine/ConsoleEngine.csproj index 4d61738..7ac882e 100644 --- a/ConsoleEngine/ConsoleEngine.csproj +++ b/ConsoleEngine/ConsoleEngine.csproj @@ -15,6 +15,7 @@ + diff --git a/ConsoleEngine/GameBase.cs b/ConsoleEngine/GameBase.cs index 49de7e7..e603304 100644 --- a/ConsoleEngine/GameBase.cs +++ b/ConsoleEngine/GameBase.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System; +using System.Threading; using ConsoleEngine.Abstractions.Rendering; using ConsoleEngine.Infrastructure; using ConsoleEngine.Infrastructure.Inputs; @@ -6,6 +7,7 @@ using ConsoleEngine.Infrastructure.Rendering; using ConsoleEngine.Infrastructure.Scenery; using ConsoleEngine.Native; +using ConsoleEngine.CrossPlatform; namespace ConsoleEngine; @@ -32,7 +34,9 @@ protected GameBase(int width, int height, int fontWidth, int fontHeight) protected GameBase(int width, int height, FontInfo fontInfo) { - _console = new RenderConsole(new ConsoleHandler(width, height, fontInfo)); + _console = new RenderConsole(OperatingSystem.IsWindows() + ? new ConsoleHandler(width, height, fontInfo) + : new CrossPlatformConsoleHandler(width, height, fontInfo)); Name = "Game"; Scenes = new SceneManager(this); } diff --git a/ConsoleEngine/Infrastructure/Inputs/Input.cs b/ConsoleEngine/Infrastructure/Inputs/Input.cs index b5a15bc..4f4ea16 100644 --- a/ConsoleEngine/Infrastructure/Inputs/Input.cs +++ b/ConsoleEngine/Infrastructure/Inputs/Input.cs @@ -3,6 +3,7 @@ using System.Linq; using ConsoleEngine.Abstractions.Inputs; using ConsoleEngine.Native; +using ConsoleEngine.CrossPlatform; namespace ConsoleEngine.Infrastructure.Inputs; @@ -13,8 +14,10 @@ public class Input //********************************************************** public static readonly Input Instance = new(); - private static readonly IInputHandler Handler = new InputHandler(); - + private static readonly IInputHandler Handler = OperatingSystem.IsWindows() + ? new InputHandler() + : new CrossPlatformInputHandler(); + //********************************************************** //** ctor: //********************************************************** diff --git a/ConsoleEngine/Infrastructure/Logging/Log.cs b/ConsoleEngine/Infrastructure/Logging/Log.cs index 4c941ae..4164f14 100644 --- a/ConsoleEngine/Infrastructure/Logging/Log.cs +++ b/ConsoleEngine/Infrastructure/Logging/Log.cs @@ -1,5 +1,7 @@ -using System.Collections.Concurrent; +using System; +using System.Collections.Concurrent; using System.Diagnostics; +using System.IO; using System.Net; using System.Net.Sockets; using System.Text; @@ -77,16 +79,42 @@ internal static void Start(GameBase game) Socket.Listen(10); LoggerThread.Start(); + StartLoggerProcess(); + } + + private static void StartLoggerProcess() + { + var logger = Path.Combine(AppContext.BaseDirectory, OperatingSystem.IsWindows() ? "ConsoleEngine.Logger.exe" : "ConsoleEngine.Logger"); + _loggerProcess = new Process(); - _loggerProcess.StartInfo.FileName = "ConsoleEngine.Logger.exe"; - _loggerProcess.StartInfo.UseShellExecute = true; - _loggerProcess.Start(); + if (OperatingSystem.IsMacOS()) + { + // The game owns the current terminal, open the logger in a new Terminal window. + _loggerProcess.StartInfo.FileName = "open"; + _loggerProcess.StartInfo.ArgumentList.Add("-a"); + _loggerProcess.StartInfo.ArgumentList.Add("Terminal"); + _loggerProcess.StartInfo.ArgumentList.Add(logger); + } + else + { + _loggerProcess.StartInfo.FileName = logger; + _loggerProcess.StartInfo.UseShellExecute = true; + } + + try + { + _loggerProcess.Start(); + } + catch (Exception) + { + // No way to open a new console window here (e.g. linux without a desktop). Run ConsoleEngine.Logger manually to see the log. + } } internal static void Stop() { _running = false; - _loggerConnection.Dispose(); + _loggerConnection?.Dispose(); Socket.Dispose(); Messages.Clear(); } diff --git a/Libs/ConsoleEngine.CrossPlatform/ConsoleEngine.CrossPlatform.csproj b/Libs/ConsoleEngine.CrossPlatform/ConsoleEngine.CrossPlatform.csproj new file mode 100644 index 0000000..6781ef3 --- /dev/null +++ b/Libs/ConsoleEngine.CrossPlatform/ConsoleEngine.CrossPlatform.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + 12 + ConsoleEngine.CrossPlatform + Release;Debug + AnyCPU + True + + + + + + diff --git a/Libs/ConsoleEngine.CrossPlatform/CrossPlatformConsoleHandler.cs b/Libs/ConsoleEngine.CrossPlatform/CrossPlatformConsoleHandler.cs new file mode 100644 index 0000000..1c0982e --- /dev/null +++ b/Libs/ConsoleEngine.CrossPlatform/CrossPlatformConsoleHandler.cs @@ -0,0 +1,308 @@ +using System; +using System.IO; +using System.Text; +using ConsoleEngine.Abstractions.Rendering; + +namespace ConsoleEngine.CrossPlatform; + +/// +/// Renders to any ANSI/VT capable terminal (macOS Terminal, iTerm2, kitty, WezTerm, Ghostty, Linux terminals ...). +/// Each frame is diffed against the previous one and flushed to stdout in a single write. +/// +public sealed class CrossPlatformConsoleHandler : IConsoleHandler +{ + private const string Esc = "\u001b"; + + private readonly FontInfo _fontInfo; + private readonly Pixel[] _previous; + private readonly StringBuilder _frame = new(); + private readonly bool _trueColor; + private readonly object _writeLock = new(); + private Stream _stdout; + private byte[] _bytes = Array.Empty(); + private bool _fullRedraw = true; + private bool _initialized; + private bool _restored; + private int _visibleWidth; + private int _visibleHeight; + private int _originalWidth; + private int _originalHeight; + + public CrossPlatformConsoleHandler(int width, int height, FontInfo fontInfo) + { + _fontInfo = fontInfo; + Width = width; + Height = height; + _previous = new Pixel[width * height]; + _trueColor = DetectTrueColor(); + } + + public int Width { get; } + public int Height { get; } + + public void InitializeConsole() + { + Console.OutputEncoding = Encoding.UTF8; + _stdout = Console.OpenStandardOutput(); + _originalWidth = SafeWindowSize(() => Console.WindowWidth, 0); + _originalHeight = SafeWindowSize(() => Console.WindowHeight, 0); + _initialized = true; + + AppDomain.CurrentDomain.ProcessExit += (_, _) => RestoreTerminal(); + Console.CancelKeyPress += (_, _) => RestoreTerminal(); + + Write( + $"{Esc}[?1049h" + // alternate screen buffer, keeps the user's scrollback intact + $"{Esc}[?7l" + // disable auto wrap so drawing the bottom right cell never scrolls + $"{Esc}[8;{Height};{Width}t" + // ask the terminal to resize itself (ignored by terminals that don't support it) + $"{Esc}[0m{Esc}[2J"); + + SetFont(_fontInfo); + } + + public void SetTitle(string title) + { + try { Console.Title = title; } catch { /* not supported by every terminal */ } + } + + public void SetCursorVisible(bool visible) + { + if (_initialized) + Write(visible ? $"{Esc}[?25h" : $"{Esc}[?25l"); + else + try { Console.CursorVisible = visible; } catch { /* ignore */ } + } + + public void Resizable(bool resizable) + { + // Terminal windows are owned by the terminal emulator, can't be locked from here. + } + + public void SetFont(FontInfo fontInfo) + { + // Font face and size are owned by the terminal emulator, can't be changed from here. + } + + public void Render(Span pixels) + { + if (!_initialized) + return; + + var visibleWidth = Math.Min(Width, SafeWindowSize(() => Console.WindowWidth, Width)); + var visibleHeight = Math.Min(Height, SafeWindowSize(() => Console.WindowHeight, Height)); + if (visibleWidth != _visibleWidth || visibleHeight != _visibleHeight) + { + _visibleWidth = visibleWidth; + _visibleHeight = visibleHeight; + _fullRedraw = true; + } + + _frame.Clear(); + _frame.Append(Esc).Append("[?2026h"); // begin synchronized update (prevents tearing where supported) + + if (_fullRedraw) + _frame.Append(Esc).Append("[0m").Append(Esc).Append("[2J"); + + var currentFg = -1; + var currentBg = -1; + var changed = _fullRedraw; + + for (var y = 0; y < visibleHeight; y++) + { + var cursorX = -1; + for (var x = 0; x < visibleWidth; x++) + { + var i = y * Width + x; + if (i >= pixels.Length) + break; + + var pixel = pixels[i]; + var c = Sanitize(pixel.Char); + ref var previous = ref _previous[i]; + + if (!_fullRedraw + && previous.Char == c + && previous.ForegroundColor == pixel.ForegroundColor + && previous.BackgroundColor == pixel.BackgroundColor) + continue; + + changed = true; + previous.Char = c; + previous.ForegroundColor = pixel.ForegroundColor; + previous.BackgroundColor = pixel.BackgroundColor; + + if (cursorX != x) + _frame.Append(Esc).Append('[').Append(y + 1).Append(';').Append(x + 1).Append('H'); + + var fg = (int)pixel.ForegroundColor & 0xF; + var bg = (int)pixel.BackgroundColor & 0xF; + if (fg != currentFg || bg != currentBg) + { + AppendColors(fg, bg); + currentFg = fg; + currentBg = bg; + } + + _frame.Append(c); + cursorX = x + 1; + } + } + + if (!changed) + return; + + _frame.Append(Esc).Append("[0m"); + _frame.Append(Esc).Append("[?2026l"); // end synchronized update + _fullRedraw = false; + + Write(_frame); + } + + public void Close() => RestoreTerminal(); + + //********************************************************** + //** private: + //********************************************************** + + private void RestoreTerminal() + { + if (!_initialized) + return; + + lock (_writeLock) + { + if (_restored) + return; + + try + { + var restoreSize = _originalWidth > 0 && _originalHeight > 0 ? $"{Esc}[8;{_originalHeight};{_originalWidth}t" : ""; + _stdout.Write(Encoding.UTF8.GetBytes($"{Esc}[?2026l{Esc}[0m{Esc}[?7h{Esc}[?25h{Esc}[?1049l{restoreSize}")); + _stdout.Flush(); + } + catch + { + // stdout might already be gone during shutdown + } + + _restored = true; + } + } + + private void Write(string value) + { + lock (_writeLock) + { + if (_restored) + return; + + _stdout.Write(Encoding.UTF8.GetBytes(value)); + _stdout.Flush(); + } + } + + private void Write(StringBuilder value) + { + lock (_writeLock) + { + if (_restored) + return; + + var maxBytes = Encoding.UTF8.GetMaxByteCount(value.Length); + if (_bytes.Length < maxBytes) + _bytes = new byte[maxBytes * 2]; + + var count = 0; + foreach (var chunk in value.GetChunks()) + count += Encoding.UTF8.GetBytes(chunk.Span, _bytes.AsSpan(count)); + + _stdout.Write(_bytes, 0, count); + _stdout.Flush(); + } + } + + private void AppendColors(int fg, int bg) + { + if (_trueColor) + { + var (fr, fgG, fb) = Palette[fg]; + var (br, bgG, bb) = Palette[bg]; + _frame.Append(Esc).Append("[38;2;").Append(fr).Append(';').Append(fgG).Append(';').Append(fb) + .Append(";48;2;").Append(br).Append(';').Append(bgG).Append(';').Append(bb).Append('m'); + } + else + { + _frame.Append(Esc).Append('[').Append(AnsiColor[fg]).Append(';').Append(AnsiColor[bg] + 10).Append('m'); + } + } + + /// + /// Windows renders NUL and control characters as blanks, terminals don't (they'd shift the rest of the line). + /// + private static char Sanitize(char c) => c < ' ' || c == '\u007f' || char.IsSurrogate(c) ? ' ' : c; + + private static int SafeWindowSize(Func getter, int fallback) + { + try + { + var size = getter(); + return size > 0 ? size : fallback; + } + catch + { + return fallback; + } + } + + private static bool DetectTrueColor() + { + var setting = Environment.GetEnvironmentVariable("CONSOLE_ENGINE_COLORS"); + if (!string.IsNullOrEmpty(setting)) + return setting.Equals("truecolor", StringComparison.OrdinalIgnoreCase); + + var colorTerm = Environment.GetEnvironmentVariable("COLORTERM"); + return colorTerm is "truecolor" or "24bit"; + } + + /// ConsoleColor (index) to ANSI SGR foreground color. + private static readonly int[] AnsiColor = + [ + 30, // Black + 34, // DarkBlue + 32, // DarkGreen + 36, // DarkCyan + 31, // DarkRed + 35, // DarkMagenta + 33, // DarkYellow + 37, // Gray + 90, // DarkGray + 94, // Blue + 92, // Green + 96, // Cyan + 91, // Red + 95, // Magenta + 93, // Yellow + 97, // White + ]; + + /// ConsoleColor (index) to RGB, the Windows console (Campbell) palette so games look the same as on Windows. + private static readonly (byte R, byte G, byte B)[] Palette = + [ + (0, 0, 0), // Black + (0, 55, 218), // DarkBlue + (19, 161, 14), // DarkGreen + (58, 150, 221), // DarkCyan + (197, 15, 31), // DarkRed + (136, 23, 152), // DarkMagenta + (193, 156, 0), // DarkYellow + (204, 204, 204), // Gray + (118, 118, 118), // DarkGray + (59, 120, 255), // Blue + (22, 198, 12), // Green + (97, 214, 214), // Cyan + (231, 72, 86), // Red + (180, 0, 158), // Magenta + (249, 241, 165), // Yellow + (242, 242, 242), // White + ]; +} diff --git a/Libs/ConsoleEngine.CrossPlatform/CrossPlatformInputHandler.cs b/Libs/ConsoleEngine.CrossPlatform/CrossPlatformInputHandler.cs new file mode 100644 index 0000000..04ab8c4 --- /dev/null +++ b/Libs/ConsoleEngine.CrossPlatform/CrossPlatformInputHandler.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using ConsoleEngine.Abstractions.Inputs; + +namespace ConsoleEngine.CrossPlatform; + +/// +/// Keyboard input for non Windows platforms. Key codes are Windows virtual key codes, same as . +/// +/// On macOS the real keyboard state is read through CoreGraphics. Everywhere else (or if macOS refuses to +/// report key state) keys are read from the terminal, which only reports presses and auto repeats. A key +/// read from the terminal is then considered held until the auto repeat stops. +/// +public class CrossPlatformInputHandler : IInputHandler +{ + // A terminal key is held until no new press/repeat arrives within this window. The first window has to + // outlast the OS' "delay until repeat", the following ones only the repeat interval. + private static readonly long InitialHoldTicks = Stopwatch.Frequency * 550 / 1000; + private static readonly long RepeatHoldTicks = Stopwatch.Frequency * 150 / 1000; + + // How far apart a terminal key press and the native key down may be to count as the same key press. + private static readonly long CalibrationTicks = Stopwatch.Frequency * 300 / 1000; + private const int MaxCalibrationMisses = 2; + + private readonly KeyState[] _keyStates = new KeyState[256]; + private readonly bool[] _down = new bool[256]; + private readonly bool[] _nativeDown = new bool[256]; + private readonly long[] _terminalHeldUntil = new long[256]; + private readonly long[] _terminalLastEvent = new long[256]; + private readonly bool[] _terminalPressed = new bool[256]; + private readonly long[] _nativeLastDown = new long[256]; + private readonly List<(int Key, long Time)> _pendingCalibration = new(); + private bool _nativeSupported; + private bool _nativeTrusted; + private int _calibrationMisses; + + public CrossPlatformInputHandler() + { + var mode = Environment.GetEnvironmentVariable("CONSOLE_ENGINE_INPUT"); + _nativeSupported = !string.Equals(mode, "terminal", StringComparison.OrdinalIgnoreCase) && MacKeyboard.IsSupported(); + + for (var i = 0; i < _keyStates.Length; i++) + _keyStates[i].Index = i; + } + + public KeyState GetKey(int id) + { + return _keyStates[id]; + } + + public IEnumerable GetPressedKeyCodes() => _keyStates.Where(k => k.Pressed).Select(k => k.Index); + + public void Update() + { + var now = Stopwatch.GetTimestamp(); + + // Always drain the terminal, otherwise key presses pile up in stdin (and get echoed when the game exits). + ReadTerminalKeys(now); + + if (_nativeSupported) + { + MacKeyboard.Poll(_nativeDown); + for (var i = 0; i < _nativeDown.Length; i++) + if (_nativeDown[i]) + _nativeLastDown[i] = now; + + if (!_nativeTrusted) + Calibrate(now); + } + + if (_nativeTrusted) + { + Array.Copy(_nativeDown, _down, _down.Length); + } + else + { + for (var i = 0; i < _down.Length; i++) + _down[i] = _terminalHeldUntil[i] > now; + } + + for (var i = 0; i < _keyStates.Length; i++) + { + var wasHeld = _keyStates[i].Held; + var isDown = _down[i]; + + // A key tapped again within its terminal hold window is still a new press. + var repressed = !_nativeTrusted && _terminalPressed[i] && wasHeld; + + _keyStates[i].Pressed = (isDown && !wasHeld) || repressed; + _keyStates[i].Released = !isDown && wasHeld; + _keyStates[i].Held = isDown; + } + } + + /// + /// Without the Input Monitoring permission macOS still reports modifier keys, but every other key as up. + /// So the native key state is only trusted once it agrees with a key press read from the terminal, and + /// dropped if it keeps missing them. Until then the terminal is used. + /// + private void Calibrate(long now) + { + for (var i = 0; i < _terminalPressed.Length; i++) + if (_terminalPressed[i] && MacKeyboard.IsMapped(i)) + _pendingCalibration.Add((i, now)); + + for (var i = _pendingCalibration.Count - 1; i >= 0; i--) + { + var (key, time) = _pendingCalibration[i]; + if (Math.Abs(_nativeLastDown[key] - time) < CalibrationTicks) + { + _nativeTrusted = true; + _pendingCalibration.Clear(); + return; + } + + if (now - time > CalibrationTicks) + { + _pendingCalibration.RemoveAt(i); + if (++_calibrationMisses >= MaxCalibrationMisses) + { + _nativeSupported = false; + _pendingCalibration.Clear(); + return; + } + } + } + } + + private void ReadTerminalKeys(long now) + { + Array.Clear(_terminalPressed); + + if (Console.IsInputRedirected) + return; + + try + { + while (Console.KeyAvailable) + { + var keyInfo = Console.ReadKey(intercept: true); + var vk = ToVirtualKey(keyInfo); + if (vk <= 0 || vk >= _terminalHeldUntil.Length) + continue; + + // Auto repeat events arrive in a rapid stream, anything slower is a new key press. + var isRepeat = now - _terminalLastEvent[vk] < RepeatHoldTicks; + _terminalLastEvent[vk] = now; + _terminalHeldUntil[vk] = now + (isRepeat ? RepeatHoldTicks : InitialHoldTicks); + _terminalPressed[vk] |= !isRepeat; + } + } + catch (InvalidOperationException) + { + // No console attached + } + } + + private static int ToVirtualKey(ConsoleKeyInfo keyInfo) + { + // ConsoleKey values are Windows virtual key codes. + if (keyInfo.Key != 0) + return (int)keyInfo.Key; + + var c = char.ToUpperInvariant(keyInfo.KeyChar); + return c is ' ' or >= '0' and <= '9' or >= 'A' and <= 'Z' ? c : 0; + } +} diff --git a/Libs/ConsoleEngine.CrossPlatform/MacKeyboard.cs b/Libs/ConsoleEngine.CrossPlatform/MacKeyboard.cs new file mode 100644 index 0000000..1d64e96 --- /dev/null +++ b/Libs/ConsoleEngine.CrossPlatform/MacKeyboard.cs @@ -0,0 +1,144 @@ +using System; +using System.Runtime.InteropServices; +using ConsoleEngine.Abstractions.Inputs; + +namespace ConsoleEngine.CrossPlatform; + +/// +/// Reads the real up/down state of the keyboard on macOS through CoreGraphics, the macOS equivalent of +/// Win32 GetAsyncKeyState. Terminals only report key presses (plus auto repeat), never releases, so this +/// is what makes Held/Released behave like on Windows. +/// +internal static class MacKeyboard +{ + private const string CoreGraphics = "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"; + private const int CombinedSessionState = 0; // kCGEventSourceStateCombinedSessionState + + [DllImport(CoreGraphics)] + [return: MarshalAs(UnmanagedType.U1)] + private static extern bool CGEventSourceKeyState(int stateId, ushort key); + + /// Windows virtual key code (index) to macOS virtual key codes (kVK_*). + private static readonly ushort[][] KeyMap = BuildKeyMap(); + + public static bool IsSupported() + { + if (!OperatingSystem.IsMacOS()) + return false; + + try + { + CGEventSourceKeyState(CombinedSessionState, 0); + return true; + } + catch (Exception e) when (e is DllNotFoundException or EntryPointNotFoundException) + { + return false; + } + } + + public static bool IsMapped(int vk) => vk >= 0 && vk < KeyMap.Length && KeyMap[vk].Length > 0; + + /// Writes the current down state of every mapped key into , indexed by Windows virtual key code. + /// true if any key is down. + public static bool Poll(bool[] down) + { + var any = false; + for (var vk = 0; vk < KeyMap.Length && vk < down.Length; vk++) + { + var isDown = false; + foreach (var macKey in KeyMap[vk]) + { + if (CGEventSourceKeyState(CombinedSessionState, macKey)) + { + isDown = true; + break; + } + } + + down[vk] = isDown; + any |= isDown; + } + + return any; + } + + private static ushort[][] BuildKeyMap() + { + var map = new ushort[256][]; + for (var i = 0; i < map.Length; i++) + map[i] = Array.Empty(); + + void Map(int vk, params ushort[] macKeys) => map[vk] = macKeys; + + // Letters (ANSI positions) + Map((int)Key.A, 0x00); Map((int)Key.S, 0x01); Map((int)Key.D, 0x02); Map((int)Key.F, 0x03); + Map((int)Key.H, 0x04); Map((int)Key.G, 0x05); Map((int)Key.Z, 0x06); Map((int)Key.X, 0x07); + Map((int)Key.C, 0x08); Map((int)Key.V, 0x09); Map((int)Key.B, 0x0B); Map((int)Key.Q, 0x0C); + Map((int)Key.W, 0x0D); Map((int)Key.E, 0x0E); Map((int)Key.R, 0x0F); Map((int)Key.Y, 0x10); + Map((int)Key.T, 0x11); Map((int)Key.O, 0x1F); Map((int)Key.U, 0x20); Map((int)Key.I, 0x22); + Map((int)Key.P, 0x23); Map((int)Key.L, 0x25); Map((int)Key.J, 0x26); Map((int)Key.K, 0x28); + Map((int)Key.N, 0x2D); Map((int)Key.M, 0x2E); + + // Digits + Map((int)Key.K1, 0x12); Map((int)Key.K2, 0x13); Map((int)Key.K3, 0x14); Map((int)Key.K4, 0x15); + Map((int)Key.K5, 0x17); Map((int)Key.K6, 0x16); Map((int)Key.K7, 0x1A); Map((int)Key.K8, 0x1C); + Map((int)Key.K9, 0x19); Map((int)Key.K0, 0x1D); + + // Function keys + Map((int)Key.F1, 0x7A); Map((int)Key.F2, 0x78); Map((int)Key.F3, 0x63); Map((int)Key.F4, 0x76); + Map((int)Key.F5, 0x60); Map((int)Key.F6, 0x61); Map((int)Key.F7, 0x62); Map((int)Key.F8, 0x64); + Map((int)Key.F9, 0x65); Map((int)Key.F10, 0x6D); Map((int)Key.F11, 0x67); Map((int)Key.F12, 0x6F); + + // Navigation / editing + Map((int)Key.ENTER, 0x24, 0x4C); // return, keypad enter + Map((int)Key.ESCAPE, 0x35); + Map((int)Key.SPACE, 0x31); + Map((int)Key.BACKSPACE, 0x33); + Map((int)Key.DELETE, 0x75); + Map((int)Key.INSERT, 0x72); // help key sits where insert is on full size keyboards + Map((int)Key.HOME, 0x73); + Map((int)Key.END, 0x77); + Map((int)Key.PRIOR, 0x74); + Map((int)Key.NEXT, 0x79); + Map((int)Key.LEFT, 0x7B); + Map((int)Key.RIGHT, 0x7C); + Map((int)Key.DOWN, 0x7D); + Map((int)Key.UP, 0x7E); + Map(0x09, 0x30); // VK_TAB + + // Modifiers + Map(0x10, 0x38, 0x3C); // VK_SHIFT + Map(0x11, 0x3B, 0x3E); // VK_CONTROL + Map(0x12, 0x3A, 0x3D); // VK_MENU (option) + Map(0x14, 0x39); // VK_CAPITAL + Map(0x5B, 0x37); // VK_LWIN (command) + Map(0x5C, 0x36); // VK_RWIN (right command) + Map(0xA0, 0x38); Map(0xA1, 0x3C); // VK_LSHIFT, VK_RSHIFT + Map(0xA2, 0x3B); Map(0xA3, 0x3E); // VK_LCONTROL, VK_RCONTROL + Map(0xA4, 0x3A); Map(0xA5, 0x3D); // VK_LMENU, VK_RMENU + + // Numpad + Map((int)Key.NUMPAD0, 0x52); Map((int)Key.NUMPAD1, 0x53); Map((int)Key.NUMPAD2, 0x54); + Map((int)Key.NUMPAD3, 0x55); Map((int)Key.NUMPAD4, 0x56); Map((int)Key.NUMPAD5, 0x57); + Map((int)Key.NUMPAD6, 0x58); Map((int)Key.NUMPAD7, 0x59); Map((int)Key.NUMPAD8, 0x5B); + Map((int)Key.NUMPAD9, 0x5C); + Map((int)Key.MULTIPLY, 0x43); Map((int)Key.ADD, 0x45); Map((int)Key.SUBTRACT, 0x4E); + Map((int)Key.DECIMAL, 0x41); Map((int)Key.DIVIDE, 0x4B); + + // OEM / punctuation (US layout positions) + Map(0xBA, 0x29); // ; + Map(0xBB, 0x18); // = + Map(0xBC, 0x2B); // , + Map(0xBD, 0x1B); // - + Map(0xBE, 0x2F); // . + Map(0xBF, 0x2C); // / + Map(0xC0, 0x32); // ` + Map(0xDB, 0x21); // [ + Map(0xDC, 0x2A); // \ + Map(0xDD, 0x1E); // ] + Map(0xDE, 0x27); // ' + + return map; + } +} diff --git a/README.md b/README.md index f78c258..da17fef 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,19 @@ [![Build and test](https://github.com/stho01/console-engine/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/stho01/console-engine/actions/workflows/build-and-test.yml) + +## Platforms + +On Windows the engine talks to the Win32 console directly. On macOS and Linux it renders with ANSI escape sequences, so any +modern terminal works (Terminal.app, iTerm2, kitty, WezTerm, Ghostty, ...). The platform is picked at runtime, so the same +build runs everywhere. + +macOS/Linux notes: + +- Font face/size and window resizing can't be controlled from the game. Set them in your terminal; the game asks the terminal + to resize to fit, which Terminal.app and iTerm2 honor. If the terminal is smaller than the game, the output is clipped. +- Colors use 24-bit RGB when `COLORTERM` is `truecolor`/`24bit`, otherwise the terminal's 16 color palette. + Override with `CONSOLE_ENGINE_COLORS=truecolor|16`. +- On macOS key up/down state is read from the OS (like `GetAsyncKeyState` on Windows), which gives precise held/released keys. + Like on Windows it sees keys pressed while another window is focused. Elsewhere, or with `CONSOLE_ENGINE_INPUT=terminal`, keys + are read from the terminal; terminals only report presses and auto repeat, so a key counts as held until its repeat stops.