From e36f79cf3dc7fb52a798b7945027e773a4695963 Mon Sep 17 00:00:00 2001 From: Daniel Machado <202309624@post.au.dk> Date: Wed, 8 Jul 2026 21:24:29 +0200 Subject: [PATCH 1/3] Reconcile web export onto main (lobby + menu restructure + swept collision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild p8/web-export-refresh cleanly on top of origin/main (e138c1e) so the WASM build carries the modern multiplayer lobby (#245/#246), the slimmed title menu / in-lobby map picking (#248), the fixed-timestep swept-collision combat (#243), and the 2026-07-04 gameplay + leaderboard batches — none of which were on the old web branch. The prior branch had diverged with parallel refactors (a time-less KillStreakTracker, an inline sim-timestep loop, ShowEnemyNames=true) that main superseded; those are dropped. Only the genuinely web-essential layer is re-applied on top of pristine main, all GODOT_WEB-gated or additive so desktop / Android / test builds are byte-identical to main: - Build config: Program.cs (WASM entry point), TankGame.sln, and the csproj web PropertyGroup/ItemGroup (OutputType=Exe, WasmEnableThreads, GODOT_WEB define, Sentry + GoDotTest excluded on web, trimmer roots). - export_presets.cfg: the [preset.1] Web export preset. - EntityId.Next() replaces Guid.NewGuid() for entity ids + ArenaSeed — the WASM runtime's crypto RNG is unavailable, so Guid.NewGuid() returns a constant there (colliding ids break combat; a frozen ArenaSeed kills the random-map feature). - Web resource fallbacks (not #if-gated, harmless on desktop): TranslationLoader loads imported .translation resources, SfxPool loads the imported AudioStream, Bootstrap skips Sentry/the test runner on web. - fire.ogg recompressed (40 KB -> 4 KB) for the web bundle. Verified: dotnet build 0/0; pure suite Architecture 14 / Domain 57 / GameLogic 456 / Infrastructure 48; headless GoDotTest 172 — all green. Co-Authored-By: Claude Fable 5 # Conflicts: # client/export_presets.cfg --- client/Program.cs | 5 + client/TankGame.csproj | 31 ++++- client/TankGame.sln | 21 +++ client/audio/sfx/fire.ogg | Bin 40471 -> 4438 bytes client/src/GameLogic/Airstrike.cs | 2 +- client/src/GameLogic/EntityId.cs | 31 +++++ client/src/GameLogic/NetProjectile.cs | 2 +- client/src/GameLogic/NetTank.cs | 2 +- client/src/GameLogic/Powerup.cs | 2 +- client/src/GameLogic/Projectile.cs | 2 +- client/src/GameLogic/Tank.cs | 2 +- .../src/Infrastructure/TranslationLoader.cs | 19 ++- client/src/Presentation/Arena/SfxPool.cs | 12 +- client/src/Presentation/Bootstrap.cs | 12 +- client/src/Presentation/GameMode.cs | 2 +- docs/web-export.md | 126 ++++++++++++++++++ 16 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 client/Program.cs create mode 100644 client/TankGame.sln create mode 100644 client/src/GameLogic/EntityId.cs create mode 100644 docs/web-export.md diff --git a/client/Program.cs b/client/Program.cs new file mode 100644 index 0000000..8f6aca0 --- /dev/null +++ b/client/Program.cs @@ -0,0 +1,5 @@ +// Entry point required only by the experimental .NET web (WASM) export: the static-linked mono +// runtime needs an assembly entry point. An empty top-level statement is sufficient — the game is +// still hosted by the Godot engine, not by this Main. Compiled only in Export* configs (see +// TankGame.csproj), so desktop/Android/test builds are unaffected. +{ } diff --git a/client/TankGame.csproj b/client/TankGame.csproj index 7875996..2c42ea9 100644 --- a/client/TankGame.csproj +++ b/client/TankGame.csproj @@ -18,16 +18,43 @@ - + - + + + + Exe + + true + + $(DefineConstants);GODOT_WEB + + + + + + + + + + + + + diff --git a/client/TankGame.sln b/client/TankGame.sln new file mode 100644 index 0000000..1f97c64 --- /dev/null +++ b/client/TankGame.sln @@ -0,0 +1,21 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TankGame", "TankGame.csproj", "{A3B5C7D9-1234-5678-9ABC-DEF012345678}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + ExportDebug|Any CPU = ExportDebug|Any CPU + ExportRelease|Any CPU = ExportRelease|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A3B5C7D9-1234-5678-9ABC-DEF012345678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3B5C7D9-1234-5678-9ABC-DEF012345678}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3B5C7D9-1234-5678-9ABC-DEF012345678}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU + {A3B5C7D9-1234-5678-9ABC-DEF012345678}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU + {A3B5C7D9-1234-5678-9ABC-DEF012345678}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU + {A3B5C7D9-1234-5678-9ABC-DEF012345678}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU + EndGlobalSection +EndGlobal diff --git a/client/audio/sfx/fire.ogg b/client/audio/sfx/fire.ogg index 7952865cb9205b10fb9ed32096eb8a799e44cf98..8fb91179ac5539c4e078f4c79300b7d70f36a068 100644 GIT binary patch delta 553 zcmbQfhv}Mo#AgKZf2!_j*%%_oU(fPP6`~X* z1Jb73$MJ((+SN!`Pg7k@Q%UJh=HcMSyADh*68e=fqiHuUBg5tG-~XFmPmX14;f!Pd}onZE#(9@?#8PpZ=NT){dxP4CpLZWoYphD`S99pt7xhUfc z`<3_qFB$^v1A&)QlH1?!d;KT#F4Mo%KjABvW(d7F#Q2SsVF6g$pkg5hgUfI0oZAmd z`uu8{86;Tb(*OLg-Iydi|G^XnMR~9)28JD0XZaWs%Ch$#_7IQfV(7U2BmV?Q;rF05 zYQH$ud0+7Vbw6(mRa{x7kpC#Ig@r*b?a%W_FRzjZDSw*(73`i3mA~V&|EophG3Pcb F1^|KV=}7

dSE5IYj$$$R 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 Tcz@=`)YKG!^w%8<>?!~bME#Dv diff --git a/client/src/GameLogic/Airstrike.cs b/client/src/GameLogic/Airstrike.cs index f2071c3..dd0e874 100644 --- a/client/src/GameLogic/Airstrike.cs +++ b/client/src/GameLogic/Airstrike.cs @@ -33,7 +33,7 @@ public sealed class Airstrike : IAirstrike public Airstrike(IWorld world, IReadOnlyList zones, int callerTeam, float zoneRadius, float armWindow, float delay, int damage) { - Id = Guid.NewGuid(); + Id = EntityId.Next(); _world = world; _callerTeam = callerTeam; _damage = damage; diff --git a/client/src/GameLogic/EntityId.cs b/client/src/GameLogic/EntityId.cs new file mode 100644 index 0000000..f89fc54 --- /dev/null +++ b/client/src/GameLogic/EntityId.cs @@ -0,0 +1,31 @@ +using System; + +namespace TankGame.GameLogic; + +///

Allocates unique entity ids. +/// +/// On desktop/Android this is just . On the experimental .NET WASM web +/// runtime, however, the cryptographic RNG that backs Guid.NewGuid() is unavailable (the +/// editor fork's README flags crypto APIs as non-working), so every call returns the same value. +/// That collapses every entity's identity, which silently breaks combat: CombatResolver skips +/// a tank when tank.Id == shot.Owner and the AI skips a tank when tank.Id == self.Id — +/// with all ids equal, every shot spares every target and every enemy looks like "me", so nothing +/// takes damage and the AI never fires. A monotonic counter gives guaranteed-unique ids with no +/// crypto dependency on the web build; desktop keeps the random Guid (netcode relies on it). +public static class EntityId +{ +#if GODOT_WEB + private static long _counter; +#endif + + public static Guid Next() + { +#if GODOT_WEB + var bytes = new byte[16]; + BitConverter.TryWriteBytes(bytes, System.Threading.Interlocked.Increment(ref _counter)); + return new Guid(bytes); +#else + return Guid.NewGuid(); +#endif + } +} diff --git a/client/src/GameLogic/NetProjectile.cs b/client/src/GameLogic/NetProjectile.cs index 168eb7d..7efec17 100644 --- a/client/src/GameLogic/NetProjectile.cs +++ b/client/src/GameLogic/NetProjectile.cs @@ -11,7 +11,7 @@ namespace TankGame.GameLogic; /// always true (the scene shows or hides shots by rebuilding from each snapshot, not by reaping). public sealed class NetProjectile : IProjectile { - public Guid Id { get; } = Guid.NewGuid(); + public Guid Id { get; } = EntityId.Next(); public Vector2 Position { get; set; } public Vector2 Direction { get; set; } public int Team { get; set; } diff --git a/client/src/GameLogic/NetTank.cs b/client/src/GameLogic/NetTank.cs index 416fad2..6026368 100644 --- a/client/src/GameLogic/NetTank.cs +++ b/client/src/GameLogic/NetTank.cs @@ -14,7 +14,7 @@ public sealed class NetTank : ITank { public NetTank(int maxHp = 3) => MaxHp = maxHp; - public Guid Id { get; } = Guid.NewGuid(); + public Guid Id { get; } = EntityId.Next(); public Vector2 Position { get; set; } public float Rotation { get; set; } public float TurretRotation { get; set; } diff --git a/client/src/GameLogic/Powerup.cs b/client/src/GameLogic/Powerup.cs index 904b07c..e1d3bab 100644 --- a/client/src/GameLogic/Powerup.cs +++ b/client/src/GameLogic/Powerup.cs @@ -47,7 +47,7 @@ public Powerup(IWorld world, Vector2 position, PowerupKind kind, IPickupEffect e float pickupRadius, bool dropOnCarrierDeath = false, float respawnCooldown = 0f, float despawnAfter = 0f) { - Id = Guid.NewGuid(); + Id = EntityId.Next(); _world = world; Position = position; Kind = kind; diff --git a/client/src/GameLogic/Projectile.cs b/client/src/GameLogic/Projectile.cs index 9641def..c6d7b72 100644 --- a/client/src/GameLogic/Projectile.cs +++ b/client/src/GameLogic/Projectile.cs @@ -32,7 +32,7 @@ public Projectile(IArena arena, Vector2 spawn, Vector2 direction, float speed, i int team = 0, IProjectileBehaviour? behaviour = null, int pierce = 0, Guid owner = default, ProjectileStyle style = ProjectileStyle.Normal, int layer = 0) { - Id = Guid.NewGuid(); + Id = EntityId.Next(); Layer = layer; _arena = arena; _state = new ProjectileState diff --git a/client/src/GameLogic/Tank.cs b/client/src/GameLogic/Tank.cs index 14e8c5a..e6ddd43 100644 --- a/client/src/GameLogic/Tank.cs +++ b/client/src/GameLogic/Tank.cs @@ -68,7 +68,7 @@ public Tank( string displayName = "", Func? respawnPoint = null) { - Id = Guid.NewGuid(); + Id = EntityId.Next(); MaxHp = maxHp; Hp = maxHp; Team = team; diff --git a/client/src/Infrastructure/TranslationLoader.cs b/client/src/Infrastructure/TranslationLoader.cs index 69033e1..8d75094 100644 --- a/client/src/Infrastructure/TranslationLoader.cs +++ b/client/src/Infrastructure/TranslationLoader.cs @@ -17,7 +17,9 @@ public static void EnsureLoaded() var file = FileAccess.Open(CsvPath, FileAccess.ModeFlags.Read); if (file is null) { - GD.PushError($"TranslationLoader: cannot open {CsvPath}"); + // Exported builds (e.g. web) don't ship the raw CSV — Godot imports it to + // per-locale .translation resources and only those are packed. Load them. + LoadImportedTranslations(); return; } @@ -69,6 +71,21 @@ public static void EnsureLoaded() _loaded = true; } + // The CSV header order is keys,en,es,dk; the importer emits one .translation per locale. + private static void LoadImportedTranslations() + { + foreach (var locale in new[] { "en", "es", "dk" }) + { + var translation = GD.Load($"res://i18n/strings.{locale}.translation"); + if (translation is not null) + { + TranslationServer.AddTranslation(translation); + } + } + + _loaded = true; + } + private static string[] ParseCsvLine(string line) { var cells = new System.Collections.Generic.List(); diff --git a/client/src/Presentation/Arena/SfxPool.cs b/client/src/Presentation/Arena/SfxPool.cs index c877d96..97e0732 100644 --- a/client/src/Presentation/Arena/SfxPool.cs +++ b/client/src/Presentation/Arena/SfxPool.cs @@ -96,9 +96,15 @@ public override void _Ready() private static AudioStream? LoadOgg(string resPath) { using var file = FileAccess.Open(resPath, FileAccess.ModeFlags.Read); - if (file is null) return null; - var bytes = file.GetBuffer((long)file.GetLength()); - return AudioStreamOggVorbis.LoadFromBuffer(bytes); + if (file is not null) + { + var bytes = file.GetBuffer((long)file.GetLength()); + return AudioStreamOggVorbis.LoadFromBuffer(bytes); + } + + // Exported builds (e.g. web) don't ship the raw .ogg, only the imported + // AudioStream resource — load that instead of giving up (no sound). + return GD.Load(resPath); } /// Set the SFX volume for every player in the pool (dB; 0 = full, negative = quieter). diff --git a/client/src/Presentation/Bootstrap.cs b/client/src/Presentation/Bootstrap.cs index c55ada5..3febd9d 100644 --- a/client/src/Presentation/Bootstrap.cs +++ b/client/src/Presentation/Bootstrap.cs @@ -1,6 +1,6 @@ using Godot; using TankGame.Infrastructure; -#if DEBUG +#if DEBUG && !GODOT_WEB using System.Reflection; using Chickensoft.GoDotTest; #endif @@ -14,13 +14,13 @@ namespace TankGame.Presentation; // the runner. Test wiring is DEBUG-only, so ExportRelease builds exclude it. public partial class Bootstrap : Node { -#if DEBUG +#if DEBUG && !GODOT_WEB private TestEnvironment _environment = default!; #endif public override void _Ready() { -#if DEBUG +#if DEBUG && !GODOT_WEB _environment = TestEnvironment.From(OS.GetCmdlineArgs()); if (_environment.ShouldRunTests) { @@ -29,7 +29,9 @@ public override void _Ready() } #endif // App init happens once here (the composition root), then the play scene loads. - SentryBootstrap.Init(); +#if !GODOT_WEB + SentryBootstrap.Init(); // Sentry's .NET SDK isn't WASM-compatible; web builds skip it. +#endif TranslationLoader.EnsureLoaded(); // Deferred: the scene tree is mid-add during _Ready and rejects an @@ -38,7 +40,7 @@ public override void _Ready() SceneTree.MethodName.ChangeSceneToFile, "res://src/Presentation/Title.tscn"); } -#if DEBUG +#if DEBUG && !GODOT_WEB private void RunTests() => _ = GoTest.RunTests(Assembly.GetExecutingAssembly(), this, _environment); #endif diff --git a/client/src/Presentation/GameMode.cs b/client/src/Presentation/GameMode.cs index 1e55227..b1f3c53 100644 --- a/client/src/Presentation/GameMode.cs +++ b/client/src/Presentation/GameMode.cs @@ -105,7 +105,7 @@ public static void StartNewMatch(GameMode mode) { Mode = mode; Series = new SeriesTracker(RoundsToWin); - ArenaSeed = Guid.NewGuid().GetHashCode(); + ArenaSeed = EntityId.Next().GetHashCode(); // Guid.NewGuid is constant on WASM (no crypto RNG) CustomMap = null; // a fresh match defaults to the built-in arena; My Maps sets it back after } diff --git a/docs/web-export.md b/docs/web-export.md new file mode 100644 index 0000000..e5c5d38 --- /dev/null +++ b/docs/web-export.md @@ -0,0 +1,126 @@ +# Web (WASM) export + Lundrea Arcade deploy + multiplayer plan + +Status as of 2026-06-27. This branch (`feat/web-export`) holds the **experimental Godot .NET +WebAssembly build** of TankGame and everything needed to rebuild and redeploy it. It is **not merged +to `main`** (it carries web-only changes guarded by `#if GODOT_WEB`). Single-player is **live and +playable** at (arcade home: ). + +--- + +## 1. Toolchain (what another machine needs) + +- **Custom Godot editor with C# web export** — stock Godot can't export .NET to web. Use the + ComplexRobot fork: . Download the + `*_mono_web_export_win64` release, extract it, run its `install.bat` (adds the web export + templates to `%AppData%\Godot\export_templates\4.6.2.stable.mono\` and a local NuGet source). On + the reference machine it lives at `C:\godot-web-export\Godot_v4.6.2-stable_mono_web_export_win64\`. +- **.NET `wasm-tools` workload** — `dotnet workload install wasm-tools`. **VERIFY with + `dotnet workload list`** — a prior install silently failed to take; without it the export still + produces a bundle but the managed C# is never compiled into WASM (tiny ~4 MB `.pck`, black screen). + When present, `.pck` is ~48 MB. + +## 2. Build & export + +```sh +EDITOR="C:/godot-web-export/.../Godot_v4.6.2-stable_mono_web_export_win64.exe" +# 1. Reimport ONCE after a fresh pull — regenerates .import/.uid/.translation (all gitignored): +"$EDITOR" --headless --path client --import +# 2. Export (output goes to build/web/, gitignored): +"$EDITOR" --headless --path client --export-release "Web" "$(pwd)/build/web/index.html" +``` + +Serve locally with COOP/COEP to test (the build needs cross-origin isolation). A minimal server is +in the session scratchpad (`serve_coi.py`); any server sending +`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp` works. + +## 3. Web-only code changes (all `#if GODOT_WEB`-guarded; desktop/Android untouched) + +| File | Change | Why | +|---|---|---| +| `client/TankGame.csproj` | `OutputType=Exe` + `Program.cs`, `WasmEnableThreads`, `GODOT_WEB` define, Sentry+GoDotTest excluded, `TrimmerRootAssembly` for `System.Private.CoreLib`, `System.Runtime`, **`TankGame`, `GodotSharp`** | wasm needs an entry point; trimmer would otherwise prune the whole game (the engine calls `GodotPlugins.Game.Main` natively, so it's invisible to the trimmer) | +| `client/Program.cs`, `client/TankGame.sln` | new (`{}` entry + hand-written solution) | the static-mono runtime needs a top-level entry + a solution to publish | +| `client/src/GameLogic/EntityId.cs` | crypto-free counter-based entity ids on web | **`Guid.NewGuid()` returns the SAME value every call on this runtime** (no crypto RNG) → all tanks shared one id → `tank.Id == shot.Owner` / `== self.Id` always true → no damage + AI never fires. This was *the* combat bug. | +| `Tank/Projectile/Airstrike/Powerup/NetTank/NetProjectile.cs`, `GameMode.cs` | use `EntityId.Next()` | (same) | +| `Arena3DScene.cs` | fixed-timestep accumulator (`_world.Step(1/120)` N×/frame) | low web FPS → large `delta` → projectiles tunnel past tanks; fixed step prevents it | +| `Arena3DScene.cs` | `ExitGame()` → `JavaScriptBridge.Eval("window.top.location.href='/'")` on web | `GetTree().Quit()` is a no-op in a browser; Exit returns to the arcade | +| `SfxPool.cs` | `AttenuationModel = Disabled` on the 3D pool + `GD.Load` fallback in `LoadOgg` | camera is ~2500u back → default attenuation silenced all SFX; exports ship only the *imported* `.ogg`, so raw `FileAccess` returns null | +| `TranslationLoader.cs`, `TitleScene.cs` | `GD.Load` fallback when raw `FileAccess` fails | exports drop raw source files (csv/png); load the imported resource instead | +| `GameMode.cs` | `ShowEnemyNames` default **true**; `KillStreakTracker` no longer time-windowed | owner asks: see enemy names; streak = kills since last death | +| `TitleScene.cs` | map editor + Exit gated out on web | web is play-only | +| `client/audio/sfx/fire.ogg` | short cartoon "pew", ~25% volume | replaced a harsh continuous tone (asset, ffmpeg-synthesized; local Stable Audio model was down) | +| `export_presets.cfg` | `[preset.1]` Web preset | — | + +### The 5 web-only bugs that had to be fixed (in order discovered) +1. `wasm-tools` not actually installed → managed C# never compiled → black screen. +2. Trimmer pruned the game (rooted `TankGame`/`GodotSharp`). +3. Exports drop raw `FileAccess` resources → untranslated menu, no SFX, no backdrop (GD.Load fallbacks). +4. `Guid.NewGuid()` collision → no combat damage, AI never fires (counter-based `EntityId`). +5. GitHub LFS bandwidth quota broke the CI deploy → store the bundle as **plain git blobs** (both files < 100 MB). + +## 4. Deploy to Lundrea Arcade (ProjectX repo) + +Deploy is **CI-on-push to ProjectX `main`** (GitHub Actions → `vite build` → Firebase Hosting). To +ship a new bundle: + +```sh +cp -r build/web/* C:/programmering/ProjectX/public/tank/ # overwrite the vendored bundle +# in ProjectX: branch off main, commit, PR, merge → auto-deploys +``` + +ProjectX integration (already in place on `main`): `public/tank/` bundle as **plain git blobs** +(NOT LFS — quota); `firebase.json` scoped `/tank/**` COOP/COEP headers; vite PWA +`globIgnores:['tank/**']`; `src/arcade/games.ts` `tank` entry with `external:true`; contract/e2e +suites skip `external` games; tile `public/tiles/tank.webp`. + +**Caveats:** Spark free tier ≈ 15–20 cold loads/day (≈58 MB bundle; repeat visits cached/free). +One harmless web-audio `sample_set_pause` console warning. Map editor is gated out of web. + +--- + +## 5. NEXT MILESTONE — Multiplayer (the spec + plan) + +**Owner spec (2026-06-27):** +- Title menu becomes: **Solo · Multiplayer · Settings · Back to Arcade** (Back-to-Arcade web-only). +- **Multiplayer** → choose **Team vs Team** or **FFA**. +- Either choice opens a **lobby** with **up to 8 slots** for players to join. +- Multiplayer also shows a **list of available lobbies to join** (a lobby browser). +- Any player can press **Start game** → **3 · 2 · 1 · Start** countdown → launches the arena. +- Both **FFA** and **Team vs Team** must work in the arena. + +**Backend:** Firebase Spark can't host realtime (no Cloud Functions/WebSockets). There is already a +Cloudflare Worker **`tankgame-worker.vodkadav.workers.dev`** (ADR-0019 relay) — extend it with a +**Durable Object** for lobby state + the N-player relay. The Cloudflare MCP is available in-session +for deploys. + +**Current netcode:** 2-player host/join (ADR-0019 steps 1–4: relay DO, Host/Join codes, HostSession ++ NetArena3DScene, protocol fidelity). Needs extending to **up to 8 players + FFA/Team + a lobby +browser + a synced countdown.** + +**Proposed phases:** +1. **Client menu/lobby UX** — restructure the title to Solo/Multiplayer/Settings/Back-to-Arcade; + Multiplayer → FFA/Team picker → lobby screen (8 slots, ready/start, countdown) + a lobby-browser + list. Dev against a local stub first. +2. **Backend (tankgame-worker)** — a `Lobby` Durable Object: create / list / join / leave / start; + broadcast slot changes + the start countdown; then relay game packets for N players. +3. **Netcode to N players** — extend HostSession/NetArena3DScene/snapshot codec from 2 → up to 8 + tanks; FFA (every tank its own team) vs Team (2 teams) win logic. +4. **Wire client ↔ worker** — lobby browser + join + countdown sync + launch into NetArena3DScene + with the right roster/mode. +5. **Playtest** across 2+ devices/browsers. + +**Known step-4 remnant (ADR-0019):** the guest's OWN shield/elevation isn't predicted +(`PredictedTank`/`Reconcile` doesn't track Shield/Layer). + +--- + +## 6. Resume checklist (fresh machine) + +1. Pull TankGame, `git checkout feat/web-export`; pull ProjectX `main`. +2. Install the ComplexRobot Godot web-export editor + `dotnet workload install wasm-tools` (verify with `dotnet workload list`). +3. `--import` once, then `--export-release "Web"` (section 2). +4. To deploy: copy `build/web/*` → `ProjectX/public/tank/`, then PR→main in ProjectX (section 4). +5. For multiplayer, start at section 5 phase 1. + +**Also pending (desktop/main parity):** the SFX-attenuation fix is ProjectX-independent and lives in +TankGame PR #229 (`fix/sfx-attenuation`); the pew asset, `ShowEnemyNames` default, and the +no-window `KillStreakTracker` still need porting to TankGame `main` for the desktop build. From ae891b0ccd4a8e3a730e476d3fcc58fb500cbe3a Mon Sep 17 00:00:00 2001 From: Daniel Machado <202309624@post.au.dk> Date: Sun, 12 Jul 2026 15:37:28 +0200 Subject: [PATCH 2/3] Fix deterministic web arena seed; refresh web-export doc status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EntityId's web counter restarts at 0 every page load, so seeding the arena from EntityId.Next().GetHashCode() alone rolled the identical "random" arena (layout, AI names, personalities) every web session. Mix in wall-clock ticks via HashCode.Combine — desktop stays effectively random, web now varies per session. Also update docs/web-export.md status header (branch is web-reconcile-256, reconciled onto main — no longer the unmerged feat/web-export). No regression test: the bug is cross-session determinism of a static seed rolled from a process-lifetime counter + wall clock; not observable within one in-process test run. Co-Authored-By: Claude Fable 5 --- client/src/Presentation/GameMode.cs | 4 +++- docs/web-export.md | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/client/src/Presentation/GameMode.cs b/client/src/Presentation/GameMode.cs index b1f3c53..f1ff9ce 100644 --- a/client/src/Presentation/GameMode.cs +++ b/client/src/Presentation/GameMode.cs @@ -105,7 +105,9 @@ public static void StartNewMatch(GameMode mode) { Mode = mode; Series = new SeriesTracker(RoundsToWin); - ArenaSeed = EntityId.Next().GetHashCode(); // Guid.NewGuid is constant on WASM (no crypto RNG) + // Guid.NewGuid is constant on WASM (no crypto RNG) and EntityId's web counter restarts at 0 + // every page load, so mix in wall-clock ticks or every web session rolls the same arena. + ArenaSeed = HashCode.Combine(EntityId.Next(), DateTime.UtcNow.Ticks); CustomMap = null; // a fresh match defaults to the built-in arena; My Maps sets it back after } diff --git a/docs/web-export.md b/docs/web-export.md index e5c5d38..083a600 100644 --- a/docs/web-export.md +++ b/docs/web-export.md @@ -1,9 +1,10 @@ # Web (WASM) export + Lundrea Arcade deploy + multiplayer plan -Status as of 2026-06-27. This branch (`feat/web-export`) holds the **experimental Godot .NET -WebAssembly build** of TankGame and everything needed to rebuild and redeploy it. It is **not merged -to `main`** (it carries web-only changes guarded by `#if GODOT_WEB`). Single-player is **live and -playable** at (arcade home: ). +Status as of 2026-07-12. This branch (`web-reconcile-256`, successor to `feat/web-export`) +reconciles the **experimental Godot .NET WebAssembly build** of TankGame onto `main` and holds +everything needed to rebuild and redeploy it (web-only changes guarded by `#if GODOT_WEB`). +Single-player is **live and playable** at +(arcade home: ). --- From 72a248dc73eceb471346f11a1d53e1aac16aadb7 Mon Sep 17 00:00:00 2001 From: Daniel Machado Date: Fri, 17 Jul 2026 15:43:26 +0200 Subject: [PATCH 3/3] Web parity: cover post-July entity-id call sites, re-level the pew, refresh doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finish reconciling the June/July web branches onto main (on top of the two cherry-picks e418ee0 + fc1e4e7): - NetPowerup / PowerupDirector ids go through EntityId.Next() — both were added to main after the web branch diverged and still used Guid.NewGuid(), which is constant on the WASM runtime (colliding ids). - SfxPool.FireOffsetDb −20 → −6 dB: the −20 was tuned for the old loud 40 KB fire clip; the 4.4 KB cartoon pew this branch restores is intrinsically much quieter. −6 keeps fire (the most frequent SFX) just under the other gameplay sounds; final level is owner-ear-gated. - docs/web-export.md rewritten to match main: web layer lives on main, old branches superseded list, multiplayer milestone delivered (ADR-0019/0021), canvas_resize_policy + lobby HTTP + slot carry + name labels already on main. Verified: Architecture 14 / Domain 69 / GameLogic 562 / Infrastructure 54; headless GoDotTest 195 — all green. Web export smoke-built with the ComplexRobot editor: index.wasm 53.7 MB + index.pck 51.2 MB (managed C# present). Co-Authored-By: Claude Fable 5 --- client/src/GameLogic/NetPowerup.cs | 2 +- client/src/GameLogic/PowerupDirector.cs | 2 +- client/src/Presentation/Arena/SfxPool.cs | 9 +- docs/web-export.md | 100 ++++++++--------------- 4 files changed, 42 insertions(+), 71 deletions(-) diff --git a/client/src/GameLogic/NetPowerup.cs b/client/src/GameLogic/NetPowerup.cs index ff43d40..2f4b7df 100644 --- a/client/src/GameLogic/NetPowerup.cs +++ b/client/src/GameLogic/NetPowerup.cs @@ -11,7 +11,7 @@ namespace TankGame.GameLogic; /// by diffing snapshot ids, not by reaping). public sealed class NetPowerup : IPowerup { - public Guid Id { get; } = Guid.NewGuid(); + public Guid Id { get; } = EntityId.Next(); public Vector2 Position { get; set; } public PowerupKind Kind { get; set; } public bool IsAvailable { get; set; } = true; diff --git a/client/src/GameLogic/PowerupDirector.cs b/client/src/GameLogic/PowerupDirector.cs index 383a450..8435178 100644 --- a/client/src/GameLogic/PowerupDirector.cs +++ b/client/src/GameLogic/PowerupDirector.cs @@ -74,7 +74,7 @@ public PowerupDirector(IWorld world, int seed, IReadOnlyList<(int X, int Y)> flo _untilNextSpawn = NextInterval(); } - public Guid Id { get; } = Guid.NewGuid(); + public Guid Id { get; } = EntityId.Next(); public Vector2 Position => Vector2.Zero; // bookkeeping entity — nowhere on the field public bool IsAlive => true; diff --git a/client/src/Presentation/Arena/SfxPool.cs b/client/src/Presentation/Arena/SfxPool.cs index 97e0732..af8cdc4 100644 --- a/client/src/Presentation/Arena/SfxPool.cs +++ b/client/src/Presentation/Arena/SfxPool.cs @@ -18,10 +18,11 @@ public partial class SfxPool : Node // louder but still subdued. private const float HoverOffsetDb = -16f; private const float UiClickOffsetDb = -6f; - // Cannon shots fire constantly during a fight; at full volume they drown everything else out - // (owner feedback 2026-06-30: too loud). −20 dB ≈ 10% of the previous amplitude, the level asked - // for. Applied per-shot to the Fire kind only, so explosions/pickups/voice keep their volume. - private const float FireOffsetDb = -20f; + // Cannon shots fire constantly during a fight, so Fire sits below the other gameplay SFX. + // The old loud clip needed −20 dB (owner feedback 2026-06-30); the current cartoon pew is + // intrinsically much quieter, so it only gets a small trim. Provisional pending owner's ear — + // adjust this constant alone to re-level. Applied per-shot to the Fire kind only. + private const float FireOffsetDb = -6f; private static readonly Dictionary SfxFiles = new() { diff --git a/docs/web-export.md b/docs/web-export.md index 083a600..138937d 100644 --- a/docs/web-export.md +++ b/docs/web-export.md @@ -1,10 +1,12 @@ -# Web (WASM) export + Lundrea Arcade deploy + multiplayer plan +# Web (WASM) export + Lundrea Arcade deploy -Status as of 2026-07-12. This branch (`web-reconcile-256`, successor to `feat/web-export`) -reconciles the **experimental Godot .NET WebAssembly build** of TankGame onto `main` and holds -everything needed to rebuild and redeploy it (web-only changes guarded by `#if GODOT_WEB`). -Single-player is **live and playable** at -(arcade home: ). +Status as of 2026-07-17: the web layer is **reconciled into `main`** — the WASM export builds +from `main` alone, no side branch. All web-only code is `#if GODOT_WEB`-guarded or additive, so +desktop/Android/test builds are unaffected. The live single-player build is at + (arcade home: ). + +The old branches `feat/web-export`, `web-export-deploy`, `web-reconcile`, and `web-reconcile-256` +are fully reconciled (ported or superseded) and can be deleted. --- @@ -30,34 +32,36 @@ EDITOR="C:/godot-web-export/.../Godot_v4.6.2-stable_mono_web_export_win64.exe" "$EDITOR" --headless --path client --export-release "Web" "$(pwd)/build/web/index.html" ``` -Serve locally with COOP/COEP to test (the build needs cross-origin isolation). A minimal server is -in the session scratchpad (`serve_coi.py`); any server sending +Serve locally with COOP/COEP to test (the build needs cross-origin isolation): any server sending `Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp` works. -## 3. Web-only code changes (all `#if GODOT_WEB`-guarded; desktop/Android untouched) +## 3. The web layer on `main` (what makes the WASM build work) | File | Change | Why | |---|---|---| -| `client/TankGame.csproj` | `OutputType=Exe` + `Program.cs`, `WasmEnableThreads`, `GODOT_WEB` define, Sentry+GoDotTest excluded, `TrimmerRootAssembly` for `System.Private.CoreLib`, `System.Runtime`, **`TankGame`, `GodotSharp`** | wasm needs an entry point; trimmer would otherwise prune the whole game (the engine calls `GodotPlugins.Game.Main` natively, so it's invisible to the trimmer) | -| `client/Program.cs`, `client/TankGame.sln` | new (`{}` entry + hand-written solution) | the static-mono runtime needs a top-level entry + a solution to publish | -| `client/src/GameLogic/EntityId.cs` | crypto-free counter-based entity ids on web | **`Guid.NewGuid()` returns the SAME value every call on this runtime** (no crypto RNG) → all tanks shared one id → `tank.Id == shot.Owner` / `== self.Id` always true → no damage + AI never fires. This was *the* combat bug. | -| `Tank/Projectile/Airstrike/Powerup/NetTank/NetProjectile.cs`, `GameMode.cs` | use `EntityId.Next()` | (same) | -| `Arena3DScene.cs` | fixed-timestep accumulator (`_world.Step(1/120)` N×/frame) | low web FPS → large `delta` → projectiles tunnel past tanks; fixed step prevents it | -| `Arena3DScene.cs` | `ExitGame()` → `JavaScriptBridge.Eval("window.top.location.href='/'")` on web | `GetTree().Quit()` is a no-op in a browser; Exit returns to the arcade | -| `SfxPool.cs` | `AttenuationModel = Disabled` on the 3D pool + `GD.Load` fallback in `LoadOgg` | camera is ~2500u back → default attenuation silenced all SFX; exports ship only the *imported* `.ogg`, so raw `FileAccess` returns null | -| `TranslationLoader.cs`, `TitleScene.cs` | `GD.Load` fallback when raw `FileAccess` fails | exports drop raw source files (csv/png); load the imported resource instead | -| `GameMode.cs` | `ShowEnemyNames` default **true**; `KillStreakTracker` no longer time-windowed | owner asks: see enemy names; streak = kills since last death | -| `TitleScene.cs` | map editor + Exit gated out on web | web is play-only | -| `client/audio/sfx/fire.ogg` | short cartoon "pew", ~25% volume | replaced a harsh continuous tone (asset, ffmpeg-synthesized; local Stable Audio model was down) | -| `export_presets.cfg` | `[preset.1]` Web preset | — | +| `client/TankGame.csproj` | web `PropertyGroup`: `OutputType=Exe` + `Program.cs`, `WasmEnableThreads`, `GODOT_WEB` define, Sentry+GoDotTest excluded, `TrimmerRootAssembly` for `System.Private.CoreLib`, `System.Runtime`, **`TankGame`, `GodotSharp`** | wasm needs an entry point; the trimmer would otherwise prune the whole game (the engine calls `GodotPlugins.Game.Main` natively, so it's invisible to the trimmer) | +| `client/Program.cs`, `client/TankGame.sln` | `{}` entry point + hand-written solution | the static-mono runtime needs a top-level entry + a solution to publish | +| `client/src/GameLogic/EntityId.cs` | crypto-free counter-based entity ids on web (`#if GODOT_WEB`; desktop keeps `Guid.NewGuid`) | **`Guid.NewGuid()` returns the SAME value every call on this runtime** (no crypto RNG) → all tanks shared one id → `tank.Id == shot.Owner` / `== self.Id` always true → no damage + AI never fires. This was *the* combat bug. Every entity id call site (`Tank`, `Projectile`, `Airstrike`, `Powerup`, `NetTank`, `NetProjectile`, `NetPowerup`, `PowerupDirector`) goes through `EntityId.Next()`. | +| `client/src/Presentation/GameMode.cs` | `ArenaSeed` from `HashCode.Combine(EntityId.Next(), DateTime.UtcNow.Ticks)` | `Guid.NewGuid` is constant on WASM, and EntityId's web counter restarts at 0 every page load — either alone rolls the identical "random" arena every web session | +| `client/src/Infrastructure/TranslationLoader.cs`, `SfxPool.LoadOgg` | `GD.Load` fallback when raw `FileAccess` fails | exports ship only the *imported* resources, not raw source files (csv/ogg) | +| `client/src/Presentation/Bootstrap.cs` | Sentry + test-runner skipped on web | neither works on the WASM runtime | +| `client/src/Infrastructure/PlatformExit.cs` | Exit → `JavaScriptBridge.Eval` back to the arcade on web | `GetTree().Quit()` is a no-op in a browser (button reads "Back to Arcade") | +| `client/src/Infrastructure/Net/GodotHttpLobbyClient.cs` | web lobby HTTP over Godot `HttpRequest` nodes | .NET `HttpClient` dies with an NRE in `BrowserHttpInterop` on the threaded WASM runtime; desktop keeps `HttpLobbyClient`, both parse via the shared `LobbyWire` | +| `client/export_presets.cfg` | `[preset.1]` Web preset, `canvas_resize_policy=2` (adaptive) | policy 1 locked the canvas to base resolution — rendered in a corner of the iframe | +| `client/audio/sfx/fire.ogg` | short cartoon "pew" (4.4 KB) | replaced a harsh 40 KB continuous tone; also shrinks the web bundle. `SfxPool.FireOffsetDb` re-levelled −20 → −6 dB for the quieter clip (final level owner-ear-gated) | ### The 5 web-only bugs that had to be fixed (in order discovered) 1. `wasm-tools` not actually installed → managed C# never compiled → black screen. 2. Trimmer pruned the game (rooted `TankGame`/`GodotSharp`). -3. Exports drop raw `FileAccess` resources → untranslated menu, no SFX, no backdrop (GD.Load fallbacks). +3. Exports drop raw `FileAccess` resources → untranslated menu, no SFX (GD.Load fallbacks). 4. `Guid.NewGuid()` collision → no combat damage, AI never fires (counter-based `EntityId`). 5. GitHub LFS bandwidth quota broke the CI deploy → store the bundle as **plain git blobs** (both files < 100 MB). +Superseded-along-the-way (never ported; `main`'s versions won): the old branch's inline +fixed-timestep loop (main has swept-collision fixed-step combat, #243), its time-less +`KillStreakTracker`, its TitleScene web gating (main's slim menu + `PlatformExit`, #248), +and its .NET-HttpClient-era lobby plumbing (host-authoritative redesign, ADR-0019/0021, #245+). + ## 4. Deploy to Lundrea Arcade (ProjectX repo) Deploy is **CI-on-push to ProjectX `main`** (GitHub Actions → `vite build` → Firebase Hosting). To @@ -74,54 +78,20 @@ ProjectX integration (already in place on `main`): `public/tank/` bundle as **pl suites skip `external` games; tile `public/tiles/tank.webp`. **Caveats:** Spark free tier ≈ 15–20 cold loads/day (≈58 MB bundle; repeat visits cached/free). -One harmless web-audio `sample_set_pause` console warning. Map editor is gated out of web. - ---- +One harmless web-audio `sample_set_pause` console warning. Map editor is desktop-only. -## 5. NEXT MILESTONE — Multiplayer (the spec + plan) - -**Owner spec (2026-06-27):** -- Title menu becomes: **Solo · Multiplayer · Settings · Back to Arcade** (Back-to-Arcade web-only). -- **Multiplayer** → choose **Team vs Team** or **FFA**. -- Either choice opens a **lobby** with **up to 8 slots** for players to join. -- Multiplayer also shows a **list of available lobbies to join** (a lobby browser). -- Any player can press **Start game** → **3 · 2 · 1 · Start** countdown → launches the arena. -- Both **FFA** and **Team vs Team** must work in the arena. - -**Backend:** Firebase Spark can't host realtime (no Cloud Functions/WebSockets). There is already a -Cloudflare Worker **`tankgame-worker.vodkadav.workers.dev`** (ADR-0019 relay) — extend it with a -**Durable Object** for lobby state + the N-player relay. The Cloudflare MCP is available in-session -for deploys. - -**Current netcode:** 2-player host/join (ADR-0019 steps 1–4: relay DO, Host/Join codes, HostSession -+ NetArena3DScene, protocol fidelity). Needs extending to **up to 8 players + FFA/Team + a lobby -browser + a synced countdown.** - -**Proposed phases:** -1. **Client menu/lobby UX** — restructure the title to Solo/Multiplayer/Settings/Back-to-Arcade; - Multiplayer → FFA/Team picker → lobby screen (8 slots, ready/start, countdown) + a lobby-browser - list. Dev against a local stub first. -2. **Backend (tankgame-worker)** — a `Lobby` Durable Object: create / list / join / leave / start; - broadcast slot changes + the start countdown; then relay game packets for N players. -3. **Netcode to N players** — extend HostSession/NetArena3DScene/snapshot codec from 2 → up to 8 - tanks; FFA (every tank its own team) vs Team (2 teams) win logic. -4. **Wire client ↔ worker** — lobby browser + join + countdown sync + launch into NetArena3DScene - with the right roster/mode. -5. **Playtest** across 2+ devices/browsers. - -**Known step-4 remnant (ADR-0019):** the guest's OWN shield/elevation isn't predicted -(`PredictedTank`/`Reconcile` doesn't track Shield/Layer). +## 5. Multiplayer status ---- +The 2026-06-27 multiplayer spec is **delivered on `main`**: slim title menu +(Solo · Multiplayer · Settings · Back-to-Arcade), lobby browser + lobby room for up to 8 players +(`LobbyProtocol.MaxPlayers`), countdown start, host-authoritative relay netcode over the +`tankgame-worker` Cloudflare Durable Objects (ADR-0019 relay, ADR-0021 lobby directory), net +pickups, victory screen + online rematch (#259/#262). See those ADRs for design; this doc only +covers the web build/deploy. ## 6. Resume checklist (fresh machine) -1. Pull TankGame, `git checkout feat/web-export`; pull ProjectX `main`. +1. Pull TankGame `main`; pull ProjectX `main`. 2. Install the ComplexRobot Godot web-export editor + `dotnet workload install wasm-tools` (verify with `dotnet workload list`). 3. `--import` once, then `--export-release "Web"` (section 2). 4. To deploy: copy `build/web/*` → `ProjectX/public/tank/`, then PR→main in ProjectX (section 4). -5. For multiplayer, start at section 5 phase 1. - -**Also pending (desktop/main parity):** the SFX-attenuation fix is ProjectX-independent and lives in -TankGame PR #229 (`fix/sfx-attenuation`); the pew asset, `ShowEnemyNames` default, and the -no-window `KillStreakTracker` still need porting to TankGame `main` for the desktop build.