Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions client/Program.cs
Original file line number Diff line number Diff line change
@@ -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.
{ }
31 changes: 29 additions & 2 deletions client/TankGame.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,43 @@
<Compile Include="src/**/*.cs" />
</ItemGroup>

<ItemGroup>
<ItemGroup Condition="'$(GodotTargetPlatform)' != 'web'">
<PackageReference Include="Sentry" Version="6.5.0" />
</ItemGroup>

<!-- GoDotTest scene tests run inside the game assembly (GoDotTest reflects over
the executing assembly), so they compile in for editor/debug builds and are
excluded from ExportRelease. The runner entry is Bootstrap.cs (#if DEBUG). -->
<ItemGroup Condition="'$(Configuration)' != 'ExportRelease'">
<ItemGroup Condition="'$(Configuration)' != 'ExportRelease' And '$(GodotTargetPlatform)' != 'web'">
<PackageReference Include="Chickensoft.GoDotTest" Version="2.0.34" />
<Compile Include="tests/Presentation/**/*.cs" />
</ItemGroup>

<!-- Experimental .NET web (WASM) export only. Godot sets GodotTargetPlatform=web during a web
export, so these are scoped to it and leave Debug/test, Android and desktop builds completely
unchanged: the static-linked mono runtime needs an executable entry point (Program.cs +
OutputType Exe), and the trimmer must be told to keep the core runtime assemblies. -->
<PropertyGroup Condition="'$(GodotTargetPlatform)' == 'web'">
<OutputType>Exe</OutputType>
<!-- The ComplexRobot web template is built with threads; the managed publish must match it or
the .NET runtime fails to initialise (black canvas, engine quits right after GL init). -->
<WasmEnableThreads>true</WasmEnableThreads>
<!-- Lets code drop browser-incompatible bits (e.g. the Sentry .NET SDK) from the web build. -->
<DefineConstants>$(DefineConstants);GODOT_WEB</DefineConstants>
</PropertyGroup>

<ItemGroup Condition="'$(GodotTargetPlatform)' == 'web'">
<Compile Include="Program.cs" />
<!-- Sentry's .NET SDK relies on native/crypto APIs unavailable under WASM; excluded from web. -->
<Compile Remove="src/Infrastructure/SentryBootstrap.cs" />
<TrimmerRootAssembly Include="System.Private.CoreLib" />
<TrimmerRootAssembly Include="System.Runtime" />
<!-- The wasm publish trims to the Program.cs entry point. The engine's real managed entry,
GodotPlugins.Game.Main, is compiled into TankGame.dll and called only from native code
(godotsharp_game_main_init), so the trimmer can't see it and prunes the whole game. Root
the game assembly (and GodotSharp, which Godot reflects over) to keep them in the build. -->
<TrimmerRootAssembly Include="TankGame" />
<TrimmerRootAssembly Include="GodotSharp" />
</ItemGroup>

</Project>
21 changes: 21 additions & 0 deletions client/TankGame.sln
Original file line number Diff line number Diff line change
@@ -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
Binary file modified client/audio/sfx/fire.ogg
Binary file not shown.
2 changes: 1 addition & 1 deletion client/src/GameLogic/Airstrike.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public sealed class Airstrike : IAirstrike
public Airstrike(IWorld world, IReadOnlyList<Vector2> zones, int callerTeam, float zoneRadius,
float armWindow, float delay, int damage)
{
Id = Guid.NewGuid();
Id = EntityId.Next();
_world = world;
_callerTeam = callerTeam;
_damage = damage;
Expand Down
31 changes: 31 additions & 0 deletions client/src/GameLogic/EntityId.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;

namespace TankGame.GameLogic;

/// <summary>Allocates unique entity ids.
///
/// On desktop/Android this is just <see cref="Guid.NewGuid"/>. On the experimental .NET WASM web
/// runtime, however, the cryptographic RNG that backs <c>Guid.NewGuid()</c> 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: <c>CombatResolver</c> skips
/// a tank when <c>tank.Id == shot.Owner</c> and the AI skips a tank when <c>tank.Id == self.Id</c> —
/// 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).</summary>
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
}
}
2 changes: 1 addition & 1 deletion client/src/GameLogic/NetPowerup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace TankGame.GameLogic;
/// by diffing snapshot ids, not by reaping).</summary>
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;
Expand Down
2 changes: 1 addition & 1 deletion client/src/GameLogic/NetProjectile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace TankGame.GameLogic;
/// always true (the scene shows or hides shots by rebuilding from each snapshot, not by reaping).</summary>
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; }
Expand Down
2 changes: 1 addition & 1 deletion client/src/GameLogic/NetTank.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
2 changes: 1 addition & 1 deletion client/src/GameLogic/Powerup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion client/src/GameLogic/PowerupDirector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion client/src/GameLogic/Projectile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion client/src/GameLogic/Tank.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public Tank(
string displayName = "",
Func<Vector2>? respawnPoint = null)
{
Id = Guid.NewGuid();
Id = EntityId.Next();
MaxHp = maxHp;
Hp = maxHp;
Team = team;
Expand Down
19 changes: 18 additions & 1 deletion client/src/Infrastructure/TranslationLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<Translation>($"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<string>();
Expand Down
21 changes: 14 additions & 7 deletions client/src/Presentation/Arena/SfxPool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SfxKind, string> SfxFiles = new()
{
Expand Down Expand Up @@ -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<AudioStream>(resPath);
}

/// <summary>Set the SFX volume for every player in the pool (dB; 0 = full, negative = quieter).
Expand Down
12 changes: 7 additions & 5 deletions client/src/Presentation/Bootstrap.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using Godot;
using TankGame.Infrastructure;
#if DEBUG
#if DEBUG && !GODOT_WEB
using System.Reflection;
using Chickensoft.GoDotTest;
#endif
Expand All @@ -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)
{
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion client/src/Presentation/GameMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading
Loading