From 6b8c1710376d1ae7e8875c7c852cb7227f81a9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Tue, 25 Nov 2025 23:32:15 +0100 Subject: [PATCH 01/12] Add clickable start button to WebGL menu --- SnakeWebGL/Game.cs | 70 +++++++++++++++++++++++++++++++++++++++++-- SnakeWebGL/Interop.cs | 8 +++-- SnakeWebGL/Program.cs | 23 +++++++++++++- SnakeWebGL/main.ts | 34 +++++++++++++++++++++ 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/SnakeWebGL/Game.cs b/SnakeWebGL/Game.cs index 769b484..5cce701 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(worldPos)) + { + 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..f7844dd 100644 --- a/SnakeWebGL/Interop.cs +++ b/SnakeWebGL/Interop.cs @@ -19,17 +19,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..e93c071 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,6 +68,22 @@ 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!"); diff --git a/SnakeWebGL/main.ts b/SnakeWebGL/main.ts index 9922b15..7188158 100644 --- a/SnakeWebGL/main.ts +++ b/SnakeWebGL/main.ts @@ -18,6 +18,11 @@ const keyBoard: { [key: string]: any } = { currKeys: {} } +const mouse = { + x: 0, + y: 0 +} + const resizeObserver = new ResizeObserver(onResize); try { // only call us of the number of device pixels changed @@ -41,8 +46,37 @@ setModuleImports("main.js", { keyBoard.currKeys[e.code] = true; }; + 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}`] = 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?.OnMouseDown(e.shiftKey, e.ctrlKey, e.altKey, e.button, x, y); + }; + + var mouseUp = (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?.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); step(); }, From 59a8e1bc9f4ef9cbc2fd254d4d5d01e2d62638d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Tue, 25 Nov 2025 23:36:07 +0100 Subject: [PATCH 02/12] Fix start button hit test type --- SnakeWebGL/Game.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SnakeWebGL/Game.cs b/SnakeWebGL/Game.cs index 5cce701..fe24ef7 100644 --- a/SnakeWebGL/Game.cs +++ b/SnakeWebGL/Game.cs @@ -147,7 +147,7 @@ public void HandlePointerDown(Vector2 screenPosition, int button) return; var worldPos = _renderer.ScreenToWorld(screenPosition); - if (_startButtonBounds.Contains(worldPos)) + if (_startButtonBounds.Contains(new PointF(worldPos.X, worldPos.Y))) { StartGame(); } From 5d702964c19c7c4354076c706ae849d843d2228e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Tue, 25 Nov 2025 23:42:36 +0100 Subject: [PATCH 03/12] Initialize interop before creating EGL display --- SnakeWebGL/Program.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/SnakeWebGL/Program.cs b/SnakeWebGL/Program.cs index e93c071..d9d1b08 100644 --- a/SnakeWebGL/Program.cs +++ b/SnakeWebGL/Program.cs @@ -88,9 +88,12 @@ public static void Main(string[] args) { Console.WriteLine($"Hello from dotnet 9!"); + // Ensure the JS side has already hooked up the canvas and input handlers before creating the GL context. + Interop.Initialize(); + var display = EGL.GetDisplay(IntPtr.Zero); if (display == IntPtr.Zero) - throw new Exception("Display was null"); + throw new Exception("Display was null; ensure the canvas element is available and WebGL is enabled."); if (!EGL.Initialize(display, out int major, out int minor)) throw new Exception("Initialize() returned false."); @@ -145,8 +148,6 @@ public static void Main(string[] args) TrampolineFuncs.ApplyWorkaroundFixingInvocations(); var gl = GL.GetApi(EGL.GetProcAddress); - Interop.Initialize(); - Game = Game.Create(gl); From e953469296cde96514c587ba29f5fda1e67889c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Tue, 25 Nov 2025 23:45:53 +0100 Subject: [PATCH 04/12] Ensure canvas ready before GL setup --- SnakeWebGL/main.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/SnakeWebGL/main.ts b/SnakeWebGL/main.ts index 7188158..dead27b 100644 --- a/SnakeWebGL/main.ts +++ b/SnakeWebGL/main.ts @@ -10,8 +10,32 @@ 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; +interop?.OnCanvasResize(canvas.width, canvas.height); const keyBoard: { [key: string]: any } = { prevKeys: {}, From 3ba47f6efa18a11fc8c3e11269e0cd8f7952ee57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Tue, 25 Nov 2025 23:49:26 +0100 Subject: [PATCH 05/12] Ensure canvas sizes reflect device pixels --- SnakeWebGL/main.ts | 78 +++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/SnakeWebGL/main.ts b/SnakeWebGL/main.ts index dead27b..018bb84 100644 --- a/SnakeWebGL/main.ts +++ b/SnakeWebGL/main.ts @@ -35,7 +35,54 @@ async function ensureCanvas(): Promise { const canvas = await ensureCanvas(); dotnet.instance.Module["canvas"] = canvas; -interop?.OnCanvasResize(canvas.width, canvas.height); + +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: {}, @@ -118,33 +165,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); } } From c595fd9f3a88b068bb81a2988cce2d65837082e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Tue, 25 Nov 2025 23:53:57 +0100 Subject: [PATCH 06/12] Ensure canvas ready before creating EGL display --- SnakeWebGL/Interop.cs | 3 +++ SnakeWebGL/Program.cs | 1 + SnakeWebGL/main.ts | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/SnakeWebGL/Interop.cs b/SnakeWebGL/Interop.cs index f7844dd..d49818c 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 void EnsureCanvasReady(); + [JSImport("isKeyPressed", "main.js")] public static partial bool IsKeyPressed(string code); diff --git a/SnakeWebGL/Program.cs b/SnakeWebGL/Program.cs index d9d1b08..927481f 100644 --- a/SnakeWebGL/Program.cs +++ b/SnakeWebGL/Program.cs @@ -90,6 +90,7 @@ public static void Main(string[] args) // Ensure the JS side has already hooked up the canvas and input handlers before creating the GL context. Interop.Initialize(); + Interop.EnsureCanvasReady(); var display = EGL.GetDisplay(IntPtr.Zero); if (display == IntPtr.Zero) diff --git a/SnakeWebGL/main.ts b/SnakeWebGL/main.ts index 018bb84..bb30767 100644 --- a/SnakeWebGL/main.ts +++ b/SnakeWebGL/main.ts @@ -104,6 +104,10 @@ try { } setModuleImports("main.js", { + ensureCanvasReady: () => { + resizeCanvasToDisplaySize(); + }, + initialize: () => { function step() { requestAnimationFrame(step); // The callback only called after this method returns. From 4d8f4448667573983091a32ffb107422d8b2026a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Tue, 25 Nov 2025 23:58:05 +0100 Subject: [PATCH 07/12] Add fallback EGL config selection --- SnakeWebGL/Program.cs | 46 +++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/SnakeWebGL/Program.cs b/SnakeWebGL/Program.cs index 927481f..2a15005 100644 --- a/SnakeWebGL/Program.cs +++ b/SnakeWebGL/Program.cs @@ -97,31 +97,47 @@ public static void Main(string[] args) throw new Exception("Display was null; ensure the canvas element is available and WebGL is enabled."); if (!EGL.Initialize(display, out int major, out int minor)) - throw new Exception("Initialize() returned false."); + throw new Exception($"Initialize() returned false (error: 0x{EGL.GetError():X})."); - int[] attributeList = new int[] + int[] attributeListMsaa = 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 + }; + + int[] attributeListNoMsaa = new int[] + { + EGL.EGL_RED_SIZE , 8, + EGL.EGL_GREEN_SIZE, 8, + EGL.EGL_BLUE_SIZE , 8, 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"); + + static bool TryChooseConfig(IntPtr displayHandle, int[] attributes, ref IntPtr chosenConfig, ref IntPtr availableConfig) + { + return EGL.ChooseConfig(displayHandle, attributes, ref chosenConfig, (IntPtr)1, ref availableConfig) && availableConfig != IntPtr.Zero; + } + + if (!TryChooseConfig(display, attributeListMsaa, ref config, ref numConfig)) + { + Console.WriteLine("Falling back to a non-MSAA EGL config."); + config = IntPtr.Zero; + numConfig = IntPtr.Zero; + + if (!TryChooseConfig(display, attributeListNoMsaa, ref config, ref numConfig)) + { + throw new Exception($"ChooseConfig() failed (error: 0x{EGL.GetError():X})."); + } + } if (!EGL.BindApi(EGL.EGL_OPENGL_ES_API)) - throw new Exception("BindApi() failed"); + throw new Exception($"BindApi() failed (error: 0x{EGL.GetError():X})."); // No other attribute is supported... int[] ctxAttribs = new int[] @@ -132,15 +148,15 @@ public static void Main(string[] args) var context = EGL.CreateContext(display, config, (IntPtr)EGL.EGL_NO_CONTEXT, ctxAttribs); if (context == IntPtr.Zero) - throw new Exception("CreateContext() failed"); + throw new Exception($"CreateContext() failed (error: 0x{EGL.GetError():X})."); // now create the surface var surface = EGL.CreateWindowSurface(display, config, IntPtr.Zero, IntPtr.Zero); if (surface == IntPtr.Zero) - throw new Exception("CreateWindowSurface() failed"); + throw new Exception($"CreateWindowSurface() failed (error: 0x{EGL.GetError():X})."); if (!EGL.MakeCurrent(display, surface, surface, context)) - throw new Exception("MakeCurrent() failed"); + throw new Exception($"MakeCurrent() failed (error: 0x{EGL.GetError():X})."); //_ = EGL.DestroyContext(display, context); //_ = EGL.DestroySurface(display, surface); From 36f7dc51ad3042b61b2326915516ed5f8c565365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Wed, 26 Nov 2025 00:03:42 +0100 Subject: [PATCH 08/12] Improve EGL diagnostics and add tests --- SnakeGame.sln | 24 +-- SnakeWebGL.Tests/EglStartupTests.cs | 96 ++++++++++++ SnakeWebGL.Tests/SnakeWebGL.Tests.csproj | 16 ++ SnakeWebGL/EglStartup.cs | 177 +++++++++++++++++++++++ SnakeWebGL/Interop.cs | 2 +- SnakeWebGL/Program.cs | 71 +-------- SnakeWebGL/Properties/AssemblyInfo.cs | 3 + SnakeWebGL/main.ts | 4 + 8 files changed, 317 insertions(+), 76 deletions(-) create mode 100644 SnakeWebGL.Tests/EglStartupTests.cs create mode 100644 SnakeWebGL.Tests/SnakeWebGL.Tests.csproj create mode 100644 SnakeWebGL/EglStartup.cs create mode 100644 SnakeWebGL/Properties/AssemblyInfo.cs 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..6ff4c22 --- /dev/null +++ b/SnakeWebGL.Tests/EglStartupTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +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; +} + +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); + } +} 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..17fe2f0 --- /dev/null +++ b/SnakeWebGL/EglStartup.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +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 sealed class EglStartup +{ + private readonly IEglApi _egl; + private readonly Action _log; + + 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) + { + _egl = eglApi ?? throw new ArgumentNullException(nameof(eglApi)); + _log = log ?? (_ => { }); + } + + 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[..^2] + "]"; + + return result; + } +} diff --git a/SnakeWebGL/Interop.cs b/SnakeWebGL/Interop.cs index d49818c..78ad8bb 100644 --- a/SnakeWebGL/Interop.cs +++ b/SnakeWebGL/Interop.cs @@ -11,7 +11,7 @@ internal static partial class Interop public static partial void Initialize(); [JSImport("ensureCanvasReady", "main.js")] - public static partial void EnsureCanvasReady(); + public static partial bool EnsureCanvasReady(); [JSImport("isKeyPressed", "main.js")] public static partial bool IsKeyPressed(string code); diff --git a/SnakeWebGL/Program.cs b/SnakeWebGL/Program.cs index 2a15005..6a27eae 100644 --- a/SnakeWebGL/Program.cs +++ b/SnakeWebGL/Program.cs @@ -90,73 +90,12 @@ public static void Main(string[] args) // Ensure the JS side has already hooked up the canvas and input handlers before creating the GL context. Interop.Initialize(); - Interop.EnsureCanvasReady(); + var canvasReady = Interop.EnsureCanvasReady(); + Console.WriteLine($"[Startup] Canvas ready: {canvasReady}"); - var display = EGL.GetDisplay(IntPtr.Zero); - if (display == IntPtr.Zero) - throw new Exception("Display was null; ensure the canvas element is available and WebGL is enabled."); - - if (!EGL.Initialize(display, out int major, out int minor)) - throw new Exception($"Initialize() returned false (error: 0x{EGL.GetError():X})."); - - int[] attributeListMsaa = new int[] - { - EGL.EGL_RED_SIZE , 8, - EGL.EGL_GREEN_SIZE, 8, - EGL.EGL_BLUE_SIZE , 8, - EGL.EGL_SAMPLES, 16, //MSAA, 16 samples - EGL.EGL_NONE - }; - - int[] attributeListNoMsaa = new int[] - { - EGL.EGL_RED_SIZE , 8, - EGL.EGL_GREEN_SIZE, 8, - EGL.EGL_BLUE_SIZE , 8, - EGL.EGL_NONE - }; - - var config = IntPtr.Zero; - var numConfig = IntPtr.Zero; - - static bool TryChooseConfig(IntPtr displayHandle, int[] attributes, ref IntPtr chosenConfig, ref IntPtr availableConfig) - { - return EGL.ChooseConfig(displayHandle, attributes, ref chosenConfig, (IntPtr)1, ref availableConfig) && availableConfig != IntPtr.Zero; - } - - if (!TryChooseConfig(display, attributeListMsaa, ref config, ref numConfig)) - { - Console.WriteLine("Falling back to a non-MSAA EGL config."); - config = IntPtr.Zero; - numConfig = IntPtr.Zero; - - if (!TryChooseConfig(display, attributeListNoMsaa, ref config, ref numConfig)) - { - throw new Exception($"ChooseConfig() failed (error: 0x{EGL.GetError():X})."); - } - } - - if (!EGL.BindApi(EGL.EGL_OPENGL_ES_API)) - throw new Exception($"BindApi() failed (error: 0x{EGL.GetError():X})."); - - // No other attribute is supported... - int[] ctxAttribs = new int[] - { - 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 (error: 0x{EGL.GetError():X})."); - - // now create the surface - var surface = EGL.CreateWindowSurface(display, config, IntPtr.Zero, IntPtr.Zero); - if (surface == IntPtr.Zero) - throw new Exception($"CreateWindowSurface() failed (error: 0x{EGL.GetError():X})."); - - if (!EGL.MakeCurrent(display, surface, surface, context)) - throw new Exception($"MakeCurrent() failed (error: 0x{EGL.GetError():X})."); + var eglStartup = new EglStartup(new EglApi(), Console.WriteLine); + var eglHandles = eglStartup.Initialize(); + 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})"); //_ = EGL.DestroyContext(display, context); //_ = EGL.DestroySurface(display, surface); 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/main.ts b/SnakeWebGL/main.ts index bb30767..7945304 100644 --- a/SnakeWebGL/main.ts +++ b/SnakeWebGL/main.ts @@ -106,6 +106,9 @@ 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: () => { @@ -152,6 +155,7 @@ setModuleImports("main.js", { canvas.addEventListener("mousemove", mouseMove, false); canvas.addEventListener("mousedown", mouseDown, false); canvas.addEventListener("mouseup", mouseUp, false); + console.log("[SnakeWebGL] Input listeners attached"); step(); }, From 64c1257eb8760ef936b9a8489ef75833b6cb85de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Wed, 26 Nov 2025 00:19:53 +0100 Subject: [PATCH 09/12] Fix input handling and tighten EGL diagnostics --- SnakeWebGL.Tests/EglStartupTests.cs | 14 ++++++++++++++ SnakeWebGL/EglStartup.cs | 6 +++--- SnakeWebGL/Program.cs | 12 +++++++++++- SnakeWebGL/main.ts | 12 +++++++----- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/SnakeWebGL.Tests/EglStartupTests.cs b/SnakeWebGL.Tests/EglStartupTests.cs index 6ff4c22..f08b8a0 100644 --- a/SnakeWebGL.Tests/EglStartupTests.cs +++ b/SnakeWebGL.Tests/EglStartupTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reflection; using Xunit; namespace SnakeWebGL.Tests; @@ -93,4 +94,17 @@ public void SurfaceErrorsBubbleWithErrorCode() 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 + } } diff --git a/SnakeWebGL/EglStartup.cs b/SnakeWebGL/EglStartup.cs index 17fe2f0..acd1e60 100644 --- a/SnakeWebGL/EglStartup.cs +++ b/SnakeWebGL/EglStartup.cs @@ -166,11 +166,11 @@ private static string DescribeAttributes(IReadOnlyList attributes) writer.AppendLiteral("]"); var result = writer.ToStringAndClear(); - if (result == "["]) + if (result == "[") return "[]"; - if (result.EndsWith(", ")) - result = result[..^2] + "]"; + if (result.EndsWith(", ]")) + result = result[..^3] + "]"; return result; } diff --git a/SnakeWebGL/Program.cs b/SnakeWebGL/Program.cs index 6a27eae..0b48fb4 100644 --- a/SnakeWebGL/Program.cs +++ b/SnakeWebGL/Program.cs @@ -94,7 +94,17 @@ public static void Main(string[] args) Console.WriteLine($"[Startup] Canvas ready: {canvasReady}"); var eglStartup = new EglStartup(new EglApi(), Console.WriteLine); - var eglHandles = eglStartup.Initialize(); + EglHandles eglHandles; + try + { + eglHandles = eglStartup.Initialize(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[Startup] EGL initialization failed: {ex}"); + throw; + } + 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})"); //_ = EGL.DestroyContext(display, context); diff --git a/SnakeWebGL/main.ts b/SnakeWebGL/main.ts index 7945304..c0b1456 100644 --- a/SnakeWebGL/main.ts +++ b/SnakeWebGL/main.ts @@ -117,11 +117,11 @@ setModuleImports("main.js", { } 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) => { @@ -133,7 +133,7 @@ setModuleImports("main.js", { }; var mouseDown = (e: MouseEvent) => { - keyBoard.currKeys[`Mouse${e.button}`] = false; + keyBoard.currKeys[`Mouse${e.button}`] = true; const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const x = (e.clientX - rect.left) * dpr; @@ -142,7 +142,7 @@ setModuleImports("main.js", { }; var mouseUp = (e: MouseEvent) => { - keyBoard.currKeys[`Mouse${e.button}`] = true; + keyBoard.currKeys[`Mouse${e.button}`] = false; const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const x = (e.clientX - rect.left) * dpr; @@ -164,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; } }); From 7534fecf1bed9a701e02900d063b589161e82a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Wed, 26 Nov 2025 00:23:51 +0100 Subject: [PATCH 10/12] Add WebGL fallback when EGL library missing --- SnakeWebGL.Tests/EglStartupTests.cs | 16 ++++++++++++ SnakeWebGL/EglStartup.cs | 26 ++++++++++++++++++- SnakeWebGL/Emscripten.cs | 30 +++++++++++++++------- SnakeWebGL/Program.cs | 39 +++++++++++++++++++++------- SnakeWebGL/WebGlStartup.cs | 40 +++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 SnakeWebGL/WebGlStartup.cs diff --git a/SnakeWebGL.Tests/EglStartupTests.cs b/SnakeWebGL.Tests/EglStartupTests.cs index f08b8a0..5827348 100644 --- a/SnakeWebGL.Tests/EglStartupTests.cs +++ b/SnakeWebGL.Tests/EglStartupTests.cs @@ -59,6 +59,13 @@ public bool ChooseConfig(IntPtr display, int[] attributeList, ref IntPtr config, public int GetError() => ErrorCode; } +internal sealed class FakeLibraryLoader : INativeLibraryLoader +{ + public bool Result { get; set; } + + public bool TryLoad(string libraryName) => Result; +} + public class EglStartupTests { [Fact] @@ -107,4 +114,13 @@ public void DescribeAttributesTrimsTrailingComma() 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/EglStartup.cs b/SnakeWebGL/EglStartup.cs index acd1e60..32ef542 100644 --- a/SnakeWebGL/EglStartup.cs +++ b/SnakeWebGL/EglStartup.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; namespace SnakeWebGL; @@ -41,10 +42,21 @@ public bool MakeCurrent(IntPtr display, IntPtr draw, IntPtr read, IntPtr context 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 = { @@ -69,10 +81,22 @@ internal sealed class EglStartup EGL.EGL_NONE }; - public EglStartup(IEglApi eglApi, Action? log) + 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() diff --git a/SnakeWebGL/Emscripten.cs b/SnakeWebGL/Emscripten.cs index b76e1c5..dcf5ff3 100644 --- a/SnakeWebGL/Emscripten.cs +++ b/SnakeWebGL/Emscripten.cs @@ -3,17 +3,29 @@ 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 + { + [DllImport("emscripten", EntryPoint = "emscripten_request_animation_frame_loop")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe void RequestAnimationFrameLoop(void* f, nint userDataPtr); - // emscripten_webgl_create_context + [DllImport("emscripten", EntryPoint = "emscripten_webgl_init_context_attributes")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe void WebGlInitContextAttributes(out EmscriptenWebGLContextAttributes attributes); - [DllImport("emscripten", EntryPoint = "emscripten_webgl_get_current_context")] - [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] - internal static extern unsafe IntPtr WebGlGetCurrentContext(); + [DllImport("emscripten", EntryPoint = "emscripten_webgl_create_context")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe IntPtr WebGlCreateContext(string target, ref EmscriptenWebGLContextAttributes attribs); + + [DllImport("emscripten", EntryPoint = "emscripten_webgl_make_context_current")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe int WebGlMakeContextCurrent(IntPtr context); + + // emscripten_webgl_create_context + + [DllImport("emscripten", EntryPoint = "emscripten_webgl_get_current_context")] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static extern unsafe IntPtr WebGlGetCurrentContext(); [DllImport("emscripten", EntryPoint = "emscripten_webgl_get_context_attributes")] [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] diff --git a/SnakeWebGL/Program.cs b/SnakeWebGL/Program.cs index 0b48fb4..f4396f2 100644 --- a/SnakeWebGL/Program.cs +++ b/SnakeWebGL/Program.cs @@ -94,26 +94,47 @@ public static void Main(string[] args) Console.WriteLine($"[Startup] Canvas ready: {canvasReady}"); var eglStartup = new EglStartup(new EglApi(), Console.WriteLine); - EglHandles eglHandles; - try + bool eglActive = false; + + if (eglStartup.IsSupported()) { - eglHandles = eglStartup.Initialize(); + 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}"); + } } - catch (Exception ex) + + if (!eglActive) { - Console.Error.WriteLine($"[Startup] EGL initialization failed: {ex}"); - throw; + 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; + } } - 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})"); - //_ = EGL.DestroyContext(display, context); //_ = EGL.DestroySurface(display, surface); //_ = EGL.Terminate(display); TrampolineFuncs.ApplyWorkaroundFixingInvocations(); - var gl = GL.GetApi(EGL.GetProcAddress); + var gl = eglActive ? GL.GetApi(EGL.GetProcAddress) : GL.GetApi(); Game = Game.Create(gl); 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; + } +} From 7641628a662796cb2353ad226158d881f5fd27a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Wed, 26 Nov 2025 00:26:12 +0100 Subject: [PATCH 11/12] Fix GL loader invocation --- SnakeWebGL/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SnakeWebGL/Program.cs b/SnakeWebGL/Program.cs index f4396f2..e9fe75a 100644 --- a/SnakeWebGL/Program.cs +++ b/SnakeWebGL/Program.cs @@ -134,7 +134,7 @@ public static void Main(string[] args) TrampolineFuncs.ApplyWorkaroundFixingInvocations(); - var gl = eglActive ? GL.GetApi(EGL.GetProcAddress) : GL.GetApi(); + var gl = GL.GetApi(EGL.GetProcAddress); Game = Game.Create(gl); From 86e39ab6debebad2851edd494c837dd817dd8305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20F=C3=A9nyes?= Date: Wed, 26 Nov 2025 00:36:55 +0100 Subject: [PATCH 12/12] Use internal emscripten library for WebGL P/Invoke --- SnakeWebGL/Emscripten.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/SnakeWebGL/Emscripten.cs b/SnakeWebGL/Emscripten.cs index dcf5ff3..983267e 100644 --- a/SnakeWebGL/Emscripten.cs +++ b/SnakeWebGL/Emscripten.cs @@ -5,35 +5,37 @@ namespace SnakeWebGL; internal static class Emscripten { - [DllImport("emscripten", EntryPoint = "emscripten_request_animation_frame_loop")] + private const string LibraryName = "__Internal"; + + [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_init_context_attributes")] + [DllImport(LibraryName, EntryPoint = "emscripten_webgl_init_context_attributes")] [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] internal static extern unsafe void WebGlInitContextAttributes(out EmscriptenWebGLContextAttributes attributes); - [DllImport("emscripten", EntryPoint = "emscripten_webgl_create_context")] + [DllImport(LibraryName, EntryPoint = "emscripten_webgl_create_context")] [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] internal static extern unsafe IntPtr WebGlCreateContext(string target, ref EmscriptenWebGLContextAttributes attribs); - [DllImport("emscripten", EntryPoint = "emscripten_webgl_make_context_current")] + [DllImport(LibraryName, EntryPoint = "emscripten_webgl_make_context_current")] [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] internal static extern unsafe int WebGlMakeContextCurrent(IntPtr context); // emscripten_webgl_create_context - [DllImport("emscripten", EntryPoint = "emscripten_webgl_get_current_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);