From bd1ab449c3722d657e2f3e4f4985098e2da454f3 Mon Sep 17 00:00:00 2001 From: Jackie Chen Date: Sat, 11 Jul 2026 21:30:30 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=E7=8D=A8=E7=AB=8B?= =?UTF-8?q?=E5=9C=B0=E5=9C=96=E7=B7=A8=E8=BC=AF=E5=99=A8=E5=B0=88=E6=A1=88?= =?UTF-8?q?=E4=B8=A6=E6=95=B4=E5=90=88=E4=B8=BB=E7=A8=8B=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出共用核心程式碼至 Shared 專案,新增 AgainstRomeMapEditor WinForms 編輯器,主程式加入地圖管理分頁並可啟動編輯器。地圖結構、複製、標記、材質編輯等皆以新 Maps 命名空間實作,並補齊 xUnit 測試。同步補充地圖格式與安全規範文件,移除硬編碼地圖數,確保自製圖完整納入流程。 --- AI_HANDOFF.md | 22 +- AgainstRomeModifier.csproj | 18 + AgainstRomeModifier.slnx | 2 + docs/map-editor-spec.md | 380 ++++++++++++++++++ docs/reverse-engineering/README.md | 3 +- docs/reverse-engineering/map-formats.md | 57 +++ src.MapEditor/AgainstRomeMapEditor.csproj | 16 + src.MapEditor/MapCanvasControl.cs | 239 +++++++++++ src.MapEditor/MapEditorForm.cs | 304 ++++++++++++++ src.MapEditor/Program.cs | 25 ++ src.Shared/AgainstRome.Shared.csproj | 13 + src.Shared/Maps/CustomMapManifest.cs | 43 ++ src.Shared/Maps/EndlessMapCatalog.cs | 62 +++ src.Shared/Maps/EndlessMapCloner.cs | 71 ++++ src.Shared/Maps/MapTextDocuments.cs | 131 ++++++ src/Core/EndlessAi/EndlessAiOrchestrator.cs | 12 +- src/Core/Localization.cs | 2 + src/Core/Services/BackupManager.cs | 4 + src/UI/ModifierForm.Layout.cs | 1 + src/UI/ModifierForm.MapManager.cs | 80 ++++ src/UI/ModifierForm.cs | 19 + .../MapEditorPhase1Tests.cs | 79 ++++ 22 files changed, 1577 insertions(+), 6 deletions(-) create mode 100644 docs/map-editor-spec.md create mode 100644 docs/reverse-engineering/map-formats.md create mode 100644 src.MapEditor/AgainstRomeMapEditor.csproj create mode 100644 src.MapEditor/MapCanvasControl.cs create mode 100644 src.MapEditor/MapEditorForm.cs create mode 100644 src.MapEditor/Program.cs create mode 100644 src.Shared/AgainstRome.Shared.csproj create mode 100644 src.Shared/Maps/CustomMapManifest.cs create mode 100644 src.Shared/Maps/EndlessMapCatalog.cs create mode 100644 src.Shared/Maps/EndlessMapCloner.cs create mode 100644 src.Shared/Maps/MapTextDocuments.cs create mode 100644 src/UI/ModifierForm.MapManager.cs create mode 100644 tests/AgainstRomeModifier.Tests/MapEditorPhase1Tests.cs diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md index 0855b27..f62658c 100644 --- a/AI_HANDOFF.md +++ b/AI_HANDOFF.md @@ -1,4 +1,4 @@ -# AI Handoff - Live Project Memory +# AI Handoff - Live Project Memory ## History Consolidation (2026-07-11) @@ -14,7 +14,25 @@ ## Current Objective -Keep the modifier maintainable and safe while documentation stays synchronized with the post-2026-07-10 codebase. The 2026-07-11 refresh and consolidation are complete in the current working tree: public README files, technical specifications, reverse-engineering notes, schema metadata, UI-maintenance rules, and this handoff are current. Completed-plan, historical-review, duplicate verification, and unimplemented-research documents were removed after their durable guidance was merged into canonical documents. Changes are intentionally uncommitted pending user direction. +Build a dedicated map editor that can eventually provide an Age-of-Empires-II-like authored-map workflow, while preserving Against Rome's undocumented-format safety constraints. `docs/map-editor-spec.md` remains the staged implementation guide. Changes are intentionally uncommitted pending user direction. + +## Map Editor Phase 1 Progress (2026-07-11) + +- Added `src.Shared/AgainstRome.Shared.csproj`; `GameLZSS`, `FileRollbackScope`, and `SafeFileWriter` now have one shared implementation. The original modifier references it without changing their namespaces or call sites. +- Added `src.MapEditor/AgainstRomeMapEditor.csproj`, producing `AgainstRomeMapEditor.exe`. It accepts `--game ` and optional `--map ENDL_NNN`, lists maps, clones a selected map into the next available `ENDL_005..999` slot, and safely edits the Phase 1 briefing/boden.ini fields. +- Shared map core implements actual `ENDL_???` discovery, `.arm_custom_map` + `MAPS/arm_custom_maps.json` registration, temporary-directory cloning, unknown-file byte preservation, briefing title changes, and SDL `name` map-path rewrite. All PFIL rewrites retain their original 64-byte headers and write through `SafeFileWriter`; multi-file property saves use `FileRollbackScope`. +- Map text is strict Windows-1251. A title that cannot be represented by the game encoding fails safely rather than being written as `?`. +- `EndlessAiOrchestrator` now enumerates existing `ENDL_???` directories for `ak_level.bci`/SDL targets and calculates expected map-target counts dynamically. `BackupManager` excludes directories marked `.arm_custom_map` from original `team.dat` baseline / `.bak` handling; map population patching remains unchanged and still applies to all maps. +- Added `MapEditorPhase1Tests`; latest verification: `dotnet test tests/AgainstRomeModifier.Tests/AgainstRomeModifier.Tests.csproj -c Release --no-restore` passed **100/100**. A full Release solution build also passed before this handoff update. +- Manual smoke evidence (user, 2026-07-11): a copied new map appears usable and can enter the game. This validates the slot-discovery and basic load path. The user explicitly deferred AI long-run behavior and save/load verification; do not treat either as verified. +- Phase 2 viewer: `MapCanvasControl` now provides read-only minimap, collision bitmap, and deterministic `boden.txt` 64x64 texture-grid views. It deliberately does not overlay SDL objects: the world-coordinate-to-image calibration is still unverified and must not be guessed. +- First authored-map tool: the texture-grid view now has a material palette and a click-to-paint single-tile brush. It writes only the static-verified `boden.txt` 64×64 table, only after the user saves, only to a `.arm_custom_map` custom map, and through the same multi-file `FileRollbackScope` transaction as map properties. Original maps are now read-only in the editor. `BodenTexturesDocument` has exact-cell parsing/writes and a round-trip test; latest xUnit count is **101 passed**. +- Editor UX redesign after user feedback: the previous nested-tab prototype was replaced with a 1440×900 canvas-first workspace. Maps are always visible at left; the center is the active canvas; the right inspector exposes the material palette and map properties; top command/tool bars expose clone, save, undo/redo, material editing, minimap, and collision preview. The material brush supports click-drag painting, tile hover coordinates, palette search/color swatches, undo/redo, dirty-state indication, and save/discard/cancel prompts. Height, collision-write, and object tools are visible but disabled with explicit validation labels. Release build has 0 warnings/errors, xUnit passes 101/101, and a hidden runtime startup smoke test confirmed the form constructs and remains running. +- Stale-launch diagnosis: the user's screenshot showed the old nested-tab UI because `bin/Debug/net8.0-windows/AgainstRomeMapEditor.dll` predated the newly built `src.MapEditor/bin/Debug` DLL. Debug and Release outputs are now rebuilt and hash-matched. `ModifierForm.MapManager` also resolves the newest development DLL/EXE candidate by timestamp while published builds continue to use the sibling executable, preventing an old copied editor from being launched silently. +- Texture-view correction after visual feedback: the first canvas-first build still rendered every texture ID as a hash-derived random color, producing an unusable multicolor grid. The material editor now draws the real `minimap.bmp` as its base, overlays a restrained 64×64 tile grid, and colors only changed cells. Palette swatches are derived from the average minimap color of all cells using that texture ID. Workspace texture folders contain no matching texture images, so IDs such as `L5B09T1A` remain the only available source labels. The computer-use visual QA pass confirmed the new canvas is a recognizable terrain map rather than a random-color wall. +- Material-tool UX correction after a second user screenshot: raw IDs are no longer the primary workflow. The right inspector defaults to a current-brush swatch plus explicit instructions; right-clicking a map tile samples its material and left-drag paints it elsewhere. Status/brush labels use stable per-map names such as `地表樣式 001`. The raw-ID library is hidden behind an explicit `顯示進階材質庫(內部 ID)` checkbox. A second computer-use visual QA pass confirmed the default inspector is clean and no longer presents an unexplained ID wall. +- Real-render preview boundary: the user requires the preview to be the actual game-rendered scene. The editor now labels minimap/collision views as data/editing backgrounds, not game previews, and exposes `在遊戲中預覽`: it transactionally saves dirty custom-map data, launches `Against_Rome.exe` with the selected game root as working directory, and tells the user which `ENDL_NNN` slot to choose. No verified direct-map command-line argument exists. Static EXE evidence for unreachable mode 9 / `TextureEditor V0.1` is documented in `docs/reverse-engineering/map-formats.md`; it appears to be a procedural texture/debug tool, not yet a safe 3D map-editor entry point. +- Phase 3 static baseline: five original ENDL samples were measured read-only and documented in `docs/reverse-engineering/map-formats.md`. BMP dimensions/channel classes and the 64×64 / 256×256 / 257×257 grid relation are static-verified; `vertex.bmp` height semantics, collision black/white semantics, cache-file authority, and SDL-to-minimap calibration are explicitly unverified. Do not expose any corresponding write control until the document's one-variable-at-a-time runtime experiments succeed. ## Verified Current State diff --git a/AgainstRomeModifier.csproj b/AgainstRomeModifier.csproj index 243c75f..f5e0262 100644 --- a/AgainstRomeModifier.csproj +++ b/AgainstRomeModifier.csproj @@ -17,6 +17,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/AgainstRomeModifier.slnx b/AgainstRomeModifier.slnx index e95f033..052651d 100644 --- a/AgainstRomeModifier.slnx +++ b/AgainstRomeModifier.slnx @@ -1,4 +1,6 @@ + + diff --git a/docs/map-editor-spec.md b/docs/map-editor-spec.md new file mode 100644 index 0000000..a221c13 --- /dev/null +++ b/docs/map-editor-spec.md @@ -0,0 +1,380 @@ +# Against Rome 無盡模式地圖編輯器 — 實作任務規格書 + +> 本文件是自足的實作規格:實作者只需要本文件 + 本 repo 即可完成開發,不需要其他對話上下文。 +> 語言慣例:說明用繁體中文,程式識別字/檔名/技術名詞保留英文。 + +--- + +## 0. 任務摘要 + +為 2004 年遊戲《Against Rome》建立一個「世紀帝國式」的**無盡模式(Endless)地圖編輯器**,讓玩家能以現有地圖為範本建立、編輯自己的 ENDL 地圖。 + +**架構決策(已由需求方確定,不可更改):** + +1. 地圖編輯器是**獨立程式**(獨立 WinForms EXE),與現有修改器 `AgainstRomeModifier` 互不干擾。 +2. 修改器只新增一個「**地圖管理**」分頁,內含地圖清單與「啟動編輯器」按鈕;按下按鈕才啟動編輯器程式。 +3. 分三個 Phase 交付;Phase 1 只使用**已完全理解的檔案格式**(全部列於本文件第 2 章),不需要任何逆向工程。 + +**關鍵可行性事實(已調查確認):** + +- 遊戲**沒有**內建編輯器。 +- 遊戲以檔案系統掃描列舉地圖:EXE 內含 `FindFirstFile("MAPS\*.*")` 掃描模式與 `ENDL_%03ld` printf 格式字串 → 新建 `MAPS/ENDL_005/` 資料夾**很可能**自動出現在無盡模式選單(此假設需第一次實作後由使用者進遊戲驗證;若失敗,fallback 方案見 §6.3)。 +- 地圖的「材質繪製」「環境參數」「聚落擺放」「文字」全部是**文字格式**(見第 2 章),Phase 1 即可編輯;只有地形高度等 BMP/二進位圖層語意未知,留待 Phase 3。 + +--- + +## 1. 專案結構與整合方式 + +### 1.1 現有 repo 概況 + +- 主專案 `AgainstRomeModifier.csproj`(repo 根目錄):C# / .NET 8 WinForms、x64、`Nullable=enable`,原始碼在 `src/`。 +- 測試 `tests/AgainstRomeModifier.Tests/`(xUnit,`InternalsVisibleTo` 已開)。 +- 解決方案檔:`AgainstRomeModifier.slnx`(新式格式)。 +- repo 內含一份**唯讀**的原版遊戲檔案 `遊戲原始檔案/`(用於測試 fixture 與格式參考,絕不可修改)。 +- CI:`.github/workflows/ci.yml`,windows-latest,`dotnet build` + `dotnet test`。 + +### 1.2 新增專案 + +| 專案 | 類型 | 說明 | +|---|---|---| +| `src.Shared/AgainstRome.Shared.csproj`(或等效路徑) | classlib, net8.0 | 兩程式共用的核心程式碼(見 1.3) | +| `src.MapEditor/AgainstRomeMapEditor.csproj` | WinExe, net8.0-windows, x64, WinForms | 地圖編輯器本體 | + +兩者都加入 `AgainstRomeModifier.slnx` 與 CI 的 build 步驟。編輯器 EXE 建置後應輸出到修改器可預期的相對位置(建議:與修改器 EXE 同資料夾,檔名 `AgainstRomeMapEditor.exe`)。 + +### 1.3 程式碼共用策略 + +把以下**與 UI 無關**的既有類別從主專案移入 `AgainstRome.Shared`(namespace 可保留 `AgainstRomeModifier` 以減少改動,或建新 namespace 並修 using): + +- `src/Core/GameLZSS.cs` — PFIL@ LZSS 壓縮/解壓(格式核心,見 §2.1)。 +- `src/Core/Services/SafeFileWriter.cs` — 原子寫入(temp + `File.Replace`,3 次重試,內容相同跳過)。 +- `src/Core/FileRollbackScope.cs` — 跨多檔交易(`TrackFile` → `Commit`,例外時自動還原)。 +- Windows-1251 編碼常數(現在散在 `src/Core/Patches/PatchText.cs` 的 `GameEncoding`)。 + +**硬性要求:搬移後修改器行為不得改變**,`dotnet test` 現有 ~98 個測試必須全綠。修改器專案改為引用 Shared 專案。 + +### 1.4 修改器側:新「地圖管理」分頁 + +修改器 UI 是 partial class `ModifierForm`(`src/UI/ModifierForm.*.cs`),主分頁用自訂 `ModernTabControl`,分頁以 `ShowTabPage(TabPage)`(`src/UI/ModifierForm.cs` 約 :379)延遲載入。新增: + +- 新 partial 檔 `src/UI/ModifierForm.MapManager.cs`:分頁「地圖管理 / Map Manager」。 +- 內容: + - **地圖清單**(ListView,大圖示):每張 `MAPS/ENDL_*` 地圖一項,顯示 `minimap.bmp` 縮圖、顯示名稱(讀 `TEXT/US/briefing.put` 的 `briefing_titel_1`,見 §2.5)、槽位編號、「自製」標記(有 `.arm_custom_map` 標記檔者)。 + - **「啟動編輯器」按鈕**:`Process.Start` 啟動 `AgainstRomeMapEditor.exe`,命令列參數:`--game "" [--map ENDL_005]`(gamePath 來自現有 `GetGamePath()`,`src/UI/ModifierForm.Data.cs:23`)。找不到 EXE 時顯示友善錯誤。 + - **「刪除自製地圖」按鈕**:僅自製圖可用,走 §4.2 的三重防護,需二次確認對話框。 +- 所有 UI 字串走現有 `Loc.Get` 雙語機制(zh-TW / EN),字串加在 `src/Core/Localization.cs` 與 `src/UI/` 的 Localization 部分。 + +### 1.5 修改器側:既有程式碼的必要調整 + +1. **`src/Core/EndlessAi/EndlessAiOrchestrator.cs`**(修改器的無盡模式 AI 修補協調器)目前硬編碼 5 張圖: + - `ResolvePaths`(約 :211-224):`ak_level.bci` 分支用 `for (int i = 0; i < 5; i++)` → 改為 `Directory.GetDirectories(mapsPath, "ENDL_???")` 動態列舉(SDL 分支 :235-244 已經是動態掃描,照它的做法)。 + - `GetExpectedFileCount`(約 :255-263):固定回傳 5(ak_level.bci)與 42(SDL)→ 改為依 gamePath 動態計算(需改簽章傳入 gamePath;呼叫點在 `DetectModule` :278)。 + - 效果:玩家自製圖會自動被無盡模式 AI 修補涵蓋(剛複製出的圖內容與原廠相同,Detect 狀態天然一致)。 +2. **`src/Core/Services/BackupManager.cs`** 會遞迴掃描 `MAPS/**/team.dat` 建備份基準。規範:**自製圖(有 `.arm_custom_map` 標記的資料夾)不進備份基準、不產 `.bak`**;掃描時排除之。完整還原時提供「保留自製地圖」選項(預設保留),不保留則呼叫 §4.2 的刪除流程。 +3. 全庫 grep `ENDL_` 確認沒有其他硬編碼 5 張圖的假設。`Map/MaxPopulationFeature`(套用到所有 `MAPS/**/team.dat`)對自製圖生效是**預期行為**,不需排除。 + +--- + +## 2. 遊戲地圖檔案格式參考(自足,已驗證) + +### 2.1 PFIL@ 容器格式(LZSS 壓縮) + +許多遊戲檔案包在自訂容器裡。判別:檔案前 4 bytes = `50 46 49 4C`(`"PFIL"`,第 5 byte 為 `40` = `'@'`)。 + +- **64-byte header**,已知欄位: + - offset 0x00:magic `PFIL@\0\0\0` + - offset 0x08:`01 00 00 00`(版本?) + - offset 0x0C:`02 00 00 00` + - **offset 0x10:uint32-LE = 解壓後大小**(唯一需要重寫的欄位) + - offset 0x20 起:內部指標/雜訊(依檔案而異,原樣保留即可) +- **64 bytes 之後**是 LZSS 壓縮資料:4096-byte 環狀視窗,初始化為前 0xFEE(4078)個 `0x20` + 最後 18 個 `0x00`;控制 byte 每 bit 指示 literal(1)或 2-byte 字典指標(0);指標編碼 `offset = p1 | ((p2 & 0xF0) << 4)`、`length = (p2 & 0x0F) + 3`。 +- **不要重新實作**:直接用 Shared 的 `GameLZSS.DecompressPfil(byte[])` 與 `GameLZSS.CompressPfil(byte[] payload, byte[] origHeader)`。`CompressPfil` 需要**原始檔的 64-byte header** 當模板(它只改 0x10 的大小欄位)— 所以讀檔時務必保留原始 bytes。round-trip 已被現有測試驗證與遊戲相容。 + +### 2.2 文字編碼 + +所有遊戲文字檔(含 PFIL 解壓後的文字)使用 **Windows-1251** 編碼(`Encoding.GetEncoding(1251)`,需 `CodePagesEncodingProvider.Instance` 註冊)。注意:本 repo 附的遊戲版本 `TEXT/US/` 內容實際是俄文文字。**編輯器寫入的文字必須限制在 CP1251 可表示的字元**(拉丁 + 西里爾);UI 應在使用者輸入無法編碼的字元(如中文)時警告(遊戲無法顯示)。 + +### 2.3 地圖資料夾清單(以 ENDL_000 為例,大小為原版實測) + +每張地圖 = `MAPS/ENDL_00N/` 資料夾。遊戲共 5 張原廠無盡圖 `ENDL_000`–`ENDL_004`。 + +| 檔案 | 大小(bytes) | 格式 | Phase 1 處理 | +|---|---|---|---| +| `boden.ini` | 1,457 | PFIL→INI 文字 | **可編輯**(環境參數,§2.4) | +| `boden.txt` | 7,255 | PFIL→文字 | **可編輯**(每格材質表,§2.7) | +| `preload.ini` | 457 | 純文字 | 原樣複製(`[ObjDefPreLoad]` 城門物件預載清單) | +| `boden.bmp` / `vertex.bmp` / `emboss.bmp` / `smooth.bmp` | 各 198,460 | 24-bit BMP,257×257 | 原樣複製(語意未知,Phase 3) | +| `collision.bmp` / `minimap.bmp` | 各 196,66x | 24-bit BMP,256×256 | 原樣複製;minimap 可當縮圖顯示 | +| `daynight.bmp` | 488 | 小 BMP | 原樣複製 | +| `briefpic.tga` | 1,950,764 | TGA(簡報圖) | 原樣複製;可選:允許替換圖片 | +| `cliprect.dat` / `shadows.dat` / `skydens.dat` / `visible.dat` | 2,698 / 140,553 / 40,184 / 16,036 | 二進位(未知) | 原樣複製(boden.ini 的 `Handle*Map` 鍵暗示這些是預計算快取) | +| `Endlos_{Ger,Hun,Kel,Rom}_Siedlung{1,2}.sdl` ×8 | 887–5,565 | PFIL→INI 文字 | **可編輯**(聚落模板,§2.6);8 檔跨五張原廠圖 byte-identical | +| `DATA/*.dat` ×25 | 93–304,758 | 全部 PFIL 包裝的二進位,**格式未知** | **全部原樣複製**。其中 `DATA/hirarchy.dat` 跨圖完全相同 | +| `SCRIPT/ak_level.bci` | 27,234 | PFIL→BCI0 編譯腳本 | 原樣複製(無盡模式出兵/派系邏輯;不含地圖專屬路徑,複製後照常運作) | +| `TEXT/US/briefing.put` | 1,548 | **純文字**(非 PFIL),CP1251 | **可編輯**(地圖名稱/簡報,§2.5) | +| `TEXT/US/text.put` | 1,216 | 純文字,CP1251 | **可編輯**(任務目標文字) | + +注意:`TEXTUREN/`(或 `Texturen/`)子資料夾存在但為空,複製時保留空資料夾。`ENDL_003` 另有額外 SDL(`Siedlung3/4`、`vil.sdl`)與一個重複的 `Boden.bmp` — 複製邏輯必須「複製來源資料夾的**全部**內容」而非白名單,以免漏檔。 + +### 2.4 `boden.ini` — 環境參數(PFIL→INI) + +格式:`[鍵名]` 行(含 `;` 註解)後跟一行值。實測 ENDL_000 完整內容(節錄關鍵鍵;註解為原檔德文): + +```ini +[Waterlevel] ;0.. +120 +[Heightmapstep] ;1.. +4 +[ShadowMeshMode] ;0=太陽只上下移動, 1=太陽東南西原始移動 +0 +[WasserTexturName] ;水面貼圖基底名 name00.bmp,... +wassAW +[CausticTexturName] +caustA +[SkyTexturName] +sky +[RainDropsOnWater] ;0/1 +1 +[WaterWarpShift] ;18(低)..10(高), 14=default +12 +[WaterBumpAmplitude] ;0..1024, 256=default +256 +[WaterBumpFrequency] ;1..16, 4=default +4 +[FlashPropability] ;每秒閃電數 0..1000 +8 +[WaterColor] ;水色 Hex (bgr), default=0xffbf7f +0xffdfbf +[DayStartTime] ;白天開始(時) +6 +[DayEndTime] ;夜晚開始(時) +20 +``` + +(其餘鍵:`Skydensspread`、`Skydensaccuracy`、`ShadowMeshAccuracy`、`ShadowMeshXZsize`、`ShadowMeshYsize`、`HandleSkyDensMap`、`HandleVisibleMap`、`HandleClipRectMap`、`HandleShadowMeshes`、`FlashObjectDefaultIndex`、`FlashObjectDefault2Index`、`FlashLightDefaultIndex`、`SnowAlrIndex`、`SnowShadowIndex`、`SnowShadowSize`、`HagelShadowIndex`、`HagelShadowSize`、`MoveListAmplitude`、`ShowCollisionMesh` — 以原檔為準,編輯器 UI 至少要露出:Waterlevel、WaterColor、DayStartTime/DayEndTime、RainDropsOnWater、FlashPropability、Water* 系列。) + +**編輯規則:只改值行,其他行(含註解、順序、空白)逐字保留**,再以 `CompressPfil` + 原 header 回寫。 + +### 2.5 `TEXT/US/*.put` — 地圖名稱與簡報(純文字,CP1251) + +`.put` 是**未壓縮**純文字,`//` 註解,格式為 `var:名稱 ="字串";`,字串可跨多行串接(相鄰字串字面值自動連接,同 C 語法),`\n` 為換行。實例(briefing.put 節錄): + +```c +//Briefing-Texte fuer Szenario ENDL_000 US + +var:briefing_titel_1 ="<地圖清單顯示的標題>"; +var:briefing_titel_2 ="<副標題>"; +var:briefing_text ="<簡報正文,可多行串接>" +"<續行>"; +var:debriefing_text_win ="<勝利文字>"; +var:debriefing_text_loss ="<失敗文字>"; +var:briefing_text_sample_name="ENDL_ALL_00.wav"; +var:briefing_text_teamname0 ="<隊伍0名>"; +... teamname1..teamname7 +``` + +`text.put` 同語法,變數名為 `endl_000_text_00`..`03`(任務目標四種狀態)。**注意:複製地圖到新槽位時,`endl_NNN_text_*` 的編號是否需要跟著改成新槽位編號未驗證** — 實作時先原樣保留測試;若遊戲內任務目標文字消失,則把變數名中的編號改為新槽位號再測(此為第一個進遊戲驗證項目之一)。 + +編輯器 Phase 1 需提供:改 `briefing_titel_1/2`(= 地圖顯示名)、`briefing_text`、隊伍名 `teamname0..7`。 + +### 2.6 `Endlos_*_Siedlung*.sdl` — 聚落模板(PFIL→INI 文字) + +無盡模式各派系(Ger/Hun/Kel/Rom ×2 階段)的聚落建築佈局。實測結構(Endlos_Rom_Siedlung1.sdl 節錄): + +```ini +[settlement] +name =Endlos_Rom_Siedlung1 +createdt=2003/09/08 +createtm=13:22:22 +teamsett=-1 +refpos =1632,159,5792 ; 參考點 x,y,z(整數) + +[object0000] +name =MAPS/ENDL_000/Endlos_Rom_Siedlung1.sdl +namedef =BauRomPal02_Palisadenecke ; objdef 名稱(對照 SYSTEM 的 objdef.dau) +def =1676 ; objdef 數字索引 +pos =-320.00,0.00,-512.00 ; 相對 refpos 的 x,y,z(浮點) +team =8 +lprel =1.00 ; 生命值比例 +mprel =0.00 +nation =3 ; 0..3 派系 +ruhm =0.00 ; 榮譽 +moral =0.00 +objdefn0= ; 三組 objdef 覆寫欄(通常空) +objdefv0=926298413 +objdefn1= / objdefv1=926298413 +objdefn2= / objdefv2=926298413 +anzv =0,0,0 +resv =0,0,0,0,0,0 ; 資源存量 +angle =0.00 ; 朝向角 +formdefn= / formdef =-1 +onload =0 + +[object0001] ...(依序遞增,四位數零填) +``` + +Phase 1 編輯操作:改 `team`/`nation`、整體平移(改 `refpos`,物件 `pos` 是相對座標不用動)、增/刪 `[objectNNNN]` 區塊(刪後**重新連續編號**)、改單一物件 `pos`/`angle`/`def`+`namedef`(objdef 名稱清單可解析 `SYSTEM/` 的 `objdef.dau` 取得,repo 的 `ObjdefPatcher.cs` 有現成解析可參考)。`name` 欄位含來源路徑字串(`MAPS/ENDL_000/...`),複製到新槽位時同步改寫為新路徑(保守作法;不確定遊戲是否讀它,但改掉無害)。 + +### 2.7 `boden.txt` — 每格材質表(PFIL→文字)★ 材質繪製的關鍵 + +``` +[Dimension] +64 +64 +[Texturen] +4BJ___51 +4BJ___52 +4UJX__11 +...(共 64×64 = 4096 行,每行一個材質 tile 名稱,row-major) +``` + +地圖是 **64×64 tile 網格**;每 tile 一個 8 字元材質名(對應遊戲材質庫)。這代表**地表材質繪製在 Phase 1/2 就能做**(改文字即可),不需逆向 BMP!材質名清單可從五張原廠圖的 boden.txt 聯集取得(去重後做成調色盤)。注意:材質名有系列規律(如 `4BJ___5x`、`4UJX__xx`),相鄰 tile 用系列變體拼接過渡 — 編輯器先提供「單格替換 + 從調色盤選」即可,過渡美化交給使用者。 + +`[Heightmapstep] 4`(boden.ini)× 64 tiles = 256,+1 = 257 → 對應 vertex/boden/emboss/smooth.bmp 的 257×257(頂點網格),collision/minimap 的 256×256(tile 網格)。此對應關係是 Phase 3 逆向的起點。 + +### 2.8 槽位規則 + +- 資料夾名 `ENDL_%03d`(EXE printf 字串 `ENDL_%03ld`),原廠佔用 000–004,自製圖從 **005** 起,上限 999。 +- 新槽位 = 最小可用編號(填補空洞)。 +- 顯示名稱來自 `briefing.put` 的 `briefing_titel_1`,與資料夾名無關。 + +--- + +## 3. 地圖編輯器功能規格(分 Phase) + +### Phase 1 — 地圖複製與屬性編輯(必要,先交付) + +**編輯器程式骨架:** + +- `AgainstRomeMapEditor.exe`,WinForms,啟動參數 `--game ""`(必要)與 `--map ENDL_NNN`(可選,直接開啟該圖)。無參數時顯示遊戲路徑選擇(可重用修改器的 registry 偵測邏輯:`HKCU/HKLM\Software\Against Rome` 的 `Path` 值,fallback `C:\Program Files (x86)\Against Rome`)。 +- 主視窗:左側地圖清單(同修改器地圖管理頁的資料來源),右側編輯區(分頁:基本資訊 / 環境參數 / 聚落 / 材質(可延後))。 + +**核心類別(建議放 Shared 或編輯器專案的 Core/):** + +| 類別 | 職責 | +|---|---| +| `EndlessMapCatalog` | 掃描 `/MAPS/ENDL_*`;讀顯示名稱、minimap 縮圖、自製標記;`GetNextFreeSlot()` | +| `EndlessMapCloner` | `Clone(sourceSlot, newSlot, newName)`:見下方複製流程 | +| `CustomMapManifest` | 自製圖登記與刪除防護(§4.2) | +| `BodenIniDocument` | boden.ini 讀改寫(§2.4 規則) | +| `BodenTexturesDocument` | boden.txt 讀改寫(64×64 材質陣列) | +| `PutTextDocument` | .put 讀改寫(CP1251、`var:` 語法、保留註解與未改動行) | +| `SdlDocument` | SDL 讀改寫(§2.6) | + +**複製流程(必須照此順序,保證無殘留):** + +1. 目標暫存資料夾 `MAPS/ENDL_NNN.tmp_arm`,遞迴複製來源圖**全部**檔案(逐 byte,含未知 .dat 與 ak_level.bci)。 +2. 改寫新圖的 `briefing.put` 標題為使用者輸入名稱;SDL 的 `name` 路徑欄改為新槽位路徑。 +3. 寫入標記檔 `.arm_custom_map`(內容:JSON,含建立時間、來源槽位、工具版本)。 +4. 驗證:檔案數與總大小和來源一致(標記檔除外)。 +5. `Directory.Move` 改名為 `ENDL_NNN` → 更新 manifest(§4.2)。 +6. 任一步失敗:刪除整個暫存資料夾後回報錯誤(新增性操作,rollback = 刪暫存夾)。 + +**編輯功能:** + +- 基本資訊:地圖名(titel_1/2)、簡報文字、隊伍名 0–7。 +- 環境參數:§2.4 列出的 boden.ini 鍵(數值用 NumericUpDown / 顏色用色彩選擇器轉 0xBBGGRR hex)。 +- 聚落:8 個 SDL 的清單 → 物件表格(namedef/def/pos/angle/team)、整體平移、增刪物件。 +- 材質(可作為 Phase 1.5):64×64 網格檢視(每格填色或縮寫),點格子從材質調色盤替換。 +- **原廠圖(ENDL_000–004)一律唯讀**,只能「另存為新地圖」;要改原廠圖 = 先複製再編輯。 + +**存檔紀律:** 所有寫入經 `SafeFileWriter`;一次「儲存變更」內的多檔寫入包在 `FileRollbackScope`(全部 `TrackFile` → 寫入 → `Commit`)。PFIL 檔回寫必用原始 64-byte header。 + +### Phase 2 — 視覺化地圖檢視器 + +- `MapCanvasControl`(double-buffered):疊層渲染 minimap.bmp / boden.bmp / collision.bmp / 材質網格(boden.txt 上色),縮放平移、圖層開關。256 vs 257 尺寸差 = tile 網格 vs 頂點網格,對齊時 tile (i,j) 對應頂點 (i..i+1, j..j+1)。 +- SDL 物件疊加:世界座標 ↔ 像素對應需推導 — 方法:取已知聚落 `refpos`(如 1632,159,5792)對照 minimap 上目視位置,用五張原廠圖交叉擬合 scale/原點/軸向(BMP 是 bottom-up,注意 Y 翻轉)。先只做唯讀疊加讓使用者目視驗證,再開放拖曳(拖曳寫回 SdlDocument)。 +- 材質繪製 UI 升級:在畫布上直接筆刷塗 boden.txt 材質。 + +### Phase 3 — 地形高度/碰撞編輯(依賴逆向工程) + +逆向任務與驗證方法(**只在自製複製圖上實驗**,由使用者進遊戲目視驗證,絕不動原廠圖): + +1. **BMP 圖層語意**:假設 `vertex.bmp`(257²)編碼高度(可能灰階或單通道)、`boden.bmp` 為預烘焙底色、`emboss/smooth` 為光影。驗證:單點/區塊像素修改 → 進遊戲看地形變化;並跨五張圖統計圖層 vs 水域/山脈特徵相關性。 +2. **`collision.bmp` 編碼**:改一小塊測單位通行性。 +3. **`DATA/*.dat` 權威性**:逐一清空/移除單檔(如 `way.dat`)觀察遊戲重建或崩潰;`hirarchy.dat` 跨圖相同 → 大概率可永遠原樣複製。 +4. 成果依序解鎖:高度筆刷(vertex.bmp + Heightmapstep 換算)→ 碰撞編輯 → minimap 自動重繪。 +5. 所有逆向成果寫入新文件 `docs/reverse-engineering/map-formats.md`。 + +--- + +## 4. 安全與備份規範(硬性規則) + +### 4.1 通用 + +- **絕不**直接用 `File.WriteAllBytes` 寫遊戲目錄 — 一律 `SafeFileWriter` + `FileRollbackScope`。 +- **絕不**修改 repo 內的 `遊戲原始檔案/`。 +- 測試**絕不**接觸真實遊戲安裝目錄(沿用現有 SyntheticFixture / BackupZipGameFixture 模式)。 + +### 4.2 自製地圖的登記與刪除三重防護 + +- Manifest:`/MAPS/arm_custom_maps.json`,記錄工具建立的資料夾清單。 +- 刪除一個地圖資料夾必須**同時**滿足: + 1. 在 manifest 清單中; + 2. 資料夾內有 `.arm_custom_map` 標記檔; + 3. 路徑符合 `MAPS[/\\]ENDL_\d{3}$` 且編號 ≥ 005。 +- **任何情況下拒絕刪除 ENDL_000–004** 與 MAPS 以外路徑。 +- 自製圖不進 BackupManager 備份基準、不產 .bak(§1.5-2)。 + +--- + +## 5. 測試清單(xUnit,加入 `tests/AgainstRomeModifier.Tests` 或新測試專案) + +全部用合成 fixture(在 temp 目錄組出假遊戲樹;可從 `遊戲原始檔案/MAPS/ENDL_000` 取樣本檔,或用現有 BackupZipGameFixture 模式): + +- **MapClonerTests**:`GetNextFreeSlot` 取 005 / 填補空洞;複製後逐 byte 相同(標記檔與改寫檔除外);標記檔與 manifest 正確;模擬中途失敗(鎖檔)→ 無 `.tmp_arm` 殘留、無半成品正式資料夾。 +- **BodenIniDocumentTests**:解壓 → 改 Waterlevel → 壓縮 → 再解壓值正確;未改動行(含德文註解)逐字保留;PFIL header 0x10 大小欄正確。 +- **BodenTexturesDocumentTests**:4096 格解析、單格替換 round-trip。 +- **PutTextDocumentTests**:CP1251 round-trip、改 titel 保留其他變數與註解、多行字串串接解析、不可編碼字元偵測。 +- **SdlDocumentTests**:解析 [settlement]/[objectNNNN] 全欄位、改 team / 整體平移 / 增刪物件後重編號 round-trip、PFIL 往返。 +- **CustomMapManifestTests**:三重防護各自缺一即拒刪;拒刪 ENDL_000–004;拒刪 MAPS 外路徑。 +- **EndlessAiOrchestratorTests(擴充現有)**:fixture 加 ENDL_005 後 `ResolvePaths` 回傳 6 個 ak_level.bci、`GetExpectedFileCount` 動態正確、Detect/Apply 涵蓋新圖;無自製圖時行為與現在完全相同(回歸)。 +- **BackupManagerTests(擴充)**:自製圖 team.dat 不進備份基準。 +- CI:三個(以上)專案 build + `dotnet test` 全綠(windows-latest)。 + +--- + +## 6. 驗收與進遊戲驗證 + +### 6.1 自動驗證 + +- `dotnet build -c Release`(x64)無新增警告;全部測試綠。 +- 修改器既有功能零回歸(現有測試全綠即視為通過)。 + +### 6.2 使用者進遊戲手動驗證(關鍵假設,Phase 1 完成後立即做) + +1. 用編輯器複製 ENDL_000 → ENDL_005 並改名 → 開遊戲:無盡模式選單**出現第 6 張圖**(驗證檔案掃描假設)。 +2. 顯示名稱為新名稱(briefing.put 生效);任務目標文字正常(驗證 §2.5 的 `endl_NNN_text_*` 編號問題)。 +3. 新圖可載入、AI 派系正常出兵發展(ak_level.bci 原樣複製有效)、存檔/讀檔正常。 +4. 修改器「完整還原」後:原廠五圖完好;自製圖依選項保留/刪除。 +5. 改 Waterlevel/WaterColor 後進遊戲目視水位/水色變化;改 SDL 後聚落佈局變化。 + +### 6.3 Fallback(若 ENDL_005 不出現在選單) + +代表遊戲用固定 0–4 迴圈探測而非目錄掃描。改用「**覆蓋槽位**」模式:編輯器把自製圖存放在 `MAPS/ARM_CUSTOM/<名稱>/`(遊戲不讀),「啟用」時備份原廠 ENDL_00N(`.bak` 或搬到 ARM_CUSTOM/_originals)再把自製圖複製進該槽;「停用」時還原。此模式下 manifest 需額外記錄「哪個槽位目前被哪張自製圖佔用」。UI 與文件其餘規格不變。 + +--- + +## 附錄 A:調查證據摘要 + +- EXE 字串(Against_Rome.exe,2,486,272 bytes):`%sENDL_%03ld/TEXT/%s/briefing.put`、`MAPS` + `*.*` + `SCRIPT` + `%s/boden.txt` 字串簇、UI widget `endl_z%02ld`。無編輯器、無 map list 設定檔(root `misc.cfg`、`SYSTEM/start.ini`、`language.ini` 均無)。 +- 跨圖 MD5:`DATA/hirarchy.dat` 與 8 個核心 SDL 在 ENDL_000/001/004 完全相同;其餘檔案每圖各異。 +- 所有 `DATA/*.dat` 皆 PFIL 包裝(header 實測);`team.dat` 的 header 0x20 簽名字(`8823af02`)與其他 dat(`d8c2ae02`)不同。 +- BMP header 實測:boden/vertex/emboss/smooth = 257×257×24bpp,collision/minimap = 256×256×24bpp,五張圖尺寸一致。 +- `boden.ini`/`boden.txt`/SDL 內容 = 本文件引用之實際解壓文字(ENDL_000)。 +- `.put` 檔無 PFIL header,為純文字。 + +## 附錄 B:repo 內可參考的既有程式碼 + +| 路徑 | 用途 | +|---|---| +| `src/Core/GameLZSS.cs` | PFIL 壓縮/解壓(演算法含遊戲相容性注意事項,見檔內註解) | +| `src/Core/Services/SafeFileWriter.cs`、`src/Core/FileRollbackScope.cs` | 寫入管線 | +| `src/Core/Patches/PatchText.cs` | CP1251 `GameEncoding`、CSV 解析 | +| `src/Core/Patches/ObjdefPatcher.cs` | objdef.dau 解析(取 objdef 名稱清單用) | +| `src/Core/EndlessAi/EndlessAiOrchestrator.cs` | 需一般化的 5 圖硬編碼(§1.5) | +| `src/Core/Services/BackupManager.cs` | 備份基準掃描(§1.5) | +| `src/UI/ModifierForm.cs`(:379 `ShowTabPage`)、`src/UI/UIElements.cs` | 分頁接線與自訂 TabControl | +| `src/UI/ModifierForm.Data.cs`(:23 `GetGamePath`、:38 registry 偵測、:66 `LoadTga`) | 遊戲路徑與 TGA 縮圖 | +| `docs/reverse-engineering/file-formats.md`、`data/game_schema.json` | 既有格式文件(本文件為地圖格式的補充) | +| `tests/AgainstRomeModifier.Tests/`(SyntheticFixture、BackupZipGameFixture) | 測試 fixture 模式 | diff --git a/docs/reverse-engineering/README.md b/docs/reverse-engineering/README.md index ba253d4..93ec7c6 100644 --- a/docs/reverse-engineering/README.md +++ b/docs/reverse-engineering/README.md @@ -1,4 +1,4 @@ -# Against Rome Reverse Engineering Notes +# Against Rome Reverse Engineering Notes > Index reviewed 2026-07-11. This is evidence documentation, not permission to write original game files directly. Product changes must be made in the modifier and verified through its fixture/apply/restore workflow. @@ -11,6 +11,7 @@ and patch code. - `../../TechDoc.md`: current Chinese technical specification, integrating the cross-feature maintenance chronology, failure cases, and safety contracts. - `file-formats.md`: compressed game files, wrappers, and encodings. +- `map-formats.md`: static BMP-grid facts, explicitly unverified map-layer semantics, and the controlled experiments required before any terrain or collision write feature. - `endless-mode-ai.md`: endless AI spawn, timing, gate, job-slot, party lifecycle, and defeat-recovery findings. - `bci0-opcodes.md`: `BCI0` script container layout and the partial bytecode diff --git a/docs/reverse-engineering/map-formats.md b/docs/reverse-engineering/map-formats.md new file mode 100644 index 0000000..6b323e3 --- /dev/null +++ b/docs/reverse-engineering/map-formats.md @@ -0,0 +1,57 @@ +# 地圖格式與 Phase 3 閘門 + +> 狀態:靜態驗證與候選假設(2026-07-11)。本文件不是直接修改原始遊戲檔的授權;所有產品功能必須經修改器的受控寫入/還原流程。 + +## 已靜態驗證 + +以工作區的五張原版 `MAPS/ENDL_000` 至 `ENDL_004` 為唯讀樣本: + +| 檔案 | 格式 | 尺寸 | 穩定結論 | +|---|---:|---:|---| +| `vertex.bmp` | 24-bit BMP | 257×257 | 頂點格候選圖層;不是灰階單通道圖。| +| `boden.bmp` | 24-bit BMP | 257×257 | 三通道幾乎相同的灰階圖;用途尚未確認。| +| `emboss.bmp` | 24-bit BMP | 257×257 | 陰影/光照候選圖層;有些原版圖全黑。| +| `smooth.bmp` | 24-bit BMP | 257×257 | 灰階遮罩候選圖層。| +| `collision.bmp` | 24-bit BMP | 256×256 | 三通道相同的純灰階、含大面積 0 與 255 區塊。| +| `minimap.bmp` | 24-bit BMP | 256×256 | 彩色預覽圖,可安全唯讀顯示。| + +`boden.txt` 是 64×64 tile 名稱表,而 `boden.ini [Heightmapstep]` 的原版值為 4。因此 `64 × 4 + 1 = 257` 與頂點圖尺寸吻合,且 `64 × 4 = 256` 與 tile 圖尺寸吻合。這個格網關係是**結構事實**;不代表任一 BMP 色值的語意已被證實。 + +## 樣本通道觀察 + +- `collision.bmp` 在五張圖均為 R=G=B;平均亮度介於約 10 至 52,標準差 48 至 102,確認不是彩色資料。 +- `boden.bmp` 在五張圖均近乎 R=G=B;`emboss.bmp` 與 `smooth.bmp` 亦主要為灰階,但 `vertex.bmp` 的三通道明顯不同。 +- `ENDL_000/001/002/003/004` 的 `vertex.bmp` 通道範圍分別不同,不能使用固定「某一顏色等於高度」公式。 +- 目視 `ENDL_000`:`vertex.bmp` 的低色彩區與 minimap 水域/地勢特徵有表面關聯,但這只是候選相關性,尚未具備寫入資格。 + +## 未證實,禁止寫入 + +1. `vertex.bmp` 哪一個通道(或其組合)代表高度、其量尺與 `Heightmapstep` 的換算方式。 +2. `collision.bmp` 的黑、白、灰階是否分別代表可通行、不可通行或其他導航遮罩。 +3. `boden.bmp`、`emboss.bmp`、`smooth.bmp` 是否由遊戲重建,或必須與 vertex/材質資料同步修改。 +4. `DATA/*.dat`、`cliprect.dat`、`shadows.dat`、`skydens.dat`、`visible.dat` 的權威性及重建規則。 +5. SDL `refpos`/`pos` 與 256 像素 minimap 的座標轉換。Phase 2 不得疊加物件位置,避免製造誤導性視圖。 + +## 原遊戲渲染與內部 TextureEditor + +- EXE 靜態庫在 `FUN_004a4c20`(`0x004a4c20`)包含 `TextureEditor V0.1`、參數清單與鍵盤操作字串;主畫面迴圈 `FUN_0047ae20` 僅在全域模式值為 `9` 時呼叫它。 +- 正常模式轉換函式只公開模式 `0、1、2、4、7、8`,目前沒有找到將模式設為 `9` 的正常選單或命令列路徑。 +- 該函式處理 buffer、seed、sharpen、blur、combine 等程序影像操作。現有證據較符合內部材質產生/除錯工具,不能視為完整地圖編輯器或安全的 3D 地圖預覽入口。 +- 尚未找到原遊戲可直接載入 `ENDL_NNN` 的命令列參數。產品 UI 的 `minimap.bmp`/資料圖層必須標示為「編輯底圖」而非真實遊戲預覽;真實預覽目前只能安全儲存後啟動 `Against_Rome.exe`,再由使用者從無盡模式選單進入目標槽位。 + +## 下一輪受控實驗 + +每次只在**自製 `ENDL_005+` 地圖的 modifier workflow**中做一件可還原的小改動,並保留原始與修改後雜湊、遊戲結果與 restore 結果。 + +1. 複製一張原版地圖,僅在 `vertex.bmp` 中心 3×3 像素改一個通道;三個通道各做一份獨立副本。進入遊戲檢查地形高度,後續還原。 +2. 以已證實影響高度的通道,量測色差 1、16、64 對應的世界高度,並跨兩張原圖重複。 +3. 只將 `collision.bmp` 一個遠離建築的 4×4 tile 區塊改為 0 或 255,測單位能否步入;不要與 vertex 或其他資料同時修改。 +4. 對每個 `DATA/*.dat` 使用「只複製/暫時移除/啟動」測試,記錄遊戲是否重建、載入失敗或無變化。未知檔案不納入編輯器寫入清單。 +5. 每個實驗都以新增的自製圖為目標;不得手動改寫安裝目錄的原廠圖。 + +## 開放功能條件 + +- 高度筆刷:須完成步驟 1–2、至少兩圖重現、提供安全值域與 rollback。 +- 碰撞筆刷:須完成步驟 3,並證實黑白方向及灰階行為。 +- minimap 自動重繪:須先確認哪個輸入圖層是權威來源及是否需更新快取。 +- SDL 疊圖:須以至少三個明確地標校正並驗證世界到像素轉換。 diff --git a/src.MapEditor/AgainstRomeMapEditor.csproj b/src.MapEditor/AgainstRomeMapEditor.csproj new file mode 100644 index 0000000..feab86c --- /dev/null +++ b/src.MapEditor/AgainstRomeMapEditor.csproj @@ -0,0 +1,16 @@ + + + WinExe + net8.0-windows + true + true + enable + latest + enable + x64 + AgainstRomeMapEditor + + + + + diff --git a/src.MapEditor/MapCanvasControl.cs b/src.MapEditor/MapCanvasControl.cs new file mode 100644 index 0000000..6bdd64a --- /dev/null +++ b/src.MapEditor/MapCanvasControl.cs @@ -0,0 +1,239 @@ +using System.Drawing.Drawing2D; +using AgainstRomeModifier.Maps; + +namespace AgainstRomeMapEditor; + +internal enum MapCanvasLayer { Textures, Minimap, Collision } + +internal sealed class MapCanvasControl : Control +{ + private Bitmap? _bitmap; + private string[]? _textures; + private string[]? _baselineTextures; + private readonly Dictionary _texturePreviewColors = new(StringComparer.OrdinalIgnoreCase); + private int _dimension; + private bool _painting; + private readonly HashSet _paintedInDrag = new(); + + public MapCanvasLayer Layer { get; private set; } = MapCanvasLayer.Textures; + public string? BrushTexture { get; set; } + public bool EditingEnabled { get; set; } + public bool ShowGrid { get; set; } = true; + public event EventHandler? TexturePainted; + public event EventHandler? TileHovered; + public event EventHandler? TextureSampled; + + public MapCanvasControl() + { + DoubleBuffered = true; + BackColor = Color.FromArgb(24, 28, 36); + Dock = DockStyle.Fill; + Cursor = Cursors.Cross; + } + + public void LoadBitmapLayer(string mapDirectory, MapCanvasLayer layer) + { + _bitmap?.Dispose(); _bitmap = null; _textures = null; _baselineTextures = null; _texturePreviewColors.Clear(); _dimension = 0; Layer = layer; + string name = layer == MapCanvasLayer.Minimap ? "minimap.bmp" : "collision.bmp"; + string path = Path.Combine(mapDirectory, name); + if (File.Exists(path)) using (var source = new Bitmap(path)) _bitmap = new Bitmap(source); + Invalidate(); + } + + public void LoadTextures(int dimension, IReadOnlyList textures, IReadOnlyList baselineTextures, string minimapPath) + { + _bitmap?.Dispose(); _bitmap = null; Layer = MapCanvasLayer.Textures; + if (File.Exists(minimapPath)) using (var source = new Bitmap(minimapPath)) _bitmap = new Bitmap(source); + _dimension = dimension; _textures = textures.ToArray(); _baselineTextures = baselineTextures.ToArray(); + BuildTexturePreviewColors(); Invalidate(); + } + + public void SetTexture(int x, int y, string texture) + { + if (_textures is null || _dimension <= 0) return; + int index = y * _dimension + x; + if (index < 0 || index >= _textures.Length) return; + _textures[index] = texture; + Invalidate(); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + e.Graphics.SmoothingMode = SmoothingMode.HighQuality; + if (Layer == MapCanvasLayer.Textures && _textures is not null && _dimension > 0) + { + DrawTextureEditor(e.Graphics); + return; + } + + if (_bitmap is not null) + { + Rectangle bounds = Fit(_bitmap.Size, ClientRectangle); + e.Graphics.InterpolationMode = InterpolationMode.NearestNeighbor; + e.Graphics.DrawImage(_bitmap, bounds); + DrawFrame(e.Graphics, bounds); + return; + } + + TextRenderer.DrawText(e.Graphics, "請從左側選擇地圖", Font, ClientRectangle, Color.SlateGray, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + } + + private void DrawTextureEditor(Graphics graphics) + { + Rectangle bounds = Fit(new Size(_dimension, _dimension), ClientRectangle); + if (_bitmap is not null) + { + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.DrawImage(_bitmap, bounds); + } + else + { + using var background = new SolidBrush(Color.FromArgb(55, 60, 68)); graphics.FillRectangle(background, bounds); + } + + float cellWidth = bounds.Width / (float)_dimension, cellHeight = bounds.Height / (float)_dimension; + if (_baselineTextures is not null && _baselineTextures.Length == _textures!.Length) + { + for (int index = 0; index < _textures.Length; index++) + { + if (StringComparer.OrdinalIgnoreCase.Equals(_textures[index], _baselineTextures[index])) continue; + int x = index % _dimension, y = index / _dimension; + Color preview = GetTexturePreviewColor(_textures[index]); + using var overlay = new SolidBrush(Color.FromArgb(185, preview)); + RectangleF cell = new(bounds.X + x * cellWidth, bounds.Y + y * cellHeight, Math.Max(1, cellWidth), Math.Max(1, cellHeight)); + graphics.FillRectangle(overlay, cell); + using var changedPen = new Pen(Color.FromArgb(230, 255, 220, 80), 1); graphics.DrawRectangle(changedPen, cell.X, cell.Y, cell.Width, cell.Height); + } + } + + if (ShowGrid && cellWidth >= 6) + { + using var shadow = new Pen(Color.FromArgb(70, 0, 0, 0), 1); + using var light = new Pen(Color.FromArgb(55, 255, 255, 255), 1); + for (int i = 0; i <= _dimension; i++) + { + float px = bounds.X + i * cellWidth, py = bounds.Y + i * cellHeight; + graphics.DrawLine(shadow, px + 1, bounds.Top, px + 1, bounds.Bottom); graphics.DrawLine(light, px, bounds.Top, px, bounds.Bottom); + graphics.DrawLine(shadow, bounds.Left, py + 1, bounds.Right, py + 1); graphics.DrawLine(light, bounds.Left, py, bounds.Right, py); + } + } + DrawFrame(graphics, bounds); + } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + if (e.Button == MouseButtons.Right && TryGetTile(e.Location, out int sampleX, out int sampleY) && _textures is not null) + { + TextureSampled?.Invoke(this, new TextureSampleEventArgs(sampleX, sampleY, _textures[sampleY * _dimension + sampleX])); + return; + } + if (e.Button != MouseButtons.Left) return; + _painting = true; _paintedInDrag.Clear(); TryPaint(e.Location); + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (TryGetTile(e.Location, out int x, out int y)) TileHovered?.Invoke(this, new TileHoverEventArgs(x, y, _textures?[y * _dimension + x])); + if (_painting && e.Button == MouseButtons.Left) TryPaint(e.Location); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); _painting = false; _paintedInDrag.Clear(); + } + + private void TryPaint(Point location) + { + if (!EditingEnabled || Layer != MapCanvasLayer.Textures || string.IsNullOrWhiteSpace(BrushTexture) || _textures is null) return; + if (!TryGetTile(location, out int x, out int y)) return; + int index = y * _dimension + x; + if (!_paintedInDrag.Add(index)) return; + string previous = _textures[index]; + if (StringComparer.OrdinalIgnoreCase.Equals(previous, BrushTexture)) return; + _textures[index] = BrushTexture; + TexturePainted?.Invoke(this, new TexturePaintEventArgs(x, y, previous, BrushTexture)); + Invalidate(); + } + + private bool TryGetTile(Point location, out int x, out int y) + { + x = y = -1; + if (Layer != MapCanvasLayer.Textures || _textures is null || _dimension <= 0) return false; + Rectangle bounds = Fit(new Size(_dimension, _dimension), ClientRectangle); + if (!bounds.Contains(location)) return false; + x = Math.Clamp((int)((location.X - bounds.X) * _dimension / (float)bounds.Width), 0, _dimension - 1); + y = Math.Clamp((int)((location.Y - bounds.Y) * _dimension / (float)bounds.Height), 0, _dimension - 1); + return true; + } + + private static Rectangle Fit(Size source, Rectangle target) + { + Rectangle inner = Rectangle.Inflate(target, -24, -24); + float scale = Math.Min(inner.Width / (float)source.Width, inner.Height / (float)source.Height); + int width = Math.Max(1, (int)(source.Width * scale)), height = Math.Max(1, (int)(source.Height * scale)); + return new Rectangle(inner.X + (inner.Width - width) / 2, inner.Y + (inner.Height - height) / 2, width, height); + } + + private static void DrawFrame(Graphics graphics, Rectangle bounds) + { + using var pen = new Pen(Color.FromArgb(100, 155, 180, 205), 1); + graphics.DrawRectangle(pen, bounds); + } + + internal Color GetTexturePreviewColor(string texture) + => _texturePreviewColors.TryGetValue(texture, out Color color) ? color : FallbackTextureColor(texture); + + private void BuildTexturePreviewColors() + { + _texturePreviewColors.Clear(); + if (_bitmap is null || _textures is null || _dimension <= 0) return; + var sums = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int y = 0; y < _dimension; y++) for (int x = 0; x < _dimension; x++) + { + string texture = _textures[y * _dimension + x]; + int left = x * _bitmap.Width / _dimension, right = (x + 1) * _bitmap.Width / _dimension; + int top = y * _bitmap.Height / _dimension, bottom = (y + 1) * _bitmap.Height / _dimension; + sums.TryGetValue(texture, out var sum); + for (int py = top; py < bottom; py++) for (int px = left; px < right; px++) { Color pixel = _bitmap.GetPixel(px, py); sum.R += pixel.R; sum.G += pixel.G; sum.B += pixel.B; sum.Count++; } + sums[texture] = sum; + } + foreach (var pair in sums) if (pair.Value.Count > 0) _texturePreviewColors[pair.Key] = Color.FromArgb((int)(pair.Value.R / pair.Value.Count), (int)(pair.Value.G / pair.Value.Count), (int)(pair.Value.B / pair.Value.Count)); + } + + private static Color FallbackTextureColor(string texture) + { + int hash = StringComparer.OrdinalIgnoreCase.GetHashCode(texture); + return Color.FromArgb(90 + (hash & 0x4f), 90 + ((hash >> 8) & 0x4f), 90 + ((hash >> 16) & 0x4f)); + } + + protected override void Dispose(bool disposing) { if (disposing) _bitmap?.Dispose(); base.Dispose(disposing); } +} + +internal sealed class TexturePaintEventArgs : EventArgs +{ + public TexturePaintEventArgs(int x, int y, string previousTexture, string texture) { X = x; Y = y; PreviousTexture = previousTexture; Texture = texture; } + public int X { get; } + public int Y { get; } + public string PreviousTexture { get; } + public string Texture { get; } +} + +internal sealed class TileHoverEventArgs : EventArgs +{ + public TileHoverEventArgs(int x, int y, string? texture) { X = x; Y = y; Texture = texture; } + public int X { get; } + public int Y { get; } + public string? Texture { get; } +} + +internal sealed class TextureSampleEventArgs : EventArgs +{ + public TextureSampleEventArgs(int x, int y, string texture) { X = x; Y = y; Texture = texture; } + public int X { get; } + public int Y { get; } + public string Texture { get; } +} diff --git a/src.MapEditor/MapEditorForm.cs b/src.MapEditor/MapEditorForm.cs new file mode 100644 index 0000000..ab82230 --- /dev/null +++ b/src.MapEditor/MapEditorForm.cs @@ -0,0 +1,304 @@ +using AgainstRomeModifier; +using AgainstRomeModifier.Maps; +using System.Diagnostics; + +namespace AgainstRomeMapEditor; + +internal sealed class MapEditorForm : Form +{ + private readonly EndlessMapCatalog _catalog = new(); + private readonly EndlessMapCloner _cloner = new(); + private readonly MapCanvasControl _canvas = new(); + private readonly ListView _maps = new() { Dock = DockStyle.Fill, View = View.Details, FullRowSelect = true, MultiSelect = false, HideSelection = false }; + private readonly ListBox _palette = new() { Dock = DockStyle.Fill, IntegralHeight = false }; + private readonly TextBox _paletteSearch = new() { Dock = DockStyle.Top, PlaceholderText = "搜尋材質名稱…" }; + private readonly Panel _advancedPaletteHost = new() { Dock = DockStyle.Fill, Visible = false }; + private readonly CheckBox _showAdvancedPalette = new() { Dock = DockStyle.Top, Height = 30, Text = "顯示進階材質庫(內部 ID)" }; + private readonly Panel _currentMaterialSwatch = new() { Width = 54, Height = 54, BackColor = Color.DimGray }; + private readonly Label _currentMaterialLabel = new() { AutoSize = true, Text = "目前筆刷:尚未取樣", ForeColor = Color.White, Font = new Font("Microsoft JhengHei UI", 10F, FontStyle.Bold) }; + private readonly TextBox _gamePath = new() { Width = 430 }; + private readonly TextBox _title = new() { Dock = DockStyle.Top }; + private readonly NumericUpDown _waterLevel = new() { Dock = DockStyle.Top, Minimum = 0, Maximum = 4096 }; + private readonly TextBox _waterColor = new() { Dock = DockStyle.Top }; + private readonly NumericUpDown _dayStart = new() { Dock = DockStyle.Top, Minimum = 0, Maximum = 24 }; + private readonly NumericUpDown _dayEnd = new() { Dock = DockStyle.Top, Minimum = 0, Maximum = 24 }; + private readonly CheckBox _rain = new() { Dock = DockStyle.Top, Text = "水面雨滴" }; + private readonly Label _modeBanner = new() { Dock = DockStyle.Top, Height = 34, TextAlign = ContentAlignment.MiddleCenter }; + private readonly ToolStripStatusLabel _status = new() { Spring = true, TextAlign = ContentAlignment.MiddleLeft }; + private readonly ToolStripButton _saveButton = new("儲存") { Enabled = false }; + private readonly ToolStripButton _gamePreviewButton = new("在遊戲中預覽") { Enabled = false }; + private readonly ToolStripButton _undoButton = new("復原") { Enabled = false }; + private readonly ToolStripButton _redoButton = new("重做") { Enabled = false }; + private readonly ToolStripButton _textureTool = new("材質筆刷") { CheckOnClick = true, Checked = true }; + private readonly ToolStripButton _minimapView = new("地圖底圖") { CheckOnClick = true }; + private readonly ToolStripButton _collisionView = new("碰撞預覽") { CheckOnClick = true }; + private readonly Stack _undo = new(); + private readonly Stack _redo = new(); + private EndlessMapInfo? _selected; + private BodenTexturesDocument? _texturesDocument; + private string[] _savedTextures = Array.Empty(); + private bool _dirty; + private bool _loading; + private bool _selectionGuard; + private int? _requestedSlot; + private Dictionary _textureLabels = new(StringComparer.OrdinalIgnoreCase); + + public MapEditorForm(EditorArguments arguments) + { + Text = "Against Rome 地圖編輯器"; + Width = 1440; Height = 900; MinimumSize = new Size(1100, 700); StartPosition = FormStartPosition.CenterScreen; + BackColor = Color.FromArgb(30, 34, 42); ForeColor = Color.Gainsboro; + _requestedSlot = arguments.SelectedSlot; + BuildInterface(); WireEvents(); + _gamePath.Text = arguments.GamePath ?? DetectGamePath(); + Shown += (_, _) => RefreshMaps(_requestedSlot); + FormClosing += (_, e) => { if (!ConfirmDiscardOrSave()) e.Cancel = true; }; + } + + private void BuildInterface() + { + var commands = new ToolStrip { GripStyle = ToolStripGripStyle.Hidden, Dock = DockStyle.Top, Padding = new Padding(8, 5, 8, 5), BackColor = Color.FromArgb(42, 47, 58), ForeColor = Color.White, RenderMode = ToolStripRenderMode.System }; + var browse = new ToolStripButton("遊戲路徑…"); + var refresh = new ToolStripButton("重新整理"); + var clone = new ToolStripButton("複製為自製地圖"); + commands.Items.AddRange(new ToolStripItem[] { browse, new ToolStripControlHost(_gamePath), refresh, new ToolStripSeparator(), clone, _saveButton, _gamePreviewButton, new ToolStripSeparator(), _undoButton, _redoButton }); + browse.Click += (_, _) => Browse(); refresh.Click += (_, _) => RefreshMaps(_selected?.Slot); clone.Click += (_, _) => CloneSelected(); + + var tools = new ToolStrip { GripStyle = ToolStripGripStyle.Hidden, Dock = DockStyle.Top, Padding = new Padding(8, 4, 8, 4), BackColor = Color.FromArgb(36, 40, 49), ForeColor = Color.White }; + var height = new ToolStripButton("高度筆刷(待驗證)") { Enabled = false }; + var collision = new ToolStripButton("碰撞筆刷(待驗證)") { Enabled = false }; + var objects = new ToolStripButton("物件配置(待校正)") { Enabled = false }; + tools.Items.AddRange(new ToolStripItem[] { new ToolStripLabel("工具:"), _textureTool, height, collision, objects, new ToolStripSeparator(), new ToolStripLabel("檢視:"), _minimapView, _collisionView }); + + _maps.Columns.Add("槽位", 82); _maps.Columns.Add("名稱", 155); _maps.Columns.Add("類型", 70); + var mapHeader = SectionHeader("地圖"); + var left = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8), BackColor = Color.FromArgb(34, 38, 47) }; + left.Controls.Add(_maps); left.Controls.Add(mapHeader); + + var paletteHeader = SectionHeader("材質工具"); + var palettePanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8), BackColor = Color.FromArgb(34, 38, 47) }; + var currentBrush = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 76, Padding = new Padding(4), FlowDirection = FlowDirection.LeftToRight }; + var currentText = new FlowLayoutPanel { Width = 205, Height = 66, FlowDirection = FlowDirection.TopDown, WrapContents = false }; + currentText.Controls.Add(_currentMaterialLabel); currentText.Controls.Add(new Label { AutoSize = true, MaximumSize = new Size(200, 0), Text = "在地圖上按右鍵吸取材質,\n再用左鍵拖曳繪製。", ForeColor = Color.Silver }); + currentBrush.Controls.Add(_currentMaterialSwatch); currentBrush.Controls.Add(currentText); + _advancedPaletteHost.Controls.Add(_palette); _advancedPaletteHost.Controls.Add(_paletteSearch); + palettePanel.Controls.Add(_advancedPaletteHost); palettePanel.Controls.Add(_showAdvancedPalette); palettePanel.Controls.Add(currentBrush); palettePanel.Controls.Add(paletteHeader); + + var properties = BuildPropertiesPanel(); + var inspectorTabs = new TabControl { Dock = DockStyle.Fill }; + inspectorTabs.TabPages.Add(new TabPage("材質") { BackColor = Color.FromArgb(34, 38, 47) }); inspectorTabs.TabPages[0].Controls.Add(palettePanel); + inspectorTabs.TabPages.Add(new TabPage("地圖屬性") { BackColor = Color.FromArgb(34, 38, 47) }); inspectorTabs.TabPages[1].Controls.Add(properties); + + var canvasHost = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8), BackColor = Color.FromArgb(20, 23, 29) }; + canvasHost.Controls.Add(_canvas); canvasHost.Controls.Add(_modeBanner); + + var centerRight = new SplitContainer { Dock = DockStyle.Fill, FixedPanel = FixedPanel.Panel2, Size = new Size(1140, 760), SplitterDistance = 820 }; + centerRight.Panel1.Controls.Add(canvasHost); centerRight.Panel2.Controls.Add(inspectorTabs); centerRight.Panel2MinSize = 280; + var main = new SplitContainer { Dock = DockStyle.Fill, FixedPanel = FixedPanel.Panel1, Size = new Size(1400, 760), SplitterDistance = 280 }; + main.Panel1.Controls.Add(left); main.Panel2.Controls.Add(centerRight); main.Panel1MinSize = 240; + + var statusStrip = new StatusStrip { BackColor = Color.FromArgb(42, 47, 58), ForeColor = Color.Gainsboro }; + statusStrip.Items.Add(_status); statusStrip.Items.Add(new ToolStripStatusLabel("右鍵:吸取材質 左鍵拖曳:繪製")); + Controls.Add(main); Controls.Add(tools); Controls.Add(commands); Controls.Add(statusStrip); + commands.BringToFront(); tools.BringToFront(); + } + + private Panel BuildPropertiesPanel() + { + var table = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 1, Padding = new Padding(10) }; + AddField(table, "地圖名稱", _title); AddField(table, "Waterlevel", _waterLevel); AddField(table, "WaterColor (0xBBGGRR)", _waterColor); + AddField(table, "DayStartTime", _dayStart); AddField(table, "DayEndTime", _dayEnd); AddField(table, "", _rain); + var panel = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(34, 38, 47) }; panel.Controls.Add(table); return panel; + } + + private void WireEvents() + { + _maps.SelectedIndexChanged += (_, _) => OnMapSelectionChanged(); + _palette.SelectedIndexChanged += (_, _) => { if (_palette.SelectedItem is PaletteItem item) SelectBrush(item.Id); }; + _palette.DrawMode = DrawMode.OwnerDrawFixed; _palette.ItemHeight = 30; _palette.DrawItem += DrawPaletteItem; + _paletteSearch.TextChanged += (_, _) => LoadPalette(_paletteSearch.Text); + _showAdvancedPalette.CheckedChanged += (_, _) => _advancedPaletteHost.Visible = _showAdvancedPalette.Checked; + _canvas.TexturePainted += (_, e) => PaintTexture(e); + _canvas.TextureSampled += (_, e) => SelectBrush(e.Texture); + _canvas.TileHovered += (_, e) => _status.Text = $"格子 ({e.X}, {e.Y}) {FriendlyTextureName(e.Texture)}"; + _textureTool.Click += (_, _) => ShowLayer(MapCanvasLayer.Textures); + _minimapView.Click += (_, _) => ShowLayer(MapCanvasLayer.Minimap); + _collisionView.Click += (_, _) => ShowLayer(MapCanvasLayer.Collision); + _saveButton.Click += (_, _) => SaveMap(showSuccess: true); + _gamePreviewButton.Click += (_, _) => PreviewInGame(); + _undoButton.Click += (_, _) => Undo(); _redoButton.Click += (_, _) => Redo(); + foreach (Control control in new Control[] { _title, _waterLevel, _waterColor, _dayStart, _dayEnd, _rain }) + { + if (control is TextBox text) text.TextChanged += (_, _) => MarkDirty(); + else if (control is NumericUpDown numeric) numeric.ValueChanged += (_, _) => MarkDirty(); + else if (control is CheckBox check) check.CheckedChanged += (_, _) => MarkDirty(); + } + } + + private void RefreshMaps(int? selectSlot) + { + if (!ConfirmDiscardOrSave()) return; + try + { + _selectionGuard = true; _maps.BeginUpdate(); _maps.Items.Clear(); + foreach (EndlessMapInfo map in _catalog.List(_gamePath.Text)) + { + var item = new ListViewItem(map.Id) { Tag = map }; item.SubItems.Add(map.DisplayName ?? "(無標題)"); item.SubItems.Add(map.IsCustom ? "自製" : "原廠"); _maps.Items.Add(item); + if (map.Slot == selectSlot) { item.Selected = true; item.Focused = true; } + } + if (_maps.SelectedItems.Count == 0 && _maps.Items.Count > 0) _maps.Items[0].Selected = true; + } + catch (Exception ex) { ShowError(ex); } + finally { _maps.EndUpdate(); _selectionGuard = false; } + LoadSelectedFromList(); + } + + private void OnMapSelectionChanged() + { + if (_selectionGuard || _maps.SelectedItems.Count != 1) return; + if (!ConfirmDiscardOrSave()) { ReselectCurrent(); return; } + LoadSelectedFromList(); + } + + private void LoadSelectedFromList() + { + _selected = _maps.SelectedItems.Count == 1 ? _maps.SelectedItems[0].Tag as EndlessMapInfo : null; + if (_selected is null) { UpdateEditorState(); return; } + try + { + _loading = true; string map = _selected.DirectoryPath; + _title.Text = PutTextDocument.Load(Path.Combine(map, "TEXT", "US", "briefing.put")).GetValue("briefing_titel_1") ?? ""; + var ini = BodenIniDocument.Load(Path.Combine(map, "boden.ini")); + _waterLevel.Value = ParseDecimal(ini.GetValue("Waterlevel"), _waterLevel); _waterColor.Text = ini.GetValue("WaterColor") ?? ""; + _dayStart.Value = ParseDecimal(ini.GetValue("DayStartTime"), _dayStart); _dayEnd.Value = ParseDecimal(ini.GetValue("DayEndTime"), _dayEnd); _rain.Checked = ini.GetValue("RainDropsOnWater") == "1"; + _texturesDocument = BodenTexturesDocument.Load(Path.Combine(map, "boden.txt")); _savedTextures = _texturesDocument.Textures.ToArray(); BuildTextureLabels(); _dirty = false; _undo.Clear(); _redo.Clear(); + LoadPalette(); ShowLayer(MapCanvasLayer.Textures); UpdateEditorState(); + } + catch (Exception ex) { ShowError(ex); } + finally { _loading = false; } + } + + private void ShowLayer(MapCanvasLayer layer) + { + if (_selected is null) return; + _textureTool.Checked = layer == MapCanvasLayer.Textures; _minimapView.Checked = layer == MapCanvasLayer.Minimap; _collisionView.Checked = layer == MapCanvasLayer.Collision; + if (layer == MapCanvasLayer.Textures && _texturesDocument is not null) _canvas.LoadTextures(_texturesDocument.Dimension, _texturesDocument.Textures, _savedTextures, Path.Combine(_selected.DirectoryPath, "minimap.bmp")); + else _canvas.LoadBitmapLayer(_selected.DirectoryPath, layer); + _canvas.EditingEnabled = layer == MapCanvasLayer.Textures && _selected.IsCustom; + _modeBanner.Text = layer switch { MapCanvasLayer.Textures => _selected.IsCustom ? "材質編輯底圖 — 右鍵吸取,左鍵拖曳繪製;真實畫面請按「在遊戲中預覽」" : "原廠地圖唯讀 — 請先使用上方「複製為自製地圖」", MapCanvasLayer.Minimap => "地圖底圖(不是遊戲渲染畫面)", _ => "碰撞資料檢視(不是遊戲渲染畫面)" }; + _modeBanner.BackColor = _canvas.EditingEnabled ? Color.FromArgb(38, 95, 72) : Color.FromArgb(86, 69, 40); + UpdateStatus(); + } + + private void PaintTexture(TexturePaintEventArgs e) + { + if (_selected is null || !_selected.IsCustom || _texturesDocument is null) return; + _texturesDocument.SetTexture(e.X, e.Y, e.Texture); _undo.Push(new TextureChange(e.X, e.Y, e.PreviousTexture, e.Texture)); _redo.Clear(); _dirty = true; UpdateEditorState(); + } + + private void Undo() + { + if (_texturesDocument is null || _undo.Count == 0) return; + TextureChange change = _undo.Pop(); _texturesDocument.SetTexture(change.X, change.Y, change.Before); _canvas.SetTexture(change.X, change.Y, change.Before); _redo.Push(change); _dirty = true; UpdateEditorState(); + } + + private void Redo() + { + if (_texturesDocument is null || _redo.Count == 0) return; + TextureChange change = _redo.Pop(); _texturesDocument.SetTexture(change.X, change.Y, change.After); _canvas.SetTexture(change.X, change.Y, change.After); _undo.Push(change); _dirty = true; UpdateEditorState(); + } + + private bool SaveMap(bool showSuccess) + { + if (_selected is null || !_selected.IsCustom) return false; + try + { + using var rollback = new FileRollbackScope(); string map = _selected.DirectoryPath; + var put = PutTextDocument.Load(Path.Combine(map, "TEXT", "US", "briefing.put")); put.SetValue("briefing_titel_1", _title.Text.Trim()); put.Save(rollback); + var ini = BodenIniDocument.Load(Path.Combine(map, "boden.ini")); ini.SetValue("Waterlevel", _waterLevel.Value.ToString()); ini.SetValue("WaterColor", _waterColor.Text.Trim()); ini.SetValue("DayStartTime", _dayStart.Value.ToString()); ini.SetValue("DayEndTime", _dayEnd.Value.ToString()); ini.SetValue("RainDropsOnWater", _rain.Checked ? "1" : "0"); ini.Save(rollback); + _texturesDocument?.Save(rollback); rollback.Commit(); _savedTextures = _texturesDocument?.Textures.ToArray() ?? Array.Empty(); _dirty = false; ShowLayer(MapCanvasLayer.Textures); UpdateEditorState(); + if (showSuccess) MessageBox.Show(this, "地圖已安全儲存。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + return true; + } + catch (Exception ex) { ShowError(ex); return false; } + } + + private void PreviewInGame() + { + if (_selected is null) { MessageBox.Show(this, "請先選擇要預覽的地圖。", Text); return; } + if (_selected.IsCustom && _dirty && !SaveMap(showSuccess: false)) return; + string exePath = Path.Combine(_gamePath.Text.Trim(), "Against_Rome.exe"); + if (!File.Exists(exePath)) { MessageBox.Show(this, "遊戲路徑中找不到 Against_Rome.exe。", Text, MessageBoxButtons.OK, MessageBoxIcon.Error); return; } + try + { + Process.Start(new ProcessStartInfo(exePath) { WorkingDirectory = _gamePath.Text.Trim(), UseShellExecute = true }); + MessageBox.Show(this, $"遊戲已啟動。\n\n請進入「無盡模式」並選擇 {_selected.Id}({_selected.DisplayName ?? "未命名"})。\n目前尚未發現可安全直接載入指定地圖的命令列參數。", "真實遊戲預覽", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) { ShowError(ex); } + } + + private bool ConfirmDiscardOrSave() + { + if (!_dirty) return true; + DialogResult result = MessageBox.Show(this, "目前地圖有尚未儲存的變更。\n\n是:儲存後繼續\n否:放棄變更\n取消:留在目前地圖", "尚未儲存", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning); + return result switch { DialogResult.Yes => SaveMap(showSuccess: false), DialogResult.No => true, _ => false }; + } + + private void CloneSelected() + { + if (_selected is null) { MessageBox.Show(this, "請先從左側選擇來源地圖。", Text); return; } + if (!ConfirmDiscardOrSave()) return; + string name = Prompt("新地圖名稱", _selected.DisplayName ?? _selected.Id); if (string.IsNullOrWhiteSpace(name)) return; + try { int slot = _catalog.GetNextFreeSlot(_gamePath.Text); _cloner.Clone(_gamePath.Text, _selected.Slot, slot, name.Trim()); RefreshMaps(slot); } + catch (Exception ex) { ShowError(ex); } + } + + private void LoadPalette(string? filter = null) + { + string? selected = (_palette.SelectedItem as PaletteItem)?.Id ?? _canvas.BrushTexture; _palette.Items.Clear(); + if (_texturesDocument is null) return; + IEnumerable values = _texturesDocument.Textures.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(filter)) values = values.Where(x => x.Contains(filter, StringComparison.OrdinalIgnoreCase) || FriendlyTextureName(x).Contains(filter, StringComparison.OrdinalIgnoreCase)); + PaletteItem[] items = values.Select(x => new PaletteItem(x, FriendlyTextureName(x))).ToArray(); _palette.Items.AddRange(items.Cast().ToArray()); + int index = selected is null ? -1 : Array.FindIndex(items, x => StringComparer.OrdinalIgnoreCase.Equals(x.Id, selected)); _palette.SelectedIndex = index; + } + + private void DrawPaletteItem(object? sender, DrawItemEventArgs e) + { + e.DrawBackground(); if (e.Index < 0 || _palette.Items[e.Index] is not PaletteItem item) return; + Font baseFont = e.Font ?? Font; + using var color = new SolidBrush(_canvas.GetTexturePreviewColor(item.Id)); e.Graphics.FillRectangle(color, e.Bounds.X + 5, e.Bounds.Y + 5, 20, e.Bounds.Height - 10); + TextRenderer.DrawText(e.Graphics, item.Name, baseFont, new Rectangle(e.Bounds.X + 32, e.Bounds.Y, 105, e.Bounds.Height), e.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); + using var idFont = new Font(baseFont.FontFamily, Math.Max(7, baseFont.Size - 1)); + TextRenderer.DrawText(e.Graphics, item.Id, idFont, new Rectangle(e.Bounds.X + 138, e.Bounds.Y, e.Bounds.Width - 140, e.Bounds.Height), Color.Gray, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); + e.DrawFocusRectangle(); + } + + private void MarkDirty() { if (_loading || _selected is null || !_selected.IsCustom) return; _dirty = true; UpdateEditorState(); } + private void BuildTextureLabels() + { + _textureLabels = _texturesDocument?.Textures.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).Select((id, index) => (id, name: $"地表樣式 {index + 1:000}")).ToDictionary(x => x.id, x => x.name, StringComparer.OrdinalIgnoreCase) ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + private string FriendlyTextureName(string? texture) => texture != null && _textureLabels.TryGetValue(texture, out string? name) ? name : "未知地表樣式"; + private void SelectBrush(string texture) + { + _canvas.BrushTexture = texture; _currentMaterialSwatch.BackColor = _canvas.GetTexturePreviewColor(texture); _currentMaterialLabel.Text = "目前筆刷:" + FriendlyTextureName(texture); UpdateStatus(); + } + private void UpdateEditorState() + { + bool editable = _selected?.IsCustom == true; _saveButton.Enabled = editable && _dirty; _gamePreviewButton.Enabled = _selected is not null; _undoButton.Enabled = editable && _undo.Count > 0; _redoButton.Enabled = editable && _redo.Count > 0; + foreach (Control control in new Control[] { _title, _waterLevel, _waterColor, _dayStart, _dayEnd, _rain }) control.Enabled = editable; + _palette.Enabled = editable; UpdateStatus(); + } + private void UpdateStatus() { _status.Text = _selected is null ? "尚未選擇地圖" : $"{_selected.Id} — {(_selected.IsCustom ? "自製地圖,可編輯" : "原廠地圖,唯讀")}{(_dirty ? " ● 尚未儲存" : "")}"; } + private void ReselectCurrent() { if (_selected is null) return; _selectionGuard = true; foreach (ListViewItem item in _maps.Items) item.Selected = item.Tag is EndlessMapInfo map && map.Slot == _selected.Slot; _selectionGuard = false; } + private void Browse() { if (!ConfirmDiscardOrSave()) return; using var dialog = new FolderBrowserDialog { Description = "選擇 Against Rome 安裝資料夾" }; if (dialog.ShowDialog(this) == DialogResult.OK) { _gamePath.Text = dialog.SelectedPath; RefreshMaps(null); } } + private static Label SectionHeader(string text) => new() { Text = text, Dock = DockStyle.Top, Height = 34, Font = new Font("Microsoft JhengHei UI", 9F, FontStyle.Bold), Padding = new Padding(4, 8, 0, 0), ForeColor = Color.White }; + private static void AddField(TableLayoutPanel table, string label, Control control) { table.Controls.Add(new Label { Text = label, AutoSize = true, Margin = new Padding(3, 10, 3, 3), ForeColor = Color.Gainsboro }); table.Controls.Add(control); } + private static decimal ParseDecimal(string? value, NumericUpDown control) => decimal.TryParse(value, out decimal parsed) ? Math.Clamp(parsed, control.Minimum, control.Maximum) : control.Minimum; + private static string Prompt(string title, string value) { using var form = new Form { Text = title, Width = 430, Height = 150, StartPosition = FormStartPosition.CenterParent }; var input = new TextBox { Text = value, Dock = DockStyle.Top, Margin = new Padding(12) }; var ok = new Button { Text = "確定", DialogResult = DialogResult.OK, Dock = DockStyle.Bottom, Height = 36 }; form.Controls.Add(input); form.Controls.Add(ok); form.AcceptButton = ok; return form.ShowDialog() == DialogResult.OK ? input.Text : ""; } + private static string DetectGamePath() => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Against Rome"); + private void ShowError(Exception ex) => MessageBox.Show(this, ex.Message, "地圖編輯器錯誤", MessageBoxButtons.OK, MessageBoxIcon.Error); + private sealed record TextureChange(int X, int Y, string Before, string After); + private sealed record PaletteItem(string Id, string Name); +} diff --git a/src.MapEditor/Program.cs b/src.MapEditor/Program.cs new file mode 100644 index 0000000..f36a6ad --- /dev/null +++ b/src.MapEditor/Program.cs @@ -0,0 +1,25 @@ +namespace AgainstRomeMapEditor; + +internal static class Program +{ + [STAThread] + private static void Main(string[] args) + { + ApplicationConfiguration.Initialize(); + Application.Run(new MapEditorForm(EditorArguments.Parse(args))); + } +} + +internal sealed record EditorArguments(string? GamePath, int? SelectedSlot) +{ + public static EditorArguments Parse(string[] args) + { + string? gamePath = null; int? slot = null; + for (int i = 0; i < args.Length; i++) + { + if (args[i].Equals("--game", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) gamePath = args[++i]; + else if (args[i].Equals("--map", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length && int.TryParse(args[++i].Replace("ENDL_", "", StringComparison.OrdinalIgnoreCase), out int parsed) && parsed is >= 0 and <= 999) slot = parsed; + } + return new EditorArguments(gamePath, slot); + } +} diff --git a/src.Shared/AgainstRome.Shared.csproj b/src.Shared/AgainstRome.Shared.csproj new file mode 100644 index 0000000..2f08fa6 --- /dev/null +++ b/src.Shared/AgainstRome.Shared.csproj @@ -0,0 +1,13 @@ + + + net8.0 + enable + latest + enable + + + + + + + diff --git a/src.Shared/Maps/CustomMapManifest.cs b/src.Shared/Maps/CustomMapManifest.cs new file mode 100644 index 0000000..66f7817 --- /dev/null +++ b/src.Shared/Maps/CustomMapManifest.cs @@ -0,0 +1,43 @@ +using System.Text.Json; + +namespace AgainstRomeModifier.Maps; + +public sealed record CustomMapEntry(int Slot, int SourceSlot, DateTimeOffset CreatedAt, string ToolVersion); + +public sealed class CustomMapManifest +{ + public const string FileName = "arm_custom_maps.json"; + public const string MarkerFileName = ".arm_custom_map"; + private readonly List _entries = new(); + public IReadOnlyList Entries => _entries; + + public static CustomMapManifest Load(string gamePath) + { + string path = Path.Combine(EndlessMapCatalog.ValidateGamePath(gamePath), "MAPS", FileName); + if (!File.Exists(path)) return new CustomMapManifest(); + var entries = JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? new List(); + var manifest = new CustomMapManifest(); + manifest._entries.AddRange(entries.Where(IsValid)); + return manifest; + } + + public void Register(CustomMapEntry entry) + { + if (!IsValid(entry)) throw new ArgumentException("無效的自製地圖登記資料。", nameof(entry)); + _entries.RemoveAll(x => x.Slot == entry.Slot); + _entries.Add(entry); + } + + public void Remove(int slot) => _entries.RemoveAll(x => x.Slot == slot); + + public void Save(string gamePath, FileRollbackScope? rollback = null) + { + string mapsPath = Path.Combine(EndlessMapCatalog.ValidateGamePath(gamePath), "MAPS"); + Directory.CreateDirectory(mapsPath); + string json = JsonSerializer.Serialize(_entries.OrderBy(x => x.Slot), new JsonSerializerOptions { WriteIndented = true }); + Core.Services.SafeFileWriter.WriteAllBytes(Path.Combine(mapsPath, FileName), System.Text.Encoding.UTF8.GetBytes(json), rollback); + } + + public static bool IsCustomMapDirectory(string path) => File.Exists(Path.Combine(path, MarkerFileName)); + private static bool IsValid(CustomMapEntry entry) => entry.Slot is >= 5 and <= 999 && entry.SourceSlot is >= 0 and <= 999 && !string.IsNullOrWhiteSpace(entry.ToolVersion); +} diff --git a/src.Shared/Maps/EndlessMapCatalog.cs b/src.Shared/Maps/EndlessMapCatalog.cs new file mode 100644 index 0000000..10ceea8 --- /dev/null +++ b/src.Shared/Maps/EndlessMapCatalog.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace AgainstRomeModifier.Maps; + +public sealed record EndlessMapInfo(int Slot, string DirectoryPath, bool IsCustom, string? DisplayName) +{ + public string Id => $"ENDL_{Slot:000}"; +} + +public sealed class EndlessMapCatalog +{ + private static readonly Regex MapDirectoryName = new("^ENDL_(\\d{3})$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + public IReadOnlyList List(string gamePath) + { + string mapsPath = Path.Combine(ValidateGamePath(gamePath), "MAPS"); + if (!Directory.Exists(mapsPath)) return Array.Empty(); + + return Directory.GetDirectories(mapsPath) + .Select(path => (path, match: MapDirectoryName.Match(Path.GetFileName(path)))) + .Where(x => x.match.Success) + .Select(x => new EndlessMapInfo( + int.Parse(x.match.Groups[1].Value, CultureInfo.InvariantCulture), + x.path, + File.Exists(Path.Combine(x.path, CustomMapManifest.MarkerFileName)), + TryReadTitle(x.path))) + .OrderBy(x => x.Slot) + .ToArray(); + } + + public int GetNextFreeSlot(string gamePath) + { + var occupied = List(gamePath).Select(x => x.Slot).ToHashSet(); + for (int slot = 5; slot <= 999; slot++) + if (!occupied.Contains(slot)) return slot; + throw new InvalidOperationException("沒有可用的 ENDL_005 至 ENDL_999 地圖槽位。"); + } + + public EndlessMapInfo Require(string gamePath, int slot) + { + if (slot is < 0 or > 999) throw new ArgumentOutOfRangeException(nameof(slot)); + return List(gamePath).SingleOrDefault(x => x.Slot == slot) + ?? throw new DirectoryNotFoundException($"找不到 ENDL_{slot:000}。"); + } + + internal static string ValidateGamePath(string gamePath) + { + if (string.IsNullOrWhiteSpace(gamePath)) throw new ArgumentException("未提供遊戲路徑。", nameof(gamePath)); + string fullPath = Path.GetFullPath(gamePath); + if (!Directory.Exists(fullPath)) throw new DirectoryNotFoundException("找不到遊戲路徑: " + fullPath); + return fullPath; + } + + private static string? TryReadTitle(string mapDirectory) + { + string path = Path.Combine(mapDirectory, "TEXT", "US", "briefing.put"); + if (!File.Exists(path)) return null; + try { return PutTextDocument.Load(path).GetValue("briefing_titel_1"); } + catch { return null; } + } +} diff --git a/src.Shared/Maps/EndlessMapCloner.cs b/src.Shared/Maps/EndlessMapCloner.cs new file mode 100644 index 0000000..b4940e1 --- /dev/null +++ b/src.Shared/Maps/EndlessMapCloner.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using System.Text.Json; + +namespace AgainstRomeModifier.Maps; + +public sealed class EndlessMapCloner +{ + private readonly EndlessMapCatalog _catalog; + public EndlessMapCloner(EndlessMapCatalog? catalog = null) => _catalog = catalog ?? new EndlessMapCatalog(); + + public EndlessMapInfo Clone(string gamePath, int sourceSlot, int newSlot, string newName) + { + if (newSlot is < 5 or > 999) throw new ArgumentOutOfRangeException(nameof(newSlot), "自製地圖槽位必須在 ENDL_005 至 ENDL_999。"); + if (string.IsNullOrWhiteSpace(newName)) throw new ArgumentException("請輸入地圖名稱。", nameof(newName)); + if (newName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) throw new ArgumentException("地圖名稱含不允許的字元。", nameof(newName)); + string normalizedGamePath = EndlessMapCatalog.ValidateGamePath(gamePath); + EndlessMapInfo source = _catalog.Require(normalizedGamePath, sourceSlot); + string mapsPath = Path.Combine(normalizedGamePath, "MAPS"); + string mapId = $"ENDL_{newSlot:000}"; + string destination = Path.Combine(mapsPath, mapId); + string temporary = destination + ".tmp_arm"; + if (Directory.Exists(destination) || Directory.Exists(temporary)) throw new IOException("目標地圖槽位已存在或有待清理暫存資料夾: " + mapId); + + try + { + CopyDirectory(source.DirectoryPath, temporary); + VerifyCopy(source.DirectoryPath, temporary); + RewriteKnownFiles(temporary, source.Id, mapId, newName); + var marker = new CustomMapEntry(newSlot, sourceSlot, DateTimeOffset.UtcNow, ToolVersion()); + Core.Services.SafeFileWriter.WriteAllBytes(Path.Combine(temporary, CustomMapManifest.MarkerFileName), JsonSerializer.SerializeToUtf8Bytes(marker, new JsonSerializerOptions { WriteIndented = true })); + Directory.Move(temporary, destination); + CustomMapManifest manifest = CustomMapManifest.Load(normalizedGamePath); + manifest.Register(marker); + manifest.Save(normalizedGamePath); + return _catalog.Require(normalizedGamePath, newSlot); + } + catch + { + if (Directory.Exists(temporary)) Directory.Delete(temporary, true); + throw; + } + } + + private static void RewriteKnownFiles(string directory, string oldMapId, string newMapId, string newName) + { + string briefing = Path.Combine(directory, "TEXT", "US", "briefing.put"); + if (File.Exists(briefing)) { var put = PutTextDocument.Load(briefing); put.SetValue("briefing_titel_1", newName); put.Save(); } + foreach (string path in Directory.GetFiles(directory, "Endlos_*_Siedlung*.sdl", SearchOption.TopDirectoryOnly)) { var sdl = SdlDocument.Load(path); sdl.RewriteMapPath(oldMapId, newMapId); sdl.Save(); } + } + + private static void CopyDirectory(string source, string destination) + { + foreach (string directory in Directory.GetDirectories(source, "*", SearchOption.AllDirectories)) Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory))); + Directory.CreateDirectory(destination); + foreach (string file in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) + { + string target = Path.Combine(destination, Path.GetRelativePath(source, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, false); + } + } + + private static void VerifyCopy(string source, string destination) + { + var sourceFiles = Directory.GetFiles(source, "*", SearchOption.AllDirectories).Select(x => new FileInfo(x)).OrderBy(x => x.FullName).ToArray(); + var destinationFiles = Directory.GetFiles(destination, "*", SearchOption.AllDirectories).Select(x => new FileInfo(x)).OrderBy(x => x.FullName).ToArray(); + if (sourceFiles.Length != destinationFiles.Length || sourceFiles.Sum(x => x.Length) != destinationFiles.Sum(x => x.Length)) throw new InvalidDataException("地圖複製驗證失敗:檔案數或總大小不一致。"); + } + + private static string ToolVersion() => Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "development"; +} diff --git a/src.Shared/Maps/MapTextDocuments.cs b/src.Shared/Maps/MapTextDocuments.cs new file mode 100644 index 0000000..1ac1aab --- /dev/null +++ b/src.Shared/Maps/MapTextDocuments.cs @@ -0,0 +1,131 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace AgainstRomeModifier.Maps; + +public abstract class MapTextDocument +{ + private readonly byte[] _originalBytes; + protected readonly Encoding Encoding; + protected string Text; + protected MapTextDocument(string path, Encoding encoding) + { + Path = path; + Encoding = encoding; + _originalBytes = File.ReadAllBytes(path); + Text = encoding.GetString(GameLZSS.DecompressPfil(_originalBytes)); + } + public string Path { get; } + public void Save(FileRollbackScope? rollback = null) + { + byte[] bytes = Encoding.GetBytes(Text); + if (IsPfil(_originalBytes)) bytes = GameLZSS.CompressPfil(bytes, _originalBytes); + Core.Services.SafeFileWriter.WriteAllBytes(Path, bytes, rollback); + } + private static bool IsPfil(byte[] bytes) => bytes.Length >= 64 && bytes[0] == 'P' && bytes[1] == 'F' && bytes[2] == 'I' && bytes[3] == 'L'; + protected static Encoding GameEncoding { get; } = CreateGameEncoding(); + private static Encoding CreateGameEncoding() { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); return Encoding.GetEncoding(1251, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); } +} + +public sealed class BodenIniDocument : MapTextDocument +{ + private BodenIniDocument(string path) : base(path, GameEncoding) { } + public static BodenIniDocument Load(string path) => new(path); + public string? GetValue(string section) => FindValue(section)?.value; + public void SetValue(string section, string value) + { + var found = FindValue(section) ?? throw new KeyNotFoundException("找不到 boden.ini 區段: " + section); + Text = Text[..found.start] + value + Text[found.end..]; + } + private (int start, int end, string value)? FindValue(string section) + { + var match = Regex.Match(Text, $@"(?im)^\s*\[{Regex.Escape(section)}\][^\r\n]*(?:\r?\n)(?[^\r\n;]+)"); + if (!match.Success) return null; + Group value = match.Groups["value"]; + int start = value.Index, end = value.Index + value.Length; + while (start < end && char.IsWhiteSpace(Text[start])) start++; + while (end > start && char.IsWhiteSpace(Text[end - 1])) end--; + return (start, end, Text[start..end]); + } +} + +public sealed class PutTextDocument : MapTextDocument +{ + private PutTextDocument(string path) : base(path, GameEncoding) { } + public static PutTextDocument Load(string path) => new(path); + public string? GetValue(string key) + { + var match = Find(key); return match.Success ? match.Groups["value"].Value : null; + } + public void SetValue(string key, string value) + { + if (value.Contains('"') || value.Contains('\r') || value.Contains('\n')) throw new ArgumentException("地圖文字不可含引號或換行。", nameof(value)); + var match = Find(key); + if (!match.Success) throw new KeyNotFoundException("找不到 .put 變數: " + key); + Group existing = match.Groups["value"]; + Text = Text[..existing.Index] + value + Text[(existing.Index + existing.Length)..]; + } + private Match Find(string key) => Regex.Match(Text, $@"(?im)^\s*var:\s*{Regex.Escape(key)}\s*=\s*""(?[^""]*)"""); +} + +public sealed class SdlDocument : MapTextDocument +{ + private SdlDocument(string path) : base(path, GameEncoding) { } + public static SdlDocument Load(string path) => new(path); + public void RewriteMapPath(string oldMapId, string newMapId) + => Text = Regex.Replace(Text, $@"(?im)^(\s*name\s*=\s*MAPS/){Regex.Escape(oldMapId)}(?=/)", "$1" + newMapId); +} + +public sealed class BodenTexturesDocument : MapTextDocument +{ + private BodenTexturesDocument(string path) : base(path, GameEncoding) { } + public static BodenTexturesDocument Load(string path) => new(path); + public int Dimension + { + get + { + Match match = Regex.Match(Text, @"(?im)^\s*\[Dimension\]\s*\r?\n\s*(\d+)"); + return match.Success && int.TryParse(match.Groups[1].Value, out int dimension) && dimension > 0 + ? dimension : throw new InvalidDataException("boden.txt 缺少有效的 [Dimension]。"); + } + } + + public IReadOnlyList Textures => Entries().Select(x => x.Value).ToArray(); + public string GetTexture(int x, int y) => EntryAt(x, y).Value; + public void SetTexture(int x, int y, string texture) + { + if (string.IsNullOrWhiteSpace(texture) || texture.IndexOfAny(new[] { '\r', '\n' }) >= 0) throw new ArgumentException("材質名稱不可為空或包含換行。", nameof(texture)); + TextureEntry entry = EntryAt(x, y); + Text = Text[..entry.Start] + texture.Trim() + Text[entry.End..]; + } + + private TextureEntry EntryAt(int x, int y) + { + int dimension = Dimension; + if (x is < 0 or >= 64 || y is < 0 or >= 64 || dimension != 64) throw new InvalidOperationException("目前只支援已驗證的 64×64 boden.txt 材質格。"); + TextureEntry[] entries = Entries(); + int index = y * dimension + x; + if (entries.Length != dimension * dimension) throw new InvalidDataException($"boden.txt 應有 {dimension * dimension} 個材質格,實際為 {entries.Length}。"); + return entries[index]; + } + + private TextureEntry[] Entries() + { + Match section = Regex.Match(Text, @"(?im)^\s*\[Texturen\]\s*\r?\n"); + if (!section.Success) throw new InvalidDataException("boden.txt 缺少 [Texturen]。"); + var entries = new List(); int position = section.Index + section.Length; + while (position < Text.Length) + { + int lineEnd = Text.IndexOf('\n', position); if (lineEnd < 0) lineEnd = Text.Length; + int valueStart = position; int valueEnd = lineEnd; + if (valueEnd > valueStart && Text[valueEnd - 1] == '\r') valueEnd--; + while (valueStart < valueEnd && char.IsWhiteSpace(Text[valueStart])) valueStart++; + while (valueEnd > valueStart && char.IsWhiteSpace(Text[valueEnd - 1])) valueEnd--; + if (valueStart < valueEnd) entries.Add(new TextureEntry(valueStart, valueEnd, Text[valueStart..valueEnd])); + position = lineEnd == Text.Length ? lineEnd : lineEnd + 1; + } + return entries.ToArray(); + } + + private sealed record TextureEntry(int Start, int End, string Value); +} diff --git a/src/Core/EndlessAi/EndlessAiOrchestrator.cs b/src/Core/EndlessAi/EndlessAiOrchestrator.cs index 9d62a98..ca62aaf 100644 --- a/src/Core/EndlessAi/EndlessAiOrchestrator.cs +++ b/src/Core/EndlessAi/EndlessAiOrchestrator.cs @@ -212,9 +212,9 @@ public static List ResolvePaths(string gamePath, string pattern) { if (Directory.Exists(mapsPath)) { - for (int i = 0; i < 5; i++) + foreach (string mapDirectory in Directory.GetDirectories(mapsPath, "ENDL_???", SearchOption.TopDirectoryOnly).OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) { - string path = Path.Combine(mapsPath, $"ENDL_{i:000}", "SCRIPT", "ak_level.bci"); + string path = Path.Combine(mapDirectory, "SCRIPT", "ak_level.bci"); if (File.Exists(path)) { paths.Add(path); @@ -262,6 +262,12 @@ public static int GetExpectedFileCount(string pattern) return 0; } + public static int GetExpectedFileCount(string gamePath, string pattern) + { + if (pattern != "MAPS/ENDL_*/SCRIPT/ak_level.bci" && pattern != "MAPS/ENDL_*/Endlos_*_Siedlung*.sdl") return GetExpectedFileCount(pattern); + return ResolvePaths(gamePath, pattern).Count; + } + public PatchState DetectModule(string gamePath, EndlessAiModule module) { bool allOriginal = true; @@ -275,7 +281,7 @@ public PatchState DetectModule(string gamePath, EndlessAiModule module) { anyFileFound = true; } - int expectedCount = GetExpectedFileCount(patch.TargetPattern); + int expectedCount = GetExpectedFileCount(gamePath, patch.TargetPattern); if (paths.Count != expectedCount) { if (paths.Count == 0) diff --git a/src/Core/Localization.cs b/src/Core/Localization.cs index 5a686df..03a6e6e 100644 --- a/src/Core/Localization.cs +++ b/src/Core/Localization.cs @@ -160,6 +160,7 @@ public static string GetFactionName(string faction) { { "NavDefaultStats", "自訂兵種屬性" }, { "NavCurrentStats", "當前兵種數值" }, { "NavSaveManager", "遊戲存檔管理" }, + { "NavMapManager", "地圖管理" }, { "NavDoc", "修改技術文件" }, { "NavSkills", "技能屬性修改" }, { "SkillsHeading", "技能與首領榮譽屬性設定" }, @@ -471,6 +472,7 @@ public static string GetFactionName(string faction) { { "NavDefaultStats", "Custom Unit Stats" }, { "NavCurrentStats", "Current Unit Stats" }, { "NavSaveManager", "Save Manager" }, + { "NavMapManager", "Map Manager" }, { "NavDoc", "Technical Doc" }, { "NavSkills", "Skill Modifiers" }, { "SkillsHeading", "Skill & Leader Glory Settings" }, diff --git a/src/Core/Services/BackupManager.cs b/src/Core/Services/BackupManager.cs index ec9d8bd..0cc8a49 100644 --- a/src/Core/Services/BackupManager.cs +++ b/src/Core/Services/BackupManager.cs @@ -7,6 +7,7 @@ using System.Globalization; using AgainstRomeModifier.Core.Patches; using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Maps; namespace AgainstRomeModifier.Core.Services { @@ -159,6 +160,7 @@ public void TryAutoHealBackupFiles(string gamePath) string normalizedGamePath = Path.GetFullPath(gamePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); foreach (string file in Directory.GetFiles(mapsPath, "team.dat", SearchOption.AllDirectories)) { + if (CustomMapManifest.IsCustomMapDirectory(Path.GetDirectoryName(file)!)) continue; string relPath = Path.GetRelativePath(normalizedGamePath, file).Replace('\\', '/'); string bakPath = file + ".bak"; string loadPath = File.Exists(bakPath) ? bakPath : file; @@ -315,6 +317,8 @@ public bool TryLoadBackupFromGameDirectory(string gamePath, bool showError) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); foreach (string file in Directory.GetFiles(mapsPath, "team.dat", SearchOption.AllDirectories)) { + // 自製圖由 .arm_custom_map 明確標記;它們不是原廠備份基準,也不可建立 .bak。 + if (CustomMapManifest.IsCustomMapDirectory(Path.GetDirectoryName(file)!)) continue; string relPath = Path.GetRelativePath(normalizedGamePath, file).Replace('\\', '/'); string bakPath = file + ".bak"; diff --git a/src/UI/ModifierForm.Layout.cs b/src/UI/ModifierForm.Layout.cs index eafb197..40e068e 100644 --- a/src/UI/ModifierForm.Layout.cs +++ b/src/UI/ModifierForm.Layout.cs @@ -79,6 +79,7 @@ private void ConfigureSidebarLayout() { btnNavSystem, btnNavDefaultStats, btnNavCurrentStats, + btnNavMapManager, btnNavSaveManager, btnNavDoc }; diff --git a/src/UI/ModifierForm.MapManager.cs b/src/UI/ModifierForm.MapManager.cs new file mode 100644 index 0000000..0f36334 --- /dev/null +++ b/src/UI/ModifierForm.MapManager.cs @@ -0,0 +1,80 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Forms; +using AgainstRomeModifier.Maps; + +namespace AgainstRomeModifier { + public partial class ModifierForm { + private readonly EndlessMapCatalog mapCatalog = new EndlessMapCatalog(); + private ListView mapList = null!; + private Label mapManagerStatus = null!; + + private void InitializeMapManagerPage() { + var title = new Label { Text = "地圖管理", AutoSize = true, Location = new System.Drawing.Point(22, 20), Font = fontJhengHei105B, ForeColor = System.Drawing.Color.FromArgb(0, 220, 255) }; + var description = new Label { Text = "檢視無盡模式地圖,並以獨立地圖編輯器安全建立或編輯自製地圖。", AutoSize = true, Location = new System.Drawing.Point(22, 58), ForeColor = System.Drawing.Color.Gainsboro }; + mapList = new ListView { Location = new System.Drawing.Point(22, 96), Size = new System.Drawing.Size(700, 560), View = View.Details, FullRowSelect = true, MultiSelect = false }; + mapList.Columns.Add("槽位", 100); mapList.Columns.Add("名稱", 400); mapList.Columns.Add("類型", 120); + var launch = new Button { Text = "啟動地圖編輯器", Location = new System.Drawing.Point(22, 680), Size = new System.Drawing.Size(180, 38) }; + launch.Click += (_, _) => LaunchMapEditor(); + var refresh = new Button { Text = "重新整理", Location = new System.Drawing.Point(214, 680), Size = new System.Drawing.Size(120, 38) }; + refresh.Click += (_, _) => RefreshMapManager(); + mapManagerStatus = new Label { AutoSize = true, Location = new System.Drawing.Point(22, 735), ForeColor = System.Drawing.Color.SlateGray }; + tabMapManager.Controls.AddRange(new Control[] { title, description, mapList, launch, refresh, mapManagerStatus }); + } + + private void RefreshMapManager() { + if (mapList == null) return; + mapList.Items.Clear(); + string gamePath = GetGamePath(); + if (string.IsNullOrWhiteSpace(gamePath) || !Directory.Exists(gamePath)) { mapManagerStatus.Text = "請先選擇有效的遊戲路徑。"; return; } + try { + foreach (EndlessMapInfo map in mapCatalog.List(gamePath)) { + var item = new ListViewItem(map.Id) { Tag = map }; + item.SubItems.Add(map.DisplayName ?? "(無標題)"); + item.SubItems.Add(map.IsCustom ? "自製" : "原廠"); + mapList.Items.Add(item); + } + mapManagerStatus.Text = $"已找到 {mapList.Items.Count} 張無盡模式地圖。"; + } catch (Exception ex) { mapManagerStatus.Text = "讀取地圖失敗: " + ex.Message; } + } + + private void LaunchMapEditor() { + string gamePath = GetGamePath(); + if (string.IsNullOrWhiteSpace(gamePath) || !Directory.Exists(gamePath)) { MessageBox.Show("請先選擇有效的遊戲路徑。", Loc.Get("TitlePathError"), MessageBoxButtons.OK, MessageBoxIcon.Error); return; } + string? editorPath = ResolveMapEditorPath(); + if (editorPath == null) { MessageBox.Show("找不到 AgainstRomeMapEditor.exe。請重新建置修改器。", Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); return; } + string args = "--game \"" + gamePath + "\""; + if (mapList.SelectedItems.Count == 1 && mapList.SelectedItems[0].Tag is EndlessMapInfo map) args += " --map " + map.Id; + try { Process.Start(new ProcessStartInfo(editorPath, args) { WorkingDirectory = gamePath, UseShellExecute = true }); } + catch (Exception ex) { MessageBox.Show("無法啟動地圖編輯器: " + ex.Message, Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); } + } + + private static string? ResolveMapEditorPath() { + var candidates = new List { + Path.Combine(AppContext.BaseDirectory, "AgainstRomeMapEditor.exe") + }; + + // 開發環境可能同時殘留主專案與 MapEditor 專案的輸出;以 DLL 時間選最新版, + // 避免修改器啟動到先前建置留下的舊 UI。發佈環境則只會使用同目錄副本。 + DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "AgainstRomeModifier.csproj"))) { + directory = directory.Parent; + } + if (directory != null) { + candidates.Add(Path.Combine(directory.FullName, "src.MapEditor", "bin", "Debug", "net8.0-windows", "AgainstRomeMapEditor.exe")); + candidates.Add(Path.Combine(directory.FullName, "src.MapEditor", "bin", "Release", "net8.0-windows", "AgainstRomeMapEditor.exe")); + } + + return candidates + .Where(File.Exists) + .OrderByDescending(path => { + string dll = Path.ChangeExtension(path, ".dll"); + return File.Exists(dll) ? File.GetLastWriteTimeUtc(dll) : File.GetLastWriteTimeUtc(path); + }) + .FirstOrDefault(); + } + } +} diff --git a/src/UI/ModifierForm.cs b/src/UI/ModifierForm.cs index 4814915..c4b6a86 100644 --- a/src/UI/ModifierForm.cs +++ b/src/UI/ModifierForm.cs @@ -32,6 +32,7 @@ public partial class ModifierForm : Form { private Button btnNavDefaultStats = null!; private Button btnNavCurrentStats = null!; private Button btnNavDoc = null!; + private Button btnNavMapManager = null!; // 主要分頁控制項與分頁 @@ -43,6 +44,7 @@ public partial class ModifierForm : Form { private TextBox txtDoc = null!; private TabPage tabSaveManager = null!; private Button btnNavSaveManager = null!; + private TabPage tabMapManager = null!; // 存檔管理介面表格與預覽圖 @@ -568,6 +570,14 @@ private void InitializeComponent() { RefreshNavButtons(); }; + btnNavMapManager = new Button { Location = new Point(10, 0) }; + StyleNavButton(btnNavMapManager, "NavMapManager", tabMapManager); + btnNavMapManager.Click += (s, e) => { + ShowTabPage(tabMapManager); + RefreshNavButtons(); + RefreshMapManager(); + }; + btnNavSaveManager = new Button { Location = new Point(10, 0) }; StyleNavButton(btnNavSaveManager, "NavSaveManager", tabSaveManager); btnNavSaveManager.Click += (s, e) => { @@ -630,6 +640,7 @@ private void InitializeComponent() { pnlSidebar.Controls.Add(btnNavSystem); pnlSidebar.Controls.Add(btnNavDefaultStats); pnlSidebar.Controls.Add(btnNavCurrentStats); + pnlSidebar.Controls.Add(btnNavMapManager); pnlSidebar.Controls.Add(btnNavSaveManager); pnlSidebar.Controls.Add(btnNavDoc); pnlSidebar.Controls.Add(lblSidebarLang); @@ -662,6 +673,12 @@ private void InitializeComponent() { mainTabControl.TabPages.Add(tabSystem); mainTabControl.TabPages.Add(tabDefaultStats); mainTabControl.TabPages.Add(tabCurrentStats); + tabMapManager = new TabPage { + BackColor = Color.FromArgb(10, 11, 16), + UseVisualStyleBackColor = false + }; + mainTabControl.TabPages.Add(tabMapManager); + InitializeMapManagerPage(); pnlNumericCard = new Panel { Location = new Point(0, 0), @@ -1495,6 +1512,7 @@ private void StyleNavButton(Button btn, string key, TabPage associatedPage) { "NavSystem" => mainTabControl.SelectedTab == tabSystem, "NavDefaultStats" => mainTabControl.SelectedTab == tabDefaultStats, "NavCurrentStats" => mainTabControl.SelectedTab == tabCurrentStats, + "NavMapManager" => mainTabControl.SelectedTab == tabMapManager, "NavSaveManager" => mainTabControl.SelectedTab == tabSaveManager, "NavDoc" => mainTabControl.SelectedTab == tabDoc, _ => mainTabControl.SelectedTab == associatedPage @@ -1547,6 +1565,7 @@ private void RefreshNavButtons() { btnNavSystem.Invalidate(); btnNavDefaultStats.Invalidate(); btnNavCurrentStats.Invalidate(); + btnNavMapManager.Invalidate(); btnNavSaveManager.Invalidate(); btnNavDoc.Invalidate(); } diff --git a/tests/AgainstRomeModifier.Tests/MapEditorPhase1Tests.cs b/tests/AgainstRomeModifier.Tests/MapEditorPhase1Tests.cs new file mode 100644 index 0000000..e825975 --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/MapEditorPhase1Tests.cs @@ -0,0 +1,79 @@ +using System.Text; +using AgainstRomeModifier.Maps; + +namespace AgainstRomeModifier.Tests; + +public sealed class MapEditorPhase1Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "AgainstRomeMapEditorTests_" + Guid.NewGuid().ToString("N")); + + [Fact] + public void Clone_Creates_next_slot_preserves_unknown_files_and_registers_custom_map() + { + CreateSourceMap(); + var catalog = new EndlessMapCatalog(); + Assert.Equal(5, catalog.GetNextFreeSlot(_root)); + + EndlessMapInfo cloned = new EndlessMapCloner(catalog).Clone(_root, 0, 5, "Test map"); + + Assert.True(cloned.IsCustom); + string clonePath = Path.Combine(_root, "MAPS", "ENDL_005"); + Assert.Equal(new byte[] { 1, 2, 3, 4 }, File.ReadAllBytes(Path.Combine(clonePath, "DATA", "unknown.dat"))); + Assert.Equal("Test map", PutTextDocument.Load(Path.Combine(clonePath, "TEXT", "US", "briefing.put")).GetValue("briefing_titel_1")); + Assert.Contains("MAPS/ENDL_005/", ReadPfil(Path.Combine(clonePath, "Endlos_Rom_Siedlung1.sdl"))); + Assert.True(File.Exists(Path.Combine(clonePath, CustomMapManifest.MarkerFileName))); + Assert.Contains(CustomMapManifest.Load(_root).Entries, x => x.Slot == 5 && x.SourceSlot == 0); + Assert.False(Directory.Exists(Path.Combine(_root, "MAPS", "ENDL_005.tmp_arm"))); + } + + [Fact] + public void Documents_Keep_pfil_header_and_only_change_requested_values() + { + string path = Path.Combine(_root, "boden.ini"); + Directory.CreateDirectory(_root); + File.WriteAllBytes(path, SyntheticFixture.Pfil("[Waterlevel]\r\n120\r\n[WaterColor]\r\n0xffdfbf\r\n")); + byte[] original = File.ReadAllBytes(path); + + var document = BodenIniDocument.Load(path); + document.SetValue("Waterlevel", "180"); + document.Save(); + + byte[] saved = File.ReadAllBytes(path); + Assert.Equal(original.Take(16), saved.Take(16)); + string text = SyntheticFixture.Text(saved); + Assert.Contains("[Waterlevel]\r\n180", text); + Assert.Contains("[WaterColor]\r\n0xffdfbf", text); + } + + [Fact] + public void BodenTexturesDocument_Changes_exactly_one_tile_and_round_trips() + { + string path = Path.Combine(_root, "boden.txt"); + Directory.CreateDirectory(_root); + string[] tiles = Enumerable.Range(0, 4096).Select(i => $"T{i:0000000}").ToArray(); + File.WriteAllBytes(path, SyntheticFixture.Pfil("[Dimension]\r\n64\r\n[Texturen]\r\n" + string.Join("\r\n", tiles) + "\r\n")); + + var document = BodenTexturesDocument.Load(path); + document.SetTexture(3, 2, "4BJ___51"); + document.Save(); + + var loaded = BodenTexturesDocument.Load(path); + Assert.Equal("4BJ___51", loaded.GetTexture(3, 2)); + Assert.Equal("T0000000", loaded.GetTexture(0, 0)); + Assert.Equal("T0004095", loaded.GetTexture(63, 63)); + } + + private void CreateSourceMap() + { + string map = Path.Combine(_root, "MAPS", "ENDL_000"); + Directory.CreateDirectory(Path.Combine(map, "TEXT", "US")); + Directory.CreateDirectory(Path.Combine(map, "DATA")); + File.WriteAllBytes(Path.Combine(map, "TEXT", "US", "briefing.put"), SyntheticFixture.Pfil("var:briefing_titel_1 =\"原始地圖\";\r\n")); + File.WriteAllBytes(Path.Combine(map, "boden.ini"), SyntheticFixture.Pfil("[Waterlevel]\r\n120\r\n[WaterColor]\r\n0xffdfbf\r\n[DayStartTime]\r\n6\r\n[DayEndTime]\r\n20\r\n[RainDropsOnWater]\r\n1\r\n")); + File.WriteAllBytes(Path.Combine(map, "Endlos_Rom_Siedlung1.sdl"), SyntheticFixture.Pfil("[object0000]\r\nname =MAPS/ENDL_000/Endlos_Rom_Siedlung1.sdl\r\n")); + File.WriteAllBytes(Path.Combine(map, "DATA", "unknown.dat"), new byte[] { 1, 2, 3, 4 }); + } + + private static string ReadPfil(string path) => SyntheticFixture.GameEncoding.GetString(GameLZSS.DecompressPfil(File.ReadAllBytes(path))); + public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, true); } +} From ef9be48fcffb3d72e0f45035c46118732f778352 Mon Sep 17 00:00:00 2001 From: Jackie Chen Date: Sat, 11 Jul 2026 22:14:23 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E8=AA=BF=E6=95=B4=E5=B0=88=E6=A1=88?= =?UTF-8?q?=E5=BB=BA=E7=BD=AE=E8=A8=AD=E5=AE=9A=EF=BC=8CDebug=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=B8=8B=E7=95=A5=E9=81=8E=E9=83=A8=E5=88=86=E5=B0=88?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 將 src.MapEditor 與 src.Shared 專案設定為在 Debug 組態下不自動建置,並調整專案引用順序,優化解決方案建置流程。 --- AgainstRomeModifier.slnx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AgainstRomeModifier.slnx b/AgainstRomeModifier.slnx index 052651d..aea62de 100644 --- a/AgainstRomeModifier.slnx +++ b/AgainstRomeModifier.slnx @@ -1,6 +1,10 @@ - - + + + + + + From 187b0d9ffa6fa2f63e6ae77aca06f95d43c3580d Mon Sep 17 00:00:00 2001 From: Jackie Chen Date: Sun, 12 Jul 2026 08:31:52 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E5=9C=B0=E5=9C=96=E7=B7=A8=E8=BC=AF?= =?UTF-8?q?=E5=99=A8=E6=94=AF=E6=8F=B4=E9=9B=A2=E7=B7=9A=E5=A0=B4=E6=99=AF?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E8=88=87=E5=9C=B0=E5=9C=96=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 floortex.dat 真實地表材質離線渲染,支援縮放、平移、格線、場景物件顯示 - 解析 SDL 檔案,於地圖上標示建築/單位/物件,並顯示統計 - 新增 GameMapCatalog,地圖自動分組(劇情/歷史/教學/多人/無盡/自製) - 支援從任意地圖複製為自製 ENDL 地圖,並可安全刪除自製地圖(含 manifest 回滾) - UI 強化:筆刷大小、格線、物件顯示、地表還原、Ctrl+S/Z/Y 快捷鍵、水面顏色選擇器 - 地圖概覽(minimap)獨立顯示,編輯僅限自製地圖 - 增加多項單元測試,確保地圖管理與渲染正確 - 文件補充遊戲資源格式與安全規範 --- AI_HANDOFF.md | 12 + docs/reverse-engineering/map-formats.md | 12 + src.MapEditor/FloorTextureLibrary.cs | 42 +++ src.MapEditor/MapCanvasControl.cs | 257 ++++++++++++++---- src.MapEditor/MapEditorForm.cs | 202 ++++++++++---- src.Shared/Maps/EndlessMapCloner.cs | 22 +- src.Shared/Maps/EndlessMapDeleter.cs | 38 +++ src.Shared/Maps/GameMapCatalog.cs | 56 ++++ src.Shared/Maps/SdlSceneCatalog.cs | 67 +++++ .../MapEditorPhase1Tests.cs | 64 +++++ 10 files changed, 669 insertions(+), 103 deletions(-) create mode 100644 src.MapEditor/FloorTextureLibrary.cs create mode 100644 src.Shared/Maps/EndlessMapDeleter.cs create mode 100644 src.Shared/Maps/GameMapCatalog.cs create mode 100644 src.Shared/Maps/SdlSceneCatalog.cs diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md index f62658c..2cf97c2 100644 --- a/AI_HANDOFF.md +++ b/AI_HANDOFF.md @@ -18,6 +18,18 @@ Build a dedicated map editor that can eventually provide an Age-of-Empires-II-li ## Map Editor Phase 1 Progress (2026-07-11) +- Independent scene renderer milestone (2026-07-12): `boden.bmp` + `Heightmapstep` now drive read-only terrain hill-shading and a `Waterlevel / Heightmapstep` threshold drives a `WaterColor` water overlay; visual QA on KAMP_000 matched the river to its minimap. `SdlSceneCatalog` parses top-level PFIL SDL files, maps `refpos + pos` through the verified 64-world-units-per-map-pixel relation, classifies building/unit/other objects, and renders team-colored scene markers. KAMP_000 produced 138 objects and ENDL_000 613 in a read-only probe; the UI adds a player-facing object toggle and friendly scene summary/list without internal names. The default grouped selection bug was fixed so startup reliably selects the first visible campaign map. The game-launch command is explicitly optional and no longer presented as the primary preview. Initial terrain composition now reuses one Graphics context instead of creating 4,096 contexts. Final verification: Release tests 105/105; full Release and MapEditor Debug builds 0 warnings/errors; `git diff --check` clean; temporary probe removed. + +- Mode grouping and camera/relief pass (2026-07-12): the 73-map list now uses visible `ListViewGroup` sections with per-mode counts for custom, campaign, historical, tutorial, endless, multiplayer, and other maps. The offline canvas has mouse-wheel zoom (0.75x–6x) and middle-drag pan. It composites each map's read-only `smooth.bmp`/`emboss.bmp` and a `boden.bmp` gradient-derived hill-shade over real floor textures to restore map-specific terrain lighting without claiming or enabling unverified height writes. Computer-use visual QA against the read-only fixture confirmed the campaign grouping, relief display, and live wheel zoom. Verification: Release tests 104/104; full Release and MapEditor Debug builds 0 warnings/errors; `git diff --check` clean. + +- Offline renderer scope expansion (2026-07-12): user requires the editor to show the map independently without launching the game. `floortex.dat` was proven to be a ZIP container with 3,005 entries and direct `boden.txt` basename matches; the editor now reads its real 128×128 floor BMPs and renders every terrain cell from game textures. Added `GameMapCatalog`: the read-only fixture contains 73 renderable maps (KAMP 34, MP 20, HIST 10, ENDL 5, TUTOR 4), all now listed and categorized; any renderable original can be cloned into a custom ENDL slot. Visual QA against the repo's read-only game fixture confirmed campaign variants and real floor textures. `alr.dat` (2,075 entries), `apt.dat` (222), and `shad.dat` (2,674) are also ZIP containers and are the next object/model renderer inputs. Do not regress to minimap-as-editor or require launching the game for primary preview. + +- Editing-scene separation (2026-07-12): the central canvas no longer draws `minimap.bmp` as its background. It now renders every 64×64 terrain cell as the editable scene, with changed cells outlined and all painting/sample operations occurring on that scene. The original minimap is displayed only in a separate bottom-right `地圖概覽` navigator. Removed the toolbar mode that replaced the editor with the minimap. A computer-use visual QA pass confirmed the scene, grid, palette, and separate overview are visible together without touching game data. Verification: Release tests 103/103; full Release build and MapEditor Debug build both 0 warnings/errors; obsolete layer/view symbols absent; `git diff --check` clean. + +- Added player-facing custom-map deletion. The command is enabled only for marker-backed custom maps, shows a default-No confirmation with map name and slot, refuses original maps in the shared deletion service, validates the exact MAPS child path, temporarily moves the directory while updating the manifest, and restores both directory and manifest if deletion fails. Verification: Release tests 103/103, full Release build 0 warnings/errors, and `git diff --check` clean. + +- Player-facing editor pass: removed developer-only affordances and wording from the main UI, including internal texture IDs and disabled unverified height/collision/object tools. The terrain palette is now always visible with friendly style names only. Added 1×1/3×3/5×5 terrain brushes, grid toggle, restore-unsaved-terrain action, a real water-color picker, Chinese gameplay labels, and Ctrl+S/Ctrl+Z/Ctrl+Y shortcuts. The editor continues to write only the established custom-map title, environment, and terrain cells; no new game format was made writable. Verification: Release tests 101/101, full Release build and MapEditor Debug build both 0 warnings/errors, `git diff --check` clean, and hidden startup smoke remained alive after 3 seconds. + - Added `src.Shared/AgainstRome.Shared.csproj`; `GameLZSS`, `FileRollbackScope`, and `SafeFileWriter` now have one shared implementation. The original modifier references it without changing their namespaces or call sites. - Added `src.MapEditor/AgainstRomeMapEditor.csproj`, producing `AgainstRomeMapEditor.exe`. It accepts `--game ` and optional `--map ENDL_NNN`, lists maps, clones a selected map into the next available `ENDL_005..999` slot, and safely edits the Phase 1 briefing/boden.ini fields. - Shared map core implements actual `ENDL_???` discovery, `.arm_custom_map` + `MAPS/arm_custom_maps.json` registration, temporary-directory cloning, unknown-file byte preservation, briefing title changes, and SDL `name` map-path rewrite. All PFIL rewrites retain their original 64-byte headers and write through `SafeFileWriter`; multi-file property saves use `FileRollbackScope`. diff --git a/docs/reverse-engineering/map-formats.md b/docs/reverse-engineering/map-formats.md index 6b323e3..f614d61 100644 --- a/docs/reverse-engineering/map-formats.md +++ b/docs/reverse-engineering/map-formats.md @@ -23,6 +23,9 @@ - `boden.bmp` 在五張圖均近乎 R=G=B;`emboss.bmp` 與 `smooth.bmp` 亦主要為灰階,但 `vertex.bmp` 的三通道明顯不同。 - `ENDL_000/001/002/003/004` 的 `vertex.bmp` 通道範圍分別不同,不能使用固定「某一顏色等於高度」公式。 - 目視 `ENDL_000`:`vertex.bmp` 的低色彩區與 minimap 水域/地勢特徵有表面關聯,但這只是候選相關性,尚未具備寫入資格。 +- 2026-07-12 追加唯讀跨層目視:`KAMP_000/boden.bmp` 的連續灰階坡面、河谷與人工高低差和 `minimap.bmp` 明確對齊;`vertex.bmp` 則更接近彩色地表快取。這足以讓離線 renderer 使用 `boden.bmp` 的局部梯度產生只讀 hill-shading,但仍不足以推導世界高度單位或授權高度寫入。 +- 同日比對 `KAMP_000`、`ENDL_000`、`MP_000`、`HIST_000`:`Heightmapstep` 均為 4,`Waterlevel / Heightmapstep` 分別為 62、30、30、36,與各圖 `boden.bmp` 河谷低灰階區吻合。離線 renderer 因此以此門檻和 `WaterColor` 產生只讀水面遮罩;此證據仍只授權顯示,不授權直接改寫高度圖。 +- SDL 唯讀解析確認 `[settlement] refpos` 加上各 `[objectNNNN] pos` 得到物件世界座標;連續柵欄以 64 世界單位排列,而 16,384 世界單位對應 256 地圖像素,因此 `world / 64` 可直接落到地圖像素。`KAMP_000/TEAM_7.sdl` 解析出 138 個有效物件,`ENDL_000` 八個聚落 SDL 合計 613 個。離線 renderer 依此顯示建築/單位/其他物件;目前只讀,不把尚未完成 round-trip 驗證的 SDL 物件編輯暴露給玩家。 ## 未證實,禁止寫入 @@ -34,6 +37,15 @@ ## 原遊戲渲染與內部 TextureEditor +### 可供獨立 renderer 使用的原始資源(2026-07-12) + +- `floortex.dat` 是標準 ZIP 容器(副檔名雖為 `.dat`),共 3,005 個 entry;地表圖位於 `SYSTEM/DATA/FLOORTEXTURE/*.bmp`。 +- `boden.txt [Texturen]` 的名稱可直接對應上述 BMP basename。例如 `4BJ___51` 與 `L5B09T1A` 均已在容器內找到;抽查圖檔為 128×128、8-bit BMP。 +- `alr.dat` 同樣是 ZIP 容器,共 2,075 個 `SYSTEM/DATA/ALR/*.alr` 模型/動畫資源。 +- `apt.dat` 是 ZIP 容器,共 222 個 `SYSTEM/DATA/APT/*.apt` 資源;`shad.dat` 是 ZIP 容器,共 2,674 個圖示/陰影資源。 +- 唯讀遊戲樣本的 `MAPS` 下共有 73 個具備 `boden.txt` 與 `minimap.bmp` 的可渲染目錄:`KAMP` 34、`MP` 20、`HIST` 10、`ENDL` 5、`TUTOR` 4。 +- 因此地圖編輯器應以自行讀取這些容器的離線 renderer 為主,不再以 `minimap.bmp` 或啟動遊戲作為主要預覽。原始資源只從使用者選定的遊戲資料夾唯讀載入,不得加入版本庫。 + - EXE 靜態庫在 `FUN_004a4c20`(`0x004a4c20`)包含 `TextureEditor V0.1`、參數清單與鍵盤操作字串;主畫面迴圈 `FUN_0047ae20` 僅在全域模式值為 `9` 時呼叫它。 - 正常模式轉換函式只公開模式 `0、1、2、4、7、8`,目前沒有找到將模式設為 `9` 的正常選單或命令列路徑。 - 該函式處理 buffer、seed、sharpen、blur、combine 等程序影像操作。現有證據較符合內部材質產生/除錯工具,不能視為完整地圖編輯器或安全的 3D 地圖預覽入口。 diff --git a/src.MapEditor/FloorTextureLibrary.cs b/src.MapEditor/FloorTextureLibrary.cs new file mode 100644 index 0000000..c9d2392 --- /dev/null +++ b/src.MapEditor/FloorTextureLibrary.cs @@ -0,0 +1,42 @@ +using System.IO.Compression; + +namespace AgainstRomeMapEditor; + +internal sealed class FloorTextureLibrary : IDisposable +{ + private readonly ZipArchive? _archive; + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _textures = new(StringComparer.OrdinalIgnoreCase); + + public FloorTextureLibrary(string archivePath) + { + if (!File.Exists(archivePath)) return; + _archive = ZipFile.OpenRead(archivePath); + foreach (ZipArchiveEntry entry in _archive.Entries) + { + if (!entry.FullName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) continue; + string name = Path.GetFileNameWithoutExtension(entry.Name); + if (!string.IsNullOrWhiteSpace(name)) _entries.TryAdd(name, entry); + } + } + + public bool IsAvailable => _archive is not null && _entries.Count > 0; + + public Bitmap? Get(string textureName) + { + if (_textures.TryGetValue(textureName, out Bitmap? cached)) return cached; + if (!_entries.TryGetValue(textureName, out ZipArchiveEntry? entry)) return null; + using Stream stream = entry.Open(); + using var source = new Bitmap(stream); + var texture = new Bitmap(source); + _textures[textureName] = texture; + return texture; + } + + public void Dispose() + { + foreach (Bitmap texture in _textures.Values) texture.Dispose(); + _textures.Clear(); + _archive?.Dispose(); + } +} diff --git a/src.MapEditor/MapCanvasControl.cs b/src.MapEditor/MapCanvasControl.cs index 6bdd64a..2b3bbf9 100644 --- a/src.MapEditor/MapCanvasControl.cs +++ b/src.MapEditor/MapCanvasControl.cs @@ -1,24 +1,36 @@ using System.Drawing.Drawing2D; +using System.Drawing.Imaging; using AgainstRomeModifier.Maps; namespace AgainstRomeMapEditor; -internal enum MapCanvasLayer { Textures, Minimap, Collision } - internal sealed class MapCanvasControl : Control { private Bitmap? _bitmap; + private Bitmap? _terrainScene; + private Bitmap? _emboss; + private Bitmap? _smooth; + private Bitmap? _heightShade; + private Bitmap? _waterOverlay; + private FloorTextureLibrary? _floorTextures; private string[]? _textures; private string[]? _baselineTextures; + private IReadOnlyList _sceneObjects = Array.Empty(); private readonly Dictionary _texturePreviewColors = new(StringComparer.OrdinalIgnoreCase); private int _dimension; private bool _painting; + private bool _panning; + private Point _panStart; + private PointF _pan = PointF.Empty; + private float _zoom = 1f; private readonly HashSet _paintedInDrag = new(); - public MapCanvasLayer Layer { get; private set; } = MapCanvasLayer.Textures; public string? BrushTexture { get; set; } + public int BrushSize { get; set; } = 1; public bool EditingEnabled { get; set; } public bool ShowGrid { get; set; } = true; + public bool ShowObjects { get; set; } = true; + public int SceneObjectCount => _sceneObjects.Count; public event EventHandler? TexturePainted; public event EventHandler? TileHovered; public event EventHandler? TextureSampled; @@ -29,23 +41,27 @@ public MapCanvasControl() BackColor = Color.FromArgb(24, 28, 36); Dock = DockStyle.Fill; Cursor = Cursors.Cross; + SetStyle(ControlStyles.Selectable, true); } - public void LoadBitmapLayer(string mapDirectory, MapCanvasLayer layer) - { - _bitmap?.Dispose(); _bitmap = null; _textures = null; _baselineTextures = null; _texturePreviewColors.Clear(); _dimension = 0; Layer = layer; - string name = layer == MapCanvasLayer.Minimap ? "minimap.bmp" : "collision.bmp"; - string path = Path.Combine(mapDirectory, name); - if (File.Exists(path)) using (var source = new Bitmap(path)) _bitmap = new Bitmap(source); - Invalidate(); - } - - public void LoadTextures(int dimension, IReadOnlyList textures, IReadOnlyList baselineTextures, string minimapPath) + public bool LoadTextures(int dimension, IReadOnlyList textures, IReadOnlyList baselineTextures, string mapDirectory, string floorTextureArchivePath, IReadOnlyList sceneObjects, float waterLevel, float heightMapStep, Color waterColor) { - _bitmap?.Dispose(); _bitmap = null; Layer = MapCanvasLayer.Textures; + _bitmap?.Dispose(); _bitmap = null; _terrainScene?.Dispose(); _terrainScene = null; _emboss?.Dispose(); _emboss = null; _smooth?.Dispose(); _smooth = null; _heightShade?.Dispose(); _heightShade = null; _waterOverlay?.Dispose(); _waterOverlay = null; _floorTextures?.Dispose(); _floorTextures = null; + string minimapPath = Path.Combine(mapDirectory, "minimap.bmp"); if (File.Exists(minimapPath)) using (var source = new Bitmap(minimapPath)) _bitmap = new Bitmap(source); + _emboss = LoadBitmap(Path.Combine(mapDirectory, "emboss.bmp")); + _smooth = LoadBitmap(Path.Combine(mapDirectory, "smooth.bmp")); + using (Bitmap? heightMap = LoadBitmap(Path.Combine(mapDirectory, "boden.bmp"))) + { + _heightShade = heightMap is null ? null : BuildHeightShade(heightMap); + _waterOverlay = heightMap is null || heightMapStep <= 0 ? null : BuildWaterOverlay(heightMap, waterLevel / heightMapStep, waterColor); + } _dimension = dimension; _textures = textures.ToArray(); _baselineTextures = baselineTextures.ToArray(); - BuildTexturePreviewColors(); Invalidate(); + _zoom = 1f; _pan = PointF.Empty; + _floorTextures = new FloorTextureLibrary(floorTextureArchivePath); + _sceneObjects = sceneObjects; + BuildTexturePreviewColors(); RenderTerrainScene(); Invalidate(); + return _floorTextures.IsAvailable; } public void SetTexture(int x, int y, string texture) @@ -54,6 +70,7 @@ public void SetTexture(int x, int y, string texture) int index = y * _dimension + x; if (index < 0 || index >= _textures.Length) return; _textures[index] = texture; + RenderTerrainCell(x, y); Invalidate(); } @@ -61,50 +78,43 @@ protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); e.Graphics.SmoothingMode = SmoothingMode.HighQuality; - if (Layer == MapCanvasLayer.Textures && _textures is not null && _dimension > 0) + if (_textures is not null && _dimension > 0) { DrawTextureEditor(e.Graphics); return; } - if (_bitmap is not null) - { - Rectangle bounds = Fit(_bitmap.Size, ClientRectangle); - e.Graphics.InterpolationMode = InterpolationMode.NearestNeighbor; - e.Graphics.DrawImage(_bitmap, bounds); - DrawFrame(e.Graphics, bounds); - return; - } - TextRenderer.DrawText(e.Graphics, "請從左側選擇地圖", Font, ClientRectangle, Color.SlateGray, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } private void DrawTextureEditor(Graphics graphics) { - Rectangle bounds = Fit(new Size(_dimension, _dimension), ClientRectangle); - if (_bitmap is not null) + string[] textures = _textures!; + Rectangle bounds = SceneBounds(); + float cellWidth = bounds.Width / (float)_dimension, cellHeight = bounds.Height / (float)_dimension; + if (_terrainScene is not null) { graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; - graphics.DrawImage(_bitmap, bounds); + graphics.DrawImage(_terrainScene, bounds); + if (_heightShade is not null) graphics.DrawImage(_heightShade, bounds); + if (_waterOverlay is not null) graphics.DrawImage(_waterOverlay, bounds); + DrawMapOverlay(graphics, _smooth, bounds, .12f); + DrawMapOverlay(graphics, _emboss, bounds, .22f); } else { using var background = new SolidBrush(Color.FromArgb(55, 60, 68)); graphics.FillRectangle(background, bounds); } - float cellWidth = bounds.Width / (float)_dimension, cellHeight = bounds.Height / (float)_dimension; - if (_baselineTextures is not null && _baselineTextures.Length == _textures!.Length) + if (_baselineTextures is not null && _baselineTextures.Length == textures.Length) { - for (int index = 0; index < _textures.Length; index++) + using var changedPen = new Pen(Color.FromArgb(235, 255, 220, 80), 1.5f); + for (int index = 0; index < textures.Length; index++) { - if (StringComparer.OrdinalIgnoreCase.Equals(_textures[index], _baselineTextures[index])) continue; + if (StringComparer.OrdinalIgnoreCase.Equals(textures[index], _baselineTextures[index])) continue; int x = index % _dimension, y = index / _dimension; - Color preview = GetTexturePreviewColor(_textures[index]); - using var overlay = new SolidBrush(Color.FromArgb(185, preview)); - RectangleF cell = new(bounds.X + x * cellWidth, bounds.Y + y * cellHeight, Math.Max(1, cellWidth), Math.Max(1, cellHeight)); - graphics.FillRectangle(overlay, cell); - using var changedPen = new Pen(Color.FromArgb(230, 255, 220, 80), 1); graphics.DrawRectangle(changedPen, cell.X, cell.Y, cell.Width, cell.Height); + graphics.DrawRectangle(changedPen, bounds.X + x * cellWidth, bounds.Y + y * cellHeight, cellWidth, cellHeight); } } @@ -119,12 +129,18 @@ private void DrawTextureEditor(Graphics graphics) graphics.DrawLine(shadow, bounds.Left, py + 1, bounds.Right, py + 1); graphics.DrawLine(light, bounds.Left, py, bounds.Right, py); } } + if (ShowObjects) DrawSceneObjects(graphics, bounds); DrawFrame(graphics, bounds); } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); + Focus(); + if (e.Button == MouseButtons.Middle) + { + _panning = true; _panStart = e.Location; Cursor = Cursors.SizeAll; return; + } if (e.Button == MouseButtons.Right && TryGetTile(e.Location, out int sampleX, out int sampleY) && _textures is not null) { TextureSampled?.Invoke(this, new TextureSampleEventArgs(sampleX, sampleY, _textures[sampleY * _dimension + sampleX])); @@ -137,33 +153,52 @@ protected override void OnMouseDown(MouseEventArgs e) protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); + if (_panning && e.Button == MouseButtons.Middle) + { + _pan = new PointF(_pan.X + e.X - _panStart.X, _pan.Y + e.Y - _panStart.Y); _panStart = e.Location; Invalidate(); return; + } if (TryGetTile(e.Location, out int x, out int y)) TileHovered?.Invoke(this, new TileHoverEventArgs(x, y, _textures?[y * _dimension + x])); if (_painting && e.Button == MouseButtons.Left) TryPaint(e.Location); } protected override void OnMouseUp(MouseEventArgs e) { - base.OnMouseUp(e); _painting = false; _paintedInDrag.Clear(); + base.OnMouseUp(e); _painting = false; _panning = false; Cursor = Cursors.Cross; _paintedInDrag.Clear(); + } + + protected override void OnMouseWheel(MouseEventArgs e) + { + base.OnMouseWheel(e); + float previous = _zoom; + _zoom = Math.Clamp(_zoom * (e.Delta > 0 ? 1.2f : 1f / 1.2f), .75f, 6f); + if (Math.Abs(previous - _zoom) < .001f) return; + _pan = new PointF(_pan.X * (_zoom / previous), _pan.Y * (_zoom / previous)); Invalidate(); } private void TryPaint(Point location) { - if (!EditingEnabled || Layer != MapCanvasLayer.Textures || string.IsNullOrWhiteSpace(BrushTexture) || _textures is null) return; + if (!EditingEnabled || string.IsNullOrWhiteSpace(BrushTexture) || _textures is null) return; if (!TryGetTile(location, out int x, out int y)) return; - int index = y * _dimension + x; - if (!_paintedInDrag.Add(index)) return; - string previous = _textures[index]; - if (StringComparer.OrdinalIgnoreCase.Equals(previous, BrushTexture)) return; - _textures[index] = BrushTexture; - TexturePainted?.Invoke(this, new TexturePaintEventArgs(x, y, previous, BrushTexture)); + int radius = Math.Max(0, BrushSize / 2); + for (int paintY = Math.Max(0, y - radius); paintY <= Math.Min(_dimension - 1, y + radius); paintY++) + for (int paintX = Math.Max(0, x - radius); paintX <= Math.Min(_dimension - 1, x + radius); paintX++) + { + int index = paintY * _dimension + paintX; + if (!_paintedInDrag.Add(index)) continue; + string previous = _textures[index]; + if (StringComparer.OrdinalIgnoreCase.Equals(previous, BrushTexture)) continue; + _textures[index] = BrushTexture; + RenderTerrainCell(paintX, paintY); + TexturePainted?.Invoke(this, new TexturePaintEventArgs(paintX, paintY, previous, BrushTexture)); + } Invalidate(); } private bool TryGetTile(Point location, out int x, out int y) { x = y = -1; - if (Layer != MapCanvasLayer.Textures || _textures is null || _dimension <= 0) return false; - Rectangle bounds = Fit(new Size(_dimension, _dimension), ClientRectangle); + if (_textures is null || _dimension <= 0) return false; + Rectangle bounds = SceneBounds(); if (!bounds.Contains(location)) return false; x = Math.Clamp((int)((location.X - bounds.X) * _dimension / (float)bounds.Width), 0, _dimension - 1); y = Math.Clamp((int)((location.Y - bounds.Y) * _dimension / (float)bounds.Height), 0, _dimension - 1); @@ -187,10 +222,132 @@ private static void DrawFrame(Graphics graphics, Rectangle bounds) internal Color GetTexturePreviewColor(string texture) => _texturePreviewColors.TryGetValue(texture, out Color color) ? color : FallbackTextureColor(texture); + private void RenderTerrainScene() + { + if (_textures is null || _dimension <= 0) return; + _terrainScene = new Bitmap(_dimension * 32, _dimension * 32); + using Graphics graphics = Graphics.FromImage(_terrainScene); + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + for (int y = 0; y < _dimension; y++) for (int x = 0; x < _dimension; x++) RenderTerrainCell(graphics, x, y); + } + + private void DrawSceneObjects(Graphics graphics, Rectangle bounds) + { + foreach (MapSceneObject item in _sceneObjects) + { + float x = bounds.Left + item.WorldX / (SdlSceneCatalog.WorldUnitsPerMapPixel * SdlSceneCatalog.MapPixelSize) * bounds.Width; + float y = bounds.Top + item.WorldZ / (SdlSceneCatalog.WorldUnitsPerMapPixel * SdlSceneCatalog.MapPixelSize) * bounds.Height; + float size = Math.Clamp(bounds.Width / 180f, 3f, 11f); + Color color = TeamColor(item.Team); + using var fill = new SolidBrush(Color.FromArgb(215, color)); + using var outline = new Pen(Color.FromArgb(235, 20, 20, 20), Math.Max(1, size / 5)); + if (item.Kind == "建築") { graphics.FillRectangle(fill, x - size, y - size, size * 2, size * 2); graphics.DrawRectangle(outline, x - size, y - size, size * 2, size * 2); } + else if (item.Kind == "單位") { graphics.FillEllipse(fill, x - size / 2, y - size / 2, size, size); graphics.DrawEllipse(outline, x - size / 2, y - size / 2, size, size); } + else { PointF[] points = { new(x, y - size), new(x + size, y), new(x, y + size), new(x - size, y) }; graphics.FillPolygon(fill, points); graphics.DrawPolygon(outline, points); } + } + } + + private static Color TeamColor(int team) => team switch + { + 1 => Color.RoyalBlue, 2 => Color.Crimson, 3 => Color.Goldenrod, 4 => Color.MediumSeaGreen, + 5 => Color.MediumOrchid, 6 => Color.DarkOrange, 7 => Color.Cyan, 8 => Color.White, _ => Color.Silver + }; + + private Rectangle SceneBounds() + { + Rectangle fitted = Fit(new Size(_dimension, _dimension), ClientRectangle); + int width = Math.Max(1, (int)(fitted.Width * _zoom)), height = Math.Max(1, (int)(fitted.Height * _zoom)); + return new Rectangle(fitted.X + (fitted.Width - width) / 2 + (int)_pan.X, fitted.Y + (fitted.Height - height) / 2 + (int)_pan.Y, width, height); + } + + private static Bitmap? LoadBitmap(string path) + { + if (!File.Exists(path)) return null; + using var source = new Bitmap(path); return new Bitmap(source); + } + + private static void DrawMapOverlay(Graphics graphics, Bitmap? overlay, Rectangle target, float opacity) + { + if (overlay is null) return; + using var attributes = new ImageAttributes(); + var matrix = new ColorMatrix { Matrix00 = 1, Matrix11 = 1, Matrix22 = 1, Matrix33 = opacity, Matrix44 = 1 }; + attributes.SetColorMatrix(matrix); + graphics.DrawImage(overlay, target, 0, 0, overlay.Width, overlay.Height, GraphicsUnit.Pixel, attributes); + } + + private static Bitmap BuildHeightShade(Bitmap heightMap) + { + int width = Math.Max(1, heightMap.Width - 1), height = Math.Max(1, heightMap.Height - 1); + var shade = new Bitmap(width, height, PixelFormat.Format32bppArgb); + for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) + { + int left = heightMap.GetPixel(Math.Max(0, x - 1), y).R; + int right = heightMap.GetPixel(Math.Min(heightMap.Width - 1, x + 1), y).R; + int top = heightMap.GetPixel(x, Math.Max(0, y - 1)).R; + int bottom = heightMap.GetPixel(x, Math.Min(heightMap.Height - 1, y + 1)).R; + int light = Math.Clamp(128 + (left - right) * 2 + (top - bottom) * 2, 0, 255); + int delta = Math.Abs(light - 128); + shade.SetPixel(x, y, light >= 128 ? Color.FromArgb(Math.Min(115, delta), 255, 248, 220) : Color.FromArgb(Math.Min(135, delta), 0, 0, 0)); + } + return shade; + } + + private static Bitmap BuildWaterOverlay(Bitmap heightMap, float threshold, Color waterColor) + { + int width = Math.Max(1, heightMap.Width - 1), height = Math.Max(1, heightMap.Height - 1); + var water = new Bitmap(width, height, PixelFormat.Format32bppArgb); + for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) + { + int elevation = heightMap.GetPixel(x, y).R; + if (elevation > threshold + 2) continue; + int alpha = elevation <= threshold ? 118 : (int)((threshold + 2 - elevation) / 2f * 118); + water.SetPixel(x, y, Color.FromArgb(Math.Clamp(alpha, 0, 118), waterColor)); + } + return water; + } + + private void RenderTerrainCell(int x, int y) + { + if (_terrainScene is null || _textures is null || _dimension <= 0) return; + using Graphics graphics = Graphics.FromImage(_terrainScene); + RenderTerrainCell(graphics, x, y); + } + + private void RenderTerrainCell(Graphics graphics, int x, int y) + { + if (_terrainScene is null || _textures is null || _dimension <= 0) return; + string name = _textures[y * _dimension + x]; + Rectangle target = new(x * 32, y * 32, 32, 32); + Bitmap? texture = _floorTextures?.Get(name); + if (texture is not null) + { + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.DrawImage(texture, target); + } + else + { + using var fill = new SolidBrush(GetTexturePreviewColor(name)); + graphics.FillRectangle(fill, target); + } + } + private void BuildTexturePreviewColors() { _texturePreviewColors.Clear(); - if (_bitmap is null || _textures is null || _dimension <= 0) return; + if (_textures is null || _dimension <= 0) return; + foreach (string textureName in _textures.Distinct(StringComparer.OrdinalIgnoreCase)) + { + Bitmap? texture = _floorTextures?.Get(textureName); + if (texture is null) continue; + long r = 0, g = 0, b = 0, count = 0; + int stepX = Math.Max(1, texture.Width / 16), stepY = Math.Max(1, texture.Height / 16); + for (int y = 0; y < texture.Height; y += stepY) for (int x = 0; x < texture.Width; x += stepX) + { + Color pixel = texture.GetPixel(x, y); r += pixel.R; g += pixel.G; b += pixel.B; count++; + } + if (count > 0) _texturePreviewColors[textureName] = Color.FromArgb((int)(r / count), (int)(g / count), (int)(b / count)); + } + if (_bitmap is null) return; var sums = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int y = 0; y < _dimension; y++) for (int x = 0; x < _dimension; x++) { @@ -201,7 +358,7 @@ private void BuildTexturePreviewColors() for (int py = top; py < bottom; py++) for (int px = left; px < right; px++) { Color pixel = _bitmap.GetPixel(px, py); sum.R += pixel.R; sum.G += pixel.G; sum.B += pixel.B; sum.Count++; } sums[texture] = sum; } - foreach (var pair in sums) if (pair.Value.Count > 0) _texturePreviewColors[pair.Key] = Color.FromArgb((int)(pair.Value.R / pair.Value.Count), (int)(pair.Value.G / pair.Value.Count), (int)(pair.Value.B / pair.Value.Count)); + foreach (var pair in sums) if (pair.Value.Count > 0 && !_texturePreviewColors.ContainsKey(pair.Key)) _texturePreviewColors[pair.Key] = Color.FromArgb((int)(pair.Value.R / pair.Value.Count), (int)(pair.Value.G / pair.Value.Count), (int)(pair.Value.B / pair.Value.Count)); } private static Color FallbackTextureColor(string texture) @@ -210,7 +367,7 @@ private static Color FallbackTextureColor(string texture) return Color.FromArgb(90 + (hash & 0x4f), 90 + ((hash >> 8) & 0x4f), 90 + ((hash >> 16) & 0x4f)); } - protected override void Dispose(bool disposing) { if (disposing) _bitmap?.Dispose(); base.Dispose(disposing); } + protected override void Dispose(bool disposing) { if (disposing) { _bitmap?.Dispose(); _terrainScene?.Dispose(); _emboss?.Dispose(); _smooth?.Dispose(); _heightShade?.Dispose(); _waterOverlay?.Dispose(); _floorTextures?.Dispose(); } base.Dispose(disposing); } } internal sealed class TexturePaintEventArgs : EventArgs diff --git a/src.MapEditor/MapEditorForm.cs b/src.MapEditor/MapEditorForm.cs index ab82230..754070d 100644 --- a/src.MapEditor/MapEditorForm.cs +++ b/src.MapEditor/MapEditorForm.cs @@ -7,13 +7,14 @@ namespace AgainstRomeMapEditor; internal sealed class MapEditorForm : Form { private readonly EndlessMapCatalog _catalog = new(); + private readonly GameMapCatalog _gameMapCatalog = new(); private readonly EndlessMapCloner _cloner = new(); + private readonly EndlessMapDeleter _deleter = new(); private readonly MapCanvasControl _canvas = new(); + private readonly PictureBox _overview = new() { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Color.FromArgb(18, 21, 27) }; private readonly ListView _maps = new() { Dock = DockStyle.Fill, View = View.Details, FullRowSelect = true, MultiSelect = false, HideSelection = false }; private readonly ListBox _palette = new() { Dock = DockStyle.Fill, IntegralHeight = false }; - private readonly TextBox _paletteSearch = new() { Dock = DockStyle.Top, PlaceholderText = "搜尋材質名稱…" }; - private readonly Panel _advancedPaletteHost = new() { Dock = DockStyle.Fill, Visible = false }; - private readonly CheckBox _showAdvancedPalette = new() { Dock = DockStyle.Top, Height = 30, Text = "顯示進階材質庫(內部 ID)" }; + private readonly TextBox _paletteSearch = new() { Dock = DockStyle.Top, PlaceholderText = "搜尋地表樣式…" }; private readonly Panel _currentMaterialSwatch = new() { Width = 54, Height = 54, BackColor = Color.DimGray }; private readonly Label _currentMaterialLabel = new() { AutoSize = true, Text = "目前筆刷:尚未取樣", ForeColor = Color.White, Font = new Font("Microsoft JhengHei UI", 10F, FontStyle.Bold) }; private readonly TextBox _gamePath = new() { Width = 430 }; @@ -23,18 +24,24 @@ internal sealed class MapEditorForm : Form private readonly NumericUpDown _dayStart = new() { Dock = DockStyle.Top, Minimum = 0, Maximum = 24 }; private readonly NumericUpDown _dayEnd = new() { Dock = DockStyle.Top, Minimum = 0, Maximum = 24 }; private readonly CheckBox _rain = new() { Dock = DockStyle.Top, Text = "水面雨滴" }; + private readonly Button _waterColorButton = new() { Dock = DockStyle.Top, Height = 34, Text = "選擇水面顏色…" }; + private readonly ComboBox _brushSize = new() { Dock = DockStyle.Top, DropDownStyle = ComboBoxStyle.DropDownList }; + private readonly CheckBox _showGrid = new() { Dock = DockStyle.Top, Height = 30, Text = "顯示格線", Checked = true }; + private readonly CheckBox _showObjects = new() { Dock = DockStyle.Top, Height = 30, Text = "顯示建築與場景物件", Checked = true }; + private readonly ListView _sceneList = new() { Dock = DockStyle.Fill, View = View.Details, FullRowSelect = true, HeaderStyle = ColumnHeaderStyle.Nonclickable }; + private readonly Label _sceneSummary = new() { Dock = DockStyle.Top, Height = 54, Padding = new Padding(8), ForeColor = Color.Gainsboro }; private readonly Label _modeBanner = new() { Dock = DockStyle.Top, Height = 34, TextAlign = ContentAlignment.MiddleCenter }; private readonly ToolStripStatusLabel _status = new() { Spring = true, TextAlign = ContentAlignment.MiddleLeft }; private readonly ToolStripButton _saveButton = new("儲存") { Enabled = false }; - private readonly ToolStripButton _gamePreviewButton = new("在遊戲中預覽") { Enabled = false }; + private readonly ToolStripButton _deleteButton = new("刪除地圖") { Enabled = false }; + private readonly ToolStripButton _gamePreviewButton = new("選用:啟動遊戲測試") { Enabled = false }; private readonly ToolStripButton _undoButton = new("復原") { Enabled = false }; private readonly ToolStripButton _redoButton = new("重做") { Enabled = false }; private readonly ToolStripButton _textureTool = new("材質筆刷") { CheckOnClick = true, Checked = true }; - private readonly ToolStripButton _minimapView = new("地圖底圖") { CheckOnClick = true }; - private readonly ToolStripButton _collisionView = new("碰撞預覽") { CheckOnClick = true }; + private readonly ToolStripButton _resetTerrainButton = new("還原地表") { Enabled = false }; private readonly Stack _undo = new(); private readonly Stack _redo = new(); - private EndlessMapInfo? _selected; + private GameMapInfo? _selected; private BodenTexturesDocument? _texturesDocument; private string[] _savedTextures = Array.Empty(); private bool _dirty; @@ -42,6 +49,7 @@ internal sealed class MapEditorForm : Form private bool _selectionGuard; private int? _requestedSlot; private Dictionary _textureLabels = new(StringComparer.OrdinalIgnoreCase); + private float _heightMapStep = 4; public MapEditorForm(EditorArguments arguments) { @@ -50,6 +58,7 @@ public MapEditorForm(EditorArguments arguments) BackColor = Color.FromArgb(30, 34, 42); ForeColor = Color.Gainsboro; _requestedSlot = arguments.SelectedSlot; BuildInterface(); WireEvents(); + KeyPreview = true; _gamePath.Text = arguments.GamePath ?? DetectGamePath(); Shown += (_, _) => RefreshMaps(_requestedSlot); FormClosing += (_, e) => { if (!ConfirmDiscardOrSave()) e.Cancel = true; }; @@ -61,36 +70,44 @@ private void BuildInterface() var browse = new ToolStripButton("遊戲路徑…"); var refresh = new ToolStripButton("重新整理"); var clone = new ToolStripButton("複製為自製地圖"); - commands.Items.AddRange(new ToolStripItem[] { browse, new ToolStripControlHost(_gamePath), refresh, new ToolStripSeparator(), clone, _saveButton, _gamePreviewButton, new ToolStripSeparator(), _undoButton, _redoButton }); - browse.Click += (_, _) => Browse(); refresh.Click += (_, _) => RefreshMaps(_selected?.Slot); clone.Click += (_, _) => CloneSelected(); + commands.Items.AddRange(new ToolStripItem[] { browse, new ToolStripControlHost(_gamePath), refresh, new ToolStripSeparator(), clone, _deleteButton, _saveButton, _gamePreviewButton, new ToolStripSeparator(), _undoButton, _redoButton }); + browse.Click += (_, _) => Browse(); refresh.Click += (_, _) => RefreshMaps(_selected?.EndlessSlot); clone.Click += (_, _) => CloneSelected(); + _deleteButton.Click += (_, _) => DeleteSelected(); var tools = new ToolStrip { GripStyle = ToolStripGripStyle.Hidden, Dock = DockStyle.Top, Padding = new Padding(8, 4, 8, 4), BackColor = Color.FromArgb(36, 40, 49), ForeColor = Color.White }; - var height = new ToolStripButton("高度筆刷(待驗證)") { Enabled = false }; - var collision = new ToolStripButton("碰撞筆刷(待驗證)") { Enabled = false }; - var objects = new ToolStripButton("物件配置(待校正)") { Enabled = false }; - tools.Items.AddRange(new ToolStripItem[] { new ToolStripLabel("工具:"), _textureTool, height, collision, objects, new ToolStripSeparator(), new ToolStripLabel("檢視:"), _minimapView, _collisionView }); + tools.Items.AddRange(new ToolStripItem[] { new ToolStripLabel("地表:"), _textureTool, _resetTerrainButton }); - _maps.Columns.Add("槽位", 82); _maps.Columns.Add("名稱", 155); _maps.Columns.Add("類型", 70); + _maps.Columns.Add("地圖", 105); _maps.Columns.Add("名稱", 145); _maps.Columns.Add("類型", 90); var mapHeader = SectionHeader("地圖"); var left = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8), BackColor = Color.FromArgb(34, 38, 47) }; left.Controls.Add(_maps); left.Controls.Add(mapHeader); - var paletteHeader = SectionHeader("材質工具"); + var paletteHeader = SectionHeader("地表繪製"); var palettePanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8), BackColor = Color.FromArgb(34, 38, 47) }; var currentBrush = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 76, Padding = new Padding(4), FlowDirection = FlowDirection.LeftToRight }; var currentText = new FlowLayoutPanel { Width = 205, Height = 66, FlowDirection = FlowDirection.TopDown, WrapContents = false }; - currentText.Controls.Add(_currentMaterialLabel); currentText.Controls.Add(new Label { AutoSize = true, MaximumSize = new Size(200, 0), Text = "在地圖上按右鍵吸取材質,\n再用左鍵拖曳繪製。", ForeColor = Color.Silver }); + currentText.Controls.Add(_currentMaterialLabel); currentText.Controls.Add(new Label { AutoSize = true, MaximumSize = new Size(200, 0), Text = "右鍵取樣,左鍵拖曳繪製。", ForeColor = Color.Silver }); currentBrush.Controls.Add(_currentMaterialSwatch); currentBrush.Controls.Add(currentText); - _advancedPaletteHost.Controls.Add(_palette); _advancedPaletteHost.Controls.Add(_paletteSearch); - palettePanel.Controls.Add(_advancedPaletteHost); palettePanel.Controls.Add(_showAdvancedPalette); palettePanel.Controls.Add(currentBrush); palettePanel.Controls.Add(paletteHeader); + _brushSize.Items.AddRange(new object[] { "精細(1 格)", "中型(3 × 3)", "大型(5 × 5)" }); _brushSize.SelectedIndex = 0; + var brushOptions = new Panel { Dock = DockStyle.Top, Height = 118 }; + brushOptions.Controls.Add(_showObjects); brushOptions.Controls.Add(_showGrid); brushOptions.Controls.Add(new Label { Dock = DockStyle.Top, Height = 22, Text = "筆刷大小", ForeColor = Color.Gainsboro }); brushOptions.Controls.Add(_brushSize); + palettePanel.Controls.Add(_palette); palettePanel.Controls.Add(_paletteSearch); palettePanel.Controls.Add(brushOptions); palettePanel.Controls.Add(currentBrush); palettePanel.Controls.Add(paletteHeader); var properties = BuildPropertiesPanel(); var inspectorTabs = new TabControl { Dock = DockStyle.Fill }; - inspectorTabs.TabPages.Add(new TabPage("材質") { BackColor = Color.FromArgb(34, 38, 47) }); inspectorTabs.TabPages[0].Controls.Add(palettePanel); + inspectorTabs.TabPages.Add(new TabPage("地表") { BackColor = Color.FromArgb(34, 38, 47) }); inspectorTabs.TabPages[0].Controls.Add(palettePanel); inspectorTabs.TabPages.Add(new TabPage("地圖屬性") { BackColor = Color.FromArgb(34, 38, 47) }); inspectorTabs.TabPages[1].Controls.Add(properties); + var scenePanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8), BackColor = Color.FromArgb(34, 38, 47) }; + _sceneList.Columns.Add("類型", 90); _sceneList.Columns.Add("編號", 90); _sceneList.Columns.Add("隊伍", 70); + scenePanel.Controls.Add(_sceneList); scenePanel.Controls.Add(_sceneSummary); + inspectorTabs.TabPages.Add(new TabPage("場景物件") { BackColor = Color.FromArgb(34, 38, 47) }); inspectorTabs.TabPages[2].Controls.Add(scenePanel); var canvasHost = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8), BackColor = Color.FromArgb(20, 23, 29) }; canvasHost.Controls.Add(_canvas); canvasHost.Controls.Add(_modeBanner); + var overviewHost = new Panel { Width = 190, Height = 190, Anchor = AnchorStyles.Right | AnchorStyles.Bottom, Padding = new Padding(5), BackColor = Color.FromArgb(55, 61, 72) }; + overviewHost.Controls.Add(_overview); overviewHost.Controls.Add(new Label { Text = "地圖概覽", Dock = DockStyle.Top, Height = 25, TextAlign = ContentAlignment.MiddleCenter, ForeColor = Color.White, BackColor = Color.FromArgb(42, 47, 58) }); + canvasHost.Controls.Add(overviewHost); overviewHost.BringToFront(); + canvasHost.Resize += (_, _) => overviewHost.Location = new Point(Math.Max(12, canvasHost.ClientSize.Width - overviewHost.Width - 18), Math.Max(46, canvasHost.ClientSize.Height - overviewHost.Height - 18)); var centerRight = new SplitContainer { Dock = DockStyle.Fill, FixedPanel = FixedPanel.Panel2, Size = new Size(1140, 760), SplitterDistance = 820 }; centerRight.Panel1.Controls.Add(canvasHost); centerRight.Panel2.Controls.Add(inspectorTabs); centerRight.Panel2MinSize = 280; @@ -98,7 +115,7 @@ private void BuildInterface() main.Panel1.Controls.Add(left); main.Panel2.Controls.Add(centerRight); main.Panel1MinSize = 240; var statusStrip = new StatusStrip { BackColor = Color.FromArgb(42, 47, 58), ForeColor = Color.Gainsboro }; - statusStrip.Items.Add(_status); statusStrip.Items.Add(new ToolStripStatusLabel("右鍵:吸取材質 左鍵拖曳:繪製")); + statusStrip.Items.Add(_status); statusStrip.Items.Add(new ToolStripStatusLabel("滾輪縮放 中鍵平移 右鍵取樣 左鍵繪製 Ctrl+S 儲存")); Controls.Add(main); Controls.Add(tools); Controls.Add(commands); Controls.Add(statusStrip); commands.BringToFront(); tools.BringToFront(); } @@ -106,8 +123,8 @@ private void BuildInterface() private Panel BuildPropertiesPanel() { var table = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 1, Padding = new Padding(10) }; - AddField(table, "地圖名稱", _title); AddField(table, "Waterlevel", _waterLevel); AddField(table, "WaterColor (0xBBGGRR)", _waterColor); - AddField(table, "DayStartTime", _dayStart); AddField(table, "DayEndTime", _dayEnd); AddField(table, "", _rain); + AddField(table, "地圖名稱", _title); AddField(table, "水面高度", _waterLevel); AddField(table, "水面顏色", _waterColorButton); + AddField(table, "日出時間", _dayStart); AddField(table, "日落時間", _dayEnd); AddField(table, "", _rain); var panel = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(34, 38, 47) }; panel.Controls.Add(table); return panel; } @@ -117,16 +134,19 @@ private void WireEvents() _palette.SelectedIndexChanged += (_, _) => { if (_palette.SelectedItem is PaletteItem item) SelectBrush(item.Id); }; _palette.DrawMode = DrawMode.OwnerDrawFixed; _palette.ItemHeight = 30; _palette.DrawItem += DrawPaletteItem; _paletteSearch.TextChanged += (_, _) => LoadPalette(_paletteSearch.Text); - _showAdvancedPalette.CheckedChanged += (_, _) => _advancedPaletteHost.Visible = _showAdvancedPalette.Checked; + _brushSize.SelectedIndexChanged += (_, _) => _canvas.BrushSize = _brushSize.SelectedIndex switch { 1 => 3, 2 => 5, _ => 1 }; + _showGrid.CheckedChanged += (_, _) => { _canvas.ShowGrid = _showGrid.Checked; _canvas.Invalidate(); }; + _showObjects.CheckedChanged += (_, _) => { _canvas.ShowObjects = _showObjects.Checked; _canvas.Invalidate(); }; + _waterColorButton.Click += (_, _) => ChooseWaterColor(); _canvas.TexturePainted += (_, e) => PaintTexture(e); _canvas.TextureSampled += (_, e) => SelectBrush(e.Texture); _canvas.TileHovered += (_, e) => _status.Text = $"格子 ({e.X}, {e.Y}) {FriendlyTextureName(e.Texture)}"; - _textureTool.Click += (_, _) => ShowLayer(MapCanvasLayer.Textures); - _minimapView.Click += (_, _) => ShowLayer(MapCanvasLayer.Minimap); - _collisionView.Click += (_, _) => ShowLayer(MapCanvasLayer.Collision); + _textureTool.Click += (_, _) => LoadEditingScene(); + _resetTerrainButton.Click += (_, _) => ResetTerrain(); _saveButton.Click += (_, _) => SaveMap(showSuccess: true); _gamePreviewButton.Click += (_, _) => PreviewInGame(); _undoButton.Click += (_, _) => Undo(); _redoButton.Click += (_, _) => Redo(); + KeyDown += (_, e) => HandleShortcut(e); foreach (Control control in new Control[] { _title, _waterLevel, _waterColor, _dayStart, _dayEnd, _rain }) { if (control is TextBox text) text.TextChanged += (_, _) => MarkDirty(); @@ -138,18 +158,34 @@ private void WireEvents() private void RefreshMaps(int? selectSlot) { if (!ConfirmDiscardOrSave()) return; + string? preferredMapId = null; try { _selectionGuard = true; _maps.BeginUpdate(); _maps.Items.Clear(); - foreach (EndlessMapInfo map in _catalog.List(_gamePath.Text)) + _maps.Groups.Clear(); + string[] groupOrder = { "自製地圖", "劇情戰役", "歷史戰役", "教學", "無盡模式", "多人地圖", "其他地圖" }; + IReadOnlyList availableMaps = _gameMapCatalog.List(_gamePath.Text); + preferredMapId = availableMaps.FirstOrDefault(map => map.EndlessSlot == selectSlot)?.Id ?? availableMaps.FirstOrDefault()?.Id; + var groups = groupOrder.ToDictionary(category => category, category => new ListViewGroup(category, HorizontalAlignment.Left)); + foreach (string category in groupOrder) { - var item = new ListViewItem(map.Id) { Tag = map }; item.SubItems.Add(map.DisplayName ?? "(無標題)"); item.SubItems.Add(map.IsCustom ? "自製" : "原廠"); _maps.Items.Add(item); - if (map.Slot == selectSlot) { item.Selected = true; item.Focused = true; } + int count = availableMaps.Count(map => (map.IsCustom ? "自製地圖" : map.Category) == category); + if (count == 0) continue; + groups[category].Header = $"{category}({count})"; _maps.Groups.Add(groups[category]); + } + foreach (GameMapInfo map in availableMaps) + { + string category = map.IsCustom ? "自製地圖" : map.Category; + var item = new ListViewItem(map.Id) { Tag = map, Group = groups[category] }; item.SubItems.Add(map.DisplayName ?? "(無標題)"); item.SubItems.Add(category); _maps.Items.Add(item); } - if (_maps.SelectedItems.Count == 0 && _maps.Items.Count > 0) _maps.Items[0].Selected = true; } catch (Exception ex) { ShowError(ex); } finally { _maps.EndUpdate(); _selectionGuard = false; } + if (preferredMapId is not null) + { + ListViewItem? preferred = _maps.Items.Cast().FirstOrDefault(item => item.Tag is GameMapInfo map && StringComparer.OrdinalIgnoreCase.Equals(map.Id, preferredMapId)); + if (preferred is not null) { preferred.Selected = true; preferred.Focused = true; preferred.EnsureVisible(); } + } LoadSelectedFromList(); } @@ -162,7 +198,7 @@ private void OnMapSelectionChanged() private void LoadSelectedFromList() { - _selected = _maps.SelectedItems.Count == 1 ? _maps.SelectedItems[0].Tag as EndlessMapInfo : null; + _selected = _maps.SelectedItems.Count == 1 ? _maps.SelectedItems[0].Tag as GameMapInfo : null; if (_selected is null) { UpdateEditorState(); return; } try { @@ -170,22 +206,31 @@ private void LoadSelectedFromList() _title.Text = PutTextDocument.Load(Path.Combine(map, "TEXT", "US", "briefing.put")).GetValue("briefing_titel_1") ?? ""; var ini = BodenIniDocument.Load(Path.Combine(map, "boden.ini")); _waterLevel.Value = ParseDecimal(ini.GetValue("Waterlevel"), _waterLevel); _waterColor.Text = ini.GetValue("WaterColor") ?? ""; + _heightMapStep = float.TryParse(ini.GetValue("Heightmapstep"), out float heightStep) && heightStep > 0 ? heightStep : 4; + if (TryParseGameColor(_waterColor.Text, out Color waterColor)) { _waterColorButton.BackColor = waterColor; _waterColorButton.ForeColor = waterColor.GetBrightness() < .45f ? Color.White : Color.Black; } _dayStart.Value = ParseDecimal(ini.GetValue("DayStartTime"), _dayStart); _dayEnd.Value = ParseDecimal(ini.GetValue("DayEndTime"), _dayEnd); _rain.Checked = ini.GetValue("RainDropsOnWater") == "1"; _texturesDocument = BodenTexturesDocument.Load(Path.Combine(map, "boden.txt")); _savedTextures = _texturesDocument.Textures.ToArray(); BuildTextureLabels(); _dirty = false; _undo.Clear(); _redo.Clear(); - LoadPalette(); ShowLayer(MapCanvasLayer.Textures); UpdateEditorState(); + LoadPalette(); LoadEditingScene(); UpdateEditorState(); } catch (Exception ex) { ShowError(ex); } finally { _loading = false; } } - private void ShowLayer(MapCanvasLayer layer) + private void LoadEditingScene() { if (_selected is null) return; - _textureTool.Checked = layer == MapCanvasLayer.Textures; _minimapView.Checked = layer == MapCanvasLayer.Minimap; _collisionView.Checked = layer == MapCanvasLayer.Collision; - if (layer == MapCanvasLayer.Textures && _texturesDocument is not null) _canvas.LoadTextures(_texturesDocument.Dimension, _texturesDocument.Textures, _savedTextures, Path.Combine(_selected.DirectoryPath, "minimap.bmp")); - else _canvas.LoadBitmapLayer(_selected.DirectoryPath, layer); - _canvas.EditingEnabled = layer == MapCanvasLayer.Textures && _selected.IsCustom; - _modeBanner.Text = layer switch { MapCanvasLayer.Textures => _selected.IsCustom ? "材質編輯底圖 — 右鍵吸取,左鍵拖曳繪製;真實畫面請按「在遊戲中預覽」" : "原廠地圖唯讀 — 請先使用上方「複製為自製地圖」", MapCanvasLayer.Minimap => "地圖底圖(不是遊戲渲染畫面)", _ => "碰撞資料檢視(不是遊戲渲染畫面)" }; + _textureTool.Checked = true; + string minimapPath = Path.Combine(_selected.DirectoryPath, "minimap.bmp"); + IReadOnlyList sceneObjects = SdlSceneCatalog.LoadDirectory(_selected.DirectoryPath); + LoadSceneList(sceneObjects); + if (!TryParseGameColor(_waterColor.Text, out Color sceneWaterColor)) sceneWaterColor = Color.SteelBlue; + bool hasRealTextures = _texturesDocument is not null && _canvas.LoadTextures(_texturesDocument.Dimension, _texturesDocument.Textures, _savedTextures, _selected.DirectoryPath, Path.Combine(_gamePath.Text.Trim(), "floortex.dat"), sceneObjects, (float)_waterLevel.Value, _heightMapStep, sceneWaterColor); + Image? oldOverview = _overview.Image; _overview.Image = null; oldOverview?.Dispose(); + if (File.Exists(minimapPath)) using (var source = new Bitmap(minimapPath)) _overview.Image = new Bitmap(source); + _canvas.EditingEnabled = _selected.IsCustom; + _modeBanner.Text = hasRealTextures + ? (_selected.IsCustom ? $"離線地圖場景 — 真實地表、地勢與 {_canvas.SceneObjectCount} 個場景物件;滾輪縮放,中鍵平移" : $"離線地圖場景 — 真實地表、地勢與 {_canvas.SceneObjectCount} 個場景物件;原廠地圖僅供瀏覽") + : "找不到 floortex.dat,目前只能顯示簡化地表;請選擇完整的遊戲資料夾"; _modeBanner.BackColor = _canvas.EditingEnabled ? Color.FromArgb(38, 95, 72) : Color.FromArgb(86, 69, 40); UpdateStatus(); } @@ -216,7 +261,7 @@ private bool SaveMap(bool showSuccess) using var rollback = new FileRollbackScope(); string map = _selected.DirectoryPath; var put = PutTextDocument.Load(Path.Combine(map, "TEXT", "US", "briefing.put")); put.SetValue("briefing_titel_1", _title.Text.Trim()); put.Save(rollback); var ini = BodenIniDocument.Load(Path.Combine(map, "boden.ini")); ini.SetValue("Waterlevel", _waterLevel.Value.ToString()); ini.SetValue("WaterColor", _waterColor.Text.Trim()); ini.SetValue("DayStartTime", _dayStart.Value.ToString()); ini.SetValue("DayEndTime", _dayEnd.Value.ToString()); ini.SetValue("RainDropsOnWater", _rain.Checked ? "1" : "0"); ini.Save(rollback); - _texturesDocument?.Save(rollback); rollback.Commit(); _savedTextures = _texturesDocument?.Textures.ToArray() ?? Array.Empty(); _dirty = false; ShowLayer(MapCanvasLayer.Textures); UpdateEditorState(); + _texturesDocument?.Save(rollback); rollback.Commit(); _savedTextures = _texturesDocument?.Textures.ToArray() ?? Array.Empty(); _dirty = false; LoadEditingScene(); UpdateEditorState(); if (showSuccess) MessageBox.Show(this, "地圖已安全儲存。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); return true; } @@ -232,7 +277,7 @@ private void PreviewInGame() try { Process.Start(new ProcessStartInfo(exePath) { WorkingDirectory = _gamePath.Text.Trim(), UseShellExecute = true }); - MessageBox.Show(this, $"遊戲已啟動。\n\n請進入「無盡模式」並選擇 {_selected.Id}({_selected.DisplayName ?? "未命名"})。\n目前尚未發現可安全直接載入指定地圖的命令列參數。", "真實遊戲預覽", MessageBoxButtons.OK, MessageBoxIcon.Information); + MessageBox.Show(this, $"遊戲已啟動。\n\n若要額外測試自製地圖,請進入「無盡模式」並選擇 {_selected.Id}({_selected.DisplayName ?? "未命名"})。\n地圖編輯與離線場景顯示不需要啟動遊戲。", "選用遊戲測試", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { ShowError(ex); } } @@ -249,7 +294,26 @@ private void CloneSelected() if (_selected is null) { MessageBox.Show(this, "請先從左側選擇來源地圖。", Text); return; } if (!ConfirmDiscardOrSave()) return; string name = Prompt("新地圖名稱", _selected.DisplayName ?? _selected.Id); if (string.IsNullOrWhiteSpace(name)) return; - try { int slot = _catalog.GetNextFreeSlot(_gamePath.Text); _cloner.Clone(_gamePath.Text, _selected.Slot, slot, name.Trim()); RefreshMaps(slot); } + try { int slot = _catalog.GetNextFreeSlot(_gamePath.Text); _cloner.Clone(_gamePath.Text, _selected.Id, slot, name.Trim()); RefreshMaps(slot); } + catch (Exception ex) { ShowError(ex); } + } + + private void DeleteSelected() + { + if (_selected is null || !_selected.IsCustom) return; + if (!ConfirmDiscardOrSave()) return; + string name = _selected.DisplayName ?? "未命名地圖"; + DialogResult result = MessageBox.Show(this, + $"確定要永久刪除這張自製地圖嗎?\n\n{name}\n{_selected.Id}\n\n刪除後無法復原。", + "刪除地圖", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2); + if (result != DialogResult.Yes) return; + + try + { + _deleter.Delete(_gamePath.Text, _selected.EndlessSlot ?? throw new InvalidOperationException("自製地圖沒有有效槽位。")); + _selected = null; _texturesDocument = null; _savedTextures = Array.Empty(); _dirty = false; _undo.Clear(); _redo.Clear(); + RefreshMaps(null); + } catch (Exception ex) { ShowError(ex); } } @@ -257,8 +321,8 @@ private void LoadPalette(string? filter = null) { string? selected = (_palette.SelectedItem as PaletteItem)?.Id ?? _canvas.BrushTexture; _palette.Items.Clear(); if (_texturesDocument is null) return; - IEnumerable values = _texturesDocument.Textures.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace(filter)) values = values.Where(x => x.Contains(filter, StringComparison.OrdinalIgnoreCase) || FriendlyTextureName(x).Contains(filter, StringComparison.OrdinalIgnoreCase)); + IEnumerable values = _texturesDocument.Textures.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(FriendlyTextureName, StringComparer.CurrentCultureIgnoreCase); + if (!string.IsNullOrWhiteSpace(filter)) values = values.Where(x => FriendlyTextureName(x).Contains(filter, StringComparison.CurrentCultureIgnoreCase)); PaletteItem[] items = values.Select(x => new PaletteItem(x, FriendlyTextureName(x))).ToArray(); _palette.Items.AddRange(items.Cast().ToArray()); int index = selected is null ? -1 : Array.FindIndex(items, x => StringComparer.OrdinalIgnoreCase.Equals(x.Id, selected)); _palette.SelectedIndex = index; } @@ -268,9 +332,7 @@ private void DrawPaletteItem(object? sender, DrawItemEventArgs e) e.DrawBackground(); if (e.Index < 0 || _palette.Items[e.Index] is not PaletteItem item) return; Font baseFont = e.Font ?? Font; using var color = new SolidBrush(_canvas.GetTexturePreviewColor(item.Id)); e.Graphics.FillRectangle(color, e.Bounds.X + 5, e.Bounds.Y + 5, 20, e.Bounds.Height - 10); - TextRenderer.DrawText(e.Graphics, item.Name, baseFont, new Rectangle(e.Bounds.X + 32, e.Bounds.Y, 105, e.Bounds.Height), e.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); - using var idFont = new Font(baseFont.FontFamily, Math.Max(7, baseFont.Size - 1)); - TextRenderer.DrawText(e.Graphics, item.Id, idFont, new Rectangle(e.Bounds.X + 138, e.Bounds.Y, e.Bounds.Width - 140, e.Bounds.Height), Color.Gray, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); + TextRenderer.DrawText(e.Graphics, item.Name, baseFont, new Rectangle(e.Bounds.X + 32, e.Bounds.Y, e.Bounds.Width - 36, e.Bounds.Height), e.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); e.DrawFocusRectangle(); } @@ -286,12 +348,56 @@ private void SelectBrush(string texture) } private void UpdateEditorState() { - bool editable = _selected?.IsCustom == true; _saveButton.Enabled = editable && _dirty; _gamePreviewButton.Enabled = _selected is not null; _undoButton.Enabled = editable && _undo.Count > 0; _redoButton.Enabled = editable && _redo.Count > 0; + bool editable = _selected?.IsCustom == true; _deleteButton.Enabled = editable; _saveButton.Enabled = editable && _dirty; _gamePreviewButton.Enabled = _selected is not null; _undoButton.Enabled = editable && _undo.Count > 0; _redoButton.Enabled = editable && _redo.Count > 0; _resetTerrainButton.Enabled = editable && _texturesDocument is not null; foreach (Control control in new Control[] { _title, _waterLevel, _waterColor, _dayStart, _dayEnd, _rain }) control.Enabled = editable; _palette.Enabled = editable; UpdateStatus(); } private void UpdateStatus() { _status.Text = _selected is null ? "尚未選擇地圖" : $"{_selected.Id} — {(_selected.IsCustom ? "自製地圖,可編輯" : "原廠地圖,唯讀")}{(_dirty ? " ● 尚未儲存" : "")}"; } - private void ReselectCurrent() { if (_selected is null) return; _selectionGuard = true; foreach (ListViewItem item in _maps.Items) item.Selected = item.Tag is EndlessMapInfo map && map.Slot == _selected.Slot; _selectionGuard = false; } + private void ResetTerrain() + { + if (_texturesDocument is null || _savedTextures.Length != _texturesDocument.Textures.Count) return; + if (MessageBox.Show(this, "要放棄這次尚未儲存的地表繪製嗎?\n地圖名稱與環境設定不會受影響。", "還原地表", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return; + for (int index = 0; index < _savedTextures.Length; index++) + { + int x = index % _texturesDocument.Dimension, y = index / _texturesDocument.Dimension; + _texturesDocument.SetTexture(x, y, _savedTextures[index]); _canvas.SetTexture(x, y, _savedTextures[index]); + } + _undo.Clear(); _redo.Clear(); MarkDirty(); + } + + private void LoadSceneList(IReadOnlyList objects) + { + _sceneList.BeginUpdate(); _sceneList.Items.Clear(); + int buildingIndex = 0, unitIndex = 0, objectIndex = 0; + foreach (MapSceneObject item in objects.OrderBy(x => x.Kind).ThenBy(x => x.Team)) + { + int index = item.Kind switch { "建築" => ++buildingIndex, "單位" => ++unitIndex, _ => ++objectIndex }; + var row = new ListViewItem(item.Kind); row.SubItems.Add($"{index:000}"); row.SubItems.Add(item.Team > 0 ? $"隊伍 {item.Team}" : "中立"); _sceneList.Items.Add(row); + } + _sceneList.EndUpdate(); + _sceneSummary.Text = $"建築 {buildingIndex} 單位 {unitIndex} 其他 {objectIndex}\n此處顯示地圖中的場景配置。"; + } + private void ChooseWaterColor() + { + using var dialog = new ColorDialog { FullOpen = true }; + if (TryParseGameColor(_waterColor.Text, out Color color)) dialog.Color = color; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + _waterColor.Text = $"0x{dialog.Color.B:X2}{dialog.Color.G:X2}{dialog.Color.R:X2}".ToLowerInvariant(); + _waterColorButton.BackColor = dialog.Color; _waterColorButton.ForeColor = dialog.Color.GetBrightness() < .45f ? Color.White : Color.Black; + } + private static bool TryParseGameColor(string value, out Color color) + { + color = Color.SteelBlue; string hex = value.Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase); + if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out int bgr)) return false; + color = Color.FromArgb(bgr & 0xff, (bgr >> 8) & 0xff, (bgr >> 16) & 0xff); return true; + } + private void HandleShortcut(KeyEventArgs e) + { + if (!e.Control) return; + if (e.KeyCode == Keys.S) SaveMap(showSuccess: false); else if (e.KeyCode == Keys.Z) Undo(); else if (e.KeyCode == Keys.Y) Redo(); else return; + e.SuppressKeyPress = true; + } + private void ReselectCurrent() { if (_selected is null) return; _selectionGuard = true; foreach (ListViewItem item in _maps.Items) item.Selected = item.Tag is GameMapInfo map && StringComparer.OrdinalIgnoreCase.Equals(map.Id, _selected.Id); _selectionGuard = false; } private void Browse() { if (!ConfirmDiscardOrSave()) return; using var dialog = new FolderBrowserDialog { Description = "選擇 Against Rome 安裝資料夾" }; if (dialog.ShowDialog(this) == DialogResult.OK) { _gamePath.Text = dialog.SelectedPath; RefreshMaps(null); } } private static Label SectionHeader(string text) => new() { Text = text, Dock = DockStyle.Top, Height = 34, Font = new Font("Microsoft JhengHei UI", 9F, FontStyle.Bold), Padding = new Padding(4, 8, 0, 0), ForeColor = Color.White }; private static void AddField(TableLayoutPanel table, string label, Control control) { table.Controls.Add(new Label { Text = label, AutoSize = true, Margin = new Padding(3, 10, 3, 3), ForeColor = Color.Gainsboro }); table.Controls.Add(control); } diff --git a/src.Shared/Maps/EndlessMapCloner.cs b/src.Shared/Maps/EndlessMapCloner.cs index b4940e1..7d75f31 100644 --- a/src.Shared/Maps/EndlessMapCloner.cs +++ b/src.Shared/Maps/EndlessMapCloner.cs @@ -9,12 +9,24 @@ public sealed class EndlessMapCloner public EndlessMapCloner(EndlessMapCatalog? catalog = null) => _catalog = catalog ?? new EndlessMapCatalog(); public EndlessMapInfo Clone(string gamePath, int sourceSlot, int newSlot, string newName) + { + string normalizedGamePath = EndlessMapCatalog.ValidateGamePath(gamePath); + EndlessMapInfo source = _catalog.Require(normalizedGamePath, sourceSlot); + return CloneCore(normalizedGamePath, source.Id, source.DirectoryPath, sourceSlot, newSlot, newName); + } + + public EndlessMapInfo Clone(string gamePath, string sourceMapId, int newSlot, string newName) + { + string normalizedGamePath = EndlessMapCatalog.ValidateGamePath(gamePath); + GameMapInfo source = new GameMapCatalog().Require(normalizedGamePath, sourceMapId); + return CloneCore(normalizedGamePath, source.Id, source.DirectoryPath, source.EndlessSlot ?? 0, newSlot, newName); + } + + private EndlessMapInfo CloneCore(string normalizedGamePath, string sourceMapId, string sourceDirectory, int sourceSlot, int newSlot, string newName) { if (newSlot is < 5 or > 999) throw new ArgumentOutOfRangeException(nameof(newSlot), "自製地圖槽位必須在 ENDL_005 至 ENDL_999。"); if (string.IsNullOrWhiteSpace(newName)) throw new ArgumentException("請輸入地圖名稱。", nameof(newName)); if (newName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) throw new ArgumentException("地圖名稱含不允許的字元。", nameof(newName)); - string normalizedGamePath = EndlessMapCatalog.ValidateGamePath(gamePath); - EndlessMapInfo source = _catalog.Require(normalizedGamePath, sourceSlot); string mapsPath = Path.Combine(normalizedGamePath, "MAPS"); string mapId = $"ENDL_{newSlot:000}"; string destination = Path.Combine(mapsPath, mapId); @@ -23,9 +35,9 @@ public EndlessMapInfo Clone(string gamePath, int sourceSlot, int newSlot, string try { - CopyDirectory(source.DirectoryPath, temporary); - VerifyCopy(source.DirectoryPath, temporary); - RewriteKnownFiles(temporary, source.Id, mapId, newName); + CopyDirectory(sourceDirectory, temporary); + VerifyCopy(sourceDirectory, temporary); + RewriteKnownFiles(temporary, sourceMapId, mapId, newName); var marker = new CustomMapEntry(newSlot, sourceSlot, DateTimeOffset.UtcNow, ToolVersion()); Core.Services.SafeFileWriter.WriteAllBytes(Path.Combine(temporary, CustomMapManifest.MarkerFileName), JsonSerializer.SerializeToUtf8Bytes(marker, new JsonSerializerOptions { WriteIndented = true })); Directory.Move(temporary, destination); diff --git a/src.Shared/Maps/EndlessMapDeleter.cs b/src.Shared/Maps/EndlessMapDeleter.cs new file mode 100644 index 0000000..e6dd135 --- /dev/null +++ b/src.Shared/Maps/EndlessMapDeleter.cs @@ -0,0 +1,38 @@ +namespace AgainstRomeModifier.Maps; + +public sealed class EndlessMapDeleter +{ + private readonly EndlessMapCatalog _catalog; + public EndlessMapDeleter(EndlessMapCatalog? catalog = null) => _catalog = catalog ?? new EndlessMapCatalog(); + + public void Delete(string gamePath, int slot) + { + string normalizedGamePath = EndlessMapCatalog.ValidateGamePath(gamePath); + EndlessMapInfo map = _catalog.Require(normalizedGamePath, slot); + if (!map.IsCustom || !CustomMapManifest.IsCustomMapDirectory(map.DirectoryPath)) + throw new InvalidOperationException("只能刪除由地圖編輯器建立的自製地圖。"); + + string mapsPath = Path.GetFullPath(Path.Combine(normalizedGamePath, "MAPS")); + string mapPath = Path.GetFullPath(map.DirectoryPath); + if (!StringComparer.OrdinalIgnoreCase.Equals(Path.GetDirectoryName(mapPath), mapsPath)) + throw new InvalidOperationException("地圖資料夾不在預期的位置,已取消刪除。"); + + string temporary = mapPath + ".deleting_arm"; + if (Directory.Exists(temporary)) throw new IOException("地圖刪除暫存資料夾已存在,請先檢查: " + temporary); + Directory.Move(mapPath, temporary); + try + { + using var rollback = new FileRollbackScope(); + CustomMapManifest manifest = CustomMapManifest.Load(normalizedGamePath); + manifest.Remove(slot); + manifest.Save(normalizedGamePath, rollback); + Directory.Delete(temporary, recursive: true); + rollback.Commit(); + } + catch + { + if (!Directory.Exists(mapPath) && Directory.Exists(temporary)) Directory.Move(temporary, mapPath); + throw; + } + } +} diff --git a/src.Shared/Maps/GameMapCatalog.cs b/src.Shared/Maps/GameMapCatalog.cs new file mode 100644 index 0000000..182cd05 --- /dev/null +++ b/src.Shared/Maps/GameMapCatalog.cs @@ -0,0 +1,56 @@ +using System.Text.RegularExpressions; + +namespace AgainstRomeModifier.Maps; + +public sealed record GameMapInfo(string Id, string DirectoryPath, bool IsCustom, string? DisplayName, string Category) +{ + public int? EndlessSlot => Id.StartsWith("ENDL_", StringComparison.OrdinalIgnoreCase) && int.TryParse(Id.AsSpan(5), out int slot) ? slot : null; +} + +public sealed class GameMapCatalog +{ + private static readonly Regex SafeMapId = new("^[A-Za-z0-9_]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public IReadOnlyList List(string gamePath) + { + string mapsPath = Path.Combine(EndlessMapCatalog.ValidateGamePath(gamePath), "MAPS"); + if (!Directory.Exists(mapsPath)) return Array.Empty(); + return Directory.GetDirectories(mapsPath) + .Where(path => SafeMapId.IsMatch(Path.GetFileName(path))) + .Where(path => File.Exists(Path.Combine(path, "boden.txt")) && File.Exists(Path.Combine(path, "minimap.bmp"))) + .Select(path => new GameMapInfo(Path.GetFileName(path), path, + CustomMapManifest.IsCustomMapDirectory(path), TryReadTitle(path), Category(Path.GetFileName(path)))) + .OrderBy(x => CategoryOrder(x.Category)).ThenBy(x => x.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public GameMapInfo Require(string gamePath, string mapId) + { + if (!SafeMapId.IsMatch(mapId)) throw new ArgumentException("無效的地圖代號。", nameof(mapId)); + return List(gamePath).SingleOrDefault(x => StringComparer.OrdinalIgnoreCase.Equals(x.Id, mapId)) + ?? throw new DirectoryNotFoundException("找不到地圖: " + mapId); + } + + private static string Category(string id) => id.ToUpperInvariant() switch + { + string value when value.StartsWith("KAMP_") => "劇情戰役", + string value when value.StartsWith("HIST_") => "歷史戰役", + string value when value.StartsWith("TUTOR_") => "教學", + string value when value.StartsWith("MP_") => "多人地圖", + string value when value.StartsWith("ENDL_") => "無盡模式", + _ => "其他地圖" + }; + + private static int CategoryOrder(string category) => category switch + { + "劇情戰役" => 0, "歷史戰役" => 1, "教學" => 2, "無盡模式" => 3, "多人地圖" => 4, _ => 5 + }; + + private static string? TryReadTitle(string mapDirectory) + { + string path = Path.Combine(mapDirectory, "TEXT", "US", "briefing.put"); + if (!File.Exists(path)) return null; + try { return PutTextDocument.Load(path).GetValue("briefing_titel_1"); } + catch { return null; } + } +} diff --git a/src.Shared/Maps/SdlSceneCatalog.cs b/src.Shared/Maps/SdlSceneCatalog.cs new file mode 100644 index 0000000..50ea2c4 --- /dev/null +++ b/src.Shared/Maps/SdlSceneCatalog.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace AgainstRomeModifier.Maps; + +public sealed record MapSceneObject(string Name, float WorldX, float WorldY, float WorldZ, int Team, string SourceFile) +{ + public string Kind => Name.StartsWith("Bau", StringComparison.OrdinalIgnoreCase) ? "建築" + : Name.StartsWith("Fig", StringComparison.OrdinalIgnoreCase) ? "單位" : "物件"; +} + +public static class SdlSceneCatalog +{ + public const float WorldUnitsPerMapPixel = 64f; + public const float MapPixelSize = 256f; + private static readonly Regex Section = new(@"(?ms)^\[(?[^\]]+)\]\s*\r?\n(?.*?)(?=^\[|\z)", RegexOptions.Compiled); + + public static IReadOnlyList LoadDirectory(string mapDirectory) + { + var objects = new List(); + foreach (string path in Directory.GetFiles(mapDirectory, "*.sdl", SearchOption.TopDirectoryOnly)) + { + try { objects.AddRange(Load(path)); } + catch (InvalidDataException) { } + } + return objects; + } + + public static IReadOnlyList Load(string path) + { + byte[] bytes = File.ReadAllBytes(path); + if (bytes.Length >= 4 && bytes.AsSpan(0, 4).SequenceEqual("PFIL"u8)) bytes = GameLZSS.DecompressPfil(bytes); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + string text = Encoding.GetEncoding(1251).GetString(bytes); + Match settlement = Section.Matches(text).FirstOrDefault(match => match.Groups["name"].Value.Equals("settlement", StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidDataException("SDL 缺少 settlement 區段。"); + float[] reference = Vector(Value(settlement.Groups["body"].Value, "refpos")); + var result = new List(); + foreach (Match section in Section.Matches(text)) + { + if (!section.Groups["name"].Value.StartsWith("object", StringComparison.OrdinalIgnoreCase)) continue; + string body = section.Groups["body"].Value; + float[] position = Vector(Value(body, "pos")); + string name = Value(body, "namedef")?.Trim() ?? "未命名物件"; + int.TryParse(Value(body, "team"), NumberStyles.Integer, CultureInfo.InvariantCulture, out int team); + float x = reference[0] + position[0], y = reference[1] + position[1], z = reference[2] + position[2]; + if (x is >= 0 and <= WorldUnitsPerMapPixel * MapPixelSize && z is >= 0 and <= WorldUnitsPerMapPixel * MapPixelSize) + result.Add(new MapSceneObject(name, x, y, z, team, Path.GetFileName(path))); + } + return result; + } + + private static string? Value(string body, string key) + { + Match match = Regex.Match(body, $@"(?im)^\s*{Regex.Escape(key)}\s*=\s*(?[^\r\n]*)"); + return match.Success ? match.Groups["value"].Value : null; + } + + private static float[] Vector(string? value) + { + string[] parts = (value ?? "0,0,0").Split(','); + var result = new float[3]; + for (int i = 0; i < Math.Min(3, parts.Length); i++) float.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out result[i]); + return result; + } +} diff --git a/tests/AgainstRomeModifier.Tests/MapEditorPhase1Tests.cs b/tests/AgainstRomeModifier.Tests/MapEditorPhase1Tests.cs index e825975..925b59c 100644 --- a/tests/AgainstRomeModifier.Tests/MapEditorPhase1Tests.cs +++ b/tests/AgainstRomeModifier.Tests/MapEditorPhase1Tests.cs @@ -63,6 +63,70 @@ public void BodenTexturesDocument_Changes_exactly_one_tile_and_round_trips() Assert.Equal("T0004095", loaded.GetTexture(63, 63)); } + [Fact] + public void Delete_Removes_custom_map_and_manifest_entry() + { + CreateSourceMap(); + var catalog = new EndlessMapCatalog(); + new EndlessMapCloner(catalog).Clone(_root, 0, 5, "Delete me"); + + new EndlessMapDeleter(catalog).Delete(_root, 5); + + Assert.False(Directory.Exists(Path.Combine(_root, "MAPS", "ENDL_005"))); + Assert.DoesNotContain(CustomMapManifest.Load(_root).Entries, x => x.Slot == 5); + Assert.Equal(5, catalog.GetNextFreeSlot(_root)); + } + + [Fact] + public void Delete_Refuses_original_map() + { + CreateSourceMap(); + + InvalidOperationException error = Assert.Throws(() => new EndlessMapDeleter().Delete(_root, 0)); + + Assert.Contains("只能刪除", error.Message); + Assert.True(Directory.Exists(Path.Combine(_root, "MAPS", "ENDL_000"))); + } + + [Fact] + public void GameMapCatalog_Lists_campaign_and_endless_maps() + { + CreateSourceMap(); + string campaign = Path.Combine(_root, "MAPS", "KAMP_000"); + CopyDirectory(Path.Combine(_root, "MAPS", "ENDL_000"), campaign); + File.WriteAllBytes(Path.Combine(campaign, "minimap.bmp"), MinimalBitmap()); + File.WriteAllBytes(Path.Combine(_root, "MAPS", "ENDL_000", "minimap.bmp"), MinimalBitmap()); + File.WriteAllBytes(Path.Combine(campaign, "boden.txt"), SyntheticFixture.Pfil("[Dimension]\r\n64\r\n[Texturen]\r\n")); + File.WriteAllBytes(Path.Combine(_root, "MAPS", "ENDL_000", "boden.txt"), SyntheticFixture.Pfil("[Dimension]\r\n64\r\n[Texturen]\r\n")); + + IReadOnlyList maps = new GameMapCatalog().List(_root); + + Assert.Contains(maps, x => x.Id == "KAMP_000" && x.Category == "劇情戰役"); + Assert.Contains(maps, x => x.Id == "ENDL_000" && x.Category == "無盡模式"); + } + + [Fact] + public void SdlSceneCatalog_Maps_reference_and_local_position_to_world_coordinates() + { + Directory.CreateDirectory(_root); + string path = Path.Combine(_root, "scene.sdl"); + File.WriteAllBytes(path, SyntheticFixture.Pfil("[settlement]\r\nrefpos=1632,159,5792\r\n[object0000]\r\nnamedef=BauRomHau00_Haupthaus\r\npos=-32.00,2.00,64.00\r\nteam=3\r\n")); + + MapSceneObject item = Assert.Single(SdlSceneCatalog.Load(path)); + + Assert.Equal("建築", item.Kind); Assert.Equal(1600, item.WorldX); Assert.Equal(161, item.WorldY); Assert.Equal(5856, item.WorldZ); Assert.Equal(3, item.Team); + } + + private static byte[] MinimalBitmap() => new byte[] { (byte)'B', (byte)'M' }; + private static void CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (string file in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) + { + string target = Path.Combine(destination, Path.GetRelativePath(source, file)); Directory.CreateDirectory(Path.GetDirectoryName(target)!); File.Copy(file, target); + } + } + private void CreateSourceMap() { string map = Path.Combine(_root, "MAPS", "ENDL_000"); From 72963d87b844d945044d125706c7065b41dd71c8 Mon Sep 17 00:00:00 2001 From: Jackie Chen Date: Sun, 12 Jul 2026 17:24:24 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E8=AA=BF=E6=95=B42x=E9=80=9F=E5=BA=A6/3x?= =?UTF-8?q?=E5=B0=84=E7=A8=8B=E7=AD=89=E9=81=B8=E9=A0=85=E5=88=86=E7=B5=84?= =?UTF-8?q?=E8=88=87=E5=81=B5=E6=B8=AC=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 將2x速度、3x射程、全地圖施法等移出實驗性分組,並移除UI與本地化的「(實驗性)」標記 - 單位移動速度倍率完全與自訂屬性平衡解耦,修正2x/4x疊加錯誤 - 修正BalancedUnitStats基礎速度,還原為3.2/2.6,避免平衡時誤增 - 平民速度補丁僅在啟用2x速度時生效 - PatchUnit僅在數值變動時寫入,避免浮點誤差與偵測誤判 - 偵測邏輯動態比對基準值,提升自訂模板相容性 - 偵測平衡時排除祭司視野,避免全地圖施法誤判 - 新增107項回歸測試,驗證所有組合下偵測與補丁正確 --- AI_HANDOFF.md | 16 +- src/Core/Features/FeatureDetector.cs | 19 +- src/Core/Localization.cs | 16 +- src/Core/Patches/ObjdefPatcher.cs | 66 +++++-- src/Core/TroopConfig.cs | 68 +++---- src/UI/ModifierForm.Layout.cs | 14 +- src/UI/ModifierForm.cs | 8 +- .../CharacterizationTests.cs | 172 ++++++++++++++++++ 8 files changed, 296 insertions(+), 83 deletions(-) diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md index 2cf97c2..81cc8d6 100644 --- a/AI_HANDOFF.md +++ b/AI_HANDOFF.md @@ -1,4 +1,18 @@ -# AI Handoff - Live Project Memory +# AI Handoff - Live Project Memory + +## Bug Fix and Patch Tuning (2026-07-12) + +- Moved `UnitMovementSpeed2x` out of the Experimental group into the Switches (Resource & Combat Upgrades) group on the UI panel, and removed '(Experimental)' / '(實驗性)' from its localization labels, adjusting card heights to 494 for both cards. +- Completely decoupled unit movement speed from custom troop stats (including balance layer and custom layer) in `ObjdefPatcher.cs`. The unit speed scale `speedScale` is now simplified to only depend on `options.UnitMovementSpeed2x`, ensuring custom stats can never affect moves, movsf, or bmovs fields. +- Fixed incorrect base movement speeds in `TroopConfig.BalancedUnitStats` (reduced 6.4/5.2 to their correct original baseline of 3.2/2.6). This resolves the issue where "Custom Unit Attribute Balance" incorrectly applied a 2x movement speed boost, and when combined with "2x Unit Speed" incorrectly applied a 4x boost. +- Fixed civilian unit movement speed patch logic in `ObjdefPatcher.cs` to prevent the balance modifier from doubling their speeds when the speed patch itself is disabled. +- Added regression test `Test_Unit_And_Civilian_Movement_Speed_Combinations` to `CharacterizationTests.cs`. All 107 tests passed. +- Fixed the bug where the "Custom Unit Attribute Balance" feature would be falsely detected as enabled after applying 2x Unit Speed and 3x Ranged Range patches. +- Optimized `ObjdefPatcher.PatchUnit` to only write fields to `objdef.dau` when their values actually differ from original (>0.01 delta), preserving original number formats and avoiding float-to-int truncation errors during reload. +- Corrected `FeatureDetector.cs` detection logic to dynamically resolve baseline values via `backupManager.GetBaseStatsForUnit` rather than static-coded expectations (e.g. 3.2, 3600.0) which breaks under custom templates. +- Excluded priest's `Sirad` (Sight) attribute check in `FeatureDetector.cs` unit balance detection to prevent false positives when `SpellEntireMap` alters the priest's range. +- Moved `RangedRange3x`, `SpellEntireMap`, and `SpellRange3x` features out of the Experimental group into the Switches (Resource & Combat Upgrades) group on the UI panel, and removed '(Experimental)' / '(實驗性)' from their localization labels. +- Added regression test `Test_RangedRange3x_And_Speed2x_Only_Does_Not_Trigger_Balance` to `CharacterizationTests.cs`. All 107 tests passed. ## History Consolidation (2026-07-11) diff --git a/src/Core/Features/FeatureDetector.cs b/src/Core/Features/FeatureDetector.cs index 32b1dac..0ba0251 100644 --- a/src/Core/Features/FeatureDetector.cs +++ b/src/Core/Features/FeatureDetector.cs @@ -226,16 +226,15 @@ internal PatchProfile Detect(string gamePath, BackupManager backupManager) // 排除領袖以防 LeaderGlory 的干擾,且只比對不受 Range3x/Speed2x 影響的屬性 (Hp, Vw, Aw, Sight) if (TroopConfig.UnitMeta[key].Tier != "leader") { + bool isPriest = utype == "priest"; bool hasDiff = Math.Abs(curHp - origHp) > 0.01 || Math.Abs(curVw - origVw) > 0.01 || Math.Abs(curAw - origAw) > 0.01 || - Math.Abs(curSight - origSight) > 0.01; + (!isPriest && Math.Abs(curSight - origSight) > 0.01); if (hasDiff) { isFileBalanced = true; - // 繼續比對其他單位,但這裡不能直接 break,因為我們還要走完整個 loop 或等偵測完。 - // 不過因為 isFileBalanced 已經是 true,在此也可以不用 break,或者直接設 true 即可。 } } } @@ -247,13 +246,11 @@ internal PatchProfile Detect(string gamePath, BackupManager backupManager) bool range3x = false; // 使用羅馬輕裝步兵 FigRomInf00_Lanze_Schild 偵測速度 2 倍 - if (unitRows.TryGetValue("FigRomInf00_Lanze_Schild", out var testMovesCols) && - origUnitRows.TryGetValue("FigRomInf00_Lanze_Schild", out var testMovesOrigCols)) + if (unitRows.TryGetValue("FigRomInf00_Lanze_Schild", out var testMovesCols)) { - double curMoves = 0, origMoves = 0; + double curMoves = 0; double.TryParse(testMovesCols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curMoves); - double.TryParse(testMovesOrigCols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origMoves); - double expectedBaseMoves = isFileBalanced ? 3.2 : origMoves; + double expectedBaseMoves = backupManager.GetBaseStatsForUnit("FigRomInf00_Lanze_Schild", options)[4] / 2.0; if (curMoves > 0 && Math.Abs(curMoves - expectedBaseMoves * 2.0) < 0.05) { speed2x = true; @@ -261,12 +258,10 @@ internal PatchProfile Detect(string gamePath, BackupManager backupManager) } // 使用羅馬弓箭手 FigRomSch01_Bogen 偵測射程 3 倍 - if (unitRows.TryGetValue("FigRomSch01_Bogen", out var testRangeCols) && - origUnitRows.TryGetValue("FigRomSch01_Bogen", out var testRangeOrigCols)) + if (unitRows.TryGetValue("FigRomSch01_Bogen", out var testRangeCols)) { double curRange = BackupManager.GetUnitMaxRange(testRangeCols, "ranged_inf"); - double origRange = BackupManager.GetUnitMaxRange(testRangeOrigCols, "ranged_inf"); - double expectedBaseRange = isFileBalanced ? 3600.0 : origRange; + double expectedBaseRange = backupManager.GetBaseStatsForUnit("FigRomSch01_Bogen", options)[7]; if (curRange > 0 && Math.Abs(curRange - expectedBaseRange * 3.0) < 5.0) { range3x = true; diff --git a/src/Core/Localization.cs b/src/Core/Localization.cs index 03a6e6e..ec164d7 100644 --- a/src/Core/Localization.cs +++ b/src/Core/Localization.cs @@ -386,16 +386,16 @@ public static string GetFactionName(string faction) { { "GameSpeedTip", "全局縮放遊戲運作時脈。從生產、建造、移動到戰鬥和 AI 反應,通通同步加速 10 倍!掛機等待時極為實用!" }, { "GeneralSkillsTip", "將所有將軍與首領的被動特殊技能數值大幅提升 5 倍。" }, { "LeaderGloryTip", "將各陣營首領隨榮譽升級的攻擊成長、防禦成長、傷害成長與士氣光環數值提升 5 倍。" }, - { "RangedRange3x", "遠程單位射程提升 3 倍 (實驗性)" }, + { "RangedRange3x", "遠程單位射程提升 3 倍" }, { "RangedRange3xTip", "將所有遠程步兵、遠程騎兵以及攻城武器的攻擊/射擊射程提升 3 倍。" }, { "LogRangedRange3xToggled", "遠程單位射程提升 3 倍已{0}。" }, - { "UnitMovementSpeed2x", "單位移動速度提升 2 倍 (實驗性)" }, + { "UnitMovementSpeed2x", "單位移動速度提升 2 倍" }, { "UnitMovementSpeed2xTip", "將所有戰鬥單位(包括士兵、領袖、祭司)與平民單位(村民、馱馬)的移動速度提升 2 倍。" }, { "LogUnitMovementSpeed2xToggled", "單位移動速度提升 2 倍已{0}。" }, -{ "SpellEntireMap", "法師施法距離提升至全地圖 (實驗性)" }, +{ "SpellEntireMap", "法師施法距離提升至全地圖" }, { "SpellEntireMapTip", "將所有祭司施放神術與復活術的最大施法距離提升至全地圖;不改變法術命中半徑。" }, { "LogSpellEntireMapToggled", "法師施法距離提升至全地圖已{0}。" }, -{ "SpellRange3x", "法師法術範圍提升 3 倍 (實驗性)" }, +{ "SpellRange3x", "法師法術範圍提升 3 倍" }, { "SpellRange3xTip", "將法術的效果與命中範圍提升為原版的 3 倍;不改變祭司的施法距離。" }, { "LogSpellRange3xToggled", "法師法術範圍提升 3 倍已{0}。" }, { "ProjectileArcHeight", "拋射彈道增高 (實驗性)" }, @@ -698,16 +698,16 @@ public static string GetFactionName(string faction) { { "GameSpeedTip", "Speedhack-style scaling of the game's master clock so movement, production, combat and AI all run at 10x speed. Patches Against_Rome.exe and is fully reversible." }, { "GeneralSkillsTip", "Greatly increases all passive special ability values of generals and chiefs by 5x." }, { "LeaderGloryTip", "Multiplies each faction chief's glory-level attack/defense/damage growth and morale aura by 5x." }, - { "RangedRange3x", "3x Range for Ranged Units (Experimental)" }, + { "RangedRange3x", "3x Range for Ranged Units" }, { "RangedRange3xTip", "Triples the attack and firing range of all ranged infantry, ranged cavalry, and siege weapons." }, { "LogRangedRange3xToggled", "3x Range for Ranged Units {0}." }, - { "UnitMovementSpeed2x", "2x Movement Speed for Units (Experimental)" }, + { "UnitMovementSpeed2x", "2x Movement Speed for Units" }, { "UnitMovementSpeed2xTip", "Doubles the movement speed of all combat units (including soldiers, leaders, priests) and civilian units (villagers, packhorses)." }, { "LogUnitMovementSpeed2xToggled", "2x Movement Speed for Units {0}." }, -{ "SpellEntireMap", "Spell Casting Distance: Entire Map (Experimental)" }, +{ "SpellEntireMap", "Spell Casting Distance: Entire Map" }, { "SpellEntireMapTip", "Raises priest spell and resurrection casting distance to the entire map; spell effect radius is unchanged." }, { "LogSpellEntireMapToggled", "Spell Casting Distance: Entire Map {0}." }, -{ "SpellRange3x", "3x Spell Effect Radius (Experimental)" }, +{ "SpellRange3x", "3x Spell Effect Radius" }, { "SpellRange3xTip", "Triples spell effect and hit radius from the original value; priest casting distance is unchanged." }, { "LogSpellRange3xToggled", "3x Spell Effect Radius {0}." }, { "ProjectileArcHeight", "Higher Projectile Arc (Experimental)" }, diff --git a/src/Core/Patches/ObjdefPatcher.cs b/src/Core/Patches/ObjdefPatcher.cs index fcae44f..bee636d 100644 --- a/src/Core/Patches/ObjdefPatcher.cs +++ b/src/Core/Patches/ObjdefPatcher.cs @@ -57,7 +57,6 @@ public static byte[] GetPatchedBytes(byte[] original, ObjdefOptions options) { if (TroopConfig.UnitMeta.TryGetValue(name, out var meta) && options.UnitStats.TryGetValue(name, out double[]? stats) && stats.Length >= 8) PatchUnit(cols, source, name, meta.UnitType, stats, options); else if (name is "FigZivMan00_Zivilist" or "FigZivWei00_Zivilistin" or "FigTiePac00_Packpferd") { double civMult = 1.0; - if (options.Balance) civMult *= 2.0; if (options.UnitMovementSpeed2x) civMult *= 2.0; PatchCivilianSpeed(cols, source, name, civMult); } @@ -78,10 +77,7 @@ private static void PatchUnit(string[] cols, string[] source, string name, strin double originalRange = GetMaxRange(source, type); GetReload(source, type, out double meleeReload, out double rangedReload); double primaryReload = type is "ranged_inf" or "ranged_cav" ? rangedReload : type == "siege" ? Math.Max(meleeReload, rangedReload) : meleeReload; - double speedScale = moves > 0 ? stats[4] / (moves * 2.0) : 1.0; - if (options.UnitMovementSpeed2x) { - speedScale *= 2.0; - } + double speedScale = options.UnitMovementSpeed2x ? 2.0 : 1.0; double rangeScale = originalRange > 0 ? stats[7] / originalRange : 1.0; bool isRangedUnit = type is "ranged_inf" or "ranged_cav" or "siege"; if (isRangedUnit && options.RangedRange3x) { @@ -90,14 +86,28 @@ private static void PatchUnit(string[] cols, string[] source, string name, strin bool isPriest = type == "priest"; double reloadScale = primaryReload > 0 ? stats[6] / primaryReload : 1.0; double damageScale = primaryDamage > 0 ? stats[1] / primaryDamage : 1.0; - if (moves > 0) SetValue(cols, (int)ObjdefIndex.Moves, (moves * speedScale).ToString("F2", CultureInfo.InvariantCulture), name, "移動速度"); - if (movsf > 0) SetValue(cols, (int)ObjdefIndex.Movsf, (movsf * speedScale).ToString("F2", CultureInfo.InvariantCulture), name, "移動速度"); - if (bmovs > 0) SetValue(cols, (int)ObjdefIndex.Bmovs, (bmovs * speedScale).ToString("F2", CultureInfo.InvariantCulture), name, "移動速度"); + + // 僅在有實際數值變動時才寫入檔案,以保持原始格式防範偵測誤判 + if (moves > 0 && Math.Abs(speedScale - 1.0) > 0.001) { + SetValue(cols, (int)ObjdefIndex.Moves, (moves * speedScale).ToString("F2", CultureInfo.InvariantCulture), name, "移動速度"); + if (movsf > 0) SetValue(cols, (int)ObjdefIndex.Movsf, (movsf * speedScale).ToString("F2", CultureInfo.InvariantCulture), name, "移動速度"); + if (bmovs > 0) SetValue(cols, (int)ObjdefIndex.Bmovs, (bmovs * speedScale).ToString("F2", CultureInfo.InvariantCulture), name, "移動速度"); + } + double sight = options.SpellEntireMap && isPriest ? 30000.0 : stats[5]; - SetValue(cols, (int)ObjdefIndex.Sirad, ((int)sight).ToString(CultureInfo.InvariantCulture), name, "視野"); - SetValue(cols, (int)ObjdefIndex.Hp, ((int)stats[0]).ToString(CultureInfo.InvariantCulture), name, "生命值"); - SetValue(cols, (int)ObjdefIndex.Aw, ((int)stats[3]).ToString(CultureInfo.InvariantCulture), name, "戰鬥"); - SetValue(cols, (int)ObjdefIndex.Vw, ((int)stats[2]).ToString(CultureInfo.InvariantCulture), name, "防禦"); + if (Math.Abs(sight - Read(source, (int)ObjdefIndex.Sirad)) > 0.01) { + SetValue(cols, (int)ObjdefIndex.Sirad, ((int)sight).ToString(CultureInfo.InvariantCulture), name, "視野"); + } + if (Math.Abs(stats[0] - Read(source, (int)ObjdefIndex.Hp)) > 0.01) { + SetValue(cols, (int)ObjdefIndex.Hp, ((int)stats[0]).ToString(CultureInfo.InvariantCulture), name, "生命值"); + } + if (Math.Abs(stats[3] - Read(source, (int)ObjdefIndex.Aw)) > 0.01) { + SetValue(cols, (int)ObjdefIndex.Aw, ((int)stats[3]).ToString(CultureInfo.InvariantCulture), name, "戰鬥"); + } + if (Math.Abs(stats[2] - Read(source, (int)ObjdefIndex.Vw)) > 0.01) { + SetValue(cols, (int)ObjdefIndex.Vw, ((int)stats[2]).ToString(CultureInfo.InvariantCulture), name, "防禦"); + } + for (int w = 1; w <= 8; w++) { // 射程欄位是 active+2 (RangeMin, w*_rad1) 與 active+3 (RangeMax, w*_rad2); // active+4 是 Weapon*Angle(角度),依 objdef-fields.csv 絕不可當射程縮放。 @@ -105,15 +115,37 @@ private static void PatchUnit(string[] cols, string[] source, string name, strin if (max >= source.Length || source[active] != "1") continue; if (isPriest) { double priestWeaponDamage = Read(source, damage); - SetValue(cols, damage, (priestWeaponDamage * damageScale).ToString("F2", CultureInfo.InvariantCulture), name, "damage"); - SetValue(cols, reload, ((int)Math.Round(Read(source, reload) * reloadScale)).ToString(CultureInfo.InvariantCulture), name, "reload"); + double newPriestDmg = priestWeaponDamage * damageScale; + if (Math.Abs(newPriestDmg - priestWeaponDamage) > 0.01) { + SetValue(cols, damage, newPriestDmg.ToString("F2", CultureInfo.InvariantCulture), name, "damage"); + } + double origPriestReload = Read(source, reload); + double newPriestReload = Math.Round(origPriestReload * reloadScale); + if (Math.Abs(newPriestReload - origPriestReload) > 0.01) { + SetValue(cols, reload, ((int)newPriestReload).ToString(CultureInfo.InvariantCulture), name, "reload"); + } continue; } - foreach (int index in new[] { min, max }) if (Read(source, index) is double range && range > 0) SetValue(cols, index, (range * rangeScale).ToString("F2", CultureInfo.InvariantCulture), name, "射程"); + + if (Math.Abs(rangeScale - 1.0) > 0.001) { + foreach (int index in new[] { min, max }) { + if (Read(source, index) is double range && range > 0) { + SetValue(cols, index, (range * rangeScale).ToString("F2", CultureInfo.InvariantCulture), name, "射程"); + } + } + } + double weaponDamage = Read(source, damage); double newDamage = type is "ranged_inf" or "ranged_cav" && w == 1 ? weaponDamage : weaponDamage * damageScale; - SetValue(cols, damage, newDamage.ToString("F2", CultureInfo.InvariantCulture), name, "傷害"); - SetValue(cols, reload, ((int)Math.Round(Read(source, reload) * reloadScale)).ToString(CultureInfo.InvariantCulture), name, "攻擊冷卻"); + if (Math.Abs(newDamage - weaponDamage) > 0.01) { + SetValue(cols, damage, newDamage.ToString("F2", CultureInfo.InvariantCulture), name, "傷害"); + } + + double origReload = Read(source, reload); + double newReload = Math.Round(origReload * reloadScale); + if (Math.Abs(newReload - origReload) > 0.01) { + SetValue(cols, reload, ((int)newReload).ToString(CultureInfo.InvariantCulture), name, "攻擊冷卻"); + } } } diff --git a/src/Core/TroopConfig.cs b/src/Core/TroopConfig.cs index 784e338..7eb1ff5 100644 --- a/src/Core/TroopConfig.cs +++ b/src/Core/TroopConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace AgainstRomeModifier { @@ -189,55 +189,55 @@ public static class TroopConfig { // 武器或兵種類型倍率,避免預覽值與實際寫入值不一致。 public static readonly Dictionary BalancedUnitStats = new Dictionary(StringComparer.OrdinalIgnoreCase) { // Roman:整體素質最強。 - {"FigRomInf00_Lanze_Schild", new double[] {140, 26, 28, 22, 6.4, 1500, 500, 400, 0}}, - {"FigRomSch00_Speer_Schild", new double[] {180, 34, 34, 30, 6.4, 4500, 400, 2700, 0}}, - {"FigRomInf01_Schwert_Schild", new double[] {210, 38, 36, 32, 6.4, 1500, 500, 400, 0}}, - {"FigRomSch01_Bogen", new double[] {140, 24, 14, 24, 6.4, 4500, 700, 3600, 0}}, - {"FigRomKav00_Schwert_Schild", new double[] {190, 54, 32, 32, 6.4, 1500, 500, 400, 0}}, - {"FigRomAnf00_Anfuehrer", new double[] {500, 85, 32, 40, 6.4, 1500, 500, 128, 0}}, + {"FigRomInf00_Lanze_Schild", new double[] {140, 26, 28, 22, 3.2, 1500, 500, 400, 0}}, + {"FigRomSch00_Speer_Schild", new double[] {180, 34, 34, 30, 3.2, 4500, 400, 2700, 0}}, + {"FigRomInf01_Schwert_Schild", new double[] {210, 38, 36, 32, 3.2, 1500, 500, 400, 0}}, + {"FigRomSch01_Bogen", new double[] {140, 24, 14, 24, 3.2, 4500, 700, 3600, 0}}, + {"FigRomKav00_Schwert_Schild", new double[] {190, 54, 32, 32, 3.2, 1500, 500, 400, 0}}, + {"FigRomAnf00_Anfuehrer", new double[] {500, 85, 32, 40, 3.2, 1500, 500, 128, 0}}, {"FigRomArt00_Speerschleuder", new double[] {1100, 180, 42, 62, 0, 4500, 3000, 3900, 0}}, {"FigRomArt00_Speerschleuder_Auf", new double[] {1100, 180, 42, 62, 0, 4500, 3000, 3900, 0}}, {"FigRomArt01_Katapult", new double[] {1600, 260, 42, 62, 0, 4500, 6000, 3600, 0}}, {"FigRomArt01_Katapult_Aufbau", new double[] {1600, 260, 42, 62, 0, 4500, 6000, 3600, 0}}, // Teuton:近戰傷害與爆發最高。 - {"FigGerInf01_Schwert", new double[] {110, 28, 10, 14, 6.4, 1500, 500, 400, 0}}, - {"FigGerSch00_Speer", new double[] {110, 28, 8, 14, 6.4, 4500, 900, 2700, 0}}, - {"FigGerInf00_Hammer_Schild", new double[] {140, 36, 22, 26, 6.4, 1500, 500, 400, 0}}, - {"FigGerSch01_Axt_Schild", new double[] {140, 34, 20, 24, 6.4, 4500, 400, 2100, 0}}, - {"FigGerInf02_Zweihandaxt", new double[] {165, 54, 14, 30, 6.4, 1500, 500, 400, 0}}, - {"FigGerKav00_Schwert_Schild", new double[] {165, 46, 24, 28, 6.4, 1500, 500, 400, 0}}, - {"FigGerInf03_Doppelhammer", new double[] {180, 68, 16, 38, 6.4, 1500, 333, 400, 0}}, - {"FigGerAnf00_Anfuehrer", new double[] {480, 96, 28, 42, 6.4, 1500, 500, 128, 0}}, - {"FigGerPri00_Priester", new double[] {110, 10, 70, 30, 5.2, 45000, 500, 3840, 0}}, + {"FigGerInf01_Schwert", new double[] {110, 28, 10, 14, 3.2, 1500, 500, 400, 0}}, + {"FigGerSch00_Speer", new double[] {110, 28, 8, 14, 3.2, 4500, 900, 2700, 0}}, + {"FigGerInf00_Hammer_Schild", new double[] {140, 36, 22, 26, 3.2, 1500, 500, 400, 0}}, + {"FigGerSch01_Axt_Schild", new double[] {140, 34, 20, 24, 3.2, 4500, 400, 2100, 0}}, + {"FigGerInf02_Zweihandaxt", new double[] {165, 54, 14, 30, 3.2, 1500, 500, 400, 0}}, + {"FigGerKav00_Schwert_Schild", new double[] {165, 46, 24, 28, 3.2, 1500, 500, 400, 0}}, + {"FigGerInf03_Doppelhammer", new double[] {180, 68, 16, 38, 3.2, 1500, 333, 400, 0}}, + {"FigGerAnf00_Anfuehrer", new double[] {480, 96, 28, 42, 3.2, 1500, 500, 128, 0}}, + {"FigGerPri00_Priester", new double[] {110, 10, 70, 30, 2.6, 45000, 500, 3840, 0}}, {"FigGerArt00_Katapult", new double[] {1500, 170, 50, 50, 0, 4500, 5000, 3000, 0}}, {"FigGerArt00_Katapult_Aufbau", new double[] {1500, 170, 50, 50, 0, 4500, 5000, 3000, 0}}, // Celt:步兵防禦與步行遠程最強,投石兵以高單發傷害輸出。 - {"FigKelInf00_Schwert", new double[] {120, 24, 12, 14, 6.4, 1500, 500, 400, 0}}, - {"FigKelSch00_Bogen", new double[] {110, 18, 8, 14, 6.4, 4500, 550, 3300, 0}}, - {"FigKelInf01_Lanze", new double[] {190, 26, 42, 24, 6.4, 1500, 500, 400, 0}}, - {"FigKelSch01_Schleuder", new double[] {140, 40, 12, 20, 6.4, 4500, 900, 3000, 0}}, - {"FigKelInf02_Doppelschwert", new double[] {160, 46, 16, 32, 6.4, 1500, 333, 400, 0}}, - {"FigKelSch02_Schwere_Schleuder", new double[] {180, 75, 20, 28, 6.4, 4500, 1000, 2700, 0}}, - {"FigKelKav00_Lanze_Schild", new double[] {170, 44, 30, 28, 6.4, 1500, 500, 400, 0}}, - {"FigKelAnf00_Anfuehrer", new double[] {480, 68, 42, 36, 6.4, 1500, 500, 128, 0}}, - {"FigKelPri00_Priester", new double[] {110, 10, 80, 60, 5.2, 45000, 500, 3840, 1250}}, + {"FigKelInf00_Schwert", new double[] {120, 24, 12, 14, 3.2, 1500, 500, 400, 0}}, + {"FigKelSch00_Bogen", new double[] {110, 18, 8, 14, 3.2, 4500, 550, 3300, 0}}, + {"FigKelInf01_Lanze", new double[] {190, 26, 42, 24, 3.2, 1500, 500, 400, 0}}, + {"FigKelSch01_Schleuder", new double[] {140, 40, 12, 20, 3.2, 4500, 900, 3000, 0}}, + {"FigKelInf02_Doppelschwert", new double[] {160, 46, 16, 32, 3.2, 1500, 333, 400, 0}}, + {"FigKelSch02_Schwere_Schleuder", new double[] {180, 75, 20, 28, 3.2, 4500, 1000, 2700, 0}}, + {"FigKelKav00_Lanze_Schild", new double[] {170, 44, 30, 28, 3.2, 1500, 500, 400, 0}}, + {"FigKelAnf00_Anfuehrer", new double[] {480, 68, 42, 36, 3.2, 1500, 500, 128, 0}}, + {"FigKelPri00_Priester", new double[] {110, 10, 80, 60, 2.6, 45000, 500, 3840, 1250}}, {"FigKelArt00_Speerschleuder", new double[] {1000, 110, 42, 60, 0, 4500, 3000, 3300, 0}}, {"FigKelArt00_Speerschleuder_A", new double[] {1000, 110, 42, 60, 0, 4500, 3000, 3300, 0}}, {"FigKelArt01_Katapult", new double[] {1500, 190, 52, 52, 0, 4500, 5000, 3000, 0}}, {"FigKelArt01_Katapult_Aufbau", new double[] {1500, 190, 52, 52, 0, 4500, 5000, 3000, 0}}, // Hun:騎兵整體最強,弓騎兵以低單發、高射速輸出。 - {"FigHunInf00_Keule", new double[] {100, 26, 8, 12, 6.4, 1500, 500, 400, 0}}, - {"FigHunSch00_Bogen", new double[] {100, 18, 8, 12, 6.4, 4500, 450, 3000, 0}}, - {"FigHunInf01_Schwert_Schild", new double[] {130, 26, 28, 22, 6.4, 1500, 500, 400, 0}}, - {"FigHunKav00_Schwert_Schild", new double[] {150, 40, 22, 26, 6.4, 1500, 500, 400, 0}}, - {"FigHunKav01_Bogen", new double[] {160, 24, 18, 28, 6.4, 4500, 350, 3000, 0}}, - {"FigHunKav02_Lanze_Schild", new double[] {210, 60, 34, 34, 6.4, 1500, 500, 400, 0}}, - {"FigHunKav03_Geisterreiter", new double[] {180, 52, 20, 32, 6.4, 1500, 500, 400, 0}}, - {"FigHunAnf00_Anfuehrer", new double[] {500, 90, 30, 42, 6.4, 1500, 500, 128, 0}}, - {"FigHunPri00_Priester", new double[] {110, 12, 30, 50, 5.2, 45000, 500, 3840, 1250}} + {"FigHunInf00_Keule", new double[] {100, 26, 8, 12, 3.2, 1500, 500, 400, 0}}, + {"FigHunSch00_Bogen", new double[] {100, 18, 8, 12, 3.2, 4500, 450, 3000, 0}}, + {"FigHunInf01_Schwert_Schild", new double[] {130, 26, 28, 22, 3.2, 1500, 500, 400, 0}}, + {"FigHunKav00_Schwert_Schild", new double[] {150, 40, 22, 26, 3.2, 1500, 500, 400, 0}}, + {"FigHunKav01_Bogen", new double[] {160, 24, 18, 28, 3.2, 4500, 350, 3000, 0}}, + {"FigHunKav02_Lanze_Schild", new double[] {210, 60, 34, 34, 3.2, 1500, 500, 400, 0}}, + {"FigHunKav03_Geisterreiter", new double[] {180, 52, 20, 32, 3.2, 1500, 500, 400, 0}}, + {"FigHunAnf00_Anfuehrer", new double[] {500, 90, 30, 42, 3.2, 1500, 500, 128, 0}}, + {"FigHunPri00_Priester", new double[] {110, 12, 30, 50, 2.6, 45000, 500, 3840, 1250}} }; /// diff --git a/src/UI/ModifierForm.Layout.cs b/src/UI/ModifierForm.Layout.cs index 40e068e..21b51b9 100644 --- a/src/UI/ModifierForm.Layout.cs +++ b/src/UI/ModifierForm.Layout.cs @@ -165,7 +165,7 @@ private void ConfigureSystemDashboard() { chkFocusLoss, chkToEng, chkDgVoodoo); - ConfigureSettingsCard(pnlExperimentalCard, lblExperimentalTitle, 686, + ConfigureSettingsCard(pnlExperimentalCard, lblExperimentalTitle, 494, chkGameSpeed, chkSpellDamage5x, chkSpellHealing10x, @@ -173,18 +173,18 @@ private void ConfigureSystemDashboard() { chkGeneralSkills, chkLeaderGlory, chkBalance, - chkRangedRange3x, - chkUnitMovementSpeed2x, - chkSpellEntireMap, - chkSpellRange3x, chkProjectileArcHeight, chkRangedAccuracy); - ConfigureSettingsCard(pnlSwitchesCard, lblSwitchesTitle, 302, + ConfigureSettingsCard(pnlSwitchesCard, lblSwitchesTitle, 494, chkFreeProd, chkFreeUpgrade, chkNoSpellCost, chkInfiniteMorale, - chkNoSpellAltar); + chkNoSpellAltar, + chkRangedRange3x, + chkUnitMovementSpeed2x, + chkSpellEntireMap, + chkSpellRange3x); ConfigureSettingsCard(pnlBuildCard, lblBuildTitle, 446, chkMaxPopulation, chkHousingCapacity20x, diff --git a/src/UI/ModifierForm.cs b/src/UI/ModifierForm.cs index c4b6a86..761957f 100644 --- a/src/UI/ModifierForm.cs +++ b/src/UI/ModifierForm.cs @@ -1034,7 +1034,7 @@ private void InitializeComponent() { Font = fontJhengHei10B }; chkRangedRange3x.CheckedChanged += new EventHandler(ChkRangedRange3x_CheckedChanged); - pnlExperimentalCard.Controls.Add(chkRangedRange3x); + pnlSwitchesCard.Controls.Add(chkRangedRange3x); chkUnitMovementSpeed2x = new ModernToggle { Text = Loc.Get("UnitMovementSpeed2x"), @@ -1045,7 +1045,7 @@ private void InitializeComponent() { Font = fontJhengHei10B }; chkUnitMovementSpeed2x.CheckedChanged += new EventHandler(ChkUnitMovementSpeed2x_CheckedChanged); - pnlExperimentalCard.Controls.Add(chkUnitMovementSpeed2x); + pnlSwitchesCard.Controls.Add(chkUnitMovementSpeed2x); chkSpellEntireMap = new ModernToggle { Text = Loc.Get("SpellEntireMap"), @@ -1056,7 +1056,7 @@ private void InitializeComponent() { Font = fontJhengHei10B }; chkSpellEntireMap.CheckedChanged += new EventHandler(ChkSpellEntireMap_CheckedChanged); - pnlExperimentalCard.Controls.Add(chkSpellEntireMap); + pnlSwitchesCard.Controls.Add(chkSpellEntireMap); chkSpellRange3x = new ModernToggle { Text = Loc.Get("SpellRange3x"), @@ -1067,7 +1067,7 @@ private void InitializeComponent() { Font = fontJhengHei10B }; chkSpellRange3x.CheckedChanged += new EventHandler(ChkSpellRange3x_CheckedChanged); - pnlExperimentalCard.Controls.Add(chkSpellRange3x); + pnlSwitchesCard.Controls.Add(chkSpellRange3x); chkProjectileArcHeight = new ModernToggle { Text = Loc.Get("ProjectileArcHeight"), diff --git a/tests/AgainstRomeModifier.Tests/CharacterizationTests.cs b/tests/AgainstRomeModifier.Tests/CharacterizationTests.cs index 4731725..f0420d8 100644 --- a/tests/AgainstRomeModifier.Tests/CharacterizationTests.cs +++ b/tests/AgainstRomeModifier.Tests/CharacterizationTests.cs @@ -104,6 +104,82 @@ public void T1_all_enabled_round_trip_is_detected() } } + [RequiresBackupZipFact] + public void Test_RangedRange3x_And_Speed2x_Only_Does_Not_Trigger_Balance() + { + using var fixture = BackupZipGameFixture.Create(); + var engine = new PatchEngine(new NullLogger()); + var profile = new PatchProfile(); + profile.RangedRange3x = true; + profile.UnitMovementSpeed2x = true; + + using (var rollback = new FileRollbackScope()) + { + engine.ApplyPatches(fixture.RootPath, profile, fixture.Backup, rollback); + rollback.Commit(); + } + + var currentObjdefPath = Path.Combine(fixture.RootPath, @"SYSTEM\DATA_MP\DEFAULTS\objdef.dau"); + byte[] raw = File.ReadAllBytes(currentObjdefPath); + byte[] decomp = GameLZSS.DecompressPfil(raw); + string currentObjdef = System.Text.Encoding.GetEncoding(1251).GetString(decomp); + + var currentRows = new List(); + foreach (string line in currentObjdef.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) + { + if (line.Length < 100) continue; + currentRows.Add(AgainstRomeModifier.Core.Patches.PatchText.ParseCsvLine(line)); + } + + var origUnitRows = fixture.Backup.GetBackupUnitRows(); + var unitRows = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string[] cols in currentRows) + { + if (cols.Length < 192) continue; + string name = cols[52].Trim(); + if (TroopConfig.UnitMeta.ContainsKey(name)) + { + unitRows[name] = cols; + } + } + + var diffs = new List(); + foreach (string key in TroopConfig.UnitMeta.Keys) + { + if (!unitRows.ContainsKey(key) || !origUnitRows.ContainsKey(key)) continue; + string utype = TroopConfig.UnitMeta[key].UnitType; + + string[] cols = unitRows[key]; + string[] origCols = origUnitRows[key]; + + double curHp = 0, origHp = 0; + double.TryParse(cols[(int)ObjdefIndex.Hp].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out curHp); + double.TryParse(origCols[(int)ObjdefIndex.Hp].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out origHp); + + double curVw = 0, origVw = 0; + double.TryParse(cols[(int)ObjdefIndex.Vw].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out curVw); + double.TryParse(origCols[(int)ObjdefIndex.Vw].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out origVw); + + double curAw = 0, origAw = 0; + double.TryParse(cols[(int)ObjdefIndex.Aw].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out curAw); + double.TryParse(origCols[(int)ObjdefIndex.Aw].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out origAw); + + double curSight = 0, origSight = 0; + double.TryParse(cols[(int)ObjdefIndex.Sirad].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out curSight); + double.TryParse(origCols[(int)ObjdefIndex.Sirad].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out origSight); + + if (TroopConfig.UnitMeta[key].Tier != "leader") + { + if (Math.Abs(curHp - origHp) > 0.01) diffs.Add($"{key} HP diff: cur={curHp}, orig={origHp}"); + if (Math.Abs(curVw - origVw) > 0.01) diffs.Add($"{key} VW diff: cur={curVw}, orig={origVw}"); + if (Math.Abs(curAw - origAw) > 0.01) diffs.Add($"{key} AW diff: cur={curAw}, orig={origAw}"); + if (Math.Abs(curSight - origSight) > 0.01) diffs.Add($"{key} Sight diff: cur={curSight}, orig={origSight}"); + } + } + + Assert.Empty(diffs); + } + [RequiresBackupZipFact] public void T2_restore_all_returns_original_fixture_bytes() { @@ -211,4 +287,100 @@ private static Dictionary Snapshot(string root) => private static bool IsPfil(byte[] bytes) => bytes.Length >= 4 && bytes[0] == (byte)'P' && bytes[1] == (byte)'F' && bytes[2] == (byte)'I' && bytes[3] == (byte)'L'; + + [RequiresBackupZipFact] + public void Test_Unit_And_Civilian_Movement_Speed_Combinations() + { + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + + // 情境 1: Balance = true, UnitMovementSpeed2x = false + { + using var fixture = BackupZipGameFixture.Create(); + var engine = new PatchEngine(new NullLogger()); + var profile = new PatchProfile { Balance = true, UnitMovementSpeed2x = false }; + using (var rollback = new FileRollbackScope()) + { + engine.ApplyPatches(fixture.RootPath, profile, fixture.Backup, rollback); + rollback.Commit(); + } + + // 讀取 objdef.dau 做精確比對 + string objdefPath = Path.Combine(fixture.RootPath, "SYSTEM", "DATA_MP", "DEFAULTS", "objdef.dau"); + byte[] fileBytes = File.ReadAllBytes(objdefPath); + byte[] decomp = GameLZSS.DecompressPfil(fileBytes); + string text = System.Text.Encoding.GetEncoding(1251).GetString(decomp); + string lineEnding = text.Contains("\r\n") ? "\r\n" : "\n"; + string[] lines = text.Split(new string[] { lineEnding }, StringSplitOptions.None); + + double? romInfSpeed = null; + double? civilianSpeed = null; + for (int idx = 2; idx < lines.Length; idx++) { + string line = lines[idx]; + if (line.Length < 100) continue; + string[] cols = line.Split(','); + if (cols.Length < 192) continue; + string name = cols[52].Trim(); + if (name == "FigRomInf00_Lanze_Schild") { + romInfSpeed = double.Parse(cols[(int)ObjdefIndex.Moves].Trim(), System.Globalization.CultureInfo.InvariantCulture); + } + if (name == "FigZivMan00_Zivilist") { + civilianSpeed = double.Parse(cols[(int)ObjdefIndex.Moves].Trim(), System.Globalization.CultureInfo.InvariantCulture); + } + } + // 驗證即使有自訂屬性平衡,速度仍應維持原速 (羅馬輕裝步兵=1.60, 平民=1.30) + Assert.NotNull(romInfSpeed); + Assert.Equal(1.60, romInfSpeed.Value, 2); + Assert.NotNull(civilianSpeed); + Assert.Equal(1.30, civilianSpeed.Value, 2); + + // 確保 Detector 偵測狀態正常 + var detected = engine.DetectCurrentPatchState(fixture.RootPath, fixture.Backup); + Assert.True(detected.Balance); + Assert.False(detected.UnitMovementSpeed2x); + } + + // 情境 2: Balance = true, UnitMovementSpeed2x = true + { + using var fixture = BackupZipGameFixture.Create(); + var engine = new PatchEngine(new NullLogger()); + var profile = new PatchProfile { Balance = true, UnitMovementSpeed2x = true }; + using (var rollback = new FileRollbackScope()) + { + engine.ApplyPatches(fixture.RootPath, profile, fixture.Backup, rollback); + rollback.Commit(); + } + + string objdefPath = Path.Combine(fixture.RootPath, "SYSTEM", "DATA_MP", "DEFAULTS", "objdef.dau"); + byte[] fileBytes = File.ReadAllBytes(objdefPath); + byte[] decomp = GameLZSS.DecompressPfil(fileBytes); + string text = System.Text.Encoding.GetEncoding(1251).GetString(decomp); + string lineEnding = text.Contains("\r\n") ? "\r\n" : "\n"; + string[] lines = text.Split(new string[] { lineEnding }, StringSplitOptions.None); + + double? romInfSpeed = null; + double? civilianSpeed = null; + for (int idx = 2; idx < lines.Length; idx++) { + string line = lines[idx]; + if (line.Length < 100) continue; + string[] cols = line.Split(','); + if (cols.Length < 192) continue; + string name = cols[52].Trim(); + if (name == "FigRomInf00_Lanze_Schild") { + romInfSpeed = double.Parse(cols[(int)ObjdefIndex.Moves].Trim(), System.Globalization.CultureInfo.InvariantCulture); + } + if (name == "FigZivMan00_Zivilist") { + civilianSpeed = double.Parse(cols[(int)ObjdefIndex.Moves].Trim(), System.Globalization.CultureInfo.InvariantCulture); + } + } + // 驗證速度變為 2 倍速 (羅馬輕裝步兵=3.20, 平民=2.60) + Assert.NotNull(romInfSpeed); + Assert.Equal(3.20, romInfSpeed.Value, 2); + Assert.NotNull(civilianSpeed); + Assert.Equal(2.60, civilianSpeed.Value, 2); + + var detected = engine.DetectCurrentPatchState(fixture.RootPath, fixture.Backup); + Assert.True(detected.Balance); + Assert.True(detected.UnitMovementSpeed2x); + } + } } From 60bca849f816a28432db7eb7e835639502a7828c Mon Sep 17 00:00:00 2001 From: Jackie Chen Date: Sun, 12 Jul 2026 18:20:27 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E6=8B=8B=E5=B0=84=E5=BD=88=E9=81=93?= =?UTF-8?q?=E5=A2=9E=E9=AB=98=E5=80=8D=E7=8E=87=E6=8F=90=E5=8D=87=E8=87=B3?= =?UTF-8?q?2=E5=80=8D=E4=B8=A6=E7=A7=BB=E5=87=BA=E5=AF=A6=E9=A9=97?= =?UTF-8?q?=E7=B5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 將「拋射彈道增高」倍率由1.5倍提升為2倍,並完成10倍倍率實機驗證,正式值採2倍。同步更新說明、UI顯示、patcher參數、測試案例與文件,並將此功能移至「資源與戰鬥升級」分組,調整UI卡片高度及一鍵全開行為,確保新分組開關正確啟用,測試預期值亦已修正。 --- AI_HANDOFF.md | 4 ++++ docs/reverse-engineering/known-patches.md | 19 +++++++++++++------ src/Core/Localization.cs | 8 ++++---- src/Core/Patches/ObjdefPatcher.cs | 6 ++++-- src/UI/ModifierForm.Layout.cs | 8 ++++---- src/UI/ModifierForm.Presets.cs | 8 +++++++- src/UI/ModifierForm.cs | 2 +- .../PatcherRoundTripTests.cs | 8 ++++---- 8 files changed, 41 insertions(+), 22 deletions(-) diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md index 81cc8d6..15b0ab1 100644 --- a/AI_HANDOFF.md +++ b/AI_HANDOFF.md @@ -2,6 +2,10 @@ ## Bug Fix and Patch Tuning (2026-07-12) +- `BtnEnableAll` now also enables the toggles previously promoted out of the Experimental group: `RangedRange3x`, `UnitMovementSpeed2x`, `SpellEntireMap`, `SpellRange3x`, and `ProjectileArcHeight`. Note the two spell toggles are independent since the 2026-07-12 rework (`SpellEntireMap` = casting distance via objdef `Sirad` 30000; `SpellRange3x` = spell effect radius via cl_script `[Spells] Radius` ×3) — an earlier mutual-exclusion assumption was stale and briefly left `SpellRange3x` out (user-reported). Experimental toggles remain excluded by design. Release build 0 warnings/errors; 107/107 tests pass. + +- `ProjectileArcHeight` runtime-verified and finalized at 2x: the mechanism was confirmed in-game at 10x (user reported success — visibly higher arcs, landing points unchanged), then per user request the shipped factor was set to 2x and the feature was promoted out of the Experimental group into the Switches (Resource & Combat Upgrades) card (experimental card 446 / switches card 542, labels dropped "(實驗性)"/"(Experimental)" and now read "拋射彈道增高 2 倍" / "2x Projectile Arc Height"). Factor history: 1.5x visually indistinguishable → 10x verification success → 2x production value. Single source of truth remains `ObjdefPatcher.ArcEmitMultiplier` (shared by `PartgeoPatcher.ArcYsubMultiplier`); emit cell widths hold up to ×10. BtnEnableAll now includes it (see the newer bullet above); BtnDisableAll clears it. `RangedAccuracy` stays experimental (runtime observation of hit-rate change still informal). Release build 0 warnings/errors; 107/107 tests pass. + - Moved `UnitMovementSpeed2x` out of the Experimental group into the Switches (Resource & Combat Upgrades) group on the UI panel, and removed '(Experimental)' / '(實驗性)' from its localization labels, adjusting card heights to 494 for both cards. - Completely decoupled unit movement speed from custom troop stats (including balance layer and custom layer) in `ObjdefPatcher.cs`. The unit speed scale `speedScale` is now simplified to only depend on `options.UnitMovementSpeed2x`, ensuring custom stats can never affect moves, movsf, or bmovs fields. - Fixed incorrect base movement speeds in `TroopConfig.BalancedUnitStats` (reduced 6.4/5.2 to their correct original baseline of 3.2/2.6). This resolves the issue where "Custom Unit Attribute Balance" incorrectly applied a 2x movement speed boost, and when combined with "2x Unit Speed" incorrectly applied a 4x boost. diff --git a/docs/reverse-engineering/known-patches.md b/docs/reverse-engineering/known-patches.md index 4275b1f..2ccad30 100644 --- a/docs/reverse-engineering/known-patches.md +++ b/docs/reverse-engineering/known-patches.md @@ -434,20 +434,27 @@ - Files: `SYSTEM/DATA_MP/DEFAULTS/objdef.dau` + `SYSTEM/DATA_MP/DEFAULTS/partgeo.dau` - objdef: every weapon slot with `w*_akti == 1` and `w*_emit > 0` gets - `w*_emit ×1.5` (rounded; 16.16 fixed vertical launch speed — bows 7208960, - spear thrower 6356992, catapults 10158080 / 9306112). + `w*_emit ×2` (rounded; 16.16 fixed vertical launch speed — bows 7208960, + spear thrower 6356992, catapults 10158080 / 9306112). All original emit + values fit their cell widths up to ×10 (verified against the full objdef). - partgeo: the six projectile rows (`Wurfspeer00`, `Wurfaxt00`, `Katapultstein00`, `Katapultstein01`, `Pfeil00`, `Spiess00`) get `ysub` - (gravity, column 12) ×1.5. Patcher aborts unless exactly six rows match. + (gravity, column 12) ×2. Patcher aborts unless exactly six rows match. - Same-factor scaling keeps landing point and flight time unchanged while the - arc apex (≈ emit²/(2·ysub)) rises ×1.5. Mechanism evidence: + arc apex (≈ emit²/(2·ysub)) rises by the same factor (bows ≈68 → ≈136 + height units). The factor lives in `ObjdefPatcher.ArcEmitMultiplier` + (shared by `PartgeoPatcher.ArcYsubMultiplier`). Mechanism evidence: `docs/reverse-engineering/projectile-ballistics.md`. +- Runtime verified 2026-07-12 at ×10 (user confirmed visibly higher arcs with + unchanged landing points); shipped factor tuned down to ×2 by user request. + 1.5 was tried first and is visually indistinguishable — keep the factor ≥2. - partgeo.dau is not in the embedded Backup.zip: auto-heal captures it from the game directory only after an `IsPartgeoOriginal` check (Pfeil00 ysub == 5832704) and immediately creates the physical `.bak`. -- Detection: all projectile weapon rows must equal original×1.5 +- Detection: all projectile weapon rows must equal original×2 (`FeatureDetector.HasProjectileWeaponScale`). -- Status: implemented, unit/integration tested; runtime verification pending. +- Status: implemented, unit/integration tested, runtime verified (at ×10); + promoted out of the Experimental UI group into Resource & Combat Upgrades. ### Ranged Accuracy Boost (RangedAccuracy) diff --git a/src/Core/Localization.cs b/src/Core/Localization.cs index ec164d7..573d40b 100644 --- a/src/Core/Localization.cs +++ b/src/Core/Localization.cs @@ -398,8 +398,8 @@ public static string GetFactionName(string faction) { { "SpellRange3x", "法師法術範圍提升 3 倍" }, { "SpellRange3xTip", "將法術的效果與命中範圍提升為原版的 3 倍;不改變祭司的施法距離。" }, { "LogSpellRange3xToggled", "法師法術範圍提升 3 倍已{0}。" }, - { "ProjectileArcHeight", "拋射彈道增高 (實驗性)" }, - { "ProjectileArcHeightTip", "將箭矢、標槍與投石等拋射物的垂直初速與重力同步提升 1.5 倍:彈道弧頂增高約 5 成、更容易越過樹木與建築等障礙物,落點與飛行時間維持不變。敵我全陣營一體適用。" }, + { "ProjectileArcHeight", "拋射彈道增高 2 倍" }, + { "ProjectileArcHeightTip", "將箭矢、標槍與投石等拋射物的垂直初速與重力同步提升 2 倍:彈道弧頂增高為原本的 2 倍、更容易越過樹木與建築等障礙物,落點與飛行時間維持不變。敵我全陣營一體適用。" }, { "RangedAccuracy", "遠程命中強化 (實驗性)" }, { "RangedAccuracyTip", "將拋射物落點的傷害判定半徑加倍(近失彈也算命中),並將對移動目標預判的隨機散布歸零,大幅提升遠程單位實際命中率。敵我全陣營一體適用。" }, { "FreeProdTip", "建造所有建築物、指派工匠進行維修,以及在營房等軍事設施生產所有村民、步兵與騎兵單位時,完全不需要消耗任何木材、食物或金錢資源!" }, @@ -710,8 +710,8 @@ public static string GetFactionName(string faction) { { "SpellRange3x", "3x Spell Effect Radius" }, { "SpellRange3xTip", "Triples spell effect and hit radius from the original value; priest casting distance is unchanged." }, { "LogSpellRange3xToggled", "3x Spell Effect Radius {0}." }, - { "ProjectileArcHeight", "Higher Projectile Arc (Experimental)" }, - { "ProjectileArcHeightTip", "Raises projectile launch speed and gravity by 1.5x together: arrows, javelins and catapult stones fly ~50% higher arcs that clear trees and buildings, while landing point and flight time stay unchanged. Applies to all factions." }, + { "ProjectileArcHeight", "2x Projectile Arc Height" }, + { "ProjectileArcHeightTip", "Raises projectile launch speed and gravity by 2x together: arrows, javelins and catapult stones fly 2x higher arcs that clear trees and buildings more easily, while landing point and flight time stay unchanged. Applies to all factions." }, { "RangedAccuracy", "Ranged Accuracy Boost (Experimental)" }, { "RangedAccuracyTip", "Doubles the impact damage radius of projectiles (near misses now hit) and removes the random lead-aim scatter against moving targets. Applies to all factions." }, { "FreeProdTip", "Makes all construction, building repairs, and unit production free (0 resource cost)." }, diff --git a/src/Core/Patches/ObjdefPatcher.cs b/src/Core/Patches/ObjdefPatcher.cs index bee636d..f12cac7 100644 --- a/src/Core/Patches/ObjdefPatcher.cs +++ b/src/Core/Patches/ObjdefPatcher.cs @@ -24,8 +24,10 @@ public static class ObjdefPatcher { internal const int GloryMultiplier = 5; /// 拋射彈道增高倍率:w*_emit(拋射垂直初速,16.16 定點整數)乘以此倍率; - /// 必須與 PartgeoPatcher.ArcYsubMultiplier(重力 ysub)同倍率,落點與飛行時間才不會改變。 - internal const double ArcEmitMultiplier = 1.5; + /// 必須與 PartgeoPatcher.ArcYsubMultiplier(重力 ysub)同倍率,落點與飛行時間才不會改變,弧頂高度按同倍率增加。 + /// 機制已於 2026-07-12 以 10 倍實機驗證成功,正式版依使用者要求採 2 倍。 + /// 原版所有拋射 emit 值 ×10 以內皆不超出欄位寬度(已對全 objdef 驗證)。 + internal const double ArcEmitMultiplier = 2.0; /// 遠程命中強化倍率:w*_drad(落點傷害半徑)乘以此倍率,讓近失彈也算命中。 internal const int AccuracyDradMultiplier = 2; diff --git a/src/UI/ModifierForm.Layout.cs b/src/UI/ModifierForm.Layout.cs index 21b51b9..674c01c 100644 --- a/src/UI/ModifierForm.Layout.cs +++ b/src/UI/ModifierForm.Layout.cs @@ -165,7 +165,7 @@ private void ConfigureSystemDashboard() { chkFocusLoss, chkToEng, chkDgVoodoo); - ConfigureSettingsCard(pnlExperimentalCard, lblExperimentalTitle, 494, + ConfigureSettingsCard(pnlExperimentalCard, lblExperimentalTitle, 446, chkGameSpeed, chkSpellDamage5x, chkSpellHealing10x, @@ -173,9 +173,8 @@ private void ConfigureSystemDashboard() { chkGeneralSkills, chkLeaderGlory, chkBalance, - chkProjectileArcHeight, chkRangedAccuracy); - ConfigureSettingsCard(pnlSwitchesCard, lblSwitchesTitle, 494, + ConfigureSettingsCard(pnlSwitchesCard, lblSwitchesTitle, 542, chkFreeProd, chkFreeUpgrade, chkNoSpellCost, @@ -184,7 +183,8 @@ private void ConfigureSystemDashboard() { chkRangedRange3x, chkUnitMovementSpeed2x, chkSpellEntireMap, - chkSpellRange3x); + chkSpellRange3x, + chkProjectileArcHeight); ConfigureSettingsCard(pnlBuildCard, lblBuildTitle, 446, chkMaxPopulation, chkHousingCapacity20x, diff --git a/src/UI/ModifierForm.Presets.cs b/src/UI/ModifierForm.Presets.cs index 6e9a593..91aeefd 100644 --- a/src/UI/ModifierForm.Presets.cs +++ b/src/UI/ModifierForm.Presets.cs @@ -28,7 +28,13 @@ private void BtnEnableAll_Click(object? sender, EventArgs e) { chkAiM6.Checked = true; chkVillageBuildRange.Checked = true; chkDgVoodoo.Checked = true; - // 實驗性功能 (含 10 倍遊戲加速) 排除在一鍵全開之外,保持原樣不變。 + chkRangedRange3x.Checked = true; + chkUnitMovementSpeed2x.Checked = true; + // 兩者各自獨立:全地圖 = 施法距離 (objdef Sirad)、3 倍 = 法術效果半徑 (cl_script Radius)。 + chkSpellEntireMap.Checked = true; + chkSpellRange3x.Checked = true; + chkProjectileArcHeight.Checked = true; + // 實驗性功能 (含 10 倍遊戲加速、遠程命中強化等) 排除在一鍵全開之外,保持原樣不變。 Log(Loc.CurrentLanguage == Language.English ? "All features enabled." : "已開啟所有功能。"); } diff --git a/src/UI/ModifierForm.cs b/src/UI/ModifierForm.cs index 761957f..dcd7b24 100644 --- a/src/UI/ModifierForm.cs +++ b/src/UI/ModifierForm.cs @@ -1077,7 +1077,7 @@ private void InitializeComponent() { BackColor = Color.Transparent, Font = fontJhengHei10B }; - pnlExperimentalCard.Controls.Add(chkProjectileArcHeight); + pnlSwitchesCard.Controls.Add(chkProjectileArcHeight); chkRangedAccuracy = new ModernToggle { Text = Loc.Get("RangedAccuracy"), diff --git a/tests/AgainstRomeModifier.Tests/PatcherRoundTripTests.cs b/tests/AgainstRomeModifier.Tests/PatcherRoundTripTests.cs index 71fee4e..00853e6 100644 --- a/tests/AgainstRomeModifier.Tests/PatcherRoundTripTests.cs +++ b/tests/AgainstRomeModifier.Tests/PatcherRoundTripTests.cs @@ -126,7 +126,7 @@ public void Objdef_projectile_arc_and_accuracy_scale_emit_and_drad_only_for_proj byte[] patched = ObjdefPatcher.GetPatchedBytes(original, new ObjdefOptions(false, false, false, false, false, false, false, false, false, false, true, true, NoStats)); string[] result = SyntheticFixture.Text(patched).Split("\r\n")[2].Split(','); - Assert.Equal("10813440", result[93].Trim()); // w2 emit ×1.5 + Assert.Equal("14417920", result[93].Trim()); // w2 emit ×2 Assert.Equal("90", result[166].Trim()); // w2 drad ×2 Assert.Equal("0", result[85].Trim()); // w1 emit 維持 0 Assert.Equal("45", result[164].Trim()); // w1(近戰)drad 不可動 @@ -150,9 +150,9 @@ static string Row(int idx, string name, string ysub) => string patched = SyntheticFixture.Text(PartgeoPatcher.GetPatchedBytes(original, new PartgeoOptions(true))); string[] lines = patched.Split("\r\n"); Assert.Contains("-983040", lines[2]); // 非拋射物(煙霧)不可動 - Assert.Equal("8749056", lines[3].Split(',')[12].Trim()); // Wurfspeer00 ×1.5 - Assert.Equal("13467648", lines[5].Split(',')[12].Trim()); // Katapultstein00 ×1.5 - Assert.Equal("8749056", lines[6].Split(',')[12].Trim()); // Pfeil00 ×1.5 + Assert.Equal("11665408", lines[3].Split(',')[12].Trim()); // Wurfspeer00 ×2 + Assert.Equal("17956864", lines[5].Split(',')[12].Trim()); // Katapultstein00 ×2 + Assert.Equal("11665408", lines[6].Split(',')[12].Trim()); // Pfeil00 ×2 Assert.Equal(original, PartgeoPatcher.GetPatchedBytes(original, new PartgeoOptions(false))); } From 090d28e1f50bd7dfcefca3cef0f66d140f29f633 Mon Sep 17 00:00:00 2001 From: Jackie Chen Date: Mon, 13 Jul 2026 06:28:29 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E5=B0=87=E3=80=8C5=E5=80=8D=E6=B3=95?= =?UTF-8?q?=E8=A1=93=E5=82=B7=E5=AE=B3=E3=80=8D=E7=A7=BB=E5=87=BA=E5=AF=A6?= =?UTF-8?q?=E9=A9=97=E7=B5=84=E4=B8=A6=E5=84=AA=E5=8C=96=E4=B8=BB=E6=8E=A7?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SpellDamage5x 正式納入資源與戰鬥升級群組,UI 開關移至 Switches 卡片,並於一鍵全開預設自動勾選 - 主控台改為四欄瀑布流自動分配卡片,支援視窗捲動,提升群組排列彈性 - 統一設定卡片底色,強化群組邊界辨識 - 調整 Switches/Experimental 卡片高度 - README 與 reverse-engineering 文件同步更新,補充 SpellDamage5x 驗證證據與現狀 --- AI_HANDOFF.md | 6 ++ README.md | 1 + docs/reverse-engineering/priest-spells.md | 8 ++- src/UI/ModifierForm.Layout.cs | 69 ++++++++++++----------- src/UI/ModifierForm.Presets.cs | 1 + src/UI/ModifierForm.cs | 2 +- 6 files changed, 53 insertions(+), 34 deletions(-) diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md index 15b0ab1..8d20d75 100644 --- a/AI_HANDOFF.md +++ b/AI_HANDOFF.md @@ -2,6 +2,8 @@ ## Bug Fix and Patch Tuning (2026-07-12) +- `SpellDamage5x` was runtime-verified in-game (user report) and promoted from the Experimental UI group into Resource & Combat Upgrades. It is now included by `BtnEnableAll`; its existing reversible `cl_script.ini` patch and detection remain unchanged. The canonical runtime-status evidence is in `docs/reverse-engineering/priest-spells.md`. + - `BtnEnableAll` now also enables the toggles previously promoted out of the Experimental group: `RangedRange3x`, `UnitMovementSpeed2x`, `SpellEntireMap`, `SpellRange3x`, and `ProjectileArcHeight`. Note the two spell toggles are independent since the 2026-07-12 rework (`SpellEntireMap` = casting distance via objdef `Sirad` 30000; `SpellRange3x` = spell effect radius via cl_script `[Spells] Radius` ×3) — an earlier mutual-exclusion assumption was stale and briefly left `SpellRange3x` out (user-reported). Experimental toggles remain excluded by design. Release build 0 warnings/errors; 107/107 tests pass. - `ProjectileArcHeight` runtime-verified and finalized at 2x: the mechanism was confirmed in-game at 10x (user reported success — visibly higher arcs, landing points unchanged), then per user request the shipped factor was set to 2x and the feature was promoted out of the Experimental group into the Switches (Resource & Combat Upgrades) card (experimental card 446 / switches card 542, labels dropped "(實驗性)"/"(Experimental)" and now read "拋射彈道增高 2 倍" / "2x Projectile Arc Height"). Factor history: 1.5x visually indistinguishable → 10x verification success → 2x production value. Single source of truth remains `ObjdefPatcher.ArcEmitMultiplier` (shared by `PartgeoPatcher.ArcYsubMultiplier`); emit cell widths hold up to ×10. BtnEnableAll now includes it (see the newer bullet above); BtnDisableAll clears it. `RangedAccuracy` stays experimental (runtime observation of hit-rate change still informal). Release build 0 warnings/errors; 107/107 tests pass. @@ -34,6 +36,10 @@ Build a dedicated map editor that can eventually provide an Age-of-Empires-II-like authored-map workflow, while preserving Against Rome's undocumented-format safety constraints. `docs/map-editor-spec.md` remains the staged implementation guide. Changes are intentionally uncommitted pending user direction. +## Modifier Main Console Layout (2026-07-13) + +- Reworked the main console into a responsive four-column masonry layout. Each settings card is placed in the currently shortest column, so the five existing groups distribute compactly without fixed column pairings; the settings area scrolls when the window is too short instead of clipping rows. This is layout-only; no patch feature behavior changed. Verification: Release build 0 warnings/errors; 107/107 tests passed; `git diff --check` clean. + ## Map Editor Phase 1 Progress (2026-07-11) - Independent scene renderer milestone (2026-07-12): `boden.bmp` + `Heightmapstep` now drive read-only terrain hill-shading and a `Waterlevel / Heightmapstep` threshold drives a `WaterColor` water overlay; visual QA on KAMP_000 matched the river to its minimap. `SdlSceneCatalog` parses top-level PFIL SDL files, maps `refpos + pos` through the verified 64-world-units-per-map-pixel relation, classifies building/unit/other objects, and renders team-colored scene markers. KAMP_000 produced 138 objects and ENDL_000 613 in a read-only probe; the UI adds a player-facing object toggle and friendly scene summary/list without internal names. The default grouped selection bug was fixed so startup reliably selects the first visible campaign map. The game-launch command is explicitly optional and no longer presented as the primary preview. Initial terrain composition now reuses one Graphics context instead of creating 4,096 contexts. Final verification: Release tests 105/105; full Release and MapEditor Debug builds 0 warnings/errors; `git diff --check` clean; temporary probe removed. diff --git a/README.md b/README.md index b641469..2dc9ffb 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ The author of this project is a devoted player who loved *Against Rome* many yea - **10x Town Hall HP**: Reversible switch to multiply hit-points (HP) of all Town Halls (`Hau` structures) in `objdef.dau` by 10x (successfully runtime-verified in-game). - **Endless-Mode AI Ultimate Mode**: Reversible, bounded BCI patches for the five `ENDL_*` maps. The current safe military configuration is 20 units per reinforcement party, 5-second respawn timing, and an active-party limit of 8; original loop pacing and the two settlement-cleanup safeguards remain intact. It never uses the previously rejected unconditional gate bypass. See [`endless-mode-ai.md`](docs/reverse-engineering/endless-mode-ai.md) for evidence, legacy migration, and runtime limits. - **Free Construction & Production**: Free construction, production, upgrades, and spell costs through `ress.ini` modification. +- **5x Spell Damage**: Reversible `cl_script.ini` patch for six configured active-spell values; successfully runtime-verified in-game. - **Unit Stat Editing**: Adjust only HP, damage, VW, AW, sight, and cooldown through `objdef.dau`. Movement speed, attack range, spell radius, and priest sight/casting distance are deliberately owned by the independent experimental modifiers, not by custom troop layers. - **Experimental Stat Modifiers**: 3× ranged range, 2× unit movement, entire-map priest casting distance, 3× spell effect radius, higher projectile arcs, and ranged-accuracy boost. These are static-verified; the two projectile features still need dedicated runtime-regression observations. - **Troop Presets & One-Click Control**: `.artroop` exports use the six-field `HP,Dmg,VW,AW,Sight,Relt` format. Legacy nine-field imports remain readable but their removed fields are discarded; one-click buttons enable or disable all registered features. diff --git a/docs/reverse-engineering/priest-spells.md b/docs/reverse-engineering/priest-spells.md index b709527..dde1732 100644 --- a/docs/reverse-engineering/priest-spells.md +++ b/docs/reverse-engineering/priest-spells.md @@ -152,12 +152,13 @@ the `FUN_005b1410` sequence around decompiled-inventory lines 186446-186448. - Respect the 16-bit signed storage limit (≤ 32767) for all non-Duration fields. All multiplied values are verified to be safe from overflow. -## Experimental feature verification +## Independent feature verification The static call/data chain and the modifier test suite verify these targets: | Feature | Decompilation target | Patched value | |---|---|---:| +| Spell damage 5x | six configured active-spell `Value` records consumed by `s_getSpecialEffectValue` | original x 5 | | Healing 10x | `KEL, Spell1, Value` consumed by `s_getSpecialEffectValue` | 65 -> 650 | | Enhanced resurrection | `KEL, Spell3, Value/Value2` passed to `s_specialEffektCreateUnit` | 50/50 -> 100/100 | | Ranged unit range 3x | `objdef` `w*_rad1/w*_rad2` (80/81, +8 per weapon) | original x 3 | @@ -165,6 +166,11 @@ The static call/data chain and the modifier test suite verify these targets: | Priest casting distance, entire map | priest `objdef` `Sirad` (24, sight radius) | max distance 30000 | | Spell effect radius 3x | `cl_script.ini` `[Spells] Radius` | original x 3 | +`SpellDamage5x` was successfully runtime-verified in-game and is promoted from +the Experimental UI group to Resource & Combat Upgrades. It remains an +independent toggle, applies only the six `ClScriptPatcher.DamageSpellKeys`, and +is included by Enable All. + The priest `Radius` value (field index 4 in the EXE spell table) is the effect/search area used by healing and resurrection, not the caster-to-target distance. `SpellEntireMap` uses the priest `Sirad` sight-radius field in diff --git a/src/UI/ModifierForm.Layout.cs b/src/UI/ModifierForm.Layout.cs index 674c01c..8eaeb96 100644 --- a/src/UI/ModifierForm.Layout.cs +++ b/src/UI/ModifierForm.Layout.cs @@ -158,28 +158,29 @@ private void ConfigureSystemDashboard() { Size = new Size(tabSystem.ClientSize.Width, tabSystem.ClientSize.Height - 72), Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right, BackColor = Color.FromArgb(9, 12, 18), - AutoScroll = false // 確保絕對不滾動 + // 卡片在較矮視窗仍完整可用;寬螢幕時則維持沒有捲軸的四欄工作區。 + AutoScroll = true }; ConfigureSettingsCard(pnlNumericCard, lblNumericTitle, 206, chkFocusLoss, chkToEng, chkDgVoodoo); - ConfigureSettingsCard(pnlExperimentalCard, lblExperimentalTitle, 446, + ConfigureSettingsCard(pnlExperimentalCard, lblExperimentalTitle, 398, chkGameSpeed, - chkSpellDamage5x, chkSpellHealing10x, chkSpellResurrection, chkGeneralSkills, chkLeaderGlory, chkBalance, chkRangedAccuracy); - ConfigureSettingsCard(pnlSwitchesCard, lblSwitchesTitle, 542, + ConfigureSettingsCard(pnlSwitchesCard, lblSwitchesTitle, 590, chkFreeProd, chkFreeUpgrade, chkNoSpellCost, chkInfiniteMorale, chkNoSpellAltar, + chkSpellDamage5x, chkRangedRange3x, chkUnitMovementSpeed2x, chkSpellEntireMap, @@ -197,12 +198,13 @@ private void ConfigureSystemDashboard() { ConfigureSettingsCard(pnlAiCard, lblAiTitle, 348, chkAiM1, chkAiM2, chkAiM3, chkAiM4, chkAiM5, chkAiM6); - // 卡片容器設為透明,移除背景繪製與邊框 - pnlNumericCard.BackColor = Color.Transparent; - pnlExperimentalCard.BackColor = Color.Transparent; - pnlSwitchesCard.BackColor = Color.Transparent; - pnlBuildCard.BackColor = Color.Transparent; - pnlAiCard.BackColor = Color.Transparent; + // 每組設定以同一層卡片底色收攏,讓長短不一的功能群組仍有清楚邊界。 + Color cardBackColor = Color.FromArgb(14, 18, 26); + pnlNumericCard.BackColor = cardBackColor; + pnlExperimentalCard.BackColor = cardBackColor; + pnlSwitchesCard.BackColor = cardBackColor; + pnlBuildCard.BackColor = cardBackColor; + pnlAiCard.BackColor = cardBackColor; pnlNumericCard.Dock = DockStyle.None; pnlExperimentalCard.Dock = DockStyle.None; @@ -234,27 +236,30 @@ private void LayoutSystemCardsFlat(Panel container) { int gapY = 16; int availWidth = container.ClientSize.Width - (paddingX * 2); - int columnWidth = (availWidth - (gapX * 3)) / 4; - if (columnWidth < 300) columnWidth = 300; - - // 第一欄:系統設定與資源/戰鬥修改垂直堆疊 - pnlNumericCard.Location = new Point(paddingX, paddingY); - pnlNumericCard.Width = columnWidth; - - pnlSwitchesCard.Location = new Point(paddingX, paddingY + pnlNumericCard.Height + gapY); - pnlSwitchesCard.Width = columnWidth; - - // 第二欄:建設與人口設定 - pnlBuildCard.Location = new Point(paddingX + columnWidth + gapX, paddingY); - pnlBuildCard.Width = columnWidth; - - // 第三欄:AI 終極戰爭模式 (無盡重生) - pnlAiCard.Location = new Point(paddingX + (columnWidth + gapX) * 2, paddingY); - pnlAiCard.Width = columnWidth; - - // 第四欄:實驗性功能 (頂部對齊獨立一欄) - pnlExperimentalCard.Location = new Point(paddingX + (columnWidth + gapX) * 3, paddingY); - pnlExperimentalCard.Width = columnWidth; + int columnCount = 4; + int columnWidth = (availWidth - (gapX * (columnCount - 1))) / columnCount; + if (columnWidth < 260) columnWidth = 260; + + // 四欄瀑布式排列:每張卡片放入目前總高度最短的欄位。 + // 新增或調整卡片高度後會自動維持緊湊,不再依賴固定欄位歸屬。 + int[] columnBottoms = Enumerable.Repeat(paddingY, columnCount).ToArray(); + Panel[] cards = { + pnlNumericCard, + pnlBuildCard, + pnlSwitchesCard, + pnlAiCard, + pnlExperimentalCard + }; + foreach (Panel card in cards) { + int column = Array.IndexOf(columnBottoms, columnBottoms.Min()); + card.Location = new Point(paddingX + column * (columnWidth + gapX), columnBottoms[column]); + card.Width = columnWidth; + columnBottoms[column] = card.Bottom + gapY; + } + + int contentHeight = columnBottoms.Max() - gapY + paddingY; + int contentWidth = paddingX * 2 + columnWidth * columnCount + gapX * (columnCount - 1); + container.AutoScrollMinSize = new Size(Math.Max(container.ClientSize.Width, contentWidth), contentHeight); container.ResumeLayout(true); } @@ -268,7 +273,7 @@ private void ConfigureSettingsCard( card.MinimumSize = new Size(0, height); card.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; card.Margin = new Padding(6, 0, 6, 0); - card.BackColor = Color.FromArgb(18, 22, 31); + card.BackColor = Color.FromArgb(14, 18, 26); title.Location = new Point(20, 17); title.Size = new Size(280, 24); diff --git a/src/UI/ModifierForm.Presets.cs b/src/UI/ModifierForm.Presets.cs index 91aeefd..2b6dff9 100644 --- a/src/UI/ModifierForm.Presets.cs +++ b/src/UI/ModifierForm.Presets.cs @@ -28,6 +28,7 @@ private void BtnEnableAll_Click(object? sender, EventArgs e) { chkAiM6.Checked = true; chkVillageBuildRange.Checked = true; chkDgVoodoo.Checked = true; + chkSpellDamage5x.Checked = true; chkRangedRange3x.Checked = true; chkUnitMovementSpeed2x.Checked = true; // 兩者各自獨立:全地圖 = 施法距離 (objdef Sirad)、3 倍 = 法術效果半徑 (cl_script Radius)。 diff --git a/src/UI/ModifierForm.cs b/src/UI/ModifierForm.cs index dcd7b24..672236a 100644 --- a/src/UI/ModifierForm.cs +++ b/src/UI/ModifierForm.cs @@ -981,7 +981,7 @@ private void InitializeComponent() { BackColor = Color.Transparent, Font = fontJhengHei10B }; - pnlExperimentalCard.Controls.Add(chkSpellDamage5x); + pnlSwitchesCard.Controls.Add(chkSpellDamage5x); chkSpellHealing10x = new ModernToggle { Text = Loc.Get("SpellHealing10x"),