Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a72031265 | ||
|
|
c78eeca0f6 | ||
|
|
2dbef9d079 | ||
|
|
715b897894 | ||
|
|
a19af31db5 | ||
|
|
18f2389e06 | ||
|
|
ac77708da2 | ||
|
|
eb9e2a7035 | ||
|
|
45c1c59a8d | ||
|
|
fc58e62d77 | ||
|
|
c55d021c43 | ||
|
|
33ac8b0d41 | ||
|
|
ccc55a5a32 | ||
|
|
d62f2f810a | ||
|
|
3484317567 | ||
|
|
375b07284b | ||
|
|
bb9413aee7 | ||
|
|
d6341b0143 | ||
|
|
51f3e0c380 | ||
|
|
697b714de8 | ||
|
|
e243c855c1 | ||
|
|
a60c82fc5f | ||
|
|
c2a7eed511 | ||
|
|
22b25976a0 | ||
|
|
63d07bb8bd | ||
|
|
c9fde9de15 | ||
|
|
0632d70631 | ||
|
|
4414256e53 | ||
|
|
010c2468f4 | ||
|
|
d4b8a579f9 | ||
|
|
e8c7341e3e | ||
|
|
caca793d30 | ||
|
|
77f2564b55 | ||
|
|
e54759c1dd | ||
|
|
77274cbb35 | ||
|
|
72808e8430 | ||
|
|
9f6cf22023 | ||
|
|
8b0764c85a | ||
|
|
f02a921a89 | ||
|
|
f6e1308950 | ||
|
|
9aee7af7c7 | ||
|
|
6410b2ff29 | ||
|
|
432dec4ada | ||
|
|
373b685797 | ||
|
|
1ed83a8b54 | ||
|
|
d29b82f14f | ||
|
|
d5bd5fc75f | ||
|
|
2d0095bcbe | ||
|
|
70605fa855 | ||
|
|
5204025394 | ||
|
|
80a44e776c | ||
|
|
fe8709085b | ||
|
|
119111051c | ||
|
|
0bbab8bd02 | ||
|
|
212ad4f332 | ||
|
|
439d6708b4 | ||
|
|
03f2cca35e | ||
|
|
ba3a70ef7a | ||
|
|
ae7f217b5d | ||
|
|
6991b49c5d | ||
|
|
97f7259a66 | ||
|
|
4da5e996fe | ||
|
|
7603da1032 | ||
|
|
741fa08d05 | ||
|
|
3a10b8d043 | ||
|
|
aacb40a492 | ||
|
|
c6dcc61032 | ||
|
|
4d8f19f1e1 | ||
|
|
1044050540 | ||
|
|
b1bd90b248 | ||
|
|
e8db7f7894 | ||
|
|
fa8832f266 | ||
|
|
678a45e432 | ||
|
|
f88e3a6224 | ||
|
|
31d521b52b | ||
|
|
75393943dd | ||
|
|
8a9f135118 | ||
|
|
65dc2edcb0 | ||
|
|
6f25d92b1e | ||
|
|
f7803c5935 | ||
|
|
d6a96e8708 | ||
|
|
01aeb3eda0 | ||
|
|
8dee3cbe05 | ||
|
|
ee77d44659 | ||
|
|
aa819f4581 | ||
|
|
761b8f3d05 | ||
|
|
a6db0075da | ||
|
|
b4209f5a88 | ||
|
|
7417c08a6a | ||
|
|
42b8a74c86 | ||
|
|
ccc17b6751 | ||
|
|
0758bed6ae | ||
|
|
14ce76212a | ||
|
|
0a07592333 | ||
|
|
03aa85b6d6 | ||
|
|
534b98832d | ||
|
|
ef45da37ac | ||
|
|
f5a489a495 | ||
|
|
8fb65abfb7 | ||
|
|
21feb463cf | ||
|
|
1f2d4d6689 | ||
|
|
24fde52b9a | ||
|
|
4e0a9c13bf | ||
|
|
66f6494f5d | ||
|
|
57c41a50cc | ||
|
|
18d228c03c | ||
|
|
130b646215 | ||
|
|
fdec2fcd9a | ||
|
|
33012bfe2b | ||
|
|
f8783659a1 | ||
|
|
fb2222c7a9 | ||
|
|
eebfd5728f | ||
|
|
572747df6a | ||
|
|
c5a9c3d029 | ||
|
|
5f98b6c568 | ||
|
|
55b9350d1b | ||
|
|
e65039d279 | ||
|
|
3b78411153 | ||
|
|
5d11ce5fa1 | ||
|
|
1c55d563f5 | ||
|
|
4d12f134c7 | ||
|
|
f1f35030e9 | ||
|
|
44fa91b524 | ||
|
|
425bdcc170 | ||
|
|
011981488c | ||
|
|
a93570e847 | ||
|
|
c6b8015caa | ||
|
|
caeefc9220 | ||
|
|
6d0c4e0e0c | ||
|
|
7dc49b3818 | ||
|
|
dd4876a4bd | ||
|
|
1758a7c5ba | ||
|
|
d00e41571f | ||
|
|
a0fa28f753 | ||
|
|
dde00753bf | ||
|
|
28f47879f2 | ||
|
|
6169dc2fff | ||
|
|
e9a8302a01 | ||
|
|
6f13fd351c | ||
|
|
ec5d464bdf | ||
|
|
1b65434faa | ||
|
|
8d39b3fc54 | ||
|
|
d4db96a389 | ||
|
|
9de5b38845 | ||
|
|
7386327acb | ||
|
|
b52dd82913 | ||
|
|
2a630577fe | ||
|
|
5b5ec830e4 | ||
|
|
9f8c149604 | ||
|
|
c84b034604 | ||
|
|
0914d763d0 | ||
|
|
25506b4b87 | ||
|
|
3d5d1d1acd | ||
|
|
5348842d0c | ||
|
|
a50f45a06a | ||
|
|
278aa03c1c | ||
|
|
4329299d36 | ||
|
|
0a6e7ddd9b | ||
|
|
d0cec56be5 | ||
|
|
a42788c382 | ||
|
|
10c2dcb938 | ||
|
|
3de2537252 | ||
|
|
067a164747 | ||
|
|
7fd003d707 | ||
|
|
8f6e47f0d3 | ||
|
|
d0fe909ae0 | ||
|
|
7212861fd5 | ||
|
|
85fa0fe29e | ||
|
|
c0b01b4a49 | ||
|
|
3b1cd8f849 | ||
|
|
1d75a744ba | ||
|
|
3cad155cc8 | ||
|
|
207322620b | ||
|
|
baf21465aa | ||
|
|
fed6e8627c | ||
|
|
d318a8c3fe | ||
|
|
71fc420321 | ||
|
|
2cb6221e10 | ||
|
|
14b39c15ec | ||
|
|
ab7a0e435a | ||
|
|
eb7110d3e0 | ||
|
|
c425752c8d | ||
|
|
fd023ba3bf | ||
|
|
9f457005d8 | ||
|
|
4306bea6f8 | ||
|
|
8751c8ac08 | ||
|
|
1c3671ad13 | ||
|
|
0349ebf2bc | ||
|
|
e0e147e2c4 | ||
|
|
dde2bac522 | ||
|
|
b43dd4ad26 | ||
|
|
9f8bd6ab15 | ||
|
|
bdfeaca09b | ||
|
|
ea771d8c57 | ||
|
|
4f92ea7ae8 | ||
|
|
f2e3667f1c | ||
|
|
7e61868750 | ||
|
|
2f38b4b781 | ||
|
|
fbc6c48ce9 | ||
|
|
14ddeab1b7 | ||
|
|
30b56060dd | ||
|
|
e61e255cbd | ||
|
|
f6e136df37 | ||
|
|
5812dbdb19 | ||
|
|
374ac9abe3 | ||
|
|
c7de835f6f | ||
|
|
ef50643acd | ||
|
|
625e48820b | ||
|
|
3b7609475a | ||
|
|
f75a574728 | ||
|
|
6c38695155 | ||
|
|
bf409c0c3c | ||
|
|
6a33670865 | ||
|
|
58016e86b2 | ||
|
|
b59f1b8555 | ||
|
|
743d699e61 | ||
|
|
cbff428a55 | ||
|
|
67cd249b7f | ||
|
|
f881257ca0 | ||
|
|
90d67c40ce | ||
|
|
7a3d1dc21b | ||
|
|
5082837382 | ||
|
|
ab07b38c98 | ||
|
|
eaffd00137 | ||
|
|
3bcdd359a9 | ||
|
|
4e7fc73ebb | ||
|
|
ab14955434 | ||
|
|
1489b9aad2 | ||
|
|
2e2266fcf4 | ||
|
|
cade553012 | ||
|
|
5dd8dcac40 | ||
|
|
da15f82611 | ||
|
|
92e6722ad5 | ||
|
|
969ae7e58f | ||
|
|
e1e11d0695 | ||
|
|
5b619cfe09 | ||
|
|
ede37e9c84 | ||
|
|
da0d41c199 | ||
|
|
8cfa1e8213 | ||
|
|
e232ac7e82 | ||
|
|
3c9952d0bc | ||
|
|
eaf3093d42 | ||
|
|
9cd796fd71 | ||
|
|
d5dd2bf805 | ||
|
|
15145ebbec | ||
|
|
34d3b8ca56 | ||
|
|
ad9140cdc5 | ||
|
|
231d5d4ace | ||
|
|
4f8337aebc | ||
|
|
3aedffa477 | ||
|
|
5bfb878dc8 | ||
|
|
dab4f560fc | ||
|
|
6057cebddb | ||
|
|
b35cce7806 | ||
|
|
c70f43ae6e | ||
|
|
30ee92f015 | ||
|
|
28bf91ee4c | ||
|
|
5cf708ed78 | ||
|
|
f7b3ec1991 | ||
|
|
157369b489 | ||
|
|
b6867efccc | ||
|
|
cb045d926b | ||
|
|
cb8d84e59b | ||
|
|
841e5234bf | ||
|
|
66a9a06a1f | ||
|
|
b9e62bb9e6 | ||
|
|
aacbe6d1a7 | ||
|
|
749934e4b9 | ||
|
|
967bf163d5 | ||
|
|
959ad9f035 | ||
|
|
ab7c55383b | ||
|
|
0e67dfc939 | ||
|
|
1b46fe8f28 | ||
|
|
d7f62ee07c | ||
|
|
866178d592 | ||
|
|
43002eeab9 | ||
|
|
9ff0e99ff7 | ||
|
|
f12248760d | ||
|
|
e25bd865ab | ||
|
|
a6cdc5fe99 | ||
|
|
60b438e481 | ||
|
|
a199a99fc2 | ||
|
|
bd683d5e3c | ||
|
|
8ddf1c2e23 | ||
|
|
07e36913e5 | ||
|
|
00e53c3695 | ||
|
|
db895dad59 | ||
|
|
f39ad730fe | ||
|
|
5dce14cc3f | ||
|
|
668a1f2b99 | ||
|
|
cc6782be97 | ||
|
|
360b65bbc8 | ||
|
|
c7a3c1ad10 | ||
|
|
69a7c70561 | ||
|
|
e535b9a5c5 | ||
|
|
47d11928dd | ||
|
|
1bc3cd36b3 | ||
|
|
372f82499b | ||
|
|
1a72305e50 | ||
|
|
7a313705ca | ||
|
|
58422a29a8 | ||
|
|
fcf80e61a9 | ||
|
|
bc3cfeb6c4 | ||
|
|
76d12ee193 | ||
|
|
25b496440c | ||
|
|
d3d5ea9dd3 | ||
|
|
a8f616c39d | ||
|
|
a7ae586ed6 | ||
|
|
6a87aaf2e6 | ||
|
|
0fa6ab3ee2 | ||
|
|
c3c93eabf6 | ||
|
|
a182c395fa | ||
|
|
6402e82ae3 | ||
|
|
51593402ae | ||
|
|
50c896e69a | ||
|
|
fcc6a21cf2 | ||
|
|
a779ad0156 | ||
|
|
e0b7eca7a9 | ||
|
|
cd863dc1b5 | ||
|
|
dd3c51e6fb | ||
|
|
b6615bf7ca | ||
|
|
25ea9fe487 | ||
|
|
dc172b1c42 | ||
|
|
2beb269bee | ||
|
|
7708cc22e9 | ||
|
|
9f00e729aa | ||
|
|
4af06666a6 | ||
|
|
84bf436577 | ||
|
|
04f2e6474c | ||
|
|
b7d61913a4 | ||
|
|
897b4da81c | ||
|
|
bcc5a8e523 | ||
|
|
473ef0f40a | ||
|
|
3ee8532539 | ||
|
|
a7f5935b39 | ||
|
|
e3e7fca80c | ||
|
|
fad3977b03 | ||
|
|
57421da791 |
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
sessionId: session-260928-131223-1ueu
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
### Overview & Goals
|
||||||
|
Resolve the compilation error `CS7036` on `PlatformImage` in `ZeroSpace.MapEditor/Services/MapBackground.cs` and resolve compiler nullability warnings in `MapEditorPage.xaml.cs`, ensuring `ZeroSpace.MapEditor` and the entire solution (`ZeroSpace.sln`) compile cleanly with zero errors.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
#### In Scope
|
||||||
|
- Update `ZeroSpace.MapEditor/Services/MapBackground.cs` to load the galaxy map background image stream via the MAUI Graphics `PlatformImage.FromStream(buffer)` factory instead of the invalid two-parameter constructor `PlatformImage(ICanvasResourceCreator, CanvasBitmap)`.
|
||||||
|
- Resolve `CS8604` nullability warnings in `ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs` for `PopulateInspector` calls.
|
||||||
|
- Verify end-to-end solution build (`dotnet build ZeroSpace\ZeroSpace.sln`) with 0 errors across all projects.
|
||||||
|
|
||||||
|
#### Out of Scope
|
||||||
|
- Redesigning MapEditor UI layouts or modifying StarSystem data structures.
|
||||||
|
- Altering web pages in `ZeroSpace.Web`.
|
||||||
|
|
||||||
|
### User Stories
|
||||||
|
- **As a developer**, I want `ZeroSpace.MapEditor` and the entire `ZeroSpace.sln` solution to build cleanly without compile errors so that the Map Editor and all companion projects can be built, debugged, and run reliably.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
1. **PlatformImage Factory**: `MapBackground.LoadAsync()` must use the platform-compatible image stream loader (`Microsoft.Maui.Graphics.Platform.PlatformImage.FromStream(buffer)`) returning `IImage?`.
|
||||||
|
2. **Graceful Exception Fallback**: Maintain existing `try-catch` exception handling so the editor runs cleanly even if the background asset cannot be loaded or is absent.
|
||||||
|
3. **Null-Safety in MapEditorPage**: Ensure `PopulateInspector(MapNode node)` receives a guaranteed non-null `MapNode` reference across all selection and marquee interaction paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Technical Design
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
- `ZeroSpace.MapEditor/Services/MapBackground.cs` attempts `new Microsoft.Maui.Graphics.Platform.PlatformImage(buffer)` which fails with `CS7036: There is no argument given that corresponds to the required parameter 'bitmap' of 'PlatformImage.PlatformImage(ICanvasResourceCreator, CanvasBitmap)'`.
|
||||||
|
- In `Microsoft.Maui.Graphics.Platform` (.NET 10 / Windows App SDK / Win2D), `PlatformImage` instances from byte streams are instantiated via `PlatformImage.FromStream(Stream stream, ImageFormat format = ImageFormat.Png)` or `PlatformImageService.Instance.FromStream(stream)`.
|
||||||
|
- `MapEditorPage.xaml.cs` has 3 occurrences of `CS8604` possible null reference arguments where `_selected` is passed to `PopulateInspector(MapNode node)` right after `SetActiveNode(_pressNode)` or in multi-node selection resolution.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
1. **Use `PlatformImage.FromStream` Factory**:
|
||||||
|
- *Chosen Approach*: Replace direct `new PlatformImage(buffer)` constructor calls with `Microsoft.Maui.Graphics.Platform.PlatformImage.FromStream(buffer)`.
|
||||||
|
- *Rationale*: `PlatformImage.FromStream` is the standard cross-platform MAUI Graphics factory method for creating `IImage` instances from stream buffers on Windows and MacCatalyst.
|
||||||
|
2. **Explicit Non-Null Variable Passing in `MapEditorPage`**:
|
||||||
|
- *Chosen Approach*: Pass `_pressNode` or computed non-null `active` node directly to `PopulateInspector(node)`.
|
||||||
|
- *Rationale*: Eliminates nullable warnings cleanly without suppressing diagnostics or adding runtime overhead.
|
||||||
|
|
||||||
|
### Proposed Changes
|
||||||
|
1. `ZeroSpace.MapEditor/Services/MapBackground.cs`:
|
||||||
|
- Replace `new Microsoft.Maui.Graphics.Platform.PlatformImage(buffer)` with `Microsoft.Maui.Graphics.Platform.PlatformImage.FromStream(buffer)`.
|
||||||
|
2. `ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs`:
|
||||||
|
- Pass `_pressNode` in selection click handlers and pass local non-null `active` node in multi-select resolution before calling `PopulateInspector`.
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
- `ZeroSpace/ZeroSpace.MapEditor/Services/MapBackground.cs` (Modified)
|
||||||
|
- `ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs` (Modified)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
### Validation Approach
|
||||||
|
- Execute `dotnet build ZeroSpace\ZeroSpace.sln` to verify all projects compile with 0 errors.
|
||||||
|
- Verify `ZeroSpace.MapEditor.csproj` compiles cleanly for `net10.0-windows10.0.19041.0`.
|
||||||
|
|
||||||
|
### Key Scenarios
|
||||||
|
1. **MapBackground Image Stream Loading**: Confirm `MapBackground.LoadAsync()` builds cleanly with `PlatformImage.FromStream`.
|
||||||
|
2. **Full Solution Compilation**: Confirm `ZeroSpace.sln` completes with 0 errors and 0 blocking warnings.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
sessionId: session-260928-192341-jvqx
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
### Overview & Goals
|
||||||
|
Introduce a dedicated **Faction Mode** to `ZeroSpace.MapEditor` that allows rapid visual painting and assignment of faction ownership to star systems on the galaxy map canvas. The user can switch the active faction using hotkeys (`A`, `S`, `D`) or toolbar controls, click on any system to assign the selected faction, immediately observe the updated faction color on the map and inspector, and have changes automatically persisted to the underlying markdown files.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
- **In Scope:**
|
||||||
|
- Active Faction Mode state in `MapEditorPage` supporting the three core factions:
|
||||||
|
- `A` → **The Eden Accord** (Green: `#10b981`)
|
||||||
|
- `S` → **The Keeper Council** (Blue: `#38bdf8`)
|
||||||
|
- `D` → **The Obsidian Order** (Red: `#ef4444`)
|
||||||
|
- Toolbar UI controls and indicators showing the current active faction mode and its associated hotkey.
|
||||||
|
- Keyboard shortcut listeners for `A`, `S`, and `D` that activate the corresponding faction mode (with guards against triggering when typing in text fields like search or coordinate entries).
|
||||||
|
- Applying the active faction to star system nodes upon single click / selection.
|
||||||
|
- Applying the active faction directly to currently selected node(s) when `A`, `S`, or `D` is pressed.
|
||||||
|
- Immediate visual feedback on the map canvas (`MapDrawable`) and inspector panel (`OwnerLabel`) reflecting the updated owner color.
|
||||||
|
- Auto-saving owner modifications back to the markdown frontmatter in `zs.docs/StarSystem/*.md` via `StarSystemDocStore`.
|
||||||
|
- **Out of Scope:**
|
||||||
|
- Modifying gameplay balance or data generation outside of `ZeroSpace.MapEditor` and `StarSystemDocStore`.
|
||||||
|
- Adding new factions beyond the defined three in `StarSystemOwners`.
|
||||||
|
|
||||||
|
### User Stories
|
||||||
|
- **As a Map Designer**, I want to press `A`, `S`, or `D` to switch the active faction mode so that I can rapidly tag multiple star systems without tedious manual dropdown selection.
|
||||||
|
- **As a Map Designer**, I want to click on a star system on the map canvas and have its faction immediately update with corresponding visual color changes so that I have real-time visual confirmation of galaxy ownership boundaries.
|
||||||
|
- **As a Map Designer**, I want my faction changes to automatically save to the system markdown files so that I do not lose progress or have to manually click save after every edit.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
1. **Faction Mode State & Controls:**
|
||||||
|
- The toolbar must display the currently active faction mode with appropriate faction accent coloring.
|
||||||
|
- Interactive buttons for each faction (`[A] Eden Accord`, `[S] Keeper Council`, `[D] Obsidian Order`) allowing mouse-based activation as well as hotkey discovery.
|
||||||
|
2. **Hotkey Handling:**
|
||||||
|
- Pressing `A` (or `a`) sets active faction to `"The Eden Accord"`.
|
||||||
|
- Pressing `S` (or `s`) sets active faction to `"The Keeper Council"`.
|
||||||
|
- Pressing `D` (or `d`) sets active faction to `"The Obsidian Order"`.
|
||||||
|
- Hotkeys must not fire when focus is in an input field (`SearchControl`, `XEntry`, `YEntry`).
|
||||||
|
3. **Click-to-Assign:**
|
||||||
|
- Clicking a star system node in faction mode assigns the active faction to that node's `Doc.Owner`.
|
||||||
|
- If nodes are selected, pressing `A`, `S`, or `D` immediately updates the owner of all selected nodes.
|
||||||
|
4. **UI Color Synchronization:**
|
||||||
|
- The star system circle on the map canvas immediately re-renders with the faction's color:
|
||||||
|
- The Eden Accord: `#10b981`
|
||||||
|
- The Keeper Council: `#38bdf8`
|
||||||
|
- The Obsidian Order: `#ef4444`
|
||||||
|
- If the system is displayed in the inspector panel, the `OwnerLabel` and its styling update in real time.
|
||||||
|
5. **Auto-Saving:**
|
||||||
|
- Modifying a node's owner marks `Doc.IsDirty = true`.
|
||||||
|
- Automatically triggers `AutoSaveAsync()` to persist the `owner: <Faction>` frontmatter property in the system's markdown file.
|
||||||
|
- The Save button and status text reflect save progress and confirmation ("Saving...", "Saved", "Auto-saved X system(s)").
|
||||||
|
|
||||||
|
# Technical Design
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
- `StarSystemDocStore`: Parses YAML frontmatter from markdown files in `zs.docs/StarSystem/`. Currently, `SavePositions` only updates `x:` and `y:` lines in frontmatter. `owner:` is read during `LoadFile` but not rewritten during save.
|
||||||
|
- `MapDrawable`: Renders systems using `ColorForOwner(node.Doc.Owner)`. Supports node translation gizmo, marquee selection, background image, and connection lines.
|
||||||
|
- `MapEditorPage`: Handles pointer gestures (`OnPointerPressed`, `OnPointerMoved`, `OnPointerReleased`), panning, selection, inspector binding, and debounced auto-saving for position edits.
|
||||||
|
- `StarSystemOwners`: Defines constant strings for `"The Eden Accord"`, `"The Keeper Council"`, and `"The Obsidian Order"` along with standard hex colors.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
1. **Faction Mode Model**:
|
||||||
|
- Introduce an explicit active faction mode state in `MapEditorPage` (`_activeFaction`: string or enum, defaulting to `"The Eden Accord"` or toggleable mode).
|
||||||
|
- Hotkeys `A`, `S`, `D` both switch the active painting faction and immediately apply it to any actively selected system(s).
|
||||||
|
2. **Keyboard Event Interception**:
|
||||||
|
- Hook window / page key down events on Windows / MAUI (`Loaded` / platform key events / MAUI `KeyboardAccelerators`).
|
||||||
|
- Check `IsFocused` on `SearchControl`, `XEntry`, and `YEntry` before handling hotkeys so normal text entry is never hindered.
|
||||||
|
3. **Markdown Frontmatter Persistence**:
|
||||||
|
- Update `StarSystemDocStore` save logic to update or insert the `owner: <Owner>` frontmatter entry alongside `x:` and `y:` while preserving all other frontmatter fields and body markdown intact.
|
||||||
|
4. **Immediate Color Feedback**:
|
||||||
|
- Mutating `node.Doc.Owner` directly triggers `MapView.Invalidate()` and `PopulateInspector(_selected)`, ensuring zero latency in UI color updates.
|
||||||
|
|
||||||
|
### Data Models & Contracts
|
||||||
|
```csharp
|
||||||
|
public enum FactionMode
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
EdenAccord, // Hotkey A - #10b981
|
||||||
|
KeeperCouncil, // Hotkey S - #38bdf8
|
||||||
|
ObsidianOrder // Hotkey D - #ef4444
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontmatter update in `StarSystemDocStore`:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
category: StarSystem
|
||||||
|
systemCategory: Side System
|
||||||
|
owner: The Keeper Council
|
||||||
|
occupationBuff: +50 Starting Hexite
|
||||||
|
systemMastery: "[[Unit Health and Shields]]"
|
||||||
|
x: 450.2
|
||||||
|
y: 310.8
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components & File Structure
|
||||||
|
- `ZeroSpace.MapEditor/Services/StarSystemDocStore.cs`:
|
||||||
|
- Enhance `SaveDoc` / `SaveDocs` / `SavePosition` to serialize `owner` to the frontmatter.
|
||||||
|
- `ZeroSpace.MapEditor/Views/MapEditorPage.xaml`:
|
||||||
|
- Add Faction Mode UI toolbar buttons/indicators (`Eden Accord [A]`, `Keeper Council [S]`, `Obsidian Order [D]`) with visual active highlights and color badges.
|
||||||
|
- `ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs`:
|
||||||
|
- Manage active faction mode state and toolbar button styling.
|
||||||
|
- Implement key press listeners for `A`, `S`, `D`.
|
||||||
|
- Handle click-to-assign on star system hit test.
|
||||||
|
- Refresh inspector and canvas colors.
|
||||||
|
- Integrate with `AutoSaveAsync()`.
|
||||||
|
|
||||||
|
### Architecture Diagram
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
UserKeyboard["User Press: A / S / D"] --> KeyHandler["MapEditorPage Key Handler"]
|
||||||
|
UserClick["User Click on Star System Node"] --> PointerHandler["OnPointerPressed / Click Handler"]
|
||||||
|
ToolbarButton["User Click Toolbar Faction Button"] --> FactionSelector["SetActiveFactionMode(...)"]
|
||||||
|
|
||||||
|
KeyHandler --> FactionSelector
|
||||||
|
KeyHandler --> ApplyFaction["Apply Faction to Selected Node(s)"]
|
||||||
|
FactionSelector --> UpdateToolbarUI["Update Toolbar Mode Indicators & Badges"]
|
||||||
|
PointerHandler --> ApplyFaction
|
||||||
|
|
||||||
|
ApplyFaction --> UpdateDoc["Update Doc.Owner & Set Doc.IsDirty = true"]
|
||||||
|
UpdateDoc --> RedrawMap["MapView.Invalidate() (MapDrawable.ColorForOwner)"]
|
||||||
|
UpdateDoc --> UpdateInspector["PopulateInspector(node) (OwnerLabel Color)"]
|
||||||
|
UpdateDoc --> AutoSave["AutoSaveAsync()"]
|
||||||
|
AutoSave --> DocStore["StarSystemDocStore.SaveDocs(...)"]
|
||||||
|
DocStore --> MarkdownFiles["Persist 'owner: ...' to zs.docs/StarSystem/*.md"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Risks & Mitigations
|
||||||
|
- **Risk:** Typing 'a', 's', or 'd' inside the search bar or coordinate entries could trigger faction mode changes.
|
||||||
|
- *Mitigation:* Ensure key events check focus status on all input entries (`SearchControl.IsFocused`, `XEntry.IsFocused`, `YEntry.IsFocused`) before processing hotkeys.
|
||||||
|
- **Risk:** Modifying frontmatter could accidentally reorder or corrupt markdown content.
|
||||||
|
- *Mitigation:* Use regex/line-based frontmatter replacement in `StarSystemDocStore` matching existing `SavePosition` patterns to preserve line endings, spacing, and other YAML metadata.
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
### Validation Approach
|
||||||
|
Verify faction mode switching, hotkey interactions, click-to-assign workflows, real-time UI color updating, and frontmatter persistence across unit tests and interactive workflows.
|
||||||
|
|
||||||
|
### Key Scenarios
|
||||||
|
1. **Hotkey Faction Switching:**
|
||||||
|
- Press `A`: Faction mode switches to **The Eden Accord**; toolbar highlights the Accord button with green accent (`#10b981`).
|
||||||
|
- Press `S`: Faction mode switches to **The Keeper Council**; toolbar highlights the Keeper button with blue accent (`#38bdf8`).
|
||||||
|
- Press `D`: Faction mode switches to **The Obsidian Order**; toolbar highlights the Obsidian button with red accent (`#ef4444`).
|
||||||
|
2. **Click-to-Assign System Faction:**
|
||||||
|
- Click an unassigned or Eden system while Keeper Council mode (`S`) is active.
|
||||||
|
- Verify node color on map canvas immediately turns blue (`#38bdf8`).
|
||||||
|
- Verify Inspector `OwnerLabel` updates to "The Keeper Council" with matching color.
|
||||||
|
3. **Multi-Selection Assignment:**
|
||||||
|
- Marquee-select 3 systems and press `D`.
|
||||||
|
- Verify all 3 systems immediately turn red (`#ef4444`).
|
||||||
|
4. **Auto-Saving to Markdown:**
|
||||||
|
- Change a system's faction; verify the Save button transitions to "Saving..." and then "Saved" / "Auto-saved 1 system".
|
||||||
|
- Inspect the `.md` file on disk in `zs.docs/StarSystem/` and confirm `owner: ...` has been updated with valid YAML frontmatter formatting.
|
||||||
|
5. **Text Input Protection:**
|
||||||
|
- Focus `SearchControl` and type "Ashes"; verify 'A' and 's' are entered into the search box and do NOT trigger faction changes.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- File with missing or malformed `owner` frontmatter key is gracefully updated and written.
|
||||||
|
- Multiple rapid hotkey presses or clicks debounce auto-save properly without race conditions.
|
||||||
|
- System with read-only attributes or special characters in name handles saving cleanly.
|
||||||
|
|
||||||
|
# Delivery Steps
|
||||||
|
|
||||||
|
### ✓ Step 1: Update StarSystemDocStore to support owner serialization and dirty state tracking
|
||||||
|
StarSystemDocStore supports persisting the `owner` property to markdown frontmatter, and StarSystemDoc dirty state tracks faction edits.
|
||||||
|
|
||||||
|
- Extend `StarSystemDocStore` methods (`SaveDoc`, `SaveDocs`, or `SavePosition`) to update or add the `owner:` frontmatter key when writing changes to `.md` files.
|
||||||
|
- Preserve all existing frontmatter keys, comments, and markdown body byte-for-byte when updating `owner`, `x`, and `y`.
|
||||||
|
- Ensure `doc.IsDirty` is set to `true` whenever `doc.Owner` is modified.
|
||||||
|
- Verify unit doc save logic parses and preserves the standard faction names (`The Eden Accord`, `The Keeper Council`, `The Obsidian Order`).
|
||||||
|
|
||||||
|
### ✓ Step 2: Add Faction Mode UI toolbar controls and visual indicators
|
||||||
|
The MapEditorPage toolbar displays visual buttons and indicators for the active faction mode with distinct faction colors.
|
||||||
|
|
||||||
|
- Add faction mode selection and indicator controls to the top toolbar in `MapEditorPage.xaml` (e.g., dedicated buttons/badges for Accord `[A]`, Keeper `[S]`, and Obsidian `[D]`, plus a mode toggle or active indicator).
|
||||||
|
- Style the faction buttons/indicators with their corresponding colors: Green (`#10b981`) for The Eden Accord, Blue (`#38bdf8`) for The Keeper Council, and Red (`#ef4444`) for The Obsidian Order.
|
||||||
|
- Update `OwnerLabel` (and optional badge) in the inspector panel to display the owner name with faction color coding.
|
||||||
|
- Provide clear visual feedback indicating which faction mode is currently active.
|
||||||
|
|
||||||
|
### ✓ Step 3: Implement hotkeys, click-to-assign logic, and auto-save integration
|
||||||
|
Pressing A, S, or D switches the active faction mode, and clicking any star system node immediately applies the faction and triggers auto-save.
|
||||||
|
|
||||||
|
- Add keyboard event handling to `MapEditorPage` for hotkeys `A`, `S`, and `D` (switching active faction to Eden Accord, Keeper Council, and Obsidian Order respectively), ignoring hotkeys when text fields (`SearchControl`, `XEntry`, `YEntry`) are focused.
|
||||||
|
- If one or more nodes are currently selected when pressing `A`, `S`, or `D`, apply the faction to the selection immediately.
|
||||||
|
- Update `OnPointerPressed` / `OnPointerReleased` to apply the active faction to the clicked `MapNode` when in faction mode.
|
||||||
|
- Invalidate the `MapView` canvas so the node's fill color updates immediately via `MapDrawable.ColorForOwner`.
|
||||||
|
- Trigger `AutoSaveAsync()` (or debounced `ScheduleAutoSave()`) whenever a system's owner changes, keeping the Save button status and feedback message in sync.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
sessionId: session-260928-184444-1lki
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
### Overview & Goals
|
||||||
|
Fix the build error in `ZeroSpace.MapEditor/Services/MapBackground.cs` where invoking `new PlatformImage(buffer)` fails with compilation error `CS7036: There is no argument given that corresponds to the required parameter 'bitmap' of 'PlatformImage.PlatformImage(ICanvasResourceCreator, CanvasBitmap)'`.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
- **In Scope:**
|
||||||
|
- Fix the image instantiation logic in `ZeroSpace.MapEditor/Services/MapBackground.cs` to use `Microsoft.Maui.Graphics.Platform.PlatformImage.FromStream(buffer)`.
|
||||||
|
- Ensure compatibility with .NET MAUI Windows (`net10.0-windows10.0.19041.0`) and MacCatalyst (`net10.0-maccatalyst`).
|
||||||
|
- Verify project build and clean diagnostic status.
|
||||||
|
- **Out of Scope:**
|
||||||
|
- Modifying the galaxy map raw asset or canvas drawing logic in `MapEditorPage.xaml.cs` and `MapEditorDrawable.cs`.
|
||||||
|
- Introducing external third-party image decoding dependencies.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
- `MapBackground.LoadAsync()` must asynchronously open `galaxymap.png` from app package assets, copy the stream into memory, and return a valid `Microsoft.Maui.Graphics.IImage` instance on supported platforms.
|
||||||
|
- If the asset cannot be found or is loaded on an unsupported platform, `MapBackground.LoadAsync()` must catch the exception and return `null` without crashing the application.
|
||||||
|
- `MapEditorPage` must continue to display the galaxy map background when enabled.
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
- Maintain zero build errors and zero warnings across all supported target frameworks.
|
||||||
|
- Preserve memory stream behavior ensuring stream data remains accessible for lazy platform decoders.
|
||||||
|
|
||||||
|
# Technical Design
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
In `ZeroSpace.MapEditor/Services/MapBackground.cs`:
|
||||||
|
```csharp
|
||||||
|
#if WINDOWS
|
||||||
|
return new Microsoft.Maui.Graphics.Platform.PlatformImage(buffer);
|
||||||
|
#elif MACCATALYST
|
||||||
|
return new Microsoft.Maui.Graphics.Platform.PlatformImage(buffer);
|
||||||
|
#else
|
||||||
|
return null;
|
||||||
|
#endif
|
||||||
|
```
|
||||||
|
On Windows (`Microsoft.Maui.Graphics.Win2D`), `PlatformImage` has no single-parameter constructor accepting a `Stream`. Its constructor expects `(ICanvasResourceCreator, CanvasBitmap)` or `(byte[], ImageFormat)`.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
Directly calling the `PlatformImage` constructor with a `MemoryStream` is invalid in Microsoft.Maui.Graphics for Win2D. The supported API to construct an `IImage` from a `Stream` across all MAUI platforms is the static factory method `PlatformImage.FromStream(Stream, ImageFormat)`.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
- **Decision:** Use `PlatformImage.FromStream(buffer)` instead of constructor invocation.
|
||||||
|
- **Rationale:** `PlatformImage.FromStream(Stream, ImageFormat)` is the standard Microsoft.Maui.Graphics API designed for stream-based image creation on both Windows and MacCatalyst platforms.
|
||||||
|
|
||||||
|
### Proposed Changes
|
||||||
|
- In `ZeroSpace.MapEditor/Services/MapBackground.cs`:
|
||||||
|
- Update lines 25–31 to invoke `Microsoft.Maui.Graphics.Platform.PlatformImage.FromStream(buffer)` under `#if WINDOWS || MACCATALYST`.
|
||||||
|
- Maintain the existing try-catch safety wrapper that returns `null` if loading fails.
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
- `ZeroSpace.MapEditor/Services/MapBackground.cs` (Modified: update image factory call)
|
||||||
|
- `ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs` (Referenced: consumes `MapBackground.LoadAsync()`)
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
### Validation Approach
|
||||||
|
Verify that the compilation error `CS7036` is resolved and `ZeroSpace.MapEditor` builds successfully.
|
||||||
|
|
||||||
|
### Key Scenarios
|
||||||
|
- **Compilation Check:**
|
||||||
|
- Verify `ZeroSpace.MapEditor/Services/MapBackground.cs` with Rider code analysis to confirm zero compiler errors.
|
||||||
|
- Build `ZeroSpace.MapEditor.csproj` target framework `net10.0-windows10.0.19041.0`.
|
||||||
|
- **Image Loading & Fallback Check:**
|
||||||
|
- Ensure `MapBackground.LoadAsync()` returns an `IImage` instance when `galaxymap.png` is available.
|
||||||
|
- Ensure exceptions during loading (e.g. missing asset) are handled cleanly and return `null`.
|
||||||
|
|
||||||
|
# Delivery Steps
|
||||||
|
|
||||||
|
### ✓ Step 1: Refactor image stream loading to use PlatformImage.FromStream in MapBackground
|
||||||
|
`MapBackground.cs` compiles without CS7036 errors across target platforms by using the static factory method `PlatformImage.FromStream`.
|
||||||
|
|
||||||
|
- Replace `new Microsoft.Maui.Graphics.Platform.PlatformImage(buffer)` with `Microsoft.Maui.Graphics.Platform.PlatformImage.FromStream(buffer)` in `ZeroSpace.MapEditor/Services/MapBackground.cs`.
|
||||||
|
- Consolidate platform conditional directives `#if WINDOWS || MACCATALYST` (or use platform-agnostic graphics loading) while preserving graceful `null` fallback in the `catch` block and unsupported target platforms.
|
||||||
|
- Ensure using directives for `Microsoft.Maui.Graphics.Platform` are properly configured.
|
||||||
|
|
||||||
|
### ✓ Step 2: Verify compilation and image rendering integration
|
||||||
|
`ZeroSpace.MapEditor` builds cleanly without diagnostics or runtime issues when loading raw background assets.
|
||||||
|
|
||||||
|
- Run code analysis/diagnostics on `ZeroSpace.MapEditor/Services/MapBackground.cs` to confirm zero compilation errors and warnings.
|
||||||
|
- Build the `ZeroSpace.MapEditor` project to ensure proper integration with `Microsoft.Maui.Controls` and `Microsoft.Maui.Graphics`.
|
||||||
|
- Validate that `MapEditorPage.xaml.cs` successfully receives the `IImage` instance from `MapBackground.LoadAsync()` and invalidates the map canvas for rendering.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
sessionId: session-260929-233614-1hed
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
### Overview & Goals
|
||||||
|
Add a "Download JSON" button to the interactive Galaxy Map in `ZeroSpace.Web` that exports all data powering the galaxy map into a structured, downloadable JSON file (`galaxy-map-data.json`). The exported data will include all planetary systems with their full metadata and Markdown bodies, system masteries with their bonuses and notes, and planetary mastery items.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
- **In Scope**:
|
||||||
|
- A client-side browser file download utility via Blazor `IJSRuntime`.
|
||||||
|
- Aggregation and serialization of `StarSystems.All` (including all markdown notes in `Body`), `SystemMasteries.All`, and `PlanetaryMasteryItems.All`.
|
||||||
|
- A "Download JSON" button styled with the project's Sci-Fi theme in `ZeroSpace.Web/Pages/Galaxy.razor`.
|
||||||
|
- **Out of Scope**:
|
||||||
|
- Backend API changes (the export is generated client-side from the static/in-memory data models already present in Blazor WebAssembly).
|
||||||
|
- Editing or mutating star system data.
|
||||||
|
|
||||||
|
### User Stories
|
||||||
|
- **As a player / community developer**, I want to download all galaxy map data (including star systems, hyperlane connections, occupation buffs, masteries, and markdown notes) in JSON format from the Galaxy Map page so that I can analyze the data offline, create custom tooling, or reference game lore.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
1. **Download Action**: Clicking the "Download JSON" button generates and initiates a browser download of `galaxy-map-data.json`.
|
||||||
|
2. **Star System Data & Markdown**: The JSON output must contain all star systems from `StarSystems.All` including ID, name, category, owner, yield flags, occupation buff, system mastery, connected systems, coordinates, and full markdown text (`Body`).
|
||||||
|
3. **Mastery Data**: The JSON output must contain all system masteries from `SystemMasteries.All` (levels, available systems, stats, and markdown body) and planetary mastery items from `PlanetaryMasteryItems.All`.
|
||||||
|
4. **JSON Formatting**: The output JSON must be well-formatted (indented, camelCase property naming, string representations for enums).
|
||||||
|
5. **UI Integration**: The button must be positioned intuitively in the Galaxy header / control bar matching the existing glassmorphic Sci-Fi styling.
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
- **Performance**: Export generation and download initiation should take < 50ms without blocking UI interactions.
|
||||||
|
- **Offline / WebAssembly Compatibility**: The export must function entirely within the client-side Blazor WebAssembly runtime without requiring server endpoints.
|
||||||
|
|
||||||
|
# Technical Design
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
- `ZeroSpace.Web/Pages/Galaxy.razor`: The interactive galaxy map page displaying star systems, hyperlanes, and faction occupation details using `StarSystems.All` and `SystemMasteries.All`.
|
||||||
|
- `ZeroSpace.Model/StarSystemData.cs` & `StarSystems.g.cs`: Contains `StarSystemData` records where `Body` contains the planetary system Markdown notes.
|
||||||
|
- `ZeroSpace.Model/SystemMasteryData.cs` & `SystemMasteries.g.cs`: Contains `SystemMasteryData` records with mastery levels, system associations, stats, and markdown `Body`.
|
||||||
|
- `ZeroSpace.Model/PlanetaryMasteryItemData.cs` & `PlanetaryMasteryItems.g.cs`: Contains `PlanetaryMasteryItemData` records for mastery items.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
1. **Client-Side Export**: Use Blazor WebAssembly's `IJSRuntime` with a lightweight browser Blob URL helper to trigger file download instantly in memory without backend roundtrips.
|
||||||
|
2. **Unified Data Structure**: Group data under top-level properties (`systems`, `masteries`, `planetaryMasteryItems`, `exportedAt`) for easy consumption by external tools.
|
||||||
|
3. **Formatting**: Use `System.Text.Json` with `JsonSerializerOptions` configured for `WriteIndented = true`, `PropertyNamingPolicy = JsonNamingPolicy.CamelCase`, and `JsonStringEnumConverter`.
|
||||||
|
|
||||||
|
### Proposed Changes
|
||||||
|
1. **File Download Helper**:
|
||||||
|
- Add `ZeroSpace.Web/wwwroot/js/fileDownload.js` containing `downloadFileFromText(fileName, contentType, textContent)`.
|
||||||
|
- Include script reference in `ZeroSpace.Web/wwwroot/index.html`.
|
||||||
|
2. **Export Model**:
|
||||||
|
- Define `GalaxyMapExportData` record containing `IReadOnlyList<StarSystemData> Systems`, `IReadOnlyList<SystemMasteryData> Masteries`, `IReadOnlyList<PlanetaryMasteryItemData> PlanetaryMasteryItems`, and `DateTimeOffset ExportedAt`.
|
||||||
|
3. **Galaxy Page Update**:
|
||||||
|
- Add a "Download JSON" button to the header action bar in `Galaxy.razor`.
|
||||||
|
- Add `DownloadJsonAsync()` method in `@code` section that builds `GalaxyMapExportData`, serializes to JSON, and calls the JS helper.
|
||||||
|
|
||||||
|
### Data Models / Contracts
|
||||||
|
```csharp
|
||||||
|
public sealed record GalaxyMapExportData(
|
||||||
|
DateTimeOffset ExportedAt,
|
||||||
|
int TotalSystems,
|
||||||
|
int TotalMasteries,
|
||||||
|
IReadOnlyList<StarSystemData> Systems,
|
||||||
|
IReadOnlyList<SystemMasteryData> Masteries,
|
||||||
|
IReadOnlyList<PlanetaryMasteryItemData> PlanetaryMasteryItems);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture Diagram
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
User[User clicks Download JSON] --> Razor[Galaxy.razor]
|
||||||
|
Razor --> Aggregator[Aggregate StarSystems, Masteries, Items]
|
||||||
|
Aggregator --> Serializer[System.Text.Json Serializer]
|
||||||
|
Serializer --> JSInterop[IJSRuntime downloadFileFromText]
|
||||||
|
JSInterop --> Browser[Browser saves galaxy-map-data.json]
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
- `ZeroSpace.Web/wwwroot/js/fileDownload.js` *(new)*
|
||||||
|
- `ZeroSpace.Web/wwwroot/index.html` *(modified to include script tag)*
|
||||||
|
- `ZeroSpace.Web/Models/GalaxyMapExportData.cs` *(new)*
|
||||||
|
- `ZeroSpace.Web/Pages/Galaxy.razor` *(modified to add download button and handler)*
|
||||||
|
- `ZeroSpace.Web/Pages/Galaxy.razor.css` *(optional style tweaks for button layout)*
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
- **Large Payload / Memory**: The total size of all systems and markdown is < 500 KB, which fits easily in browser memory for Blob generation.
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
### Validation Approach
|
||||||
|
- Verify solution builds with 0 errors via `build_solution_start` and `build_solution_state`.
|
||||||
|
- Verify the serialized JSON structure against required fields (star system metadata, markdown `Body`, masteries, levels, stats).
|
||||||
|
- Verify the button UI placement, styling, and JSInterop invocation logic.
|
||||||
|
|
||||||
|
### Key Scenarios
|
||||||
|
1. **Download Button Interaction**: User clicks the "Download JSON" button on `/galaxy`.
|
||||||
|
- Expected: `downloadFileFromText` JSInterop is called with `galaxy-map-data.json`, `application/json`, and the formatted JSON string.
|
||||||
|
2. **JSON Content Completeness**:
|
||||||
|
- Every star system from `StarSystems.All` is present in the `systems` array.
|
||||||
|
- The markdown `body` field of each star system is included.
|
||||||
|
- Every mastery from `SystemMasteries.All` is present in the `masteries` array with levels and stats.
|
||||||
|
- Planetary mastery items from `PlanetaryMasteryItems.All` are included.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- Systems with empty vs populated markdown `Body` serialize properly as strings (`""` or markdown text).
|
||||||
|
- Enum values (`StarSystemCategory`) serialize cleanly as strings (e.g. `"homeworldSystem"`, `"coreSystem"`, `"sideSystem"` or PascalCase).
|
||||||
|
|
||||||
|
# Delivery Steps
|
||||||
|
|
||||||
|
### ✓ Step 1: Implement file download JSInterop helper and export serialization model
|
||||||
|
Create the file download JSInterop utility and the galaxy map data export serialization contract.
|
||||||
|
|
||||||
|
- Add a JavaScript file download helper (`ZeroSpace.Web/wwwroot/js/fileDownload.js` or equivalent) that accepts filename, content type, and content/bytes, creating an object URL blob and triggering a download anchor click.
|
||||||
|
- Reference the script in `ZeroSpace.Web/wwwroot/index.html`.
|
||||||
|
- Define an export container record (e.g., `GalaxyMapExportData`) in `ZeroSpace.Web` / `ZeroSpace.Model` that aggregates star systems (with full metadata and markdown `Body`), system masteries (with levels, stats, available systems, and `Body`), and planetary mastery items.
|
||||||
|
- Implement a JSON serialization helper/service configured with `JsonSerializerOptions` (`WriteIndented = true`, camelCase naming, `JsonStringEnumConverter`) for clean formatting.
|
||||||
|
|
||||||
|
### ✓ Step 2: Integrate Download JSON button and handler into the Galaxy Map page
|
||||||
|
Add the Download JSON button to the Galaxy Map page and wire up the export trigger.
|
||||||
|
|
||||||
|
- Add the "Download JSON" button in the top action bar of `ZeroSpace.Web/Pages/Galaxy.razor` next to the map legend/stats controls.
|
||||||
|
- Apply consistent Sci-Fi design system styling (`btn-scifi-outline` with appropriate icon/label) in `Galaxy.razor` and `Galaxy.razor.css`.
|
||||||
|
- Inject `IJSRuntime` in `Galaxy.razor` and implement the `DownloadGalaxyJsonAsync` event handler to generate the export payload and trigger the client-side download named `galaxy-map-data.json`.
|
||||||
|
- Build the solution and verify clean compilation without errors or warnings.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"route": "/index.html",
|
||||||
|
"headers": {
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/_framework/*",
|
||||||
|
"headers": {
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"navigationFallback": {
|
||||||
|
"rewrite": "/index.html"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ The goal is to modernize the ZeroSpace application by introducing **.NET Aspire*
|
|||||||
# Technical Design
|
# Technical Design
|
||||||
|
|
||||||
### Current Implementation
|
### Current Implementation
|
||||||
- `ZeroSpace.Web` is a standalone Blazor WebAssembly application (`Microsoft.NET.Sdk.BlazorWebAssembly`) targeting .NET 10 with Telerik UI for Blazor.
|
- `ZeroSpace.Web` is a standalone Blazor WebAssembly application (`Microsoft.NET.Sdk.BlazorWebAssembly`) targeting .NET 10, using native Blazor UI components only.
|
||||||
- `ZeroSpace.Model` contains records like `UnitData.cs`, `UnitEfficiency.cs`, and generated static unit arrays in `Units.g.cs`.
|
- `ZeroSpace.Model` contains records like `UnitData.cs`, `UnitEfficiency.cs`, and generated static unit arrays in `Units.g.cs`.
|
||||||
- `ZeroSpace.Deploy` publishes static files to Azure Static Web Apps.
|
- `ZeroSpace.Deploy` publishes static files to Azure Static Web Apps.
|
||||||
- `ZeroSpace.AppHost`, `ZeroSpace.ServiceDefaults`, and `ZeroSpace.ApiService` are integrated into the solution.
|
- `ZeroSpace.AppHost`, `ZeroSpace.ServiceDefaults`, and `ZeroSpace.ApiService` are integrated into the solution.
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
sessionId: session-260930-201705-1ilk
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
### Overview & Goals
|
||||||
|
The objective is to ensure that deep link URLs (such as `https://zs.gameslopreference.ca/galaxy`, `/planetary-mastery-items`, `/StarSystem/...`, and `/SystemMastery/...`) load properly when requested directly or refreshed in production browsers, rather than resulting in 404 Not Found errors.
|
||||||
|
|
||||||
|
Providing robust URL resolution maximizes utility for end users by enabling direct bookmarking, shareable links, and reliable browser refreshes across deployment targets including Azure Static Web Apps and static hosting environments.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
- **In Scope:**
|
||||||
|
- Configuring Azure Static Web Apps navigation fallback rules (`staticwebapp.config.json`) in `ZeroSpace.Web/wwwroot`.
|
||||||
|
- Adding universal static host SPA 404 redirect fallback (`404.html`) and client-side URL restoration in `ZeroSpace.Web/wwwroot/index.html`.
|
||||||
|
- Ensuring static assets (images, CSS, JS, WASM binaries) remain directly accessible and properly cached without being captured by fallback rewrites.
|
||||||
|
- Verifying publish and deployment compatibility with `ZeroSpace.Deploy`.
|
||||||
|
|
||||||
|
- **Out of Scope:**
|
||||||
|
- Modifying backend APIs or database schemas in `ZeroSpace.ApiService`.
|
||||||
|
- Altering Blazor internal routing logic in `App.razor` or `ReleaseGuard.razor`.
|
||||||
|
|
||||||
|
### User Stories
|
||||||
|
- As a user navigating directly to `https://zs.gameslopreference.ca/galaxy`, I want the galaxy map page to render directly without encountering a host 404 error.
|
||||||
|
- As a user sharing or bookmarking a deep link to a star system or planetary mastery item, I want the recipient to immediately load the exact target route.
|
||||||
|
- As an administrator refreshing a deep page URL, I want the session and route to remain intact with zero broken navigation flows.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
- Direct GET requests to client-side routes (e.g., `/galaxy`, `/planetary-mastery-items`) must return the main application shell (`index.html`) with HTTP 200/redirect so the Blazor WebAssembly client router can take over.
|
||||||
|
- Requests for physical static files under `/images/`, `/css/`, `/js/`, `/lib/`, and `/_framework/` must continue to be served directly as static files or return standard 404s if missing, avoiding infinite loop rewrites.
|
||||||
|
- URL query parameters and hash fragments must be preserved when client-side redirects occur.
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
- **Performance & Reliability:** The redirect mechanism must execute with negligible overhead (<5ms execution time) before WebAssembly startup.
|
||||||
|
- **Portability:** The solution must function seamlessly whether deployed via Azure Static Web Apps (`swa deploy`), containerized Nginx (`ZeroSpace.Web/nginx.conf`), or static CDN hosts.
|
||||||
|
|
||||||
|
# Technical Design
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
- `ZeroSpace.Web` is a client-side Blazor WebAssembly application (`Microsoft.NET.Sdk.BlazorWebAssembly`) targeting .NET 10.
|
||||||
|
- `ZeroSpace.Deploy` publishes the Web project to a temporary directory and deploys `wwwroot` to Azure Static Web Apps using `swa deploy`.
|
||||||
|
- While `ZeroSpace.Web/nginx.conf` contains `try_files $uri $uri/ /index.html;` for Docker deployments, Azure Static Web Apps lacks a `staticwebapp.config.json` configuration, causing direct HTTP requests to deep paths like `/galaxy` to return 404 responses from Azure's edge servers.
|
||||||
|
- `ZeroSpace.Web/wwwroot/index.html` currently lacks client-side deep URL reconstruction script handlers for static host fallbacks.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
- **Dual-Layer Fallback Strategy (Azure SWA + Universal SPA Redirect):**
|
||||||
|
- *Decision:* Provide both `staticwebapp.config.json` for native Azure Static Web Apps server-side rewrites and `404.html` + `index.html` URL restoration script for generic static hosting.
|
||||||
|
- *Rationale:* Maximizes availability and utility across all deployment topologies while delivering instant, clean URL serving on Azure SWA without redirect hops.
|
||||||
|
|
||||||
|
- **Explicit Asset Exclusion in Navigation Fallback:**
|
||||||
|
- *Decision:* Explicitly exclude `_framework`, `images`, `css`, `js`, and `lib` directories from navigation fallback rewriting.
|
||||||
|
- *Rationale:* Prevents corrupted binary downloads (such as `.wasm` or missing images) from accidentally serving `index.html` content with invalid MIME types.
|
||||||
|
|
||||||
|
### Proposed Changes
|
||||||
|
|
||||||
|
#### 1. Add `ZeroSpace.Web/wwwroot/staticwebapp.config.json`
|
||||||
|
Configure the Azure Static Web Apps routing engine:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"navigationFallback": {
|
||||||
|
"rewrite": "/index.html",
|
||||||
|
"exclude": [
|
||||||
|
"/images/*",
|
||||||
|
"/css/*",
|
||||||
|
"/js/*",
|
||||||
|
"/lib/*",
|
||||||
|
"/_framework/*",
|
||||||
|
"/*.ico",
|
||||||
|
"/*.png",
|
||||||
|
"/*.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"responseOverrides": {
|
||||||
|
"404": {
|
||||||
|
"rewrite": "/index.html"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mimeTypes": {
|
||||||
|
".wasm": "application/wasm",
|
||||||
|
".json": "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Add `ZeroSpace.Web/wwwroot/404.html`
|
||||||
|
Add lightweight SPA 404 redirect handler for static hosts:
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>ZeroSpace</title>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var pathSegmentsToKeep = 0;
|
||||||
|
var l = window.location;
|
||||||
|
l.replace(
|
||||||
|
l.protocol + '//' + l.hostname + (l.port ? ':' + l.port : '') +
|
||||||
|
l.pathname.split('/').slice(0, 1 + pathSegmentsToKeep).join('/') + '/?/' +
|
||||||
|
l.pathname.slice(1).split('/').slice(pathSegmentsToKeep).join('/').replace(/&/g, '~and~') +
|
||||||
|
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
|
||||||
|
l.hash
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Update `ZeroSpace.Web/wwwroot/index.html`
|
||||||
|
Add the URL reconstruction handler before Blazor script startup in `<head>`:
|
||||||
|
```html
|
||||||
|
<script type="text/javascript">
|
||||||
|
(function(l) {
|
||||||
|
if (l.search[1] === '/' ) {
|
||||||
|
var decoded = l.search.slice(1).split('&').map(function(s) {
|
||||||
|
return s.replace(/~and~/g, '&')
|
||||||
|
}).join('?');
|
||||||
|
window.history.replaceState(null, null,
|
||||||
|
l.pathname.slice(0, -1) + decoded + l.hash
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}(window.location))
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
- `ZeroSpace.Web/`
|
||||||
|
- `wwwroot/`
|
||||||
|
- `staticwebapp.config.json` *(new)*
|
||||||
|
- `404.html` *(new)*
|
||||||
|
- `index.html` *(modified)*
|
||||||
|
|
||||||
|
### Risks & Mitigations
|
||||||
|
- **Static Asset Masking:** If asset routes are mistakenly rewritten to `index.html`, WASM or image loads fail silently.
|
||||||
|
- *Mitigation:* Explicit glob exclusions in `staticwebapp.config.json` for `/_framework/*`, `/images/*`, `/css/*`, and asset extensions.
|
||||||
|
- **Route Guard Interaction:** Unreleased pages should still respect release restrictions.
|
||||||
|
- *Mitigation:* `ReleaseGuard.razor` continues to evaluate `SiteMap.IsReleased(pageType)` after router loads, gracefully redirecting unreleased routes to `/galaxy`.
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
### Validation Approach
|
||||||
|
Verification focuses on confirming that published artifacts contain all necessary configuration files, that the build succeeds without error, and that URL restoration logic accurately handles routes, query strings, and asset exclusions.
|
||||||
|
|
||||||
|
### Key Scenarios
|
||||||
|
- **Direct Route Navigation:** Direct GET to `/galaxy` serves the application shell and initializes Blazor at `/galaxy`.
|
||||||
|
- **Deep Item Route Navigation:** Direct GET to `/planetary-mastery-items` or `/StarSystem` routes directly to the requested component.
|
||||||
|
- **Static Asset Serving:** Requests to `/_framework/blazor.webassembly.js`, `/images/planetary-mastery-items/Battery.png`, and `/css/app.css` return the physical static files directly with correct MIME types.
|
||||||
|
- **Query Parameter Preservation:** Direct URL with queries (e.g. `/galaxy?filter=core`) retains the query parameters after URL reconstruction.
|
||||||
|
|
||||||
|
### Test Changes
|
||||||
|
- Execute `dotnet build ZeroSpace.sln` to ensure all projects build cleanly.
|
||||||
|
- Execute `dotnet publish ZeroSpace.Web/ZeroSpace.Web.csproj -c Release` and inspect the output directory to confirm `staticwebapp.config.json`, `404.html`, and `index.html` are copied to `publish/wwwroot`.
|
||||||
|
|
||||||
|
# Delivery Steps
|
||||||
|
|
||||||
|
### Step 1: Configure Azure Static Web Apps navigation fallback
|
||||||
|
Azure Static Web Apps routes direct SPA page requests to `/index.html` while preserving direct access to static assets.
|
||||||
|
|
||||||
|
- Create `ZeroSpace.Web/wwwroot/staticwebapp.config.json` with `navigationFallback.rewrite` set to `/index.html`.
|
||||||
|
- Add route exclusions for static assets (`/images/*`, `/css/*`, `/js/*`, `/lib/*`, `/_framework/*`, and common file extensions).
|
||||||
|
- Define MIME type overrides for `.wasm` and `.json` to ensure optimal loading performance.
|
||||||
|
|
||||||
|
### Step 2: Implement universal 404 SPA redirect and URL reconstruction
|
||||||
|
Static hosts without native rewrite capabilities redirect deep link 404s back to `index.html` where the original URL path is reconstructed seamlessly.
|
||||||
|
|
||||||
|
- Add `ZeroSpace.Web/wwwroot/404.html` containing the lightweight SPA redirect script that converts deep URL paths into query parameters.
|
||||||
|
- Update `ZeroSpace.Web/wwwroot/index.html` `<head>` section with the matching URL restoration script using `history.replaceState` before the Blazor WebAssembly runtime initializes.
|
||||||
|
- Ensure query strings and route hash fragments are preserved intact during URL reconstruction.
|
||||||
|
|
||||||
|
### Step 3: Validate publish output and build verification
|
||||||
|
The project builds and publishes cleanly with all fallback and redirect assets bundled in the output `wwwroot`.
|
||||||
|
|
||||||
|
- Run `dotnet build ZeroSpace.sln` to confirm compilation integrity.
|
||||||
|
- Run `dotnet publish ZeroSpace.Web/ZeroSpace.Web.csproj -c Release` to verify that `staticwebapp.config.json`, `404.html`, and `index.html` are correctly packaged into the published output directory.
|
||||||
|
- Verify that `ZeroSpace.Deploy/Program.cs` can deploy the published `wwwroot` artifact containing the fallback configurations.
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.5.4" />
|
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.6.0" />
|
||||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.5.4" />
|
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.6.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -69,6 +69,132 @@ if (skipped.Count > 0)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var starSystems = new List<StarSystemSource>();
|
||||||
|
var systemMasteries = new List<SystemMasterySource>();
|
||||||
|
var planetaryMasteryItems = new List<PlanetaryMasteryItemSource>();
|
||||||
|
var generalItems = new List<GeneralItemSource>();
|
||||||
|
var factionPowerItems = new List<FactionPowerItemSource>();
|
||||||
|
var skippedDocs = new List<string>();
|
||||||
|
var usedIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var path in Directory.EnumerateFiles(docsRoot, "*.md", SearchOption.AllDirectories).Order(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var (frontMatter, body) = ReadDocument(path);
|
||||||
|
if (frontMatter is null || !frontMatter.TryGetValue("category", out var categoryValue))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(categoryValue.Scalar, "StarSystem", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (TryReadStarSystem(path, frontMatter, body, usedIds, out var system, out var reason))
|
||||||
|
{
|
||||||
|
starSystems.Add(system);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skippedDocs.Add($"{Path.GetFileName(path)} ({reason})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (string.Equals(categoryValue.Scalar, "SystemMastery", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (TryReadSystemMastery(path, frontMatter, body, usedIds, out var mastery, out var reason))
|
||||||
|
{
|
||||||
|
systemMasteries.Add(mastery);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skippedDocs.Add($"{Path.GetFileName(path)} ({reason})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (string.Equals(categoryValue.Scalar, "PlanetaryMasteryItem", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (TryReadPlanetaryMasteryItem(path, frontMatter, body, usedIds, out var item, out var reason))
|
||||||
|
{
|
||||||
|
planetaryMasteryItems.Add(item);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skippedDocs.Add($"{Path.GetFileName(path)} ({reason})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (string.Equals(categoryValue.Scalar, "GeneralItem", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (TryReadGeneralItem(path, frontMatter, body, usedIds, out var generalItem, out var reason))
|
||||||
|
{
|
||||||
|
generalItems.Add(generalItem);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skippedDocs.Add($"{Path.GetFileName(path)} ({reason})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (string.Equals(categoryValue.Scalar, "FactionPowerItem", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (TryReadFactionPowerItem(path, frontMatter, body, usedIds, out var factionPowerItem, out var reason))
|
||||||
|
{
|
||||||
|
factionPowerItems.Add(factionPowerItem);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skippedDocs.Add($"{Path.GetFileName(path)} ({reason})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
starSystems.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
systemMasteries.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
planetaryMasteryItems.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
generalItems.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
factionPowerItems.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var starSystemsPath = Path.Combine(modelRoot, "StarSystems.g.cs");
|
||||||
|
var systemMasteriesPath = Path.Combine(modelRoot, "SystemMasteries.g.cs");
|
||||||
|
var planetaryMasteryItemsPath = Path.Combine(modelRoot, "PlanetaryMasteryItems.g.cs");
|
||||||
|
var generalItemsPath = Path.Combine(modelRoot, "GeneralItems.g.cs");
|
||||||
|
var factionPowerItemsPath = Path.Combine(modelRoot, "FactionPowerItems.g.cs");
|
||||||
|
|
||||||
|
File.WriteAllText(starSystemsPath, GenerateStarSystems(starSystems), Encoding.UTF8);
|
||||||
|
File.WriteAllText(systemMasteriesPath, GenerateSystemMasteries(systemMasteries), Encoding.UTF8);
|
||||||
|
File.WriteAllText(planetaryMasteryItemsPath, GeneratePlanetaryMasteryItems(planetaryMasteryItems), Encoding.UTF8);
|
||||||
|
File.WriteAllText(generalItemsPath, GenerateGeneralItems(generalItems), Encoding.UTF8);
|
||||||
|
File.WriteAllText(factionPowerItemsPath, GenerateFactionPowerItems(factionPowerItems), Encoding.UTF8);
|
||||||
|
|
||||||
|
Console.WriteLine($"Generated {starSystems.Count} star systems: {Path.GetRelativePath(solutionRoot, starSystemsPath)}");
|
||||||
|
Console.WriteLine($"Generated {systemMasteries.Count} system masteries: {Path.GetRelativePath(solutionRoot, systemMasteriesPath)}");
|
||||||
|
Console.WriteLine($"Generated {planetaryMasteryItems.Count} planetary mastery items: {Path.GetRelativePath(solutionRoot, planetaryMasteryItemsPath)}");
|
||||||
|
Console.WriteLine($"Generated {generalItems.Count} general items: {Path.GetRelativePath(solutionRoot, generalItemsPath)}");
|
||||||
|
Console.WriteLine($"Generated {factionPowerItems.Count} faction power items: {Path.GetRelativePath(solutionRoot, factionPowerItemsPath)}");
|
||||||
|
|
||||||
|
// Copy item icons next to the markdown docs into the web app's wwwroot so the
|
||||||
|
// pages can reference them. Images are copied, never moved: the docs folder
|
||||||
|
// stays the source of truth.
|
||||||
|
var webImagesRoot = Path.Combine(solutionRoot, "ZeroSpace.Web", "wwwroot", "images");
|
||||||
|
var copiedPlanetaryImages = SyncItemImages(
|
||||||
|
planetaryMasteryItems.Select(static item => (item.ImageFileName, item.ImageSourcePath)),
|
||||||
|
Path.Combine(webImagesRoot, "planetary-mastery-items"),
|
||||||
|
solutionRoot);
|
||||||
|
var copiedGeneralImages = SyncItemImages(
|
||||||
|
generalItems.Select(static item => (item.ImageFileName, item.ImageSourcePath)),
|
||||||
|
Path.Combine(webImagesRoot, "general-items"),
|
||||||
|
solutionRoot);
|
||||||
|
var copiedFactionPowerImages = SyncItemImages(
|
||||||
|
factionPowerItems.Select(static item => (item.ImageFileName, item.ImageSourcePath)),
|
||||||
|
Path.Combine(webImagesRoot, "faction-power-items"),
|
||||||
|
solutionRoot);
|
||||||
|
|
||||||
|
Console.WriteLine($"Copied {copiedPlanetaryImages} planetary mastery item images to {Path.GetRelativePath(solutionRoot, Path.Combine(webImagesRoot, "planetary-mastery-items"))}");
|
||||||
|
Console.WriteLine($"Copied {copiedGeneralImages} general item images to {Path.GetRelativePath(solutionRoot, Path.Combine(webImagesRoot, "general-items"))}");
|
||||||
|
Console.WriteLine($"Copied {copiedFactionPowerImages} faction power item images to {Path.GetRelativePath(solutionRoot, Path.Combine(webImagesRoot, "faction-power-items"))}");
|
||||||
|
if (skippedDocs.Count > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Skipped {skippedDocs.Count} incomplete docs:");
|
||||||
|
foreach (var entry in skippedDocs.Order(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"- {entry}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static string FindSolutionRoot()
|
static string FindSolutionRoot()
|
||||||
{
|
{
|
||||||
var candidates = new[] { Environment.CurrentDirectory, AppContext.BaseDirectory };
|
var candidates = new[] { Environment.CurrentDirectory, AppContext.BaseDirectory };
|
||||||
@@ -368,9 +494,650 @@ static string GenerateUnits(IReadOnlyList<UnitSource> units)
|
|||||||
|
|
||||||
static string Literal(string? value)
|
static string Literal(string? value)
|
||||||
{
|
{
|
||||||
return value is null
|
if (value is null)
|
||||||
? "null"
|
{
|
||||||
: "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
return "null";
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder("\"");
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
builder.Append(character switch
|
||||||
|
{
|
||||||
|
'\\' => "\\\\",
|
||||||
|
'"' => "\\\"",
|
||||||
|
'\n' => "\\n",
|
||||||
|
'\r' => "\\r",
|
||||||
|
'\t' => "\\t",
|
||||||
|
_ => character.ToString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('"');
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static (Dictionary<string, FrontMatterValue>? FrontMatter, string Body) ReadDocument(string path)
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(path);
|
||||||
|
if (lines.Length == 0 || lines[0].Trim() != "---")
|
||||||
|
{
|
||||||
|
return (null, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = new Dictionary<string, FrontMatterValue>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string? listKey = null;
|
||||||
|
|
||||||
|
for (var i = 1; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = lines[i];
|
||||||
|
if (line.Trim() == "---")
|
||||||
|
{
|
||||||
|
var body = string.Join('\n', lines, i + 1, lines.Length - i - 1).Trim();
|
||||||
|
return (values, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listKey is not null && line.StartsWith(" - ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
values[listKey].List.Add(CleanValue(line[4..]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
listKey = null;
|
||||||
|
var separatorIndex = line.IndexOf(':');
|
||||||
|
if (separatorIndex < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = line[..separatorIndex].Trim();
|
||||||
|
var rawValue = line[(separatorIndex + 1)..].Trim();
|
||||||
|
if (rawValue.Length == 0)
|
||||||
|
{
|
||||||
|
values[key] = new FrontMatterValue(null);
|
||||||
|
listKey = key;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
values[key] = new FrontMatterValue(CleanValue(rawValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsTrue(IReadOnlyDictionary<string, FrontMatterValue> frontMatter, string key)
|
||||||
|
{
|
||||||
|
return frontMatter.TryGetValue(key, out var value)
|
||||||
|
&& string.Equals(value.Scalar, "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ToUniqueIdentifier(string name, HashSet<string> usedIds)
|
||||||
|
{
|
||||||
|
var id = ToIdentifier(name);
|
||||||
|
if (usedIds.Add(id))
|
||||||
|
{
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix = 2;
|
||||||
|
while (!usedIds.Add($"{id}{suffix}"))
|
||||||
|
{
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{id}{suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool TryReadStarSystem(
|
||||||
|
string path,
|
||||||
|
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
|
||||||
|
string body,
|
||||||
|
HashSet<string> usedIds,
|
||||||
|
out StarSystemSource system,
|
||||||
|
out string reason)
|
||||||
|
{
|
||||||
|
system = default!;
|
||||||
|
|
||||||
|
string category;
|
||||||
|
if (frontMatter.TryGetValue("systemCategory", out var categoryVal) && !string.IsNullOrWhiteSpace(categoryVal.Scalar))
|
||||||
|
{
|
||||||
|
var catStr = categoryVal.Scalar.Trim();
|
||||||
|
if (catStr.Equals("Homeworld System", StringComparison.OrdinalIgnoreCase) || catStr.Equals("Homeworld", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
category = "HomeworldSystem";
|
||||||
|
}
|
||||||
|
else if (catStr.Equals("Core System", StringComparison.OrdinalIgnoreCase) || catStr.Equals("Core", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
category = "CoreSystem";
|
||||||
|
}
|
||||||
|
else if (catStr.Equals("Side System", StringComparison.OrdinalIgnoreCase) || catStr.Equals("Side", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
category = "SideSystem";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reason = $"unknown systemCategory '{catStr}'";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var kindCount = 0;
|
||||||
|
category = string.Empty;
|
||||||
|
if (IsTrue(frontMatter, "isSideSystem"))
|
||||||
|
{
|
||||||
|
category = "SideSystem";
|
||||||
|
kindCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTrue(frontMatter, "isHomeworldSystem"))
|
||||||
|
{
|
||||||
|
category = "HomeworldSystem";
|
||||||
|
kindCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTrue(frontMatter, "isCoreSystem"))
|
||||||
|
{
|
||||||
|
category = "CoreSystem";
|
||||||
|
kindCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kindCount != 1)
|
||||||
|
{
|
||||||
|
reason = "missing or invalid systemCategory";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var owner = TryGetOptionalString(frontMatter, "owner") ?? "The Eden Accord";
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var connectedSystems = frontMatter.TryGetValue("connectedSystems", out var connectedValue)
|
||||||
|
? connectedValue.List.Where(static value => !string.IsNullOrWhiteSpace(value)).ToArray()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
system = new StarSystemSource(
|
||||||
|
ToUniqueIdentifier(name, usedIds),
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
owner,
|
||||||
|
IsTrue(frontMatter, "higherInfluenceYield"),
|
||||||
|
IsTrue(frontMatter, "higherCreditYield"),
|
||||||
|
IsTrue(frontMatter, "higherEnergyYield"),
|
||||||
|
IsTrue(frontMatter, "higherChanceForItems"),
|
||||||
|
TryGetOptionalString(frontMatter, "occupationBuff"),
|
||||||
|
TryGetOptionalString(frontMatter, "systemMastery"),
|
||||||
|
connectedSystems,
|
||||||
|
GetDouble(frontMatter, "x"),
|
||||||
|
GetDouble(frontMatter, "y"),
|
||||||
|
body);
|
||||||
|
reason = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool TryReadSystemMastery(
|
||||||
|
string path,
|
||||||
|
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
|
||||||
|
string body,
|
||||||
|
HashSet<string> usedIds,
|
||||||
|
out SystemMasterySource mastery,
|
||||||
|
out string reason)
|
||||||
|
{
|
||||||
|
mastery = default!;
|
||||||
|
|
||||||
|
var levels = new string[5];
|
||||||
|
for (var level = 1; level <= 5; level++)
|
||||||
|
{
|
||||||
|
if (!TryGetRequiredString(frontMatter, $"level_{level}", out var text, out reason))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
levels[level - 1] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableSystems = frontMatter.TryGetValue("availableSystems", out var availableValue)
|
||||||
|
? availableValue.List.Where(static value => !string.IsNullOrWhiteSpace(value)).ToArray()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
var stats = new List<(string Key, double Value)>();
|
||||||
|
foreach (var (key, value) in frontMatter)
|
||||||
|
{
|
||||||
|
if (IsMasteryField(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(value.Scalar) ||
|
||||||
|
!double.TryParse(value.Scalar, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var number))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Warning: {Path.GetFileName(path)} has non-numeric stat '{key}', skipping it.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Add((key, number));
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
mastery = new SystemMasterySource(
|
||||||
|
ToUniqueIdentifier(name, usedIds),
|
||||||
|
name,
|
||||||
|
levels,
|
||||||
|
availableSystems,
|
||||||
|
stats,
|
||||||
|
body);
|
||||||
|
reason = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool TryReadPlanetaryMasteryItem(
|
||||||
|
string path,
|
||||||
|
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
|
||||||
|
string body,
|
||||||
|
HashSet<string> usedIds,
|
||||||
|
out PlanetaryMasteryItemSource item,
|
||||||
|
out string reason)
|
||||||
|
{
|
||||||
|
item = default!;
|
||||||
|
|
||||||
|
var levels = new string[5];
|
||||||
|
for (var level = 1; level <= 5; level++)
|
||||||
|
{
|
||||||
|
if (!TryGetRequiredString(frontMatter, $"level_{level}", out var text, out reason))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
levels[level - 1] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
|
||||||
|
// An icon with the same file name as the doc (e.g. "Battery.png" next to
|
||||||
|
// "Battery.md") is treated as this item's image when present.
|
||||||
|
string? imageFileName = null;
|
||||||
|
string? imageSourcePath = null;
|
||||||
|
var candidateImagePath = Path.ChangeExtension(path, ".png");
|
||||||
|
if (File.Exists(candidateImagePath))
|
||||||
|
{
|
||||||
|
imageFileName = Path.GetFileName(candidateImagePath);
|
||||||
|
imageSourcePath = candidateImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
item = new PlanetaryMasteryItemSource(
|
||||||
|
ToUniqueIdentifier(name, usedIds),
|
||||||
|
name,
|
||||||
|
levels,
|
||||||
|
imageFileName,
|
||||||
|
imageSourcePath,
|
||||||
|
body);
|
||||||
|
reason = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool TryReadGeneralItem(
|
||||||
|
string path,
|
||||||
|
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
|
||||||
|
string body,
|
||||||
|
HashSet<string> usedIds,
|
||||||
|
out GeneralItemSource item,
|
||||||
|
out string reason)
|
||||||
|
{
|
||||||
|
item = default!;
|
||||||
|
|
||||||
|
// General items have a variable number of levels (e.g. Renewal Matrix has
|
||||||
|
// two); collect whichever level_1..level_5 entries are present.
|
||||||
|
var levels = ReadOptionalLevels(frontMatter);
|
||||||
|
if (levels.Count == 0)
|
||||||
|
{
|
||||||
|
reason = "missing level_1";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var (imageFileName, imageSourcePath) = FindItemImage(path);
|
||||||
|
|
||||||
|
item = new GeneralItemSource(
|
||||||
|
ToUniqueIdentifier(name, usedIds),
|
||||||
|
name,
|
||||||
|
levels,
|
||||||
|
TryGetOptionalString(frontMatter, "limitedToMerc"),
|
||||||
|
imageFileName,
|
||||||
|
imageSourcePath,
|
||||||
|
body);
|
||||||
|
reason = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool TryReadFactionPowerItem(
|
||||||
|
string path,
|
||||||
|
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
|
||||||
|
string body,
|
||||||
|
HashSet<string> usedIds,
|
||||||
|
out FactionPowerItemSource item,
|
||||||
|
out string reason)
|
||||||
|
{
|
||||||
|
item = default!;
|
||||||
|
|
||||||
|
var levels = ReadOptionalLevels(frontMatter);
|
||||||
|
if (levels.Count == 0)
|
||||||
|
{
|
||||||
|
reason = "missing level_1";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var (imageFileName, imageSourcePath) = FindItemImage(path);
|
||||||
|
|
||||||
|
item = new FactionPowerItemSource(
|
||||||
|
ToUniqueIdentifier(name, usedIds),
|
||||||
|
name,
|
||||||
|
levels,
|
||||||
|
TryGetOptionalString(frontMatter, "limitedTo"),
|
||||||
|
TryGetOptionalString(frontMatter, "limitedToMerc"),
|
||||||
|
TryGetOptionalInt(frontMatter, "minimumLevel"),
|
||||||
|
imageFileName,
|
||||||
|
imageSourcePath,
|
||||||
|
body);
|
||||||
|
reason = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static IReadOnlyList<string> ReadOptionalLevels(IReadOnlyDictionary<string, FrontMatterValue> frontMatter)
|
||||||
|
{
|
||||||
|
var levels = new List<string>();
|
||||||
|
for (var level = 1; level <= 5; level++)
|
||||||
|
{
|
||||||
|
var text = TryGetOptionalString(frontMatter, $"level_{level}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
levels.Add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
static (string? FileName, string? SourcePath) FindItemImage(string path)
|
||||||
|
{
|
||||||
|
// An icon with the same file name as the doc (e.g. "Battery.png" next to
|
||||||
|
// "Battery.md") is treated as this item's image when present.
|
||||||
|
var candidateImagePath = Path.ChangeExtension(path, ".png");
|
||||||
|
if (File.Exists(candidateImagePath))
|
||||||
|
{
|
||||||
|
return (Path.GetFileName(candidateImagePath), candidateImagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsMasteryField(string key)
|
||||||
|
{
|
||||||
|
if (string.Equals(key, "category", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(key, "availableSystems", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.Length == 7 &&
|
||||||
|
key.StartsWith("level_", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
key[6] is >= '1' and <= '5';
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GenerateStarSystems(IReadOnlyList<StarSystemSource> systems)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("// <auto-generated />");
|
||||||
|
builder.AppendLine("namespace ZeroSpace.Model;");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("public static class StarSystems");
|
||||||
|
builder.AppendLine("{");
|
||||||
|
|
||||||
|
foreach (var system in systems)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" public static readonly StarSystemData {system.Id} = new(");
|
||||||
|
builder.AppendLine($" Id: {Literal(system.Id)},");
|
||||||
|
builder.AppendLine($" Name: {Literal(system.Name)},");
|
||||||
|
builder.AppendLine($" SystemCategory: StarSystemCategory.{system.SystemCategory},");
|
||||||
|
builder.AppendLine($" Owner: {Literal(system.Owner)},");
|
||||||
|
builder.AppendLine($" HigherInfluenceYield: {BoolLiteral(system.HigherInfluenceYield)},");
|
||||||
|
builder.AppendLine($" HigherCreditYield: {BoolLiteral(system.HigherCreditYield)},");
|
||||||
|
builder.AppendLine($" HigherEnergyYield: {BoolLiteral(system.HigherEnergyYield)},");
|
||||||
|
builder.AppendLine($" HigherChanceForItems: {BoolLiteral(system.HigherChanceForItems)},");
|
||||||
|
builder.AppendLine($" OccupationBuff: {Literal(system.OccupationBuff)},");
|
||||||
|
builder.AppendLine($" SystemMastery: {Literal(system.SystemMastery)},");
|
||||||
|
builder.AppendLine($" ConnectedSystems: [{string.Join(", ", system.ConnectedSystems.Select(Literal))}],");
|
||||||
|
builder.AppendLine($" X: {system.X.ToString("R", CultureInfo.InvariantCulture)},");
|
||||||
|
builder.AppendLine($" Y: {system.Y.ToString("R", CultureInfo.InvariantCulture)},");
|
||||||
|
builder.AppendLine($" Body: {Literal(system.Body)});");
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" public static IReadOnlyList<StarSystemData> All { get; } =");
|
||||||
|
builder.AppendLine(" [");
|
||||||
|
|
||||||
|
foreach (var system in systems)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" {system.Id},");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" ];");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" public static IReadOnlyDictionary<string, StarSystemData> ByName { get; } =");
|
||||||
|
builder.AppendLine(" All.ToDictionary(static system => system.Name, StringComparer.OrdinalIgnoreCase);");
|
||||||
|
builder.AppendLine("}");
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GenerateSystemMasteries(IReadOnlyList<SystemMasterySource> masteries)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("// <auto-generated />");
|
||||||
|
builder.AppendLine("namespace ZeroSpace.Model;");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("public static class SystemMasteries");
|
||||||
|
builder.AppendLine("{");
|
||||||
|
|
||||||
|
foreach (var mastery in masteries)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" public static readonly SystemMasteryData {mastery.Id} = new(");
|
||||||
|
builder.AppendLine($" Id: {Literal(mastery.Id)},");
|
||||||
|
builder.AppendLine($" Name: {Literal(mastery.Name)},");
|
||||||
|
builder.AppendLine($" Levels: [{string.Join(", ", mastery.Levels.Select(Literal))}],");
|
||||||
|
builder.AppendLine($" AvailableSystems: [{string.Join(", ", mastery.AvailableSystems.Select(Literal))}],");
|
||||||
|
builder.AppendLine(" Stats: new Dictionary<string, double>");
|
||||||
|
builder.AppendLine(" {");
|
||||||
|
|
||||||
|
foreach (var (key, value) in mastery.Stats)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" [{Literal(key)}] = {value.ToString("R", CultureInfo.InvariantCulture)},");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" },");
|
||||||
|
builder.AppendLine($" Body: {Literal(mastery.Body)});");
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" public static IReadOnlyList<SystemMasteryData> All { get; } =");
|
||||||
|
builder.AppendLine(" [");
|
||||||
|
|
||||||
|
foreach (var mastery in masteries)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" {mastery.Id},");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" ];");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" public static IReadOnlyDictionary<string, SystemMasteryData> ByName { get; } =");
|
||||||
|
builder.AppendLine(" All.ToDictionary(static mastery => mastery.Name, StringComparer.OrdinalIgnoreCase);");
|
||||||
|
builder.AppendLine("}");
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GeneratePlanetaryMasteryItems(IReadOnlyList<PlanetaryMasteryItemSource> items)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("// <auto-generated />");
|
||||||
|
builder.AppendLine("namespace ZeroSpace.Model;");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("public static class PlanetaryMasteryItems");
|
||||||
|
builder.AppendLine("{");
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" public static readonly PlanetaryMasteryItemData {item.Id} = new(");
|
||||||
|
builder.AppendLine($" Id: {Literal(item.Id)},");
|
||||||
|
builder.AppendLine($" Name: {Literal(item.Name)},");
|
||||||
|
builder.AppendLine($" Levels: [{string.Join(", ", item.Levels.Select(Literal))}],");
|
||||||
|
builder.AppendLine($" ImageFileName: {Literal(item.ImageFileName)},");
|
||||||
|
builder.AppendLine($" Body: {Literal(item.Body)});");
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" public static IReadOnlyList<PlanetaryMasteryItemData> All { get; } =");
|
||||||
|
builder.AppendLine(" [");
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" {item.Id},");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" ];");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" public static IReadOnlyDictionary<string, PlanetaryMasteryItemData> ByName { get; } =");
|
||||||
|
builder.AppendLine(" All.ToDictionary(static item => item.Name, StringComparer.OrdinalIgnoreCase);");
|
||||||
|
builder.AppendLine("}");
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GenerateGeneralItems(IReadOnlyList<GeneralItemSource> items)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("// <auto-generated />");
|
||||||
|
builder.AppendLine("namespace ZeroSpace.Model;");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("public static class GeneralItems");
|
||||||
|
builder.AppendLine("{");
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" public static readonly GeneralItemData {item.Id} = new(");
|
||||||
|
builder.AppendLine($" Id: {Literal(item.Id)},");
|
||||||
|
builder.AppendLine($" Name: {Literal(item.Name)},");
|
||||||
|
builder.AppendLine($" Levels: [{string.Join(", ", item.Levels.Select(Literal))}],");
|
||||||
|
builder.AppendLine($" LimitedToMerc: {Literal(item.LimitedToMerc)},");
|
||||||
|
builder.AppendLine($" ImageFileName: {Literal(item.ImageFileName)},");
|
||||||
|
builder.AppendLine($" Body: {Literal(item.Body)});");
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" public static IReadOnlyList<GeneralItemData> All { get; } =");
|
||||||
|
builder.AppendLine(" [");
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" {item.Id},");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" ];");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" public static IReadOnlyDictionary<string, GeneralItemData> ByName { get; } =");
|
||||||
|
builder.AppendLine(" All.ToDictionary(static item => item.Name, StringComparer.OrdinalIgnoreCase);");
|
||||||
|
builder.AppendLine("}");
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GenerateFactionPowerItems(IReadOnlyList<FactionPowerItemSource> items)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("// <auto-generated />");
|
||||||
|
builder.AppendLine("namespace ZeroSpace.Model;");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("public static class FactionPowerItems");
|
||||||
|
builder.AppendLine("{");
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" public static readonly FactionPowerItemData {item.Id} = new(");
|
||||||
|
builder.AppendLine($" Id: {Literal(item.Id)},");
|
||||||
|
builder.AppendLine($" Name: {Literal(item.Name)},");
|
||||||
|
builder.AppendLine($" Levels: [{string.Join(", ", item.Levels.Select(Literal))}],");
|
||||||
|
builder.AppendLine($" LimitedTo: {Literal(item.LimitedTo)},");
|
||||||
|
builder.AppendLine($" LimitedToMerc: {Literal(item.LimitedToMerc)},");
|
||||||
|
builder.AppendLine($" MinimumLevel: {item.MinimumLevel?.ToString(CultureInfo.InvariantCulture) ?? "null"},");
|
||||||
|
builder.AppendLine($" ImageFileName: {Literal(item.ImageFileName)},");
|
||||||
|
builder.AppendLine($" Body: {Literal(item.Body)});");
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" public static IReadOnlyList<FactionPowerItemData> All { get; } =");
|
||||||
|
builder.AppendLine(" [");
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" {item.Id},");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" ];");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" public static IReadOnlyDictionary<string, FactionPowerItemData> ByName { get; } =");
|
||||||
|
builder.AppendLine(" All.ToDictionary(static item => item.Name, StringComparer.OrdinalIgnoreCase);");
|
||||||
|
builder.AppendLine("}");
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string BoolLiteral(bool value)
|
||||||
|
{
|
||||||
|
return value ? "true" : "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
static int SyncItemImages(
|
||||||
|
IEnumerable<(string? FileName, string? SourcePath)> items,
|
||||||
|
string imagesDir,
|
||||||
|
string solutionRoot)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(imagesDir);
|
||||||
|
|
||||||
|
var expectedImages = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var copiedImages = 0;
|
||||||
|
foreach (var (fileName, sourcePath) in items)
|
||||||
|
{
|
||||||
|
if (fileName is null || sourcePath is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedImages.Add(fileName);
|
||||||
|
File.Copy(sourcePath, Path.Combine(imagesDir, fileName), overwrite: true);
|
||||||
|
copiedImages++;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var stale in Directory.EnumerateFiles(imagesDir, "*.png"))
|
||||||
|
{
|
||||||
|
if (!expectedImages.Contains(Path.GetFileName(stale)))
|
||||||
|
{
|
||||||
|
File.Delete(stale);
|
||||||
|
Console.WriteLine($"Removed stale item image: {Path.GetRelativePath(solutionRoot, stale)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return copiedImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double GetDouble(IReadOnlyDictionary<string, FrontMatterValue> frontMatter, string key)
|
||||||
|
{
|
||||||
|
if (frontMatter.TryGetValue(key, out var value) &&
|
||||||
|
double.TryParse(value.Scalar, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var number))
|
||||||
|
{
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class FrontMatterValue(string? scalar)
|
internal sealed class FrontMatterValue(string? scalar)
|
||||||
@@ -400,3 +1167,55 @@ internal sealed record UnitSource(
|
|||||||
string? Hotkey,
|
string? Hotkey,
|
||||||
int BuildAtSameTime,
|
int BuildAtSameTime,
|
||||||
int? Limit);
|
int? Limit);
|
||||||
|
|
||||||
|
internal sealed record StarSystemSource(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string SystemCategory,
|
||||||
|
string Owner,
|
||||||
|
bool HigherInfluenceYield,
|
||||||
|
bool HigherCreditYield,
|
||||||
|
bool HigherEnergyYield,
|
||||||
|
bool HigherChanceForItems,
|
||||||
|
string? OccupationBuff,
|
||||||
|
string? SystemMastery,
|
||||||
|
IReadOnlyList<string> ConnectedSystems,
|
||||||
|
double X,
|
||||||
|
double Y,
|
||||||
|
string Body);
|
||||||
|
|
||||||
|
internal sealed record SystemMasterySource(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
IReadOnlyList<string> AvailableSystems,
|
||||||
|
IReadOnlyList<(string Key, double Value)> Stats,
|
||||||
|
string Body);
|
||||||
|
|
||||||
|
internal sealed record PlanetaryMasteryItemSource(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
string? ImageFileName,
|
||||||
|
string? ImageSourcePath,
|
||||||
|
string Body);
|
||||||
|
|
||||||
|
internal sealed record GeneralItemSource(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
string? LimitedToMerc,
|
||||||
|
string? ImageFileName,
|
||||||
|
string? ImageSourcePath,
|
||||||
|
string Body);
|
||||||
|
|
||||||
|
internal sealed record FactionPowerItemSource(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
string? LimitedTo,
|
||||||
|
string? LimitedToMerc,
|
||||||
|
int? MinimumLevel,
|
||||||
|
string? ImageFileName,
|
||||||
|
string? ImageSourcePath,
|
||||||
|
string Body);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
|
x:Class="ZeroSpace.MapEditor.App">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using ZeroSpace.MapEditor.Views;
|
||||||
|
|
||||||
|
namespace ZeroSpace.MapEditor;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public App()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Window CreateWindow(IActivationState? activationState)
|
||||||
|
{
|
||||||
|
return new Window(new MapEditorPage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ZeroSpace.MapEditor;
|
||||||
|
|
||||||
|
public static class MauiProgram
|
||||||
|
{
|
||||||
|
public static MauiApp CreateMauiApp()
|
||||||
|
{
|
||||||
|
var builder = MauiApp.CreateBuilder();
|
||||||
|
builder.UseMauiApp<App>();
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ZeroSpace.Model;
|
||||||
|
|
||||||
|
namespace ZeroSpace.MapEditor.Models;
|
||||||
|
|
||||||
|
public enum FactionMode
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
EdenAccord, // Hotkey A - #10b981
|
||||||
|
KeeperCouncil, // Hotkey S - #38bdf8
|
||||||
|
ObsidianOrder // Hotkey D - #ef4444
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class FactionModeExtensions
|
||||||
|
{
|
||||||
|
public static string ToOwnerName(this FactionMode mode) => mode switch
|
||||||
|
{
|
||||||
|
FactionMode.EdenAccord => StarSystemOwners.EdenAccord,
|
||||||
|
FactionMode.KeeperCouncil => StarSystemOwners.KeeperCouncil,
|
||||||
|
FactionMode.ObsidianOrder => StarSystemOwners.ObsidianOrder,
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string ToDisplayName(this FactionMode mode) => mode switch
|
||||||
|
{
|
||||||
|
FactionMode.EdenAccord => "Eden Accord",
|
||||||
|
FactionMode.KeeperCouncil => "Keeper Council",
|
||||||
|
FactionMode.ObsidianOrder => "Obsidian Order",
|
||||||
|
_ => "None"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string GetColorHex(this FactionMode mode) => mode switch
|
||||||
|
{
|
||||||
|
FactionMode.EdenAccord => StarSystemOwners.GetColor(StarSystemOwners.EdenAccord),
|
||||||
|
FactionMode.KeeperCouncil => StarSystemOwners.GetColor(StarSystemOwners.KeeperCouncil),
|
||||||
|
FactionMode.ObsidianOrder => StarSystemOwners.GetColor(StarSystemOwners.ObsidianOrder),
|
||||||
|
_ => "#94a3b8"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static FactionMode FromOwnerName(string? owner) => owner switch
|
||||||
|
{
|
||||||
|
StarSystemOwners.EdenAccord => FactionMode.EdenAccord,
|
||||||
|
StarSystemOwners.KeeperCouncil => FactionMode.KeeperCouncil,
|
||||||
|
StarSystemOwners.ObsidianOrder => FactionMode.ObsidianOrder,
|
||||||
|
_ => FactionMode.None
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ZeroSpace.MapEditor.Services;
|
||||||
|
|
||||||
|
namespace ZeroSpace.MapEditor.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A star system node on the map canvas: the document plus selection state.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MapNode
|
||||||
|
{
|
||||||
|
public MapNode(StarSystemDoc doc)
|
||||||
|
{
|
||||||
|
Doc = doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StarSystemDoc Doc { get; }
|
||||||
|
|
||||||
|
public double X
|
||||||
|
{
|
||||||
|
get => Doc.X;
|
||||||
|
set => Doc.X = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Y
|
||||||
|
{
|
||||||
|
get => Doc.Y;
|
||||||
|
set => Doc.Y = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsSelected { get; set; }
|
||||||
|
|
||||||
|
public bool IsHighlighted { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<maui:MauiWinUIApplication
|
||||||
|
x:Class="ZeroSpace.MapEditor.WinUI.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:maui="using:Microsoft.Maui"
|
||||||
|
xmlns:local="using:ZeroSpace.MapEditor.WinUI">
|
||||||
|
|
||||||
|
</maui:MauiWinUIApplication>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace ZeroSpace.MapEditor.WinUI;
|
||||||
|
|
||||||
|
public partial class App : MauiWinUIApplication
|
||||||
|
{
|
||||||
|
public App()
|
||||||
|
{
|
||||||
|
this.InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="ZeroSpace.MapEditor.WinUI.app"/>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,37 @@
|
|||||||
|
using IImage = Microsoft.Maui.Graphics.IImage;
|
||||||
|
|
||||||
|
namespace ZeroSpace.MapEditor.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the in-game galaxy map screenshot as the canvas background.
|
||||||
|
/// The PNG lives in Resources/Raw and is deployed as a MauiAsset.
|
||||||
|
/// </summary>
|
||||||
|
public static class MapBackground
|
||||||
|
{
|
||||||
|
private const string AssetName = "Resources\\Raw\\galaxymap.png";
|
||||||
|
|
||||||
|
public static async Task<IImage?> LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var packageStream = await FileSystem.OpenAppPackageFileAsync(AssetName);
|
||||||
|
|
||||||
|
// Copy into memory and leave the buffer undisposed: some platform
|
||||||
|
// image implementations read from the stream lazily.
|
||||||
|
var buffer = new MemoryStream();
|
||||||
|
await packageStream.CopyToAsync(buffer);
|
||||||
|
buffer.Position = 0;
|
||||||
|
#if WINDOWS || MACCATALYST
|
||||||
|
return Microsoft.Maui.Graphics.Platform.PlatformImage.FromStream(buffer);
|
||||||
|
#else
|
||||||
|
return null;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// No map image available (missing asset, unsupported platform):
|
||||||
|
// the editor works fine without the background.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace ZeroSpace.MapEditor.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One StarSystem markdown document: the editable map data plus the
|
||||||
|
/// read-only details shown in the inspector. Has no MAUI dependencies
|
||||||
|
/// so it can be tested outside the app.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StarSystemDoc
|
||||||
|
{
|
||||||
|
private string _owner = string.Empty;
|
||||||
|
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string SystemCategory { get; set; } = string.Empty;
|
||||||
|
public string Owner
|
||||||
|
{
|
||||||
|
get => _owner;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!string.Equals(_owner, value, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_owner = value;
|
||||||
|
IsDirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public bool HigherInfluenceYield { get; set; }
|
||||||
|
public bool HigherCreditYield { get; set; }
|
||||||
|
public bool HigherEnergyYield { get; set; }
|
||||||
|
public bool HigherChanceForItems { get; set; }
|
||||||
|
public string? OccupationBuff { get; set; }
|
||||||
|
public string? SystemMastery { get; set; }
|
||||||
|
public List<string> ConnectedSystems { get; } = new();
|
||||||
|
public double X { get; set; }
|
||||||
|
public double Y { get; set; }
|
||||||
|
public bool HasPosition { get; set; }
|
||||||
|
public bool IsDirty { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StarSystemDocStore
|
||||||
|
{
|
||||||
|
/// <summary>World coordinate space used in the markdown files.</summary>
|
||||||
|
public const double WorldSize = 1000;
|
||||||
|
|
||||||
|
public static List<StarSystemDoc> LoadFolder(string folder)
|
||||||
|
{
|
||||||
|
var docs = new List<StarSystemDoc>();
|
||||||
|
foreach (var path in Directory.GetFiles(folder, "*.md")
|
||||||
|
.OrderBy(static p => p, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var doc = LoadFile(path);
|
||||||
|
if (doc is not null)
|
||||||
|
{
|
||||||
|
docs.Add(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any file missing x/y gets a deterministic spread-out placeholder.
|
||||||
|
var missing = docs.Where(static d => !d.HasPosition).ToList();
|
||||||
|
for (var i = 0; i < missing.Count; i++)
|
||||||
|
{
|
||||||
|
var (x, y) = DefaultPosition(i, missing.Count);
|
||||||
|
missing[i].X = x;
|
||||||
|
missing[i].Y = y;
|
||||||
|
missing[i].IsDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StarSystemDoc? LoadFile(string path)
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(path);
|
||||||
|
if (lines.Length < 2 || lines[0].Trim() != "---")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var scalars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string? listKey = null;
|
||||||
|
var closed = false;
|
||||||
|
|
||||||
|
for (var i = 1; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = lines[i];
|
||||||
|
if (line.Trim() == "---")
|
||||||
|
{
|
||||||
|
closed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listKey is not null && line.StartsWith(" - ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
values[listKey].Add(CleanValue(line[4..]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
listKey = null;
|
||||||
|
var separator = line.IndexOf(':');
|
||||||
|
if (separator < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = line[..separator].Trim();
|
||||||
|
var raw = line[(separator + 1)..].Trim();
|
||||||
|
if (raw.Length == 0)
|
||||||
|
{
|
||||||
|
values[key] = new List<string>();
|
||||||
|
listKey = key;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
scalars[key] = CleanValue(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!closed)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scalars.TryGetValue("category", out var category) ||
|
||||||
|
!string.Equals(category, "StarSystem", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = new StarSystemDoc
|
||||||
|
{
|
||||||
|
FilePath = path,
|
||||||
|
Name = Path.GetFileNameWithoutExtension(path),
|
||||||
|
SystemCategory = scalars.TryGetValue("systemCategory", out var categoryVal) ? categoryVal : string.Empty,
|
||||||
|
Owner = scalars.TryGetValue("owner", out var ownerVal) ? ownerVal : "The Eden Accord",
|
||||||
|
OccupationBuff = scalars.TryGetValue("occupationBuff", out var buff) ? buff : null,
|
||||||
|
SystemMastery = scalars.TryGetValue("systemMastery", out var mastery) ? mastery : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(doc.SystemCategory))
|
||||||
|
{
|
||||||
|
if (IsTrue(scalars, "isSideSystem")) doc.SystemCategory = "Side System";
|
||||||
|
if (IsTrue(scalars, "isHomeworldSystem")) doc.SystemCategory = "Homeworld System";
|
||||||
|
if (IsTrue(scalars, "isCoreSystem")) doc.SystemCategory = "Core System";
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.HigherInfluenceYield = IsTrue(scalars, "higherInfluenceYield");
|
||||||
|
doc.HigherCreditYield = IsTrue(scalars, "higherCreditYield");
|
||||||
|
doc.HigherEnergyYield = IsTrue(scalars, "higherEnergyYield");
|
||||||
|
doc.HigherChanceForItems = IsTrue(scalars, "higherChanceForItems");
|
||||||
|
|
||||||
|
if (values.TryGetValue("connectedSystems", out var connected))
|
||||||
|
{
|
||||||
|
doc.ConnectedSystems.AddRange(connected.Where(static c => !string.IsNullOrWhiteSpace(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scalars.TryGetValue("x", out var xRaw) && TryParseDouble(xRaw, out var x) &&
|
||||||
|
scalars.TryGetValue("y", out var yRaw) && TryParseDouble(yRaw, out var y))
|
||||||
|
{
|
||||||
|
doc.X = x;
|
||||||
|
doc.Y = y;
|
||||||
|
doc.HasPosition = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.IsDirty = false;
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes x/y and owner back into each dirty file's frontmatter, preserving
|
||||||
|
/// every other line byte-for-byte.
|
||||||
|
/// </summary>
|
||||||
|
public static int SaveDocs(IEnumerable<StarSystemDoc> docs)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
var errors = new List<Exception>();
|
||||||
|
var items = docs.Where(static d => d.IsDirty)
|
||||||
|
.Select(static d => (Doc: d, TargetX: d.X, TargetY: d.Y, TargetOwner: d.Owner))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SaveDoc(item.Doc.FilePath, item.TargetX, item.TargetY, item.TargetOwner);
|
||||||
|
if (item.Doc.X == item.TargetX &&
|
||||||
|
item.Doc.Y == item.TargetY &&
|
||||||
|
string.Equals(item.Doc.Owner, item.TargetOwner, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
item.Doc.IsDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors.Add(new IOException($"Failed to save '{item.Doc.Name}': {ex.Message}", ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count == 1)
|
||||||
|
{
|
||||||
|
throw errors[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count > 1)
|
||||||
|
{
|
||||||
|
throw new AggregateException(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Backwards-compatible alias for <see cref="SaveDocs"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static int SavePositions(IEnumerable<StarSystemDoc> docs) => SaveDocs(docs);
|
||||||
|
|
||||||
|
public static void SaveDoc(StarSystemDoc doc)
|
||||||
|
{
|
||||||
|
SaveDoc(doc.FilePath, doc.X, doc.Y, doc.Owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SavePosition(StarSystemDoc doc) => SaveDoc(doc);
|
||||||
|
|
||||||
|
public static void SavePosition(string filePath, double x, double y)
|
||||||
|
{
|
||||||
|
SaveDoc(filePath, x, y, owner: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SaveDoc(string filePath, double x, double y, string? owner = null)
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(filePath);
|
||||||
|
var newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
|
||||||
|
var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None).ToList();
|
||||||
|
if (lines.Count < 2 || lines[0].Trim() != "---")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var closeIndex = lines.FindIndex(1, static line => line.Trim() == "---");
|
||||||
|
if (closeIndex < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var xLine = $"x: {x.ToString("F1", CultureInfo.InvariantCulture)}";
|
||||||
|
var yLine = $"y: {y.ToString("F1", CultureInfo.InvariantCulture)}";
|
||||||
|
var ownerLine = owner is not null ? $"owner: {owner}" : null;
|
||||||
|
var foundX = false;
|
||||||
|
var foundY = false;
|
||||||
|
var foundOwner = false;
|
||||||
|
|
||||||
|
for (var i = 1; i < closeIndex; i++)
|
||||||
|
{
|
||||||
|
var key = KeyOf(lines[i]);
|
||||||
|
if (string.Equals(key, "x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lines[i] = xLine;
|
||||||
|
foundX = true;
|
||||||
|
}
|
||||||
|
else if (string.Equals(key, "y", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lines[i] = yLine;
|
||||||
|
foundY = true;
|
||||||
|
}
|
||||||
|
else if (ownerLine is not null && string.Equals(key, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lines[i] = ownerLine;
|
||||||
|
foundOwner = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownerLine is not null && !foundOwner)
|
||||||
|
{
|
||||||
|
lines.Insert(closeIndex, ownerLine);
|
||||||
|
closeIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundX)
|
||||||
|
{
|
||||||
|
lines.Insert(closeIndex, xLine);
|
||||||
|
closeIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundY)
|
||||||
|
{
|
||||||
|
lines.Insert(closeIndex, yLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(filePath, string.Join(newline, lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic phyllotaxis (sunflower spiral) spread across the
|
||||||
|
/// world space, so placeholder nodes never cover each other.
|
||||||
|
/// </summary>
|
||||||
|
public static (double X, double Y) DefaultPosition(int index, int count)
|
||||||
|
{
|
||||||
|
var goldenAngle = Math.PI * (3 - Math.Sqrt(5));
|
||||||
|
var radius = (WorldSize / 2 - 30) * Math.Sqrt(index / (double)Math.Max(count, 1));
|
||||||
|
var theta = index * goldenAngle;
|
||||||
|
return (WorldSize / 2 + radius * Math.Cos(theta),
|
||||||
|
WorldSize / 2 + radius * Math.Sin(theta));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? KeyOf(string line)
|
||||||
|
{
|
||||||
|
var separator = line.IndexOf(':');
|
||||||
|
return separator < 0 ? null : line[..separator].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTrue(Dictionary<string, string> scalars, string key)
|
||||||
|
{
|
||||||
|
return scalars.TryGetValue(key, out var value) &&
|
||||||
|
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseDouble(string raw, out double value)
|
||||||
|
{
|
||||||
|
return double.TryParse(raw.Trim('"', '\'', ' '),
|
||||||
|
NumberStyles.Float | NumberStyles.AllowThousands,
|
||||||
|
CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string CleanValue(string value)
|
||||||
|
{
|
||||||
|
value = value.Trim();
|
||||||
|
|
||||||
|
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
|
||||||
|
{
|
||||||
|
value = value[1..^1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.StartsWith("[[", StringComparison.Ordinal) && value.EndsWith("]]", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
value = value[2..^2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
using Microsoft.Maui.Graphics;
|
||||||
|
using ZeroSpace.MapEditor.Models;
|
||||||
|
using ZeroSpace.MapEditor.Services;
|
||||||
|
using IImage = Microsoft.Maui.Graphics.IImage;
|
||||||
|
|
||||||
|
namespace ZeroSpace.MapEditor.Views;
|
||||||
|
|
||||||
|
/// <summary>Which part of the translation gizmo was hit or is active.</summary>
|
||||||
|
public enum GizmoPart
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Center,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draws the star map: connection lines between connected systems and
|
||||||
|
/// one circle per system. Names are intentionally not drawn on the
|
||||||
|
/// canvas; details live in the inspector.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MapDrawable : IDrawable
|
||||||
|
{
|
||||||
|
public const double NodeRadius = 14;
|
||||||
|
|
||||||
|
/// <summary>Screen-space length of each gizmo axis arm, in pixels.</summary>
|
||||||
|
public const double GizmoArmLength = 64;
|
||||||
|
|
||||||
|
public IList<MapNode> Nodes { get; set; } = Array.Empty<MapNode>();
|
||||||
|
|
||||||
|
public Func<string, MapNode?> FindNode { get; set; } = _ => null;
|
||||||
|
|
||||||
|
/// <summary>Screen pixels per world unit.</summary>
|
||||||
|
public double Scale { get; set; } = 1;
|
||||||
|
|
||||||
|
public double OffsetX { get; set; }
|
||||||
|
|
||||||
|
public double OffsetY { get; set; }
|
||||||
|
|
||||||
|
public RectF? SelectionBox { get; set; }
|
||||||
|
|
||||||
|
/// <summary>In-game galaxy map screenshot, drawn under the nodes.</summary>
|
||||||
|
public IImage? BackgroundImage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the galaxy map background is shown.</summary>
|
||||||
|
public bool ShowBackground { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Node the translation gizmo is drawn for (the active selection).</summary>
|
||||||
|
public MapNode? GizmoNode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gizmo part currently hovered or being dragged, for highlight.</summary>
|
||||||
|
public GizmoPart ActiveGizmoPart { get; set; } = GizmoPart.None;
|
||||||
|
|
||||||
|
public void Draw(ICanvas canvas, RectF dirtyRect)
|
||||||
|
{
|
||||||
|
canvas.FillColor = Color.FromArgb("#1e2430");
|
||||||
|
canvas.FillRectangle(dirtyRect);
|
||||||
|
|
||||||
|
DrawBackground(canvas);
|
||||||
|
|
||||||
|
PointF ToScreen(double worldX, double worldY) =>
|
||||||
|
new((float)(worldX * Scale + OffsetX), (float)(worldY * Scale + OffsetY));
|
||||||
|
|
||||||
|
// Connections first, so nodes draw on top of the lines.
|
||||||
|
canvas.StrokeColor = Color.FromArgb("#5b6b82");
|
||||||
|
canvas.StrokeSize = Math.Max(1, (float)(1.5 * Scale));
|
||||||
|
foreach (var node in Nodes)
|
||||||
|
{
|
||||||
|
var from = ToScreen(node.X, node.Y);
|
||||||
|
foreach (var name in node.Doc.ConnectedSystems)
|
||||||
|
{
|
||||||
|
var target = FindNode(name);
|
||||||
|
if (target is null || ReferenceEquals(target, node))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var to = ToScreen(target.X, target.Y);
|
||||||
|
canvas.DrawLine(from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var node in Nodes)
|
||||||
|
{
|
||||||
|
var center = ToScreen(node.X, node.Y);
|
||||||
|
var radius = (float)(NodeRadius * Scale);
|
||||||
|
|
||||||
|
canvas.FillColor = ColorForOwner(node.Doc.Owner);
|
||||||
|
canvas.FillCircle(center, radius);
|
||||||
|
|
||||||
|
if (node.IsHighlighted)
|
||||||
|
{
|
||||||
|
canvas.StrokeColor = Color.FromArgb("#FFD700");
|
||||||
|
canvas.StrokeSize = 3;
|
||||||
|
canvas.DrawCircle(center, radius + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.IsSelected)
|
||||||
|
{
|
||||||
|
canvas.StrokeColor = Colors.White;
|
||||||
|
canvas.StrokeSize = 3;
|
||||||
|
canvas.DrawCircle(center, node.IsHighlighted ? radius + 8 : radius + 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SelectionBox is { Width: > 0, Height: > 0 } box)
|
||||||
|
{
|
||||||
|
canvas.FillColor = Color.FromArgb("#334a90d9");
|
||||||
|
canvas.FillRectangle(box);
|
||||||
|
canvas.StrokeColor = Color.FromArgb("#4a90d9");
|
||||||
|
canvas.StrokeSize = 1.5f;
|
||||||
|
canvas.DrawRectangle(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GizmoNode is not null)
|
||||||
|
{
|
||||||
|
DrawGizmo(canvas, GizmoNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draws the 2D translation gizmo at the node's screen position:
|
||||||
|
/// a red +X arm, a green +Y arm, and a center square for free XY moves.
|
||||||
|
/// Screen-space, so it stays the same size at any zoom level.
|
||||||
|
/// </summary>
|
||||||
|
private void DrawGizmo(ICanvas canvas, MapNode node)
|
||||||
|
{
|
||||||
|
var cx = (float)(node.X * Scale + OffsetX);
|
||||||
|
var cy = (float)(node.Y * Scale + OffsetY);
|
||||||
|
const float arm = (float)GizmoArmLength;
|
||||||
|
|
||||||
|
DrawGizmoArm(canvas, cx, cy, cx + arm, cy, isX: true,
|
||||||
|
active: ActiveGizmoPart == GizmoPart.XAxis);
|
||||||
|
DrawGizmoArm(canvas, cx, cy, cx, cy + arm, isX: false,
|
||||||
|
active: ActiveGizmoPart == GizmoPart.YAxis);
|
||||||
|
|
||||||
|
// Center handle: free XY move.
|
||||||
|
const float size = 12f;
|
||||||
|
var centerColor = ActiveGizmoPart == GizmoPart.Center
|
||||||
|
? Color.FromArgb("#fde047")
|
||||||
|
: Color.FromArgb("#eab308");
|
||||||
|
canvas.FillColor = Color.FromArgb("#111827");
|
||||||
|
canvas.FillRectangle(cx - size / 2 - 2, cy - size / 2 - 2, size + 4, size + 4);
|
||||||
|
canvas.FillColor = centerColor;
|
||||||
|
canvas.FillRectangle(cx - size / 2, cy - size / 2, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawGizmoArm(ICanvas canvas, float x1, float y1, float x2, float y2,
|
||||||
|
bool isX, bool active)
|
||||||
|
{
|
||||||
|
var color = isX
|
||||||
|
? (active ? Color.FromArgb("#f87171") : Color.FromArgb("#ef4444"))
|
||||||
|
: (active ? Color.FromArgb("#4ade80") : Color.FromArgb("#22c55e"));
|
||||||
|
|
||||||
|
// Dark underlay so the arm reads against the bright galaxy map.
|
||||||
|
canvas.StrokeColor = Color.FromArgb("#111827");
|
||||||
|
canvas.StrokeSize = 5;
|
||||||
|
canvas.DrawLine(x1, y1, x2, y2);
|
||||||
|
canvas.StrokeColor = color;
|
||||||
|
canvas.StrokeSize = 3;
|
||||||
|
canvas.DrawLine(x1, y1, x2, y2);
|
||||||
|
|
||||||
|
// Arrowhead pointing along the axis.
|
||||||
|
const float headLen = 12f;
|
||||||
|
const float headHalf = 6f;
|
||||||
|
PointF tip, a, b;
|
||||||
|
if (isX)
|
||||||
|
{
|
||||||
|
tip = new PointF(x2, y2);
|
||||||
|
a = new PointF(x2 - headLen, y2 - headHalf);
|
||||||
|
b = new PointF(x2 - headLen, y2 + headHalf);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tip = new PointF(x2, y2);
|
||||||
|
a = new PointF(x2 - headHalf, y2 - headLen);
|
||||||
|
b = new PointF(x2 + headHalf, y2 - headLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
var head = new PathF();
|
||||||
|
head.MoveTo(tip);
|
||||||
|
head.LineTo(a);
|
||||||
|
head.LineTo(b);
|
||||||
|
head.Close();
|
||||||
|
canvas.FillColor = color;
|
||||||
|
canvas.FillPath(head);
|
||||||
|
canvas.StrokeColor = Color.FromArgb("#111827");
|
||||||
|
canvas.StrokeSize = 1.5f;
|
||||||
|
canvas.DrawPath(head);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draws the galaxy map screenshot fitted inside the world square,
|
||||||
|
/// preserving aspect ratio so node world coordinates line up exactly
|
||||||
|
/// with the map underneath.
|
||||||
|
/// </summary>
|
||||||
|
private void DrawBackground(ICanvas canvas)
|
||||||
|
{
|
||||||
|
if (!ShowBackground || BackgroundImage is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageWidth = BackgroundImage.Width;
|
||||||
|
var imageHeight = BackgroundImage.Height;
|
||||||
|
if (imageWidth <= 0 || imageHeight <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double world = StarSystemDocStore.WorldSize;
|
||||||
|
var fit = Math.Min(world / imageWidth, world / imageHeight);
|
||||||
|
var drawWidth = imageWidth * fit;
|
||||||
|
var drawHeight = imageHeight * fit;
|
||||||
|
var originX = (world - drawWidth) / 2;
|
||||||
|
var originY = (world - drawHeight) / 2;
|
||||||
|
|
||||||
|
canvas.DrawImage(BackgroundImage,
|
||||||
|
(float)(originX * Scale + OffsetX),
|
||||||
|
(float)(originY * Scale + OffsetY),
|
||||||
|
(float)(drawWidth * Scale),
|
||||||
|
(float)(drawHeight * Scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Color ColorForOwner(string owner) => owner switch
|
||||||
|
{
|
||||||
|
"The Eden Accord" => Color.FromArgb("#10b981"),
|
||||||
|
"The Keeper Council" => Color.FromArgb("#38bdf8"),
|
||||||
|
"The Obsidian Order" => Color.FromArgb("#ef4444"),
|
||||||
|
_ => Color.FromArgb("#9aa5b1"),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Color ColorForCategory(string category) => category switch
|
||||||
|
{
|
||||||
|
"Homeworld System" or "Homeworld" => Color.FromArgb("#43a047"),
|
||||||
|
"Core System" or "Core" => Color.FromArgb("#fb8c00"),
|
||||||
|
"Side System" or "Side" => Color.FromArgb("#4a90d9"),
|
||||||
|
_ => Color.FromArgb("#9aa5b1"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
|
x:Class="ZeroSpace.MapEditor.Views.MapEditorPage"
|
||||||
|
Title="ZeroSpace Map Editor">
|
||||||
|
<Grid RowDefinitions="Auto,*" ColumnDefinitions="*,340">
|
||||||
|
<ScrollView Grid.Row="0" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalScrollBarVisibility="Never">
|
||||||
|
<HorizontalStackLayout Padding="8" Spacing="8" VerticalOptions="Center">
|
||||||
|
<Button Text="Open Folder" Clicked="OnOpenFolderClicked" />
|
||||||
|
<Button x:Name="SaveButton" Text="Saved" Clicked="OnSaveClicked" IsEnabled="False" />
|
||||||
|
<Button Text="+" WidthRequest="44" Clicked="OnZoomInClicked" />
|
||||||
|
<Button Text="−" WidthRequest="44" Clicked="OnZoomOutClicked" />
|
||||||
|
<Button Text="Reset View" Clicked="OnResetViewClicked" />
|
||||||
|
<Button x:Name="PanModeButton" Text="Pan: Off" Clicked="OnPanModeClicked" />
|
||||||
|
<CheckBox x:Name="BackgroundCheckBox" IsChecked="True" CheckedChanged="OnBackgroundToggled" VerticalOptions="Center" />
|
||||||
|
<Label Text="Galaxy map" VerticalOptions="Center" />
|
||||||
|
<BoxView WidthRequest="1" HeightRequest="24" Color="#334155" VerticalOptions="Center" Margin="4,0" />
|
||||||
|
<Label Text="Faction:" VerticalOptions="Center" FontAttributes="Bold" />
|
||||||
|
<Button x:Name="EdenFactionButton" Text="[A] Eden Accord" Clicked="OnEdenFactionClicked" />
|
||||||
|
<Button x:Name="KeeperFactionButton" Text="[S] Keeper Council" Clicked="OnKeeperFactionClicked" />
|
||||||
|
<Button x:Name="ObsidianFactionButton" Text="[D] Obsidian Order" Clicked="OnObsidianFactionClicked" />
|
||||||
|
<Button x:Name="NoneFactionButton" Text="None" Clicked="OnNoneFactionClicked" />
|
||||||
|
<Border x:Name="ActiveFactionBadge"
|
||||||
|
StrokeShape="RoundRectangle 4"
|
||||||
|
StrokeThickness="1"
|
||||||
|
Padding="6,2"
|
||||||
|
VerticalOptions="Center"
|
||||||
|
IsVisible="False">
|
||||||
|
<Label x:Name="ActiveFactionLabel"
|
||||||
|
FontSize="12"
|
||||||
|
FontAttributes="Bold" />
|
||||||
|
</Border>
|
||||||
|
<BoxView WidthRequest="1" HeightRequest="24" Color="#334155" VerticalOptions="Center" Margin="4,0" />
|
||||||
|
<SearchBar x:Name="SearchControl"
|
||||||
|
Placeholder="Search systems..."
|
||||||
|
WidthRequest="220"
|
||||||
|
TextChanged="OnSearchTextChanged"
|
||||||
|
SearchButtonPressed="OnSearchButtonPressed" />
|
||||||
|
<Label x:Name="StatusLabel"
|
||||||
|
Text="No folder loaded."
|
||||||
|
VerticalOptions="Center"
|
||||||
|
Margin="8,0,0,0" />
|
||||||
|
</HorizontalStackLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<GraphicsView Grid.Row="1" Grid.Column="0" x:Name="MapView">
|
||||||
|
<GraphicsView.GestureRecognizers>
|
||||||
|
<PointerGestureRecognizer PointerPressed="OnPointerPressed"
|
||||||
|
PointerMoved="OnPointerMoved"
|
||||||
|
PointerReleased="OnPointerReleased" />
|
||||||
|
</GraphicsView.GestureRecognizers>
|
||||||
|
</GraphicsView>
|
||||||
|
|
||||||
|
<ScrollView Grid.Row="1" Grid.Column="1" x:Name="InspectorScroll" IsVisible="False">
|
||||||
|
<VerticalStackLayout Padding="12" Spacing="6">
|
||||||
|
<Label Text="Inspector" FontSize="18" FontAttributes="Bold" />
|
||||||
|
|
||||||
|
<Label Text="Name" FontAttributes="Bold" />
|
||||||
|
<Label x:Name="NameLabel" />
|
||||||
|
|
||||||
|
<Label Text="Category" FontAttributes="Bold" />
|
||||||
|
<Label x:Name="CategoryLabel" />
|
||||||
|
|
||||||
|
<Label Text="Owner" FontAttributes="Bold" />
|
||||||
|
<Border x:Name="OwnerBadge"
|
||||||
|
StrokeShape="RoundRectangle 6"
|
||||||
|
StrokeThickness="1"
|
||||||
|
Padding="8,4"
|
||||||
|
HorizontalOptions="Start"
|
||||||
|
BackgroundColor="#1e293b"
|
||||||
|
Stroke="#334155">
|
||||||
|
<Label x:Name="OwnerLabel" Text="—" FontAttributes="Bold" TextColor="#94a3b8" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Label Text="Yields" FontAttributes="Bold" />
|
||||||
|
<Label x:Name="YieldsLabel" />
|
||||||
|
|
||||||
|
<Label Text="Occupation buff" FontAttributes="Bold" />
|
||||||
|
<Label x:Name="BuffLabel" />
|
||||||
|
|
||||||
|
<Label Text="System mastery" FontAttributes="Bold" />
|
||||||
|
<Label x:Name="MasteryLabel" />
|
||||||
|
|
||||||
|
<Label Text="X" FontAttributes="Bold" />
|
||||||
|
<Entry x:Name="XEntry" Keyboard="Numeric" TextChanged="OnPositionTextChanged" />
|
||||||
|
|
||||||
|
<Label Text="Y" FontAttributes="Bold" />
|
||||||
|
<Entry x:Name="YEntry" Keyboard="Numeric" TextChanged="OnPositionTextChanged" />
|
||||||
|
|
||||||
|
<Label Text="Connected systems" FontAttributes="Bold" />
|
||||||
|
<CollectionView x:Name="ConnectionsView"
|
||||||
|
SelectionMode="Single"
|
||||||
|
SelectionChanged="OnConnectionSelectionChanged">
|
||||||
|
<CollectionView.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Label Text="{Binding}"
|
||||||
|
Padding="4,4"
|
||||||
|
TextColor="#4a90d9"
|
||||||
|
TextDecorations="Underline" />
|
||||||
|
</DataTemplate>
|
||||||
|
</CollectionView.ItemTemplate>
|
||||||
|
</CollectionView>
|
||||||
|
</VerticalStackLayout>
|
||||||
|
</ScrollView>
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||||
|
<TargetFrameworks Condition="!$([MSBuild]::IsOSPlatform('windows'))">net10.0-maccatalyst</TargetFrameworks>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<RootNamespace>ZeroSpace.MapEditor</RootNamespace>
|
||||||
|
<UseMaui>true</UseMaui>
|
||||||
|
<SingleProject>true</SingleProject>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<ApplicationTitle>ZeroSpace Map Editor</ApplicationTitle>
|
||||||
|
<ApplicationId>ca.jonathanmccaffrey.zerospace.mapeditor</ApplicationId>
|
||||||
|
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||||
|
<ApplicationVersion>1</ApplicationVersion>
|
||||||
|
|
||||||
|
<!-- Unpackaged Windows desktop execution -->
|
||||||
|
<WindowsPackageType>None</WindowsPackageType>
|
||||||
|
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||||
|
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZeroSpace.Model\ZeroSpace.Model.csproj" />
|
||||||
|
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.110" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Maui.Core" Version="15.0.1" />
|
||||||
|
<!-- In-game galaxy map screenshot shown as the canvas background. -->
|
||||||
|
<MauiAsset Include="Resources\Raw\galaxymap.png">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</MauiAsset>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public sealed record FactionPowerItemData(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
string? LimitedTo,
|
||||||
|
string? LimitedToMerc,
|
||||||
|
int? MinimumLevel,
|
||||||
|
string? ImageFileName,
|
||||||
|
string Body);
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public static class FactionPowerItems
|
||||||
|
{
|
||||||
|
public static readonly FactionPowerItemData Airstrike = new(
|
||||||
|
Id: "Airstrike",
|
||||||
|
Name: "Airstrike",
|
||||||
|
Levels: ["Calls down an Airstrike, dealing 300 damage to ground units. Requires Airstrips."],
|
||||||
|
LimitedTo: "Prefect Aster",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 2,
|
||||||
|
ImageFileName: "Airstrike.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData Destroyer = new(
|
||||||
|
Id: "Destroyer",
|
||||||
|
Name: "Destroyer",
|
||||||
|
Levels: ["Spawns a powerful guardian with limited life. Spawns Boomers over time and on death. Retains infused when called again."],
|
||||||
|
LimitedTo: "Vynthra",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 10,
|
||||||
|
ImageFileName: "Destroyer.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData ForcedAssimilation = new(
|
||||||
|
Id: "ForcedAssimilation",
|
||||||
|
Name: "Forced Assimilation",
|
||||||
|
Levels: ["Target a friendly T1/ T2 unit. All nearby friendly T1/T2 units are assimilated, giving 30% of their health, shield and damage to the targeted unit, and an additional 20% if the unit is a T1 unit. Cannot be used on the same unit more than once."],
|
||||||
|
LimitedTo: "Anrubic",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 1,
|
||||||
|
ImageFileName: "Forced Assimilation.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData Havoc = new(
|
||||||
|
Id: "Havoc",
|
||||||
|
Name: "Havoc",
|
||||||
|
Levels: ["Spawn a Havoc at location. It charges through enemies. Explodes on death dealing 300 damage."],
|
||||||
|
LimitedTo: "Anrubic",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 1,
|
||||||
|
ImageFileName: "Havoc.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData HealingPod = new(
|
||||||
|
Id: "HealingPod",
|
||||||
|
Name: "Healing Pod",
|
||||||
|
Levels: ["Spawn a Nourishing Pod and three Seedlings. Nourishing Pod heals nearby units."],
|
||||||
|
LimitedTo: "Vynthra",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 1,
|
||||||
|
ImageFileName: "Healing Pod.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData Infusion = new(
|
||||||
|
Id: "Infusion",
|
||||||
|
Name: "Infusion",
|
||||||
|
Levels: ["Increases the target's health, attack damage, health and energy regeneration."],
|
||||||
|
LimitedTo: "Vynthra",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 1,
|
||||||
|
ImageFileName: "Infusion.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData Minefield = new(
|
||||||
|
Id: "Minefield",
|
||||||
|
Name: "Minefield",
|
||||||
|
Levels: ["Spawn 12 mines that damage and stun enemies", "Spawn 14 mines that damage and stun enemies", "Spawn 16 mines that damage and stun enemies", "Spawn 18 mines that damage and stun enemies", "Spawn 20 mines that damage and stun enemies"],
|
||||||
|
LimitedTo: null,
|
||||||
|
LimitedToMerc: "Free Corsair",
|
||||||
|
MinimumLevel: null,
|
||||||
|
ImageFileName: "Minefield.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData NanoSwarm = new(
|
||||||
|
Id: "NanoSwarm",
|
||||||
|
Name: "Nano Swarm",
|
||||||
|
Levels: ["Spawn a Nano Swarm at targeted location. It draws enemy fire. After 15 seconds it explodes dealing damage to enemies. The damage increases when it takes damage."],
|
||||||
|
LimitedTo: "Anrubic",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 9,
|
||||||
|
ImageFileName: "Nano Swarm.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData OrbitalLaser = new(
|
||||||
|
Id: "OrbitalLaser",
|
||||||
|
Name: "Orbital Laser",
|
||||||
|
Levels: ["Heals friendly units and Revives recently lost units."],
|
||||||
|
LimitedTo: null,
|
||||||
|
LimitedToMerc: "The Molten",
|
||||||
|
MinimumLevel: 3,
|
||||||
|
ImageFileName: "Orbital Laser.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData Reinforcements = new(
|
||||||
|
Id: "Reinforcements",
|
||||||
|
Name: "Reinforcements",
|
||||||
|
Levels: ["Call down reinforcements of the selected type."],
|
||||||
|
LimitedTo: "Prefect Aster",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 1,
|
||||||
|
ImageFileName: "Reinforcements.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData Replicate = new(
|
||||||
|
Id: "Replicate",
|
||||||
|
Name: "Replicate",
|
||||||
|
Levels: ["Create copies of the targeted friendly units."],
|
||||||
|
LimitedTo: "Anrubic",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 2,
|
||||||
|
ImageFileName: "Replicate.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData Shockwave = new(
|
||||||
|
Id: "Shockwave",
|
||||||
|
Name: "Shockwave",
|
||||||
|
Levels: ["Stuns enemy units in area for 3 seconds."],
|
||||||
|
LimitedTo: "Prefect Aster",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 1,
|
||||||
|
ImageFileName: "Shockwave.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData SolInvictus = new(
|
||||||
|
Id: "SolInvictus",
|
||||||
|
Name: "Sol Invictus",
|
||||||
|
Levels: ["Call down a powerful mech that explodes on death and sets the ground on fire."],
|
||||||
|
LimitedTo: "Prefect Aster",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 7,
|
||||||
|
ImageFileName: "Sol Invictus.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData SpawnMonolith = new(
|
||||||
|
Id: "SpawnMonolith",
|
||||||
|
Name: "Spawn Monolith",
|
||||||
|
Levels: ["Summon a powerful structure. Nearby Thrall deaths cause bonus attacks to occur."],
|
||||||
|
LimitedTo: "Galavax",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 7,
|
||||||
|
ImageFileName: "Spawn Monolith.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData SpawnObelisks = new(
|
||||||
|
Id: "SpawnObelisks",
|
||||||
|
Name: "Spawn Obelisks",
|
||||||
|
Levels: ["Spawn various obelisks on the battlefield"],
|
||||||
|
LimitedTo: "Galavax",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 1,
|
||||||
|
ImageFileName: "Spawn Obelisks.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData SummonEmperor = new(
|
||||||
|
Id: "SummonEmperor",
|
||||||
|
Name: "Summon Emperor",
|
||||||
|
Levels: ["Summoning takes 15 durartion. Units dying nearby the site empower the Emperor. Thralls can be sacrificed with 75% cost refund (right-click on the site). Emperor loses health when summoned."],
|
||||||
|
LimitedTo: "Galavax",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 1,
|
||||||
|
ImageFileName: "Summon Emperor.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly FactionPowerItemData TreeOfLife = new(
|
||||||
|
Id: "TreeOfLife",
|
||||||
|
Name: "Tree of Life",
|
||||||
|
Levels: ["Heals friendly units and Revives recently lost units."],
|
||||||
|
LimitedTo: "Vynthra",
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: 3,
|
||||||
|
ImageFileName: "Tree of Life.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static IReadOnlyList<FactionPowerItemData> All { get; } =
|
||||||
|
[
|
||||||
|
Airstrike,
|
||||||
|
Destroyer,
|
||||||
|
ForcedAssimilation,
|
||||||
|
Havoc,
|
||||||
|
HealingPod,
|
||||||
|
Infusion,
|
||||||
|
Minefield,
|
||||||
|
NanoSwarm,
|
||||||
|
OrbitalLaser,
|
||||||
|
Reinforcements,
|
||||||
|
Replicate,
|
||||||
|
Shockwave,
|
||||||
|
SolInvictus,
|
||||||
|
SpawnMonolith,
|
||||||
|
SpawnObelisks,
|
||||||
|
SummonEmperor,
|
||||||
|
TreeOfLife,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, FactionPowerItemData> ByName { get; } =
|
||||||
|
All.ToDictionary(static item => item.Name, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public sealed record GalaxyMapExportData(
|
||||||
|
DateTimeOffset ExportedAt,
|
||||||
|
int TotalSystems,
|
||||||
|
int TotalMasteries,
|
||||||
|
IReadOnlyList<StarSystemData> Systems,
|
||||||
|
IReadOnlyList<SystemMasteryData> Masteries,
|
||||||
|
IReadOnlyList<PlanetaryMasteryItemData> PlanetaryMasteryItems)
|
||||||
|
{
|
||||||
|
public static GalaxyMapExportData CreateDefault() =>
|
||||||
|
new(
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
StarSystems.All.Count,
|
||||||
|
SystemMasteries.All.Count,
|
||||||
|
StarSystems.All,
|
||||||
|
SystemMasteries.All,
|
||||||
|
ZeroSpace.Model.PlanetaryMasteryItems.All);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public sealed record GeneralItemData(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
string? LimitedToMerc,
|
||||||
|
string? ImageFileName,
|
||||||
|
string Body);
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public static class GeneralItems
|
||||||
|
{
|
||||||
|
public static readonly GeneralItemData ArandiPenumbra = new(
|
||||||
|
Id: "ArandiPenumbra",
|
||||||
|
Name: "Arandi Penumbra",
|
||||||
|
Levels: ["Unlocks construction of Arandi Penumbra and production of mercenaries from it.", "Increase mercenary health +15% and damage +15%", "Increase mercenary compound limit by 1.", "Increase mercenary health +30% and damage +30%", "Increase mercenary compound limit by 1."],
|
||||||
|
LimitedToMerc: "Arandi",
|
||||||
|
ImageFileName: "Arandi Penumbra.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData AssaultforgedWeaponry = new(
|
||||||
|
Id: "AssaultforgedWeaponry",
|
||||||
|
Name: "Assaultforged Weaponry",
|
||||||
|
Levels: ["+20% weapon damage for melee units", "+25% weapon damage for melee units", "+30% weapon damage for melee units", "+35% weapon damage for melee units", "+40% weapon damage for melee units"],
|
||||||
|
LimitedToMerc: "Cha'Kru Kingdom",
|
||||||
|
ImageFileName: "Assaultforged Weaponry.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData ElderbastionShell = new(
|
||||||
|
Id: "ElderbastionShell",
|
||||||
|
Name: "Elderbastion Shell",
|
||||||
|
Levels: ["Gives you access to the wall building. +15% vitals for buildings.", "+30% vitals for buildings.", "+45% vitals for buildings.", "+60% vitals for buildings.", "+75% vitals for buildings."],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Elderbastion Shell.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData EnigmaticCapacitor = new(
|
||||||
|
Id: "EnigmaticCapacitor",
|
||||||
|
Name: "Enigmatic Capacitor",
|
||||||
|
Levels: ["-5% unit ability cooldowns.", "-10% unit ability cooldowns.", "-15% unit ability cooldowns.", "-20% unit ability cooldowns.", "-25% unit ability cooldowns."],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Enigmatic Capacitor.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData FreeCorsairMercenaryOutpost = new(
|
||||||
|
Id: "FreeCorsairMercenaryOutpost",
|
||||||
|
Name: "Free Corsair Mercenary Outpost",
|
||||||
|
Levels: ["Unlocks construction of Free Corsair Mercernary Outpost and production of mercenaries from it.", "Increase mercenary health +15% and damage +15%", "Increase mercenary compound limit by 1.", "Increase mercenary health +30% and damage +30%", "Increase mercenary compound limit by 1."],
|
||||||
|
LimitedToMerc: "Free Corsair",
|
||||||
|
ImageFileName: "Free Corsair Mercenary Outpost.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData GensisCore = new(
|
||||||
|
Id: "GensisCore",
|
||||||
|
Name: "Gensis Core",
|
||||||
|
Levels: ["+1 unit health regeneration per second.", "+2 unit health regeneration per second.", "+3 unit health regeneration per second.", "+4 unit health regeneration per second.", "+5 unit health regeneration per second."],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Gensis Core.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData Heroesbane = new(
|
||||||
|
Id: "Heroesbane",
|
||||||
|
Name: "Heroesbane",
|
||||||
|
Levels: ["+40% lifesteal to heroic units", "+45% lifesteal to heroic units", "+50% lifesteal to heroic units", "+55% lifesteal to heroic units", "+60% lifesteal to heroic units"],
|
||||||
|
LimitedToMerc: "Valkaru",
|
||||||
|
ImageFileName: "Heroesbane.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData KaikinoLifebleeders = new(
|
||||||
|
Id: "KaikinoLifebleeders",
|
||||||
|
Name: "Kaikino Lifebleeders",
|
||||||
|
Levels: ["+30% lifesteal for melee units", "+35% lifesteal for melee units", "+40% lifesteal for melee units", "+45% lifesteal for melee units", "+50% lifesteal for melee units"],
|
||||||
|
LimitedToMerc: "Endari",
|
||||||
|
ImageFileName: "Kaikino Lifebleeders.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData LightweightAlloys = new(
|
||||||
|
Id: "LightweightAlloys",
|
||||||
|
Name: "Lightweight alloys",
|
||||||
|
Levels: ["+10% unit movement speed.", "+20% unit movement speed.", "+30% unit movement speed.", "+40% unit movement speed.", "+50% unit movement speed."],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Lightweight alloys.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData MarranBulwark = new(
|
||||||
|
Id: "MarranBulwark",
|
||||||
|
Name: "Marran Bulwark",
|
||||||
|
Levels: ["Unlocks construction of Marran Bulwark and production of mercenaries from it.", "Increase mercenary health +15% and damage +15%", "Increase mercenary compound limit by 1.", "Increase mercenary health +30% and damage +30%", "Increase mercenary compound limit by 1."],
|
||||||
|
LimitedToMerc: "Marran",
|
||||||
|
ImageFileName: "Marran Bulwark.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData MirageArmour = new(
|
||||||
|
Id: "MirageArmour",
|
||||||
|
Name: "Mirage Armour",
|
||||||
|
Levels: ["Units take 5% less damage.", "Units take 7.5% less damage.", "Units take 10% less damage.", "Units take 12.5% less damage.", "Units take 15% less damage."],
|
||||||
|
LimitedToMerc: "Arandi",
|
||||||
|
ImageFileName: "Mirage Armour.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData RemnantEngineering = new(
|
||||||
|
Id: "RemnantEngineering",
|
||||||
|
Name: "Remnant Engineering",
|
||||||
|
Levels: ["+25% production, research and construction speed.", "+30% production, research and construction speed.", "+35% production, research and construction speed.", "+40% production, research and construction speed.", "+50% production, research and construction speed."],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Remnant Engineering.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData RenewalMatrix = new(
|
||||||
|
Id: "RenewalMatrix",
|
||||||
|
Name: "Renewal Matrix",
|
||||||
|
Levels: ["5x faster hero and harvester respawn rate.", "10x faster hero and harvester respawn rate."],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Renewal Matrix.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData Scavenging = new(
|
||||||
|
Id: "Scavenging",
|
||||||
|
Name: "Scavenging",
|
||||||
|
Levels: ["Gain 4% enemy structures cost as flux after destroying it", "Gain 6% enemy structures cost as flux after destroying it", "Gain 8% enemy structures cost as flux after destroying it", "Gain 10% enemy structures cost as flux after destroying it", "Gain 12% enemy structures cost as flux after destroying it"],
|
||||||
|
LimitedToMerc: "Free Corsair",
|
||||||
|
ImageFileName: "Scavenging.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData SilentHollow = new(
|
||||||
|
Id: "SilentHollow",
|
||||||
|
Name: "Silent Hollow",
|
||||||
|
Levels: ["Unlocks construction of Deep One and production of mercenaries from it.", "Increase mercenary health +15% and damage +15%", "Increase mercenary compound limit by 1.", "Increase mercenary health +30% and damage +30%", "Increase mercenary compound limit by 1."],
|
||||||
|
LimitedToMerc: "Enrandi",
|
||||||
|
ImageFileName: "Silent Hollow.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData SingularityCell = new(
|
||||||
|
Id: "SingularityCell",
|
||||||
|
Name: "Singularity Cell",
|
||||||
|
Levels: ["+20% unit energy regeneration.", "+40% unit energy regeneration.", "+60% unit energy regeneration.", "+80% unit energy regeneration.", "+100% unit energy regeneration."],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Singularity Cell.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData StarhunterScopes = new(
|
||||||
|
Id: "StarhunterScopes",
|
||||||
|
Name: "Starhunter Scopes",
|
||||||
|
Levels: ["+0.5 attack range to army units", "+0.75 attack range to army units", "+1 attack range to army units", "+1.25 attack range to army units", "+1.5 attack range to army units"],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Starhunter Scopes.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData SupplyCache = new(
|
||||||
|
Id: "SupplyCache",
|
||||||
|
Name: "Supply Cache",
|
||||||
|
Levels: ["+20 starting and max supply.", "+25 starting and max supply.", "+30 starting and max supply.", "+35 starting and max supply.", "+40 starting and max supply."],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Supply Cache.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData TempleOfAnqas = new(
|
||||||
|
Id: "TempleOfAnqas",
|
||||||
|
Name: "Temple of Anqas",
|
||||||
|
Levels: ["Unlocks construction of Cha'kru Temple of Anqas and production of mercenaries from it.", "Increase mercenary health +15% and damage +15%", "Increase mercenary compound limit by 1.", "Increase mercenary health +30% and damage +30%", "Increase mercenary compound limit by 1."],
|
||||||
|
LimitedToMerc: "Cha'Kru Kingdom",
|
||||||
|
ImageFileName: "Temple of Anqas.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData TempleOfStars = new(
|
||||||
|
Id: "TempleOfStars",
|
||||||
|
Name: "Temple of Stars",
|
||||||
|
Levels: ["Unlocks construction of Cha'kru Temple of Stars and production of mercenaries from it.", "Increase mercenary health +15% and damage +15%", "Increase mercenary compound limit by 1.", "Increase mercenary health +30% and damage +30%", "Increase mercenary compound limit by 1."],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Temple of Stars.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData ValkaruWarVault = new(
|
||||||
|
Id: "ValkaruWarVault",
|
||||||
|
Name: "Valkaru War Vault",
|
||||||
|
Levels: ["Unlocks construction of Valkaru War Vault and production of mercenaries from it.", "Increase mercenary health +15% and damage +15%", "Increase mercenary compound limit by 1.", "Increase mercenary health +30% and damage +30%", "Increase mercenary compound limit by 1."],
|
||||||
|
LimitedToMerc: "Valkaru",
|
||||||
|
ImageFileName: "Valkaru War Vault.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly GeneralItemData XolRelicOutpost = new(
|
||||||
|
Id: "XolRelicOutpost",
|
||||||
|
Name: "Xol Relic Outpost",
|
||||||
|
Levels: ["Ancient Xol Master relic. Calls Xol mercenaries to fight for you.", "Unlock Molten Xyther", "Unlock Molten Synapse", "Unlock Molten Aegis", "Can build additional Xol Relic Outpost"],
|
||||||
|
LimitedToMerc: null,
|
||||||
|
ImageFileName: "Xol Relic Outpost.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static IReadOnlyList<GeneralItemData> All { get; } =
|
||||||
|
[
|
||||||
|
ArandiPenumbra,
|
||||||
|
AssaultforgedWeaponry,
|
||||||
|
ElderbastionShell,
|
||||||
|
EnigmaticCapacitor,
|
||||||
|
FreeCorsairMercenaryOutpost,
|
||||||
|
GensisCore,
|
||||||
|
Heroesbane,
|
||||||
|
KaikinoLifebleeders,
|
||||||
|
LightweightAlloys,
|
||||||
|
MarranBulwark,
|
||||||
|
MirageArmour,
|
||||||
|
RemnantEngineering,
|
||||||
|
RenewalMatrix,
|
||||||
|
Scavenging,
|
||||||
|
SilentHollow,
|
||||||
|
SingularityCell,
|
||||||
|
StarhunterScopes,
|
||||||
|
SupplyCache,
|
||||||
|
TempleOfAnqas,
|
||||||
|
TempleOfStars,
|
||||||
|
ValkaruWarVault,
|
||||||
|
XolRelicOutpost,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, GeneralItemData> ByName { get; } =
|
||||||
|
All.ToDictionary(static item => item.Name, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public sealed record PlanetaryMasteryItemData(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
string? ImageFileName,
|
||||||
|
string Body);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public static class PlanetaryMasteryItems
|
||||||
|
{
|
||||||
|
public static readonly PlanetaryMasteryItemData Accelerant = new(
|
||||||
|
Id: "Accelerant",
|
||||||
|
Name: "Accelerant",
|
||||||
|
Levels: ["10% cooldown reduction on Topbar abilities.", "15% cooldown reduction on Topbar abilities.", "20% cooldown reduction on Topbar abilities.", "25% cooldown reduction on Topbar abilities.", "30% cooldown reduction on Topbar abilities."],
|
||||||
|
ImageFileName: "Accelerant.png",
|
||||||
|
Body: "Grass gives [[Vynthra]] reduce damage taken. So I want cooldown reduction on Topbar to cast as many Healing Pods as possible.\n\nGiven Healing Pods also spawns three Seedlings, it helps with [[Vynthra]]'s weakness of getting seedlings where needed, and I want those power AOE turrets that poison and slow that [[Vynthra]] has everywhere to spread the grass and deal with the constant random raids.");
|
||||||
|
|
||||||
|
public static readonly PlanetaryMasteryItemData Battery = new(
|
||||||
|
Id: "Battery",
|
||||||
|
Name: "Battery",
|
||||||
|
Levels: ["+10 starting topbar energy.", "+12.5 starting topbar energy.", "+15 starting topbar energy.", "+17.5 starting topbar energy.", "+20 starting topbar energy."],
|
||||||
|
ImageFileName: "Battery.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly PlanetaryMasteryItemData EnergySiphon = new(
|
||||||
|
Id: "EnergySiphon",
|
||||||
|
Name: "Energy Siphon",
|
||||||
|
Levels: ["+0.15 Topbar regeneration rate.", "+0.2 Topbar regeneration rate.", "+0.25 Topbar regeneration rate.", "+0.3 Topbar regeneration rate.", "+0.35 Topbar regeneration rate."],
|
||||||
|
ImageFileName: "Energy Siphon.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly PlanetaryMasteryItemData FasterReload = new(
|
||||||
|
Id: "FasterReload",
|
||||||
|
Name: "Faster Reload",
|
||||||
|
Levels: ["Units attack +10% faster.", "Units attack +12.5% faster.", "Units attack +15% faster.", "Units attack +17.5% faster.", "Units attack +20% faster."],
|
||||||
|
ImageFileName: "Faster Reload.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly PlanetaryMasteryItemData FluxCache = new(
|
||||||
|
Id: "FluxCache",
|
||||||
|
Name: "Flux Cache",
|
||||||
|
Levels: ["+50 Starting Flux", "+75 Starting Flux", "+100 Starting Flux", "+125 Starting Flux", "+150 Starting Flux"],
|
||||||
|
ImageFileName: "Flux Cache.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly PlanetaryMasteryItemData GenesisPlating = new(
|
||||||
|
Id: "GenesisPlating",
|
||||||
|
Name: "Genesis Plating",
|
||||||
|
Levels: ["+10% unit health and shields.", "+12.5% unit health and shields.", "+15% unit health and shields.", "+17.5% unit health and shields.", "+20% unit health and shields."],
|
||||||
|
ImageFileName: "Genesis Plating.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly PlanetaryMasteryItemData HexiteCache = new(
|
||||||
|
Id: "HexiteCache",
|
||||||
|
Name: "Hexite Cache",
|
||||||
|
Levels: ["+100 Starting Hexite", "+125 Starting Hexite", "+150 Starting Hexite", "+175 Starting Hexite", "+200 Starting Hexite"],
|
||||||
|
ImageFileName: "Hexite Cache.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly PlanetaryMasteryItemData ImprovedWeaponry = new(
|
||||||
|
Id: "ImprovedWeaponry",
|
||||||
|
Name: "Improved Weaponry",
|
||||||
|
Levels: ["+10% unit weapon damage.", "+12.5% unit weapon damage.", "+15% unit weapon damage.", "+17.5% unit weapon damage.", "+20% unit weapon damage."],
|
||||||
|
ImageFileName: "Improved Weaponry.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly PlanetaryMasteryItemData ValkaruArcheoalloys = new(
|
||||||
|
Id: "ValkaruArcheoalloys",
|
||||||
|
Name: "Valkaru Archeoalloys",
|
||||||
|
Levels: ["+1 armor to units.", "+1.5 armor to units.", "+2 armor to units.", "+2.5 armor to units.", "+3 armor to units."],
|
||||||
|
ImageFileName: "Valkaru Archeoalloys.png",
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static IReadOnlyList<PlanetaryMasteryItemData> All { get; } =
|
||||||
|
[
|
||||||
|
Accelerant,
|
||||||
|
Battery,
|
||||||
|
EnergySiphon,
|
||||||
|
FasterReload,
|
||||||
|
FluxCache,
|
||||||
|
GenesisPlating,
|
||||||
|
HexiteCache,
|
||||||
|
ImprovedWeaponry,
|
||||||
|
ValkaruArcheoalloys,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, PlanetaryMasteryItemData> ByName { get; } =
|
||||||
|
All.ToDictionary(static item => item.Name, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public enum StarSystemCategory
|
||||||
|
{
|
||||||
|
HomeworldSystem,
|
||||||
|
CoreSystem,
|
||||||
|
SideSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StarSystemCategoryExtensions
|
||||||
|
{
|
||||||
|
public static string ToDisplayName(this StarSystemCategory category) => category switch
|
||||||
|
{
|
||||||
|
StarSystemCategory.HomeworldSystem => "Homeworld System",
|
||||||
|
StarSystemCategory.CoreSystem => "Core System",
|
||||||
|
StarSystemCategory.SideSystem => "Side System",
|
||||||
|
_ => category.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StarSystemOwners
|
||||||
|
{
|
||||||
|
public const string EdenAccord = "The Eden Accord";
|
||||||
|
public const string KeeperCouncil = "The Keeper Council";
|
||||||
|
public const string ObsidianOrder = "The Obsidian Order";
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<string> All =
|
||||||
|
[
|
||||||
|
EdenAccord,
|
||||||
|
KeeperCouncil,
|
||||||
|
ObsidianOrder
|
||||||
|
];
|
||||||
|
|
||||||
|
public static string GetColor(string? owner) => owner switch
|
||||||
|
{
|
||||||
|
EdenAccord => "#10b981", // Green
|
||||||
|
KeeperCouncil => "#38bdf8", // Blue
|
||||||
|
ObsidianOrder => "#ef4444", // Red
|
||||||
|
_ => "#94a3b8"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string GetCssClass(string? owner) => owner switch
|
||||||
|
{
|
||||||
|
EdenAccord => "owner-eden",
|
||||||
|
KeeperCouncil => "owner-keeper",
|
||||||
|
ObsidianOrder => "owner-obsidian",
|
||||||
|
_ => "owner-neutral"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StarSystemData(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
StarSystemCategory SystemCategory,
|
||||||
|
string Owner,
|
||||||
|
bool HigherInfluenceYield,
|
||||||
|
bool HigherCreditYield,
|
||||||
|
bool HigherEnergyYield,
|
||||||
|
bool HigherChanceForItems,
|
||||||
|
string? OccupationBuff,
|
||||||
|
string? SystemMastery,
|
||||||
|
IReadOnlyList<string> ConnectedSystems,
|
||||||
|
double X,
|
||||||
|
double Y,
|
||||||
|
string Body);
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public static class SystemMasteries
|
||||||
|
{
|
||||||
|
public static readonly SystemMasteryData BuildingHealthAndShields = new(
|
||||||
|
Id: "BuildingHealthAndShields",
|
||||||
|
Name: "Building Health and Shields",
|
||||||
|
Levels: ["+2% building health and shields", "+2% building health and shields", "+2% building health and shields", "+2% building health and shields", "+5% building health and shields"],
|
||||||
|
AvailableSystems: ["Stronghold Orion", "Magenta Bloom", "Obsidian Palace", "Raider's Reef", "Nexus Prime", "Erebus Deep", "Celestial Forge Stronghold", "Aquila Rift"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["buildingHealthAndShields"] = 0.13,
|
||||||
|
},
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly SystemMasteryData HeroDamage = new(
|
||||||
|
Id: "HeroDamage",
|
||||||
|
Name: "Hero Damage",
|
||||||
|
Levels: ["+3% hero damage", "+3% hero damage", "+3% hero damage", "+3% hero damage", "-5% cooldown on heroes abilities"],
|
||||||
|
AvailableSystems: ["Stronghold Atlas", "Hyperion Nebula", "Stronghold Leo", "Tartarus Expanse", "Shattered Light", "Lambda Gateway", "Devil's Trident", "Beta Clarity"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["heroDamage"] = 0.12,
|
||||||
|
["heroCooldown"] = 0.05,
|
||||||
|
},
|
||||||
|
Body: "I don't like micromanaging heroes, so I wouldn't put too much stock in this skill.\n\nThat said, it's hard to ignore the whole hero cooldown reduction, and stacking it.\n\n[[Vynthra]]'s cooldown on the AOE generates top bar energy, so the more you cast it the better. \n\n[[Galavax]] can convert enemies, so it's a instant kill that can give you a big unit.\n\nAnd [[Prefect Aster]] has his +40% attack speed buff to allies in his area. So all clear powerful abilities you want to cast often.");
|
||||||
|
|
||||||
|
public static readonly SystemMasteryData HeroHealthAndShields = new(
|
||||||
|
Id: "HeroHealthAndShields",
|
||||||
|
Name: "Hero Health and Shields",
|
||||||
|
Levels: ["+3% hero health and shields", "+3% hero health and shields", "+3% hero health and shields", "+3% hero health and shields", "+4 to heroes health and shields regeneration rate"],
|
||||||
|
AvailableSystems: ["Yellow Dawn", "Gelid Storm", "Occam's Abode", "Scarlett Ire", "Negative Ion", "The Crucible", "Idal's World", "Crimson Expanse"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["heroHealthAndShields"] = 0.12,
|
||||||
|
["heroHealthAndShieldRegen"] = 4,
|
||||||
|
},
|
||||||
|
Body: "Seems pointless. Heroes currently are all ranged, so while they are good tanks in terms of durability, it's a bit micro intensive to actually get them to tank. Maybe when a melee hero gets released, this would be worth stacking as a easy health and shield tanking hero.");
|
||||||
|
|
||||||
|
public static readonly SystemMasteryData MaxSupply = new(
|
||||||
|
Id: "MaxSupply",
|
||||||
|
Name: "Max Supply",
|
||||||
|
Levels: ["+2 max supply", "+2 max supply", "+2 max supply", "+2 max supply", "+5 starting and max supply"],
|
||||||
|
AvailableSystems: ["Aurora Vale", "Corundum Dream", "Draconis Reach", "Stellar Forge", "Magnetic Folly", "Mercury Forge", "Wild's End", "The Undergrowth"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["startingSupply"] = 5,
|
||||||
|
["maxSupply"] = 13,
|
||||||
|
},
|
||||||
|
Body: "Gives +13 max supply and +5 starting supply.\n\nNot an obvious punch up in power. \n\nMakes initial time to make a bigger army cheaper, given an extra five supply of units. \n\nAnd stacking these buffs is kind of an obvious end game bonus.\n\nBoth [[Prefect Aster]] and [[Galavax]] can hit max supply pretty easily given there powers create units that take up supply, so could be beneficial for armies that need large groups to split up to hold objectives.\n\nPerhaps an uneducated opinion, but I would skip this if focusing on [[Vynthra]] unless a good neutral power comes out that takes supply. Because your not reasonably hitting supply cap in harder difficulties in solo co-op.");
|
||||||
|
|
||||||
|
public static readonly SystemMasteryData ProductionSpeedForBuildings = new(
|
||||||
|
Id: "ProductionSpeedForBuildings",
|
||||||
|
Name: "Production Speed for Buildings",
|
||||||
|
Levels: ["+1% Production speed for buildings", "+1% Production speed for buildings", "+1% Production speed for buildings", "+1% Production speed for buildings", "+5% Production speed for buildings"],
|
||||||
|
AvailableSystems: ["Endless Wastes", "Sullen Dwarf", "Starless Sky", "Steel Depths", "Glittering Hope", "Ebony Keep", "Stronghold Cronus", "Iotan Tears"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["productionSpeedForBuildings"] = 0.09,
|
||||||
|
},
|
||||||
|
Body: "9% production speed. Seems obvious for any tech up strategy.\n\nMight be beneficial towards getting a certain mercenary tier unlocked very quickly.\n\nI would be interested in using [[Vynthra]] and quickly teching up to [[Corsair Snipers]] and infusing them. Probably also better ideas in this regard.");
|
||||||
|
|
||||||
|
public static readonly SystemMasteryData StartingTopbarEnergy = new(
|
||||||
|
Id: "StartingTopbarEnergy",
|
||||||
|
Name: "Starting Topbar Energy",
|
||||||
|
Levels: ["+1 Topbar starting energy", "+1 Topbar starting energy", "+1 Topbar starting energy", "+1 Topbar starting energy", "+2 Topbar starting energy and +5 Topbar maximum energy"],
|
||||||
|
AvailableSystems: ["Stronghold Nova Centauri", "Lights Bounty", "Astral Haven", "The High Council", "Uanachian Whirlpool", "Vulcan's Breath", "Xenon Abyss", "Calm Belt", "Nucleic Redux"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["startingTopbarEnergy"] = 6,
|
||||||
|
["maxTopbarEnergy"] = 5,
|
||||||
|
},
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly SystemMasteryData TurretDamage = new(
|
||||||
|
Id: "TurretDamage",
|
||||||
|
Name: "Turret Damage",
|
||||||
|
Levels: ["+5% turret damage.", "+5% turret damage.", "+5% turret damage.", "+5% turret damage.", "+10% turret attack speed."],
|
||||||
|
AvailableSystems: ["Solaris Reach", "Xenthic Fields", "Stronghold Vega", "Stronghold Tau", "Inferno's Edge Stronghold", "Titan's Fall", "Nebulon Drift", "Proxima Prime"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["turretDamage"] = 0.2,
|
||||||
|
["turretAttackSpeed"] = 0.1,
|
||||||
|
},
|
||||||
|
Body: "This just seems crazy good on [[Vynthra]].\n\nIf you want go go base defense crazy, I don't see how this is not prioritized.");
|
||||||
|
|
||||||
|
public static readonly SystemMasteryData UnitEnergyAndRegenerationNotHeroes = new(
|
||||||
|
Id: "UnitEnergyAndRegenerationNotHeroes",
|
||||||
|
Name: "Unit Energy and Regeneration (not heroes)",
|
||||||
|
Levels: ["+5% unit max energy and regeneration", "-3% unit ability cooldown", "+5% unit max energy and regeneration", "-3% unit ability cooldown", "+10% unit max energy and regeneration, -5% unit ability cooldown"],
|
||||||
|
AvailableSystems: ["Eden's Heart", "Crystallic Genesis", "Pleiades Cluster", "Ivory Fields", "Heigen Stream", "Gamma Swarm", "Helios Crossing", "Epsilon Arc", "Orion's Gate"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["maxUnitEnergyAndRegen"] = 0.2,
|
||||||
|
["unitAbilityCooldown"] = -0.11,
|
||||||
|
},
|
||||||
|
Body: "I don't feel like there are good enough abilities that I would want to use this. I am sure this will rapidly change as more units get added to the roster. Perhaps that caster unit with [[Galavax]] is good.\nSome of the hotkeys on mercs are kind of bad, which is understandable given the sheer amount of variety. Perhaps I am also downplaying some of them, and tweaking the hotkeys will make them more friendly.\n\nI would ignore this for now, unless there is a very powerful caster unit added to the game. Like anything with summons via energy spending and cooldowns.");
|
||||||
|
|
||||||
|
public static readonly SystemMasteryData UnitHealthAndShields = new(
|
||||||
|
Id: "UnitHealthAndShields",
|
||||||
|
Name: "Unit Health and Shields",
|
||||||
|
Levels: ["+1% Unit health and shields", "+1% Unit health and shields", "+1% Unit health and shields", "+1% Unit health and shields", "+1 unit health and shields regeneration rate"],
|
||||||
|
AvailableSystems: ["Ashes of Revenge", "Andus Reach", "Forge of Souls", "Typhon's Call", "Neera's Gift", "Ivory Steel", "Void Star", "Burning Cairn"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["unitHealthAndShields"] = 0.04,
|
||||||
|
["unitHealthAndShieldRegen"] = 1,
|
||||||
|
},
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static readonly SystemMasteryData UnitVisionAndAttackRange = new(
|
||||||
|
Id: "UnitVisionAndAttackRange",
|
||||||
|
Name: "Unit Vision and Attack Range",
|
||||||
|
Levels: ["+0.5 unit vision range", "+0.5 unit vision range", "+0.5 unit vision range", "+0.5 unit vision range", "+0.1 unit attack range"],
|
||||||
|
AvailableSystems: ["Quartzalian Berth", "Igneous Cascade", "Noxious Energy", "Artemis Grotto", "Leviathan's Wake", "Galactic Zenith", "Sigma Vortex", "Zeta Horizon"],
|
||||||
|
Stats: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["unitVisionRange"] = 2,
|
||||||
|
["unitAttackRange"] = 0.1,
|
||||||
|
},
|
||||||
|
Body: "");
|
||||||
|
|
||||||
|
public static IReadOnlyList<SystemMasteryData> All { get; } =
|
||||||
|
[
|
||||||
|
BuildingHealthAndShields,
|
||||||
|
HeroDamage,
|
||||||
|
HeroHealthAndShields,
|
||||||
|
MaxSupply,
|
||||||
|
ProductionSpeedForBuildings,
|
||||||
|
StartingTopbarEnergy,
|
||||||
|
TurretDamage,
|
||||||
|
UnitEnergyAndRegenerationNotHeroes,
|
||||||
|
UnitHealthAndShields,
|
||||||
|
UnitVisionAndAttackRange,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, SystemMasteryData> ByName { get; } =
|
||||||
|
All.ToDictionary(static mastery => mastery.Name, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ZeroSpace.Model;
|
||||||
|
|
||||||
|
public sealed record SystemMasteryData(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
IReadOnlyList<string> AvailableSystems,
|
||||||
|
IReadOnlyDictionary<string, double> Stats,
|
||||||
|
string Body);
|
||||||
@@ -171,27 +171,6 @@ public static class Units
|
|||||||
BuildAtSameTime: 3,
|
BuildAtSameTime: 3,
|
||||||
Limit: null);
|
Limit: null);
|
||||||
|
|
||||||
public static readonly UnitData Harbinger = new(
|
|
||||||
Id: "Harbinger",
|
|
||||||
Name: "Harbinger",
|
|
||||||
Hexite: 75,
|
|
||||||
Flux: 50,
|
|
||||||
Supply: 4,
|
|
||||||
ProductionTime: 35,
|
|
||||||
Health: 400,
|
|
||||||
Energy: 0,
|
|
||||||
Shields: 0,
|
|
||||||
ArmorRating: 1,
|
|
||||||
MovementSpeed: 475,
|
|
||||||
DamagePerSecond: 14,
|
|
||||||
AttackRange: 6,
|
|
||||||
Attributes: ["Biological", "Medium Armor", "Bonus Damage vs Heavy Armor"],
|
|
||||||
Tier: 2,
|
|
||||||
Faction: "Grell",
|
|
||||||
Hotkey: "W",
|
|
||||||
BuildAtSameTime: 1,
|
|
||||||
Limit: null);
|
|
||||||
|
|
||||||
public static readonly UnitData Mandragora = new(
|
public static readonly UnitData Mandragora = new(
|
||||||
Id: "Mandragora",
|
Id: "Mandragora",
|
||||||
Name: "Mandragora",
|
Name: "Mandragora",
|
||||||
@@ -276,6 +255,300 @@ public static class Units
|
|||||||
BuildAtSameTime: 1,
|
BuildAtSameTime: 1,
|
||||||
Limit: null);
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData ScoutingDrone = new(
|
||||||
|
Id: "ScoutingDrone",
|
||||||
|
Name: "Scouting Drone",
|
||||||
|
Hexite: 25,
|
||||||
|
Flux: 0,
|
||||||
|
Supply: 0,
|
||||||
|
ProductionTime: 10,
|
||||||
|
Health: 75,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 0,
|
||||||
|
MovementSpeed: 650,
|
||||||
|
DamagePerSecond: 0,
|
||||||
|
AttackRange: 0,
|
||||||
|
Attributes: ["Light Armor"],
|
||||||
|
Tier: 0,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData XolHarvester = new(
|
||||||
|
Id: "XolHarvester",
|
||||||
|
Name: "Xol Harvester",
|
||||||
|
Hexite: 175,
|
||||||
|
Flux: 0,
|
||||||
|
Supply: 0,
|
||||||
|
ProductionTime: 30,
|
||||||
|
Health: 400,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 0,
|
||||||
|
MovementSpeed: 400,
|
||||||
|
DamagePerSecond: 0,
|
||||||
|
AttackRange: 0,
|
||||||
|
Attributes: ["Medium Armor", "Harvests Flux", "Respawns 60 seconds after death"],
|
||||||
|
Tier: 0,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Roller = new(
|
||||||
|
Id: "Roller",
|
||||||
|
Name: "Roller",
|
||||||
|
Hexite: 25,
|
||||||
|
Flux: 0,
|
||||||
|
Supply: 1,
|
||||||
|
ProductionTime: 24,
|
||||||
|
Health: 80,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 0,
|
||||||
|
MovementSpeed: 375,
|
||||||
|
DamagePerSecond: 10,
|
||||||
|
AttackRange: 2,
|
||||||
|
Attributes: ["Light Armor"],
|
||||||
|
Tier: 1,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Spectron = new(
|
||||||
|
Id: "Spectron",
|
||||||
|
Name: "Spectron",
|
||||||
|
Hexite: 25,
|
||||||
|
Flux: 50,
|
||||||
|
Supply: 2,
|
||||||
|
ProductionTime: 5,
|
||||||
|
Health: 120,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 0,
|
||||||
|
MovementSpeed: 600,
|
||||||
|
DamagePerSecond: 14,
|
||||||
|
AttackRange: 10,
|
||||||
|
Attributes: ["Light Armor", "Bonus Damage vs Heavy Armor"],
|
||||||
|
Tier: 1,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Xyther = new(
|
||||||
|
Id: "Xyther",
|
||||||
|
Name: "Xyther",
|
||||||
|
Hexite: 75,
|
||||||
|
Flux: 0,
|
||||||
|
Supply: 2,
|
||||||
|
ProductionTime: 30,
|
||||||
|
Health: 150,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 100,
|
||||||
|
ArmorRating: 0,
|
||||||
|
MovementSpeed: 540,
|
||||||
|
DamagePerSecond: 12,
|
||||||
|
AttackRange: 1,
|
||||||
|
Attributes: ["Medium Armor", "Rebuilds after being destroyed"],
|
||||||
|
Tier: 1,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Defiant = new(
|
||||||
|
Id: "Defiant",
|
||||||
|
Name: "Defiant",
|
||||||
|
Hexite: 150,
|
||||||
|
Flux: 50,
|
||||||
|
Supply: 4,
|
||||||
|
ProductionTime: 16,
|
||||||
|
Health: 500,
|
||||||
|
Energy: 6,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 2,
|
||||||
|
MovementSpeed: 575,
|
||||||
|
DamagePerSecond: 29,
|
||||||
|
AttackRange: 2,
|
||||||
|
Attributes: ["Heavy Armor", "Bonus Damage vs Light Armor"],
|
||||||
|
Tier: 2,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Echo = new(
|
||||||
|
Id: "Echo",
|
||||||
|
Name: "Echo",
|
||||||
|
Hexite: 50,
|
||||||
|
Flux: 50,
|
||||||
|
Supply: 2,
|
||||||
|
ProductionTime: 16,
|
||||||
|
Health: 125,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 0,
|
||||||
|
MovementSpeed: 800,
|
||||||
|
DamagePerSecond: 7,
|
||||||
|
AttackRange: 5,
|
||||||
|
Attributes: ["Medium Armor", "Bonus Damage vs Medium Armor"],
|
||||||
|
Tier: 2,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Havoc = new(
|
||||||
|
Id: "Havoc",
|
||||||
|
Name: "Havoc",
|
||||||
|
Hexite: 200,
|
||||||
|
Flux: 200,
|
||||||
|
Supply: 4,
|
||||||
|
ProductionTime: 0,
|
||||||
|
Health: 400,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 1,
|
||||||
|
MovementSpeed: 900,
|
||||||
|
DamagePerSecond: 0,
|
||||||
|
AttackRange: 0,
|
||||||
|
Attributes: ["Medium Armor", "Transforms from Roller"],
|
||||||
|
Tier: 2,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Synapse = new(
|
||||||
|
Id: "Synapse",
|
||||||
|
Name: "Synapse",
|
||||||
|
Hexite: 50,
|
||||||
|
Flux: 150,
|
||||||
|
Supply: 4,
|
||||||
|
ProductionTime: 16,
|
||||||
|
Health: 250,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 0,
|
||||||
|
MovementSpeed: 675,
|
||||||
|
DamagePerSecond: 17,
|
||||||
|
AttackRange: 6,
|
||||||
|
Attributes: ["Light Armor"],
|
||||||
|
Tier: 2,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Aegis = new(
|
||||||
|
Id: "Aegis",
|
||||||
|
Name: "Aegis",
|
||||||
|
Hexite: 275,
|
||||||
|
Flux: 100,
|
||||||
|
Supply: 6,
|
||||||
|
ProductionTime: 5,
|
||||||
|
Health: 600,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 100,
|
||||||
|
ArmorRating: 2,
|
||||||
|
MovementSpeed: 540,
|
||||||
|
DamagePerSecond: 11,
|
||||||
|
AttackRange: 6,
|
||||||
|
Attributes: ["Heavy Armor"],
|
||||||
|
Tier: 3,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Cataclyst = new(
|
||||||
|
Id: "Cataclyst",
|
||||||
|
Name: "Cataclyst",
|
||||||
|
Hexite: 0,
|
||||||
|
Flux: 0,
|
||||||
|
Supply: 15,
|
||||||
|
ProductionTime: 50,
|
||||||
|
Health: 1000,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 1,
|
||||||
|
MovementSpeed: 350,
|
||||||
|
DamagePerSecond: 60,
|
||||||
|
AttackRange: 12,
|
||||||
|
Attributes: ["Heavy Armor"],
|
||||||
|
Tier: 3,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Eradicator = new(
|
||||||
|
Id: "Eradicator",
|
||||||
|
Name: "Eradicator",
|
||||||
|
Hexite: 125,
|
||||||
|
Flux: 250,
|
||||||
|
Supply: 6,
|
||||||
|
ProductionTime: 24,
|
||||||
|
Health: 1000,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 0,
|
||||||
|
ArmorRating: 1,
|
||||||
|
MovementSpeed: 400,
|
||||||
|
DamagePerSecond: 7,
|
||||||
|
AttackRange: 16,
|
||||||
|
Attributes: ["Heavy Armor", "Bonus Damage vs Heavy Armor"],
|
||||||
|
Tier: 3,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Gravitron = new(
|
||||||
|
Id: "Gravitron",
|
||||||
|
Name: "Gravitron",
|
||||||
|
Hexite: 125,
|
||||||
|
Flux: 100,
|
||||||
|
Supply: 3,
|
||||||
|
ProductionTime: 24,
|
||||||
|
Health: 200,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 200,
|
||||||
|
ArmorRating: 0,
|
||||||
|
MovementSpeed: 400,
|
||||||
|
DamagePerSecond: 10,
|
||||||
|
AttackRange: 8,
|
||||||
|
Attributes: ["Medium Armor"],
|
||||||
|
Tier: 3,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
|
public static readonly UnitData Paradox = new(
|
||||||
|
Id: "Paradox",
|
||||||
|
Name: "Paradox",
|
||||||
|
Hexite: 0,
|
||||||
|
Flux: 0,
|
||||||
|
Supply: 10,
|
||||||
|
ProductionTime: 50,
|
||||||
|
Health: 750,
|
||||||
|
Energy: 0,
|
||||||
|
Shields: 750,
|
||||||
|
ArmorRating: 0,
|
||||||
|
MovementSpeed: 450,
|
||||||
|
DamagePerSecond: 14,
|
||||||
|
AttackRange: 13,
|
||||||
|
Attributes: ["Heavy Armor"],
|
||||||
|
Tier: 4,
|
||||||
|
Faction: "Xol",
|
||||||
|
Hotkey: null,
|
||||||
|
BuildAtSameTime: 1,
|
||||||
|
Limit: null);
|
||||||
|
|
||||||
public static IReadOnlyList<UnitData> All { get; } =
|
public static IReadOnlyList<UnitData> All { get; } =
|
||||||
[
|
[
|
||||||
CorsairRaiders,
|
CorsairRaiders,
|
||||||
@@ -286,10 +559,23 @@ public static class Units
|
|||||||
Gatherer,
|
Gatherer,
|
||||||
BroodGuard,
|
BroodGuard,
|
||||||
Stinger,
|
Stinger,
|
||||||
Harbinger,
|
|
||||||
Mandragora,
|
Mandragora,
|
||||||
Behemoth,
|
Behemoth,
|
||||||
Reaver,
|
Reaver,
|
||||||
Weaver,
|
Weaver,
|
||||||
|
ScoutingDrone,
|
||||||
|
XolHarvester,
|
||||||
|
Roller,
|
||||||
|
Spectron,
|
||||||
|
Xyther,
|
||||||
|
Defiant,
|
||||||
|
Echo,
|
||||||
|
Havoc,
|
||||||
|
Synapse,
|
||||||
|
Aegis,
|
||||||
|
Cataclyst,
|
||||||
|
Eradicator,
|
||||||
|
Gravitron,
|
||||||
|
Paradox,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
<ReleaseGuard RouteData="@routeData"/>
|
||||||
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
|
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
|
||||||
</Found>
|
</Found>
|
||||||
</Router>
|
</Router>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@using Markdig
|
||||||
|
@using ZeroSpace.Web.Services
|
||||||
|
@((MarkupString)Markdig.Markdown.ToHtml(ZeroSpaceLinks.ResolveWikiLinks(Markdown), Pipeline, null))
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().Build();
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string Markdown { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
@* Route guard: unreleased pages (see SiteMap) bounce to the galaxy map. *@
|
||||||
|
@if (_released)
|
||||||
|
{
|
||||||
|
<RouteView RouteData="@RouteData" DefaultLayout="@typeof(MainLayout)"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public RouteData RouteData { get; set; } = default!;
|
||||||
|
|
||||||
|
private bool _released;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_released = SiteMap.IsReleased(RouteData.PageType);
|
||||||
|
if (!_released)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/galaxy", replace: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
@using Telerik.Blazor.Components
|
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="sidebar">
|
<div class="sidebar @(_isNavCollapsed ? "collapsed" : "")">
|
||||||
<NavMenu/>
|
<NavMenu IsCollapsed="_isNavCollapsed" IsCollapsedChanged="OnNavCollapsedChanged" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<article class="content px-4">
|
<article class="content px-4">
|
||||||
<TelerikRootComponent>
|
@Body
|
||||||
@Body
|
|
||||||
</TelerikRootComponent>
|
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _isNavCollapsed = false;
|
||||||
|
|
||||||
|
private void OnNavCollapsedChanged(bool isCollapsed)
|
||||||
|
{
|
||||||
|
_isNavCollapsed = isCollapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,21 +2,32 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #080c14;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 15% 20%, rgba(56, 189, 248, 0.04) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 85% 80%, rgba(16, 185, 129, 0.03) 0%, transparent 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background-image: linear-gradient(180deg, #0d1117 0%, #161b22 70%);
|
background-color: #0b0f19;
|
||||||
|
background-image: linear-gradient(180deg, #0d131f 0%, #080c14 100%);
|
||||||
|
border-right: 1px solid rgba(56, 189, 248, 0.12);
|
||||||
|
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row {
|
.top-row {
|
||||||
background-color: #f7f7f7;
|
background-color: rgba(13, 19, 31, 0.8);
|
||||||
border-bottom: 1px solid #d6d5d5;
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
height: 3.5rem;
|
height: 3.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -25,10 +36,12 @@ main {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
color: #38bdf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
color: #7dd3fc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row ::deep a:first-child {
|
.top-row ::deep a:first-child {
|
||||||
@@ -56,6 +69,12 @@ main {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row {
|
.top-row {
|
||||||
@@ -70,8 +89,10 @@ main {
|
|||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row, article {
|
article {
|
||||||
padding-left: 2rem !important;
|
padding-left: 2rem !important;
|
||||||
padding-right: 1.5rem !important;
|
padding-right: 2rem !important;
|
||||||
|
padding-top: 1.5rem !important;
|
||||||
|
padding-bottom: 2.5rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,110 @@
|
|||||||
<div class="top-row ps-3 navbar navbar-dark">
|
<div class="nav-wrapper d-flex flex-column h-100 @(IsCollapsed ? "is-collapsed" : "")">
|
||||||
<div class="container-fluid">
|
<div class="top-row navbar navbar-dark d-flex align-items-center @(IsCollapsed ? "justify-content-center px-1" : "justify-content-between px-3")">
|
||||||
<a class="navbar-brand" href="">ZeroSpace</a>
|
@if (!IsCollapsed)
|
||||||
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
|
{
|
||||||
<span class="navbar-toggler-icon"></span>
|
<a class="navbar-brand d-flex align-items-center me-0" href="galaxy" title="ZeroSpace Database & Command Hub">
|
||||||
</button>
|
<span class="brand-glow me-2">✦</span>
|
||||||
</div>
|
<span class="brand-text">ZeroSpace</span>
|
||||||
</div>
|
</a>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<button type="button" class="btn btn-sm btn-nav-toggle nav-toggle-btn"
|
||||||
|
@onclick="ToggleCollapse"
|
||||||
|
title="Collapse navigation"
|
||||||
|
aria-label="Collapse navigation">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5M10 8a.5.5 0 0 1-.5.5H4.207l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L4.207 7.5H9.5a.5.5 0 0 1 .5.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleMobileMenu">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center justify-content-center gap-1 w-100">
|
||||||
|
<a class="navbar-brand d-flex align-items-center m-0 p-0" href="galaxy" title="ZeroSpace Database & Command Hub">
|
||||||
|
<span class="brand-glow">✦</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-nav-toggle nav-toggle-btn"
|
||||||
|
@onclick="ToggleCollapse"
|
||||||
|
title="Expand navigation"
|
||||||
|
aria-label="Expand navigation">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M3.5 15a.5.5 0 0 0 .5-.5v-13a.5.5 0 0 0-1 0v13a.5.5 0 0 0 .5.5M6 8a.5.5 0 0 0 .5.5h5.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L12.293 7.5H6.5A.5.5 0 0 0 6 8"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
|
<div class="@NavMenuCssClass nav-scrollable flex-grow-1" @onclick="OnNavMenuClick">
|
||||||
<nav class="nav flex-column">
|
<nav class="nav flex-column">
|
||||||
<div class="nav-item px-3">
|
@foreach (var entry in SiteMap.NavEntries)
|
||||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
{
|
||||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
<div class="nav-item @(IsCollapsed ? "px-1" : "px-3")">
|
||||||
</NavLink>
|
<NavLink class="nav-link" href="@entry.Href" Match="@entry.Match" title="@entry.Title">
|
||||||
|
<span class="@entry.IconCss" aria-hidden="true"></span>
|
||||||
|
@if (!IsCollapsed)
|
||||||
|
{
|
||||||
|
<span class="nav-text">@entry.Title</span>
|
||||||
|
@if (entry.Badge is not null)
|
||||||
|
{
|
||||||
|
<span class="badge badge-nav-live ms-auto">@entry.Badge</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="units">
|
<div class="sidebar-footer p-2 nav-toggle-btn">
|
||||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Units
|
<button type="button" class="btn btn-nav-toggle-footer w-100 d-flex align-items-center justify-content-center"
|
||||||
</NavLink>
|
@onclick="ToggleCollapse"
|
||||||
|
title="@(IsCollapsed ? "Expand sidebar" : "Collapse sidebar")"
|
||||||
|
aria-label="@(IsCollapsed ? "Expand sidebar" : "Collapse sidebar")">
|
||||||
|
@if (IsCollapsed)
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M3.5 15a.5.5 0 0 0 .5-.5v-13a.5.5 0 0 0-1 0v13a.5.5 0 0 0 .5.5M6 8a.5.5 0 0 0 .5.5h5.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L12.293 7.5H6.5A.5.5 0 0 0 6 8"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" class="me-2">
|
||||||
|
<path fill-rule="evenodd" d="M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5M10 8a.5.5 0 0 1-.5.5H4.207l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L4.207 7.5H9.5a.5.5 0 0 1 .5.5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="fs-7 text-uppercase fw-semibold">Collapse</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool collapseNavMenu = true;
|
[Parameter]
|
||||||
|
public bool IsCollapsed { get; set; } = false;
|
||||||
|
|
||||||
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
|
[Parameter]
|
||||||
|
public EventCallback<bool> IsCollapsedChanged { get; set; }
|
||||||
|
|
||||||
private void ToggleNavMenu()
|
private bool _collapseMobileNav = true;
|
||||||
|
|
||||||
|
private string? NavMenuCssClass => _collapseMobileNav ? "collapse" : null;
|
||||||
|
|
||||||
|
private async Task ToggleCollapse()
|
||||||
{
|
{
|
||||||
collapseNavMenu = !collapseNavMenu;
|
IsCollapsed = !IsCollapsed;
|
||||||
|
await IsCollapsedChanged.InvokeAsync(IsCollapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ToggleMobileMenu()
|
||||||
|
{
|
||||||
|
_collapseMobileNav = !_collapseMobileNav;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNavMenuClick()
|
||||||
|
{
|
||||||
|
_collapseMobileNav = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,44 @@
|
|||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(56, 189, 248, 0.1);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row {
|
.top-row {
|
||||||
min-height: 3.5rem;
|
min-height: 3.75rem;
|
||||||
background-color: rgba(0,0,0,0.4);
|
background: linear-gradient(180deg, rgba(13, 19, 31, 0.95) 0%, rgba(9, 13, 22, 0.95) 100%);
|
||||||
|
border-bottom: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-size: 1.1rem;
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-glow {
|
||||||
|
color: #38bdf8;
|
||||||
|
text-shadow: 0 0 10px rgba(56, 189, 248, 0.8);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
background: linear-gradient(135deg, #ffffff 30%, #38bdf8 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-nav-live {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(56, 189, 248, 0.2);
|
||||||
|
color: #38bdf8;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||||
|
box-shadow: 0 0 6px rgba(56, 189, 248, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bi {
|
.bi {
|
||||||
@@ -18,24 +48,42 @@
|
|||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
top: -1px;
|
top: -1px;
|
||||||
background-size: cover;
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bi-house-door-fill-nav-menu {
|
.bi-house-door-fill-nav-menu {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%2394a3b8' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
.bi-plus-square-fill-nav-menu {
|
.bi-galaxy-nav-menu {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%2338bdf8' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='2.5' fill='%2338bdf8'/%3E%3Ccircle cx='2.5' cy='4' r='1.5' fill='%2338bdf8'/%3E%3Ccircle cx='13.5' cy='5' r='1.5' fill='%2338bdf8'/%3E%3Ccircle cx='4' cy='13' r='1.5' fill='%2338bdf8'/%3E%3Ccircle cx='12' cy='12' r='1.5' fill='%2338bdf8'/%3E%3Cpath stroke='%2338bdf8' stroke-width='1.2' stroke-linecap='round' d='M2.5 4L8 8m0 0l5.5-3M8 8l-4 5m4-5l4 4' opacity='.65'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
.bi-list-nested-nav-menu {
|
.bi-star-systems-nav-menu {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23f59e0b' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3.5' fill='%23f59e0b'/%3E%3Cpath stroke='%23f59e0b' stroke-width='1.2' stroke-linecap='round' d='M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15M3 3l1.8 1.8M11.2 11.2l1.8 1.8M3 13l1.8-1.8M11.2 4.8l1.8-1.8' opacity='.75'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi-masteries-nav-menu {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23a78bfa' viewBox='0 0 16 16'%3E%3Cpath d='M8 0.5l2.2 4.6 5 .7-3.6 3.6.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.6 5-.7L8 0.5z' fill='%23a78bfa'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi-units-nav-menu {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%2310b981' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' stroke='%2310b981' stroke-width='1.3' fill='none'/%3E%3Ccircle cx='8' cy='8' r='2' fill='%2310b981'/%3E%3Cpath stroke='%2310b981' stroke-width='1.3' d='M8 0v4M8 12v4M0 8h4M12 8h4'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi-items-nav-menu {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Crect x='2' y='2' width='12' height='12' rx='2.5' fill='none' stroke='%2334d399' stroke-width='1.3'/%3E%3Ccircle cx='8' cy='8' r='2' fill='%2334d399'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi-reference-nav-menu {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%2338bdf8' viewBox='0 0 16 16'%3E%3Cpath d='M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811V2.828zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v10.749c-.917-.35-2.107-.691-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
font-size: 0.9rem;
|
font-size: 0.92rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:first-of-type {
|
.nav-item:first-of-type {
|
||||||
@@ -47,37 +95,125 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item ::deep a {
|
.nav-item ::deep a {
|
||||||
color: #d7d7d7;
|
color: #94a3b8;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
height: 3rem;
|
height: 2.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
line-height: 3rem;
|
line-height: 2.75rem;
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item ::deep a.active {
|
.nav-item ::deep a.active {
|
||||||
background-color: rgba(88, 166, 255, 0.2);
|
background: linear-gradient(90deg, rgba(56, 189, 248, 0.15) 0%, rgba(56, 189, 248, 0.05) 100%);
|
||||||
color: #58a6ff;
|
color: #38bdf8;
|
||||||
|
border-color: rgba(56, 189, 248, 0.35);
|
||||||
|
box-shadow: inset 0 0 12px rgba(56, 189, 248, 0.12), 0 0 8px rgba(56, 189, 248, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item ::deep a:hover {
|
.nav-item ::deep a:hover:not(.active) {
|
||||||
background-color: rgba(88, 166, 255, 0.1);
|
background-color: rgba(56, 189, 248, 0.08);
|
||||||
color: #58a6ff;
|
color: #e2e8f0;
|
||||||
|
border-color: rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-toggle {
|
||||||
|
background-color: rgba(56, 189, 248, 0.08);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||||
|
color: #38bdf8;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-toggle:hover {
|
||||||
|
background-color: rgba(56, 189, 248, 0.18);
|
||||||
|
border-color: rgba(56, 189, 248, 0.5);
|
||||||
|
color: #7dd3fc;
|
||||||
|
box-shadow: 0 0 10px rgba(56, 189, 248, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-toggle-footer {
|
||||||
|
background-color: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
|
color: #94a3b8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-toggle-footer:hover {
|
||||||
|
background-color: rgba(56, 189, 248, 0.12);
|
||||||
|
border-color: rgba(56, 189, 248, 0.35);
|
||||||
|
color: #38bdf8;
|
||||||
|
box-shadow: 0 0 8px rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
border-top: 1px solid rgba(56, 189, 248, 0.12);
|
||||||
|
background: rgba(8, 12, 20, 0.5);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapsed .nav-item {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapsed .nav-item ::deep a {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapsed .bi {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapsed .sidebar-footer {
|
||||||
|
padding: 0.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 641px) {
|
@media (min-width: 641px) {
|
||||||
|
.nav-toggle-btn {
|
||||||
|
display: inline-flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse {
|
.collapse {
|
||||||
/* Never collapse the sidebar for wide screens */
|
/* Never collapse the sidebar for wide screens */
|
||||||
display: block;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-scrollable {
|
.nav-scrollable {
|
||||||
/* Allow sidebar to scroll for tall menus */
|
/* Allow sidebar to scroll for tall menus */
|
||||||
height: calc(100vh - 3.5rem);
|
height: calc(100vh - 3.75rem - 3.25rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640.98px) {
|
||||||
|
.nav-toggle-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggler {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,760 @@
|
|||||||
|
@page "/galaxy"
|
||||||
|
@page "/"
|
||||||
|
@using System.Globalization
|
||||||
|
@using System.Text.RegularExpressions
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime Js
|
||||||
|
@inject IGalaxyMapExportSerializer ExportSerializer
|
||||||
|
|
||||||
|
<PageTitle>Galaxy Map</PageTitle>
|
||||||
|
|
||||||
|
<div class="galaxy-page">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-1 text-light fw-bold">Galaxy Map</h1>
|
||||||
|
<p class="text-secondary mb-0 small">Interactive tactical star chart displaying all planetary systems and
|
||||||
|
hyperlane routes.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<div class="map-legend d-flex flex-wrap align-items-center gap-3 px-3 py-2 rounded">
|
||||||
|
<div class="legend-group d-flex align-items-center gap-2">
|
||||||
|
<span class="legend-header text-dim fs-8 text-uppercase">Faction:</span>
|
||||||
|
<div class="legend-item d-flex align-items-center gap-1" title="The Eden Accord (Green)">
|
||||||
|
<span class="legend-dot dot-eden"></span>
|
||||||
|
<span class="legend-text">Eden Accord</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item d-flex align-items-center gap-1" title="The Keeper Council (Blue)">
|
||||||
|
<span class="legend-dot dot-keeper"></span>
|
||||||
|
<span class="legend-text">Keeper Council</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item d-flex align-items-center gap-1" title="The Obsidian Order (Red)">
|
||||||
|
<span class="legend-dot dot-obsidian"></span>
|
||||||
|
<span class="legend-text">Obsidian Order</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="map-stats text-secondary small px-3 py-2 rounded">
|
||||||
|
<span><strong>@_matchingCount</strong> / <strong>@_systems.Count</strong> Systems</span>
|
||||||
|
<span class="mx-1">•</span>
|
||||||
|
<span><strong>@_connections.Count</strong> Hyperlanes</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-scifi-outline d-inline-flex align-items-center gap-2 px-3 py-2"
|
||||||
|
@onclick="DownloadGalaxyJsonAsync"
|
||||||
|
disabled="@_isExporting"
|
||||||
|
title="Download galaxy map data as JSON">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||||
|
<path
|
||||||
|
d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||||
|
</svg>
|
||||||
|
<span>@(_isExporting ? "Exporting..." : "Download JSON")</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Control Bar -->
|
||||||
|
<div class="filter-bar zs-glass-card p-3 mb-3 d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<div class="d-flex align-items-center gap-2 filter-item">
|
||||||
|
<label class="filter-label text-dim small fw-semibold text-uppercase">System Type:</label>
|
||||||
|
<select class="form-select form-select-sm scifi-select" @bind="_selectedCategory"
|
||||||
|
@bind:after="ApplyFilters">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="@StarSystemCategory.HomeworldSystem">Homeworld System</option>
|
||||||
|
<option value="@StarSystemCategory.CoreSystem">Core System</option>
|
||||||
|
<option value="@StarSystemCategory.SideSystem">Side System</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 filter-item">
|
||||||
|
<label class="filter-label text-dim small fw-semibold text-uppercase">Mastery:</label>
|
||||||
|
<select class="form-select form-select-sm scifi-select" @bind="_selectedMastery" @bind:after="ApplyFilters">
|
||||||
|
<option value="">All Masteries</option>
|
||||||
|
<option value="__ANY__">Any Mastery</option>
|
||||||
|
@foreach (var mastery in _availableMasteries)
|
||||||
|
{
|
||||||
|
<option value="@mastery">@mastery</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 filter-item">
|
||||||
|
<label class="filter-label text-dim small fw-semibold text-uppercase">Special Yields:</label>
|
||||||
|
<select class="form-select form-select-sm scifi-select" @bind="_selectedYield" @bind:after="ApplyFilters">
|
||||||
|
<option value="">All Yields</option>
|
||||||
|
<option value="ANY">Any Special Yield</option>
|
||||||
|
<option value="Influence">Higher Influence</option>
|
||||||
|
<option value="Credits">Higher Credits</option>
|
||||||
|
<option value="Energy">Higher Energy</option>
|
||||||
|
<option value="Items">Higher Items (Currently, items are only attainable in shops)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (HasActiveFilters)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-scifi-outline py-1 px-3 ms-auto" @onclick="ResetFilters">
|
||||||
|
<span>Reset Filters</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="galaxy-layout">
|
||||||
|
<div class="galaxy-map-container" @onmouseleave="OnContainerMouseLeave">
|
||||||
|
<svg viewBox="0 0 1000 1000" class="galaxy-svg" preserveAspectRatio="xMidYMid meet"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<!-- Background Grid Pattern -->
|
||||||
|
<pattern id="space-grid" width="50" height="50" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="rgba(56, 189, 248, 0.04)" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
|
||||||
|
<!-- Center Core Glow -->
|
||||||
|
<radialGradient id="galaxy-core-glow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="rgba(56, 189, 248, 0.08)"/>
|
||||||
|
<stop offset="60%" stop-color="rgba(16, 185, 129, 0.03)"/>
|
||||||
|
<stop offset="100%" stop-color="rgba(13, 17, 23, 0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Glow filters for Owners / Factions -->
|
||||||
|
<filter id="glow-eden" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="glow-keeper" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="glow-obsidian" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Map Background & Grid -->
|
||||||
|
<rect width="1000" height="1000" class="map-bg"/>
|
||||||
|
<rect width="1000" height="1000" fill="url(#space-grid)"/>
|
||||||
|
<rect width="1000" height="1000" fill="url(#galaxy-core-glow)"/>
|
||||||
|
|
||||||
|
<!-- Connection Lines (Hyperlanes) -->
|
||||||
|
<g class="hyperlanes">
|
||||||
|
@foreach (var edge in _connections)
|
||||||
|
{
|
||||||
|
var isFromMatch = _matchingSystemIds.Contains(edge.From.Id);
|
||||||
|
var isToMatch = _matchingSystemIds.Contains(edge.To.Id);
|
||||||
|
var isFilterDimmed = HasActiveFilters && (!isFromMatch || !isToMatch);
|
||||||
|
|
||||||
|
var isHoverHighlighted = _hoveredSystem is not null &&
|
||||||
|
(string.Equals(edge.From.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(edge.To.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var isHoverDimmed = _hoveredSystem is not null && !isHoverHighlighted;
|
||||||
|
|
||||||
|
var lineClass = isHoverHighlighted ? "hyperlane-line hyperlane-highlighted" :
|
||||||
|
(isHoverDimmed || isFilterDimmed) ? "hyperlane-line hyperlane-dimmed" : "hyperlane-line";
|
||||||
|
<line x1="@SvgNumber(edge.From.X)"
|
||||||
|
y1="@SvgNumber(edge.From.Y)"
|
||||||
|
x2="@SvgNumber(edge.To.X)"
|
||||||
|
y2="@SvgNumber(edge.To.Y)"
|
||||||
|
class="@lineClass"/>
|
||||||
|
}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Planetary Nodes -->
|
||||||
|
<g class="planetary-nodes">
|
||||||
|
@foreach (var system in _systems)
|
||||||
|
{
|
||||||
|
var ownerClass = GetOwnerCssClass(system.Owner);
|
||||||
|
var radius = GetNodeRadius(system.SystemCategory);
|
||||||
|
var isMatch = _matchingSystemIds.Contains(system.Id);
|
||||||
|
var isHovered = _hoveredSystem is not null && string.Equals(system.Id, _hoveredSystem.Id, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isNeighbor = _hoveredConnectedIds.Contains(system.Id);
|
||||||
|
var isDimmed = (_hoveredSystem is not null && !isHovered && !isNeighbor) || (HasActiveFilters && !isMatch);
|
||||||
|
var stateClass = isHovered ? "is-hovered" :
|
||||||
|
isNeighbor ? "is-neighbor" :
|
||||||
|
isDimmed ? "is-dimmed" :
|
||||||
|
(HasActiveFilters && isMatch ? "is-filter-matched" : "");
|
||||||
|
|
||||||
|
<g class="node-group @ownerClass @stateClass"
|
||||||
|
transform="translate(@SvgNumber(system.X), @SvgNumber(system.Y))"
|
||||||
|
@onclick="() => OnNodeClick(system.Id)"
|
||||||
|
@onmouseenter="() => OnNodeMouseEnter(system)"
|
||||||
|
@onmouseleave="() => OnNodeMouseLeave(system)"
|
||||||
|
@onfocus="() => OnNodeFocus(system)"
|
||||||
|
@onblur="OnNodeBlur"
|
||||||
|
@onkeydown="e => OnNodeKeyDown(e, system.Id)"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="@system.Name (@system.SystemCategory.ToDisplayName(), @system.Owner)">
|
||||||
|
|
||||||
|
<!-- Outer aura/glow circle -->
|
||||||
|
<circle r="@SvgNumber(radius + 4)" class="node-aura"/>
|
||||||
|
|
||||||
|
<!-- Main Node Circle -->
|
||||||
|
<circle r="@SvgNumber(radius)" class="node-circle"/>
|
||||||
|
|
||||||
|
<!-- Center core dot for homeworlds and core systems -->
|
||||||
|
@if (system.SystemCategory == StarSystemCategory.CoreSystem)
|
||||||
|
{
|
||||||
|
<circle r="@SvgNumber(radius * 0.4)" class="node-core-dot"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (system.SystemCategory == StarSystemCategory.HomeworldSystem)
|
||||||
|
{
|
||||||
|
<circle r="@SvgNumber(radius * 0.15)" class="node-core-dot"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- System Name Label -->
|
||||||
|
<text y="@SvgNumber(radius + 10)" class="node-label"
|
||||||
|
text-anchor="middle">@system.Name</text>
|
||||||
|
</g>
|
||||||
|
}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Floating Glassmorphism Hover Card -->
|
||||||
|
@if (_hoveredSystem is not null)
|
||||||
|
{
|
||||||
|
var ownerClass = GetOwnerCssClass(_hoveredSystem.Owner);
|
||||||
|
var higherYields = GetHigherYields(_hoveredSystem);
|
||||||
|
<div class="planet-hover-card @ownerClass"
|
||||||
|
style="@GetTooltipStyle(_hoveredSystem)"
|
||||||
|
role="tooltip"
|
||||||
|
aria-live="polite">
|
||||||
|
<div class="card-header-row d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="system-owner-indicator"></span>
|
||||||
|
<span class="system-title fw-bold text-light">@_hoveredSystem.Name</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="badge @CategoryBadgeClass(_hoveredSystem.SystemCategory)">@_hoveredSystem.SystemCategory.ToDisplayName()</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-details-grid">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Faction Owner</span>
|
||||||
|
<span class="badge @OwnerBadgeClass(_hoveredSystem.Owner) mt-1">@_hoveredSystem.Owner</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Occupation Buff</span>
|
||||||
|
<span
|
||||||
|
class="detail-value @(_hoveredSystem.OccupationBuff != null ? "text-buff" : "text-secondary")">
|
||||||
|
@(_hoveredSystem.OccupationBuff ?? "—")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">System Mastery</span>
|
||||||
|
<span
|
||||||
|
class="detail-value @(_hoveredSystem.SystemMastery != null ? "text-mastery" : "text-secondary")">
|
||||||
|
@(_hoveredSystem.SystemMastery ?? "—")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Yield Bonuses</span>
|
||||||
|
<div class="yield-badges d-flex flex-wrap gap-1 mt-1">
|
||||||
|
@if (higherYields.Count == 0)
|
||||||
|
{
|
||||||
|
<span class="yield-badge yield-standard">Standard</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var y in higherYields)
|
||||||
|
{
|
||||||
|
<span class="yield-badge yield-@y.ToLowerInvariant()">+@y</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<span class="detail-label">Connected Systems</span>
|
||||||
|
<span
|
||||||
|
class="badge bg-dark text-secondary border border-secondary border-opacity-25">@_hoveredSystem.ConnectedSystems.Count</span>
|
||||||
|
</div>
|
||||||
|
<div class="connected-systems-list text-secondary small mt-1">
|
||||||
|
@string.Join(" • ", _hoveredSystem.ConnectedSystems)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer-hint mt-2 pt-2 border-top border-secondary border-opacity-25 text-center">
|
||||||
|
<span class="hint-text small">Click system to view full details →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="faction-drawer zs-glass-card @(_drawerCollapsed ? "is-collapsed" : "")"
|
||||||
|
aria-label="Faction occupational bonuses">
|
||||||
|
<div class="drawer-header d-flex align-items-center justify-content-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="drawer-title text-light fw-bold mb-0">Faction Bonuses</h2>
|
||||||
|
<p class="drawer-subtitle text-secondary mb-0">Occupation buffs by faction</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-scifi-outline drawer-toggle" @onclick="ToggleDrawer"
|
||||||
|
aria-expanded="@(!_drawerCollapsed)"
|
||||||
|
title="@(_drawerCollapsed ? "Expand bonus panel" : "Collapse bonus panel")">
|
||||||
|
@(_drawerCollapsed ? "»" : "«")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!_drawerCollapsed)
|
||||||
|
{
|
||||||
|
<div class="drawer-body">
|
||||||
|
<div class="faction-picker" role="radiogroup" aria-label="Select faction">
|
||||||
|
@foreach (var owner in StarSystemOwners.All)
|
||||||
|
{
|
||||||
|
var isActive = string.Equals(owner, _selectedFaction, StringComparison.Ordinal);
|
||||||
|
<button role="radio" aria-checked="@isActive"
|
||||||
|
class="faction-pick @(isActive ? "is-active " + GetOwnerCssClass(owner) : "")"
|
||||||
|
@onclick="() => SelectFaction(owner)">
|
||||||
|
<span class="legend-dot @FactionDotClass(owner)"></span>
|
||||||
|
<span class="faction-pick-name">@ShortFactionName(owner)</span>
|
||||||
|
<span class="faction-pick-count">@FactionSystemCount(owner)</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-section">
|
||||||
|
<h3 class="drawer-section-title">Occupational bonuses</h3>
|
||||||
|
<p class="drawer-summary text-secondary">
|
||||||
|
<strong class="text-light">@FactionSystems.Count</strong> systems owned by
|
||||||
|
<strong class="text-light">@ShortFactionName(_selectedFaction)</strong>
|
||||||
|
</p>
|
||||||
|
@if (FactionBonuses.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-secondary small mb-0">No occupation buffs recorded for this faction's
|
||||||
|
systems.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="bonus-list list-unstyled mb-0">
|
||||||
|
@foreach (var bonus in FactionBonuses.OrderBy(b => b.Label))
|
||||||
|
{
|
||||||
|
<li class="bonus-row">
|
||||||
|
<span class="bonus-total">@FormatBonusTotal(bonus)</span>
|
||||||
|
<span class="bonus-label">@bonus.Label</span>
|
||||||
|
<span
|
||||||
|
class="bonus-count">@bonus.PlanetCount @(bonus.PlanetCount == 1 ? "planet" : "planets")</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-section">
|
||||||
|
<h3 class="drawer-section-title">Planets (@FactionSystems.Count)</h3>
|
||||||
|
<ul class="planet-list list-unstyled mb-0">
|
||||||
|
@foreach (var system in FactionSystems)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<button class="planet-row" @onclick="() => OnNodeClick(system.Id)"
|
||||||
|
title="View @system.Name details">
|
||||||
|
<span class="planet-name">@system.Name</span>
|
||||||
|
<span class="planet-buff">@(system.OccupationBuff ?? "—")</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
public sealed record ConnectionEdge(StarSystemData From, StarSystemData To);
|
||||||
|
|
||||||
|
private IReadOnlyList<StarSystemData> _systems = [];
|
||||||
|
private IReadOnlyList<ConnectionEdge> _connections = [];
|
||||||
|
private IReadOnlyList<string> _availableMasteries = [];
|
||||||
|
private StarSystemData? _hoveredSystem;
|
||||||
|
private readonly HashSet<string> _hoveredConnectedIds = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> _matchingSystemIds = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private StarSystemCategory? _selectedCategory;
|
||||||
|
private string _selectedMastery = string.Empty;
|
||||||
|
private string _selectedYield = string.Empty;
|
||||||
|
private int _matchingCount;
|
||||||
|
|
||||||
|
// Faction picker: one of the three factions is always selected.
|
||||||
|
private string _selectedFaction = StarSystemOwners.EdenAccord;
|
||||||
|
private bool _drawerCollapsed;
|
||||||
|
private bool _isExporting;
|
||||||
|
|
||||||
|
private bool HasActiveFilters =>
|
||||||
|
_selectedCategory.HasValue ||
|
||||||
|
!string.IsNullOrEmpty(_selectedMastery) ||
|
||||||
|
!string.IsNullOrEmpty(_selectedYield);
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_systems = StarSystems.All;
|
||||||
|
_connections = BuildUniqueConnections(_systems);
|
||||||
|
_availableMasteries = SystemMasteries.All.Select(m => m.Name).Distinct().OrderBy(m => m).ToList();
|
||||||
|
ApplyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyFilters()
|
||||||
|
{
|
||||||
|
_matchingSystemIds.Clear();
|
||||||
|
foreach (var system in _systems)
|
||||||
|
{
|
||||||
|
if (MatchesFilters(system))
|
||||||
|
{
|
||||||
|
_matchingSystemIds.Add(system.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_matchingCount = _matchingSystemIds.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool MatchesFilters(StarSystemData system)
|
||||||
|
{
|
||||||
|
if (_selectedCategory.HasValue && system.SystemCategory != _selectedCategory.Value)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_selectedMastery))
|
||||||
|
{
|
||||||
|
if (_selectedMastery == "__ANY__")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(system.SystemMastery)) return false;
|
||||||
|
}
|
||||||
|
else if (!string.Equals(system.SystemMastery, _selectedMastery, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_selectedYield))
|
||||||
|
{
|
||||||
|
if (_selectedYield == "ANY")
|
||||||
|
{
|
||||||
|
if (!system.HigherInfluenceYield && !system.HigherCreditYield && !system.HigherEnergyYield && !system.HigherChanceForItems)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_selectedYield == "Influence" && !system.HigherInfluenceYield) return false;
|
||||||
|
else if (_selectedYield == "Credits" && !system.HigherCreditYield) return false;
|
||||||
|
else if (_selectedYield == "Energy" && !system.HigherEnergyYield) return false;
|
||||||
|
else if (_selectedYield == "Items" && !system.HigherChanceForItems) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetFilters()
|
||||||
|
{
|
||||||
|
_selectedCategory = null;
|
||||||
|
_selectedMastery = string.Empty;
|
||||||
|
_selectedYield = string.Empty;
|
||||||
|
ApplyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadGalaxyJsonAsync()
|
||||||
|
{
|
||||||
|
if (_isExporting) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isExporting = true;
|
||||||
|
var exportData = GalaxyMapExportData.CreateDefault();
|
||||||
|
var json = ExportSerializer.Serialize(exportData);
|
||||||
|
await Js.InvokeVoidAsync("downloadFileFromText", "galaxy-map-data.json", "application/json", json);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- faction bonus drawer -----
|
||||||
|
|
||||||
|
public sealed record FactionBonus(string Label, bool IsPercent, double Total, int PlanetCount);
|
||||||
|
|
||||||
|
private void SelectFaction(string owner)
|
||||||
|
{
|
||||||
|
if (string.Equals(_selectedFaction, owner, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedFaction = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleDrawer() => _drawerCollapsed = !_drawerCollapsed;
|
||||||
|
|
||||||
|
private int FactionSystemCount(string owner) =>
|
||||||
|
_systems.Count(s => string.Equals(s.Owner, owner, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private IReadOnlyList<StarSystemData> FactionSystems =>
|
||||||
|
_systems
|
||||||
|
.Where(s => string.Equals(s.Owner, _selectedFaction, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(s => s.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups the selected faction's occupation buffs by bonus type and sums
|
||||||
|
/// the magnitudes, so e.g. two "+15% cooldown reduction" planets total +30%.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyList<FactionBonus> FactionBonuses
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var groups = new Dictionary<(string Label, bool IsPercent), (double Total, int Count)>();
|
||||||
|
foreach (var system in _systems)
|
||||||
|
{
|
||||||
|
if (!string.Equals(system.Owner, _selectedFaction, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(system.OccupationBuff))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (amount, isPercent, label) = ParseOccupationBuff(system.OccupationBuff);
|
||||||
|
var key = (label, isPercent);
|
||||||
|
groups.TryGetValue(key, out var current);
|
||||||
|
groups[key] = (current.Total + amount, current.Count + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
.Select(g => new FactionBonus(g.Key.Label, g.Key.IsPercent, g.Value.Total, g.Value.Count))
|
||||||
|
.OrderByDescending(b => b.PlanetCount)
|
||||||
|
.ThenBy(b => b.Label)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses buffs shaped like "+50 Starting Hexite" or "+15% cooldown reduction".</summary>
|
||||||
|
private static (double Amount, bool IsPercent, string Label) ParseOccupationBuff(string buff)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(buff.Trim(), @"^\+\s*([0-9]*\.?[0-9]+)\s*(%?)\s*(.+)$");
|
||||||
|
if (match.Success &&
|
||||||
|
double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var amount))
|
||||||
|
{
|
||||||
|
return (amount, match.Groups[2].Value == "%", match.Groups[3].Value.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (0, false, buff.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatBonusTotal(FactionBonus bonus) =>
|
||||||
|
$"+{bonus.Total.ToString("0.##", CultureInfo.InvariantCulture)}{(bonus.IsPercent ? "%" : string.Empty)}";
|
||||||
|
|
||||||
|
private static string ShortFactionName(string owner) => owner switch
|
||||||
|
{
|
||||||
|
StarSystemOwners.EdenAccord => "Eden Accord",
|
||||||
|
StarSystemOwners.KeeperCouncil => "Keeper Council",
|
||||||
|
StarSystemOwners.ObsidianOrder => "Obsidian Order",
|
||||||
|
_ => owner
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FactionDotClass(string owner) => owner switch
|
||||||
|
{
|
||||||
|
StarSystemOwners.EdenAccord => "dot-eden",
|
||||||
|
StarSystemOwners.KeeperCouncil => "dot-keeper",
|
||||||
|
StarSystemOwners.ObsidianOrder => "dot-obsidian",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IReadOnlyList<ConnectionEdge> BuildUniqueConnections(IReadOnlyList<StarSystemData> allSystems)
|
||||||
|
{
|
||||||
|
var edges = new List<ConnectionEdge>();
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var system in allSystems)
|
||||||
|
{
|
||||||
|
foreach (var targetName in system.ConnectedSystems)
|
||||||
|
{
|
||||||
|
if (!StarSystems.ByName.TryGetValue(targetName, out var target))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var edgeKey = string.Compare(system.Id, target.Id, StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
? $"{system.Id}::{target.Id}"
|
||||||
|
: $"{target.Id}::{system.Id}";
|
||||||
|
|
||||||
|
if (seen.Add(edgeKey))
|
||||||
|
{
|
||||||
|
edges.Add(new ConnectionEdge(system, target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetHoveredSystem(StarSystemData? system)
|
||||||
|
{
|
||||||
|
_hoveredSystem = system;
|
||||||
|
_hoveredConnectedIds.Clear();
|
||||||
|
if (system is not null)
|
||||||
|
{
|
||||||
|
foreach (var connName in system.ConnectedSystems)
|
||||||
|
{
|
||||||
|
if (StarSystems.ByName.TryGetValue(connName, out var target))
|
||||||
|
{
|
||||||
|
_hoveredConnectedIds.Add(target.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNodeMouseEnter(StarSystemData system)
|
||||||
|
{
|
||||||
|
SetHoveredSystem(system);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNodeMouseLeave(StarSystemData system)
|
||||||
|
{
|
||||||
|
if (_hoveredSystem?.Id == system.Id)
|
||||||
|
{
|
||||||
|
SetHoveredSystem(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNodeFocus(StarSystemData system)
|
||||||
|
{
|
||||||
|
SetHoveredSystem(system);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNodeBlur()
|
||||||
|
{
|
||||||
|
SetHoveredSystem(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnContainerMouseLeave()
|
||||||
|
{
|
||||||
|
SetHoveredSystem(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNodeKeyDown(KeyboardEventArgs e, string systemId)
|
||||||
|
{
|
||||||
|
if (e.Key is "Enter" or " ")
|
||||||
|
{
|
||||||
|
OnNodeClick(systemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNodeClick(string systemId)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo($"/StarSystem/{systemId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> GetHigherYields(StarSystemData system)
|
||||||
|
{
|
||||||
|
var yields = new List<string>(4);
|
||||||
|
if (system.HigherInfluenceYield) yields.Add("Influence");
|
||||||
|
if (system.HigherCreditYield) yields.Add("Credits");
|
||||||
|
if (system.HigherEnergyYield) yields.Add("Energy");
|
||||||
|
if (system.HigherChanceForItems) yields.Add("Items");
|
||||||
|
return yields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTooltipStyle(StarSystemData system)
|
||||||
|
{
|
||||||
|
var posX = system.X / 10.0;
|
||||||
|
var posY = system.Y / 10.0;
|
||||||
|
|
||||||
|
string transform;
|
||||||
|
|
||||||
|
if (posX > 65.0)
|
||||||
|
{
|
||||||
|
if (posY < 20.0)
|
||||||
|
{
|
||||||
|
transform = "translate(calc(-100% - 16px), 0%)";
|
||||||
|
}
|
||||||
|
else if (posY > 80.0)
|
||||||
|
{
|
||||||
|
transform = "translate(calc(-100% - 16px), -100%)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
transform = "translate(calc(-100% - 16px), -50%)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (posX < 35.0)
|
||||||
|
{
|
||||||
|
if (posY < 20.0)
|
||||||
|
{
|
||||||
|
transform = "translate(16px, 0%)";
|
||||||
|
}
|
||||||
|
else if (posY > 80.0)
|
||||||
|
{
|
||||||
|
transform = "translate(16px, -100%)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
transform = "translate(16px, -50%)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (posY > 50.0)
|
||||||
|
{
|
||||||
|
transform = "translate(-50%, calc(-100% - 16px))";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
transform = "translate(-50%, 16px)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"left: {SvgNumber(posX)}%; top: {SvgNumber(posY)}%; transform: {transform};";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOwnerCssClass(string owner) => owner switch
|
||||||
|
{
|
||||||
|
StarSystemOwners.EdenAccord => "owner-eden",
|
||||||
|
StarSystemOwners.KeeperCouncil => "owner-keeper",
|
||||||
|
StarSystemOwners.ObsidianOrder => "owner-obsidian",
|
||||||
|
_ => "owner-eden"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string CategoryBadgeClass(StarSystemCategory category) => category switch
|
||||||
|
{
|
||||||
|
StarSystemCategory.HomeworldSystem => "badge-homeworld",
|
||||||
|
StarSystemCategory.CoreSystem => "badge-core",
|
||||||
|
StarSystemCategory.SideSystem => "badge-side",
|
||||||
|
_ => "badge-side"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string OwnerBadgeClass(string owner) => owner switch
|
||||||
|
{
|
||||||
|
StarSystemOwners.EdenAccord => "badge-eden",
|
||||||
|
StarSystemOwners.KeeperCouncil => "badge-keeper",
|
||||||
|
StarSystemOwners.ObsidianOrder => "badge-obsidian",
|
||||||
|
_ => "badge-side"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static double GetNodeRadius(StarSystemCategory category) => category switch
|
||||||
|
{
|
||||||
|
StarSystemCategory.HomeworldSystem => 7.0,
|
||||||
|
StarSystemCategory.CoreSystem => 6.0,
|
||||||
|
_ => 4.5
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string SvgNumber(double value) => value.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
.galaxy-page {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-legend,
|
||||||
|
.map-stats {
|
||||||
|
background: rgba(22, 27, 34, 0.85);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-eden {
|
||||||
|
background-color: #10b981;
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-keeper {
|
||||||
|
background-color: #38bdf8;
|
||||||
|
box-shadow: 0 0 8px rgba(56, 189, 248, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-obsidian {
|
||||||
|
background-color: #ef4444;
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.galaxy-map-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #080b10;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 20px rgba(56, 189, 248, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.galaxy-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-bg {
|
||||||
|
fill: #080b10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hyperlanes (Connection Lines) */
|
||||||
|
.hyperlane-line {
|
||||||
|
stroke: #3a4d6b;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-opacity: 0.65;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke 0.2s ease, stroke-opacity 0.2s ease, stroke-width 0.2s ease, filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hyperlane-line.hyperlane-highlighted {
|
||||||
|
stroke: #38bdf8;
|
||||||
|
stroke-width: 2.75;
|
||||||
|
stroke-opacity: 1;
|
||||||
|
filter: drop-shadow(0 0 5px rgba(56, 189, 248, 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hyperlane-line.hyperlane-dimmed {
|
||||||
|
stroke-opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node Groups */
|
||||||
|
.node-group {
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-circle {
|
||||||
|
stroke-width: 1.5;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-aura {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
transition: opacity 0.2s ease, r 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-core-dot {
|
||||||
|
fill: #ffffff;
|
||||||
|
opacity: 0.9;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
fill: #8b949e;
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9);
|
||||||
|
transition: fill 0.2s ease, font-weight 0.2s ease, font-size 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faction owner colors */
|
||||||
|
.owner-eden .node-circle {
|
||||||
|
fill: #10b981;
|
||||||
|
stroke: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-eden .node-aura {
|
||||||
|
fill: #10b981;
|
||||||
|
filter: url(#glow-eden);
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-keeper .node-circle {
|
||||||
|
fill: #38bdf8;
|
||||||
|
stroke: #7dd3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-keeper .node-aura {
|
||||||
|
fill: #38bdf8;
|
||||||
|
filter: url(#glow-keeper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-obsidian .node-circle {
|
||||||
|
fill: #ef4444;
|
||||||
|
stroke: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-obsidian .node-aura {
|
||||||
|
fill: #ef4444;
|
||||||
|
filter: url(#glow-obsidian);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node State Styles */
|
||||||
|
.node-group.is-hovered .node-circle,
|
||||||
|
.node-group:focus-visible .node-circle {
|
||||||
|
stroke: #ffffff;
|
||||||
|
stroke-width: 2.75;
|
||||||
|
filter: brightness(1.35) drop-shadow(0 0 6px rgba(255, 255, 255, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-group.is-hovered .node-aura,
|
||||||
|
.node-group:focus-visible .node-aura {
|
||||||
|
opacity: 0.95;
|
||||||
|
animation: pulseAura 1.4s infinite alternate ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-group.is-hovered .node-label,
|
||||||
|
.node-group:focus-visible .node-label {
|
||||||
|
fill: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 9.5px;
|
||||||
|
text-shadow: 0 0 6px rgba(56, 189, 248, 0.9), 0 2px 4px rgba(0, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-group.is-neighbor .node-aura {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-group.is-neighbor .node-label {
|
||||||
|
fill: #f0f6fc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-group.is-neighbor .node-circle {
|
||||||
|
stroke-width: 2;
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-group.is-dimmed {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse Aura Animation */
|
||||||
|
@keyframes pulseAura {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.35);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Glassmorphism Hover Card */
|
||||||
|
.planet-hover-card {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 310px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(13, 17, 23, 0.94);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.8), 0 0 24px rgba(56, 189, 248, 0.12);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: popupFadeIn 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
transition: transform 0.12s ease-out, left 0.12s ease-out, top 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popupFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
scale: 0.96;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-owner-indicator {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faction owner themes for hover card */
|
||||||
|
.planet-hover-card.owner-eden {
|
||||||
|
border-color: rgba(16, 185, 129, 0.45);
|
||||||
|
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.8), 0 0 24px rgba(16, 185, 129, 0.18);
|
||||||
|
}
|
||||||
|
.planet-hover-card.owner-eden .system-owner-indicator {
|
||||||
|
background-color: #10b981;
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-hover-card.owner-keeper {
|
||||||
|
border-color: rgba(56, 189, 248, 0.45);
|
||||||
|
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.8), 0 0 24px rgba(56, 189, 248, 0.18);
|
||||||
|
}
|
||||||
|
.planet-hover-card.owner-keeper .system-owner-indicator {
|
||||||
|
background-color: #38bdf8;
|
||||||
|
box-shadow: 0 0 8px rgba(56, 189, 248, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-hover-card.owner-obsidian {
|
||||||
|
border-color: rgba(239, 68, 68, 0.45);
|
||||||
|
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.8), 0 0 24px rgba(239, 68, 68, 0.18);
|
||||||
|
}
|
||||||
|
.planet-hover-card.owner-obsidian .system-owner-indicator {
|
||||||
|
background-color: #ef4444;
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Details Grid */
|
||||||
|
.card-details-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: #8b949e;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-buff {
|
||||||
|
color: #34d399;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-mastery {
|
||||||
|
color: #60a5fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Yield Badges */
|
||||||
|
.yield-badges {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-influence {
|
||||||
|
background: rgba(168, 85, 247, 0.18);
|
||||||
|
color: #c084fc;
|
||||||
|
border: 1px solid rgba(168, 85, 247, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-credits {
|
||||||
|
background: rgba(234, 179, 8, 0.18);
|
||||||
|
color: #facc15;
|
||||||
|
border: 1px solid rgba(234, 179, 8, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-energy {
|
||||||
|
background: rgba(6, 182, 212, 0.18);
|
||||||
|
color: #22d3ee;
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-items {
|
||||||
|
background: rgba(16, 185, 129, 0.18);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-standard {
|
||||||
|
background: rgba(100, 116, 139, 0.15);
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid rgba(100, 116, 139, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected-systems-list {
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #8b949e;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
color: #38bdf8;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Galaxy layout with right-side faction drawer */
|
||||||
|
.galaxy-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.galaxy-layout .galaxy-map-container {
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-drawer {
|
||||||
|
width: 330px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-drawer.is-collapsed {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-subtitle {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faction picker: always exactly one selected */
|
||||||
|
.faction-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-pick {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(139, 148, 158, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #c9d1d9;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-pick:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-pick-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-pick-count {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #8b949e;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-pick.is-active {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-pick.is-active.owner-eden {
|
||||||
|
border-color: rgba(16, 185, 129, 0.7);
|
||||||
|
box-shadow: 0 0 12px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-pick.is-active.owner-keeper {
|
||||||
|
border-color: rgba(56, 189, 248, 0.7);
|
||||||
|
box-shadow: 0 0 12px rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-pick.is-active.owner-obsidian {
|
||||||
|
border-color: rgba(239, 68, 68, 0.7);
|
||||||
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawer sections */
|
||||||
|
.drawer-section-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: #8b949e;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-summary {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aggregated bonus rows */
|
||||||
|
.bonus-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(139, 148, 158, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-total {
|
||||||
|
color: #34d399;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-label {
|
||||||
|
color: #c9d1d9;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-count {
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Planet list */
|
||||||
|
.planet-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #c9d1d9;
|
||||||
|
padding: 5px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-row:hover {
|
||||||
|
background: rgba(56, 189, 248, 0.08);
|
||||||
|
border-color: rgba(56, 189, 248, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-buff {
|
||||||
|
color: #34d399;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.galaxy-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-drawer {
|
||||||
|
width: 100%;
|
||||||
|
max-height: none;
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,304 @@
|
|||||||
@page "/"
|
@using ZeroSpace.Model
|
||||||
|
|
||||||
<PageTitle>Home</PageTitle>
|
<PageTitle>ZeroSpace Database & Command Hub</PageTitle>
|
||||||
|
|
||||||
<h1>Hello, world!</h1>
|
<div class="home-container">
|
||||||
|
<!-- Hero Banner -->
|
||||||
|
<div class="zs-hero p-4 p-md-5 mb-4 position-relative overflow-hidden">
|
||||||
|
<div class="hero-glow-orb"></div>
|
||||||
|
<div class="position-relative" style="z-index: 1;">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<span class="badge badge-pill-scifi badge-side">
|
||||||
|
<span class="hero-status-pulse"></span> SYSTEM ONLINE
|
||||||
|
</span>
|
||||||
|
<span class="text-dim fs-7">ZEROSPACE INTEL ARCHIVE v1.0</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="display-6 fw-bold mb-3 hero-title">ZeroSpace Database & Command Hub</h1>
|
||||||
|
<p class="lead text-muted mb-4 hero-subtitle">
|
||||||
|
Comprehensive tactical star map diagnostics, planetary sector dossiers, doctrine masteries, and unit combat intelligence.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
<a href="/galaxy" class="btn btn-scifi-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="2.5"/>
|
||||||
|
<circle cx="2.5" cy="4" r="1.5"/>
|
||||||
|
<circle cx="13.5" cy="5" r="1.5"/>
|
||||||
|
<circle cx="4" cy="13" r="1.5"/>
|
||||||
|
<circle cx="12" cy="12" r="1.5"/>
|
||||||
|
<path stroke="currentColor" stroke-width="1.2" stroke-linecap="round" d="M2.5 4L8 8m0 0l5.5-3M8 8l-4 5m4-5l4 4" opacity=".65"/>
|
||||||
|
</svg>
|
||||||
|
<span>Launch Galaxy Map</span>
|
||||||
|
</a>
|
||||||
|
<a href="/StarSystem" class="btn btn-scifi-outline">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="3.5"/>
|
||||||
|
<path stroke="currentColor" stroke-width="1.2" stroke-linecap="round" d="M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15M3 3l1.8 1.8M11.2 11.2l1.8 1.8M3 13l1.8-1.8M11.2 4.8l1.8-1.8" opacity=".75"/>
|
||||||
|
</svg>
|
||||||
|
<span>Browse Systems</span>
|
||||||
|
</a>
|
||||||
|
<a href="/units" class="btn btn-scifi-outline">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3" fill="none"/>
|
||||||
|
<circle cx="8" cy="8" r="2"/>
|
||||||
|
<path stroke="currentColor" stroke-width="1.3" d="M8 0v4M8 12v4M0 8h4M12 8h4"/>
|
||||||
|
</svg>
|
||||||
|
<span>Unit Intel</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
Welcome to your new app.
|
<!-- Quick Stats Bar -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="zs-glass-card p-3 d-flex align-items-center gap-3 stat-card">
|
||||||
|
<div class="stat-icon-wrapper icon-cyan">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 8 8A8.01 8.01 0 0 0 8 0zm0 14.5a6.5 6.5 0 1 1 6.5-6.5 6.51 6.51 0 0 1-6.5 6.5z"/>
|
||||||
|
<circle cx="8" cy="8" r="3.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fs-4 fw-bold text-white lh-1">@totalSystemsCount</div>
|
||||||
|
<div class="text-dim fs-7 text-uppercase fw-semibold">Star Systems</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="zs-glass-card p-3 d-flex align-items-center gap-3 stat-card">
|
||||||
|
<div class="stat-icon-wrapper icon-emerald">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path stroke="currentColor" stroke-width="1.5" stroke-linecap="round" d="M2 14L8 2l6 12M4 10h8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fs-4 fw-bold text-white lh-1">@totalHyperlanesCount+</div>
|
||||||
|
<div class="text-dim fs-7 text-uppercase fw-semibold">Hyperlanes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="zs-glass-card p-3 d-flex align-items-center gap-3 stat-card">
|
||||||
|
<div class="stat-icon-wrapper icon-purple">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0.5l2.2 4.6 5 .7-3.6 3.6.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.6 5-.7L8 0.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fs-4 fw-bold text-white lh-1">@totalMasteriesCount</div>
|
||||||
|
<div class="text-dim fs-7 text-uppercase fw-semibold">Masteries</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="zs-glass-card p-3 d-flex align-items-center gap-3 stat-card">
|
||||||
|
<div class="stat-icon-wrapper icon-amber">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3" fill="none"/>
|
||||||
|
<circle cx="8" cy="8" r="2"/>
|
||||||
|
<path stroke="currentColor" stroke-width="1.3" d="M8 0v4M8 12v4M0 8h4M12 8h4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fs-4 fw-bold text-white lh-1">@totalUnitsCount</div>
|
||||||
|
<div class="text-dim fs-7 text-uppercase fw-semibold">Combat Units</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature Hub Cards (2x2 Grid) -->
|
||||||
|
<div class="section-header d-flex align-items-center mb-3">
|
||||||
|
<h2 class="h4 mb-0 text-white">Database Modules</h2>
|
||||||
|
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<!-- Card 1: Galaxy Map -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="zs-glass-card p-4 h-100 d-flex flex-column feature-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div class="feature-icon-badge icon-cyan">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="2.5"/>
|
||||||
|
<circle cx="2.5" cy="4" r="1.5"/>
|
||||||
|
<circle cx="13.5" cy="5" r="1.5"/>
|
||||||
|
<circle cx="4" cy="13" r="1.5"/>
|
||||||
|
<circle cx="12" cy="12" r="1.5"/>
|
||||||
|
<path stroke="currentColor" stroke-width="1.2" stroke-linecap="round" d="M2.5 4L8 8m0 0l5.5-3M8 8l-4 5m4-5l4 4" opacity=".65"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-pill-scifi badge-side">INTERACTIVE SVG</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h5 fw-bold text-white mb-2">Interactive Galaxy Map</h3>
|
||||||
|
<p class="text-muted fs-6 mb-4 flex-grow-1">
|
||||||
|
Visual SVG star chart rendering all 78 star systems and hyperlane connection networks. Features instant hover intel cards, planetary buff diagnostics, and direct dossier navigation.
|
||||||
|
</p>
|
||||||
|
<div class="feature-meta mb-3 p-2 rounded">
|
||||||
|
<span class="badge badge-homeworld me-1">@homeworldsCount Homeworlds</span>
|
||||||
|
<span class="badge badge-core me-1">@coreCount Core</span>
|
||||||
|
<span class="badge badge-side">@sideCount Side</span>
|
||||||
|
</div>
|
||||||
|
<a href="/galaxy" class="btn btn-scifi-primary w-100 justify-content-center">
|
||||||
|
Launch Galaxy Map
|
||||||
|
<span class="arrow-icon">→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 2: Star Systems Directory -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="zs-glass-card p-4 h-100 d-flex flex-column feature-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div class="feature-icon-badge icon-amber">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="3.5"/>
|
||||||
|
<path stroke="currentColor" stroke-width="1.2" stroke-linecap="round" d="M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15M3 3l1.8 1.8M11.2 11.2l1.8 1.8M3 13l1.8-1.8M11.2 4.8l1.8-1.8" opacity=".75"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-pill-scifi badge-core">78 DOSSIERS</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h5 fw-bold text-white mb-2">Star Systems Directory</h3>
|
||||||
|
<p class="text-muted fs-6 mb-4 flex-grow-1">
|
||||||
|
Complete catalog of planetary systems. Review sector classifications, starting hexite buffs, system masteries, item drop chances, and resource yield bonuses.
|
||||||
|
</p>
|
||||||
|
<div class="feature-meta mb-3 p-2 rounded">
|
||||||
|
<span class="badge badge-pill-scifi badge-side me-1">@buffsCount Occupation Buffs</span>
|
||||||
|
<span class="badge badge-pill-scifi badge-mastery">@higherYieldCount Yield Modifiers</span>
|
||||||
|
</div>
|
||||||
|
<a href="/StarSystem" class="btn btn-scifi-outline w-100 justify-content-center">
|
||||||
|
Browse Star Systems
|
||||||
|
<span class="arrow-icon">→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 3: System Masteries -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="zs-glass-card p-4 h-100 d-flex flex-column feature-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div class="feature-icon-badge icon-purple">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0.5l2.2 4.6 5 .7-3.6 3.6.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.6 5-.7L8 0.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-pill-scifi badge-mastery">@totalMasteriesCount DOCTRINES</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h5 fw-bold text-white mb-2">System Masteries</h3>
|
||||||
|
<p class="text-muted fs-6 mb-4 flex-grow-1">
|
||||||
|
Strategic progression doctrines offering tiered battle bonuses, economic multipliers, and global faction enhancements with system availability matrices.
|
||||||
|
</p>
|
||||||
|
<div class="feature-meta mb-3 p-2 rounded text-dim fs-7">
|
||||||
|
<span>Includes 3-Tier Multiplier Progression per Doctrine</span>
|
||||||
|
</div>
|
||||||
|
<a href="/SystemMastery" class="btn btn-scifi-outline w-100 justify-content-center">
|
||||||
|
Explore Masteries
|
||||||
|
<span class="arrow-icon">→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 4: Combat Units -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="zs-glass-card p-4 h-100 d-flex flex-column feature-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div class="feature-icon-badge icon-emerald">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3" fill="none"/>
|
||||||
|
<circle cx="8" cy="8" r="2"/>
|
||||||
|
<path stroke="currentColor" stroke-width="1.3" d="M8 0v4M8 12v4M0 8h4M12 8h4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-pill-scifi badge-homeworld">@totalUnitsCount COMBAT UNITS</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h5 fw-bold text-white mb-2">Unit Roster & Intel</h3>
|
||||||
|
<p class="text-muted fs-6 mb-4 flex-grow-1">
|
||||||
|
Tactical combat unit encyclopedia with detailed Hexite/Flux cost breakdowns, health, DPS-per-cost efficiency metrics, armor classes, and faction hotkeys.
|
||||||
|
</p>
|
||||||
|
<div class="feature-meta mb-3 p-2 rounded text-dim fs-7">
|
||||||
|
<span>Interactive Sortable & Filterable Data Grid</span>
|
||||||
|
</div>
|
||||||
|
<a href="/units" class="btn btn-scifi-outline w-100 justify-content-center">
|
||||||
|
View Unit Intel
|
||||||
|
<span class="arrow-icon">→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Galaxy Classification Breakdown -->
|
||||||
|
<div class="zs-glass-card p-4 mb-4">
|
||||||
|
<h3 class="h5 fw-bold text-white mb-3 d-flex align-items-center gap-2">
|
||||||
|
<span class="text-cyan">◈</span>
|
||||||
|
<span>Galaxy System Classification</span>
|
||||||
|
</h3>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="classification-box p-3 rounded border-homeworld">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span class="badge badge-homeworld">Homeworlds</span>
|
||||||
|
<span class="fw-bold text-emerald">@homeworldsCount systems</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Primary faction command centers with critical strategic value.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="classification-box p-3 rounded border-core">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span class="badge badge-core">Core Systems</span>
|
||||||
|
<span class="fw-bold text-amber">@coreCount systems</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">High-density central systems connecting major hyperlane corridors.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="classification-box p-3 rounded border-side">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span class="badge badge-side">Side Sectors</span>
|
||||||
|
<span class="fw-bold text-cyan">@sideCount systems</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Outer rim border systems offering specialized tactical yields.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private int totalSystemsCount;
|
||||||
|
private int homeworldsCount;
|
||||||
|
private int coreCount;
|
||||||
|
private int sideCount;
|
||||||
|
private int totalHyperlanesCount;
|
||||||
|
private int totalMasteriesCount;
|
||||||
|
private int totalUnitsCount;
|
||||||
|
private int buffsCount;
|
||||||
|
private int higherYieldCount;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
totalSystemsCount = StarSystems.All.Count;
|
||||||
|
homeworldsCount = StarSystems.All.Count(s => s.SystemCategory == StarSystemCategory.HomeworldSystem);
|
||||||
|
coreCount = StarSystems.All.Count(s => s.SystemCategory == StarSystemCategory.CoreSystem);
|
||||||
|
sideCount = StarSystems.All.Count(s => s.SystemCategory == StarSystemCategory.SideSystem);
|
||||||
|
|
||||||
|
// Calculate unique hyperlanes
|
||||||
|
var edges = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var s in StarSystems.All)
|
||||||
|
{
|
||||||
|
foreach (var connected in s.ConnectedSystems)
|
||||||
|
{
|
||||||
|
var edgeKey = string.Compare(s.Id, connected, StringComparison.OrdinalIgnoreCase) < 0
|
||||||
|
? $"{s.Id}--{connected}"
|
||||||
|
: $"{connected}--{s.Id}";
|
||||||
|
edges.Add(edgeKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalHyperlanesCount = edges.Count;
|
||||||
|
|
||||||
|
totalMasteriesCount = SystemMasteries.All.Count;
|
||||||
|
totalUnitsCount = ZeroSpace.Model.Units.All.Count;
|
||||||
|
buffsCount = StarSystems.All.Count(s => !string.IsNullOrWhiteSpace(s.OccupationBuff));
|
||||||
|
higherYieldCount = StarSystems.All.Count(s => s.HigherCreditYield || s.HigherInfluenceYield || s.HigherEnergyYield || s.HigherChanceForItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
.home-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-glow-orb {
|
||||||
|
position: absolute;
|
||||||
|
width: 350px;
|
||||||
|
height: 350px;
|
||||||
|
top: -100px;
|
||||||
|
right: -50px;
|
||||||
|
background: radial-gradient(circle, rgba(56, 189, 248, 0.18) 0%, rgba(16, 185, 129, 0.05) 50%, transparent 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(40px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
background: linear-gradient(135deg, #ffffff 40%, #7dd3fc 80%, #38bdf8 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
max-width: 750px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-status-pulse {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background-color: #38bdf8;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 6px #38bdf8;
|
||||||
|
animation: statusPulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes statusPulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.9);
|
||||||
|
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 0 6px rgba(56, 189, 248, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(0.9);
|
||||||
|
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-wrapper {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-cyan {
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
color: #38bdf8;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||||
|
box-shadow: 0 0 10px rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-emerald {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #10b981;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-amber {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #f59e0b;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
box-shadow: 0 0 10px rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-purple {
|
||||||
|
background: rgba(167, 139, 250, 0.15);
|
||||||
|
color: #a78bfa;
|
||||||
|
border: 1px solid rgba(167, 139, 250, 0.3);
|
||||||
|
box-shadow: 0 0 10px rgba(167, 139, 250, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7), 0 0 20px rgba(56, 189, 248, 0.15);
|
||||||
|
border-color: rgba(56, 189, 248, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon-badge {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-meta {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover .arrow-icon {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-box {
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-box:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-homeworld {
|
||||||
|
border-left: 3px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-core {
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-side {
|
||||||
|
border-left: 3px solid #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-cyan {
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-emerald {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-amber {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-purple {
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-dim {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-7 {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
@page "/loadout"
|
||||||
|
|
||||||
|
<PageTitle>Loadout</PageTitle>
|
||||||
|
|
||||||
|
<div class="section-header d-flex align-items-center mb-4 flex-wrap gap-2">
|
||||||
|
<h1 class="mb-0">Loadout</h1>
|
||||||
|
<div class="ms-4 d-flex align-items-center gap-2">
|
||||||
|
<span class="hero-label">Hero:</span>
|
||||||
|
@foreach (var hero in Heroes)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="btn @(hero == _hero ? "btn-primary" : "btn-outline-secondary") btn-sm"
|
||||||
|
@onclick="() => SelectHero(hero)">
|
||||||
|
@hero
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
|
||||||
|
<a href="/reference" class="btn btn-sm btn-scifi-outline ms-2">
|
||||||
|
<span>Item Reference Docs ↗</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loadout-layout">
|
||||||
|
<section class="loadout-pool" aria-label="Available items">
|
||||||
|
<label class="details-toggle">
|
||||||
|
<input type="checkbox" @bind="_showDetails" />
|
||||||
|
Show details
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h2 class="pool-heading">Faction Powers</h2>
|
||||||
|
<div class="pool-list @(_showDetails ? "" : "compact")">
|
||||||
|
@foreach (var item in FactionPowers)
|
||||||
|
{
|
||||||
|
<article class="zs-glass-card pool-card @(_showDetails ? "" : "icon-only") @(_factionSlots.Contains(item) ? "equipped" : "")"
|
||||||
|
draggable="true"
|
||||||
|
title="@item.Name"
|
||||||
|
@ondragstart="() => _drag = item"
|
||||||
|
@ondragend="() => _drag = null">
|
||||||
|
@if (item.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img class="pool-icon" src="@item.ImageUrl" alt="@item.Name" loading="lazy" draggable="false" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="pool-icon pool-icon-placeholder" aria-hidden="true">✦</div>
|
||||||
|
}
|
||||||
|
@if (_showDetails)
|
||||||
|
{
|
||||||
|
<div class="pool-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-1">
|
||||||
|
<div class="pool-name">@item.Name</div>
|
||||||
|
<a href="@item.DocUrl" class="pool-doc-link" title="View @item.Name reference doc" @onclick:stopPropagation draggable="false">
|
||||||
|
<span>Doc ↗</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@if (item.LimitedToMerc is not null)
|
||||||
|
{
|
||||||
|
<span class="merc-badge">@item.LimitedToMerc</span>
|
||||||
|
}
|
||||||
|
<ul class="pool-levels">
|
||||||
|
@foreach (var level in item.Levels)
|
||||||
|
{
|
||||||
|
<li>@level</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="pool-heading">General Items</h2>
|
||||||
|
<div class="pool-list @(_showDetails ? "" : "compact")">
|
||||||
|
@foreach (var item in GeneralItemsList)
|
||||||
|
{
|
||||||
|
<article class="zs-glass-card pool-card @(_showDetails ? "" : "icon-only") @(_generalSlots.Contains(item) ? "equipped" : "")"
|
||||||
|
draggable="true"
|
||||||
|
title="@item.Name"
|
||||||
|
@ondragstart="() => _drag = item"
|
||||||
|
@ondragend="() => _drag = null">
|
||||||
|
@if (item.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img class="pool-icon" src="@item.ImageUrl" alt="@item.Name" loading="lazy" draggable="false" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="pool-icon pool-icon-placeholder" aria-hidden="true">✦</div>
|
||||||
|
}
|
||||||
|
@if (_showDetails)
|
||||||
|
{
|
||||||
|
<div class="pool-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-1">
|
||||||
|
<div class="pool-name">@item.Name</div>
|
||||||
|
<a href="@item.DocUrl" class="pool-doc-link" title="View @item.Name reference doc" @onclick:stopPropagation draggable="false">
|
||||||
|
<span>Doc ↗</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@if (item.LimitedToMerc is not null)
|
||||||
|
{
|
||||||
|
<span class="merc-badge">@item.LimitedToMerc</span>
|
||||||
|
}
|
||||||
|
<ul class="pool-levels">
|
||||||
|
@foreach (var level in item.Levels)
|
||||||
|
{
|
||||||
|
<li>@level</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="pool-heading">Master Items</h2>
|
||||||
|
<div class="pool-list @(_showDetails ? "" : "compact")">
|
||||||
|
@foreach (var item in MasterItems)
|
||||||
|
{
|
||||||
|
<article class="zs-glass-card pool-card @(_showDetails ? "" : "icon-only") @(_masterySlots.Contains(item) ? "equipped" : "")"
|
||||||
|
draggable="true"
|
||||||
|
title="@item.Name"
|
||||||
|
@ondragstart="() => _drag = item"
|
||||||
|
@ondragend="() => _drag = null">
|
||||||
|
@if (item.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img class="pool-icon" src="@item.ImageUrl" alt="@item.Name" loading="lazy" draggable="false" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="pool-icon pool-icon-placeholder" aria-hidden="true">✦</div>
|
||||||
|
}
|
||||||
|
@if (_showDetails)
|
||||||
|
{
|
||||||
|
<div class="pool-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-1">
|
||||||
|
<div class="pool-name">@item.Name</div>
|
||||||
|
<a href="@item.DocUrl" class="pool-doc-link" title="View @item.Name reference doc" @onclick:stopPropagation draggable="false">
|
||||||
|
<span>Doc ↗</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<ul class="pool-levels">
|
||||||
|
@foreach (var level in item.Levels)
|
||||||
|
{
|
||||||
|
<li>@level</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="loadout-board" aria-label="Current loadout">
|
||||||
|
@if (_mercError is not null)
|
||||||
|
{
|
||||||
|
<div class="merc-error" role="alert">@_mercError</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2 class="pool-heading">Faction Powers <span class="slot-count">pick up to 4</span></h2>
|
||||||
|
<div class="slot-grid">
|
||||||
|
@for (var i = 0; i < _factionSlots.Length; i++)
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
<div class="slot @(IsConflict(_factionSlots[index]) ? "slot-conflict" : "") @(_factionSlots[index] is null ? "slot-empty" : "")"
|
||||||
|
@ondragover:preventDefault
|
||||||
|
@ondrop="() => DropFaction(index)">
|
||||||
|
@if (_factionSlots[index] is { } slotted)
|
||||||
|
{
|
||||||
|
<button type="button" class="slot-remove" @onclick="() => RemoveFaction(index)" aria-label="Remove @slotted.Name">×</button>
|
||||||
|
@if (slotted.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img class="slot-icon" src="@slotted.ImageUrl" alt="" draggable="false" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="slot-icon slot-icon-placeholder" aria-hidden="true">✦</div>
|
||||||
|
}
|
||||||
|
<span class="slot-name">@slotted.Name</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="slot-hint">Drop here</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="pool-heading">General Items <span class="slot-count">pick up to 3</span></h2>
|
||||||
|
<div class="slot-grid">
|
||||||
|
@for (var i = 0; i < _generalSlots.Length; i++)
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
<div class="slot @(IsConflict(_generalSlots[index]) ? "slot-conflict" : "") @(_generalSlots[index] is null ? "slot-empty" : "")"
|
||||||
|
@ondragover:preventDefault
|
||||||
|
@ondrop="() => DropGeneral(index)">
|
||||||
|
@if (_generalSlots[index] is { } slotted)
|
||||||
|
{
|
||||||
|
<button type="button" class="slot-remove" @onclick="() => RemoveGeneral(index)" aria-label="Remove @slotted.Name">×</button>
|
||||||
|
@if (slotted.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img class="slot-icon" src="@slotted.ImageUrl" alt="" draggable="false" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="slot-icon slot-icon-placeholder" aria-hidden="true">✦</div>
|
||||||
|
}
|
||||||
|
<span class="slot-name">@slotted.Name</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="slot-hint">Drop here</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="pool-heading">Master Items <span class="slot-count">pick up to 2</span></h2>
|
||||||
|
<div class="slot-grid">
|
||||||
|
@for (var i = 0; i < _masterySlots.Length; i++)
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
<div class="slot @(IsConflict(_masterySlots[index]) ? "slot-conflict" : "") @(_masterySlots[index] is null ? "slot-empty" : "")"
|
||||||
|
@ondragover:preventDefault
|
||||||
|
@ondrop="() => DropMastery(index)">
|
||||||
|
@if (_masterySlots[index] is { } slotted)
|
||||||
|
{
|
||||||
|
<button type="button" class="slot-remove" @onclick="() => RemoveMastery(index)" aria-label="Remove @slotted.Name">×</button>
|
||||||
|
@if (slotted.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img class="slot-icon" src="@slotted.ImageUrl" alt="" draggable="false" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="slot-icon slot-icon-placeholder" aria-hidden="true">✦</div>
|
||||||
|
}
|
||||||
|
<span class="slot-name">@slotted.Name</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="slot-hint">Drop here</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private sealed record PoolItem(
|
||||||
|
string Kind,
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
string? LimitedTo,
|
||||||
|
string? LimitedToMerc,
|
||||||
|
string? ImageUrl,
|
||||||
|
string DocUrl);
|
||||||
|
|
||||||
|
private static readonly string[] Heroes = ["Vynthra", "Prefect Aster"];
|
||||||
|
|
||||||
|
private string _hero = Heroes[0];
|
||||||
|
private bool _showDetails = true;
|
||||||
|
private PoolItem? _drag;
|
||||||
|
|
||||||
|
private List<PoolItem> _allFactionPowers = [];
|
||||||
|
private List<PoolItem> _allGeneralItems = [];
|
||||||
|
private List<PoolItem> _allMasterItems = [];
|
||||||
|
|
||||||
|
private PoolItem?[] _factionSlots = new PoolItem?[4];
|
||||||
|
private PoolItem?[] _generalSlots = new PoolItem?[3];
|
||||||
|
private PoolItem?[] _masterySlots = new PoolItem?[2];
|
||||||
|
|
||||||
|
private readonly HashSet<PoolItem> _mercConflicts = [];
|
||||||
|
private string? _mercError;
|
||||||
|
|
||||||
|
private IEnumerable<PoolItem> FactionPowers =>
|
||||||
|
_allFactionPowers.Where(item => item.LimitedTo is null || item.LimitedTo == _hero);
|
||||||
|
|
||||||
|
private IEnumerable<PoolItem> GeneralItemsList => _allGeneralItems;
|
||||||
|
|
||||||
|
private IEnumerable<PoolItem> MasterItems => _allMasterItems;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_allFactionPowers = FactionPowerItems.All
|
||||||
|
.Select(item => new PoolItem(
|
||||||
|
"faction",
|
||||||
|
item.Id,
|
||||||
|
item.Name,
|
||||||
|
item.Levels,
|
||||||
|
item.LimitedTo,
|
||||||
|
item.LimitedToMerc,
|
||||||
|
item.ImageFileName is null ? null : $"images/faction-power-items/{Uri.EscapeDataString(item.ImageFileName)}",
|
||||||
|
ReferenceCatalog.GetUrlFor(item.Name) ?? $"/reference/faction-item/{ReferenceCatalog.ToSlug(item.Name)}"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_allGeneralItems = GeneralItems.All
|
||||||
|
.Select(item => new PoolItem(
|
||||||
|
"general",
|
||||||
|
item.Id,
|
||||||
|
item.Name,
|
||||||
|
item.Levels,
|
||||||
|
LimitedTo: null,
|
||||||
|
item.LimitedToMerc,
|
||||||
|
item.ImageFileName is null ? null : $"images/general-items/{Uri.EscapeDataString(item.ImageFileName)}",
|
||||||
|
ReferenceCatalog.GetUrlFor(item.Name) ?? $"/reference/general-item/{ReferenceCatalog.ToSlug(item.Name)}"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_allMasterItems = PlanetaryMasteryItems.All
|
||||||
|
.Select(item => new PoolItem(
|
||||||
|
"mastery",
|
||||||
|
item.Id,
|
||||||
|
item.Name,
|
||||||
|
item.Levels,
|
||||||
|
LimitedTo: null,
|
||||||
|
LimitedToMerc: null,
|
||||||
|
item.ImageFileName is null ? null : $"images/planetary-mastery-items/{Uri.EscapeDataString(item.ImageFileName)}",
|
||||||
|
ReferenceCatalog.GetUrlFor(item.Name) ?? $"/reference/planetary-mastery-item/{ReferenceCatalog.ToSlug(item.Name)}"))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectHero(string hero)
|
||||||
|
{
|
||||||
|
if (_hero == hero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hero = hero;
|
||||||
|
|
||||||
|
// Faction powers are hero-locked: unequip any that belong to another hero.
|
||||||
|
for (var i = 0; i < _factionSlots.Length; i++)
|
||||||
|
{
|
||||||
|
var slotted = _factionSlots[i];
|
||||||
|
if (slotted?.LimitedTo is not null && slotted.LimitedTo != _hero)
|
||||||
|
{
|
||||||
|
_factionSlots[i] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateMercs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DropFaction(int index) => DropInto(_factionSlots, "faction", index);
|
||||||
|
private void DropGeneral(int index) => DropInto(_generalSlots, "general", index);
|
||||||
|
private void DropMastery(int index) => DropInto(_masterySlots, "mastery", index);
|
||||||
|
|
||||||
|
private void DropInto(PoolItem?[] slots, string kind, int index)
|
||||||
|
{
|
||||||
|
var dragged = _drag;
|
||||||
|
_drag = null;
|
||||||
|
if (dragged is null || dragged.Kind != kind)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slots.Contains(dragged) || slots[index] is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
slots[index] = dragged;
|
||||||
|
ValidateMercs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveFaction(int index) => RemoveFrom(_factionSlots, index);
|
||||||
|
private void RemoveGeneral(int index) => RemoveFrom(_generalSlots, index);
|
||||||
|
private void RemoveMastery(int index) => RemoveFrom(_masterySlots, index);
|
||||||
|
|
||||||
|
private void RemoveFrom(PoolItem?[] slots, int index)
|
||||||
|
{
|
||||||
|
slots[index] = null;
|
||||||
|
ValidateMercs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsConflict(PoolItem? item) => item is not null && _mercConflicts.Contains(item);
|
||||||
|
|
||||||
|
private IEnumerable<PoolItem> SlottedItems() =>
|
||||||
|
_factionSlots.Concat(_generalSlots).Concat(_masterySlots).Where(static item => item is not null)!;
|
||||||
|
|
||||||
|
private void ValidateMercs()
|
||||||
|
{
|
||||||
|
var mercs = SlottedItems()
|
||||||
|
.Where(static item => item.LimitedToMerc is not null)
|
||||||
|
.Select(static item => item.LimitedToMerc!)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_mercConflicts.Clear();
|
||||||
|
if (mercs.Count > 1)
|
||||||
|
{
|
||||||
|
foreach (var item in SlottedItems().Where(static item => item.LimitedToMerc is not null))
|
||||||
|
{
|
||||||
|
_mercConflicts.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
_mercError = "Two items cannot be from different mercs.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_mercError = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
.loadout-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.loadout-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The item pool scrolls independently so the loadout board stays on screen. */
|
||||||
|
.loadout-pool {
|
||||||
|
max-height: calc(100vh - 10rem);
|
||||||
|
max-height: calc(100dvh - 10rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.loadout-pool {
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-heading {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin: 1.5rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-heading:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon-only mode: icons wrap in a compact flex grid and shrink a little. */
|
||||||
|
.pool-list.compact {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-list.compact .pool-card {
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-list.compact .pool-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-card:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-card.icon-only {
|
||||||
|
padding: 0.35rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-card.equipped {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--zs-border-subtle);
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-icon-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
text-shadow: 0 0 8px rgba(56, 189, 248, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-doc-link {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-doc-link:hover {
|
||||||
|
background: rgba(56, 189, 248, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--zs-cyan);
|
||||||
|
box-shadow: 0 0 6px rgba(56, 189, 248, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.merc-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fbbf24;
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.5);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-levels {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merc-error {
|
||||||
|
background: rgba(220, 38, 38, 0.15);
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
color: #fca5a5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot {
|
||||||
|
position: relative;
|
||||||
|
min-height: 92px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--zs-border-subtle);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-empty {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: rgba(148, 163, 184, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-icon-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border: 1px solid var(--zs-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 6px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-remove:hover {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-conflict {
|
||||||
|
border: 2px solid #dc2626;
|
||||||
|
box-shadow: 0 0 10px rgba(220, 38, 38, 0.6);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@page "/planetary-mastery-items"
|
||||||
|
|
||||||
|
<PageTitle>Planetary Mastery Items</PageTitle>
|
||||||
|
|
||||||
|
<div class="section-header d-flex align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Planetary Mastery Items</h1>
|
||||||
|
<span class="badge badge-mastery ms-3">@PlanetaryMasteryItems.All.Count Items</span>
|
||||||
|
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pmi-grid">
|
||||||
|
@foreach (var item in PlanetaryMasteryItems.All.OrderBy(i => i.Name))
|
||||||
|
{
|
||||||
|
<article class="zs-glass-card pmi-card">
|
||||||
|
@if (item.ImageFileName is not null)
|
||||||
|
{
|
||||||
|
<img class="pmi-icon" src="images/planetary-mastery-items/@Uri.EscapeDataString(item.ImageFileName)" alt="@item.Name" loading="lazy" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="pmi-icon pmi-icon-placeholder" aria-hidden="true">✦</div>
|
||||||
|
}
|
||||||
|
<div class="pmi-body">
|
||||||
|
<h2 class="pmi-name">@item.Name</h2>
|
||||||
|
<ol class="pmi-levels">
|
||||||
|
@for (var tier = 0; tier < item.Levels.Count; tier++)
|
||||||
|
{
|
||||||
|
var tierLabel = (tier + 1).ToString("D2");
|
||||||
|
<li class="pmi-level"><span class="pmi-tier">@tierLabel</span><span>@item.Levels[tier]</span></li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.pmi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmi-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmi-icon {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--zs-border-subtle);
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmi-icon-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-shadow: 0 0 10px rgba(56, 189, 248, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmi-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmi-name {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmi-levels {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmi-level {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmi-tier {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
min-width: 1.75rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
@page "/reference"
|
||||||
|
@page "/reference/{Category}"
|
||||||
|
@page "/reference/{Category}/{ItemSlug}"
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<PageTitle>@(SelectedItem is not null ? $"{SelectedItem.Name} - Reference" : SelectedCategory is not null ? $"{SelectedCategory.Name} - Reference" : "Item Reference Docs")</PageTitle>
|
||||||
|
|
||||||
|
<div class="ref-container">
|
||||||
|
@* Sidebar Navigation *@
|
||||||
|
<aside class="ref-sidebar" aria-label="Reference Navigation">
|
||||||
|
<div class="ref-sidebar-header">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<span class="ref-sidebar-title">
|
||||||
|
<span class="ref-icon-glow">✦</span> Item Reference
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-subtle">@FilteredTotalCount / @ReferenceCatalog.All.Count</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ref-search-box">
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-sm ref-search-input"
|
||||||
|
placeholder="Search reference docs..."
|
||||||
|
aria-label="Search reference docs"
|
||||||
|
@bind="_searchQuery"
|
||||||
|
@bind:event="oninput" />
|
||||||
|
@if (!string.IsNullOrEmpty(_searchQuery))
|
||||||
|
{
|
||||||
|
<button type="button" class="ref-search-clear" @onclick="ClearSearch" aria-label="Clear search">×</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ref-category-pills">
|
||||||
|
<button type="button"
|
||||||
|
class="ref-pill @(_activeCategoryFilter == null ? "active" : "")"
|
||||||
|
@onclick="() => FilterCategory(null)">
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
@foreach (var cat in ReferenceCatalog.Categories)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="ref-pill @(_activeCategoryFilter == cat.Slug ? "active" : "")"
|
||||||
|
@onclick="() => FilterCategory(cat.Slug)">
|
||||||
|
@cat.SingularName
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="ref-nav-tree">
|
||||||
|
@foreach (var cat in DisplayCategories)
|
||||||
|
{
|
||||||
|
var itemsInCat = GetFilteredItems(cat);
|
||||||
|
if (itemsInCat.Count == 0 && !string.IsNullOrWhiteSpace(_searchQuery))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="ref-group">
|
||||||
|
<div class="ref-group-header" @onclick="() => ToggleGroup(cat.Slug)">
|
||||||
|
<span class="ref-group-arrow @(IsGroupExpanded(cat.Slug) ? "expanded" : "")">▶</span>
|
||||||
|
<a href="/reference/@cat.Slug" class="ref-group-title" @onclick:stopPropagation>
|
||||||
|
@cat.Name
|
||||||
|
</a>
|
||||||
|
<span class="badge @cat.BadgeClass ms-auto">@itemsInCat.Count</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsGroupExpanded(cat.Slug))
|
||||||
|
{
|
||||||
|
<ul class="ref-item-list">
|
||||||
|
@foreach (var item in itemsInCat)
|
||||||
|
{
|
||||||
|
var isActive = SelectedItem?.Id == item.Id;
|
||||||
|
<li class="ref-item-row">
|
||||||
|
<a href="@item.CanonicalUrl" class="ref-item-link @(isActive ? "active" : "")">
|
||||||
|
@if (item.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img src="@item.ImageUrl" class="ref-thumb" alt="" loading="lazy" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="ref-thumb ref-thumb-placeholder">✦</span>
|
||||||
|
}
|
||||||
|
<span class="ref-item-name">@item.Name</span>
|
||||||
|
@if (item.LimitedTo is not null)
|
||||||
|
{
|
||||||
|
<span class="ref-mini-tag hero-tag" title="Hero: @item.LimitedTo">@item.LimitedTo</span>
|
||||||
|
}
|
||||||
|
else if (item.LimitedToMerc is not null)
|
||||||
|
{
|
||||||
|
<span class="ref-mini-tag merc-tag" title="Mercenary: @item.LimitedToMerc">@item.LimitedToMerc</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
@* Main Content Area *@
|
||||||
|
<main class="ref-main" role="main">
|
||||||
|
@if (IsItemNotFound)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning zs-glass-card mb-4" role="alert">
|
||||||
|
<h4 class="alert-heading">Item Not Found</h4>
|
||||||
|
<p>Could not find reference item matching <code>@Category/@ItemSlug</code>.</p>
|
||||||
|
<hr class="opacity-25" />
|
||||||
|
<a href="/reference" class="btn btn-sm btn-scifi-outline">Return to Reference Overview</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (SelectedItem is not null)
|
||||||
|
{
|
||||||
|
@* Breadcrumbs *@
|
||||||
|
<nav aria-label="breadcrumb" class="ref-breadcrumbs">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/reference">Reference</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="/reference/@SelectedItem.CategorySlug">@SelectedItem.CategoryName</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">@SelectedItem.Name</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@* Item Header Card *@
|
||||||
|
<header class="zs-glass-card ref-hero-card mb-4">
|
||||||
|
<div class="d-flex flex-wrap align-items-start gap-4">
|
||||||
|
<div class="ref-hero-icon-container">
|
||||||
|
@if (SelectedItem.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img class="ref-hero-icon" src="@SelectedItem.ImageUrl" alt="@SelectedItem.Name" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="ref-hero-icon ref-hero-icon-placeholder" aria-hidden="true">✦</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ref-hero-info flex-grow-1">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||||
|
<span class="badge @GetCategoryBadgeClass(SelectedItem.CategoryKind)">@SelectedItem.CategoryName</span>
|
||||||
|
@if (SelectedItem.LimitedTo is not null)
|
||||||
|
{
|
||||||
|
<span class="badge badge-hero" title="Limited to Hero">
|
||||||
|
<span class="badge-icon">👤</span> Hero: @SelectedItem.LimitedTo
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (SelectedItem.LimitedToMerc is not null)
|
||||||
|
{
|
||||||
|
<span class="badge badge-merc" title="Limited to Mercenary">
|
||||||
|
<span class="badge-icon">⚔</span> Mercenary: @SelectedItem.LimitedToMerc
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (SelectedItem.MinimumLevel is not null)
|
||||||
|
{
|
||||||
|
<span class="badge badge-level" title="Minimum Required Level">
|
||||||
|
<span class="badge-icon">★</span> Min Level @SelectedItem.MinimumLevel
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="badge badge-tiers">@SelectedItem.Levels.Count @(SelectedItem.Levels.Count == 1 ? "Tier" : "Tiers")</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="ref-hero-title mb-2">@SelectedItem.Name</h1>
|
||||||
|
|
||||||
|
<p class="ref-hero-summary text-muted mb-3">
|
||||||
|
@GetSummaryText(SelectedItem)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a href="/loadout" class="btn btn-sm btn-scifi-primary">
|
||||||
|
<span class="btn-icon">⚡</span> Open in Loadout
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-scifi-outline" @onclick="CopyCanonicalUrl">
|
||||||
|
<span>@(_copyNotification ?? "Copy Reference URL")</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@* Technical Metadata Specs Table *@
|
||||||
|
<section class="zs-glass-card ref-spec-card mb-4" aria-labelledby="specs-heading">
|
||||||
|
<h2 id="specs-heading" class="ref-section-heading">
|
||||||
|
<span class="heading-accent">▍</span> Technical Specifications
|
||||||
|
</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm ref-spec-table mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">System Identifier</th>
|
||||||
|
<td><code>@SelectedItem.Id</code></td>
|
||||||
|
<th scope="row">Category</th>
|
||||||
|
<td>@SelectedItem.CategoryName</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Hero Restriction</th>
|
||||||
|
<td>@(SelectedItem.LimitedTo ?? "Universal / Any")</td>
|
||||||
|
<th scope="row">Mercenary Restriction</th>
|
||||||
|
<td>@(SelectedItem.LimitedToMerc ?? "None")</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Minimum Level</th>
|
||||||
|
<td>@(SelectedItem.MinimumLevel.HasValue ? $"Level {SelectedItem.MinimumLevel}" : "None (Unlocked at Level 1)")</td>
|
||||||
|
<th scope="row">Progression Tiers</th>
|
||||||
|
<td>@SelectedItem.Levels.Count @(SelectedItem.Levels.Count == 1 ? "Level" : "Levels")</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Canonical API Route</th>
|
||||||
|
<td colspan="3"><code>@SelectedItem.CanonicalUrl</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Tiers & Upgrades Section *@
|
||||||
|
<section class="zs-glass-card ref-tiers-card mb-4" aria-labelledby="progression-heading">
|
||||||
|
<h2 id="progression-heading" class="ref-section-heading">
|
||||||
|
<span class="heading-accent">▍</span> Progression & Upgrades
|
||||||
|
</h2>
|
||||||
|
<div class="ref-tiers-list">
|
||||||
|
@for (var i = 0; i < SelectedItem.Levels.Count; i++)
|
||||||
|
{
|
||||||
|
var tierNumber = i + 1;
|
||||||
|
var levelDesc = SelectedItem.Levels[i];
|
||||||
|
<div class="ref-tier-item">
|
||||||
|
<div class="ref-tier-badge">
|
||||||
|
<span class="tier-label">TIER</span>
|
||||||
|
<span class="tier-num">@tierNumber.ToString("D2")</span>
|
||||||
|
</div>
|
||||||
|
<div class="ref-tier-content">
|
||||||
|
<div class="ref-tier-title">Level @tierNumber Upgrade</div>
|
||||||
|
<div class="ref-tier-desc">@levelDesc</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Markdown Field Notes / Body *@
|
||||||
|
@if (!string.IsNullOrWhiteSpace(SelectedItem.Body))
|
||||||
|
{
|
||||||
|
<section class="zs-glass-card ref-notes-card mb-4" aria-labelledby="notes-heading">
|
||||||
|
<h2 id="notes-heading" class="ref-section-heading">
|
||||||
|
<span class="heading-accent">▍</span> Documentation & Tactical Notes
|
||||||
|
</h2>
|
||||||
|
<div class="ref-markdown-body">
|
||||||
|
<MarkdownView Markdown="@SelectedItem.Body" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Navigation Footer (Previous / Next Item) *@
|
||||||
|
<nav class="ref-pager-nav d-flex justify-content-between gap-3 mt-4" aria-label="Item Reference Pager">
|
||||||
|
@if (PreviousItem is not null)
|
||||||
|
{
|
||||||
|
<a href="@PreviousItem.CanonicalUrl" class="ref-pager-card ref-pager-prev zs-glass-card">
|
||||||
|
<span class="pager-direction">← Previous Item</span>
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-1">
|
||||||
|
@if (PreviousItem.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img src="@PreviousItem.ImageUrl" class="pager-thumb" alt="" />
|
||||||
|
}
|
||||||
|
<span class="pager-name">@PreviousItem.Name</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="ref-pager-card-empty"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (NextItem is not null)
|
||||||
|
{
|
||||||
|
<a href="@NextItem.CanonicalUrl" class="ref-pager-card ref-pager-next zs-glass-card text-end">
|
||||||
|
<span class="pager-direction">Next Item →</span>
|
||||||
|
<div class="d-flex align-items-center justify-content-end gap-2 mt-1">
|
||||||
|
<span class="pager-name">@NextItem.Name</span>
|
||||||
|
@if (NextItem.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img src="@NextItem.ImageUrl" class="pager-thumb" alt="" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="ref-pager-card-empty"></div>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* Reference Hub / Overview Landing View *@
|
||||||
|
<div class="ref-overview">
|
||||||
|
<header class="zs-hero p-4 mb-4">
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-scifi-glow mb-2">ZERO SPACE DATABASE</span>
|
||||||
|
<h1 class="ref-overview-title mb-1">Loadout Item Reference</h1>
|
||||||
|
<p class="ref-overview-subtitle mb-0 text-muted">
|
||||||
|
Comprehensive documentation for all 47 faction powers, general enhancements, and planetary mastery items.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/loadout" class="btn btn-scifi-primary">
|
||||||
|
<span class="btn-icon">⚡</span> Configure Loadout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@* Category Cards *@
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
@foreach (var cat in ReferenceCatalog.Categories)
|
||||||
|
{
|
||||||
|
var isCatSelected = SelectedCategory?.Slug == cat.Slug;
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="zs-glass-card ref-category-card h-100 @(isCatSelected ? "active" : "")">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="badge @cat.BadgeClass">@cat.SingularName</span>
|
||||||
|
<span class="badge badge-subtle">@cat.Items.Count Items</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="ref-category-card-title">@cat.Name</h3>
|
||||||
|
<p class="ref-category-card-desc text-muted mb-3">@cat.Description</p>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<a href="/reference/@cat.Slug" class="btn btn-sm btn-scifi-outline w-100">
|
||||||
|
Browse @cat.Name
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Category Detail Grids *@
|
||||||
|
@foreach (var cat in DisplayCategories)
|
||||||
|
{
|
||||||
|
<section class="zs-glass-card p-4 mb-4" aria-labelledby="cat-heading-@cat.Slug">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3 border-bottom pb-2 border-secondary border-opacity-25">
|
||||||
|
<h2 id="cat-heading-@cat.Slug" class="h4 mb-0 d-flex align-items-center gap-2">
|
||||||
|
<span class="heading-accent">▍</span> @cat.Name
|
||||||
|
</h2>
|
||||||
|
<span class="badge @cat.BadgeClass">@cat.Items.Count Items</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
@foreach (var item in cat.Items)
|
||||||
|
{
|
||||||
|
<div class="col-sm-6 col-lg-4 col-xl-3">
|
||||||
|
<a href="@item.CanonicalUrl" class="ref-grid-card zs-glass-card d-flex align-items-center gap-3 p-2 text-decoration-none">
|
||||||
|
@if (item.ImageUrl is not null)
|
||||||
|
{
|
||||||
|
<img src="@item.ImageUrl" class="ref-grid-icon" alt="" loading="lazy" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="ref-grid-icon ref-grid-icon-placeholder">✦</div>
|
||||||
|
}
|
||||||
|
<div class="ref-grid-info min-w-0 flex-grow-1">
|
||||||
|
<div class="ref-grid-name text-truncate">@item.Name</div>
|
||||||
|
<div class="ref-grid-sub text-truncate">
|
||||||
|
@if (item.LimitedTo is not null)
|
||||||
|
{
|
||||||
|
<span>Hero: @item.LimitedTo</span>
|
||||||
|
}
|
||||||
|
else if (item.LimitedToMerc is not null)
|
||||||
|
{
|
||||||
|
<span>Merc: @item.LimitedToMerc</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>@item.Levels.Count @(item.Levels.Count == 1 ? "Tier" : "Tiers")</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string? Category { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? ItemSlug { get; set; }
|
||||||
|
|
||||||
|
private ReferenceItem? SelectedItem;
|
||||||
|
private ReferenceCategory? SelectedCategory;
|
||||||
|
private ReferenceItem? PreviousItem;
|
||||||
|
private ReferenceItem? NextItem;
|
||||||
|
private bool IsItemNotFound;
|
||||||
|
|
||||||
|
private string _searchQuery = string.Empty;
|
||||||
|
private string? _activeCategoryFilter;
|
||||||
|
private readonly HashSet<string> _expandedGroups = ["faction-item", "general-item", "planetary-mastery-item"];
|
||||||
|
private string? _copyNotification;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
IsItemNotFound = false;
|
||||||
|
PreviousItem = null;
|
||||||
|
NextItem = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Category) && !string.IsNullOrWhiteSpace(ItemSlug))
|
||||||
|
{
|
||||||
|
SelectedItem = ReferenceCatalog.FindItem(Category, ItemSlug);
|
||||||
|
if (SelectedItem is null)
|
||||||
|
{
|
||||||
|
IsItemNotFound = true;
|
||||||
|
SelectedCategory = ReferenceCatalog.GetCategory(Category);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedCategory = ReferenceCatalog.GetCategory(SelectedItem.CategorySlug);
|
||||||
|
_expandedGroups.Add(SelectedItem.CategorySlug);
|
||||||
|
var adjacent = ReferenceCatalog.GetAdjacentItems(SelectedItem);
|
||||||
|
PreviousItem = adjacent.Previous;
|
||||||
|
NextItem = adjacent.Next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(Category))
|
||||||
|
{
|
||||||
|
SelectedCategory = ReferenceCatalog.GetCategory(Category);
|
||||||
|
SelectedItem = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedCategory = null;
|
||||||
|
SelectedItem = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<ReferenceCategory> DisplayCategories =>
|
||||||
|
SelectedCategory is not null && string.IsNullOrEmpty(_activeCategoryFilter) && string.IsNullOrEmpty(_searchQuery)
|
||||||
|
? [SelectedCategory]
|
||||||
|
: _activeCategoryFilter is not null
|
||||||
|
? ReferenceCatalog.Categories.Where(c => c.Slug == _activeCategoryFilter)
|
||||||
|
: ReferenceCatalog.Categories;
|
||||||
|
|
||||||
|
private IReadOnlyList<ReferenceItem> GetFilteredItems(ReferenceCategory category)
|
||||||
|
{
|
||||||
|
var items = category.Items.AsEnumerable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_searchQuery))
|
||||||
|
{
|
||||||
|
var query = _searchQuery.Trim();
|
||||||
|
items = items.Where(item =>
|
||||||
|
item.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
item.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(item.LimitedTo?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||||
|
(item.LimitedToMerc?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||||
|
item.Levels.Any(l => l.Contains(query, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
item.Body.Contains(query, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int FilteredTotalCount =>
|
||||||
|
ReferenceCatalog.Categories.Sum(c => GetFilteredItems(c).Count);
|
||||||
|
|
||||||
|
private void FilterCategory(string? categorySlug)
|
||||||
|
{
|
||||||
|
_activeCategoryFilter = categorySlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSearch()
|
||||||
|
{
|
||||||
|
_searchQuery = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleGroup(string slug)
|
||||||
|
{
|
||||||
|
if (_expandedGroups.Contains(slug))
|
||||||
|
{
|
||||||
|
_expandedGroups.Remove(slug);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_expandedGroups.Add(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsGroupExpanded(string slug) => _expandedGroups.Contains(slug);
|
||||||
|
|
||||||
|
private static string GetCategoryBadgeClass(ReferenceCategoryKind kind) => kind switch
|
||||||
|
{
|
||||||
|
ReferenceCategoryKind.FactionPower => "badge-faction-power",
|
||||||
|
ReferenceCategoryKind.GeneralItem => "badge-general-item",
|
||||||
|
ReferenceCategoryKind.PlanetaryMasteryItem => "badge-mastery-item",
|
||||||
|
_ => "badge-side"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetSummaryText(ReferenceItem item)
|
||||||
|
{
|
||||||
|
if (item.Levels.Count > 0)
|
||||||
|
{
|
||||||
|
return item.Levels[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{item.CategoryName} available in loadout customization.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyCanonicalUrl()
|
||||||
|
{
|
||||||
|
if (SelectedItem is null) return;
|
||||||
|
var fullUrl = Navigation.ToAbsoluteUri(SelectedItem.CanonicalUrl).ToString();
|
||||||
|
|
||||||
|
// In WebAssembly, or standard Blazor, notification feedback:
|
||||||
|
_copyNotification = "Copied link to clipboard!";
|
||||||
|
await Task.Delay(2000);
|
||||||
|
_copyNotification = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
.ref-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
min-height: calc(100vh - 4rem);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.ref-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--zs-bg-card);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--zs-border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-sidebar-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--zs-border-subtle);
|
||||||
|
background: rgba(8, 12, 20, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-sidebar-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-icon-glow {
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
text-shadow: 0 0 8px rgba(56, 189, 248, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-search-box {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-search-input {
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||||
|
color: var(--zs-text-main);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding-right: 1.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-search-input:focus {
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
border-color: var(--zs-cyan);
|
||||||
|
box-shadow: 0 0 10px rgba(56, 189, 248, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.4rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--zs-text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-search-clear:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-category-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-pill {
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
|
color: var(--zs-text-muted);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-pill:hover {
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(56, 189, 248, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-pill.active {
|
||||||
|
background: rgba(56, 189, 248, 0.25);
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
border-color: var(--zs-cyan);
|
||||||
|
box-shadow: 0 0 8px rgba(56, 189, 248, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-nav-tree {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-group {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-group-header:hover {
|
||||||
|
background: rgba(56, 189, 248, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-group-arrow {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--zs-text-dim);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-group-arrow.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-group-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--zs-text-main);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-group-title:hover {
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.25rem 0 0 0.5rem;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
border-left: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-row {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--zs-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-link:hover {
|
||||||
|
background: rgba(56, 189, 248, 0.08);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-link.active {
|
||||||
|
background: linear-gradient(90deg, rgba(56, 189, 248, 0.2) 0%, rgba(56, 189, 248, 0.05) 100%);
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
border-color: rgba(56, 189, 248, 0.4);
|
||||||
|
box-shadow: 0 0 10px rgba(56, 189, 248, 0.15);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-thumb {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-thumb-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-name {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-mini-tag {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-tag {
|
||||||
|
background: rgba(167, 139, 250, 0.15);
|
||||||
|
color: #c4b5fd;
|
||||||
|
border: 1px solid rgba(167, 139, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.merc-tag {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #fcd34d;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Area */
|
||||||
|
.ref-main {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-breadcrumbs {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-breadcrumbs .breadcrumb {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-breadcrumbs .breadcrumb-item a {
|
||||||
|
color: var(--zs-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-breadcrumbs .breadcrumb-item.active {
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Card */
|
||||||
|
.ref-hero-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||||
|
background: radial-gradient(ellipse at top right, rgba(56, 189, 248, 0.08) 0%, transparent 60%),
|
||||||
|
rgba(15, 23, 42, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-hero-icon-container {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(8, 12, 20, 0.7);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||||
|
box-shadow: 0 0 15px rgba(56, 189, 248, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-hero-icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-hero-icon-placeholder {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
text-shadow: 0 0 12px rgba(56, 189, 248, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-hero-title {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-hero-summary {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Headings */
|
||||||
|
.ref-section-heading {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-accent {
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
text-shadow: 0 0 8px rgba(56, 189, 248, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spec Table */
|
||||||
|
.ref-spec-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-spec-table {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--zs-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-spec-table th {
|
||||||
|
color: var(--zs-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
background: transparent;
|
||||||
|
border-color: rgba(56, 189, 248, 0.12);
|
||||||
|
width: 20%;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-spec-table td {
|
||||||
|
background: transparent;
|
||||||
|
border-color: rgba(56, 189, 248, 0.12);
|
||||||
|
width: 30%;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-spec-table code {
|
||||||
|
background: rgba(56, 189, 248, 0.1);
|
||||||
|
color: #7dd3fc;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tiers */
|
||||||
|
.ref-tiers-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tiers-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tier-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.85rem;
|
||||||
|
background: rgba(8, 12, 20, 0.45);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tier-item:hover {
|
||||||
|
border-color: rgba(56, 189, 248, 0.3);
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tier-badge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
min-width: 58px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-label {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-num {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tier-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tier-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tier-desc {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--zs-text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes / Markdown */
|
||||||
|
.ref-notes-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-markdown-body {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--zs-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-markdown-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge-faction-power {
|
||||||
|
background: rgba(167, 139, 250, 0.18);
|
||||||
|
color: #c4b5fd;
|
||||||
|
border: 1px solid rgba(167, 139, 250, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(167, 139, 250, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-general-item {
|
||||||
|
background: rgba(16, 185, 129, 0.18);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-mastery-item {
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
color: #fbbf24;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(245, 158, 11, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-hero {
|
||||||
|
background: rgba(167, 139, 250, 0.15);
|
||||||
|
color: #ddd6fe;
|
||||||
|
border: 1px solid rgba(167, 139, 250, 0.35);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-merc {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #fde68a;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-level {
|
||||||
|
background: rgba(244, 63, 94, 0.15);
|
||||||
|
color: #fda4af;
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.35);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-tiers {
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
color: #bae6fd;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-subtle {
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pager */
|
||||||
|
.ref-pager-card {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--zs-text-main);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-pager-card:hover {
|
||||||
|
border-color: var(--zs-cyan);
|
||||||
|
box-shadow: 0 0 15px rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-pager-card-empty {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-direction {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-thumb {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overview Hub */
|
||||||
|
.ref-overview-title {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-overview-subtitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-category-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-category-card-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-category-card-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-grid-card {
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-grid-card:hover {
|
||||||
|
border-color: var(--zs-cyan);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4), 0 0 12px rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-grid-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: rgba(8, 12, 20, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-grid-icon-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-grid-name {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-grid-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--zs-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.ref-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
max-height: 380px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
@page "/StarSystem/{Id}"
|
||||||
|
|
||||||
|
<PageTitle>@(system?.Name ?? "Star System")</PageTitle>
|
||||||
|
|
||||||
|
@if (system is null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
Unknown star system. <a href="/StarSystem">Back to all star systems</a>.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="section-header d-flex align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">@system.Name</h1>
|
||||||
|
<span class="badge @CategoryBadgeClass(system.SystemCategory) ms-3">@system.SystemCategory.ToDisplayName()</span>
|
||||||
|
<span class="badge @OwnerBadgeClass(system.Owner) ms-2">@system.Owner</span>
|
||||||
|
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
|
||||||
|
<a href="/galaxy" class="btn btn-sm btn-scifi-outline ms-2 d-none d-md-inline-flex">
|
||||||
|
<span>View on Map</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-3">Owner</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<span class="badge @OwnerBadgeClass(system.Owner)">@system.Owner</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Category</dt>
|
||||||
|
<dd class="col-sm-9">@system.SystemCategory.ToDisplayName()</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Occupation buff</dt>
|
||||||
|
<dd class="col-sm-9">@(system.OccupationBuff ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">System mastery</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
@if (system.SystemMastery is null)
|
||||||
|
{
|
||||||
|
@:—
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var masteryUrl = ZeroSpaceLinks.UrlFor(system.SystemMastery);
|
||||||
|
if (masteryUrl is null)
|
||||||
|
{
|
||||||
|
@system.SystemMastery
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a href="@masteryUrl">@system.SystemMastery</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Yields</dt>
|
||||||
|
<dd class="col-sm-9">@YieldText(system)</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Connected systems</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
@for (var i = 0; i < system.ConnectedSystems.Count; i++)
|
||||||
|
{
|
||||||
|
var name = system.ConnectedSystems[i];
|
||||||
|
var url = ZeroSpaceLinks.UrlFor(name);
|
||||||
|
if (url is null)
|
||||||
|
{
|
||||||
|
<span>@name</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a href="@url">@name</a>
|
||||||
|
}
|
||||||
|
@if (i < system.ConnectedSystems.Count - 1)
|
||||||
|
{
|
||||||
|
@:,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(system.Body))
|
||||||
|
{
|
||||||
|
<h2>Notes</h2>
|
||||||
|
<MarkdownView Markdown="@system.Body" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private StarSystemData? system;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
system = StarSystems.All.FirstOrDefault(s => s.Id.Equals(Id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CategoryBadgeClass(StarSystemCategory category) => category switch
|
||||||
|
{
|
||||||
|
StarSystemCategory.HomeworldSystem => "badge-homeworld",
|
||||||
|
StarSystemCategory.CoreSystem => "badge-core",
|
||||||
|
StarSystemCategory.SideSystem => "badge-side",
|
||||||
|
_ => "badge-side"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string OwnerBadgeClass(string owner) => owner switch
|
||||||
|
{
|
||||||
|
StarSystemOwners.EdenAccord => "badge-eden",
|
||||||
|
StarSystemOwners.KeeperCouncil => "badge-keeper",
|
||||||
|
StarSystemOwners.ObsidianOrder => "badge-obsidian",
|
||||||
|
_ => "badge-side"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string YieldText(StarSystemData s)
|
||||||
|
{
|
||||||
|
var yields = new List<string>();
|
||||||
|
if (s.HigherInfluenceYield)
|
||||||
|
{
|
||||||
|
yields.Add("Influence");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.HigherCreditYield)
|
||||||
|
{
|
||||||
|
yields.Add("Credits");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.HigherEnergyYield)
|
||||||
|
{
|
||||||
|
yields.Add("Energy");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.HigherChanceForItems)
|
||||||
|
{
|
||||||
|
yields.Add("Items");
|
||||||
|
}
|
||||||
|
|
||||||
|
return yields.Count == 0 ? "Standard" : "Higher " + string.Join(", ", yields);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@page "/StarSystem"
|
||||||
|
|
||||||
|
<PageTitle>Star Systems</PageTitle>
|
||||||
|
|
||||||
|
<div class="section-header d-flex align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Star Systems</h1>
|
||||||
|
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
|
||||||
|
<a href="/galaxy" class="btn btn-sm btn-scifi-primary ms-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="2.5"/>
|
||||||
|
<circle cx="2.5" cy="4" r="1.5"/>
|
||||||
|
<circle cx="13.5" cy="5" r="1.5"/>
|
||||||
|
<path stroke="currentColor" stroke-width="1.2" stroke-linecap="round" d="M2.5 4L8 8m0 0l5.5-3" opacity=".65"/>
|
||||||
|
</svg>
|
||||||
|
<span>Galaxy Map</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@foreach (var category in Enum.GetValues<StarSystemCategory>())
|
||||||
|
{
|
||||||
|
var systems = StarSystems.All.Where(s => s.SystemCategory == category).OrderBy(s => s.Name).ToList();
|
||||||
|
var badgeClass = category switch {
|
||||||
|
StarSystemCategory.HomeworldSystem => "badge-homeworld",
|
||||||
|
StarSystemCategory.CoreSystem => "badge-core",
|
||||||
|
StarSystemCategory.SideSystem => "badge-side",
|
||||||
|
_ => "badge-side"
|
||||||
|
};
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<h2 class="h4 mb-0 text-white">@category.ToDisplayName()</h2>
|
||||||
|
<span class="badge @badgeClass">@systems.Count</span>
|
||||||
|
</div>
|
||||||
|
<ul class="list-unstyled row g-2">
|
||||||
|
@foreach (var system in systems)
|
||||||
|
{
|
||||||
|
<li class="col-md-4 col-lg-3">
|
||||||
|
<a class="d-block p-2 rounded zs-glass-card text-decoration-none" href="/StarSystem/@system.Id">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<span class="fw-semibold text-white">@system.Name</span>
|
||||||
|
<span class="badge @OwnerBadgeClass(system.Owner) fs-8">@system.Owner</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(system.OccupationBuff))
|
||||||
|
{
|
||||||
|
<div class="small text-dim text-truncate mt-1" title="@system.OccupationBuff">@system.OccupationBuff</div>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private static string OwnerBadgeClass(string owner) => owner switch
|
||||||
|
{
|
||||||
|
StarSystemOwners.EdenAccord => "badge-eden",
|
||||||
|
StarSystemOwners.KeeperCouncil => "badge-keeper",
|
||||||
|
StarSystemOwners.ObsidianOrder => "badge-obsidian",
|
||||||
|
_ => "badge-side"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
@page "/SystemMastery/{Id}"
|
||||||
|
|
||||||
|
<PageTitle>@(mastery?.Name ?? "System Mastery")</PageTitle>
|
||||||
|
|
||||||
|
@if (mastery is null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
Unknown system mastery. <a href="/SystemMastery">Back to all masteries</a>.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="section-header d-flex align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">@mastery.Name</h1>
|
||||||
|
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Levels</h2>
|
||||||
|
<ol>
|
||||||
|
@for (var i = 0; i < mastery.Levels.Count; i++)
|
||||||
|
{
|
||||||
|
<li><strong>Level @(i + 1):</strong> @mastery.Levels[i]</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
@if (mastery.Stats.Count > 0)
|
||||||
|
{
|
||||||
|
<h2>Totals</h2>
|
||||||
|
<table class="table table-sm w-auto">
|
||||||
|
<tbody>
|
||||||
|
@foreach (var stat in mastery.Stats)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@ZeroSpaceLinks.Humanize(stat.Key)</td>
|
||||||
|
<td class="text-end">@stat.Value</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2>Available on</h2>
|
||||||
|
<ul class="list-unstyled row">
|
||||||
|
@foreach (var name in mastery.AvailableSystems.OrderBy(n => n))
|
||||||
|
{
|
||||||
|
<li class="col-md-4 col-lg-3 mb-1">
|
||||||
|
@{
|
||||||
|
var url = ZeroSpaceLinks.UrlFor(name);
|
||||||
|
}
|
||||||
|
@if (url is null)
|
||||||
|
{
|
||||||
|
@name
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a href="@url">@name</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(mastery.Body) && false) // Let's not display notes. They are very placeholder and probably wrong
|
||||||
|
{
|
||||||
|
<h2>Notes</h2>
|
||||||
|
<MarkdownView Markdown="@mastery.Body" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private SystemMasteryData? mastery;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
mastery = SystemMasteries.All.FirstOrDefault(m => m.Id.Equals(Id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@page "/SystemMastery"
|
||||||
|
|
||||||
|
<PageTitle>System Masteries</PageTitle>
|
||||||
|
|
||||||
|
<div class="section-header d-flex align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">System Masteries</h1>
|
||||||
|
<span class="badge badge-mastery ms-3">@SystemMasteries.All.Count Doctrines</span>
|
||||||
|
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-group gap-2">
|
||||||
|
@foreach (var mastery in SystemMasteries.All.OrderBy(m => m.Name))
|
||||||
|
{
|
||||||
|
<a class="list-group-item list-group-item-action rounded border p-3 zs-glass-card text-decoration-none" href="/SystemMastery/@mastery.Id">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span class="fw-bold text-white fs-5">@mastery.Name</span>
|
||||||
|
<span class="badge badge-pill-scifi badge-side">@mastery.AvailableSystems.Count systems</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">@mastery.Levels[0]</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
@page "/units"
|
@page "/units"
|
||||||
@using ZeroSpace.Model
|
@using ZeroSpace.Model
|
||||||
@using ZeroSpace.Web.Services
|
@using ZeroSpace.Web.Services
|
||||||
@using Telerik.Blazor
|
|
||||||
@using Telerik.Blazor.Components
|
|
||||||
@inject IUnitsApiClient UnitsApiClient
|
@inject IUnitsApiClient UnitsApiClient
|
||||||
|
|
||||||
<PageTitle>Units</PageTitle>
|
<PageTitle>Units</PageTitle>
|
||||||
@@ -29,7 +27,7 @@ else if (errorMessage != null)
|
|||||||
<button class="btn btn-outline-danger btn-sm" @onclick="LoadUnitsAsync">Retry</button>
|
<button class="btn btn-outline-danger btn-sm" @onclick="LoadUnitsAsync">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else if (gearNotes == null || gearNotes.Count == 0)
|
else if (units == null || units.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
No units found.
|
No units found.
|
||||||
@@ -37,42 +35,159 @@ else if (gearNotes == null || gearNotes.Count == 0)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="grid-container">
|
<div class="table-responsive">
|
||||||
<TelerikGrid Data="@gearNotes" Pageable="true" PageSize="50" Sortable="true" FilterMode="@GridFilterMode.FilterRow"
|
<table class="table table-striped table-hover table-sm align-middle text-nowrap">
|
||||||
Height="calc(100vh - 250px)">
|
<thead class="table-light">
|
||||||
<GridColumns>
|
<tr>
|
||||||
<GridColumn Field="@(nameof(UnitData.Name))" Title="Name" Width="200px"/>
|
@foreach (var col in Columns)
|
||||||
<GridColumn Field="@(nameof(UnitData.Hexite))" Title="Hexite" Width="90px" />
|
{
|
||||||
<GridColumn Field="@(nameof(UnitData.Flux))" Title="Flux" Width="90px" />
|
<th style="min-width:@col.Width">
|
||||||
|
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none text-body fw-bold" @onclick="() => ToggleSort(col.Key)" title="Sort by @col.Title">
|
||||||
<GridColumn Field="@(nameof(UnitData.DpsPerTotalCost))" Title="DpsPerTotalCost" Width="90px" />
|
@col.Title
|
||||||
|
<span class="text-muted">@SortGlyph(col.Key)</span>
|
||||||
<GridColumn Field="@(nameof(UnitData.HealthPerTotalCost))" Title="HealthPerTotalCost" Width="90px" />
|
</button>
|
||||||
|
</th>
|
||||||
<GridColumn Field="@(nameof(UnitData.Supply))" Title="Supply" Width="90px" />
|
}
|
||||||
<GridColumn Field="@(nameof(UnitData.ProductionTime))" Title="Production Time" Width="90px" />
|
</tr>
|
||||||
<GridColumn Field="@(nameof(UnitData.Health))" Title="Health" Width="90px" />
|
<tr>
|
||||||
<GridColumn Field="@(nameof(UnitData.Shields))" Title="Shields" Width="90px" />
|
@foreach (var col in Columns)
|
||||||
<GridColumn Field="@(nameof(UnitData.ArmorRating))" Title="Armor Rating" Width="90px" />
|
{
|
||||||
<GridColumn Field="@(nameof(UnitData.MovementSpeed))" Title="Movement Speed" Width="90px" />
|
<th>
|
||||||
<GridColumn Field="@(nameof(UnitData.DamagePerSecond))" Title="Damage Per Second" Width="90px" />
|
<input class="form-control form-control-sm" placeholder="Filter" aria-label="Filter @col.Title"
|
||||||
<GridColumn Field="@(nameof(UnitData.AttackRange))" Title="Attack Range" Width="90px" />
|
value="@GetFilter(col.Key)"
|
||||||
<GridColumn Field="@(nameof(UnitData.Attributes))" Title="Attributes" Width="90px" />
|
@oninput="(e) => SetFilter(col.Key, e.Value?.ToString())" />
|
||||||
<GridColumn Field="@(nameof(UnitData.Tier))" Title="Tier" Width="90px" />
|
</th>
|
||||||
<GridColumn Field="@(nameof(UnitData.Faction))" Title="Faction" Width="90px" />
|
}
|
||||||
<GridColumn Field="@(nameof(UnitData.Hotkey))" Title="Hotkey" Width="90px" />
|
</tr>
|
||||||
<GridColumn Field="@(nameof(UnitData.BuildAtSameTime))" Title="Build At Same Time" Width="90px" />
|
</thead>
|
||||||
<GridColumn Field="@(nameof(UnitData.Limit))" Title="Limit" Width="90px" />
|
<tbody>
|
||||||
</GridColumns>
|
@foreach (var unit in PagedUnits)
|
||||||
</TelerikGrid>
|
{
|
||||||
|
<tr>
|
||||||
|
@foreach (var col in Columns)
|
||||||
|
{
|
||||||
|
<td>@col.Display(unit)</td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" disabled="@(page <= 1)" @onclick="() => ChangePage(page - 1)">Previous</button>
|
||||||
|
<span class="text-muted">Page @(page) of @TotalPages (@SortedUnits.Count units)</span>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" disabled="@(page >= TotalPages)" @onclick="() => ChangePage(page + 1)">Next</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<UnitData>? gearNotes;
|
private List<UnitData>? units;
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
|
|
||||||
|
private string? sortKey;
|
||||||
|
private bool sortAscending = true;
|
||||||
|
private int page = 1;
|
||||||
|
private const int PageSize = 50;
|
||||||
|
private readonly Dictionary<string, string> filters = new();
|
||||||
|
|
||||||
|
private sealed record ColumnDef(
|
||||||
|
string Key,
|
||||||
|
string Title,
|
||||||
|
string Width,
|
||||||
|
Func<UnitData, IComparable?> SortValue,
|
||||||
|
Func<UnitData, string> Display);
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<ColumnDef> Columns = new List<ColumnDef>
|
||||||
|
{
|
||||||
|
new("Name", "Name", "200px", u => u.Name, u => u.Name),
|
||||||
|
new("Hexite", "Hexite", "90px", u => u.Hexite, u => u.Hexite.ToString()),
|
||||||
|
new("Flux", "Flux", "90px", u => u.Flux, u => u.Flux.ToString()),
|
||||||
|
new("DpsPerTotalCost", "DpsPerTotalCost", "90px", u => u.DpsPerTotalCost, u => u.DpsPerTotalCost.ToString()),
|
||||||
|
new("HealthPerTotalCost", "HealthPerTotalCost", "90px", u => u.HealthPerTotalCost, u => u.HealthPerTotalCost.ToString()),
|
||||||
|
new("Supply", "Supply", "90px", u => u.Supply, u => u.Supply.ToString()),
|
||||||
|
new("ProductionTime", "Production Time", "90px", u => u.ProductionTime, u => u.ProductionTime.ToString()),
|
||||||
|
new("Health", "Health", "90px", u => u.Health, u => u.Health.ToString()),
|
||||||
|
new("Shields", "Shields", "90px", u => u.Shields, u => u.Shields.ToString()),
|
||||||
|
new("ArmorRating", "Armor Rating", "90px", u => u.ArmorRating, u => u.ArmorRating.ToString()),
|
||||||
|
new("MovementSpeed", "Movement Speed", "90px", u => u.MovementSpeed, u => u.MovementSpeed.ToString()),
|
||||||
|
new("DamagePerSecond", "Damage Per Second", "90px", u => u.DamagePerSecond, u => u.DamagePerSecond.ToString()),
|
||||||
|
new("AttackRange", "Attack Range", "90px", u => u.AttackRange, u => u.AttackRange.ToString()),
|
||||||
|
new("Attributes", "Attributes", "90px", u => string.Join(", ", u.Attributes), u => string.Join(", ", u.Attributes)),
|
||||||
|
new("Tier", "Tier", "90px", u => u.Tier, u => u.Tier.ToString()),
|
||||||
|
new("Faction", "Faction", "90px", u => u.Faction, u => u.Faction),
|
||||||
|
new("Hotkey", "Hotkey", "90px", u => u.Hotkey, u => u.Hotkey ?? string.Empty),
|
||||||
|
new("BuildAtSameTime", "Build At Same Time", "90px", u => u.BuildAtSameTime, u => u.BuildAtSameTime.ToString()),
|
||||||
|
new("Limit", "Limit", "90px", u => u.Limit, u => u.Limit?.ToString() ?? string.Empty),
|
||||||
|
};
|
||||||
|
|
||||||
|
private List<UnitData> SortedUnits
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var query = (units ?? new List<UnitData>()).AsEnumerable();
|
||||||
|
|
||||||
|
foreach (var col in Columns)
|
||||||
|
{
|
||||||
|
if (filters.TryGetValue(col.Key, out var text) && !string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
query = query.Where(u => col.Display(u).Contains(text, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortCol = Columns.FirstOrDefault(c => c.Key == sortKey);
|
||||||
|
if (sortCol != null)
|
||||||
|
{
|
||||||
|
query = sortAscending
|
||||||
|
? query.OrderBy(u => sortCol.SortValue(u))
|
||||||
|
: query.OrderByDescending(u => sortCol.SortValue(u));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int TotalPages => Math.Max(1, (int)Math.Ceiling(SortedUnits.Count / (double)PageSize));
|
||||||
|
|
||||||
|
private List<UnitData> PagedUnits => SortedUnits.Skip((page - 1) * PageSize).Take(PageSize).ToList();
|
||||||
|
|
||||||
|
private void ToggleSort(string key)
|
||||||
|
{
|
||||||
|
if (sortKey == key)
|
||||||
|
{
|
||||||
|
sortAscending = !sortAscending;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sortKey = key;
|
||||||
|
sortAscending = true;
|
||||||
|
}
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string SortGlyph(string key) => sortKey == key ? (sortAscending ? "▲" : "▼") : string.Empty;
|
||||||
|
|
||||||
|
private string GetFilter(string key) => filters.TryGetValue(key, out var value) ? value : string.Empty;
|
||||||
|
|
||||||
|
private void SetFilter(string key, string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
filters.Remove(key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
filters[key] = value;
|
||||||
|
}
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ChangePage(int newPage)
|
||||||
|
{
|
||||||
|
page = Math.Clamp(newPage, 1, TotalPages);
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await LoadUnitsAsync();
|
await LoadUnitsAsync();
|
||||||
@@ -85,8 +200,8 @@ else
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var units = await UnitsApiClient.GetUnitsAsync();
|
var result = await UnitsApiClient.GetUnitsAsync();
|
||||||
gearNotes = units.ToList();
|
units = result.ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.
|
|||||||
builder.Services.AddHttpClient<IUnitsApiClient, UnitsApiClient>(client =>
|
builder.Services.AddHttpClient<IUnitsApiClient, UnitsApiClient>(client =>
|
||||||
{
|
{
|
||||||
var apiBaseUrl = builder.Configuration["services:apiservice:https:0"]
|
var apiBaseUrl = builder.Configuration["services:apiservice:https:0"]
|
||||||
?? builder.Configuration["services:apiservice:http:0"]
|
?? builder.Configuration["services:apiservice:http:0"]
|
||||||
?? builder.Configuration["ApiServiceUrl"]
|
?? builder.Configuration["ApiServiceUrl"]
|
||||||
?? builder.HostEnvironment.BaseAddress;
|
?? builder.HostEnvironment.BaseAddress;
|
||||||
|
|
||||||
if (!apiBaseUrl.EndsWith('/'))
|
if (!apiBaseUrl.EndsWith('/'))
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,6 @@ builder.Services.AddHttpClient<IUnitsApiClient, UnitsApiClient>(client =>
|
|||||||
client.BaseAddress = new Uri(apiBaseUrl);
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddTelerikBlazor();
|
builder.Services.AddSingleton<IGalaxyMapExportSerializer, GalaxyMapExportSerializer>();
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ZeroSpace.Model;
|
||||||
|
|
||||||
|
namespace ZeroSpace.Web.Services;
|
||||||
|
|
||||||
|
public sealed class GalaxyMapExportSerializer : IGalaxyMapExportSerializer
|
||||||
|
{
|
||||||
|
public static readonly JsonSerializerOptions DefaultOptions = CreateDefaultOptions();
|
||||||
|
|
||||||
|
public static JsonSerializerOptions CreateDefaultOptions()
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Serialize(GalaxyMapExportData data) => SerializeData(data);
|
||||||
|
|
||||||
|
public static string SerializeData(GalaxyMapExportData data, JsonSerializerOptions? options = null)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(data, options ?? DefaultOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using ZeroSpace.Model;
|
||||||
|
|
||||||
|
namespace ZeroSpace.Web.Services;
|
||||||
|
|
||||||
|
public interface IGalaxyMapExportSerializer
|
||||||
|
{
|
||||||
|
string Serialize(GalaxyMapExportData data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using ZeroSpace.Model;
|
||||||
|
|
||||||
|
namespace ZeroSpace.Web.Services;
|
||||||
|
|
||||||
|
public enum ReferenceCategoryKind
|
||||||
|
{
|
||||||
|
FactionPower,
|
||||||
|
GeneralItem,
|
||||||
|
PlanetaryMasteryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ReferenceItem(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
string CategorySlug,
|
||||||
|
string CategoryName,
|
||||||
|
ReferenceCategoryKind CategoryKind,
|
||||||
|
IReadOnlyList<string> Levels,
|
||||||
|
string? LimitedTo,
|
||||||
|
string? LimitedToMerc,
|
||||||
|
int? MinimumLevel,
|
||||||
|
string? ImageUrl,
|
||||||
|
string Body)
|
||||||
|
{
|
||||||
|
public string CanonicalUrl => $"/reference/{CategorySlug}/{Slug}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ReferenceCategory(
|
||||||
|
string Slug,
|
||||||
|
string Name,
|
||||||
|
string SingularName,
|
||||||
|
string Description,
|
||||||
|
string BadgeClass,
|
||||||
|
ReferenceCategoryKind Kind,
|
||||||
|
IReadOnlyList<ReferenceItem> Items);
|
||||||
|
|
||||||
|
public static partial class ReferenceCatalog
|
||||||
|
{
|
||||||
|
public const string FactionCategorySlug = "faction-item";
|
||||||
|
public const string GeneralCategorySlug = "general-item";
|
||||||
|
public const string MasteryCategorySlug = "planetary-mastery-item";
|
||||||
|
|
||||||
|
private static readonly List<ReferenceItem> FactionItemsList;
|
||||||
|
private static readonly List<ReferenceItem> GeneralItemsList;
|
||||||
|
private static readonly List<ReferenceItem> MasteryItemsList;
|
||||||
|
private static readonly List<ReferenceItem> AllItemsList;
|
||||||
|
private static readonly List<ReferenceCategory> CategoriesList;
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, ReferenceItem> ItemsByCanonicalUrl;
|
||||||
|
private static readonly Dictionary<string, ReferenceItem> ItemsByName;
|
||||||
|
private static readonly Dictionary<string, ReferenceItem> ItemsById;
|
||||||
|
|
||||||
|
static ReferenceCatalog()
|
||||||
|
{
|
||||||
|
FactionItemsList = ZeroSpace.Model.FactionPowerItems.All
|
||||||
|
.Select(item => new ReferenceItem(
|
||||||
|
Id: item.Id,
|
||||||
|
Name: item.Name,
|
||||||
|
Slug: ToSlug(item.Name),
|
||||||
|
CategorySlug: FactionCategorySlug,
|
||||||
|
CategoryName: "Faction Power",
|
||||||
|
CategoryKind: ReferenceCategoryKind.FactionPower,
|
||||||
|
Levels: item.Levels,
|
||||||
|
LimitedTo: item.LimitedTo,
|
||||||
|
LimitedToMerc: item.LimitedToMerc,
|
||||||
|
MinimumLevel: item.MinimumLevel,
|
||||||
|
ImageUrl: item.ImageFileName is null ? null : $"images/faction-power-items/{Uri.EscapeDataString(item.ImageFileName)}",
|
||||||
|
Body: item.Body))
|
||||||
|
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
GeneralItemsList = ZeroSpace.Model.GeneralItems.All
|
||||||
|
.Select(item => new ReferenceItem(
|
||||||
|
Id: item.Id,
|
||||||
|
Name: item.Name,
|
||||||
|
Slug: ToSlug(item.Name),
|
||||||
|
CategorySlug: GeneralCategorySlug,
|
||||||
|
CategoryName: "General Item",
|
||||||
|
CategoryKind: ReferenceCategoryKind.GeneralItem,
|
||||||
|
Levels: item.Levels,
|
||||||
|
LimitedTo: null,
|
||||||
|
LimitedToMerc: item.LimitedToMerc,
|
||||||
|
MinimumLevel: null,
|
||||||
|
ImageUrl: item.ImageFileName is null ? null : $"images/general-items/{Uri.EscapeDataString(item.ImageFileName)}",
|
||||||
|
Body: item.Body))
|
||||||
|
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
MasteryItemsList = ZeroSpace.Model.PlanetaryMasteryItems.All
|
||||||
|
.Select(item => new ReferenceItem(
|
||||||
|
Id: item.Id,
|
||||||
|
Name: item.Name,
|
||||||
|
Slug: ToSlug(item.Name),
|
||||||
|
CategorySlug: MasteryCategorySlug,
|
||||||
|
CategoryName: "Planetary Mastery Item",
|
||||||
|
CategoryKind: ReferenceCategoryKind.PlanetaryMasteryItem,
|
||||||
|
Levels: item.Levels,
|
||||||
|
LimitedTo: null,
|
||||||
|
LimitedToMerc: null,
|
||||||
|
MinimumLevel: null,
|
||||||
|
ImageUrl: item.ImageFileName is null ? null : $"images/planetary-mastery-items/{Uri.EscapeDataString(item.ImageFileName)}",
|
||||||
|
Body: item.Body))
|
||||||
|
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AllItemsList = [.. FactionItemsList, .. GeneralItemsList, .. MasteryItemsList];
|
||||||
|
|
||||||
|
CategoriesList =
|
||||||
|
[
|
||||||
|
new ReferenceCategory(
|
||||||
|
Slug: FactionCategorySlug,
|
||||||
|
Name: "Faction Powers",
|
||||||
|
SingularName: "Faction Power",
|
||||||
|
Description: "High-impact active and passive faction powers and commander abilities equipped to customize faction tactical capabilities.",
|
||||||
|
BadgeClass: "badge-faction-power",
|
||||||
|
Kind: ReferenceCategoryKind.FactionPower,
|
||||||
|
Items: FactionItemsList),
|
||||||
|
new ReferenceCategory(
|
||||||
|
Slug: GeneralCategorySlug,
|
||||||
|
Name: "General Items",
|
||||||
|
SingularName: "General Item",
|
||||||
|
Description: "Universal strategic enhancements, defensive matrix structures, mercenary compound unlocks, and economic items.",
|
||||||
|
BadgeClass: "badge-general-item",
|
||||||
|
Kind: ReferenceCategoryKind.GeneralItem,
|
||||||
|
Items: GeneralItemsList),
|
||||||
|
new ReferenceCategory(
|
||||||
|
Slug: MasteryCategorySlug,
|
||||||
|
Name: "Planetary Mastery Items",
|
||||||
|
SingularName: "Planetary Mastery Item",
|
||||||
|
Description: "Mastery artifacts and planetary topbar energy catalysts that scale with system and loadout progression.",
|
||||||
|
BadgeClass: "badge-mastery-item",
|
||||||
|
Kind: ReferenceCategoryKind.PlanetaryMasteryItem,
|
||||||
|
Items: MasteryItemsList)
|
||||||
|
];
|
||||||
|
|
||||||
|
ItemsByCanonicalUrl = new Dictionary<string, ReferenceItem>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
ItemsByName = new Dictionary<string, ReferenceItem>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
ItemsById = new Dictionary<string, ReferenceItem>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var item in AllItemsList)
|
||||||
|
{
|
||||||
|
ItemsByCanonicalUrl[item.CanonicalUrl] = item;
|
||||||
|
ItemsByName[item.Name] = item;
|
||||||
|
ItemsById[item.Id] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<ReferenceItem> All => AllItemsList;
|
||||||
|
public static IReadOnlyList<ReferenceItem> FactionPowers => FactionItemsList;
|
||||||
|
public static IReadOnlyList<ReferenceItem> GeneralItems => GeneralItemsList;
|
||||||
|
public static IReadOnlyList<ReferenceItem> MasteryItems => MasteryItemsList;
|
||||||
|
public static IReadOnlyList<ReferenceCategory> Categories => CategoriesList;
|
||||||
|
|
||||||
|
public static ReferenceCategory? GetCategory(string? categorySlugOrAlias)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(categorySlugOrAlias))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = Normalize(categorySlugOrAlias);
|
||||||
|
|
||||||
|
if (normalized is "faction-item" or "faction-items" or "faction-power" or "faction-powers" or "faction" or "factionitem" or "factionpower")
|
||||||
|
{
|
||||||
|
return CategoriesList[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized is "general-item" or "general-items" or "general" or "generalitem" or "generalitems")
|
||||||
|
{
|
||||||
|
return CategoriesList[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized is "planetary-mastery-item" or "planetary-mastery-items" or "master-item" or "master-items" or "mastery" or "planetary-mastery" or "planetarymasteryitem" or "masteritem")
|
||||||
|
{
|
||||||
|
return CategoriesList[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return CategoriesList.FirstOrDefault(c => c.Slug.Equals(categorySlugOrAlias, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
Normalize(c.Name).Equals(normalized, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReferenceItem? FindItem(string? categorySlugOrAlias, string? itemSlugOrId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(itemSlugOrId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = GetCategory(categorySlugOrAlias);
|
||||||
|
var searchPool = category is not null ? category.Items : AllItemsList;
|
||||||
|
|
||||||
|
var normalizedTarget = Normalize(itemSlugOrId);
|
||||||
|
|
||||||
|
// 1. Direct match on slug
|
||||||
|
var match = searchPool.FirstOrDefault(i => Normalize(i.Slug).Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Match on Id
|
||||||
|
match = searchPool.FirstOrDefault(i => i.Id.Equals(itemSlugOrId, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
Normalize(i.Id).Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Match on Name
|
||||||
|
match = searchPool.FirstOrDefault(i => i.Name.Equals(itemSlugOrId, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
Normalize(i.Name).Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback: Search all items regardless of category if not found in specific category
|
||||||
|
if (category is not null)
|
||||||
|
{
|
||||||
|
return FindItem(null, itemSlugOrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReferenceItem? FindByName(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ItemsByName.TryGetValue(name, out var item))
|
||||||
|
{
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindItem(null, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? GetUrlFor(string name)
|
||||||
|
{
|
||||||
|
var item = FindByName(name);
|
||||||
|
return item?.CanonicalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (ReferenceItem? Previous, ReferenceItem? Next) GetAdjacentItems(ReferenceItem current)
|
||||||
|
{
|
||||||
|
var category = GetCategory(current.CategorySlug);
|
||||||
|
var pool = category?.Items ?? AllItemsList;
|
||||||
|
var index = pool.ToList().FindIndex(i => i.Id == current.Id);
|
||||||
|
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous = index > 0 ? pool[index - 1] : null;
|
||||||
|
var next = index < pool.Count - 1 ? pool[index + 1] : null;
|
||||||
|
|
||||||
|
return (previous, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToSlug(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lower = text.Trim().ToLowerInvariant();
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
var prevHyphen = false;
|
||||||
|
|
||||||
|
foreach (var c in lower)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(c))
|
||||||
|
{
|
||||||
|
builder.Append(c);
|
||||||
|
prevHyphen = false;
|
||||||
|
}
|
||||||
|
else if (!prevHyphen)
|
||||||
|
{
|
||||||
|
builder.Append('-');
|
||||||
|
prevHyphen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = builder.ToString().Trim('-');
|
||||||
|
return result.Length == 0 ? "item" : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string text)
|
||||||
|
{
|
||||||
|
return text.Trim().ToLowerInvariant().Replace("_", "-").Replace(" ", "-").Replace("'", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using ZeroSpace.Model;
|
||||||
|
|
||||||
|
namespace ZeroSpace.Web.Services;
|
||||||
|
|
||||||
|
public static partial class ZeroSpaceLinks
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> SystemUrls = StarSystems.All
|
||||||
|
.ToDictionary(static system => system.Name, static system => $"/StarSystem/{system.Id}", StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> MasteryUrls = SystemMasteries.All
|
||||||
|
.ToDictionary(static mastery => mastery.Name, static mastery => $"/SystemMastery/{mastery.Id}", StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> ReferenceUrls = ReferenceCatalog.All
|
||||||
|
.ToDictionary(static item => item.Name, static item => item.CanonicalUrl, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static string? UrlFor(string name)
|
||||||
|
{
|
||||||
|
if (SystemUrls.TryGetValue(name, out var systemUrl))
|
||||||
|
{
|
||||||
|
return systemUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MasteryUrls.TryGetValue(name, out var masteryUrl))
|
||||||
|
{
|
||||||
|
return masteryUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReferenceUrls.TryGetValue(name, out var referenceUrl) ? referenceUrl : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ResolveWikiLinks(string markdown)
|
||||||
|
{
|
||||||
|
return WikiLinkPattern().Replace(markdown, match =>
|
||||||
|
{
|
||||||
|
var name = match.Groups[1].Value.Trim();
|
||||||
|
var url = UrlFor(name);
|
||||||
|
return url is null ? name : $"[{name}]({url})";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Humanize(string camelCase)
|
||||||
|
{
|
||||||
|
var spaced = CamelCasePattern().Replace(camelCase, " ");
|
||||||
|
return spaced.Length == 0 ? spaced : char.ToUpperInvariant(spaced[0]) + spaced[1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\[\[([^\]]+)\]\]")]
|
||||||
|
private static partial Regex WikiLinkPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex("(?<=[a-z0-9])(?=[A-Z])")]
|
||||||
|
private static partial Regex CamelCasePattern();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Routing;
|
||||||
|
|
||||||
|
namespace ZeroSpace.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for which pages exist, which are released,
|
||||||
|
/// and which appear in navigation. New pages are hidden until explicitly
|
||||||
|
/// released here (fail-closed): anything not listed, or listed with
|
||||||
|
/// Released=false, redirects to the galaxy map via <see cref="Components.ReleaseGuard"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class SiteMap
|
||||||
|
{
|
||||||
|
public sealed record Entry(
|
||||||
|
Type Page,
|
||||||
|
string Title,
|
||||||
|
string Href,
|
||||||
|
string IconCss,
|
||||||
|
string? Badge,
|
||||||
|
NavLinkMatch Match,
|
||||||
|
bool Released,
|
||||||
|
bool ShowInNav);
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<Entry> Entries =
|
||||||
|
[
|
||||||
|
// The releasable surface.
|
||||||
|
new(typeof(Pages.Galaxy), "Galaxy Map", "galaxy", "bi bi-galaxy-nav-menu", "MAP",
|
||||||
|
NavLinkMatch.Prefix, Released: true, ShowInNav: true),
|
||||||
|
|
||||||
|
new(typeof(Pages.PlanetaryMasteryItemIndex), "Planetary Items", "planetary-mastery-items", "bi bi-items-nav-menu", null,
|
||||||
|
NavLinkMatch.All, Released: true, ShowInNav: false),
|
||||||
|
|
||||||
|
new(typeof(Pages.Loadout), "Loadout", "loadout", "bi bi-items-nav-menu", null,
|
||||||
|
NavLinkMatch.All, Released: true, ShowInNav: true),
|
||||||
|
|
||||||
|
new(typeof(Pages.ReferenceDoc), "Reference", "reference", "bi bi-reference-nav-menu", null,
|
||||||
|
NavLinkMatch.Prefix, Released: true, ShowInNav: true),
|
||||||
|
|
||||||
|
// Detail dossiers: part of the galaxy experience (linked from the map),
|
||||||
|
// but not top-level navigation destinations.
|
||||||
|
new(typeof(Pages.StarSystemDetail), "System Dossier", "StarSystem", "bi bi-star-systems-nav-menu", null,
|
||||||
|
NavLinkMatch.Prefix, Released: true, ShowInNav: false),
|
||||||
|
|
||||||
|
new(typeof(Pages.SystemMasteryDetail), "Mastery Dossier", "SystemMastery", "bi bi-masteries-nav-menu", null,
|
||||||
|
NavLinkMatch.Prefix, Released: true, ShowInNav: false),
|
||||||
|
|
||||||
|
// In-development pages: hidden from nav and unreachable by URL for now.
|
||||||
|
new(typeof(Pages.Home), "Home", "", "bi bi-house-door-fill-nav-menu", null,
|
||||||
|
NavLinkMatch.All, Released: false, ShowInNav: true),
|
||||||
|
new(typeof(Pages.StarSystemIndex), "Star Systems", "StarSystem", "bi bi-star-systems-nav-menu", null,
|
||||||
|
NavLinkMatch.Prefix, Released: false, ShowInNav: true),
|
||||||
|
new(typeof(Pages.SystemMasteryIndex), "Masteries", "SystemMastery", "bi bi-masteries-nav-menu", null,
|
||||||
|
NavLinkMatch.Prefix, Released: false, ShowInNav: true),
|
||||||
|
new(typeof(Pages.Units), "Units", "units", "bi bi-units-nav-menu", null,
|
||||||
|
NavLinkMatch.Prefix, Released: false, ShowInNav: true),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IEnumerable<Entry> NavEntries => Entries.Where(e => e.Released && e.ShowInNav);
|
||||||
|
|
||||||
|
public static bool IsReleased(Type pageType) =>
|
||||||
|
Entries.FirstOrDefault(e => e.Page == pageType)?.Released ?? false;
|
||||||
|
}
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
<PropertyGroup>
|
||||||
<PropertyGroup>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<Nullable>enable</Nullable>
|
||||||
<Nullable>enable</Nullable>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
|
||||||
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
|
</PropertyGroup>
|
||||||
</PropertyGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
|
||||||
<ItemGroup>
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9"/>
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all"/>
|
<PackageReference Include="Markdig" Version="1.4.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" />
|
</ItemGroup>
|
||||||
<PackageReference Include="Telerik.UI.for.Blazor" Version="14.0.0" />
|
<ItemGroup>
|
||||||
</ItemGroup>
|
<ProjectReference Include="..\ZeroSpace.Model\ZeroSpace.Model.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
</Project>
|
||||||
<ProjectReference Include="..\ZeroSpace.Model\ZeroSpace.Model.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -8,4 +8,6 @@
|
|||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using ZeroSpace.Web
|
@using ZeroSpace.Web
|
||||||
@using ZeroSpace.Web.Layout
|
@using ZeroSpace.Web.Layout
|
||||||
|
@using ZeroSpace.Web.Components
|
||||||
|
@using ZeroSpace.Web.Services
|
||||||
@using ZeroSpace.Model
|
@using ZeroSpace.Model
|
||||||
@@ -1,7 +1,25 @@
|
|||||||
|
:root {
|
||||||
|
--zs-bg-base: #080c14;
|
||||||
|
--zs-bg-surface: #0f172a;
|
||||||
|
--zs-bg-card: rgba(15, 23, 42, 0.75);
|
||||||
|
--zs-bg-card-hover: rgba(30, 41, 59, 0.85);
|
||||||
|
--zs-border-subtle: rgba(56, 189, 248, 0.15);
|
||||||
|
--zs-border-glow: rgba(56, 189, 248, 0.45);
|
||||||
|
--zs-cyan: #38bdf8;
|
||||||
|
--zs-emerald: #10b981;
|
||||||
|
--zs-amber: #f59e0b;
|
||||||
|
--zs-purple: #a78bfa;
|
||||||
|
--zs-rose: #f43f5e;
|
||||||
|
--zs-text-main: #f0f6fc;
|
||||||
|
--zs-text-muted: #94a3b8;
|
||||||
|
--zs-text-dim: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
font-family: 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
background-color: #0d1117;
|
background-color: var(--zs-bg-base);
|
||||||
color: #c9d1d9;
|
color: var(--zs-text-main);
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1:focus {
|
h1:focus {
|
||||||
@@ -9,25 +27,234 @@ h1:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
color: #e6edf3;
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
a, .btn-link {
|
a, .btn-link {
|
||||||
color: #58a6ff;
|
color: var(--zs-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease, text-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover, .btn-link:hover {
|
||||||
|
color: #7dd3fc;
|
||||||
|
text-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: #8b949e;
|
color: var(--zs-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #080c14;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #334155;
|
||||||
|
border-color: rgba(56, 189, 248, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sci-Fi Glass Panels & Cards */
|
||||||
|
.zs-glass-card {
|
||||||
|
background: var(--zs-bg-card);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--zs-border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zs-glass-card:hover {
|
||||||
|
border-color: var(--zs-border-glow);
|
||||||
|
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.8), 0 0 20px rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zs-hero {
|
||||||
|
background: radial-gradient(ellipse at top right, rgba(56, 189, 248, 0.12) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse at bottom left, rgba(16, 185, 129, 0.08) 0%, transparent 60%),
|
||||||
|
rgba(15, 23, 42, 0.85);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sci-Fi Badges */
|
||||||
|
.badge-homeworld {
|
||||||
|
background: rgba(16, 185, 129, 0.18);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-core {
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
color: #fbbf24;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(245, 158, 11, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-side {
|
||||||
|
background: rgba(56, 189, 248, 0.18);
|
||||||
|
color: #7dd3fc;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(56, 189, 248, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-eden {
|
||||||
|
background: rgba(16, 185, 129, 0.18);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-keeper {
|
||||||
|
background: rgba(56, 189, 248, 0.18);
|
||||||
|
color: #7dd3fc;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(56, 189, 248, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-obsidian {
|
||||||
|
background: rgba(239, 68, 68, 0.18);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-mastery {
|
||||||
|
background: rgba(167, 139, 250, 0.18);
|
||||||
|
color: #c4b5fd;
|
||||||
|
border: 1px solid rgba(167, 139, 250, 0.45);
|
||||||
|
box-shadow: 0 0 8px rgba(167, 139, 250, 0.25);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pill-scifi {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sci-Fi Buttons */
|
||||||
|
.btn-scifi-primary {
|
||||||
|
background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #38bdf8;
|
||||||
|
box-shadow: 0 0 16px rgba(56, 189, 248, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-scifi-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #0369a1 0%, #075985 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 0 24px rgba(56, 189, 248, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-scifi-outline {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||||
|
box-shadow: 0 0 8px rgba(56, 189, 248, 0.15);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-scifi-outline:hover {
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
box-shadow: 0 0 16px rgba(56, 189, 248, 0.35);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List group dark theme styling */
|
||||||
|
.list-group-item {
|
||||||
|
background-color: rgba(15, 23, 42, 0.7);
|
||||||
|
border-color: rgba(56, 189, 248, 0.12);
|
||||||
|
color: var(--zs-text-main);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item-action:hover, .list-group-item-action:focus {
|
||||||
|
background-color: rgba(30, 41, 59, 0.8);
|
||||||
|
border-color: rgba(56, 189, 248, 0.3);
|
||||||
|
color: #38bdf8;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables in dark theme */
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-color: var(--zs-text-main);
|
||||||
|
--bs-table-border-color: rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
color: var(--zs-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid rgba(56, 189, 248, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Header Cyber Line */
|
||||||
|
.section-header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header .border-bottom {
|
||||||
|
border-color: rgba(56, 189, 248, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #1b6ec2;
|
background-color: #0284c7;
|
||||||
border-color: #1861ac;
|
border-color: #0369a1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #0284c7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -43,14 +270,16 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.validation-message {
|
.validation-message {
|
||||||
color: red;
|
color: #f43f5e;
|
||||||
}
|
}
|
||||||
|
|
||||||
#blazor-error-ui {
|
#blazor-error-ui {
|
||||||
color-scheme: light only;
|
color-scheme: dark only;
|
||||||
background: lightyellow;
|
background: #1e1b4b;
|
||||||
|
color: #f87171;
|
||||||
|
border-top: 1px solid rgba(244, 63, 94, 0.4);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.5);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: none;
|
display: none;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -65,10 +294,11 @@ p {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.75rem;
|
right: 0.75rem;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blazor-error-boundary {
|
.blazor-error-boundary {
|
||||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #881337;
|
||||||
padding: 1rem 1rem 1rem 3.7rem;
|
padding: 1rem 1rem 1rem 3.7rem;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -88,16 +318,17 @@ p {
|
|||||||
|
|
||||||
.loading-progress circle {
|
.loading-progress circle {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #e0e0e0;
|
stroke: #1e293b;
|
||||||
stroke-width: 0.6rem;
|
stroke-width: 0.6rem;
|
||||||
transform-origin: 50% 50%;
|
transform-origin: 50% 50%;
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-progress circle:last-child {
|
.loading-progress circle:last-child {
|
||||||
stroke: #1b6ec2;
|
stroke: #38bdf8;
|
||||||
stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
|
stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
|
||||||
transition: stroke-dasharray 0.05s ease-in-out;
|
transition: stroke-dasharray 0.05s ease-in-out;
|
||||||
|
filter: drop-shadow(0 0 6px #38bdf8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-progress-text {
|
.loading-progress-text {
|
||||||
@@ -105,6 +336,8 @@ p {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
|
inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
|
||||||
|
color: #38bdf8;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-progress-text:after {
|
.loading-progress-text:after {
|
||||||
@@ -112,7 +345,11 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
color: #c02d76;
|
color: #f43f5e;
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.9 KiB |