diff --git a/SnakeGame.sln b/SnakeGame.sln index 0fa6969..b770c49 100644 --- a/SnakeGame.sln +++ b/SnakeGame.sln @@ -12,6 +12,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SnakeWebGL", "SnakeWebGL\Sn EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SnakeCore.Logging", "SnakeCore.Logging\SnakeCore.Logging.csproj", "{3D99F8B1-5913-4FE0-A999-6F876F26473D}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SnakeWebGL.Tests", "SnakeWebGL.Tests\SnakeWebGL.Tests.csproj", "{5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -30,15 +32,19 @@ Global {3685AFF4-0131-43CA-9757-5B59DD877854}.Debug|Any CPU.Build.0 = Debug|Any CPU {3685AFF4-0131-43CA-9757-5B59DD877854}.Release|Any CPU.ActiveCfg = Release|Any CPU {3685AFF4-0131-43CA-9757-5B59DD877854}.Release|Any CPU.Build.0 = Release|Any CPU - {91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Release|Any CPU.Build.0 = Release|Any CPU - {3D99F8B1-5913-4FE0-A999-6F876F26473D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3D99F8B1-5913-4FE0-A999-6F876F26473D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3D99F8B1-5913-4FE0-A999-6F876F26473D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3D99F8B1-5913-4FE0-A999-6F876F26473D}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection + {91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Release|Any CPU.Build.0 = Release|Any CPU + {3D99F8B1-5913-4FE0-A999-6F876F26473D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D99F8B1-5913-4FE0-A999-6F876F26473D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D99F8B1-5913-4FE0-A999-6F876F26473D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D99F8B1-5913-4FE0-A999-6F876F26473D}.Release|Any CPU.Build.0 = Release|Any CPU + {5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection diff --git a/SnakeWebGL.Tests/EglStartupTests.cs b/SnakeWebGL.Tests/EglStartupTests.cs new file mode 100644 index 0000000..5827348 --- /dev/null +++ b/SnakeWebGL.Tests/EglStartupTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Xunit; + +namespace SnakeWebGL.Tests; + +internal sealed class FakeEgl : IEglApi +{ + public IntPtr DisplayHandle { get; set; } = (IntPtr)1; + public bool InitializeResult { get; set; } = true; + public Queue ChooseConfigSequence { get; set; } = new(new[] { true }); + public Queue ConfigHandles { get; set; } = new(new[] { (IntPtr)2 }); + public bool BindApiResult { get; set; } = true; + public IntPtr ContextHandle { get; set; } = (IntPtr)3; + public IntPtr SurfaceHandle { get; set; } = (IntPtr)4; + public bool MakeCurrentResult { get; set; } = true; + public int ErrorCode { get; set; } = unchecked((int)0xBAD); // Arbitrary default + + public IntPtr GetDisplay(IntPtr displayId) => DisplayHandle; + + public bool Initialize(IntPtr display, out int major, out int minor) + { + major = 3; + minor = 0; + return InitializeResult; + } + + public bool ChooseConfig(IntPtr display, int[] attributeList, ref IntPtr config, IntPtr configSize, ref IntPtr numConfig) + { + if (ChooseConfigSequence.Count == 0) + { + return false; + } + + var result = ChooseConfigSequence.Dequeue(); + if (result) + { + config = ConfigHandles.Count > 0 ? ConfigHandles.Dequeue() : (IntPtr)99; + numConfig = (IntPtr)1; + } + else + { + config = IntPtr.Zero; + numConfig = IntPtr.Zero; + } + + return result; + } + + public bool BindApi(int api) => BindApiResult; + + public IntPtr CreateContext(IntPtr display, IntPtr config, IntPtr shareContext, int[] contextAttribs) => ContextHandle; + + public IntPtr CreateWindowSurface(IntPtr display, IntPtr config, IntPtr window, IntPtr attribList) => SurfaceHandle; + + public bool MakeCurrent(IntPtr display, IntPtr draw, IntPtr read, IntPtr context) => MakeCurrentResult; + + public int GetError() => ErrorCode; +} + +internal sealed class FakeLibraryLoader : INativeLibraryLoader +{ + public bool Result { get; set; } + + public bool TryLoad(string libraryName) => Result; +} + +public class EglStartupTests +{ + [Fact] + public void FallsBackWhenMsaaConfigUnavailable() + { + var fake = new FakeEgl + { + ChooseConfigSequence = new Queue(new[] { false, true }), + ConfigHandles = new Queue(new[] { (IntPtr)11, (IntPtr)22 }) + }; + + var logs = new List(); + var startup = new EglStartup(fake, logs.Add); + + var handles = startup.Initialize(); + + Assert.Equal((IntPtr)22, handles.Config); + Assert.Contains(logs, l => l.Contains("MSAA config unavailable")); + Assert.Contains(logs, l => l.Contains("Selected non-MSAA config")); + } + + [Fact] + public void SurfaceErrorsBubbleWithErrorCode() + { + var fake = new FakeEgl + { + DisplayHandle = IntPtr.Zero, + ErrorCode = 0x3001 + }; + + var startup = new EglStartup(fake, _ => { }); + + var exception = Assert.Throws(() => startup.Initialize()); + Assert.Contains("0x3001", exception.Message); + } + + [Fact] + public void DescribeAttributesTrimsTrailingComma() + { + // Use reflection to reach the private helper so we can lock in the string format that was failing. + var method = typeof(EglStartup).GetMethod("DescribeAttributes", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + + var attributes = new[] { EGL.EGL_RED_SIZE, 8, EGL.EGL_NONE, 0 }; + var result = method!.Invoke(null, new object[] { attributes }); + + Assert.Equal("[12324=8]", result); // 12324 == EGL.EGL_RED_SIZE + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsSupportedReflectsLibraryAvailability(bool available) + { + var startup = new EglStartup(new FakeEgl(), _ => { }, new FakeLibraryLoader { Result = available }); + Assert.Equal(available, startup.IsSupported()); + } +} diff --git a/SnakeWebGL.Tests/SnakeWebGL.Tests.csproj b/SnakeWebGL.Tests/SnakeWebGL.Tests.csproj new file mode 100644 index 0000000..070dae2 --- /dev/null +++ b/SnakeWebGL.Tests/SnakeWebGL.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + false + enable + + + + + + + + + + + diff --git a/SnakeWebGL/EglStartup.cs b/SnakeWebGL/EglStartup.cs new file mode 100644 index 0000000..32ef542 --- /dev/null +++ b/SnakeWebGL/EglStartup.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace SnakeWebGL; + +internal interface IEglApi +{ + IntPtr GetDisplay(IntPtr displayId); + bool Initialize(IntPtr display, out int major, out int minor); + bool ChooseConfig(IntPtr display, int[] attributeList, ref IntPtr config, IntPtr configSize, ref IntPtr numConfig); + bool BindApi(int api); + IntPtr CreateContext(IntPtr display, IntPtr config, IntPtr shareContext, int[] contextAttribs); + IntPtr CreateWindowSurface(IntPtr display, IntPtr config, IntPtr window, IntPtr attribList); + bool MakeCurrent(IntPtr display, IntPtr draw, IntPtr read, IntPtr context); + int GetError(); +} + +internal sealed class EglApi : IEglApi +{ + public IntPtr GetDisplay(IntPtr displayId) => EGL.GetDisplay(displayId); + + public bool Initialize(IntPtr display, out int major, out int minor) => EGL.Initialize(display, out major, out minor); + + public bool ChooseConfig(IntPtr display, int[] attributeList, ref IntPtr config, IntPtr configSize, ref IntPtr numConfig) + => EGL.ChooseConfig(display, attributeList, ref config, configSize, ref numConfig); + + public bool BindApi(int api) => EGL.BindApi(api); + + public IntPtr CreateContext(IntPtr display, IntPtr config, IntPtr shareContext, int[] contextAttribs) + => EGL.CreateContext(display, config, shareContext, contextAttribs); + + public IntPtr CreateWindowSurface(IntPtr display, IntPtr config, IntPtr window, IntPtr attribList) + => EGL.CreateWindowSurface(display, config, window, attribList); + + public bool MakeCurrent(IntPtr display, IntPtr draw, IntPtr read, IntPtr context) + => EGL.MakeCurrent(display, draw, read, context); + + public int GetError() => EGL.GetError(); +} + +internal readonly record struct EglHandles(IntPtr Display, IntPtr Config, IntPtr Context, IntPtr Surface, int MajorVersion, int MinorVersion); + +internal interface INativeLibraryLoader +{ + bool TryLoad(string libraryName); +} + +internal sealed class NativeLibraryLoader : INativeLibraryLoader +{ + public bool TryLoad(string libraryName) => NativeLibrary.TryLoad(libraryName, out _); +} + +internal sealed class EglStartup +{ + private readonly IEglApi _egl; + private readonly Action _log; + private readonly INativeLibraryLoader _libraryLoader; + + private static readonly int[] AttributeListMsaa = + { + EGL.EGL_RED_SIZE, 8, + EGL.EGL_GREEN_SIZE, 8, + EGL.EGL_BLUE_SIZE, 8, + EGL.EGL_SAMPLES, 16, + EGL.EGL_NONE + }; + + private static readonly int[] AttributeListNoMsaa = + { + EGL.EGL_RED_SIZE, 8, + EGL.EGL_GREEN_SIZE, 8, + EGL.EGL_BLUE_SIZE, 8, + EGL.EGL_NONE + }; + + private static readonly int[] ContextAttributes = + { + EGL.EGL_CONTEXT_CLIENT_VERSION, 3, + EGL.EGL_NONE + }; + + public EglStartup(IEglApi eglApi, Action? log, INativeLibraryLoader? libraryLoader = null) + { + _egl = eglApi ?? throw new ArgumentNullException(nameof(eglApi)); + _log = log ?? (_ => { }); + _libraryLoader = libraryLoader ?? new NativeLibraryLoader(); + } + + public bool IsSupported() + { + var available = _libraryLoader.TryLoad(EGL.LibEgl); + if (!available) + { + _log($"[EGL] {EGL.LibEgl} not found; skipping EGL initialization"); + } + + return available; + } + + public EglHandles Initialize() + { + _log("[EGL] Starting initialization"); + + var display = _egl.GetDisplay(IntPtr.Zero); + _log($"[EGL] GetDisplay returned 0x{display.ToInt64():X}"); + if (display == IntPtr.Zero) + throw new InvalidOperationException($"EGL.GetDisplay returned null (error: 0x{_egl.GetError():X}). Ensure a canvas is available and WebGL is enabled."); + + if (!_egl.Initialize(display, out int major, out int minor)) + throw new InvalidOperationException($"EGL.Initialize failed (error: 0x{_egl.GetError():X})."); + + _log($"[EGL] Initialized version {major}.{minor}"); + + var config = PickConfig(display); + + if (!_egl.BindApi(EGL.EGL_OPENGL_ES_API)) + throw new InvalidOperationException($"EGL.BindApi failed (error: 0x{_egl.GetError():X})."); + + var context = _egl.CreateContext(display, config, (IntPtr)EGL.EGL_NO_CONTEXT, ContextAttributes); + _log($"[EGL] CreateContext returned 0x{context.ToInt64():X}"); + if (context == IntPtr.Zero) + throw new InvalidOperationException($"EGL.CreateContext failed (error: 0x{_egl.GetError():X})."); + + var surface = _egl.CreateWindowSurface(display, config, IntPtr.Zero, IntPtr.Zero); + _log($"[EGL] CreateWindowSurface returned 0x{surface.ToInt64():X}"); + if (surface == IntPtr.Zero) + throw new InvalidOperationException($"EGL.CreateWindowSurface failed (error: 0x{_egl.GetError():X})."); + + if (!_egl.MakeCurrent(display, surface, surface, context)) + throw new InvalidOperationException($"EGL.MakeCurrent failed (error: 0x{_egl.GetError():X})."); + + _log("[EGL] Context made current successfully"); + + return new EglHandles(display, config, context, surface, major, minor); + } + + private IntPtr PickConfig(IntPtr display) + { + _log($"[EGL] Selecting config with MSAA attributes: {DescribeAttributes(AttributeListMsaa)}"); + if (TryChooseConfig(display, AttributeListMsaa, out var config)) + { + _log($"[EGL] Selected MSAA config 0x{config.ToInt64():X}"); + return config; + } + + _log("[EGL] MSAA config unavailable, retrying without MSAA"); + + if (TryChooseConfig(display, AttributeListNoMsaa, out config)) + { + _log($"[EGL] Selected non-MSAA config 0x{config.ToInt64():X}"); + return config; + } + + throw new InvalidOperationException($"EGL.ChooseConfig failed for both attribute sets (last error: 0x{_egl.GetError():X})."); + } + + private bool TryChooseConfig(IntPtr display, int[] attributes, out IntPtr config) + { + config = IntPtr.Zero; + var numConfig = IntPtr.Zero; + var result = _egl.ChooseConfig(display, attributes, ref config, (IntPtr)1, ref numConfig) && numConfig != IntPtr.Zero; + if (!result) + { + _log($"[EGL] ChooseConfig failed for attributes {DescribeAttributes(attributes)} (error: 0x{_egl.GetError():X})"); + } + + return result; + } + + private static string DescribeAttributes(IReadOnlyList attributes) + { + var writer = new DefaultInterpolatedStringHandler(0, 0); + + writer.AppendLiteral("["); + for (int i = 0; i < attributes.Count; i += 2) + { + var key = attributes[i]; + if (key == EGL.EGL_NONE) + break; + + var value = (i + 1) < attributes.Count ? attributes[i + 1] : 0; + writer.AppendFormatted(key); + writer.AppendLiteral("="); + writer.AppendFormatted(value); + writer.AppendLiteral(", "); + } + + writer.AppendLiteral("]"); + + var result = writer.ToStringAndClear(); + if (result == "[") + return "[]"; + + if (result.EndsWith(", ]")) + result = result[..^3] + "]"; + + return result; + } +} diff --git a/SnakeWebGL/Emscripten.cs b/SnakeWebGL/Emscripten.cs index b76e1c5..983267e 100644 --- a/SnakeWebGL/Emscripten.cs +++ b/SnakeWebGL/Emscripten.cs @@ -3,25 +3,39 @@ namespace SnakeWebGL; -internal static class Emscripten -{ - [DllImport("emscripten", EntryPoint = "emscripten_request_animation_frame_loop")] - [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] - internal static extern unsafe void RequestAnimationFrameLoop(void* f, nint userDataPtr); + internal static class Emscripten + { + private const string LibraryName = "__Internal"; - // emscripten_webgl_create_context + [DllImport(LibraryName, EntryPoint = "emscripten_request_animation_frame_loop")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe void RequestAnimationFrameLoop(void* f, nint userDataPtr); - [DllImport("emscripten", EntryPoint = "emscripten_webgl_get_current_context")] - [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] - internal static extern unsafe IntPtr WebGlGetCurrentContext(); + [DllImport(LibraryName, EntryPoint = "emscripten_webgl_init_context_attributes")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe void WebGlInitContextAttributes(out EmscriptenWebGLContextAttributes attributes); + + [DllImport(LibraryName, EntryPoint = "emscripten_webgl_create_context")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe IntPtr WebGlCreateContext(string target, ref EmscriptenWebGLContextAttributes attribs); + + [DllImport(LibraryName, EntryPoint = "emscripten_webgl_make_context_current")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe int WebGlMakeContextCurrent(IntPtr context); + + // emscripten_webgl_create_context + + [DllImport(LibraryName, EntryPoint = "emscripten_webgl_get_current_context")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe IntPtr WebGlGetCurrentContext(); - [DllImport("emscripten", EntryPoint = "emscripten_webgl_get_context_attributes")] + [DllImport(LibraryName, EntryPoint = "emscripten_webgl_get_context_attributes")] [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] internal static extern unsafe int WebGlGetContextAttributes(IntPtr context, out EmscriptenWebGLContextAttributes userDataPtr); //EM_BOOL emscripten_webgl_enable_extension(EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context, const char* extension) - [DllImport("emscripten", EntryPoint = "emscripten_webgl_enable_extension")] + [DllImport(LibraryName, EntryPoint = "emscripten_webgl_enable_extension")] [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] internal static extern unsafe bool WebGlEnableExtension(IntPtr context, string extension); diff --git a/SnakeWebGL/Game.cs b/SnakeWebGL/Game.cs index 769b484..fe24ef7 100644 --- a/SnakeWebGL/Game.cs +++ b/SnakeWebGL/Game.cs @@ -2,6 +2,7 @@ using Silk.NET.OpenGLES; using SnakeCore; using SnakeCore.Logging; +using System.Drawing; using System.IO; using System.Numerics; using System.Runtime.InteropServices; @@ -30,6 +31,9 @@ public partial class Game { private WebGlRenderer _renderer; private SnakeCore.Game _game; + private bool _isInMenu = true; + private Texture2D _uiTexture; + private RectangleF _startButtonBounds; private class FontAtlas { @@ -108,19 +112,59 @@ private Game(GL gl) _game = new(logger); _renderer = new WebGlRenderer(gl, _game.playground.DesignWidth, _game.playground.DesignHeight); + _uiTexture = ((IRenderer)_renderer).CreateImage(1, 1, new byte[] { 255, 255, 255, 255 }); + var buttonSize = new Vector2(200f, 40f); + var buttonPosition = new Vector2(_game.playground.DesignWidth / 2f - buttonSize.X / 2f, _game.playground.DesignHeight / 2f + 20f); + _startButtonBounds = new RectangleF(buttonPosition.X, buttonPosition.Y, buttonSize.X, buttonSize.Y); + _game.Initialize(_renderer); } - public void Update(float elapsedSeconds, Direction direction) + public void StartGame() + { + if (!_isInMenu) + return; + + _isInMenu = false; + } + + public void Update(float elapsedSeconds, Direction direction, bool startRequested = false) { + if (_isInMenu) + { + if (startRequested) + StartGame(); + + return; + } + _game.Update(elapsedSeconds, direction); } + public void HandlePointerDown(Vector2 screenPosition, int button) + { + if (!_isInMenu || button != 0) + return; + + var worldPos = _renderer.ScreenToWorld(screenPosition); + if (_startButtonBounds.Contains(new PointF(worldPos.X, worldPos.Y))) + { + StartGame(); + } + } + public void Draw() { _renderer.BeginRender(); - _game.Draw(0.016f, _renderer); + if (_isInMenu) + { + DrawMenu(); + } + else + { + _game.Draw(0.016f, _renderer); + } _renderer.EndRender(); } @@ -130,6 +174,28 @@ internal void CanvasResized(int canvasWidth, int canvasHeight) _renderer.SetCanvasSize(canvasWidth, canvasHeight); } + private void DrawMenu() + { + var centerX = _game.playground.DesignWidth / 2f; + var centerY = _game.playground.DesignHeight / 2f; + + var titlePosition = new Vector2(centerX - 36f, centerY - 20f); + var promptPosition = new Vector2(centerX - 60f, _startButtonBounds.Y - 18f); + var buttonTextPosition = new Vector2(_startButtonBounds.X + 70f, _startButtonBounds.Y + 14f); + + _renderer.DrawText("SNAKE", titlePosition); + _renderer.DrawText("Press Enter, Space, or Click to start", promptPosition); + DrawStartButton(buttonTextPosition); + } + + private void DrawStartButton(Vector2 textPosition) + { + var srcRect = new Rectangle(0, 0, 1, 1); + _renderer.DrawImage(_uiTexture, new Vector2(_startButtonBounds.X, _startButtonBounds.Y), new Vector2(_startButtonBounds.Width, _startButtonBounds.Height), 0, Vector2.Zero, srcRect, Color.DarkSeaGreen); + _renderer.DrawImage(_uiTexture, new Vector2(_startButtonBounds.X + 4f, _startButtonBounds.Y + 4f), new Vector2(_startButtonBounds.Width - 8f, _startButtonBounds.Height - 8f), 0, Vector2.Zero, srcRect, Color.SeaGreen); + _renderer.DrawText("Start", textPosition); + } + public static Game Create(GL gl) { return new Game(gl); diff --git a/SnakeWebGL/Interop.cs b/SnakeWebGL/Interop.cs index 4658898..78ad8bb 100644 --- a/SnakeWebGL/Interop.cs +++ b/SnakeWebGL/Interop.cs @@ -10,6 +10,9 @@ internal static partial class Interop [JSImport("initialize", "main.js")] public static partial void Initialize(); + [JSImport("ensureCanvasReady", "main.js")] + public static partial bool EnsureCanvasReady(); + [JSImport("isKeyPressed", "main.js")] public static partial bool IsKeyPressed(string code); @@ -19,17 +22,19 @@ internal static partial class Interop [JSExport] public static void OnMouseMove(float x, float y) { - + Program.OnMouseMove(x, y); } [JSExport] - public static void OnMouseDown(bool shift, bool ctrl, bool alt, int button) + public static void OnMouseDown(bool shift, bool ctrl, bool alt, int button, float x, float y) { + Program.OnMouseDown(button, x, y); } [JSExport] - public static void OnMouseUp(bool shift, bool ctrl, bool alt, int button) + public static void OnMouseUp(bool shift, bool ctrl, bool alt, int button, float x, float y) { + Program.OnMouseUp(button, x, y); } [JSExport] diff --git a/SnakeWebGL/Program.cs b/SnakeWebGL/Program.cs index c6c5558..e9fe75a 100644 --- a/SnakeWebGL/Program.cs +++ b/SnakeWebGL/Program.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; @@ -41,6 +42,10 @@ public static int Frame(double newTime, int userData) else if (Interop.IsKeyPressed(Keys.A)) direction = SnakeCore.Direction.Left; + var startRequested = Interop.IsKeyPressed("Space") || + Interop.IsKeyPressed("Enter") || + Interop.IsKeyPressed("Mouse0"); + if(accumulator >= dt) { Interop.UpdateInput(); @@ -48,7 +53,7 @@ public static int Frame(double newTime, int userData) while (accumulator >= dt) { - Game.Update(dt, direction); + Game.Update(dt, direction, startRequested); accumulator -= dt; } @@ -63,59 +68,65 @@ public static void CanvasResized(int width, int height) Game?.CanvasResized(width, height); } + public static void OnMouseMove(float x, float y) + { + } + + public static void OnMouseDown(int button, float x, float y) + { + if (Game == null) + return; + + Game.HandlePointerDown(new Vector2(x, y), button); + } + + public static void OnMouseUp(int button, float x, float y) + { + } + public static void Main(string[] args) { Console.WriteLine($"Hello from dotnet 9!"); - var display = EGL.GetDisplay(IntPtr.Zero); - if (display == IntPtr.Zero) - throw new Exception("Display was null"); + // Ensure the JS side has already hooked up the canvas and input handlers before creating the GL context. + Interop.Initialize(); + var canvasReady = Interop.EnsureCanvasReady(); + Console.WriteLine($"[Startup] Canvas ready: {canvasReady}"); - if (!EGL.Initialize(display, out int major, out int minor)) - throw new Exception("Initialize() returned false."); + var eglStartup = new EglStartup(new EglApi(), Console.WriteLine); + bool eglActive = false; - int[] attributeList = new int[] - { - EGL.EGL_RED_SIZE , 8, - EGL.EGL_GREEN_SIZE, 8, - EGL.EGL_BLUE_SIZE , 8, - //EGL.EGL_DEPTH_SIZE, 24, - //EGL.EGL_STENCIL_SIZE, 8, - //EGL.EGL_SURFACE_TYPE, EGL.EGL_WINDOW_BIT, - //EGL.EGL_RENDERABLE_TYPE, EGL.EGL_OPENGL_ES3_BIT, - EGL.EGL_SAMPLES, 16, //MSAA, 16 samples - //EGL.EGL_SAMPLES, 0, //MSAA, 16 samples - EGL.EGL_NONE - }; - - var config = IntPtr.Zero; - var numConfig = IntPtr.Zero; - if (!EGL.ChooseConfig(display, attributeList, ref config, (IntPtr)1, ref numConfig)) - throw new Exception("ChoseConfig() failed"); - if (numConfig == IntPtr.Zero) - throw new Exception("ChoseConfig() returned no configs"); - - if (!EGL.BindApi(EGL.EGL_OPENGL_ES_API)) - throw new Exception("BindApi() failed"); - - // No other attribute is supported... - int[] ctxAttribs = new int[] + if (eglStartup.IsSupported()) { - EGL.EGL_CONTEXT_CLIENT_VERSION, 3, - EGL.EGL_NONE - }; - - var context = EGL.CreateContext(display, config, (IntPtr)EGL.EGL_NO_CONTEXT, ctxAttribs); - if (context == IntPtr.Zero) - throw new Exception("CreateContext() failed"); - - // now create the surface - var surface = EGL.CreateWindowSurface(display, config, IntPtr.Zero, IntPtr.Zero); - if (surface == IntPtr.Zero) - throw new Exception("CreateWindowSurface() failed"); + try + { + var eglHandles = eglStartup.Initialize(); + eglActive = true; + Console.WriteLine($"[Startup] EGL handles display=0x{eglHandles.Display.ToInt64():X}, config=0x{eglHandles.Config.ToInt64():X}, context=0x{eglHandles.Context.ToInt64():X}, surface=0x{eglHandles.Surface.ToInt64():X} (v{eglHandles.MajorVersion}.{eglHandles.MinorVersion})"); + } + catch (DllNotFoundException ex) + { + Console.Error.WriteLine($"[Startup] EGL unavailable: {ex.Message}. Falling back to emscripten WebGL."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[Startup] EGL initialization failed: {ex}"); + } + } - if (!EGL.MakeCurrent(display, surface, surface, context)) - throw new Exception("MakeCurrent() failed"); + if (!eglActive) + { + try + { + var context = WebGlStartup.EnsureContext(Console.WriteLine); + Console.WriteLine($"[Startup] WebGL context 0x{context.ToInt64():X} ready via emscripten"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[Startup] WebGL fallback failed: {ex}"); + throw; + } + } //_ = EGL.DestroyContext(display, context); //_ = EGL.DestroySurface(display, surface); @@ -124,8 +135,6 @@ public static void Main(string[] args) TrampolineFuncs.ApplyWorkaroundFixingInvocations(); var gl = GL.GetApi(EGL.GetProcAddress); - Interop.Initialize(); - Game = Game.Create(gl); diff --git a/SnakeWebGL/Properties/AssemblyInfo.cs b/SnakeWebGL/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..2110fcb --- /dev/null +++ b/SnakeWebGL/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SnakeWebGL.Tests")] diff --git a/SnakeWebGL/WebGlStartup.cs b/SnakeWebGL/WebGlStartup.cs new file mode 100644 index 0000000..eeacfc3 --- /dev/null +++ b/SnakeWebGL/WebGlStartup.cs @@ -0,0 +1,40 @@ +using System; + +namespace SnakeWebGL; + +internal static class WebGlStartup +{ + public static IntPtr EnsureContext(Action log) + { + log("[WebGL] Creating context via emscripten"); + + Emscripten.WebGlInitContextAttributes(out var attributes); + attributes.alpha = true; + attributes.depth = true; + attributes.stencil = false; + attributes.antialias = true; + attributes.majorVersion = 2; + attributes.minorVersion = 0; + attributes.enableExtensionsByDefault = true; + + var context = Emscripten.WebGlCreateContext("#canvas", ref attributes); + log($"[WebGL] Created context handle=0x{context.ToInt64():X} with attributes {attributes}"); + + if (context == IntPtr.Zero) + { + throw new InvalidOperationException("emscripten_webgl_create_context returned null; check canvas availability and browser WebGL support."); + } + + var makeCurrentResult = Emscripten.WebGlMakeContextCurrent(context); + if (makeCurrentResult != 0) + { + log("[WebGL] Context made current successfully"); + } + else + { + throw new InvalidOperationException("emscripten_webgl_make_context_current failed to bind the WebGL context"); + } + + return context; + } +} diff --git a/SnakeWebGL/main.ts b/SnakeWebGL/main.ts index 9922b15..c0b1456 100644 --- a/SnakeWebGL/main.ts +++ b/SnakeWebGL/main.ts @@ -10,14 +10,90 @@ const config = getConfig(); const exports = await getAssemblyExports(config.mainAssemblyName); const interop = exports.SnakeWebGL.Interop; -var canvas = globalThis.document.getElementById("canvas") as HTMLCanvasElement; +function domReady(): Promise { + if (globalThis.document.readyState === "complete" || globalThis.document.readyState === "interactive") { + return Promise.resolve(); + } + + return new Promise((resolve) => { + globalThis.document.addEventListener("DOMContentLoaded", () => resolve(), { once: true }); + }); +} + +async function ensureCanvas(): Promise { + await domReady(); + + let canvas = globalThis.document.getElementById("canvas") as HTMLCanvasElement | null; + if (!canvas) { + canvas = globalThis.document.createElement("canvas") as HTMLCanvasElement; + canvas.id = "canvas"; + globalThis.document.body.appendChild(canvas); + } + + return canvas; +} + +const canvas = await ensureCanvas(); dotnet.instance.Module["canvas"] = canvas; +function resizeCanvasToDisplaySize(entry?: ResizeObserverEntry) { + let width: number; + let height: number; + let dpr = window.devicePixelRatio; + + if (entry?.devicePixelContentBoxSize) { + width = entry.devicePixelContentBoxSize[0].inlineSize; + height = entry.devicePixelContentBoxSize[0].blockSize; + dpr = 1; // already in physical pixels + } else if (entry?.contentBoxSize) { + if (Array.isArray(entry.contentBoxSize) && entry.contentBoxSize[0]) { + width = entry.contentBoxSize[0].inlineSize; + height = entry.contentBoxSize[0].blockSize; + } else { + // Firefox legacy path + // @ts-ignore + width = entry?.contentBoxSize?.inlineSize; + // @ts-ignore + height = entry?.contentBoxSize?.blockSize; + } + } else if (entry?.contentRect) { + width = entry.contentRect.width; + height = entry.contentRect.height; + } else { + // Fallback to current CSS size if no observer entry is provided + const rect = canvas.getBoundingClientRect(); + width = rect.width; + height = rect.height; + } + + if (!width || !height) { + width = globalThis.innerWidth; + height = globalThis.innerHeight; + } + + const displayWidth = Math.round(width * dpr); + const displayHeight = Math.round(height * dpr); + + if (canvas.width !== displayWidth || canvas.height !== displayHeight) { + canvas.width = displayWidth; + canvas.height = displayHeight; + } + + interop?.OnCanvasResize(canvas.width, canvas.height); +} + +resizeCanvasToDisplaySize(); + const keyBoard: { [key: string]: any } = { prevKeys: {}, currKeys: {} } +const mouse = { + x: 0, + y: 0 +} + const resizeObserver = new ResizeObserver(onResize); try { // only call us of the number of device pixels changed @@ -28,21 +104,58 @@ try { } setModuleImports("main.js", { + ensureCanvasReady: () => { + resizeCanvasToDisplaySize(); + const ready = Boolean(canvas && canvas.width > 0 && canvas.height > 0); + console.log(`[SnakeWebGL] ensureCanvasReady ready=${ready} size=${canvas.width}x${canvas.height} dpr=${window.devicePixelRatio}`); + return ready; + }, + initialize: () => { function step() { requestAnimationFrame(step); // The callback only called after this method returns. } var keyDown = (e: KeyboardEvent) => { - keyBoard.currKeys[e.code] = false; + keyBoard.currKeys[e.code] = true; }; var keyUp = (e: KeyboardEvent) => { - keyBoard.currKeys[e.code] = true; + keyBoard.currKeys[e.code] = false; + }; + + var mouseMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + mouse.x = (e.clientX - rect.left) * dpr; + mouse.y = (e.clientY - rect.top) * dpr; + interop?.OnMouseMove(mouse.x, mouse.y); + }; + + var mouseDown = (e: MouseEvent) => { + keyBoard.currKeys[`Mouse${e.button}`] = true; + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const x = (e.clientX - rect.left) * dpr; + const y = (e.clientY - rect.top) * dpr; + interop?.OnMouseDown(e.shiftKey, e.ctrlKey, e.altKey, e.button, x, y); + }; + + var mouseUp = (e: MouseEvent) => { + keyBoard.currKeys[`Mouse${e.button}`] = false; + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const x = (e.clientX - rect.left) * dpr; + const y = (e.clientY - rect.top) * dpr; + interop?.OnMouseUp(e.shiftKey, e.ctrlKey, e.altKey, e.button, x, y); }; canvas.addEventListener("keydown", keyDown, false); canvas.addEventListener("keyup", keyUp, false); + canvas.addEventListener("mousemove", mouseMove, false); + canvas.addEventListener("mousedown", mouseDown, false); + canvas.addEventListener("mouseup", mouseUp, false); + console.log("[SnakeWebGL] Input listeners attached"); step(); }, @@ -51,7 +164,9 @@ setModuleImports("main.js", { }, isKeyPressed: (key: string) => { - return !keyBoard.currKeys[key] && keyBoard.prevKeys[key]; + const current = Boolean(keyBoard.currKeys[key]); + const previous = Boolean(keyBoard.prevKeys[key]); + return current && !previous; } }); @@ -60,33 +175,6 @@ await dotnet.run(); function onResize(entries: ResizeObserverEntry[]) { for (const entry of entries) { - let width; - let height; - let dpr = window.devicePixelRatio; - if (entry.devicePixelContentBoxSize) { - // NOTE: Only this path gives the correct answer - // The other paths are imperfect fallbacks - // for browsers that don't provide anyway to do this - width = entry.devicePixelContentBoxSize[0].inlineSize; - height = entry.devicePixelContentBoxSize[0].blockSize; - dpr = 1; // it's already in width and height - } else if (entry.contentBoxSize) { - if (entry.contentBoxSize[0]) { - width = entry.contentBoxSize[0].inlineSize; - height = entry.contentBoxSize[0].blockSize; - } else { - // but old versions of Firefox treat it as a single item - // @ts-ignore - width = entry.contentBoxSize?.inlineSize; - // @ts-ignore - height = entry.contentBoxSize?.blockSize; - } - } else { - width = entry.contentRect.width; - height = entry.contentRect.height; - } - const displayWidth = Math.round(width * dpr); - const displayHeight = Math.round(height * dpr); - interop?.OnCanvasResize(canvas.width, canvas.height); + resizeCanvasToDisplaySize(entry); } }