diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d8f022f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,51 @@ +name: Publish +on: + push: + tags: ["*"] + +env: + SOLUTION_NAME: Umbra.Bejeweled + RELEASE_DIR: out/Release + +jobs: + Build: + permissions: + contents: write + runs-on: windows-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.0.x + + - name: Download Dalamud Latest + run: | + Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/latest.zip -OutFile latest.zip + Expand-Archive -Force latest.zip "$env:AppData\XIVLauncher\addon\Hooks\dev" + + - name: Download Umbra Latest + run: | + Invoke-WebRequest -Uri https://raw.githubusercontent.com/una-xiv/umbra-dist/main/dist/Umbra/latest.zip -OutFile latest.zip + Expand-Archive -Force latest.zip "$env:AppData\XIVLauncher\installedPlugins\Umbra\dist" + + - name: Restore solution + run: dotnet restore -r win ${{ env.SOLUTION_NAME }}.sln + + - name: Build solution + run: | + dotnet restore -r win ${{ env.SOLUTION_NAME }}.sln + dotnet build --configuration Release + + - name: Create plugin archive + run: Compress-Archive -Path ${{env.RELEASE_DIR}}* -DestinationPath ${{env.RELEASE_DIR}}/${{env.SOLUTION_NAME}}.zip + + - name: Publish Plugin + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: ${{env.RELEASE_DIR}}/${{env.SOLUTION_NAME}}.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..927390d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +out/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..61567eb --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Bejeweled plugin for Umbra + +Adds a Bejeweled minigame to Umbra. + +## Download + +1. Grab the latest version from the [Releases](https://github.com/una-xiv/Umbra.CounterSpyPlugin/releases) page. +2. Extract the ZIP file somewhere on your computer. +3. Open Umbra's settings, then go to the "Plugins" category. +4. Click on "Add plugin" and select the DLL file that you extracted in step 2. + +## Adding the game to your toolbar. + +Bejeweled is a widget. Once the plugin has been installed and Umbra has been restarted, you can add the game to your toolbar by clicking on the "Add widget" button in the toolbar and selecting "Bejeweled". diff --git a/Umbra.Bejeweled.sln b/Umbra.Bejeweled.sln new file mode 100644 index 0000000..387638e --- /dev/null +++ b/Umbra.Bejeweled.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbra.Bejeweled", "Umbra.Bejeweled\Umbra.Bejeweled.csproj", "{53E266A9-368F-40F3-9A37-BB1ED7385B06}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {53E266A9-368F-40F3-9A37-BB1ED7385B06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53E266A9-368F-40F3-9A37-BB1ED7385B06}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53E266A9-368F-40F3-9A37-BB1ED7385B06}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53E266A9-368F-40F3-9A37-BB1ED7385B06}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Umbra.Bejeweled/Umbra.Bejeweled.csproj b/Umbra.Bejeweled/Umbra.Bejeweled.csproj new file mode 100644 index 0000000..1bec4d8 --- /dev/null +++ b/Umbra.Bejeweled/Umbra.Bejeweled.csproj @@ -0,0 +1,73 @@ + + + + $(appdata)\XIVLauncher\addon\Hooks\dev\ + + + + + + $([System.IO.Directory]::GetDirectories($(appdata)\XIVLauncher\installedPlugins\Umbra\)[0])\ + + + + net8.0-windows + x64 + enable + latest + true + false + false + ..\out\$(Configuration)\ + + + + Una + Una XIV + 1.0.0 + A bejeweled game to kill time while waiting for queues. + (C)2024 + https://github.com/una-xiv/Umbra.BejeweledPlugin + AGPL-3.0-or-later + false + + + + + $(UmbraLibPath)Umbra.dll + false + + + $(UmbraLibPath)Umbra.Common.dll + false + + + $(UmbraLibPath)Umbra.Game.dll + false + + + $(UmbraLibPath)Una.Drawing.dll + false + + + $(DalamudLibPath)Dalamud.dll + false + + + $(DalamudLibPath)FFXIVClientStructs.dll + false + + + $(DalamudLibPath)Lumina.dll + false + + + $(DalamudLibPath)Lumina.Excel.dll + false + + + $(DalamudLibPath)ImGui.NET.dll + false + + + diff --git a/Umbra.Bejeweled/src/BejeweledWidget.cs b/Umbra.Bejeweled/src/BejeweledWidget.cs new file mode 100644 index 0000000..aad979b --- /dev/null +++ b/Umbra.Bejeweled/src/BejeweledWidget.cs @@ -0,0 +1,181 @@ +using System.Collections.Generic; +using Umbra.Bejeweled.Popup; +using Umbra.Common; +using Umbra.Widgets; + +namespace Umbra.Bejeweled; + +[ToolbarWidget("Bejeweled", "Bejeweled", "A mini-game of Bejeweled to play while you wait for your queue to pop.")] +internal sealed class BejeweledWidget( + WidgetInfo info, + string? guid = null, + Dictionary? configValues = null +) + : DefaultToolbarWidget(info, guid, configValues) +{ + /// + public override BejeweledPopup Popup { get; } = new(); + + /// + protected override IEnumerable GetConfigVariables() + { + return [ + new BooleanWidgetConfigVariable( + "EnableSound", + "Enable Sound Effects", + "Whether to enable sound effects.", + true + ) { Category = "Game Options" }, + new IntegerWidgetConfigVariable( + "Difficulty", + "Difficulty", + "The difficulty level of the game. Higher values increase the number of gem types. Changing this value will reset the game.", + 2, + 1, + 4 + ) { Category = "Game Options" }, + new IntegerWidgetConfigVariable( + "GemIcon1", + "Icon 1", + "The icon ID for the first gem type.", + 21275, + 0 + ) { Category = "Gem Icons" }, + new IntegerWidgetConfigVariable( + "GemIcon2", + "Icon 2", + "The icon ID for the second gem type.", + 21281, + 0 + ) { Category = "Gem Icons" }, + new IntegerWidgetConfigVariable( + "GemIcon3", + "Icon 3", + "The icon ID for the third gem type.", + 21283, + 0 + ) { Category = "Gem Icons" }, + new IntegerWidgetConfigVariable( + "GemIcon4", + "Icon 4", + "The icon ID for the fourth gem type.", + 21284, + 0 + ) { Category = "Gem Icons" }, + new IntegerWidgetConfigVariable( + "GemIcon5", + "Icon 5", + "The icon ID for the fifth gem type.", + 21289, + 0 + ) { Category = "Gem Icons" }, + new IntegerWidgetConfigVariable( + "GemIcon6", + "Icon 6", + "The icon ID for the sixth gem type.", + 21293, + 0 + ) { Category = "Gem Icons" }, + new IntegerWidgetConfigVariable( + "BombIcon", + "Bomb Icon", + "The icon ID for the bomb power-up.", + 60728, + 0 + ) { Category = "Gem Icons" }, + new IntegerWidgetConfigVariable( + "HorizontalRocketIcon", + "Horizontal Rocket Icon", + "The icon ID for the horizontal rocket power-up.", + 60727, + 0 + ) { Category = "Gem Icons" }, + new IntegerWidgetConfigVariable( + "VerticalRocketIcon", + "Vertical Rocket Icon", + "The icon ID for the vertical rocket power-up.", + 60726, + 0 + ) { Category = "Gem Icons" }, + new IntegerWidgetConfigVariable( + "RainbowBombIcon", + "Rainbow Bomb Icon", + "The icon ID for the Rainbow Bomb power-up.", + 60722, + 0 + ) { Category = "Gem Icons" }, + new StringWidgetConfigVariable( + "ButtonLabel", + "Button Label", + "The label for the button.", + Info.Name + ) { Category = I18N.Translate("Widget.ConfigCategory.WidgetAppearance") }, + new IntegerWidgetConfigVariable( + "ButtonIcon", + "Button Icon", + "The icon for the button.", + 14, + 0 + ) { Category = I18N.Translate("Widget.ConfigCategory.WidgetAppearance") }, + ..DefaultToolbarWidgetConfigVariables, + ..SingleLabelTextOffsetVariables, + new IntegerWidgetConfigVariable("HiScore", "", null, 0, 0) { IsHidden = true }, + ]; + } + + private uint _lastHiScore; + private int _lastDifficulty; + + /// + protected override void Initialize() + { + SetIcon(14); + SetLabel(Info.Name); + + _lastHiScore = (uint)GetConfigValue("HiScore"); + _lastDifficulty = GetConfigValue("Difficulty"); + + Popup.HiScore = _lastHiScore; + Popup.Board.ColorCount = 2 + _lastDifficulty; + + Popup.ResetGame(); + } + + public override string GetInstanceName() + { + return GetConfigValue("ButtonLabel"); + } + + protected override void OnUpdate() + { + SetIcon((uint)GetConfigValue("ButtonIcon")); + SetLabel(GetConfigValue("ButtonLabel")); + + int difficulty = GetConfigValue("Difficulty"); + if (difficulty != _lastDifficulty) { + _lastDifficulty = difficulty; + Popup.Board.ColorCount = 2 + difficulty; + Popup.Board.Reset(); + } + + Popup.Sound = GetConfigValue("EnableSound"); + Popup.Board.EnableSfx = Popup.Sound; + Popup.Board.IconIds.GemType1 = (uint)GetConfigValue("GemIcon1"); + Popup.Board.IconIds.GemType2 = (uint)GetConfigValue("GemIcon2"); + Popup.Board.IconIds.GemType3 = (uint)GetConfigValue("GemIcon3"); + Popup.Board.IconIds.GemType4 = (uint)GetConfigValue("GemIcon4"); + Popup.Board.IconIds.GemType5 = (uint)GetConfigValue("GemIcon5"); + Popup.Board.IconIds.GemType6 = (uint)GetConfigValue("GemIcon6"); + Popup.Board.IconIds.Bomb = (uint)GetConfigValue("BombIcon"); + Popup.Board.IconIds.HorizontalRocket = (uint)GetConfigValue("HorizontalRocketIcon"); + Popup.Board.IconIds.VerticalRocket = (uint)GetConfigValue("VerticalRocketIcon"); + Popup.Board.IconIds.RainbowBomb = (uint)GetConfigValue("RainbowBombIcon"); + + if (Popup.Board.Score > _lastHiScore) { + _lastHiScore = Popup.Board.Score; + SetConfigValue("HiScore", (int)Popup.HiScore); + } + + base.OnUpdate(); + } +} diff --git a/Umbra.Bejeweled/src/Game/Board.Entities.cs b/Umbra.Bejeweled/src/Game/Board.Entities.cs new file mode 100644 index 0000000..e297bc5 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Board.Entities.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Umbra.Bejeweled.Game.Entities; +using Umbra.Common; +using Una.Drawing; + +namespace Umbra.Bejeweled.Game; + +internal sealed partial class Board +{ + private List Entities { get; } = []; + private List DestroyedEntities { get; } = []; + private List Particles { get; } = []; + + private void SpawnParticlesFor(Entity entity) + { + int amount = new Random().Next(10, 20); + + for (var i = 0; i < amount; i++) { + Particles.Add(new(0, this, entity.CellPosition, new Random().Next(500, 1500), entity.GetIconId())); + } + } + + private void UpdateParticles(float deltaTime) + { + foreach (var particle in Particles.ToArray()) { + particle.Render(deltaTime); + + if (particle.IsDestroyed) { + Particles.Remove(particle); + } + } + } + + /// + /// Creates new gems at the top of the board if necessary. + /// + private void CreateNewGems() + { + for (var x = 0; x < Width; x++) { + Vector2 p1 = new(Viewport.TopLeft.X + x * CellSize, Viewport.TopLeft.Y - CellSize); + Vector2 p2 = p1 + new Vector2(CellSize, CellSize * 1.2f); + + Rect cellRect = new(p1, p2); + + if (Entities.Any(e => e.Rect.Intersects(cellRect))) { + continue; + } + + AddGem(x, -1, GetNewGemTypeAt(x)); + PlaySound(30); + } + } + + /// + /// Fills the board with gems. + /// + private void FillBoard(int iteration = 0) + { + if (iteration > 100) { + Logger.Warning($"Failed to fill the board after {iteration} iterations."); + return; + } + + for (var y = 0; y < Height; y++) { + for (var x = 0; x < Width; x++) { + byte type = GetInitialTypeGemAt(x, y); + + if (type == 0) { + Logger.Info($"Failed to get initial type for gem at {x}, {y}. Retrying..."); + FillBoard(iteration + 1); + return; + } + + AddGem(x, y, type, true); + } + } + + int px = new Random().Next(0, Width); + int py = new Random().Next(0, Height); + + // AddPowerUp(px, py); + } + + private void AddGem(int x, int y, byte type, bool addToGrid = false) + { + var entity = new Gem(this, new(x, y), type, IconIds); + if (addToGrid) Grid[x, y] = entity; + Entities.Add(entity); + } +} diff --git a/Umbra.Bejeweled/src/Game/Board.Grid.cs b/Umbra.Bejeweled/src/Game/Board.Grid.cs new file mode 100644 index 0000000..a7f6b9d --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Board.Grid.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Linq; +using Umbra.Bejeweled.Game.Entities; +using Umbra.Common; + +namespace Umbra.Bejeweled.Game; + +internal sealed partial class Board +{ + /// + /// The game grid. + /// + /// This represents the actual state of the game, ignoring any animations. + /// This means that empty cells are immediately filled with new entities, + /// even though the entities may still be falling to their final position. + /// + /// + private Entity?[,] Grid { get; } = new Entity?[size.X, size.Y]; + + /// + /// Clears the cell at the given position. + /// + public void ClearCell(Vec2 position) + { + int x = position.X; + int y = position.Y; + + if (x < 0 || x >= Width || y < 0 || y >= Height) { + return; + } + + Entity? entity = Grid[x, y]; + + if (entity != null) { + entity.IsDestroyed = true; + Grid[x, y] = null; + } + } + + /// + /// Returns true if there are empty cells on the board. + /// + private bool HasEmptyCells() + { + for (var y = 0; y < Height; y++) { + for (var x = 0; x < Width; x++) { + if (Grid[x, y] == null) { + return true; + } + } + } + + return false; + } + + private void UpdateGrid() + { + for (var y = 0; y < Height; y++) { + for (var x = 0; x < Width; x++) { + Grid[x, y] = null; + } + } + + List entities = Entities.ToList(); + entities.Sort((a, b) => a.EntityType.CompareTo(b.EntityType)); + + foreach (var entity in entities) { + if (entity is { IsDestroyed: false, IsAlive: true, IsFalling: false, CellPosition.Y: >= 0 }) { + if (Grid[entity.CellPosition.X, entity.CellPosition.Y] != null) { + Logger.Info($"Removed entity at {entity.CellPosition.X}, {entity.CellPosition.Y} with type {entity.EntityType}."); + Entities.Remove(Grid[entity.CellPosition.X, entity.CellPosition.Y]!); + } + + Grid[entity.CellPosition.X, entity.CellPosition.Y] = entity; + } + } + } + + /// + /// Returns true if the cell below the given position is empty. + /// + public bool IsCellEmptyBelow(Vec2 position) + { + return position.Y + 1 < Height && Grid[position.X, position.Y + 1] == null; + } + + /// + /// Returns the entity type at the given position. Returns 0 if the + /// given position is out of bounds or if there is no entity there. + /// + public byte GetGemAt(int x, int y) + { + if (x < 0 || x >= Width || y < 0 || y >= Height) { + return 0; + } + + Entity? entity = Grid[x, y]; + return entity == null || entity.IsDestroyed ? (byte)0 : entity.EntityType; + } + + /// + /// Returns the entity type at the given position. Returns NULL if + /// the given position is out of bounds or empty. + /// + public Entity? GetEntityAt(int x, int y) + { + if (x < 0 || x >= Width || y < 0 || y >= Height) { + return null; + } + + Entity? entity = Grid[x, y]; + return entity == null || entity.IsDestroyed ? null : entity; + } +} diff --git a/Umbra.Bejeweled/src/Game/Board.Match.cs b/Umbra.Bejeweled/src/Game/Board.Match.cs new file mode 100644 index 0000000..a46fd56 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Board.Match.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using Umbra.Bejeweled.Game.Entities; +using Umbra.Common; + +namespace Umbra.Bejeweled.Game; + +internal struct Match +{ + public MatchType Type { get; set; } + public int X { get; set; } + public int Y { get; set; } + public HashSet Entities { get; set; } +} + +internal sealed partial class Board +{ + private void ProcessMatch(Match match) + { + if (match.Type == MatchType.None) return; + + foreach (var ent in match.Entities) { + ent.IsDestroyed = true; + } + + switch (match.Type) { + case MatchType.HorizontalRocket: + AddPowerUp(match.X, match.Y); + break; + case MatchType.VerticalRocket: + AddPowerUp(match.X, match.Y); + break; + case MatchType.Bomb: + case MatchType.TeeShape: + AddPowerUp(match.X, match.Y); + break; + case MatchType.Rainbow: + AddPowerUp(match.X, match.Y); + break; + case MatchType.Default: + if (match.Entities.Count >= 5) AddPowerUp(match.X, match.Y); + break; + case MatchType.None: + break; + default: + throw new ArgumentOutOfRangeException(nameof(match)); + } + } + + private void AddPowerUp(int x, int y) where T : Entity + { + ClearCell(new(x, y)); + + Moves += 2; + + var entity = (T)Activator.CreateInstance(typeof(T), this, new Vec2(x, y), IconIds)!; + Grid[x, y] = entity; + Entities.Add(entity); + + PlaySound(60); + } + + public Match GetMatchType(int x, int y) + { + byte t = GetGemAt(x, y); + + return t == 0 ? new() { Type = MatchType.None } : GetMatchType(t, x, y); + } + + public Match GetMatchType(byte t, int sx = -1, int sy = -1) + { + List matches = []; + + bool isAutoParam = sx == -1 && sy == -1; + + // Check for horizontal match of 5 + for (var x = 0; x <= Width; x++) { + for (var y = 0; y < Height; y++) { + if (isAutoParam) { + sx = x; + sy = y; + } + + // Rainbow match horizontal + var rbh = GetMatchedEntitiesAt(t, [(x, y), (x + 1, y), (x + 2, y), (x + 3, y), (x + 4, y)]); + if (rbh.Count > 0) matches.Add(new() { Type = MatchType.Rainbow, X = sx, Y = sy, Entities = rbh }); + + // Rainbow match vertical + var rbv = GetMatchedEntitiesAt(t, [(x, y), (x, y + 1), (x, y + 2), (x, y + 3), (x, y + 4)]); + if (rbv.Count > 0) matches.Add(new() { Type = MatchType.Rainbow, X = sx, Y = sy, Entities = rbv }); + + // T-shape match up + var tsu = GetMatchedEntitiesAt(t, [(x, y), (x - 1, y), (x + 1, y), (x, y + 1), (x, y + 2)]); + if (tsu.Count > 0) matches.Add(new() { Type = MatchType.TeeShape, X = sx, Y = sy, Entities = tsu }); + + // T-shape match down + var tsd = GetMatchedEntitiesAt(t, [(x, y), (x, y + 1), (x, y + 2), (x - 1, y + 2), (x + 1, y + 2)]); + if (tsd.Count > 0) matches.Add(new() { Type = MatchType.TeeShape, X = sx, Y = sy, Entities = tsd }); + + // Square block match + var sqb = GetMatchedEntitiesAt(t, [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)]); + if (sqb.Count > 0) matches.Add(new() { Type = MatchType.Bomb, X = sx, Y = sy, Entities = sqb }); + + // Horizontal match of 4 + var h4 = GetMatchedEntitiesAt(t, [(x, y), (x + 1, y), (x + 2, y), (x + 3, y)]); + if (h4.Count > 0) matches.Add(new() { Type = MatchType.VerticalRocket, X = sx, Y = sy, Entities = h4 }); + + // Vertical match of 4 + var v4 = GetMatchedEntitiesAt(t, [(x, y), (x, y + 1), (x, y + 2), (x, y + 3)]); + + if (v4.Count > 0) + matches.Add(new() { Type = MatchType.HorizontalRocket, X = sx, Y = sy, Entities = v4 }); + + // Horizontal match of 3 + var h3 = GetMatchedEntitiesAt(t, [(x, y), (x + 1, y), (x + 2, y)]); + if (h3.Count > 0) matches.Add(new() { Type = MatchType.Default, X = sx, Y = sy, Entities = h3 }); + + // Vertical match of 3 + var v3 = GetMatchedEntitiesAt(t, [(x, y), (x, y + 1), (x, y + 2)]); + if (v3.Count > 0) matches.Add(new() { Type = MatchType.Default, X = sx, Y = sy, Entities = v3 }); + } + } + + if (matches.Count == 0) { + return new() { Type = MatchType.None }; + } + + Match bestMatch = matches[0]; + HashSet allEntities = []; + + foreach (var match in matches) { + if (bestMatch.Type < match.Type) { + bestMatch = match; + } + + allEntities.UnionWith(match.Entities); + } + + bestMatch.Entities = allEntities; + + PlaySound(20); + + return bestMatch; + } + + private HashSet GetMatchedEntitiesAt(byte type, List<(int, int)> positions) + { + HashSet entities = []; + + foreach ((int x, int y) in positions) { + Entity? entity = GetEntityAt(x, y); + if (entity == null || entity.EntityType == 0) return []; + + entities.Add(entity); + } + + if (entities.Count == 0) return []; + + return entities.All(e => type == e.EntityType) ? entities : []; + } + + private uint ProcessMatches() + { + uint score = 0; + uint count = 0; + + for (byte t = 1; t < 16; t++) { + Match match = GetMatchType(t); + + if (match.Type != MatchType.None) { + ProcessMatch(match); + + score += (uint)(ScoreMultiplier * match.Entities.Count); + count++; + } + } + + if (count > 0) ScoreMultiplier += count; + + return score; + } + + /// + /// Returns a gem type that would be appropriate for the given + /// horizontal position. This is used to fill the board with new gems + /// from the top. + /// + private byte GetNewGemTypeAt(int x) + { + while (true) { + byte gem = PickRandomGemType(); + + byte below = GetGemAt(x, 0); + byte below2 = GetGemAt(x, 1); + byte left = GetGemAt(x - 1, 0); + byte left2 = GetGemAt(x - 2, 0); + byte right = GetGemAt(x + 1, 0); + byte right2 = GetGemAt(x + 2, 0); + + if (gem == below && gem == below2) { + continue; + } + + if (x >= 2 && gem == left && gem == left2) { + continue; + } + + if (x < Width - 2 && gem == right && gem == right2) { + continue; + } + + return gem; + } + } + + /// + /// Returns a gem type that would be appropriate for the initial board state + /// at the given position. The gem type should not be part of any matches. + /// + /// A blank board is filled from top to bottom, left to right. + /// + private byte GetInitialTypeGemAt(int x, int y) + { + int iteration = 0; + + while (true) { + iteration++; + + if (iteration > (Width * Height) * 2) { + Logger.Warning($"Failed to pick a gem type for ({x}, {y}) after {iteration} iterations."); + return 0; + } + + byte type = PickRandomGemType(); + + if (x >= 1) { + byte left = GetGemAt(x - 1, y); + + if (type == left) { + continue; + } + } + + if (y >= 1) { + byte above = GetGemAt(x, y - 1); + + if (type == above) { + continue; + } + } + + return type; + } + } + + public byte PickRandomGemType() + { + // Buffer to hold the random byte + var randomNumber = new byte[1]; + + // Generate a cryptographically secure random number + using RandomNumberGenerator rng = RandomNumberGenerator.Create(); + + do { + rng.GetBytes(randomNumber); + } while (randomNumber[0] > 250); // This avoids bias in the distribution + + // Ensure the random number is in the range 1 to 6 (inclusive) + return (byte)(randomNumber[0] % (ColorCount) + 1); + } +} diff --git a/Umbra.Bejeweled/src/Game/Board.Physics.cs b/Umbra.Bejeweled/src/Game/Board.Physics.cs new file mode 100644 index 0000000..61ed4cd --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Board.Physics.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Umbra.Bejeweled.Game; + +internal sealed partial class Board +{ + public void UpdatePhysics(float deltaTime) + { + // Sort entities with highest Y position first + List entities = [..Entities]; + entities.Sort((a, b) => a.CellPosition.Y.CompareTo(b.CellPosition.Y)); + + // Update physics + foreach (Entity entity in entities) + { + entity.UpdatePhysics(deltaTime); + + if (false == entity.IsAlive) { + Entities.Remove(entity); + DestroyedEntities.Remove(entity); + Score += 1 * ScoreMultiplier; + } + } + } + + /// + /// Returns true if any entity is falling. + /// + private bool HasFallingEntities() + { + return Entities.Any(e => e.IsFalling); + } +} diff --git a/Umbra.Bejeweled/src/Game/Board.cs b/Umbra.Bejeweled/src/Game/Board.cs new file mode 100644 index 0000000..b6eb2bc --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Board.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.UI; +using Umbra.Common; + +namespace Umbra.Bejeweled.Game; + +internal sealed partial class Board(Viewport viewport, Vec2 size) +{ + public const int CellSize = 64; + + public bool Active { get; set; } + public bool EnableSfx { get; set; } = true; + public int ColorCount { get; set; } = 6; + + public uint Moves { get; private set; } = 10; + public uint ScoreMultiplier { get; private set; } = 1; + public uint Score { get; private set; } = 0; + + public int Width { get; private set; } = size.X; + public int Height { get; private set; } = size.Y; + public Viewport Viewport { get; private set; } = viewport; + public GameState State { get; private set; } = GameState.Idle; + public IconIds IconIds { get; private set; } = new(); + + private Entity? _swapSource; + private Entity? _swapTarget; + private Vector2? _swapSourcePosition; + private Vector2? _swapTargetPosition; + private bool _hasSwapped; + private long _lastMatchAt; + private long _lastActivityAt; + + public void Reset() + { + _lastMatchAt = 0; + _swapSource = null; + _swapTarget = null; + _swapSourcePosition = null; + _swapTargetPosition = null; + _hasSwapped = false; + + Moves = 10; + Score = 0; + ScoreMultiplier = 1; + + Entities.Clear(); + DestroyedEntities.Clear(); + FillBoard(); + + State = GameState.Idle; + } + + public void Update(float deltaTime) + { + if (State == GameState.GameOver) { + return; + } + + long now = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + + if (now - _lastMatchAt > 2000) { + ScoreMultiplier = 1; + } + + if (State != GameState.Swapping) { + CreateNewGems(); + UpdateGrid(); + + if (HasEmptyCells()) { + State = GameState.Falling; + _lastActivityAt = now; + } else { + if (now - _lastActivityAt < 500) { + uint score = ProcessMatches(); + + if (score > 0) { + _lastMatchAt = now; + } + } + + State = HasFallingEntities() ? GameState.Falling : GameState.Idle; + + if (State == GameState.Idle && now - _lastActivityAt > 500) { + if (Moves == 0) { + State = GameState.GameOver; + } + } + } + } + + switch (State) { + case GameState.Falling: + UpdatePhysics(deltaTime); + _lastActivityAt = now; + break; + case GameState.Swapping: + UpdateSwapPosition(deltaTime); + _lastActivityAt = now; + break; + } + + foreach (var e in Entities) { + e.Render(deltaTime); + + if (e.IsDestroyed) { + if (!DestroyedEntities.Contains(e)) { + DestroyedEntities.Add(e); + SpawnParticlesFor(e); + } + } + } + + UpdateParticles(deltaTime); + } + + public byte GetDominantGemType() + { + Dictionary counts = []; + + foreach (var entity in Entities) { + if (entity.EntityType == 0) continue; + + counts.TryAdd(entity.EntityType, 0); + counts[entity.EntityType]++; + } + + byte dominantType = 0; + var maxCount = 0; + + foreach ((byte type, int count) in counts) { + if (count > maxCount) { + dominantType = type; + maxCount = count; + } + } + + return dominantType; + } + + public bool TryInvokePowerUp(Vec2 pos) + { + Entity? entity = GetEntityAt(pos.X, pos.Y); + if (entity == null) return false; + + if (entity.EntityType < 10) return false; + + entity.IsDestroyed = true; + Moves--; + + return true; + } + + public void TrySwap(Vec2 src, Vec2 dst) + { + Entity? source = GetEntityAt(src.X, src.Y); + Entity? target = GetEntityAt(dst.X, dst.Y); + + if (source == null || target == null) { + return; + } + + if (source.IsFalling + || target.IsFalling + || source.IsDestroyed + || target.IsDestroyed + || source.EntityType == target.EntityType) { + return; + } + + State = GameState.Swapping; + + _swapSource = source; + _swapTarget = target; + + _swapSourcePosition = source.SpritePosition; + _swapTargetPosition = target.SpritePosition; + + _swapSource.OverridePosition = target.SpritePosition; + _swapTarget.OverridePosition = source.SpritePosition; + } + + private void UpdateSwapPosition(float deltaTime) + { + if (_swapSource?.OverridePosition == null && _swapTarget?.OverridePosition == null) { + if (_hasSwapped) { + _hasSwapped = false; + _swapSource = null; + _swapTarget = null; + _swapSourcePosition = null; + _swapTargetPosition = null; + State = GameState.Idle; + return; + } + + // Test for a match. + Vec2 tP = _swapTarget!.CellPosition; + Vec2 sP = _swapSource!.CellPosition; + + Grid[tP.X, tP.Y] = _swapSource; + Grid[sP.X, sP.Y] = _swapTarget; + + var match1 = GetMatchType(tP.X, tP.Y); + var match2 = GetMatchType(sP.X, sP.Y); + + if (match1.Type != MatchType.None || match2.Type != MatchType.None) { + Moves--; + + _swapSource.CellPosition = tP; + _swapTarget.CellPosition = sP; + + State = GameState.Idle; + _hasSwapped = false; + _swapSource = null; + _swapTarget = null; + _swapSourcePosition = null; + _swapTargetPosition = null; + + ProcessMatch(match1); + ProcessMatch(match2); + return; + } + + Grid[sP.X, sP.Y] = _swapSource; + Grid[tP.X, tP.Y] = _swapTarget; + + _hasSwapped = true; + _swapSource!.OverridePosition = _swapSourcePosition; + _swapTarget!.OverridePosition = _swapTargetPosition; + return; + } + + _swapSource?.UpdateSwap(deltaTime); + _swapTarget?.UpdateSwap(deltaTime); + } + + private readonly Dictionary _lastSfxPlayedAt = []; + + public void PlaySound(uint id) + { + if (!EnableSfx || !Active) return; + + long now = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + if (_lastSfxPlayedAt.TryGetValue(id, out long lastPlayedAt) && now - lastPlayedAt < 500) return; + _lastSfxPlayedAt[id] = now; + + UIModule.PlaySound(id); + } +} diff --git a/Umbra.Bejeweled/src/Game/Entities/Bomb.cs b/Umbra.Bejeweled/src/Game/Entities/Bomb.cs new file mode 100644 index 0000000..5f9b811 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Entities/Bomb.cs @@ -0,0 +1,58 @@ +using System; + +namespace Umbra.Bejeweled.Game.Entities; + +internal class Bomb(Board board, Vec2 cellPosition, IconIds iconIds) : Entity(10, board, cellPosition) +{ + private readonly Board _board = board; + + private int _shrinkSize = 4; + private int _growSize = -8; + private bool _isInvoked; + + public override uint GetIconId() + { + return iconIds.Bomb; + } + + protected override void OnDraw(float deltaTime) + { + _growSize++; + if (_growSize > 4) _growSize = 4; + + DrawIcon(iconIds.Bomb, _growSize); + } + + protected override bool OnDrawDestroyed(float deltaTime) + { + if (!_isInvoked) { + _isInvoked = true; + + int x1 = CellPosition.X - 1; + int x2 = CellPosition.X + 1; + int y1 = CellPosition.Y - 1; + int y2 = CellPosition.Y + 1; + + x1 = Math.Max(x1, 0); + x2 = Math.Min(x2, _board.Width); + y1 = Math.Max(y1, 0); + y2 = Math.Min(y2, _board.Height); + + for (var y = y1; y <= y2; y++) { + for (var x = x1; x <= x2; x++) { + if (x == CellPosition.X && y == CellPosition.Y) continue; + _board.ClearCell(new(x, y)); + } + } + + _board.PlaySound(78); + } + + _shrinkSize++; + if (_shrinkSize > 30) return true; + + DrawIcon(iconIds.Bomb, _shrinkSize); + + return false; + } +} diff --git a/Umbra.Bejeweled/src/Game/Entities/Gem.cs b/Umbra.Bejeweled/src/Game/Entities/Gem.cs new file mode 100644 index 0000000..ecc8785 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Entities/Gem.cs @@ -0,0 +1,46 @@ +using System.Numerics; +using ImGuiNET; + +namespace Umbra.Bejeweled.Game.Entities; + +internal class Gem(Board board, Vec2 cellPosition, byte type, IconIds iconIds) : Entity(type, board, cellPosition) +{ + private int _shrinkSize = 4; + private int _growSize = 30; + + /// + protected override void OnDraw(float deltaTime) + { + _growSize--; + if (_growSize < 4) _growSize = 4; + + DrawIcon(GetIconId(), _growSize); + + ImGui + .GetForegroundDrawList() + .AddText(Rect.TopLeft + new Vector2(5, 5), 0xFFFFFFFF, $"{EntityType}"); + } + + /// + protected override bool OnDrawDestroyed(float deltaTime) + { + _shrinkSize++; + if (_shrinkSize > 30) return true; + + DrawIcon(GetIconId(), _shrinkSize); + return false; + } + + public override uint GetIconId() + { + return EntityType switch { + 1 => iconIds.GemType1, + 2 => iconIds.GemType2, + 3 => iconIds.GemType3, + 4 => iconIds.GemType4, + 5 => iconIds.GemType5, + 6 => iconIds.GemType6, + _ => 14u, + }; + } +} diff --git a/Umbra.Bejeweled/src/Game/Entities/HorizontalRocket.cs b/Umbra.Bejeweled/src/Game/Entities/HorizontalRocket.cs new file mode 100644 index 0000000..e14fe6b --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Entities/HorizontalRocket.cs @@ -0,0 +1,44 @@ +namespace Umbra.Bejeweled.Game.Entities; + +internal class HorizontalRocket(Board board, Vec2 cellPosition, IconIds iconIds) : Entity(11, board, cellPosition) +{ + private readonly Board _board = board; + + private int _shrinkSize = 4; + private int _growSize = -8; + private bool _isInvoked = false; + + public override uint GetIconId() + { + return iconIds.HorizontalRocket; + } + + protected override void OnDraw(float deltaTime) + { + _growSize++; + if (_growSize > 4) _growSize = 4; + + DrawIcon(iconIds.HorizontalRocket, _growSize); + } + + protected override bool OnDrawDestroyed(float deltaTime) + { + if (!_isInvoked) { + _isInvoked = true; + + for (var x = 0; x < _board.Width; x++) { + if (CellPosition.X == x) continue; + _board.ClearCell(new(x, CellPosition.Y)); + } + + _board.PlaySound(78); + } + + _shrinkSize++; + if (_shrinkSize > 30) return true; + + DrawIcon(iconIds.HorizontalRocket, _shrinkSize); + + return false; + } +} diff --git a/Umbra.Bejeweled/src/Game/Entities/Particle.cs b/Umbra.Bejeweled/src/Game/Entities/Particle.cs new file mode 100644 index 0000000..c105721 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Entities/Particle.cs @@ -0,0 +1,101 @@ +using System; +using System.Numerics; +using ImGuiNET; + +namespace Umbra.Bejeweled.Game.Entities; + +internal sealed class Particle( + byte entityType, + Board board, + Vec2 cellPosition, + float lifeTime = 1000, + uint iconId = 14 +) : Entity( + entityType, + board, + cellPosition +) +{ + private readonly Board _board = board; + + private float _elapsedTime; + private float _size; + private float _opacity = 1.0f; + private uint _color = 0xFFFFFFFF; + private Vector2 _acceleration = new(0, 0); + private Vector2 _velocity = new(0, 0); + private Vector2 _position = new(0, 0); + private Vector2 _uv1 = new(0, 0); + private Vector2 _uv2 = new(1, 1); + + protected override void OnDraw(float deltaTime) + { + if (_elapsedTime == 0) { + int h = Board.CellSize / 2; + int x = new Random().Next(-h, h); + int y = new Random().Next(-h, h); + + float uvOffset = ((float)new Random().NextDouble() * 0.25f) + 0.2f; + _uv1 = new Vector2(uvOffset, uvOffset); + _uv2 = new Vector2(1 - uvOffset, 1 - uvOffset); + + _size = new Random().Next(2, 10); + _position = _board.Viewport.TopLeft + SpritePosition + new Vector2(x + h, y + h); + + _acceleration = new Vector2( + (float)(-1 + 2 * new Random().NextDouble()), + (float)(-1 + 2 * new Random().NextDouble()) + ) + * 100; + } + + _elapsedTime += (deltaTime * 1000); + + if (_elapsedTime >= lifeTime) { + IsDestroyed = true; + return; + } + + _opacity = 1.0f - (_elapsedTime / lifeTime); + + _velocity += _acceleration * deltaTime; + _position += _velocity * deltaTime; + + float size = MathF.Max(2, _size * (1 - _opacity)); + + if (_opacity > 0.05f) { + ImGui.GetForegroundDrawList().AddImageRounded( + TextureProvider.GetFromGameIcon(new(iconId)).GetWrapOrEmpty().ImGuiHandle, + _position - new Vector2(size), + _position + new Vector2(size), + _uv1, + _uv2, + GetColorWithOpacity(), + _size / 2f + ); + //ImGui.GetForegroundDrawList().AddCircleFilled(_position, size, GetColorWithOpacity()); + } + } + + protected override bool OnDrawDestroyed(float deltaTime) + { + return true; + } + + private uint GetColorWithOpacity() + { + var a = (byte)((_color & 0xFF000000) >> 24); + var b = (byte)((_color & 0x00FF0000) >> 16); + var g = (byte)((_color & 0x0000FF00) >> 8); + var r = (byte)((_color & 0x000000FF) >> 0); + + a = (byte)(a * _opacity); + + return (uint)((a << 24) | (b << 16) | (g << 8) | r); + } + + public override uint GetIconId() + { + return 0; + } +} diff --git a/Umbra.Bejeweled/src/Game/Entities/RainbowBomb.cs b/Umbra.Bejeweled/src/Game/Entities/RainbowBomb.cs new file mode 100644 index 0000000..5955315 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Entities/RainbowBomb.cs @@ -0,0 +1,56 @@ +namespace Umbra.Bejeweled.Game.Entities; + +internal class RainbowBomb(Board board, Vec2 cellPosition, IconIds iconIds) : Entity(13, board, cellPosition) +{ + private readonly Board _board = board; + + private int _shrinkSize = 4; + private int _growSize = -8; + private int _frameCounter; + + public override uint GetIconId() + { + return iconIds.RainbowBomb; + } + + protected override void OnDraw(float deltaTime) + { + _growSize++; + if (_growSize > 4) _growSize = 4; + + DrawIcon(iconIds.RainbowBomb, _growSize); + } + + protected override bool OnDrawDestroyed(float deltaTime) + { + if (_frameCounter < 60) { + _frameCounter++; + + if (_frameCounter == 1) { + _board.PlaySound(70); + } + + if (_frameCounter == 60) { + byte type = _board.GetDominantGemType(); + + for (var y = 0; y < _board.Height; y++) { + for (var x = 0; x < _board.Width; x++) { + var gem = _board.GetEntityAt(x, y); + if (gem == null) continue; + if (gem.EntityType == type || gem.EntityType >= 10) _board.ClearCell(new(x, y)); + } + } + + _board.PlaySound(78); + return true; + } + } + + _shrinkSize++; + if (_shrinkSize > 30) _shrinkSize = 30; + + DrawIcon(iconIds.RainbowBomb, _shrinkSize); + + return false; + } +} diff --git a/Umbra.Bejeweled/src/Game/Entities/VerticalRocket.cs b/Umbra.Bejeweled/src/Game/Entities/VerticalRocket.cs new file mode 100644 index 0000000..de4e421 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Entities/VerticalRocket.cs @@ -0,0 +1,44 @@ +namespace Umbra.Bejeweled.Game.Entities; + +internal class VerticalRocket(Board board, Vec2 cellPosition, IconIds iconIds) : Entity(12, board, cellPosition) +{ + private readonly Board _board = board; + + private int _shrinkSize = 4; + private int _growSize = -8; + private bool _isInvoked; + + public override uint GetIconId() + { + return iconIds.VerticalRocket; + } + + protected override void OnDraw(float deltaTime) + { + _growSize++; + if (_growSize > 4) _growSize = 4; + + DrawIcon(iconIds.VerticalRocket, _growSize); + } + + protected override bool OnDrawDestroyed(float deltaTime) + { + if (!_isInvoked) { + _isInvoked = true; + + for (var y = 0; y < _board.Height; y++) { + if (CellPosition.Y == y) continue; + _board.ClearCell(new(CellPosition.X, y)); + } + + _board.PlaySound(78); + } + + _shrinkSize++; + if (_shrinkSize > 30) return true; + + DrawIcon(iconIds.VerticalRocket, _shrinkSize); + + return false; + } +} diff --git a/Umbra.Bejeweled/src/Game/Entity.cs b/Umbra.Bejeweled/src/Game/Entity.cs new file mode 100644 index 0000000..a2a8d6f --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Entity.cs @@ -0,0 +1,193 @@ +using System; +using System.Numerics; +using Dalamud.Plugin.Services; +using ImGuiNET; +using Umbra.Common; +using Una.Drawing; + +namespace Umbra.Bejeweled.Game; + +internal abstract class Entity(byte entityType, Board board, Vec2 cellPosition) +{ + public byte EntityType { get; private set; } = entityType; + + /// + /// The cell position of the entity. + /// + public Vec2 CellPosition { get; set; } = cellPosition; + + /// + /// Returns the sprite position of the entity. + /// + public Vector2 SpritePosition { get; private set; } = new( + Board.CellSize * cellPosition.X, + Board.CellSize * cellPosition.Y + ); + + /// + /// Moves the entity to the specified position. Once reached, the + /// value of is set to null. This + /// value should be similar to that of . + /// + public Vector2? OverridePosition { get; set; } = null; + + public bool IsDestroyed { get; set; } + public bool IsAlive { get; private set; } = true; + public bool IsFalling { get; private set; } + + private const int FallSpeed = 1500; + + private Board Board { get; } = board; + private Vector2 Velocity { get; set; } = Vector2.Zero; + private Vector2 Acceleration { get; set; } = new(0, FallSpeed); + private Vector2 MinVelocity { get; } = new(-FallSpeed, -FallSpeed); + private Vector2 MaxVelocity { get; } = new(FallSpeed, FallSpeed); + + protected ITextureProvider TextureProvider => _textureProvider ??= Framework.Service(); + private ITextureProvider? _textureProvider; + + public void UpdateSwap(float deltaTime) + { + if (!IsAlive) return; + + if (OverridePosition != null) { + Vector2 targetPos = OverridePosition.Value; + Vector2 newPos = Vector2.Lerp(SpritePosition, targetPos, 0.1f); + + if (Vector2.Distance(newPos, targetPos) < 1) { + SpritePosition = targetPos; + OverridePosition = null; + return; + } + + SpritePosition = newPos; + } + } + + public void UpdatePhysics(float deltaTime) + { + if (!IsAlive) return; + + float multiplier = MathF.Min(2, 1 + (Board.ScoreMultiplier / 10f)); + + Velocity += Acceleration * (deltaTime * multiplier); + Velocity = Vector2.Clamp(Velocity, MinVelocity, MaxVelocity); + + Vector2 newPos = SpritePosition + Velocity * (deltaTime * multiplier); + + if (newPos.Y > SpritePosition.Y + (Board.CellSize / 4)) { + newPos.Y = SpritePosition.Y + (Board.CellSize / 4); + } + + if (ClampToFloor(newPos)) { + StopFalling(); + return; + } + + if (ClampToCell(newPos)) { + StopFalling(); + return; + } + + SpritePosition = newPos; + CellPosition = new((int)newPos.X / Board.CellSize, (int)newPos.Y / Board.CellSize); + IsFalling = true; + } + + public void Render(float deltaTime) + { + if (!IsAlive) return; + + if (IsDestroyed) { + IsAlive = !OnDrawDestroyed(deltaTime); + return; + } + + OnDraw(deltaTime); + } + + /// + /// Invoked on every frame for as long as the entity is alive and not destroyed. + /// + /// The elapsed time in milliseconds since the last frame. + protected abstract void OnDraw(float deltaTime); + + /// + /// Invoked on every frame when the entity has been marked as destroyed. The entity + /// is considered dead when this method returns true. + /// + /// + /// + protected abstract bool OnDrawDestroyed(float deltaTime); + + public abstract uint GetIconId(); + + /// + /// Draws an icon at the entity's position. + /// + protected void DrawIcon(uint iconId, int padding = 4) + { + Rect r = new Rect(Rect.TopLeft, Rect.BottomRight); + r.Shrink(new(padding)); + + ImGui + .GetForegroundDrawList() + .AddImage( + TextureProvider.GetFromGameIcon(new(iconId)).GetWrapOrEmpty().ImGuiHandle, + r.TopLeft, + r.BottomRight, + Vector2.Zero, + Vector2.One, + 0xFFFFFFFF + ); + } + + public Rect Rect => new(Board.Viewport.TopLeft + SpritePosition, new Size(Board.CellSize)); + + private float FloorY => Board.Viewport.BottomRight.Y - Board.Viewport.TopLeft.Y - Board.CellSize; + + private bool ClampToFloor(Vector2 position) + { + if (position.Y > FloorY) { + position.Y = FloorY; + + SpritePosition = position; + CellPosition = new((int)position.X / Board.CellSize, (int)position.Y / Board.CellSize); + return true; + } + + return false; + } + + private bool ClampToCell(Vector2 position) + { + Vec2 cellPos = new((int)position.X / Board.CellSize, (int)position.Y / Board.CellSize); + + if (Board.IsCellEmptyBelow(cellPos)) { + return false; + } + + // Calculate the target position. + Vector2 targetPos = new( + cellPos.X * Board.CellSize, + cellPos.Y * Board.CellSize + ); + + // If the entity is close enough to the target position, snap to it. + if (Vector2.Distance(position, targetPos) < 16) { + SpritePosition = targetPos; + CellPosition = cellPos; + return true; + } + + return false; + } + + private void StopFalling() + { + if (!IsFalling && Velocity.Y == 0) return; + + Velocity = Vector2.Zero; + IsFalling = false; + } +} diff --git a/Umbra.Bejeweled/src/Game/GameState.cs b/Umbra.Bejeweled/src/Game/GameState.cs new file mode 100644 index 0000000..34ea57c --- /dev/null +++ b/Umbra.Bejeweled/src/Game/GameState.cs @@ -0,0 +1,12 @@ +namespace Umbra.Bejeweled.Game; + +public enum GameState +{ + Idle, + + Falling, + + Swapping, + + GameOver, +} diff --git a/Umbra.Bejeweled/src/Game/IconIds.cs b/Umbra.Bejeweled/src/Game/IconIds.cs new file mode 100644 index 0000000..e343738 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/IconIds.cs @@ -0,0 +1,15 @@ +namespace Umbra.Bejeweled.Game; + +internal sealed class IconIds +{ + public uint GemType1 { get; set; } = 21275; + public uint GemType2 { get; set; } = 21281; + public uint GemType3 { get; set; } = 21283; + public uint GemType4 { get; set; } = 21284; + public uint GemType5 { get; set; } = 21289; + public uint GemType6 { get; set; } = 21293; + public uint Bomb { get; set; } = 60728; + public uint HorizontalRocket { get; set; } = 60727; + public uint VerticalRocket { get; set; } = 60726; + public uint RainbowBomb { get; set; } = 60722; +} diff --git a/Umbra.Bejeweled/src/Game/MatchType.cs b/Umbra.Bejeweled/src/Game/MatchType.cs new file mode 100644 index 0000000..64c14c6 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/MatchType.cs @@ -0,0 +1,39 @@ +namespace Umbra.Bejeweled.Game; + +public enum MatchType +{ + /// + /// A match of 5 gems in either axis. + /// + Rainbow = 6, + + /// + /// A T-shape match. + /// + TeeShape = 5, + + /// + /// A square 2x2 match. + /// + Bomb = 4, + + /// + /// Horizontal rocket + /// + HorizontalRocket = 3, + + /// + /// Vertical rocket + /// + VerticalRocket = 2, + + /// + /// Default match of 3 gems. + /// + Default = 1, + + /// + /// No match. + /// + None = 0 +} diff --git a/Umbra.Bejeweled/src/Game/Vec2.cs b/Umbra.Bejeweled/src/Game/Vec2.cs new file mode 100644 index 0000000..3a9c839 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Vec2.cs @@ -0,0 +1,18 @@ +using System; + +namespace Umbra.Bejeweled.Game; + +public readonly struct Vec2(int x, int y) +{ + public int X { get; } = x; + public int Y { get; } = y; + + public static Vec2 operator +(Vec2 a, Vec2 b) => new Vec2(a.X + b.X, a.Y + b.Y); + public static Vec2 operator -(Vec2 a, Vec2 b) => new Vec2(a.X - b.X, a.Y - b.Y); + + public static bool operator ==(Vec2 a, Vec2 b) => a.X == b.X && a.Y == b.Y; + public static bool operator !=(Vec2 a, Vec2 b) => a.X != b.X || a.Y != b.Y; + + public override bool Equals(object? o) => o is Vec2 v && v == this; + public override int GetHashCode() => HashCode.Combine(X, Y); +} diff --git a/Umbra.Bejeweled/src/Game/Viewport.cs b/Umbra.Bejeweled/src/Game/Viewport.cs new file mode 100644 index 0000000..8675ef1 --- /dev/null +++ b/Umbra.Bejeweled/src/Game/Viewport.cs @@ -0,0 +1,15 @@ +using System.Numerics; + +namespace Umbra.Bejeweled.Game; + +public class Viewport() +{ + public Vector2 TopLeft { get; set; } = Vector2.Zero; + public Vector2 BottomRight { get; set; } = Vector2.One; + + public void Update(Vector2 topLeft, Vector2 bottomRight) + { + TopLeft = topLeft; + BottomRight = bottomRight; + } +} diff --git a/Umbra.Bejeweled/src/Popup/BejeweledPopup.Nodes.cs b/Umbra.Bejeweled/src/Popup/BejeweledPopup.Nodes.cs new file mode 100644 index 0000000..11cb5a4 --- /dev/null +++ b/Umbra.Bejeweled/src/Popup/BejeweledPopup.Nodes.cs @@ -0,0 +1,91 @@ +using Umbra.Bejeweled.Popup.Nodes; +using Una.Drawing; + +namespace Umbra.Bejeweled.Popup; + +internal sealed partial class BejeweledPopup +{ + protected override Node Node { get; } = new() { + Id = "Popup", + Stylesheet = Stylesheet, + ChildNodes = [ + new() { + Id = "Header", + ChildNodes = [ + new() { + Id = "Title", + ChildNodes = [ + new() { + Id = "Score", + ChildNodes = [ + new() { Id = "ScoreNumber" }, + new() { Id = "ScoreMultiplier" } + ] + }, + new() { + Id = "HiScore", + NodeValue = "Hi-Score: 0", + } + ] + }, + new() { + Id = "Moves", + ChildNodes = [ + new() { Id = "MovesNumber", NodeValue = "999" }, + new() { Id = "MovesLabel", NodeValue = "Moves" } + ] + }, + new() { + Id = "Buttons", + ChildNodes = [ + new ButtonNode("Reset", "Reset"), + ] + }, + ], + }, + CreateGameBoard() + ] + }; + + private static Node CreateGameBoard() + { + Node board = new() { Id = "Game" }; + + for (int y = 0; y < 8; y++) { + Node row = new() { ClassList = ["row"] }; + + for (int x = 0; x < 10; x++) { + Node cell = new() { + Id = $"Cell-{x}-{y}", + ClassList = ["cell"], + }; + + row.ChildNodes.Add(cell); + } + + board.ChildNodes.Add(row); + } + + board.AppendChild( + new() { + Id = "GameOver", + ChildNodes = [ + new() { + Id = "GameOverText", + NodeValue = "Game Over", + }, + new() { + Id = "GameOverScore", + NodeValue = "Score: 0", + }, + new() { + Id = "GameOverHiScore", + NodeValue = "Hi-Score: 0", + }, + ], + } + ); + + return board; + } +} diff --git a/Umbra.Bejeweled/src/Popup/BejeweledPopup.Stylesheet.cs b/Umbra.Bejeweled/src/Popup/BejeweledPopup.Stylesheet.cs new file mode 100644 index 0000000..20eaf92 --- /dev/null +++ b/Umbra.Bejeweled/src/Popup/BejeweledPopup.Stylesheet.cs @@ -0,0 +1,206 @@ +using Una.Drawing; + +namespace Umbra.Bejeweled.Popup; + +internal sealed partial class BejeweledPopup +{ + private static Stylesheet Stylesheet { get; } = new( + [ + new( + "#Popup", + new() { + Flow = Flow.Vertical, + } + ), + new( + "#Header", + new() { + Flow = Flow.Horizontal, + Size = new(656, 50), + BorderColor = new() { Bottom = new("Window.Border") }, + BorderWidth = new() { Bottom = 1 }, + } + ), + new( + "#Title", + new() { + Flow = Flow.Vertical, + Size = new(500, 50), + Padding = new(8), + } + ), + new( + "#Moves", + new() { + Anchor = Anchor.MiddleCenter, + Flow = Flow.Vertical, + Size = new(0, 36), + } + ), + new( + "#MovesNumber", + new() { + Anchor = Anchor.TopCenter, + TextAlign = Anchor.TopCenter, + FontSize = 20, + Color = new("Widget.PopupMenuText"), + OutlineColor = new("Widget.PopupMenuTextOutline"), + OutlineSize = 1, + Padding = new(0, 8), + Stretch = true, + Margin = new() { Top = 2 }, + } + ), + new( + "#MovesLabel", + new() { + Anchor = Anchor.TopCenter, + TextAlign = Anchor.TopCenter, + FontSize = 11, + Color = new("Widget.PopupMenuTextMuted"), + Padding = new(0, 8), + Stretch = true, + Margin = new() { Top = -2 }, + } + ), + new( + "#Score", + new() { + Flow = Flow.Horizontal, + Gap = 2, + } + ), + new( + "#ScoreNumber", + new() { + TextAlign = Anchor.TopLeft, + FontSize = 20, + Color = new("Widget.PopupMenuText"), + OutlineColor = new("Widget.PopupMenuTextOutline"), + OutlineSize = 1, + } + ), + new( + "#ScoreMultiplier", + new() { + TextAlign = Anchor.TopLeft, + FontSize = 12, + Color = new("Widget.PopupMenuTextMuted"), + OutlineColor = new("Widget.PopupMenuTextOutline"), + OutlineSize = 1, + Padding = new() { Top = 6 }, + } + ), + new( + "#HiScore", + new() { + Flow = Flow.Vertical, + Size = new(500, 0), + TextAlign = Anchor.MiddleLeft, + FontSize = 12, + Color = new("Widget.PopupMenuTextMuted"), + OutlineColor = new("Widget.PopupMenuTextOutline"), + OutlineSize = 1, + } + ), + new( + "#Buttons", + new() { + Anchor = Anchor.MiddleRight, + Padding = new(8), + Gap = 8, + } + ), + new( + "#Game", + new() { + Flow = Flow.Vertical, + Size = new(656, 530), + Padding = new(8), + } + ), + new( + ".row", + new() { } + ), + new( + ".cell", + new() { + Size = new(64, 64), + StrokeColor = new("Window.Border"), + StrokeWidth = 1, + } + ), + new( + ".cell:hover", + new() { + StrokeColor = new(0xFFFFFFFF), + StrokeWidth = 1, + } + ), + new( + ".cell.selected", + new() { + StrokeColor = new(0xFFFFFFFF), + StrokeWidth = 3, + } + ), + new( + ".cell.targeted", + new() { + StrokeColor = new(0xA0FFFFFF), + StrokeWidth = 2, + } + ), + new( + "#GameOver", + new() { + Flow = Flow.Vertical, + Anchor = Anchor.MiddleCenter, + } + ), + new( + "#GameOverText", + new() { + Anchor = Anchor.TopCenter, + TextAlign = Anchor.TopCenter, + FontSize = 32, + Color = new("Widget.PopupMenuText"), + OutlineColor = new("Widget.PopupMenuTextOutline"), + OutlineSize = 1, + Padding = new(8), + BorderColor = new() { Bottom = new("Window.Border") }, + BorderWidth = new() { Bottom = 2 }, + Size = new(250, 0), + Margin = new() { Bottom = 16 }, + } + ), + new( + "#GameOverScore", + new() { + Anchor = Anchor.TopCenter, + TextAlign = Anchor.TopCenter, + FontSize = 18, + Color = new("Widget.PopupMenuText"), + OutlineColor = new("Widget.PopupMenuTextOutline"), + OutlineSize = 1, + Padding = new(2), + Size = new(250, 0), + } + ), + new( + "#GameOverHiScore", + new() { + Anchor = Anchor.TopCenter, + TextAlign = Anchor.TopCenter, + FontSize = 14, + Color = new("Widget.PopupMenuTextMuted"), + OutlineColor = new("Widget.PopupMenuTextOutline"), + OutlineSize = 1, + Padding = new(2) { Bottom = 16 }, + Size = new(250, 0), + } + ) + ] + ); +} diff --git a/Umbra.Bejeweled/src/Popup/BejeweledPopup.cs b/Umbra.Bejeweled/src/Popup/BejeweledPopup.cs new file mode 100644 index 0000000..2d1e667 --- /dev/null +++ b/Umbra.Bejeweled/src/Popup/BejeweledPopup.cs @@ -0,0 +1,192 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Client.UI; +using Umbra.Bejeweled.Game; +using Umbra.Widgets; +using Una.Drawing; + +namespace Umbra.Bejeweled.Popup; + +internal sealed partial class BejeweledPopup : WidgetPopup +{ + public uint Score { get; set; } + public uint HiScore { get; set; } + public bool Sound { get; set; } = true; + public Board Board { get; private set; } + + private Vec2? SelectedCell { get; set; } + private Node? SelectedNode { get; set; } + private Vec2? TargetedCell { get; set; } + private Node? TargetedNode { get; set; } + private uint TargetScore { get; set; } + + private Viewport Viewport { get; set; } + + public BejeweledPopup() + { + Viewport = new(); + Board = new(Viewport, new(10, 8)); + + Node.QuerySelector("#Reset")!.OnMouseUp += _ => ResetGame(); + + foreach (Node cell in Node.QuerySelectorAll(".cell")) { + cell.OnMouseUp += OnCellClicked; + cell.OnMouseEnter += OnCellMouseEnter; + } + + ResetGame(); + } + + /// + protected override void OnOpen() + { + Board.Active = true; + } + + /// + protected override void OnClose() + { + Board.Active = false; + } + + /// + protected override unsafe void OnUpdate() + { + if (Board.State == GameState.GameOver) { + foreach (var cell in Node.QuerySelectorAll(".cell")) cell.Style.IsVisible = false; + Node.QuerySelector("#GameOver")!.Style.IsVisible = true; + Node.QuerySelector("#GameOverScore")!.NodeValue = $"Score: {Board.Score:N0}"; + Node.QuerySelector("#GameOverHiScore")!.NodeValue = $"Hi-Score: {HiScore:N0}"; + return; + } + + float deltaTime = Framework.Instance()->FrameDeltaTime; + Vector2 topLeft = Node.QuerySelector("#Cell-0-0")!.Bounds.MarginRect.TopLeft; + Vector2 bottomRight = Node.QuerySelector("#Cell-9-7")!.Bounds.MarginRect.BottomRight; + + for (var y = 0; y < 8; y++) { + for (var x = 0; x < 10; x++) { + var cell = Node.QuerySelector($"#Cell-{x}-{y}")!; + cell.ToggleClass("selected", SelectedNode == cell); + cell.ToggleClass("targeted", TargetedNode == cell); + + cell.IsDisabled = Board.State != GameState.Idle; + + if (cell.IsDisabled && cell.TagsList.Contains("hover")) { + cell.TagsList.Remove("hover"); + } + } + } + + Viewport.Update(topLeft, bottomRight); + Board.Update(deltaTime); + + TargetScore = Board.Score; + + if (Score < TargetScore) { + Score += Math.Max(1, (uint)((TargetScore - Score) * deltaTime * 10)); + } + + if (Score > HiScore) { + HiScore = Score; + } + + Node.QuerySelector("#GameOver")!.Style.IsVisible = false; + Node.QuerySelector("#ScoreNumber")!.NodeValue = $"Score: {Score:N0}"; + Node.QuerySelector("#ScoreMultiplier")!.NodeValue = $"x{Board.ScoreMultiplier}"; + Node.QuerySelector("#HiScore")!.NodeValue = $"Hi-Score: {HiScore:N0}"; + Node.QuerySelector("#MovesNumber")!.NodeValue = $"{Board.Moves}"; + } + + private void ResetSelection() + { + SelectedCell = null; + SelectedNode = null; + TargetedCell = null; + TargetedNode = null; + } + + private void OnCellClicked(Node node) + { + if (Board.State != GameState.Idle) return; + + byte x = byte.Parse(node.Id!.Split('-')[1]); + byte y = byte.Parse(node.Id!.Split('-')[2]); + + if (SelectedNode == node) { + ResetSelection(); + PlaySound(15); + return; + } + + if (SelectedCell == null) { + if (Board.TryInvokePowerUp(new(x, y))) { + return; + } + + SelectedCell = new(x, y); + SelectedNode = node; + PlaySound(5); + return; + } + + // Make sure the selected cell is adjacent to the clicked cell. + if (Math.Abs(SelectedCell.Value.X - x) + Math.Abs(SelectedCell.Value.Y - y) != 1) { + SelectedCell = new(x, y); + SelectedNode = node; + PlaySound(5); + return; + } + + // Swap the gems + if (SelectedCell != null && TargetedCell != null) { + Board.TrySwap(SelectedCell.Value, TargetedCell.Value); + PlaySound(10); + ResetSelection(); + } + } + + private void PlaySound(uint soundId) + { + if (Sound) UIModule.PlaySound(soundId); + } + + private void OnCellMouseEnter(Node node) + { + if (Board.State != GameState.Idle) return; + + if (SelectedCell == null || node == SelectedNode) { + // There is no target cell if there is no selected (source) cell or + // if the mouse is over the selected cell. + TargetedCell = null; + TargetedNode = null; + return; + } + + byte x = byte.Parse(node.Id!.Split('-')[1]); + byte y = byte.Parse(node.Id!.Split('-')[2]); + + // Make sure the target cell is adjacent to the selected cell. + if (Math.Abs(SelectedCell.Value.X - x) + Math.Abs(SelectedCell.Value.Y - y) != 1) { + TargetedCell = null; + TargetedNode = null; + return; + } + + TargetedCell = new(x, y); + TargetedNode = node; + } + + /// + /// Resets the game. + /// + public void ResetGame() + { + Score = 0; + TargetScore = 0; + Board.Reset(); + + foreach (var cell in Node.QuerySelectorAll(".cell")) cell.Style.IsVisible = true; + } +} diff --git a/Umbra.Bejeweled/src/Popup/Nodes/ButtonNode.cs b/Umbra.Bejeweled/src/Popup/Nodes/ButtonNode.cs new file mode 100644 index 0000000..cc47baa --- /dev/null +++ b/Umbra.Bejeweled/src/Popup/Nodes/ButtonNode.cs @@ -0,0 +1,168 @@ +using Dalamud.Interface; +using Una.Drawing; + +namespace Umbra.Bejeweled.Popup.Nodes; + +internal class ButtonNode : Node +{ + public string? Label { + set => QuerySelector("Label")!.NodeValue = value; + } + + public FontAwesomeIcon? Icon { + set => QuerySelector("Icon")!.NodeValue = value?.ToIconString(); + } + + public bool IsGhost { get; set; } + + public ButtonNode( + string id, string? label, FontAwesomeIcon? icon = null, bool isGhost = false, bool isSmall = false + ) + { + Id = id; + IsGhost = isGhost; + ClassList = ["button"]; + Stylesheet = ButtonStylesheet; + + if (isSmall) TagsList.Add("small"); + + ChildNodes = [ + new() { Id = "Icon", ClassList = ["button--icon"], InheritTags = true }, + new() { Id = "Label", ClassList = ["button--label"], InheritTags = true }, + ]; + + Label = label; + Icon = icon; + + BeforeReflow += _ => { + switch (IsGhost) { + case true when !ClassList.Contains("ghost"): + ClassList.Add("ghost"); + break; + case false when ClassList.Contains("ghost"): + ClassList.Remove("ghost"); + break; + } + + QuerySelector("Icon")!.Style.IsVisible = QuerySelector("Icon")!.NodeValue is not null; + QuerySelector("Label")!.Style.IsVisible = QuerySelector("Label")!.NodeValue is not null; + + if (IsDisabled) { + QuerySelector("Label")!.TagsList.Add("disabled"); + QuerySelector("Icon")!.TagsList.Add("disabled"); + } else { + QuerySelector("Label")!.TagsList.Remove("disabled"); + QuerySelector("Icon")!.TagsList.Remove("disabled"); + } + + return true; + }; + } + + private static Stylesheet ButtonStylesheet { get; } = new( + [ + new( + ".button", + new() { + Size = new(0, 28), + Padding = new(0, 8), + BorderRadius = 5, + StrokeInset = 1, + StrokeWidth = 1, + Gap = 6, + BackgroundColor = new("Input.Background"), + StrokeColor = new("Input.Border"), + IsAntialiased = false, + } + ), + new( + ".button:small", + new() { + Size = new(0, 20), + Padding = new(0, 5), + } + ), + new( + ".button:hover", + new() { + BackgroundColor = new("Input.BackgroundHover"), + StrokeColor = new("Input.BorderHover"), + } + ), + new( + ".button:disabled", + new() { + Color = new("Input.TextDisabled"), + OutlineColor = new("Input.TextOutlineDisabled"), + BackgroundColor = new("Input.BackgroundDisabled"), + StrokeColor = new("Input.BorderDisabled"), + } + ), + new( + ".button.ghost", + new() { + BackgroundColor = new(0), + StrokeColor = new(0), + } + ), + new( + ".button--icon", + new() { + Anchor = Anchor.MiddleLeft, + TextAlign = Anchor.MiddleCenter, + FontSize = 13, + Font = 2, + Size = new(0, 28), + Color = new("Input.Text"), + OutlineColor = new("Input.TextOutline"), + } + ), + new( + ".button--label", + new() { + Anchor = Anchor.MiddleLeft, + TextAlign = Anchor.MiddleCenter, + Size = new(0, 28), + FontSize = 13, + OutlineSize = 1, + Color = new("Input.Text"), + OutlineColor = new("Input.TextOutline"), + } + ), + new( + ".button--label:small", + new() { + FontSize = 11, + } + ), + new( + ".button--icon:hover", + new() { + Color = new("Input.TextHover"), + OutlineColor = new("Input.TextOutlineHover"), + } + ), + new( + ".button--label:hover", + new() { + Color = new("Input.TextHover"), + OutlineColor = new("Input.TextOutlineHover"), + } + ), + new( + ".button--icon:disabled", + new() { + Color = new("Input.TextDisabled"), + OutlineColor = new("Input.TextOutlineDisabled"), + } + ), + new( + ".button--label:disabled", + new() { + Color = new("Input.TextDisabled"), + OutlineColor = new("Input.TextOutlineDisabled"), + } + ) + ] + ); +}