diff options
Diffstat (limited to 'src/Godosa.Desktop')
| -rw-r--r-- | src/Godosa.Desktop/Buttons.cs | 15 | ||||
| -rw-r--r-- | src/Godosa.Desktop/CursorWarp.cs | 66 | ||||
| -rw-r--r-- | src/Godosa.Desktop/DisplayMode.cs | 19 | ||||
| -rw-r--r-- | src/Godosa.Desktop/FrameTimeGraph.cs | 65 | ||||
| -rw-r--r-- | src/Godosa.Desktop/GlProgram.cs | 35 | ||||
| -rw-r--r-- | src/Godosa.Desktop/Godosa.Desktop.csproj | 12 | ||||
| -rw-r--r-- | src/Godosa.Desktop/GpuTimer.cs | 71 | ||||
| -rw-r--r-- | src/Godosa.Desktop/InputScriptPlayer.cs | 124 | ||||
| -rw-r--r-- | src/Godosa.Desktop/PadNavigator.cs | 85 |
9 files changed, 492 insertions, 0 deletions
diff --git a/src/Godosa.Desktop/Buttons.cs b/src/Godosa.Desktop/Buttons.cs new file mode 100644 index 0000000..34a89e0 --- /dev/null +++ b/src/Godosa.Desktop/Buttons.cs @@ -0,0 +1,15 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; + +namespace Godosa.Desktop; + +public static class Buttons +{ + /// <summary>Presses a button as a click would: toggles a ToggleButton, else raises Click.</summary> + public static void Press(Button button) + { + if (button is ToggleButton toggle) toggle.IsChecked = toggle.IsChecked != true; + else button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + } +} diff --git a/src/Godosa.Desktop/CursorWarp.cs b/src/Godosa.Desktop/CursorWarp.cs new file mode 100644 index 0000000..c44342d --- /dev/null +++ b/src/Godosa.Desktop/CursorWarp.cs @@ -0,0 +1,66 @@ +using System; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Controls; +using Godosa.Core.Diagnostics; + +namespace Godosa.Desktop; + +/// <summary> +/// Moves the OS pointer to a point in a visual (free look recentering, camera +/// spec §3: Avalonia 12.1.2 has no pointer lock). X11/XWayland via libX11 +/// (XWayland turns a warp under a hidden cursor into a pointer lock), Windows +/// via user32. Returns false when unsupported or on any failure. +/// </summary> +public static partial class CursorWarp +{ + private static IntPtr _display; + + public static bool TryWarp(Visual visual, Point viewPoint) + { + try + { + var topLevel = TopLevel.GetTopLevel(visual); + var handle = topLevel?.TryGetPlatformHandle(); + if (topLevel is null || handle is null) return false; + + if (OperatingSystem.IsWindows()) + { + var screen = visual.PointToScreen(viewPoint); + return SetCursorPos(screen.X, screen.Y); + } + if (OperatingSystem.IsLinux() && handle.HandleDescriptor == "XID") + { + if (visual.TranslatePoint(viewPoint, topLevel) is not { } p) return false; + if (_display == IntPtr.Zero) _display = XOpenDisplay(IntPtr.Zero); + if (_display == IntPtr.Zero) return false; + double scaling = topLevel.RenderScaling; + XWarpPointer(_display, 0, (nuint)handle.Handle, 0, 0, 0, 0, + (int)Math.Round(p.X * scaling), (int)Math.Round(p.Y * scaling)); + XFlush(_display); + return true; + } + if (EngineDebug.Input) EngineDebug.Write("input", $"cursor warp unsupported (handle {handle.HandleDescriptor})"); + return false; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + if (EngineDebug.Input) EngineDebug.Write("input", $"cursor warp failed: {ex.Message}"); + return false; + } + } + + [LibraryImport("libX11.so.6")] + private static partial IntPtr XOpenDisplay(IntPtr displayName); + + [LibraryImport("libX11.so.6")] + private static partial int XWarpPointer(IntPtr display, nuint srcWindow, nuint destWindow, + int srcX, int srcY, uint srcWidth, uint srcHeight, int destX, int destY); + + [LibraryImport("libX11.so.6")] + private static partial int XFlush(IntPtr display); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetCursorPos(int x, int y); +} diff --git a/src/Godosa.Desktop/DisplayMode.cs b/src/Godosa.Desktop/DisplayMode.cs new file mode 100644 index 0000000..527fa43 --- /dev/null +++ b/src/Godosa.Desktop/DisplayMode.cs @@ -0,0 +1,19 @@ +using Avalonia.Controls; +using Avalonia.Media; + +namespace Godosa.Desktop; + +/// <summary>Window fullscreen and a UI layer's scale (the 3D view stays outside that layer).</summary> +public static class DisplayMode +{ + public static void SetFullscreen(Window? window, bool fullscreen) + { + if (window is null) return; + bool full = window.WindowState == WindowState.FullScreen; + if (fullscreen && !full) window.WindowState = WindowState.FullScreen; + else if (!fullscreen && full) window.WindowState = WindowState.Normal; + } + + public static void SetUiScale(LayoutTransformControl layer, double factor) => + layer.LayoutTransform = factor == 1.0 ? null : new ScaleTransform(factor, factor); +} diff --git a/src/Godosa.Desktop/FrameTimeGraph.cs b/src/Godosa.Desktop/FrameTimeGraph.cs new file mode 100644 index 0000000..7e59a17 --- /dev/null +++ b/src/Godosa.Desktop/FrameTimeGraph.cs @@ -0,0 +1,65 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Godosa.Core.Diagnostics; + +namespace Godosa.Desktop; + +/// <summary> +/// Frame-time bar graph (game-loop spec §5): one 1-px bar per frame in the +/// stats window, newest at the right, 0–50 ms scale, dashed guides at 16.7 ms +/// (60 fps) and 33.3 ms (30 fps). Bars are colored relative to the window +/// median (green ≤ 1.5×, yellow ≤ 2×, red = hitch) so ordinary vsync jitter +/// stays green whatever the refresh rate. Drawn by Avalonia, not GL, so it +/// doesn't perturb the GL frames it measures. Presentation only. +/// </summary> +public sealed class FrameTimeGraph : Control +{ + private const float ScaleMs = 50f; + private const float Ms60 = 1000f / 60f; + private const float Ms30 = 1000f / 30f; + + private static readonly IBrush Backdrop = new SolidColorBrush(Color.FromArgb(90, 255, 255, 255)); + private static readonly IBrush Good = new SolidColorBrush(Color.FromRgb(80, 200, 120)); + private static readonly IBrush Slow = new SolidColorBrush(Color.FromRgb(230, 200, 60)); + private static readonly IBrush Bad = new SolidColorBrush(Color.FromRgb(230, 70, 60)); + private static readonly IPen Guide = new Pen(new SolidColorBrush(Color.FromArgb(170, 255, 255, 255)), 1, DashStyle.Dash); + + private readonly FrameTimeStats _stats; + private readonly float[] _buffer; + + public FrameTimeGraph(FrameTimeStats stats) + { + _stats = stats; + _buffer = new float[stats.Capacity]; + Width = stats.Capacity; + Height = 60; + } + + public override void Render(DrawingContext context) + { + double w = Bounds.Width, h = Bounds.Height; + context.FillRectangle(Backdrop, new Rect(0, 0, w, h)); + + int n = _stats.CopyIntervalsMs(_buffer); + float median = (float)_stats.Summarize().P50Ms; + double x0 = w - n; + for (int i = 0; i < n; i++) + { + float ms = _buffer[i]; + double barHeight = Math.Min(ms / ScaleMs, 1f) * h; + var brush = ms > 2f * median ? Bad : ms > 1.5f * median ? Slow : Good; + context.FillRectangle(brush, new Rect(x0 + i, h - barHeight, 1, barHeight)); + } + + DrawGuide(context, Ms60, w, h); + DrawGuide(context, Ms30, w, h); + } + + private static void DrawGuide(DrawingContext context, float ms, double w, double h) + { + double y = h - ms / ScaleMs * h; + context.DrawLine(Guide, new Point(0, y), new Point(w, y)); + } +} diff --git a/src/Godosa.Desktop/GlProgram.cs b/src/Godosa.Desktop/GlProgram.cs new file mode 100644 index 0000000..25ebe5c --- /dev/null +++ b/src/Godosa.Desktop/GlProgram.cs @@ -0,0 +1,35 @@ +using Silk.NET.OpenGL; + +namespace Godosa.Desktop; + +/// <summary>Shader program helpers: compile + link with the driver's info log in the +/// exception, so a bad shader fails loudly at startup instead of drawing nothing.</summary> +public static class GlProgram +{ + public static uint Link(GL gl, string vertexSource, string fragmentSource) + { + uint vs = Compile(gl, ShaderType.VertexShader, vertexSource); + uint fs = Compile(gl, ShaderType.FragmentShader, fragmentSource); + uint program = gl.CreateProgram(); + gl.AttachShader(program, vs); + gl.AttachShader(program, fs); + gl.LinkProgram(program); + gl.GetProgram(program, ProgramPropertyARB.LinkStatus, out int linkStatus); + if (linkStatus == 0) + throw new InvalidOperationException($"Shader link failed: {gl.GetProgramInfoLog(program)}"); + gl.DeleteShader(vs); + gl.DeleteShader(fs); + return program; + } + + public static uint Compile(GL gl, ShaderType type, string source) + { + uint shader = gl.CreateShader(type); + gl.ShaderSource(shader, source); + gl.CompileShader(shader); + gl.GetShader(shader, ShaderParameterName.CompileStatus, out int status); + if (status == 0) + throw new InvalidOperationException($"Shader compile failed: {gl.GetShaderInfoLog(shader)}"); + return shader; + } +} diff --git a/src/Godosa.Desktop/Godosa.Desktop.csproj b/src/Godosa.Desktop/Godosa.Desktop.csproj new file mode 100644 index 0000000..0be77f4 --- /dev/null +++ b/src/Godosa.Desktop/Godosa.Desktop.csproj @@ -0,0 +1,12 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Avalonia" /> + <PackageReference Include="Silk.NET.OpenGL" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" /> + </ItemGroup> +</Project> diff --git a/src/Godosa.Desktop/GpuTimer.cs b/src/Godosa.Desktop/GpuTimer.cs new file mode 100644 index 0000000..11076e8 --- /dev/null +++ b/src/Godosa.Desktop/GpuTimer.cs @@ -0,0 +1,71 @@ +using System; +using Silk.NET.OpenGL; + +namespace Godosa.Desktop; + +/// <summary>GPU time per frame from GL_TIME_ELAPSED queries (game-loop spec §6). A small +/// ring so reading a result never stalls on the frame still in flight; disables itself +/// when the context has no timer queries (e.g. GLES without the extension).</summary> +public sealed class GpuTimer +{ + private const int Ring = 4; + private readonly uint[] _queries = new uint[Ring]; + private readonly bool[] _pending = new bool[Ring]; + private int _current; + private bool _open; + + public bool Supported { get; private set; } + + public void Init(GL gl) + { + try + { + string version = gl.GetStringS(StringName.Version) ?? ""; + if (version.Contains("OpenGL ES", StringComparison.Ordinal)) return; + for (int i = 0; i < Ring; i++) _queries[i] = gl.GenQuery(); + Supported = gl.GetError() == GLEnum.NoError; + } + catch (Exception) + { + Supported = false; + } + } + + public void Begin(GL gl) + { + if (!Supported || _pending[_current]) return; // slot still in flight: skip this frame + gl.BeginQuery(QueryTarget.TimeElapsed, _queries[_current]); + _open = true; + } + + public void End(GL gl) + { + if (!_open) return; + gl.EndQuery(QueryTarget.TimeElapsed); + _pending[_current] = true; + _open = false; + _current = (_current + 1) % Ring; + } + + /// <summary>Seconds for each finished query since the last call.</summary> + public void Collect(GL gl, Action<double> onResult) + { + if (!Supported) return; + for (int i = 0; i < Ring; i++) + { + if (!_pending[i]) continue; + gl.GetQueryObject(_queries[i], QueryObjectParameterName.ResultAvailable, out int available); + if (available == 0) continue; + gl.GetQueryObject(_queries[i], QueryObjectParameterName.Result, out ulong nanoseconds); + _pending[i] = false; + onResult(nanoseconds / 1e9); + } + } + + public void Delete(GL gl) + { + if (!Supported) return; + for (int i = 0; i < Ring; i++) gl.DeleteQuery(_queries[i]); + Supported = false; + } +} diff --git a/src/Godosa.Desktop/InputScriptPlayer.cs b/src/Godosa.Desktop/InputScriptPlayer.cs new file mode 100644 index 0000000..2aad679 --- /dev/null +++ b/src/Godosa.Desktop/InputScriptPlayer.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Godosa.Core.Diagnostics; +using Godosa.Core.Input; + +namespace Godosa.Desktop; + +/// <summary>Replays the game's input-script file (Core <see cref="InputScript"/>, path in the +/// variable given to <see cref="Configure"/>) as real +/// routed key events to the focused control and clicks on visible buttons, so smoke runs +/// can drive the game without X key injection (which never reaches a focused window on +/// KDE Wayland). Log category <c>script</c> (<see cref="EngineDebug"/>).</summary> +public sealed class InputScriptPlayer +{ + private readonly Window _window; + private readonly InputScript _script; + private readonly Stopwatch _clock = Stopwatch.StartNew(); + private readonly List<ScriptEvent> _due = new(); + private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(10) }; + + private static string? _variable; + private readonly Func<Window, Interactive?>? _keyTarget; + + /// <summary>True while a script drives the game: window focus no longer pauses or + /// releases input.</summary> + public static bool Active { get; private set; } + + /// <summary>Scripted gamepad (<c>PadA … PadRT</c>, see <see cref="GamepadScript"/>).</summary> + public static GamepadState Pad { get; private set; } + + /// <summary>Call first in <c>Main</c>: names the game's script variable.</summary> + public static void Configure(string environmentVariable) + { + _variable = environmentVariable; + Active = Environment.GetEnvironmentVariable(environmentVariable) is { Length: > 0 }; + } + + private InputScriptPlayer(Window window, InputScript script, Func<Window, Interactive?>? keyTarget) + { + _window = window; + _script = script; + _keyTarget = keyTarget; + _timer.Tick += (_, _) => Step(); + _timer.Start(); + } + + /// <param name="keyTarget">Where keys go when nothing has focus (e.g. the game view); the window otherwise.</param> + public static InputScriptPlayer? Start(Window window, Func<Window, Interactive?>? keyTarget = null) + { + if (!Active) return null; + string path = Environment.GetEnvironmentVariable(_variable!)!; + var script = InputScript.Parse(File.ReadAllText(path)); + if (EngineDebug.Script) EngineDebug.Write("script", $"{script.Events.Count} events from {path}"); + return new InputScriptPlayer(window, script, keyTarget); + } + + private void Step() + { + _script.Due(_clock.Elapsed.TotalSeconds, _due); + foreach (var e in _due) + { + bool ok = e.Action switch + { + ScriptAction.Click => Click(e.Arg), + ScriptAction.Type => Type(e.Arg), + _ => Key(e.Action == ScriptAction.Down, e.Arg), + }; + if (EngineDebug.Script) EngineDebug.Write("script", $"t={_clock.Elapsed.TotalSeconds:F2} {e.Action} {e.Arg}{(ok ? "" : " (FAILED)")}"); + } + _due.Clear(); + if (_script.Finished) _timer.Stop(); + } + + private bool Key(bool down, string name) + { + if (name.StartsWith("Pad", StringComparison.OrdinalIgnoreCase)) + { + if (GamepadScript.Apply(Pad, name, down) is not { } next) return false; + Pad = next; + return true; + } + if (!Enum.TryParse(name, ignoreCase: true, out PhysicalKey physical)) return false; + Enum.TryParse(name, ignoreCase: true, out Key key); + var target = _window.FocusManager?.GetFocusedElement() as Interactive + ?? _keyTarget?.Invoke(_window) + ?? _window; + target.RaiseEvent(new KeyEventArgs + { + RoutedEvent = down ? InputElement.KeyDownEvent : InputElement.KeyUpEvent, + PhysicalKey = physical, + Key = key, + Source = target, + }); + return true; + } + + private bool Type(string text) + { + if (_window.FocusManager?.GetFocusedElement() is not TextBox box) return false; + box.Text = text; + return true; + } + + private bool Click(string label) + { + var buttons = _window.GetVisualDescendants().OfType<Button>() + .Where(b => b.IsEffectivelyVisible && b.IsEffectivelyEnabled).ToList(); + string Text(Button b) => b.Content?.ToString()?.Trim() ?? ""; + var button = buttons.FirstOrDefault(b => Text(b).Equals(label, StringComparison.OrdinalIgnoreCase)) // exact label first + ?? buttons.FirstOrDefault(b => b.Name == label) // steppers: fov+ / fov- + ?? buttons.FirstOrDefault(b => Text(b).Contains(label, StringComparison.OrdinalIgnoreCase)); + if (button is not null) Buttons.Press(button); + return button is not null; + } +} diff --git a/src/Godosa.Desktop/PadNavigator.cs b/src/Godosa.Desktop/PadNavigator.cs new file mode 100644 index 0000000..b4b59d5 --- /dev/null +++ b/src/Godosa.Desktop/PadNavigator.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using Avalonia.Media; +using Godosa.Core.Input; + +namespace Godosa.Desktop; + +/// <summary>Gamepad control of an open window: D-pad moves a highlight through its enabled +/// buttons (wrapping), A presses (the game decides B/Start).</summary> +public sealed class PadNavigator +{ + private static readonly Thickness HighlightThickness = new(2); + private readonly MenuCursor _cursor = new(); + private readonly List<Button> _buttons = new(); + private Control? _container; + private Button? _highlighted; + private readonly IBrush _highlight; + + public PadNavigator(IBrush highlight) => _highlight = highlight; + + /// <summary>The window pad input should drive now, or null (pad drives the character).</summary> + public Control? Active { get; private set; } + + /// <summary>Highlight only while the gamepad is the device in use (keyboard/mouse users + /// get no stray gold border).</summary> + public bool ShowHighlight { get; set; } + + public void Refresh(Control? active) + { + Active = active; + if (!ReferenceEquals(active, _container)) + { + _container = active; + _cursor.Clamp(0); + } + Collect(); + _cursor.Clamp(_buttons.Count); + Highlight(); + } + + public void Move(int delta) + { + Collect(); + _cursor.Clamp(_buttons.Count); + _cursor.Move(delta, _buttons.Count); + Highlight(); + } + + public void Press() + { + Collect(); + _cursor.Clamp(_buttons.Count); + if (_cursor.Index >= 0) Buttons.Press(_buttons[_cursor.Index]); + } + + private void Collect() + { + _buttons.Clear(); + if (_container is null) return; + _buttons.AddRange(_container.GetVisualDescendants().OfType<Button>() + .Where(b => b.IsEffectivelyVisible && b.IsEffectivelyEnabled)); + } + + private void Highlight() + { + var target = ShowHighlight && _cursor.Index >= 0 && _cursor.Index < _buttons.Count ? _buttons[_cursor.Index] : null; + if (ReferenceEquals(target, _highlighted)) return; + if (_highlighted is not null) + { + _highlighted.ClearValue(Button.BorderBrushProperty); + _highlighted.ClearValue(Button.BorderThicknessProperty); + } + _highlighted = target; + if (target is not null) + { + target.BorderBrush = _highlight; + target.BorderThickness = HighlightThickness; + target.BringIntoView(); // long option pages scroll + } + } +} |
