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 7952865..8fb9117 100644 Binary files a/client/audio/sfx/fire.ogg and b/client/audio/sfx/fire.ogg differ 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/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/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/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/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..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() { @@ -96,9 +97,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..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 = Guid.NewGuid().GetHashCode(); + // 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 new file mode 100644 index 0000000..138937d --- /dev/null +++ b/docs/web-export.md @@ -0,0 +1,97 @@ +# Web (WASM) export + Lundrea Arcade deploy + +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. + +--- + +## 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): any server sending +`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp` works. + +## 3. The web layer on `main` (what makes the WASM build work) + +| File | Change | Why | +|---|---|---| +| `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 (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 +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 desktop-only. + +## 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 `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).