diff --git a/AGENTS.md b/AGENTS.md index dc7745e..908fd59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,9 @@ These are the working rules for agents in this repo. Puzzle Boy is a faithful, c ## Code layout -- `external/puzzle-boy-engine`: the shared engine, a pinned git submodule. `PuzzleBoy.Rules` has stage parsing (`Stage`, `Cell`) and the rules (`Board`), with no MonoGame and no file I/O. `PuzzleBoy.Nocturne` finds the install (`GameInstall`) and decodes its bundles. `PuzzleBoy.Rendering` draws the board and Jack (`BoardView`, `CharacterView`, `CharacterModel`), runs the camera and move timing (`ViewCamera`, `MoveAnimation`) and plays `Sounds`. Don't edit them here: a change and its test go into the engine repo first, then this repo bumps the pin with `build: bump engine to `. Community Edition draws its Classic theme with the same code, so a change there changes both games. -- `src/PuzzleBoy`: the MonoGame (DesktopGL) game: the tick loop (`PuzzleBoyGame`), `GameAssets`, the HUD, dialogue, menu and `TextRenderer`. `OriginalStageOrder` lists the original's 20 stage files in order. `Program` finds the player's install through the engine's `GameInstall`, or takes the game folder as the first argument. It has no content pipeline; every asset is read at runtime. -- `tests/PuzzleBoy.Tests`: xunit.v3 on Microsoft Testing Platform, covering the game. The rules, asset-reading and rendering tests live in the engine. Tests that need a board draw it as text with `Ascii.cs`; its summary has the legend. Test names read as sentences. +- `external/puzzle-boy-engine`: the shared engine, a pinned git submodule. `PuzzleBoy.Rules` has stage parsing (`Stage`, `Cell`) and the rules (`Board`), with no MonoGame and no file I/O. `PuzzleBoy.Nocturne` finds the install (`GameInstall`) and decodes its bundles. `PuzzleBoy.Rendering` draws the board and Jack (`BoardView`, `CharacterView`, `CharacterModel`), runs the camera and move timing (`ViewCamera`, `MoveAnimation`), plays `Sounds`, and draws the original's interface (`Hud`, `TextRenderer`, `Menu`, `Dialogue`). Don't edit them here: a change and its test go into the engine repo first, then this repo bumps the pin with `build: bump engine to `. Community Edition draws its Classic theme with the same code, so a change there changes both games. +- `src/PuzzleBoy`: the MonoGame (DesktopGL) game: the tick loop (`PuzzleBoyGame`), which decides when the HUD, dialogue, menu and help show, and `GameAssets`. `OriginalStageOrder` lists the original's 20 stage files in order. `Program` finds the player's install through the engine's `GameInstall`, or takes the game folder as the first argument. It has no content pipeline; every asset is read at runtime. +- `tests/PuzzleBoy.Tests`: xunit.v3 on Microsoft Testing Platform, covering the game. The rules, asset-reading, rendering and UI tests live in the engine. Tests that need a board draw it as text with `Ascii.cs`; its summary has the legend. Test names read as sentences. ## Working style diff --git a/ROADMAP.md b/ROADMAP.md index 63067b3..eff4f7b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -285,15 +285,28 @@ Community Edition's Classic theme draws the original with this port's code, so t [v0.3.0](https://github.com/hazeliscoding/puzzle-boy/releases/tag/v0.3.0) published the split (PR #3): the original's 20 stages on the shared engine, after the start checks passed on all three systems ([run 38775719377](https://github.com/hazeliscoding/puzzle-boy/actions/runs/38775719377)). +## M7: Shared original UI (2026.10.15) + +Community Edition's Classic theme shows the original's HUD, controls and menus, so the code that draws them moves to the engine too, as in M6. Nothing about what this port shows, or when, may change. + +- [x] Move `Hud` (with `UiLayout`, `UiSheets`, `HudState` and `BarSet`), `TextRenderer` and `BakedText`, `Menu`, `MenuItem` and `KeyRepeat`, and `Dialogue` to the engine's `PuzzleBoy.Rendering`. `Dialogue` goes too because `Hud.DrawDialogue` takes one; when the Manikin Kid talks stays in `PuzzleBoyGame`. +- [x] Use them here, pinned at engine `49a07d8`, and delete the copies. + - Evidence (observed): each moved file diffed against `83c9565`, the last commit before the move. 8 files, 1,081 lines; 19 lines changed and 1 line removed. Every change is the namespace (8), `internal` to `public` on a type (11), or the `using PuzzleBoy.Rendering;` that `Hud.cs` no longer needs inside that namespace. No citation, statement, constant or expression changed. + - 28 of the 51 tests moved with the code: Hud 10, TextRenderer 10, Menu 4, Dialogue 4. The engine went from 238 to 266, and 23 stay here (Controls 17, PreservationScope 6). `ControlsTests` stays whole: every case reads `PuzzleBoyGame`'s key tables. + - `PuzzleBoy no-game-here` prints the same missing-game message from a debug build. + - Not checked: a screen drawn from the owner's install. The code is the same, but the screen wasn't compared. + +**Done when** the moved code differs from this port's last copy only in namespace and visibility, no test is lost, and every gate passes. + ## Cross-repo ledger (2026.10.15) | Repo | Role | Engine pin | |---|---|---| | [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine) | Headless rules (`PuzzleBoy.Rules`), plus reading (`PuzzleBoy.Nocturne`) and drawing (`PuzzleBoy.Rendering`) the player's own Nocturne files. Not a game. | — | -| [puzzle-boy](https://github.com/hazeliscoding/puzzle-boy) (this repo, `split/three-repos`, draft PR #3) | The preservation port: the original 20 stages from the player's own copy. | `2a38379` | +| [puzzle-boy](https://github.com/hazeliscoding/puzzle-boy) (this repo) | The preservation port: the original 20 stages from the player's own copy. | `49a07d8` on `shared-ui` (M7), `2a38379` on `main` | | [puzzle-boy-community](https://github.com/hazeliscoding/puzzle-boy-community) | The minigame expanded: new stages, editor, packs, bonus stages; Default theme bundled, Classic theme from the player's copy. | `2a38379` | -Both games must pin the same engine commit. An engine change lands there first, then both bump with `build: bump engine to `. +Both games must pin the same engine commit. An engine change lands there first, then both bump with `build: bump engine to `. Community bumps to `49a07d8` with its C7; until then the pins differ. **Definition of done, where it stands:** the engine (E1 to E3) and this port's split (M5, M6) are done, with CI green on GitHub. This port plays only the original 20 (`The_original_order_is_exactly_the_twenty_documented_files`, `The_original_order_holds_no_bonus_file`, `The_preservation_game_never_calls_Board_Switch`, `The_preservation_game_has_no_switch_or_stage_skip_key`). Community's C1 to C4 and C6 are done and C5 (its first release) is open; its own ROADMAP has the per-platform table. Still open: merging PR #3 (the owner's call), replaying the 20 stages from the owner's install since the split, signing, and hand-play on macOS and Linux. diff --git a/docs/architecture.md b/docs/architecture.md index 0c5a1f2..9ba1a5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # Architecture > [!TIP] -> **TL;DR:** There are two projects, plus the engine. The engine, [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine), is a git submodule in `external/puzzle-boy-engine` that Community Edition shares. Its `PuzzleBoy.Rules` holds the game's logic, with no graphics and no file access, so tests drive it with plain bytes. Its `PuzzleBoy.Nocturne` reads your install's bundles, and its `PuzzleBoy.Rendering` draws the board and Jack and plays the sounds. `PuzzleBoy` is the MonoGame app: it reads everything from your install once at startup, then runs the original's 30-tick loop and draws the HUD, dialogue and menu. +> **TL;DR:** There are two projects, plus the engine. The engine, [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine), is a git submodule in `external/puzzle-boy-engine` that Community Edition shares. Its `PuzzleBoy.Rules` holds the game's logic, with no graphics and no file access, so tests drive it with plain bytes. Its `PuzzleBoy.Nocturne` reads your install's bundles, and its `PuzzleBoy.Rendering` draws the board, Jack, the HUD, dialogue and menu and plays the sounds. `PuzzleBoy` is the MonoGame app: it reads everything from your install once at startup, then runs the original's 30-tick loop and decides what shows when. **Read time:** 4 minutes @@ -15,19 +15,20 @@ flowchart LR subgraph app["PuzzleBoy (MonoGame)"] order["OriginalStageOrder
which files, in what order"] --> ga ga["GameAssets
everything, decoded once"] --> loop["PuzzleBoyGame
the tick loop"] - loop --> hud["Hud, TextRenderer,
dialogue and menu"] end subgraph nocturne["PuzzleBoy.Nocturne, from the engine"] gi["GameInstall
finds and reads the bundles"] end subgraph rendering["PuzzleBoy.Rendering, from the engine"] views["BoardView, CharacterView,
ViewCamera, MoveAnimation, Sounds"] + hud["Hud, TextRenderer,
Dialogue and Menu"] end subgraph rules["PuzzleBoy.Rules, from the engine"] stage["Stage
parses 828-byte files"] --> board["Board
steps, pushes, swivels, undo"] end gi --> ga loop --> views + loop --> hud bundles --> gi ga -- "stage bytes" --> stage loop -- "Step, Undo, Retry" --> board @@ -77,7 +78,7 @@ sequenceDiagram | `GameInstall` | Finds the Steam install and reads bundles with AssetsTools.NET (engine, `PuzzleBoy.Nocturne`) | | `GameAssets` | Reads and decodes every asset once at startup, in about 2 seconds | | `PuzzleBoyGame` | The tick loop: input, menus, dialogue, stage flow | -| `ViewCamera` | The follow camera, view turns, the overhead view and the quake (engine, `PuzzleBoy.Rendering`, like the next three and `Sounds`) | +| `ViewCamera` | The follow camera, view turns, the overhead view and the quake (engine, `PuzzleBoy.Rendering`, like the next five and `Sounds`) | | `MoveAnimation` | Replays one move over its ticks | | `BoardView` | Board geometry, glass walls, outlines, the goal and the backdrop | | `CharacterModel`, `CharacterView` | Pose and skin Jack each tick, then draw him with his shadow and glow | diff --git a/docs/rendering-and-ui.md b/docs/rendering-and-ui.md index a1a9ab3..20524f6 100644 --- a/docs/rendering-and-ui.md +++ b/docs/rendering-and-ui.md @@ -3,7 +3,7 @@ > [!TIP] > **TL;DR:** The board is unlit, textured from the game's own atlas, with see-through glass walls and a glowing goal. Jack is skinned on the CPU and toon-shaded. The HUD keeps the original's 1920×1080 layout at any window size, and all text goes through a TextMeshPro distance-field renderer written for the CPU. The finer numbers were checked against gameplay footage. -**Read time:** 5 minutes · **Code:** `Hud.cs`, `TextRenderer.cs`, and `BoardView.cs`, `CharacterModel.cs` and `CharacterView.cs` in the engine's `PuzzleBoy.Rendering` +**Read time:** 5 minutes · **Code:** `Hud.cs`, `TextRenderer.cs`, `BoardView.cs`, `CharacterModel.cs` and `CharacterView.cs` in the engine's `PuzzleBoy.Rendering` ## Coordinates diff --git a/external/puzzle-boy-engine b/external/puzzle-boy-engine index 2a38379..49a07d8 160000 --- a/external/puzzle-boy-engine +++ b/external/puzzle-boy-engine @@ -1 +1 @@ -Subproject commit 2a38379251e28bd8b623fcb7c8c385b641127911 +Subproject commit 49a07d88f0e11c1ee3dd42f8af91ac3d273fac90 diff --git a/src/PuzzleBoy/Dialogue.cs b/src/PuzzleBoy/Dialogue.cs deleted file mode 100644 index 539e7f2..0000000 --- a/src/PuzzleBoy/Dialogue.cs +++ /dev/null @@ -1,155 +0,0 @@ -namespace PuzzleBoy; - -/// -/// The Manikin Kid's pages at a stage start, timed as in the HD footage (re/m3-ui.md, section 5): one character a tick, -/// the advance icon a tick after the last one, confirm to turn the page, then a short fade. -/// -/// The pages' text, with the original's <COLOR n> tags and line breaks. -/// -/// For a question, its options: they show once the last page has typed, with no advance icon, and the cursor starts on -/// the last one. Null for plain talk. -/// -internal sealed class Dialogue(IEnumerable pages, IReadOnlyList? choices = null) -{ - // After the last page the name and its plate fade over 2 ticks, the text and the window over 7. - private const int NameFade = 2; - private const int WindowFade = 7; - - private readonly List>> _pages = [.. pages.Select(Prepare)]; - private int _revealed; - private int _doneTicks; - private int _closing = -1; - - /// The stages whose start the Manikin Kid talks over, and his pages' string ids (re/m3-ui.md, 5.1). - public static IReadOnlyDictionary Stages { get; } = new Dictionary - { - [1] = ["PBMESSAGE_L0000", "PBMESSAGE_L0001", "PBMESSAGE_L0002"], - [6] = ["PBMESSAGE_L0003"], - [11] = ["PBMESSAGE_L0004"], - [16] = ["PBMESSAGE_L0005"], - [20] = ["PBMESSAGE_L0006"], - }; - - /// The speaker's name's string id, the same on every page. - public const string Speaker = "pbMessage_pb_mes_01_000"; - - /// Which page shows, counting from 0. - public int Page { get; private set; } - - /// The current page's lines, each a run of characters with the material its colour tag chose. - public IReadOnlyList> Lines => _pages[Page]; - - /// How many of the page's characters show so far; line breaks don't count. - public int Revealed => _revealed; - - /// True from the tick after the page's last character until confirm; a question shows its choices instead. - public bool ShowsIcon => _closing < 0 && _revealed >= Total && _doneTicks >= 1 && !ShowsChoices; - - /// The question's options, or null for plain talk. - public IReadOnlyList? Choices => choices; - - /// True once a question's last page has typed, until it's answered. - public bool ShowsChoices => choices is not null && _closing < 0 && Page == _pages.Count - 1 && _revealed >= Total; - - /// The option under the cursor; the original starts it on the last option, "No". - public int Choice { get; private set; } = (choices?.Count ?? 1) - 1; - - /// The window's and the text's opacity: 1, then fading over 7 ticks after the last page. - public float WindowOpacity => _closing < 0 ? 1 : 1 - (Math.Min(_closing, WindowFade) / (float)WindowFade); - - /// The name's and its plate's opacity, which fade faster. - public float NameOpacity => _closing < 0 ? 1 : 1 - (Math.Min(_closing, NameFade) / (float)NameFade); - - /// True once the fade has ended and play can go on. - public bool Finished => _closing >= WindowFade; - - private int Total => _pages[Page].Sum(line => line.Count); - - /// - /// A page's lines: leading line breaks (and the tags before them) dropped, split at line breaks, at most 3 lines. - /// Colour tags carry across the split. - /// - public static List> Prepare(string text) - { - var runs = TextRenderer.Runs(text); - var lines = new List> { new() }; - foreach (var run in runs.SkipWhile(r => r.Char == '\n')) - { - if (run.Char == '\n') - { - lines.Add([]); - } - else - { - lines[^1].Add(run); - } - } - - return [.. lines.Take(3)]; - } - - /// Advances one tick: types a character, counts toward the icon, or fades. - public void Tick() - { - if (_closing >= 0) - { - _closing++; - } - else if (_revealed < Total) - { - _revealed++; - } - else - { - _doneTicks++; - } - } - - /// - /// Confirm: finishes a page still typing (a port choice; the footage never shows it), turns a finished page, or - /// starts the fade after the last one. - /// - public void Confirm() - { - if (_closing >= 0) - { - return; - } - - if (_revealed < Total) - { - (_revealed, _doneTicks) = (Total, 0); - } - else if (ShowsChoices) - { - _closing = 0; - } - else if (ShowsIcon && Page + 1 < _pages.Count) - { - (Page, _revealed, _doneTicks) = (Page + 1, 0, 0); - } - else if (ShowsIcon) - { - _closing = 0; - } - } - - /// Moves a question's cursor options, wrapping. Does nothing when no choices show. - public void MoveChoice(int by) - { - if (ShowsChoices) - { - Choice = (((Choice + by) % choices!.Count) + choices.Count) % choices.Count; - } - } - - /// Cancel on a question: picks the last option, "No", and closes. - public void Cancel() - { - if (ShowsChoices) - { - Choice = choices!.Count - 1; - _closing = 0; - } - } -} diff --git a/src/PuzzleBoy/GameAssets.cs b/src/PuzzleBoy/GameAssets.cs index 37a82a1..3573514 100644 --- a/src/PuzzleBoy/GameAssets.cs +++ b/src/PuzzleBoy/GameAssets.cs @@ -1,4 +1,5 @@ using PuzzleBoy.Nocturne; +using PuzzleBoy.Rendering; using PuzzleBoy.Rules; namespace PuzzleBoy; diff --git a/src/PuzzleBoy/Hud.cs b/src/PuzzleBoy/Hud.cs deleted file mode 100644 index cf081a2..0000000 --- a/src/PuzzleBoy/Hud.cs +++ /dev/null @@ -1,488 +0,0 @@ -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using PuzzleBoy.Nocturne; -using PuzzleBoy.Rendering; - -namespace PuzzleBoy; - -/// The original's 1920x1080 UI layout scaled uniformly into the window and centred (re/m3-ui.md, section 1). -internal static class UiLayout -{ - /// The layout's scale and the offset that centres it in a window of by pixels. - public static (float Scale, Vector2 Offset) Fit(int width, int height) - { - var scale = Math.Min(width / 1920f, height / 1080f); - return (scale, new Vector2((width - (1920 * scale)) / 2, (height - (1080 * scale)) / 2)); - } -} - -/// Which controls the bar shows: the original's guide ids PZL01 (play) and PZL02 (scroll mode). -internal enum BarSet -{ - /// The bar is off, as during dialogue. - None, - - /// PZL01: Move, Rotate, Menu, Toggle Scroll, Angle. - Play, - - /// PZL02: Scroll, Rotate, Toggle Scroll, Angle. - Scroll, - - /// PZL03, while the menu is open: Select, Cancel, Confirm. - Menu, - - /// PZL04, while help is open: Cancel. - Help, -} - -/// What the HUD shows this tick. -/// The stage number, 1 to 20. -/// True while COMPLETE! shows. -/// True while UNDO shows. -/// The scroll arrows to show, one bit per screen direction: 1 up, 2 right, 4 down, 8 left. -/// Ticks since scroll mode began, for the arrows' bob. -/// Which controls the bar shows. -/// Ticks since the bar came on, for its fade-in. -internal readonly record struct HudState(int Stage, bool Clear, bool Undo, int Arrows, int ScrollTicks, BarSet Bar, int BarTicks); - -/// The UI sheets the HUD and menus draw from, read from the player's install. -/// pzlUI01 from common: plates, glows, arrows, the menu and help. -/// pzlUI_font01_en from ui_tex_en: STAGE, COMPLETE! and UNDO. -/// pzlUI_font01_ja from common, whose stage digits the original draws. -/// buttonUI01_en_keyboard from ui_tex_keyboard_en: key icons and the bar's base strip. -/// talkUI01 from common: the dialogue window, name plate and choice box. -/// buttonUI01_en_common from ui_tex_en: the dialogue's advance icon. -internal sealed record UiSheets(TextureData Sheet, TextureData Words, TextureData Digits, TextureData Keyboard, TextureData Talk, TextureData Buttons); - -/// -/// The stage counter, COMPLETE!, UNDO and the scroll arrows, at the original's layout (re/m3-ui.md, section 4). -/// Sprite rects are in sheet texels from the bottom-left, as Unity stores them; the sheets keep Unity's row order on the -/// GPU, so each sprite is drawn flipped. -/// -internal sealed class Hud : IDisposable -{ - // The scroll arrows: rest centre, sheet rect on pzlUI01, and the way each bobs toward the centre (layout y is down). - private static readonly (Vector2 Centre, Rectangle Sprite, Vector2 Inward)[] ScrollArrows = - [ - (new(960, 84), new(1152, 808, 72, 72), new(0, 1)), - (new(1774, 518), new(1380, 808, 72, 72), new(-1, 0)), - (new(960, 952), new(1228, 808, 72, 72), new(0, -1)), - (new(146, 518), new(1304, 808, 72, 72), new(1, 0)), - ]; - - // Key icons on the keyboard sheet (48x48). The original's PC keys: WASD move, Q/E turn, F menu, Tab scroll, V angle. - private static readonly Rectangle KeyW = new(1612, 60, 48, 48), KeyA = new(28, 60, 48, 48), KeyS = new(1324, 60, 48, 48), KeyD = new(244, 60, 48, 48); - private static readonly Rectangle KeyQ = new(1180, 60, 48, 48), KeyE = new(316, 60, 48, 48), KeyF = new(388, 60, 48, 48), KeyV = new(1540, 60, 48, 48); - private static readonly Rectangle KeyTab = new(460, 108, 48, 48), KeySpace = new(532, 108, 48, 48), KeyBackspace = new(316, 108, 48, 48); - - // In the menu and help, Space (or Enter) confirms and Backspace cancels; the original's keys for these are unknown. - private static readonly (Rectangle[] Keys, string Label)[] MenuSets = - [([KeyW, KeyS], "GUIDE_L0001"), ([KeyBackspace], "GUIDE_L0002"), ([KeySpace], "GUIDE_L0003")]; - - private static readonly (Rectangle[] Keys, string Label)[] HelpSets = [([KeyBackspace], "GUIDE_L0002")]; - - // Help's text: string id, material, box (x, width) for centring, pen x, baseline (re/m3-ui.md, section 7). - private static readonly (string Id, string Material, bool Centre, float X, float Width, float Baseline)[] HelpLines = - [ - ("PBHELP_L0034", "TMC12", true, 203, 210.01f, 205.96f), - ("PBHELP_L0035", "TMC24", false, 258, 0, 265.96f), - ("PBHELP_L0036", "TMC24", false, 258, 0, 315.96f), - ("PBHELP_L0037", "TMC12", true, 182, 252.01f, 395.96f), - ("PBHELP_L0038", "TMC25", false, 238, 0, 456.96f), - ("PBHELP_L0039", "TMC24", false, 258, 0, 505.96f), - ("PBHELP_L0040", "TMC24", false, 258, 0, 555.96f), - ("PBHELP_L0041", "TMC25", false, 238, 0, 638.96f), - ("PBHELP_L0042", "TMC24", false, 258, 0, 687.96f), - ("PBHELP_L0043", "TMC25", false, 238, 0, 770.96f), - ("PBHELP_L0044", "TMC24", false, 258, 0, 819.96f), - ("PBHELP_L0045", "TMC24", false, 258, 0, 869.96f), - ]; - - // The bar's sets, left to right: key icons and the label's string id. - private static readonly (Rectangle[] Keys, string Label)[] PlaySets = - [([KeyW, KeyA, KeyS, KeyD], "GUIDE_L0012"), ([KeyQ, KeyE], "GUIDE_L0021"), ([KeyF], "GUIDE_L0028"), ([KeyTab], "GUIDE_L0029"), ([KeyV], "GUIDE_L0032")]; - - private static readonly (Rectangle[] Keys, string Label)[] ScrollSets = - [([KeyW, KeyA, KeyS, KeyD], "GUIDE_L0016"), ([KeyQ, KeyE], "GUIDE_L0021"), ([KeyTab], "GUIDE_L0029"), ([KeyV], "GUIDE_L0032")]; - - private readonly GraphicsDevice _device; - private readonly SpriteBatch _sprites; - private readonly Texture2D _sheet; - private readonly Texture2D _words; - private readonly Texture2D _digits; - private readonly Texture2D _keyboard; - private readonly Texture2D _talk; - private readonly Texture2D _buttons; - private readonly TextData _text; - private readonly Dictionary _baked = []; - private readonly Dictionary _typed = []; - private (Dialogue Dialogue, int Page)? _typedPage; - private (float Scale, Vector2 Offset) _fit; - - /// Uploads the sheets; text is baked on first use. - public Hud(GraphicsDevice device, UiSheets sheets, TextData text) - { - _device = device; - _sprites = new SpriteBatch(device); - _sheet = BoardView.Upload(device, sheets.Sheet); - _words = BoardView.Upload(device, sheets.Words); - _digits = BoardView.Upload(device, sheets.Digits); - _keyboard = BoardView.Upload(device, sheets.Keyboard); - _talk = BoardView.Upload(device, sheets.Talk); - _buttons = BoardView.Upload(device, sheets.Buttons); - _text = text; - } - - /// - /// The dialogue window over the board and HUD (re/m3-ui.md, 5.2): the band, the name plate and name, the typed - /// lines and, when a page is done, the advance icon. - /// - public void DrawDialogue(Dialogue dialogue) - { - // The body bakes again as each character appears; those bakes only live as long as their page. - if (_typedPage != (dialogue, dialogue.Page)) - { - Clear(_typed); - _typedPage = (dialogue, dialogue.Page); - } - - var name = _text.Strings[Dialogue.Speaker]; - Begin(BlendState.NonPremultiplied); - Sliced(_talk, new(4, 4, 32, 352), 8, 8, new(-4, 732, 1928, 352), 0.702f * dialogue.WindowOpacity); - Sliced(_talk, new(40, 276, 548, 98), 245, 245, new(183, 755, TextRenderer.Advance(_text.Font, name, 53) + 160, 98), 0.498f * dialogue.NameOpacity); - Swap(BlendState.AlphaBlend); - Text(name, 53, "MsgFont3", 263, 824.1f, dialogue.NameOpacity); - - // Colour tag n picks MsgFont n; 0 is the talk window's own material, which has MsgFont0's colours. - string[] materials = ["FOT-MatisseProN-B SDF Material", "MsgFont1", "MsgFont2", "MsgFont3", "MsgFont4", "MsgFont5"]; - var left = dialogue.Revealed; - for (var i = 0; i < dialogue.Lines.Count && left > 0; i++) - { - var shown = dialogue.Lines[i].Take(left).ToList(); - left -= shown.Count; - Runs(_typed, shown, materials, 53, 213, 890.1f + (60 * i), dialogue.WindowOpacity); - } - - if (dialogue.ShowsChoices) - { - DrawChoices(dialogue.Choices!, dialogue.Choice); - } - - if (dialogue.ShowsIcon) - { - // shortcut: the icon's glow is steady; the original's 3-second talk_ok loop isn't decoded. - Swap(BlendState.NonPremultiplied); - Sprite(_buttons, new(1944, 152, 100, 100), new(1683, 935, 100, 100)); - Swap(BlendState.Additive); - Sprite(_talk, new(652, 112, 100, 100), new(1683, 935, 100, 100)); - Sprite(_talk, new(548, 112, 100, 100), new(1683, 935, 100, 100)); - } - - _sprites.End(); - } - - /// The bar's fade-in: the original's smoothstep 12t^2 - 16t^3 over half a second, t in seconds. - public static float BarOpacity(int ticks) - { - var t = Math.Min(ticks / 30f, 0.5f); - return (12 * t * t) - (16 * t * t * t); - } - - /// - /// Lays out the bar's sets from the right: the last label ends at x , each set is its icons - /// then its label, and units separate sets. Returns each set's left edge, in set order. - /// - public static float[] BarLayout(IReadOnlyList<(int Icons, float Advance)> sets, float right = 1828, float gap = 36, float icon = 48) - { - var lefts = new float[sets.Count]; - for (var i = sets.Count - 1; i >= 0; i--) - { - lefts[i] = right - sets[i].Advance - (icon * sets[i].Icons); - right = lefts[i] - gap; - } - - return lefts; - } - - /// The stage number as the counter's two digits: the tens are blank below 10. - public static (int? Tens, int Ones) Digits(int stage) => (stage >= 10 ? stage / 10 : null, stage % 10); - - /// - /// The scroll arrows' bob at into its half-second cycle: how far toward the centre, in - /// layout units, and the glow's alpha. Both follow the original's three-key Hermite curves. - /// - public static (float Offset, float Glow) Bob(float seconds) - { - seconds %= 0.5f; - return seconds < 0.25f - ? (Hermite(0, 0, 24, 96, seconds / 0.25f, 0.25f), Hermite(0, 0, 0.5f, 2, seconds / 0.25f, 0.25f)) - : (Hermite(24, -96, 0, 0, (seconds - 0.25f) / 0.25f, 0.25f), Hermite(0.5f, -2, 0, 0, (seconds - 0.25f) / 0.25f, 0.25f)); - } - - /// Draws the HUD over the scene. - public void Draw(HudState state) - { - _fit = UiLayout.Fit(_device.Viewport.Width, _device.Viewport.Height); - - // The stage counter: plate, glow, the word STAGE, then the digits. - Begin(BlendState.NonPremultiplied); - Sprite(_sheet, new(772, 1096, 372, 68), new(1494, 56, 372, 68)); - Swap(BlendState.Additive); - Sprite(_sheet, new(632, 1096, 136, 124), new(1712, 30, 136, 124)); - Swap(BlendState.NonPremultiplied); - Sprite(_words, new(1120, 144, 208, 44), new(1528, 68, 208, 44)); - var (tens, ones) = Digits(state.Stage); - if (tens is { } t) - { - Sprite(_digits, Digit(t), new(1736, 60, 48, 60)); - } - - Sprite(_digits, Digit(ones), new(1774, 60, 48, 60)); - - if (state.Undo) - { - Sprite(_words, new(1120, 68, 448, 72), new(736, 552, 448, 72)); - } - - // The arrows: a half-transparent base, then an animated additive glow. - var (offset, glow) = Bob(state.ScrollTicks / 30f); - for (var i = 0; i < ScrollArrows.Length; i++) - { - if ((state.Arrows & (1 << i)) != 0) - { - var (centre, sprite, inward) = ScrollArrows[i]; - Sprite(_sheet, sprite, Square(centre + (inward * offset)), 0.502f); - } - } - - Swap(BlendState.Additive); - for (var i = 0; i < ScrollArrows.Length; i++) - { - if ((state.Arrows & (1 << i)) != 0) - { - var (centre, sprite, inward) = ScrollArrows[i]; - Sprite(_sheet, sprite, Square(centre + (inward * offset)), glow); - } - } - - if (state.Clear) - { - Swap(BlendState.NonPremultiplied); - Sprite(_words, new(4, 4, 1112, 224), new(424, 444, 1112, 224)); - Swap(BlendState.Additive); - Sprite(_words, new(4, 232, 1112, 224), new(424, 444, 1112, 224)); - } - - _sprites.End(); - var sets = state.Bar switch - { - BarSet.Play => PlaySets, - BarSet.Scroll => ScrollSets, - BarSet.Menu => MenuSets, - BarSet.Help => HelpSets, - _ => null, - }; - if (sets is not null) - { - DrawBar(sets, BarOpacity(state.BarTicks)); - } - } - - /// The □ menu (re/m3-ui.md, section 6): its panel, the cursor and the four items. - public void DrawMenu(Menu menu) - { - Begin(BlendState.NonPremultiplied); - Sprite(_sheet, new(632, 808, 516, 284), new(90, 138, 516, 284)); - Sprite(_sheet, new(1964, 4, 60, 56), new(104, 164 + (58 * (int)menu.Cursor), 60, 56)); - Swap(BlendState.AlphaBlend); - for (var i = 0; i < Menu.Labels.Length; i++) - { - Text(_text.Strings[Menu.Labels[i]], 42, "TMC22", 168, 207.96f + (58 * i)); - } - - _sprites.End(); - } - - /// The rules screen (re/m3-ui.md, section 7): glows, the panel with its pictures, header icons, then the text. - public void DrawHelp() - { - // shortcut: the top glow is the bottom one turned 180 degrees; its borders are equal, so mirroring the ends doesn't matter. - Begin(BlendState.Additive); - Sliced(_sheet, new(1936, 4, 24, 128), 8, 8, new(-4, 932, 1928, 128), 1); - Sliced(_sheet, new(1936, 4, 24, 128), 8, 8, new(-4, 20, 1928, 128), 1, upsideDown: true); - Swap(BlendState.NonPremultiplied); - Sprite(_sheet, new(4, 4, 1928, 800), new(-4, 140, 1928, 800)); - foreach (var (x, y) in new[] { (98f, 160f), (458.01f, 160f), (98f, 350f), (458.01f, 350f) }) - { - Sprite(_sheet, new(1964, 64, 60, 60), new(x, y, 60, 60)); - } - - Swap(BlendState.AlphaBlend); - foreach (var (id, material, centre, x, width, baseline) in HelpLines) - { - var text = _text.Strings[id]; - var pen = centre ? x + ((width - TextRenderer.Advance(_text.Font, text, 42)) / 2) : x; - Text(text, 42, material, pen, baseline); - } - - _sprites.End(); - } - - /// - public void Dispose() - { - _sprites.Dispose(); - _sheet.Dispose(); - _words.Dispose(); - _digits.Dispose(); - _keyboard.Dispose(); - _talk.Dispose(); - _buttons.Dispose(); - Clear(_baked); - Clear(_typed); - } - - private static void Clear(Dictionary bakes) - { - foreach (var (texture, _) in bakes.Values) - { - texture?.Dispose(); - } - - bakes.Clear(); - } - - /// - /// A question's choice box, its bottom-right corner at (1868, 758), with the cursor on . - /// Expects an open batch with and leaves one open. - /// - private void DrawChoices(IReadOnlyList choices, int choice) - { - // shortcut: no footage shows this box, so its rows are spaced evenly in the window's native height. Upgrade - // with the talk01_choice prefab's layout if a capture of the quit question turns up. - var widest = choices.Max(c => TextRenderer.Advance(_text.Font, c, 53)); - var (width, height) = (Math.Max(480, 48 + 52 + widest + 48), 244f); - var (x, y) = (1868 - width, 758 - height); - Swap(BlendState.NonPremultiplied); - Sliced(_talk, new(40, 28, 504, 244), 240, 240, new(x, y, width, height), 0.702f); - var rows = choices.Select((_, i) => y + (height / (choices.Count + 1) * (i + 1))).ToArray(); - Sprite(_talk, new(596, 214, 40, 34), new(x + 48, rows[choice] - 17, 40, 34)); - Swap(BlendState.Additive); - Sprite(_talk, new(596, 214, 40, 34), new(x + 48, rows[choice] - 17, 40, 34), 0.196f); - Sprite(_talk, new(596, 214, 40, 34), new(x + 48, rows[choice] - 17, 40, 34), 0.196f); - Swap(BlendState.AlphaBlend); - for (var i = 0; i < choices.Count; i++) - { - Text(choices[i], 53, "FOT-MatisseProN-B SDF Material", x + 48 + 52, TextRenderer.MiddleBaseline(_text.Font, rows[i], 53)); - } - } - - /// The controls bar: a dark strip, then each set's key icons and label, right to left. - private void DrawBar((Rectangle[] Keys, string Label)[] sets, float opacity) - { - var labels = sets.Select(s => _text.Strings[s.Label]).ToArray(); - var lefts = BarLayout([.. sets.Select((s, i) => (s.Keys.Length, TextRenderer.Advance(_text.Font, labels[i], 32)))]); - Begin(BlendState.NonPremultiplied); - Sprite(_keyboard, new(4, 4, 1508, 52), new(416, 980, 1508, 52), opacity); - for (var i = 0; i < sets.Length; i++) - { - for (var k = 0; k < sets[i].Keys.Length; k++) - { - Sprite(_keyboard, sets[i].Keys[k], new(lefts[i] + (48 * k), 982, 48, 48), opacity); - } - } - - Swap(BlendState.AlphaBlend); - for (var i = 0; i < sets.Length; i++) - { - Text(labels[i], 32, "FOT-MatisseProN-B SDF Material", lefts[i] + (48 * sets[i].Keys.Length), 1019.16f, opacity); - } - - _sprites.End(); - } - - /// - /// Draws text baked by at a layout pen and baseline, faded by , - /// inside an open sprite batch using . Bakes are cached; the window doesn't resize. - /// - private void Text(string text, float size, string material, float penX, float baseline, float opacity = 1) => - Runs(_baked, [.. text.Select(c => (c, 0))], [material], size, penX, baseline, opacity); - - /// Draws characters with per-character materials (indices into ), baking into . - private void Runs(Dictionary cache, List<(char Char, int Material)> runs, string[] materials, - float size, float penX, float baseline, float opacity) - { - var key = $"{string.Concat(runs.Select(r => $"{r.Char}{r.Material}"))}|{string.Join(',', materials)}|{size}|{penX}|{baseline}"; - if (!cache.TryGetValue(key, out var baked)) - { - var image = TextRenderer.Bake(_text.Font, [.. materials.Select(m => _text.Materials[m])], runs, size, penX, baseline, _fit); - Texture2D? texture = null; - if (image.Width > 0) - { - texture = new Texture2D(_device, image.Width, image.Height); - texture.SetData(image.Rgba); - } - - cache[key] = baked = (texture, new Point(image.X, image.Y)); - } - - if (baked.Texture is not null) - { - // Baked text is premultiplied, so fading it scales all four channels. - _sprites.Draw(baked.Texture, baked.At.ToVector2(), Color.White * opacity); - } - } - - /// - /// A sprite cut in three across: fixed borders of and texels at each - /// end and a stretched middle. A target narrower than both borders shrinks them in proportion, as Unity does. - /// - private void Sliced(Texture2D sheet, Rectangle unity, int left, int right, RectangleF layout, float alpha, bool upsideDown = false) - { - var shrink = Math.Min(1, layout.Width / (left + right)); - var (l, r) = (left * shrink, right * shrink); - Sprite(sheet, new(unity.X, unity.Y, left, unity.Height), new(layout.X, layout.Y, l, layout.Height), alpha, upsideDown); - if (layout.Width > l + r) - { - Sprite(sheet, new(unity.X + left, unity.Y, unity.Width - left - right, unity.Height), new(layout.X + l, layout.Y, layout.Width - l - r, layout.Height), alpha, upsideDown); - } - - Sprite(sheet, new(unity.Right - right, unity.Y, right, unity.Height), new(layout.X + layout.Width - r, layout.Y, r, layout.Height), alpha, upsideDown); - } - - /// A cubic Hermite segment from p0 (slope m0) to p1 (slope m1) over seconds, at fraction . - private static float Hermite(float p0, float m0, float p1, float m1, float s, float length) - { - var (s2, s3) = (s * s, s * s * s); - return (((2 * s3) - (3 * s2) + 1) * p0) + ((s3 - (2 * s2) + s) * length * m0) + (((-2 * s3) + (3 * s2)) * p1) + ((s3 - s2) * length * m1); - } - - /// Digit d's 48x60 sprite on the digit sheet: 1 to 9 run from x 1120, and 0 sits at x 1552. - private static Rectangle Digit(int d) => new(d == 0 ? 1552 : 1120 + (48 * (d - 1)), 4, 48, 60); - - private static RectangleF Square(Vector2 centre) => new(centre.X - 36, centre.Y - 36, 72, 72); - - private void Begin(BlendState blend) => - _sprites.Begin(SpriteSortMode.Deferred, blend, SamplerState.LinearClamp, DepthStencilState.None, RasterizerState.CullNone); - - private void Swap(BlendState blend) - { - _sprites.End(); - Begin(blend); - } - - /// Stretches a sheet rect (Unity's, from the bottom-left) over a layout rect, at . - private void Sprite(Texture2D sheet, Rectangle unity, RectangleF layout, float alpha = 1, bool upsideDown = false) - { - var position = _fit.Offset + (new Vector2(layout.X, layout.Y) * _fit.Scale); - var scale = new Vector2(layout.Width / unity.Width, layout.Height / unity.Height) * _fit.Scale; - - // Fading a straight-alpha sprite scales its alpha only; the additive glows take the same alpha as their strength. - // The sheets keep Unity's bottom-up rows, so an upright sprite is flipped vertically; turned 180 degrees, horizontally. - var flip = upsideDown ? SpriteEffects.FlipHorizontally : SpriteEffects.FlipVertically; - _sprites.Draw(sheet, position, unity, new Color(Color.White, alpha), 0, Vector2.Zero, scale, flip, 0); - } - - /// A rectangle in layout units, which can be fractional. - private readonly record struct RectangleF(float X, float Y, float Width, float Height); -} diff --git a/src/PuzzleBoy/Menu.cs b/src/PuzzleBoy/Menu.cs deleted file mode 100644 index 6a53a91..0000000 --- a/src/PuzzleBoy/Menu.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace PuzzleBoy; - -/// The □ menu's items, in the order they show. -internal enum MenuItem -{ - /// Takes back the last push or turn; the menu stays open. - Undo, - - /// Starts the stage again. - Restart, - - /// Shows the rules. - Help, - - /// Asks whether to stop playing. - Quit, -} - -/// The □ menu's cursor (re/m3-ui.md, section 6): four items, wrapping at both ends. -internal sealed class Menu -{ - /// The string ids of the items' labels, in order. - public static readonly string[] Labels = ["PBHELP_L0046", "PBHELP_L0047", "PBHELP_L0048", "PBHELP_L0049"]; - - /// The item under the cursor. - public MenuItem Cursor { get; private set; } - - /// Puts the cursor back on Undo, as opening the menu does. - public void Reset() => Cursor = MenuItem.Undo; - - /// Moves the cursor items, wrapping around. - public void Move(int by) => Cursor = (MenuItem)((((int)Cursor + by) % 4 + 4) % 4); -} - -/// -/// The original pad layer's repeat for a held key (re/m3-input-camera.md, dds3PadUpdate): it fires on the first -/// tick, then on every second tick once the key has been held more than 7. -/// -internal sealed class KeyRepeat -{ - private int _held; - - /// Counts one tick of the key being , and says whether it fires. - public bool Fires(bool down) - { - _held = down ? _held + 1 : 0; - return _held == 1 || (_held > 7 && _held % 2 == 0); - } -} diff --git a/src/PuzzleBoy/TextRenderer.cs b/src/PuzzleBoy/TextRenderer.cs deleted file mode 100644 index e36f906..0000000 --- a/src/PuzzleBoy/TextRenderer.cs +++ /dev/null @@ -1,144 +0,0 @@ -using Microsoft.Xna.Framework; -using PuzzleBoy.Nocturne; - -namespace PuzzleBoy; - -/// A line of text baked to premultiplied RGBA at whole-pixel position (, ). -/// The image's left edge in window pixels. -/// The image's top edge in window pixels. -/// Its width in pixels. -/// Its height in pixels. -/// Premultiplied RGBA, 4 bytes per pixel, rows top-down. -internal sealed record BakedText(int X, int Y, int Width, int Height, byte[] Rgba); - -/// -/// Lays out and draws text the way TextMeshPro's distance-field shader does, on the CPU, since the port has no custom -/// shaders (re/m3-ui.md, section 3). Positions are in the 1920x1080 layout; baking turns them into window pixels. -/// -internal static class TextRenderer -{ - // TMP's padding around each glyph quad, in font units, for these materials. - private const float Padding = 5.1f; - - /// The width of at , in layout units: the sum of advances. - public static float Advance(FontData font, string text, float size) => - text.Sum(c => font.Glyphs.TryGetValue(c, out var g) ? g.Advance : 0) * size / font.PointSize; - - /// The baseline for text middle-aligned in a box whose centre is . - public static float MiddleBaseline(FontData font, float centre, float size) => centre + ((font.Ascent + font.Descent) / 2 * size / font.PointSize); - - /// Face coverage at atlas value : TMP's linear clamp around the edge, over 1/. - public static float Coverage(float c, float edge, float scale) => Math.Clamp(((c - edge) * scale) + 0.5f, 0, 1); - - /// - /// Splits text into characters, each with the material its <COLOR n> tag chose (starting with - /// ). Tags take no width. Characters the font lacks are kept; drawing skips them. - /// - public static List<(char Char, int Material)> Runs(string text, int initial = 0) - { - var runs = new List<(char, int)>(); - var material = initial; - for (var i = 0; i < text.Length; i++) - { - if (text.AsSpan(i).StartsWith("', i) is var close and > 0 && int.TryParse(text.AsSpan(i + 7, close - i - 7), out var n)) - { - material = n; - i = close; - continue; - } - - runs.Add((text[i], material)); - } - - return runs; - } - - /// - /// Bakes one line at with its pen starting at layout x on layout - /// baseline , for a window whose layout scale and offset are . - /// Glyphs are composited in order with premultiplied "over", since their quads overlap. - /// - public static BakedText Bake(FontData font, IReadOnlyList materials, IReadOnlyList<(char Char, int Material)> line, - float size, float penX, float baseline, (float Scale, Vector2 Offset) fit) - { - var s = size / font.PointSize * fit.Scale; - var pen = fit.Offset.X + (penX * fit.Scale); - var base0 = fit.Offset.Y + (baseline * fit.Scale); - var quads = new List<(FontGlyph Glyph, TextMaterial Material, float X, float Y, float W, float H)>(); - foreach (var (c, m) in line) - { - if (!font.Glyphs.TryGetValue(c, out var g)) - { - continue; - } - - if (g.Rect.Width > 0) - { - quads.Add((g, materials[m], pen + ((g.BearingX - Padding) * s), base0 - ((g.BearingY + Padding) * s), (g.Width + (2 * Padding)) * s, (g.Height + (2 * Padding)) * s)); - } - - pen += g.Advance * s; - } - - if (quads.Count == 0) - { - return new BakedText(0, 0, 0, 0, []); - } - - var (x0, y0) = ((int)MathF.Floor(quads.Min(q => q.X)), (int)MathF.Floor(quads.Min(q => q.Y))); - var (x1, y1) = ((int)MathF.Ceiling(quads.Max(q => q.X + q.W)), (int)MathF.Ceiling(quads.Max(q => q.Y + q.H))); - var (width, height) = (x1 - x0, y1 - y0); - var image = new Vector4[width * height]; - foreach (var (g, m, qx, qy, qw, qh) in quads) - { - var (uw, vh) = (g.Rect.Width + (2 * Padding), g.Rect.Height + (2 * Padding)); - - // Every edge ramps over sqrt(2) screen pixels: TMP's scale under the UI's orthographic projection. - var scale = MathF.Sqrt(2) * m.GradientScale * (qw / uw); - var outlineMix = MathF.Sqrt(MathF.Min(1, m.OutlineWidth * m.ScaleRatioA * scale)); - for (var py = (int)MathF.Floor(qy); py < (int)MathF.Ceiling(qy + qh); py++) - { - for (var px = (int)MathF.Floor(qx); px < (int)MathF.Ceiling(qx + qw); px++) - { - var (fx, fy) = ((px + 0.5f - qx) / qw, (py + 0.5f - qy) / qh); - if (fx is < 0 or >= 1 || fy is < 0 or >= 1) - { - continue; - } - - // The atlas rows run bottom-up, so v counts down from the padded rect's top. - var c = Sample(font, g.Rect.X - Padding + (fx * uw) - 0.5f, g.Rect.Y + g.Rect.Height + Padding - (fy * vh) - 0.5f); - var fa = Coverage(c, m.DrawnFaceEdge, scale); - var mix = Math.Clamp(((m.ColourEdge - c) * scale) + 0.5f, 0, 1) * outlineMix; - var ua = Coverage(c, m.UnderlayEdge, scale) * m.Underlay.W * (1 - fa); - var colour = (Vector3.Lerp(new Vector3(m.Face.X, m.Face.Y, m.Face.Z), new Vector3(m.Outline.X, m.Outline.Y, m.Outline.Z), mix) * fa) - + (new Vector3(m.Underlay.X, m.Underlay.Y, m.Underlay.Z) * ua); - var src = new Vector4(colour, fa + ua); - ref var dst = ref image[((py - y0) * width) + (px - x0)]; - dst = src + (dst * (1 - src.W)); - } - } - } - - var rgba = new byte[width * height * 4]; - for (var i = 0; i < image.Length; i++) - { - var p = Vector4.Clamp(image[i], Vector4.Zero, Vector4.One) * 255; - (rgba[4 * i], rgba[(4 * i) + 1], rgba[(4 * i) + 2], rgba[(4 * i) + 3]) = ((byte)MathF.Round(p.X), (byte)MathF.Round(p.Y), (byte)MathF.Round(p.Z), (byte)MathF.Round(p.W)); - } - - return new BakedText(x0, y0, width, height, rgba); - } - - /// The atlas value at texel position (u, v), bilinear, as 0 to 1. - private static float Sample(FontData font, float u, float v) - { - u = Math.Clamp(u, 0, font.AtlasWidth - 1); - v = Math.Clamp(v, 0, font.AtlasHeight - 1); - var (x, y) = ((int)u, (int)v); - var (x1, y1) = (Math.Min(x + 1, font.AtlasWidth - 1), Math.Min(y + 1, font.AtlasHeight - 1)); - var (fx, fy) = (u - x, v - y); - float At(int ax, int ay) => font.Atlas[(ay * font.AtlasWidth) + ax]; - return ((((At(x, y) * (1 - fx)) + (At(x1, y) * fx)) * (1 - fy)) + (((At(x, y1) * (1 - fx)) + (At(x1, y1) * fx)) * fy)) / 255f; - } -} diff --git a/tests/PuzzleBoy.Tests/DialogueTests.cs b/tests/PuzzleBoy.Tests/DialogueTests.cs deleted file mode 100644 index 1095ec7..0000000 --- a/tests/PuzzleBoy.Tests/DialogueTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace PuzzleBoy.Tests; - -public sealed class DialogueTests -{ - [Fact] - public void Leading_line_breaks_are_dropped_and_lines_split_with_tags_carried_across() - { - var lines = Dialogue.Prepare("\n\nOne two\nthree"); - - Assert.Equal(["One two", "three"], lines.Select(l => new string([.. l.Select(r => r.Char)]))); - Assert.Equal(1, lines[1][0].Material); - Assert.Equal(0, lines[0][0].Material); - } - - [Fact] - public void A_page_types_a_character_a_tick_and_shows_the_icon_a_tick_after() - { - var dialogue = new Dialogue(["ab\nc"]); - var shown = new List<(int, bool)>(); - for (var tick = 0; tick < 5; tick++) - { - dialogue.Tick(); - shown.Add((dialogue.Revealed, dialogue.ShowsIcon)); - } - - Assert.Equal([(1, false), (2, false), (3, false), (3, true), (3, true)], shown); - } - - [Fact] - public void Confirm_finishes_a_typing_page_then_turns_it() - { - var dialogue = new Dialogue(["abc", "d"]); - dialogue.Tick(); - - dialogue.Confirm(); - Assert.Equal(3, dialogue.Revealed); - Assert.Equal(0, dialogue.Page); - - dialogue.Tick(); - dialogue.Confirm(); - Assert.Equal((1, 0), (dialogue.Page, dialogue.Revealed)); - dialogue.Tick(); - Assert.Equal(1, dialogue.Revealed); - } - - [Fact] - public void After_the_last_page_the_name_fades_in_2_ticks_and_the_window_in_7() - { - var dialogue = new Dialogue(["a"]); - dialogue.Tick(); - dialogue.Tick(); - dialogue.Confirm(); - - dialogue.Tick(); - Assert.Equal(0.5f, dialogue.NameOpacity); - Assert.False(dialogue.ShowsIcon); - for (var tick = 1; tick < 7; tick++) - { - Assert.False(dialogue.Finished); - dialogue.Tick(); - } - - Assert.Equal(0f, dialogue.WindowOpacity); - Assert.True(dialogue.Finished); - } -} diff --git a/tests/PuzzleBoy.Tests/HudTests.cs b/tests/PuzzleBoy.Tests/HudTests.cs deleted file mode 100644 index 2686db1..0000000 --- a/tests/PuzzleBoy.Tests/HudTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.Xna.Framework; - -namespace PuzzleBoy.Tests; - -public sealed class HudTests -{ - [Theory] - [InlineData(1280, 720, 2f / 3, 0, 0)] - [InlineData(1920, 1200, 1, 0, 60)] - [InlineData(2560, 1080, 1, 320, 0)] - public void The_layout_scales_uniformly_and_centres_in_the_window(int width, int height, float scale, float x, float y) - { - var fit = UiLayout.Fit(width, height); - - Assert.Equal(scale, fit.Scale, 5); - Assert.Equal(new Vector2(x, y), fit.Offset); - } - - [Theory] - [InlineData(7, null, 7)] - [InlineData(12, 1, 2)] - [InlineData(20, 2, 0)] - public void The_counter_leaves_the_tens_blank_below_10(int stage, int? tens, int ones) - { - Assert.Equal((tens, ones), Hud.Digits(stage)); - } - - [Theory] - [InlineData(0f, 0f, 0f)] - [InlineData(0.125f, 9f, 0.1875f)] - [InlineData(0.25f, 24f, 0.5f)] - [InlineData(0.5f, 0f, 0f)] - public void The_arrows_bob_peaks_at_the_middle_key(float seconds, float offset, float glow) - { - var bob = Hud.Bob(seconds); - - Assert.Equal(offset, bob.Offset, 4); - Assert.Equal(glow, bob.Glow, 4); - } -} diff --git a/tests/PuzzleBoy.Tests/MenuTests.cs b/tests/PuzzleBoy.Tests/MenuTests.cs deleted file mode 100644 index 0d943e4..0000000 --- a/tests/PuzzleBoy.Tests/MenuTests.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace PuzzleBoy.Tests; - -public sealed class MenuTests -{ - [Fact] - public void The_menu_cursor_wraps_at_both_ends() - { - var menu = new Menu(); - - menu.Move(-1); - Assert.Equal(MenuItem.Quit, menu.Cursor); - menu.Move(1); - Assert.Equal(MenuItem.Undo, menu.Cursor); - } - - [Fact] - public void A_held_key_fires_first_then_every_second_tick_after_7() - { - var repeat = new KeyRepeat(); - var fired = Enumerable.Range(1, 12).Where(_ => repeat.Fires(down: true)).ToList(); - - Assert.Equal([1, 8, 10, 12], fired); - Assert.False(repeat.Fires(down: false)); - Assert.True(repeat.Fires(down: true)); - } - - [Fact] - public void A_question_shows_its_choices_when_typed_and_starts_on_the_last() - { - var question = new Dialogue(["Quit?"], ["Yes", "No"]); - for (var tick = 0; tick < 6; tick++) - { - question.Tick(); - } - - Assert.True(question.ShowsChoices); - Assert.False(question.ShowsIcon); - Assert.Equal(1, question.Choice); - - question.MoveChoice(1); - Assert.Equal(0, question.Choice); - question.Confirm(); - Assert.False(question.ShowsChoices); - } - - [Fact] - public void Cancelling_a_question_picks_the_last_option() - { - var question = new Dialogue(["Quit?"], ["Yes", "No"]); - for (var tick = 0; tick < 6; tick++) - { - question.Tick(); - } - - question.MoveChoice(1); - question.Cancel(); - - Assert.Equal(1, question.Choice); - } -} diff --git a/tests/PuzzleBoy.Tests/TextRendererTests.cs b/tests/PuzzleBoy.Tests/TextRendererTests.cs deleted file mode 100644 index a928829..0000000 --- a/tests/PuzzleBoy.Tests/TextRendererTests.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Microsoft.Xna.Framework; -using PuzzleBoy.Nocturne; - -namespace PuzzleBoy.Tests; - -// Made-up fonts and materials, so these need no game files (re/m3-ui.md, section 9). -public sealed class TextRendererTests -{ - private static readonly TextMaterial Plain = new(Vector4.One, new Vector4(0, 0, 1, 1), new Vector4(0, 0, 1, 0.5f), 0, 0, 0, 1, 1, 11); - - private static FontData Font(byte fill = 255) => new(100, 80, -20, - new Dictionary - { - ['a'] = new(10, 10, 0, 10, 40, new Rectangle(20, 20, 10, 10)), - ['b'] = new(10, 10, 0, 10, 60, new Rectangle(20, 20, 10, 10)), - [' '] = new(0, 0, 0, 0, 30, new Rectangle(0, 0, 0, 0)), - }, - 64, 64, Enumerable.Repeat(fill, 64 * 64).ToArray()); - - [Fact] - public void A_texel_on_the_face_edge_is_half_covered_and_full_coverage_is_a_ramp_away() - { - Assert.Equal(0.5f, TextRenderer.Coverage(0.4f, 0.4f, 10)); - Assert.Equal(1f, TextRenderer.Coverage(0.4f + 0.05f, 0.4f, 10)); - Assert.Equal(0f, TextRenderer.Coverage(0.4f - 0.05f, 0.4f, 10)); - } - - [Fact] - public void The_pen_advances_by_size_over_point_size() - { - Assert.Equal(65f, TextRenderer.Advance(Font(), "ab ", 50)); - } - - [Fact] - public void Middle_alignment_puts_the_baseline_below_the_box_centre() - { - Assert.Equal(115f, TextRenderer.MiddleBaseline(Font(), 100, 50)); - } - - [Fact] - public void Colour_tags_take_no_width_and_switch_the_material_of_what_follows() - { - var runs = TextRenderer.Runs("ab c"); - - Assert.Equal("ab c", new string([.. runs.Select(r => r.Char)])); - Assert.Equal([0, 1, 0, 0], runs.Select(r => r.Material)); - } - - [Fact] - public void A_glyph_inside_the_face_bakes_to_the_face_colour() - { - var baked = TextRenderer.Bake(Font(), [Plain], [('a', 0)], 100, 0, 50, (1f, Vector2.Zero)); - - // The glyph box spans pen 0..10 and baseline-10..baseline; its centre pixel is fully covered and white. - var centre = 4 * (((45 - baked.Y) * baked.Width) + (5 - baked.X)); - Assert.Equal([255, 255, 255, 255], baked.Rgba[centre..(centre + 4)]); - } - - [Fact] - public void Nothing_to_draw_bakes_an_empty_image() - { - Assert.Equal(0, TextRenderer.Bake(Font(), [Plain], [(' ', 0)], 50, 0, 50, (1f, Vector2.Zero)).Width); - } - - [Fact] - public void The_bar_lays_out_sets_from_the_right_with_a_gap() - { - Assert.Equal([740f, 874f], Hud.BarLayout([(1, 50f), (2, 30f)], right: 1000, gap: 36, icon: 48)); - } - - [Theory] - [InlineData(0, 0f)] - [InlineData(15, 1f)] - [InlineData(30, 1f)] - public void The_bar_fades_in_with_a_smoothstep_over_15_ticks(int ticks, float opacity) - { - Assert.Equal(opacity, Hud.BarOpacity(ticks), 4); - } -}