From ccd68f87488ae62b66d87e21661863aa4b4ddd2d Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Sat, 10 Oct 2026 10:48:21 +0200 Subject: [PATCH 1/5] Introduce tactical maps editor --- .erb/configs/webpack.config.base.mts | 4 + .erb/configs/webpack.config.renderer.prod.mts | 5 + .erb/configs/webpack.module.renderer.mts | 5 + .erb/configs/webpack.paths.mts | 8 + .erb/scripts/build-map-editor.mts | 77 + .github/workflows/test.yml | 5 + .gitignore | 1 + AGENTS.md | 62 + Cargo.lock | 8328 +++++++++++++++++ Cargo.toml | 31 + README.md | 5 + eslint.config.mjs | 1 + package-lock.json | 3721 ++------ package.json | 22 +- rust-toolchain | 1 - rust-toolchain.toml | 3 + src-map-editor/COPYING | 6 + src-map-editor/Cargo.toml | 31 + src-map-editor/README.md | 109 + src-map-editor/assets/fonts/OFL.txt | 94 + .../assets/fonts/SourceSansPro-Regular.ttf | Bin 0 -> 269108 bytes src-map-editor/src/components/camera.rs | 9 + src-map-editor/src/components/map.rs | 185 + src-map-editor/src/components/mod.rs | 2 + src-map-editor/src/editor/brush.rs | 387 + src-map-editor/src/editor/building.rs | 1052 +++ src-map-editor/src/editor/building_mode.rs | 982 ++ src-map-editor/src/editor/caves.rs | 290 + src-map-editor/src/editor/cliffs.rs | 432 + src-map-editor/src/editor/commands.rs | 327 + src-map-editor/src/editor/document.rs | 1007 ++ src-map-editor/src/editor/doors.rs | 277 + src-map-editor/src/editor/ground.rs | 422 + src-map-editor/src/editor/input.rs | 336 + src-map-editor/src/editor/item_events.rs | 384 + src-map-editor/src/editor/item_overlay.rs | 166 + src-map-editor/src/editor/items.rs | 1604 ++++ src-map-editor/src/editor/layout.rs | 247 + src-map-editor/src/editor/light_events.rs | 517 + src-map-editor/src/editor/light_overlay.rs | 118 + src-map-editor/src/editor/lights.rs | 878 ++ src-map-editor/src/editor/mod.rs | 230 + src-map-editor/src/editor/mode.rs | 680 ++ src-map-editor/src/editor/palette.rs | 172 + src-map-editor/src/editor/placement.rs | 233 + src-map-editor/src/editor/preview.rs | 729 ++ src-map-editor/src/editor/roads.rs | 328 + src-map-editor/src/editor/rooms_overlay.rs | 160 + src-map-editor/src/editor/save.rs | 89 + src-map-editor/src/editor/schedules.rs | 649 ++ src-map-editor/src/editor/scroll_area.rs | 53 + src-map-editor/src/editor/selection.rs | 323 + src-map-editor/src/editor/smart.rs | 540 ++ src-map-editor/src/editor/smooth.rs | 371 + src-map-editor/src/editor/soldier_events.rs | 928 ++ src-map-editor/src/editor/soldier_overlay.rs | 363 + src-map-editor/src/editor/soldiers.rs | 1640 ++++ src-map-editor/src/editor/traversal.rs | 868 ++ src-map-editor/src/editor/traversal_events.rs | 480 + .../src/editor/traversal_overlay.rs | 335 + src-map-editor/src/editor/undo.rs | 358 + src-map-editor/src/editor/walls.rs | 476 + src-map-editor/src/file_formats/ja2_map.rs | 1659 ++++ src-map-editor/src/file_formats/ja2_stci.rs | 33 + .../src/file_formats/ja2_structure.rs | 781 ++ .../src/file_formats/ja2_tile_database.rs | 835 ++ .../src/file_formats/ja2_tile_types.rs | 568 ++ .../src/file_formats/ja2_tileset.rs | 380 + src-map-editor/src/file_formats/mod.rs | 79 + src-map-editor/src/file_formats/stci/color.rs | 104 + src-map-editor/src/file_formats/stci/etrle.rs | 253 + .../src/file_formats/stci/indexed.rs | 465 + src-map-editor/src/file_formats/stci/mod.rs | 917 ++ src-map-editor/src/file_formats/stci/rgb.rs | 168 + src-map-editor/src/host.rs | 462 + src-map-editor/src/item/mod.rs | 521 ++ src-map-editor/src/lib.rs | 655 ++ src-map-editor/src/light/mod.rs | 139 + src-map-editor/src/map/convert.rs | 1463 +++ src-map-editor/src/map/door.rs | 81 + src-map-editor/src/map/edge_points.rs | 50 + src-map-editor/src/map/exit_grid.rs | 98 + src-map-editor/src/map/grid.rs | 296 + src-map-editor/src/map/item.rs | 146 + src-map-editor/src/map/item_data.rs | 226 + src-map-editor/src/map/light.rs | 132 + src-map-editor/src/map/mod.rs | 212 + src-map-editor/src/map/new_map.rs | 196 + src-map-editor/src/map/placement.rs | 318 + src-map-editor/src/map/save.rs | 1208 +++ src-map-editor/src/map/schedule.rs | 88 + src-map-editor/src/map/tile.rs | 51 + src-map-editor/src/random.rs | 44 + src-map-editor/src/render/atlas.rs | 440 + src-map-editor/src/render/bins.rs | 129 + src-map-editor/src/render/draw_list.rs | 1461 +++ src-map-editor/src/render/edge_points.rs | 699 ++ src-map-editor/src/render/items.rs | 173 + src-map-editor/src/render/lighting.rs | 683 ++ src-map-editor/src/render/mod.rs | 506 + src-map-editor/src/render/movement_costs.rs | 1467 +++ src-map-editor/src/render/palette.rs | 146 + src-map-editor/src/render/quantize.rs | 219 + src-map-editor/src/render/radar.rs | 772 ++ src-map-editor/src/render/reachable.rs | 579 ++ src-map-editor/src/render/reference.rs | 375 + src-map-editor/src/render/regions.rs | 181 + src-map-editor/src/render/replay_material.rs | 174 + src-map-editor/src/render/soldiers.rs | 250 + src-map-editor/src/render/tests.rs | 927 ++ src-map-editor/src/render/world.rs | 1043 +++ src-map-editor/src/shaders/ja2_replay.wesl | 240 + src-map-editor/src/soldier/body_type.rs | 367 + src-map-editor/src/soldier/class.rs | 91 + src-map-editor/src/soldier/mod.rs | 214 + src-map-editor/src/soldier/palette.rs | 164 + src-map-editor/src/soldier/palette_files.rs | 239 + src-map-editor/src/soldier/random.rs | 9 + .../src/soldier/standing_surface.rs | 99 + src-map-editor/src/soldier/team.rs | 53 + src-map-editor/src/states/loading.rs | 629 ++ src-map-editor/src/states/map.rs | 32 + src-map-editor/src/states/mod.rs | 52 + src-map-editor/src/states/tile_debug.rs | 215 + src-map-editor/src/systems/bake.rs | 363 + src-map-editor/src/systems/camera.rs | 239 + src-map-editor/src/systems/cursor.rs | 123 + src-map-editor/src/systems/mod.rs | 35 + src-map-editor/src/systems/pixel_view.rs | 386 + src-map-editor/src/systems/radar.rs | 357 + src-map-editor/src/tileset/building.rs | 202 + src-map-editor/src/tileset/convert.rs | 1250 +++ src-map-editor/src/tileset/mod.rs | 380 + src-map-editor/src/tileset/sheet.rs | 112 + src-map-editor/src/tileset/structure.rs | 119 + src-map-editor/src/tileset/tile.rs | 339 + src-rust/Cargo.lock | 3202 ------- src-rust/Cargo.toml | 5 +- src-rust/src/invokables/image.rs | 12 +- src-rust/src/invokables/mod.rs | 3 + src-rust/src/invokables/resources.rs | 113 + src-rust/src/state.rs | 43 +- .../components/TacticalMapViewer.test.tsx | 2349 +++++ .../renderer/components/TacticalMaps.test.tsx | 406 + .../mapEditor/DoorLockModal.test.tsx | 61 + .../components/mapEditor/ToolSelect.test.tsx | 66 + .../components/mapEditor/buildingMode.test.ts | 296 + .../components/mapEditor/itemData.test.ts | 185 + .../components/mapEditor/itemFields.test.ts | 210 + .../components/mapEditor/itemMode.test.ts | 92 + .../components/mapEditor/lightMode.test.ts | 84 + .../components/mapEditor/lockTable.test.ts | 43 + .../components/mapEditor/scheduleTab.test.ts | 35 + .../components/mapEditor/soldierData.test.ts | 98 + .../components/mapEditor/soldierMode.test.ts | 103 + .../components/mapEditor/terrainMode.test.ts | 265 + .../mapEditor/traversalMode.test.ts | 157 + src/__tests__/renderer/lib/mapViewer.test.ts | 327 + src/__tests__/renderer/test-utils/palette.ts | 20 + src/__tests__/renderer/test-utils/soldier.ts | 133 + src/__tests__/stubs/ja2-map-editor.ts | 25 + src/common/invokables/images.ts | 2 + src/common/invokables/index.ts | 10 +- src/common/invokables/resources.ts | 61 + src/main/main.ts | 6 + src/renderer/App.css | 8 + src/renderer/EditorRoutes.tsx | 7 + src/renderer/components/common/HelpIcon.tsx | 41 + .../components/content/NewMapDialog.tsx | 144 + .../components/content/RadarMapPreview.tsx | 93 + .../components/content/StiPreview.tsx | 57 +- .../components/content/StrategicMap.css | 19 +- .../components/content/TacticalMapViewer.tsx | 311 + .../components/content/TacticalMaps.tsx | 319 + .../content/mapEditor/BrushControls.tsx | 72 + .../content/mapEditor/BuildingPanel.tsx | 409 + .../content/mapEditor/DoorLockModal.tsx | 183 + .../content/mapEditor/EraseControl.tsx | 32 + .../content/mapEditor/ItemStatsEditor.tsx | 558 ++ .../content/mapEditor/ItemsPanel.tsx | 478 + .../content/mapEditor/LightsPanel.tsx | 249 + .../content/mapEditor/MapEditorSidebar.tsx | 671 ++ .../content/mapEditor/OverviewPanel.tsx | 144 + .../mapEditor/SoldierAppearanceTab.tsx | 137 + .../mapEditor/SoldierAttributesTab.tsx | 130 + .../content/mapEditor/SoldierEditor.tsx | 195 + .../content/mapEditor/SoldierGeneralTab.tsx | 315 + .../content/mapEditor/SoldierInventoryTab.tsx | 161 + .../mapEditor/SoldierProfileSelect.tsx | 55 + .../content/mapEditor/SoldierScheduleTab.tsx | 316 + .../content/mapEditor/SoldiersPanel.tsx | 174 + .../content/mapEditor/TerrainPanel.tsx | 310 + .../content/mapEditor/TextureSwatches.tsx | 136 + .../content/mapEditor/TilePalette.tsx | 245 + .../content/mapEditor/ToolSelect.tsx | 62 + .../content/mapEditor/TraversalPanel.tsx | 554 ++ .../content/mapEditor/buildingMode.ts | 373 + .../components/content/mapEditor/itemData.ts | 474 + .../content/mapEditor/itemFields.ts | 389 + .../components/content/mapEditor/itemMode.ts | 124 + .../components/content/mapEditor/lightMode.ts | 97 + .../components/content/mapEditor/lockTable.ts | 85 + .../content/mapEditor/soldierData.ts | 123 + .../content/mapEditor/soldierEdit.ts | 52 + .../content/mapEditor/soldierMode.ts | 121 + .../content/mapEditor/terrainMode.ts | 235 + .../content/mapEditor/traversalMode.ts | 215 + .../components/layout/EditorContent.tsx | 64 +- .../components/layout/ToolsetLayout.css | 22 +- src/renderer/hooks/useImage.tsx | 9 +- src/renderer/lib/ja2-map-editor.d.ts | 63 + src/renderer/lib/mapViewer.ts | 1412 +++ vitest.config.ts | 6 + 213 files changed, 74157 insertions(+), 6011 deletions(-) create mode 100644 .erb/scripts/build-map-editor.mts create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 rust-toolchain create mode 100644 rust-toolchain.toml create mode 100644 src-map-editor/COPYING create mode 100644 src-map-editor/Cargo.toml create mode 100644 src-map-editor/README.md create mode 100644 src-map-editor/assets/fonts/OFL.txt create mode 100644 src-map-editor/assets/fonts/SourceSansPro-Regular.ttf create mode 100644 src-map-editor/src/components/camera.rs create mode 100644 src-map-editor/src/components/map.rs create mode 100644 src-map-editor/src/components/mod.rs create mode 100644 src-map-editor/src/editor/brush.rs create mode 100644 src-map-editor/src/editor/building.rs create mode 100644 src-map-editor/src/editor/building_mode.rs create mode 100644 src-map-editor/src/editor/caves.rs create mode 100644 src-map-editor/src/editor/cliffs.rs create mode 100644 src-map-editor/src/editor/commands.rs create mode 100644 src-map-editor/src/editor/document.rs create mode 100644 src-map-editor/src/editor/doors.rs create mode 100644 src-map-editor/src/editor/ground.rs create mode 100644 src-map-editor/src/editor/input.rs create mode 100644 src-map-editor/src/editor/item_events.rs create mode 100644 src-map-editor/src/editor/item_overlay.rs create mode 100644 src-map-editor/src/editor/items.rs create mode 100644 src-map-editor/src/editor/layout.rs create mode 100644 src-map-editor/src/editor/light_events.rs create mode 100644 src-map-editor/src/editor/light_overlay.rs create mode 100644 src-map-editor/src/editor/lights.rs create mode 100644 src-map-editor/src/editor/mod.rs create mode 100644 src-map-editor/src/editor/mode.rs create mode 100644 src-map-editor/src/editor/palette.rs create mode 100644 src-map-editor/src/editor/placement.rs create mode 100644 src-map-editor/src/editor/preview.rs create mode 100644 src-map-editor/src/editor/roads.rs create mode 100644 src-map-editor/src/editor/rooms_overlay.rs create mode 100644 src-map-editor/src/editor/save.rs create mode 100644 src-map-editor/src/editor/schedules.rs create mode 100644 src-map-editor/src/editor/scroll_area.rs create mode 100644 src-map-editor/src/editor/selection.rs create mode 100644 src-map-editor/src/editor/smart.rs create mode 100644 src-map-editor/src/editor/smooth.rs create mode 100644 src-map-editor/src/editor/soldier_events.rs create mode 100644 src-map-editor/src/editor/soldier_overlay.rs create mode 100644 src-map-editor/src/editor/soldiers.rs create mode 100644 src-map-editor/src/editor/traversal.rs create mode 100644 src-map-editor/src/editor/traversal_events.rs create mode 100644 src-map-editor/src/editor/traversal_overlay.rs create mode 100644 src-map-editor/src/editor/undo.rs create mode 100644 src-map-editor/src/editor/walls.rs create mode 100644 src-map-editor/src/file_formats/ja2_map.rs create mode 100644 src-map-editor/src/file_formats/ja2_stci.rs create mode 100644 src-map-editor/src/file_formats/ja2_structure.rs create mode 100644 src-map-editor/src/file_formats/ja2_tile_database.rs create mode 100644 src-map-editor/src/file_formats/ja2_tile_types.rs create mode 100644 src-map-editor/src/file_formats/ja2_tileset.rs create mode 100644 src-map-editor/src/file_formats/mod.rs create mode 100644 src-map-editor/src/file_formats/stci/color.rs create mode 100644 src-map-editor/src/file_formats/stci/etrle.rs create mode 100644 src-map-editor/src/file_formats/stci/indexed.rs create mode 100644 src-map-editor/src/file_formats/stci/mod.rs create mode 100644 src-map-editor/src/file_formats/stci/rgb.rs create mode 100644 src-map-editor/src/host.rs create mode 100644 src-map-editor/src/item/mod.rs create mode 100644 src-map-editor/src/lib.rs create mode 100644 src-map-editor/src/light/mod.rs create mode 100644 src-map-editor/src/map/convert.rs create mode 100644 src-map-editor/src/map/door.rs create mode 100644 src-map-editor/src/map/edge_points.rs create mode 100644 src-map-editor/src/map/exit_grid.rs create mode 100644 src-map-editor/src/map/grid.rs create mode 100644 src-map-editor/src/map/item.rs create mode 100644 src-map-editor/src/map/item_data.rs create mode 100644 src-map-editor/src/map/light.rs create mode 100644 src-map-editor/src/map/mod.rs create mode 100644 src-map-editor/src/map/new_map.rs create mode 100644 src-map-editor/src/map/placement.rs create mode 100644 src-map-editor/src/map/save.rs create mode 100644 src-map-editor/src/map/schedule.rs create mode 100644 src-map-editor/src/map/tile.rs create mode 100644 src-map-editor/src/random.rs create mode 100644 src-map-editor/src/render/atlas.rs create mode 100644 src-map-editor/src/render/bins.rs create mode 100644 src-map-editor/src/render/draw_list.rs create mode 100644 src-map-editor/src/render/edge_points.rs create mode 100644 src-map-editor/src/render/items.rs create mode 100644 src-map-editor/src/render/lighting.rs create mode 100644 src-map-editor/src/render/mod.rs create mode 100644 src-map-editor/src/render/movement_costs.rs create mode 100644 src-map-editor/src/render/palette.rs create mode 100644 src-map-editor/src/render/quantize.rs create mode 100644 src-map-editor/src/render/radar.rs create mode 100644 src-map-editor/src/render/reachable.rs create mode 100644 src-map-editor/src/render/reference.rs create mode 100644 src-map-editor/src/render/regions.rs create mode 100644 src-map-editor/src/render/replay_material.rs create mode 100644 src-map-editor/src/render/soldiers.rs create mode 100644 src-map-editor/src/render/tests.rs create mode 100644 src-map-editor/src/render/world.rs create mode 100644 src-map-editor/src/shaders/ja2_replay.wesl create mode 100644 src-map-editor/src/soldier/body_type.rs create mode 100644 src-map-editor/src/soldier/class.rs create mode 100644 src-map-editor/src/soldier/mod.rs create mode 100644 src-map-editor/src/soldier/palette.rs create mode 100644 src-map-editor/src/soldier/palette_files.rs create mode 100644 src-map-editor/src/soldier/random.rs create mode 100644 src-map-editor/src/soldier/standing_surface.rs create mode 100644 src-map-editor/src/soldier/team.rs create mode 100644 src-map-editor/src/states/loading.rs create mode 100644 src-map-editor/src/states/map.rs create mode 100644 src-map-editor/src/states/mod.rs create mode 100644 src-map-editor/src/states/tile_debug.rs create mode 100644 src-map-editor/src/systems/bake.rs create mode 100644 src-map-editor/src/systems/camera.rs create mode 100644 src-map-editor/src/systems/cursor.rs create mode 100644 src-map-editor/src/systems/mod.rs create mode 100644 src-map-editor/src/systems/pixel_view.rs create mode 100644 src-map-editor/src/systems/radar.rs create mode 100644 src-map-editor/src/tileset/building.rs create mode 100644 src-map-editor/src/tileset/convert.rs create mode 100644 src-map-editor/src/tileset/mod.rs create mode 100644 src-map-editor/src/tileset/sheet.rs create mode 100644 src-map-editor/src/tileset/structure.rs create mode 100644 src-map-editor/src/tileset/tile.rs delete mode 100644 src-rust/Cargo.lock create mode 100644 src/__tests__/renderer/components/TacticalMapViewer.test.tsx create mode 100644 src/__tests__/renderer/components/TacticalMaps.test.tsx create mode 100644 src/__tests__/renderer/components/mapEditor/DoorLockModal.test.tsx create mode 100644 src/__tests__/renderer/components/mapEditor/ToolSelect.test.tsx create mode 100644 src/__tests__/renderer/components/mapEditor/buildingMode.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/itemData.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/itemFields.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/itemMode.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/lightMode.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/lockTable.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/scheduleTab.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/soldierData.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/soldierMode.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/terrainMode.test.ts create mode 100644 src/__tests__/renderer/components/mapEditor/traversalMode.test.ts create mode 100644 src/__tests__/renderer/lib/mapViewer.test.ts create mode 100644 src/__tests__/renderer/test-utils/palette.ts create mode 100644 src/__tests__/renderer/test-utils/soldier.ts create mode 100644 src/__tests__/stubs/ja2-map-editor.ts create mode 100644 src/renderer/components/common/HelpIcon.tsx create mode 100644 src/renderer/components/content/NewMapDialog.tsx create mode 100644 src/renderer/components/content/RadarMapPreview.tsx create mode 100644 src/renderer/components/content/TacticalMapViewer.tsx create mode 100644 src/renderer/components/content/TacticalMaps.tsx create mode 100644 src/renderer/components/content/mapEditor/BrushControls.tsx create mode 100644 src/renderer/components/content/mapEditor/BuildingPanel.tsx create mode 100644 src/renderer/components/content/mapEditor/DoorLockModal.tsx create mode 100644 src/renderer/components/content/mapEditor/EraseControl.tsx create mode 100644 src/renderer/components/content/mapEditor/ItemStatsEditor.tsx create mode 100644 src/renderer/components/content/mapEditor/ItemsPanel.tsx create mode 100644 src/renderer/components/content/mapEditor/LightsPanel.tsx create mode 100644 src/renderer/components/content/mapEditor/MapEditorSidebar.tsx create mode 100644 src/renderer/components/content/mapEditor/OverviewPanel.tsx create mode 100644 src/renderer/components/content/mapEditor/SoldierAppearanceTab.tsx create mode 100644 src/renderer/components/content/mapEditor/SoldierAttributesTab.tsx create mode 100644 src/renderer/components/content/mapEditor/SoldierEditor.tsx create mode 100644 src/renderer/components/content/mapEditor/SoldierGeneralTab.tsx create mode 100644 src/renderer/components/content/mapEditor/SoldierInventoryTab.tsx create mode 100644 src/renderer/components/content/mapEditor/SoldierProfileSelect.tsx create mode 100644 src/renderer/components/content/mapEditor/SoldierScheduleTab.tsx create mode 100644 src/renderer/components/content/mapEditor/SoldiersPanel.tsx create mode 100644 src/renderer/components/content/mapEditor/TerrainPanel.tsx create mode 100644 src/renderer/components/content/mapEditor/TextureSwatches.tsx create mode 100644 src/renderer/components/content/mapEditor/TilePalette.tsx create mode 100644 src/renderer/components/content/mapEditor/ToolSelect.tsx create mode 100644 src/renderer/components/content/mapEditor/TraversalPanel.tsx create mode 100644 src/renderer/components/content/mapEditor/buildingMode.ts create mode 100644 src/renderer/components/content/mapEditor/itemData.ts create mode 100644 src/renderer/components/content/mapEditor/itemFields.ts create mode 100644 src/renderer/components/content/mapEditor/itemMode.ts create mode 100644 src/renderer/components/content/mapEditor/lightMode.ts create mode 100644 src/renderer/components/content/mapEditor/lockTable.ts create mode 100644 src/renderer/components/content/mapEditor/soldierData.ts create mode 100644 src/renderer/components/content/mapEditor/soldierEdit.ts create mode 100644 src/renderer/components/content/mapEditor/soldierMode.ts create mode 100644 src/renderer/components/content/mapEditor/terrainMode.ts create mode 100644 src/renderer/components/content/mapEditor/traversalMode.ts create mode 100644 src/renderer/lib/ja2-map-editor.d.ts create mode 100644 src/renderer/lib/mapViewer.ts diff --git a/.erb/configs/webpack.config.base.mts b/.erb/configs/webpack.config.base.mts index c40c8d2..d32576e 100644 --- a/.erb/configs/webpack.config.base.mts +++ b/.erb/configs/webpack.config.base.mts @@ -54,6 +54,10 @@ const configuration: webpack.Configuration = { resolve: { extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], modules: [webpackPaths.srcPath, 'node_modules'], + alias: { + // wasm-bindgen glue of the tactical map viewer, see src/renderer/lib/mapViewer.ts + 'ja2-map-editor$': webpackPaths.mapEditorGluePath, + }, }, plugins: [ diff --git a/.erb/configs/webpack.config.renderer.prod.mts b/.erb/configs/webpack.config.renderer.prod.mts index 6cec760..43bd8e5 100644 --- a/.erb/configs/webpack.config.renderer.prod.mts +++ b/.erb/configs/webpack.config.renderer.prod.mts @@ -71,6 +71,11 @@ const configuration: webpack.Configuration = { test: /\.(png|jpg|jpeg|gif)$/i, type: 'asset/resource', }, + // WebAssembly, loaded by the wasm-bindgen glue of the tactical map viewer + { + test: /\.wasm$/, + type: 'asset/resource', + }, // SVG { test: /\.svg$/, diff --git a/.erb/configs/webpack.module.renderer.mts b/.erb/configs/webpack.module.renderer.mts index 46112ee..533c9ec 100644 --- a/.erb/configs/webpack.module.renderer.mts +++ b/.erb/configs/webpack.module.renderer.mts @@ -34,6 +34,11 @@ const rendererModuleConfig: webpack.ModuleOptions = { test: /\.(png|jpg|jpeg|gif)$/i, type: 'asset/resource', }, + // WebAssembly, loaded by the wasm-bindgen glue of the tactical map viewer + { + test: /\.wasm$/, + type: 'asset/resource', + }, // SVG { test: /\.svg$/, diff --git a/.erb/configs/webpack.paths.mts b/.erb/configs/webpack.paths.mts index bb3886a..6fee833 100644 --- a/.erb/configs/webpack.paths.mts +++ b/.erb/configs/webpack.paths.mts @@ -25,6 +25,13 @@ const distRendererPath = path.join(distPath, 'renderer'); const buildPath = path.join(releasePath, 'build'); +const mapEditorGluePath = path.join( + rootPath, + 'src-map-editor', + 'pkg', + 'ja2_map_editor.js', +); + export default { rootPath, erbNodeModulesPath, @@ -41,4 +48,5 @@ export default { distMainPath, distRendererPath, buildPath, + mapEditorGluePath, }; diff --git a/.erb/scripts/build-map-editor.mts b/.erb/scripts/build-map-editor.mts new file mode 100644 index 0000000..556d8b7 --- /dev/null +++ b/.erb/scripts/build-map-editor.mts @@ -0,0 +1,77 @@ +import { execFileSync, execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import chalk from 'chalk'; +import webpackPaths from '../configs/webpack.paths.mts'; + +// Builds the tactical map viewer (src-map-editor) to wasm and generates the JS glue in src-map-editor/pkg +// Pass `--dist` to build a smaller wasm with LTO for packaging, which takes much longer + +const profile = process.argv.includes('--dist') ? 'wasm-dist' : 'release'; +const mapEditorPath = path.join(webpackPaths.rootPath, 'src-map-editor'); +const outDir = path.join(mapEditorPath, 'pkg'); +const wasmPath = path.join( + webpackPaths.rootPath, + 'target', + 'wasm32-unknown-unknown', + profile, + 'ja2_map_editor.wasm', +); + +// The wasm-bindgen CLI has to match the version of the wasm-bindgen crate exactly +const cargoToml = fs.readFileSync( + path.join(mapEditorPath, 'Cargo.toml'), + 'utf8', +); +const pinnedVersion = cargoToml.match(/^wasm-bindgen = "=([^"]+)"/m)?.[1]; +if (!pinnedVersion) { + throw new Error('Could not find pinned wasm-bindgen version in Cargo.toml'); +} + +function wasmBindgenVersion(command: string): string | null { + try { + const output = execFileSync(command, ['--version'], { encoding: 'utf8' }); + return output.trim().split(' ')[1] ?? null; + } catch { + return null; + } +} + +// `cargo install` puts binaries into $CARGO_HOME/bin, which is not always on the PATH +const exe = process.platform === 'win32' ? '.exe' : ''; +const cargoHome = process.env.CARGO_HOME ?? path.join(os.homedir(), '.cargo'); +const candidates = [ + `wasm-bindgen${exe}`, + path.join(cargoHome, 'bin', `wasm-bindgen${exe}`), +]; +const wasmBindgen = candidates.find( + (candidate) => wasmBindgenVersion(candidate) === pinnedVersion, +); +if (!wasmBindgen) { + const found = candidates + .map((candidate) => wasmBindgenVersion(candidate)) + .find((version) => version !== null); + console.error( + chalk.whiteBright.bgRed.bold( + `wasm-bindgen ${pinnedVersion} is required to build the map editor${found ? `, found ${found}` : ''}. Install it with:`, + ), + ); + console.error( + `cargo install wasm-bindgen-cli --version ${pinnedVersion} --locked`, + ); + process.exit(1); +} + +execSync( + // Always pass `-p`, the Neon addon cannot be built for wasm + `cargo build --profile ${profile} -p ja2-map-editor --target wasm32-unknown-unknown`, + { + cwd: webpackPaths.rootPath, + stdio: 'inherit', + }, +); +execFileSync(wasmBindgen, ['--target', 'web', '--out-dir', outDir, wasmPath], { + cwd: webpackPaths.rootPath, + stdio: 'inherit', +}); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b69a92c..f148c8e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,11 @@ jobs: node-version: 22 cache: npm + # Needed to build the tactical map viewer, must match the version in src-map-editor/Cargo.toml + - name: Install wasm-bindgen CLI + run: | + cargo install wasm-bindgen-cli --version 0.2.129 --locked + - name: npm install run: | npm install diff --git a/.gitignore b/.gitignore index 7c3ab05..92800bf 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ npm-debug.log.* *.css.d.ts *.sass.d.ts *.scss.d.ts +src-map-editor/pkg diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..de0c8f9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Electron desktop app (React 19 + Ant Design + Redux Toolkit) for creating mods for JA2 Stracciatella. Most heavy lifting (file I/O, VFS, schemas, image/sound decoding, mod management) is done in a Rust native module (`src-rust/`, built with Neon) that depends on the `stracciatella` crate from the main game repo, pinned to a git rev in `src-rust/Cargo.toml`. The tactical map viewer (`src-map-editor/`, Bevy compiled to wasm, see its README) runs inside the renderer. Both crates are members of the Cargo workspace in the repo root and share `Cargo.lock`, the `stracciatella` dependency and the toolchain (`rust-toolchain.toml`). The build is based on electron-react-boilerplate; webpack configs and helper scripts live in `.erb/`. + +## Commands + +- `npm install`: install deps (postinstall builds the renderer DLL bundle) +- `npm start`: dev mode (rebuilds Rust, builds main, serves renderer with hot reload; electronmon restarts on `src/main/**` changes) +- `npm run rebuild:rust`: `cargo build --release` in `src-rust/`, then copies the cdylib to `src-rust/target/stracciatella-toolset.node`. Rerun this after any Rust change. +- `npm run rebuild:map-editor`: builds the map viewer wasm and its wasm-bindgen glue into `src-map-editor/pkg/` (needs the pinned `wasm-bindgen-cli`). Rerun this after any change in `src-map-editor/`. `prestart` and `build:renderer` run it. +- `npm run tsc:check`: typecheck the app and `.erb/` +- `npm run lint`: ESLint with Prettier (single quotes) +- `npm test` / `npm run test:watch`: Vitest (jsdom) +- Single test: `npx cross-env NODE_ENV=development vitest run src/__tests__/renderer/state/files.test.tsx` (add `-t ""` to filter) +- `npm run package`: build and package with electron-builder + +CI (`.github/workflows/test.yml`) runs lint, tsc:check, test and package on macOS, Windows and Linux. + +## Architecture + +### IPC "invokables" (the core pattern) + +All renderer → backend calls go through a single IPC channel (`invoke`) with a `{ name, input }` payload, where names look like `category/action` (e.g. `json/read`). + +1. **Definition** (`src/common/invokables/.ts`): an `InvokableDefinition` with zod `inputSchema` / `outputSchema`. It must be added to `ALL_INVOKABLES` in `src/common/invokables/index.ts`, which drives the typing of `invoke()`. +2. **Renderer** (`src/renderer/lib/invoke.tsx`): `invoke(name, input)` calls `window.electronAPI.invoke` (exposed in `src/main/preload.ts`) and validates the result against `outputSchema`. +3. **Main** (`src/main/invokables/index.ts`, `handleInvoke`): first checks the JS invokables in `src/main/invokables/` (Electron-only things such as dialogs and window close). Anything else is forwarded as JSON (`{ func, params }`) to the Rust `invoke`. +4. **Rust** (`src-rust/src/invokables/`): each invokable is a serde-deserializable struct implementing the `Invokable` trait (`name()`, optional `validate()`, `invoke(&AppState)`). It must be registered in `Invokables::new()` in `invokables/mod.rs`. It runs on a Neon task thread and returns a JS promise. + +A new backend call therefore touches the common definition, `ALL_INVOKABLES`, and either a main-process JS invokable or a Rust struct plus its registration. Rust and TS schemas are kept in sync by hand. + +Main → renderer events work the same way: definitions live in `src/common/events/`, main sends with `sendEventToRenderer` (`src/main/events.ts`) on channel `event/`, and the renderer subscribes via the `Listen` components. + +### Rust state + +`AppState` (`src-rust/src/state.rs`) is created once through `newAppState` and passed as a boxed handle on every invoke. It holds the toolset config (read from the user config dir, `config.rs`) and the currently opened mod (`OpenedMod`: a `stracciatella` `Vfs` layered over vanilla data plus the mod). JSON schemas and vanilla data come from the `stracciatella` crate's `SchemaManager` and VFS. + +### JSON editing model + +- A mod can override a vanilla JSON file with a full replacement (`foo.json`) or a JSON patch (`foo.patch.json`, RFC 6902). `json/read` returns `schema`, `vanilla`, and the mod's `value` or `patch`. `json/persist` writes one of the two. +- `src/renderer/state/files.tsx` (Redux slice) tracks `disk` (loaded data, wrapped in `Persistable`/loadable) and `open` (editor buffers). Each open file has a `saveMode` (`patch` | `replace`) and an `editMode` (`visual` | `text`). In visual patch mode, the patch is generated with `fast-json-patch` `compare` against vanilla on save. +- Components read and write state through the many small `useFile*` hooks in `src/renderer/hooks/`, not directly through the store. + +### Editor UI + +`src/renderer/EditorRoutes.tsx` defines the sidebar `MENU`: each JSON file is mapped via `makeFileItem` to an editor component (`JsonForm`, `JsonItemsForm`, `JsonStrategicMapForm`, or custom forms) plus an RJSF `uiSchema`. Forms are rendered with `@rjsf/antd` from the schema supplied by Rust. Cross-references between files use custom widgets in `components/visual/form/` (`StringReferenceWidget`, `ResourceReferenceWidget`, sector selectors). Adding support for a new JSON file usually means adding a `makeFileItem` entry here. + +### Tests + +Renderer tests live in `src/__tests__/renderer/`. `window.electronAPI` is stubbed with `vi.fn()` in `src/__tests__/vitest.setup.ts`. Use `InvokeMock` (`test-utils/invoke.tsx`) to declare expected invoke calls and their results, and `renderWithTestProviders` (`test-utils/render.tsx`) to render with a fresh Redux store. Unexpected invoke calls throw. The map viewer has Rust unit tests (`cargo test -p ja2-map-editor`), which run natively; the Neon module has none. + +## Project rules (from `.rules`) + +- Don't add new dependencies unless asked to. +- Run typechecking, linting, building and tests before committing. Only commit to the current branch. +- Run `npm install` after changing dependencies in `package.json`. +- React: use `useMemo` / `useCallback` for performance. +- Rust: return errors (`anyhow`) instead of panicking. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..89bbf3d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,8328 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_windows", + "raw-window-handle 0.6.2", + "winit", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.3", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum 0.7.6", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86cd1c51b95d71dde52bca69ed225008f6ff4c8cc825b08042aa1ef823e1980" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.0", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "assert_type_match" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.0", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomicow" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301801c08259e328a1c7da556608c0c22687708831b22024dbd3a57ea741e6de" +dependencies = [ + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bevy" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd0e4ea5ec677698955f9f285221eab78606d8c27b57b889def199c16ce36e6" +dependencies = [ + "bevy_internal", +] + +[[package]] +name = "bevy_a11y" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1070b9b7f087a5073be2da713367f29a831c85cd3391afcfc95bc0498125704" +dependencies = [ + "accesskit", + "bevy_app", + "bevy_derive", + "bevy_ecs", + "bevy_reflect", +] + +[[package]] +name = "bevy_android" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17fd56a4708085b36f8aa1a011e347f8bd9334f0343bb3d5d9c3742897968a55" +dependencies = [ + "android-activity", +] + +[[package]] +name = "bevy_animation" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9409aa300e8b023281b78759b3aed4124ec745dc7e1c9f96cbb445110e4b32ad" +dependencies = [ + "bevy_animation_macros", + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_curve", + "bevy_derive", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bitvec", + "blake3", + "derive_more", + "downcast-rs 2.0.2", + "either", + "petgraph", + "ron", + "serde", + "smallvec", + "thiserror 2.0.21", + "thread_local", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_animation_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "011144ef75ba95b665b1e6b03f3b7bbe9aa85d55472abb1d21e64813dac1c0a4" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_anti_alias" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4df27fadf88e6ff72bf63a5e80e3921536eabfcde3196db347c31b6bbb39606" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_math", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_utils", + "tracing", +] + +[[package]] +name = "bevy_app" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2489da2972d57cccd19060d7faa44c4c4cfb296a657c84ee6ab8bde2b1c0115" +dependencies = [ + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "console_error_panic_hook", + "ctrlc", + "downcast-rs 2.0.2", + "log", + "thiserror 2.0.21", + "variadics_please", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "bevy_asset" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b88b3767a2c3f5a017b2febca08a670819ea2acb46846e4d8cd02980aeb0ed3e" +dependencies = [ + "async-broadcast", + "async-channel", + "async-fs", + "async-io", + "async-lock", + "atomicow", + "bevy_android", + "bevy_app", + "bevy_asset_macros", + "bevy_diagnostic", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.13.2", + "blake3", + "crossbeam-channel", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "either", + "futures-io", + "futures-lite", + "futures-util", + "js-sys", + "ron", + "serde", + "stackfuture", + "thiserror 2.0.21", + "tracing", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "bevy_asset_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875b4fd236612744dbd61fb0a77ffde0aee37b8b780cf8a89caa308511c70646" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_audio" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16f2ac779d6ce5fdcc951c314141d37deff54cef213197872054259fc3a031" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_ecs", + "bevy_math", + "bevy_reflect", + "bevy_transform", + "rodio", + "tracing", +] + +[[package]] +name = "bevy_camera" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32c0d8436fb9373dc3b64fa30c3dec51d6a7cfda61de3c70fd1f62f8a903887" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_shape", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "downcast-rs 2.0.2", + "serde", + "smallvec", + "thiserror 2.0.21", + "wgpu-types", +] + +[[package]] +name = "bevy_clipboard" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0267b0beafd7e587a1b25b5ce14b271577f5fc1d8fd7828b2b66f0cf5dd649b" +dependencies = [ + "arboard", + "bevy_app", + "bevy_ecs", + "bevy_log", + "bevy_platform", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "bevy_color" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e57bfc1a0da1c018235fb98bb0e15ab61f1d0253122020ab6e1450e6cfbcf9" +dependencies = [ + "bevy_curve", + "bevy_math", + "bevy_reflect", + "bytemuck", + "derive_more", + "encase", + "serde", + "thiserror 2.0.21", + "wgpu-types", +] + +[[package]] +name = "bevy_core_pipeline" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a077b301be4b373a4cb224c54988e1f8385ecf3ab9e60bd3727a65b3d6901a" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.2", + "indexmap", + "nonmax", +] + +[[package]] +name = "bevy_curve" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ae28ed95c1c8a1bfc8930471840ee5a4b99b6b31138b17598e7ee6f4291b64" +dependencies = [ + "approx", + "bevy_math", + "bevy_reflect", + "itertools 0.14.0", + "serde", + "thiserror 2.0.21", + "variadics_please", +] + +[[package]] +name = "bevy_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd59e9e600d792e60df17b4d52719b28c288bf047884ac89ad29d9c7a6592d2c" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_dev_tools" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c166963d672c45b34cf71f2cab3df627ec61f7ed06df451f008c9519f87e2e34" +dependencies = [ + "bevy_animation", + "bevy_app", + "bevy_asset", + "bevy_audio", + "bevy_camera", + "bevy_color", + "bevy_diagnostic", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_input", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_state", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_widgets", + "bevy_window", + "bevy_world_serialization", + "tracing", +] + +[[package]] +name = "bevy_diagnostic" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8851dc1b253b350bf941695457007e6e44ad4b8f075bf52a1ef202e6866b6a" +dependencies = [ + "atomic-waker", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_tasks", + "bevy_time", + "const-fnv1a-hash", + "log", + "serde", + "sysinfo", +] + +[[package]] +name = "bevy_ecs" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5541a1658c5029b39e3ec59c6ecb47ad0a6d85fa083312a5ee2a95d6982a926" +dependencies = [ + "arrayvec", + "bevy_ecs_macros", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.13.2", + "bumpalo", + "concurrent-queue", + "derive_more", + "fixedbitset", + "indexmap", + "log", + "nonmax", + "serde", + "slotmap", + "smallvec", + "thiserror 2.0.21", + "variadics_please", +] + +[[package]] +name = "bevy_ecs_macro_logic" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5197fea7643d2733e8b6d81c3dfa99adad3a754cdfd159c00255cea157b795d7" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_ecs_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dde5305169503c9101404bf8cf17e8351f56fccf5152b8e5d7d7c32dcf1c486" +dependencies = [ + "bevy_ecs_macro_logic", + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_encase_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e4c4183c39ae8d00e53afeeab66aa71c41b08d82638149f9d485a2a62df569" +dependencies = [ + "bevy_macro_utils", + "encase_derive_impl", +] + +[[package]] +name = "bevy_extract" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08e27fc07ef8e3ab7c217fed1b171d42c90f3b6e4f7f526ffa28f9006ac49aa4" +dependencies = [ + "bevy_app", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_extract_macros", + "bevy_log", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_utils", +] + +[[package]] +name = "bevy_extract_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a5c67e8c55ef35c65295e8cb614eda169780e21e2748f6df470d3bff21849a3" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_feathers" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c4566f226580b5d852e3fa4fdb821e45367f598c0c6090896d686e446fa91f4" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_scene", + "bevy_shader", + "bevy_text", + "bevy_ui", + "bevy_ui_render", + "bevy_ui_widgets", + "bevy_window", + "smol_str", +] + +[[package]] +name = "bevy_gilrs" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a8c6c22d01a7b2e949ac1be313fffca62a23ae0b802a884f009e6dc70a4cea" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_platform", + "bevy_time", + "gilrs", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "bevy_gizmos" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc92f635ec5eaef5733afee45e6a0452e8c65de42d818dc7269dc5265087689" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_curve", + "bevy_ecs", + "bevy_gizmos_macros", + "bevy_input", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_shape", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", +] + +[[package]] +name = "bevy_gizmos_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501e72c4ac31caef17ef9e627dee212d2107b95402a891ec6f0fc4107dd0cdf4" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_gizmos_render" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5be0c9f114dc9b2c929076517d088e059b971edec5dfff4d5a225df6796df" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_ecs", + "bevy_extract", + "bevy_gizmos", + "bevy_image", + "bevy_log", + "bevy_material", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_shape", + "bevy_sprite_render", + "bevy_transform", + "bevy_utils", + "bytemuck", + "tracing", +] + +[[package]] +name = "bevy_image" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb818dd8d705809d54adea08e346edce81a51ddbf06406ddf12a72ee467a3855" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "bitflags 2.13.2", + "bytemuck", + "futures-lite", + "guillotiere", + "half", + "image 0.25.10", + "rectangle-pack", + "serde", + "thiserror 2.0.21", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_input" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261ca5cc8a0bf228bfe4e841570db18a3ab1d034ce089772b8ec49c163ae74ea" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "derive_more", + "log", + "smol_str", + "thiserror 2.0.21", +] + +[[package]] +name = "bevy_input_focus" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c81d369d0ab3ec17ba14bab1e07da7cacf3751a3104a93b1a3cd99d887887b" +dependencies = [ + "bevy_app", + "bevy_camera", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_window", + "log", + "thiserror 2.0.21", +] + +[[package]] +name = "bevy_internal" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ed1cb58d1761426a077a8aff55e6a2e0cdfd19e411211d4b0217409330bb84" +dependencies = [ + "bevy_a11y", + "bevy_android", + "bevy_animation", + "bevy_anti_alias", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_clipboard", + "bevy_color", + "bevy_core_pipeline", + "bevy_curve", + "bevy_derive", + "bevy_dev_tools", + "bevy_diagnostic", + "bevy_ecs", + "bevy_extract", + "bevy_feathers", + "bevy_gilrs", + "bevy_gizmos", + "bevy_gizmos_render", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_light", + "bevy_log", + "bevy_material", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_picking", + "bevy_platform", + "bevy_post_process", + "bevy_ptr", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_shape", + "bevy_sprite", + "bevy_sprite_render", + "bevy_state", + "bevy_tasks", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_render", + "bevy_utils", + "bevy_window", + "bevy_winit", +] + +[[package]] +name = "bevy_light" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529d5ec7db637bbce7d016b2bfd0798362ad67118de8e9d37aaac24ecb3337cd" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_curve", + "bevy_ecs", + "bevy_gizmos", + "bevy_image", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_shape", + "bevy_transform", + "bevy_utils", + "half", + "smallvec", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_log" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a87d5152292b0e581a589cffb6b7e83e0afe345279d4126545ce7b786bd8a47a" +dependencies = [ + "android_log-sys", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_utils", + "tracing", + "tracing-log", + "tracing-oslog", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "bevy_macro_utils" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d03a948df65138e30917d91237797d7e38763135ebacd0f28c891e1a3b2b52" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "toml_edit 0.25.6+spec-1.1.0", +] + +[[package]] +name = "bevy_material" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be7ee4b2f4764e8d58a59afb47a3ba8bae36f955f409f08c658cace2808ef40" +dependencies = [ + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_material_macros", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_shader", + "bevy_utils", + "encase", + "smallvec", + "thiserror 2.0.21", + "tracing", + "variadics_please", + "wgpu-types", +] + +[[package]] +name = "bevy_material_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70551fa201e1416ab44968631b7ce6320c194acfdb652a1971ec6882af122333" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_math" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598cbdcec4d0f0d966905996240ac76f77a644c583f95fb612d4f59f5f649038" +dependencies = [ + "approx", + "bevy_reflect", + "derive_more", + "glam", + "libm", + "rand", + "serde", + "thiserror 2.0.21", + "variadics_please", +] + +[[package]] +name = "bevy_mesh" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09358ac9a3ede5855568c10eaba6bcf126bf86639e786fb21b413457ebec5f26" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_encase_derive", + "bevy_math", + "bevy_mikktspace", + "bevy_platform", + "bevy_reflect", + "bevy_shape", + "bevy_transform", + "bitflags 2.13.2", + "bytemuck", + "derive_more", + "encase", + "glam", + "half", + "hexasphere", + "thiserror 2.0.21", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_mikktspace" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bff34eb29ff4b8a8688bc7299f14fb6b597461ca80fec03ed7d22939ab33e48f" + +[[package]] +name = "bevy_pbr" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb760932d1e683e7c00011f5c99e4e1cc893646a3a7b7224da6daf9bfa94853d" +dependencies = [ + "arrayvec", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_light", + "bevy_log", + "bevy_material", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_shape", + "bevy_tasks", + "bevy_transform", + "bevy_utils", + "bitflags 2.13.2", + "bytemuck", + "derive_more", + "fixedbitset", + "indexmap", + "nonmax", + "offset-allocator", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_picking" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a04fb644c62a57a4f338f5cd8b61519c569139742eafe87dccb523e4b7cf2e" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_shape", + "bevy_time", + "bevy_transform", + "bevy_window", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_platform" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000bc467d1b344b6609fcf217a9b08d349d49c8e85dc4179fb7656f2b6e3db47" +dependencies = [ + "bytemuck", + "critical-section", + "foldhash 0.2.0", + "futures-channel", + "futures-lite", + "hashbrown 0.16.1", + "js-sys", + "portable-atomic", + "portable-atomic-util", + "serde", + "spin", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", + "windows-sys 0.61.0", +] + +[[package]] +name = "bevy_post_process" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d684c2ac93e94d9b7ba767ce02ea435ef244677a24746241726de2aa552ba1a4" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_curve", + "bevy_derive", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_utils", + "smallvec", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "bevy_ptr" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41935a00c02004753bd7ed5094754397800b3f673bf15553bb007b06216872d5" + +[[package]] +name = "bevy_reflect" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eecc733575b873deedcc37639e36290092388657091b3321fa6e073e7818e6" +dependencies = [ + "assert_type_match", + "bevy_platform", + "bevy_ptr", + "bevy_reflect_derive", + "bevy_utils", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "erased-serde", + "foldhash 0.2.0", + "glam", + "indexmap", + "inventory", + "petgraph", + "serde", + "smallvec", + "smol_str", + "thiserror 2.0.21", + "uuid", + "variadics_please", + "wgpu-types", +] + +[[package]] +name = "bevy_reflect_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfaa1e315676c625ed8d30ba8a671ebf917cad6355320295b9802a8cbc06853b" +dependencies = [ + "bevy_macro_utils", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "bevy_render" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f438a921fd3ef82cc09ccf5e0cd8f1214dcae6286890f9d4acba0409d9a61a8" +dependencies = [ + "async-channel", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_encase_derive", + "bevy_extract", + "bevy_extract_macros", + "bevy_image", + "bevy_log", + "bevy_material", + "bevy_material_macros", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render_macros", + "bevy_shader", + "bevy_shape", + "bevy_tasks", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.2", + "bytemuck", + "derive_more", + "downcast-rs 2.0.2", + "encase", + "glam", + "image 0.25.10", + "indexmap", + "itertools 0.14.0", + "js-sys", + "nonmax", + "offset-allocator", + "send_wrapper", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "tracing", + "variadics_please", + "wasm-bindgen", + "weak-table", + "web-sys", + "wgpu", + "wgpu-types", +] + +[[package]] +name = "bevy_render_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119ca6d445eba5c9976c658f29bac854094b27c27624aa92c4d9668c319618d0" +dependencies = [ + "bevy_extract_macros", + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_scene" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98c4b1677c948e4c1453883f4441136003c53fd9caa44e0f7d78b37618bdb9ad" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_log", + "bevy_platform", + "bevy_reflect", + "bevy_scene_macros", + "bevy_utils", + "smallvec", + "thiserror 2.0.21", + "tracing", + "variadics_please", +] + +[[package]] +name = "bevy_scene_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4da846d8418ca6e38943707c88075a4a0b8f6eea0fd286d33a024d0257439d0" +dependencies = [ + "bevy_ecs_macro_logic", + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_shader" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2becafd54079da13835d6a42fc743e305d6270c27a91de613afe6e3f4b5de19" +dependencies = [ + "bevy_asset", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "serde", + "thiserror 2.0.21", + "tracing", + "wesl", + "wgpu-types", +] + +[[package]] +name = "bevy_shape" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8b6096ad1ec7dfe1af8e51d7cfa7de1a9e0a3bd02be58a061092c5e1ff1eb1" +dependencies = [ + "arrayvec", + "bevy_math", + "bevy_reflect", + "derive_more", + "rand", + "rand_distr", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "bevy_sprite" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24dc4f69160513952a1969a3df5b4a2c729acfe0caf773854acea4a8c71f0918" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_text", + "bevy_transform", + "bevy_window", + "radsort", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_sprite_render" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8918649806697740b61d3fd0559cb8c7de2ade5046aacf8abbef20e49ea1de0c" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_material", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_shape", + "bevy_sprite", + "bevy_text", + "bevy_transform", + "bevy_utils", + "bitflags 2.13.2", + "bytemuck", + "derive_more", + "fixedbitset", + "nonmax", + "smallvec", + "static_assertions", + "tracing", +] + +[[package]] +name = "bevy_state" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09292e08e1eb34d2dd915630fb3c04ba75220c7819943c9f213d3a5f9a8be1e8" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_state_macros", + "bevy_utils", + "log", + "variadics_please", +] + +[[package]] +name = "bevy_state_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec1a8f6a253e39ddf7e447b1b9286caacb205ed8e619e6fb9a2e010d58dd04b" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_tasks" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbd8116f12dc50bde3758a72fba85e1f4c00e6c66d0ad58bdc46c0c0690829a8" +dependencies = [ + "async-channel", + "async-executor", + "async-task", + "atomic-waker", + "bevy_platform", + "concurrent-queue", + "crossbeam-queue", + "derive_more", + "futures-lite", + "heapless", + "web-task", +] + +[[package]] +name = "bevy_text" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119d2b37ce3f5ea5a5b9092a7c9eec8d3734de62592d2869bd1d925976f34d05" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_clipboard", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "parley", + "serde", + "smallvec", + "smol_str", + "swash", + "sys-locale", + "thiserror 2.0.21", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_time" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13901174b792bb7848d8e088075f0009ea409b99179934c0d5bdd2b95622694e" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "crossbeam-channel", + "log", + "serde", +] + +[[package]] +name = "bevy_transform" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83379023d77a0739569a5bcb859eed652781a1b3271e94a86dfbb56fc5d067e0" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_math", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "derive_more", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "bevy_ui" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7926fba78f133376db0cba73a054db6931952379b5bfdc63f2fe9ba0c3b96c39" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_sprite", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "parley", + "smallvec", + "swash", + "taffy", + "thiserror 2.0.21", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_ui_render" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "620b51b51eb8e7d8b2362ba96263142829560d9aa254e2f28032570667c6b0fb" +dependencies = [ + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_input_focus", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_sprite_render", + "bevy_text", + "bevy_transform", + "bevy_ui", + "bevy_utils", + "bytemuck", + "derive_more", + "indexmap", + "smallvec", + "tracing", +] + +[[package]] +name = "bevy_ui_widgets" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442cd402df6a54f0e51ed8c1bd004c8f9c6715d0f8045e33c68ea9aebb1ce16" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_camera", + "bevy_ecs", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_text", + "bevy_time", + "bevy_ui", + "bevy_window", + "parley", + "smol_str", + "web-sys", +] + +[[package]] +name = "bevy_utils" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cbd58495039d4e63e6fcfef1242fecab33ffe62b4a5e010467e69e790879812" +dependencies = [ + "async-channel", + "bevy_platform", + "disqualified", + "indexmap", + "thread_local", +] + +[[package]] +name = "bevy_window" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc9a83f3cbaa04155b5dc9b247819c0e215aee754451a4324b82ddeb80dc53f1" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "log", + "raw-window-handle 0.6.2", + "serde", +] + +[[package]] +name = "bevy_winit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ef0a5a55524e793ab13536ef244229fa66a4839fae6564df2aa4b3c13956a82" +dependencies = [ + "accesskit", + "accesskit_winit", + "approx", + "bevy_a11y", + "bevy_android", + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_window", + "bytemuck", + "js-sys", + "tracing", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winit", +] + +[[package]] +name = "bevy_world_serialization" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef3a23ed3899c88467ce27a0420a1dadeb543981c2e1b9617f6ecf6e5fce1bf" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "derive_more", + "ron", + "serde", + "thiserror 2.0.21", + "uuid", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "bytemuck", + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.4.2", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "cc" +version = "1.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.0", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", + "portable-atomic", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-fnv1a-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" + +[[package]] +name = "const_panic" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3" +dependencies = [ + "typewit", +] + +[[package]] +name = "const_soft_float" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ca1caa64ef4ed453e68bb3db612e51cf1b2f5b871337f0fcab1c8f87cc3dff" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "constgebra" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aaf9b65849a68662ac6c0810c8893a765c960b907dd7cfab9c4a50bf764fbc" +dependencies = [ + "const_soft_float", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk 0.9.0", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.0", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "deflate" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73770f8e1fe7d64df17ca66ad28994a0a623ea497fa69486e14984e715c5d174" +dependencies = [ + "adler32", + "byteorder", +] + +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "disqualified" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e90a5dd66beeca277ae954f8055b02a712792b6b6b43f2bc2f92b0b7d8291614" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encase" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1acbc46b960480c4959213714511e22f9a2e0d361520693bcf5057b5924f53e" +dependencies = [ + "const_panic", + "encase_derive", + "thiserror 2.0.21", +] + +[[package]] +name = "encase_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e30d38c47336a0afbdfde297b442944041cf6dccbb1a79eceea16abfb27a92a" +dependencies = [ + "encase_derive_impl", +] + +[[package]] +name = "encase_derive_impl" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495d24abe406f090f2ba8fc362ecaa8a001e192522aa75eae7b144163c3235ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.0", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0678ab2d46fa5195aaf59ad034c083d351377d4af57f3e073c074d0da3e3c766" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "libz-rs-sys", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "parlance", + "read-fonts 0.41.0", + "smallvec", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa5de57a62c2440ece64342ea59efb7171aa7d016faf8dfcb8795066a17146b" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" +dependencies = [ + "rustix 1.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3edd93c6756b4dfaf2709eafcc345ba2636565295c198a9cfbf75fa5e3e00b06" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gilrs" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.33.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37bfe73dc8ec21f54d181e5a60554327ec3f1c600c455a75ccbfc65d2106a6c4" +dependencies = [ + "approx", + "bytemuck", + "encase", + "libm", + "rand", + "serde_core", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "bytemuck", + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts 0.41.0", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "portable-atomic", + "stable_deref_trait", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexasphere" +version = "19.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163385eff5a0cf606ebe2c10b3a44ab02f4306e83fbd068a64f5e1d72a3049ac" +dependencies = [ + "constgebra", + "glam", + "tinyvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.23.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ffcb7e7244a9bf19d35bf2883b9c080c4ced3c07a9895572178cdb8f13f6a1" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif", + "jpeg-decoder", + "num-iter", + "num-rational 0.3.2", + "num-traits", + "png 0.16.8", + "scoped_threadpool", + "tiff", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "iso8601" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296af15e112ec6dc38c9fd3ae027b5337a75466e8eed757bd7d5cf742ea85eb6" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "ja2-map-editor" +version = "0.0.1" +dependencies = [ + "bevy", + "bitflags 2.13.2", + "byteorder", + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "jni" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +dependencies = [ + "cesu8", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link 0.2.0", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" +dependencies = [ + "rayon", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1fb8864823fad91877e6caea0baca82e49e8db50f8e5c9f9a453e27d3330fc" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "json_comments" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dbbfed4e59ba9750e15ba154fdfd9329cee16ff3df539c2666b70f58cc32105" + +[[package]] +name = "jsonptr" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6e529149475ca0b2820835d3dce8fcc41c6b943ca608d32f35b449255e4627" +dependencies = [ + "fluent-uri", + "serde", + "serde_json", +] + +[[package]] +name = "jsonschema" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ca9e2b45609132ae2214d50482c03aeee78826cd6fd53a8940915b81acedf16" +dependencies = [ + "ahash 0.8.12", + "anyhow", + "base64 0.13.1", + "bytecount", + "fancy-regex", + "fraction", + "iso8601", + "itoa", + "lazy_static", + "memchr", + "num-cmp", + "parking_lot", + "percent-encoding", + "regex", + "serde", + "serde_json", + "time", + "url", + "uuid", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lalrpop-util" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884f3e747ed2dcee867cda1b0c31a048f9e20de2d916a248949319921a2e666e" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lexical" +version = "7.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc8a009b2ff1f419ccc62706f04fe0ca6e67b37460513964a3dfdb919bb37d6" +dependencies = [ + "lexical-core", +] + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "351a32417a12d5f7e82c368a66781e307834dae04c6ce0cd4456d52989229883" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.0", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.13.2", + "libc", + "redox_syscall 0.5.17", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "libz-rs-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.119", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lru" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6e8aaa3f231bb4bd57b84b2d5dc3ae7f350265df8aa96492e0bc394a1571909" +dependencies = [ + "hashbrown 0.12.3", +] + +[[package]] +name = "lzma-rust2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" +dependencies = [ + "crc", + "sha2", +] + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791daaae1ed6889560f8c4359194f56648355540573244a5448a83ba1ecc7435" +dependencies = [ + "adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +dependencies = [ + "adler", + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.0", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutants" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451422b7e4718271c8b5b3aadf5adedba43dc76312454b387e98fae0fc951aa0" +dependencies = [ + "bitflags 1.3.2", + "jni-sys 0.3.0", + "ndk-sys 0.4.1+23.1.7779620", + "num_enum 0.5.11", + "raw-window-handle 0.5.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.0", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum 0.7.6", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.4.1+23.1.7779620" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf2aae958bd232cac5069850591667ad422d263686d75b52a065f9badeee5a3" +dependencies = [ + "jni-sys 0.3.0", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.0", +] + +[[package]] +name = "neon" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28e15415261d880aed48122e917a45e87bb82cf0260bb6db48bbab44b7464373" +dependencies = [ + "neon-build", + "neon-macros", + "neon-runtime", + "semver 0.9.0", + "smallvec", +] + +[[package]] +name = "neon-build" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bac98a702e71804af3dacfde41edde4a16076a7bbe889ae61e56e18c5b1c811" + +[[package]] +name = "neon-macros" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7288eac8b54af7913c60e0eb0e2a7683020dffa342ab3fd15e28f035ba897cf" +dependencies = [ + "quote", + "syn 1.0.109", + "syn-mid", +] + +[[package]] +name = "neon-runtime" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676720fa8bb32c64c3d9f49c47a47289239ec46b4bdb66d0913cc512cb0daca" +dependencies = [ + "cfg-if", + "libloading 0.6.7", + "smallvec", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational 0.4.2", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive 0.5.11", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive 0.7.6", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "offset-allocator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e234d535da3521eb95106f40f0b73483d80bfb3aacf27c40d7e2b72f1a3e00a2" +dependencies = [ + "log", + "nonmax", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.0", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.17", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "parlance" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" + +[[package]] +name = "parley" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa 0.44.0", +] + +[[package]] +name = "parley_data" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889" +dependencies = [ + "icu_properties", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", + "serde_derive", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.16.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3287920cb847dee3de33d301c463fba14dda99db24214ddf93f83d3021f4c6" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "deflate", + "miniz_oxide 0.3.7", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.0", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "serde", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "radsort" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47cf772000d7aea67a6ea51de8a21b77674aaa538f3b523d89ae6514bf45bb4" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "rectangle-pack" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d463f2884048e7153449a55166f91028d5b0ea53c79377099ce4e8cf0cf9bb" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rodio" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" +dependencies = [ + "cpal", + "dasp_sample", + "num-rational 0.4.2", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.28", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.0", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scoped_threadpool" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplelog" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1348164456f72ca0116e4538bdaabb0ddb622c7d9f16387c725af3e96d6001c" +dependencies = [ + "chrono", + "log", + "termcolor", +] + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + +[[package]] +name = "skrifa" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbe997d0f2480442d727488fbe2150779114cbe480ecdbadef58b33e0318ffb" +dependencies = [ + "bytemuck", + "read-fonts 0.40.1", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stackfuture" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115beb9c69db2393ff10b75a1b8587a51716e5551d015001e55320ed279d32f9" +dependencies = [ + "const_panic", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stracciatella" +version = "0.1.0" +source = "git+https://github.com/ja2-stracciatella/ja2-stracciatella.git?rev=11e9430b67d788b73f7a57d22166e8b500c23dab#11e9430b67d788b73f7a57d22166e8b500c23dab" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "caseless", + "digest", + "dirs", + "dunce", + "getopts", + "hex", + "jni 0.19.0", + "jni-sys 0.3.0", + "json-patch", + "json_comments", + "jsonschema", + "lazy_static", + "libc", + "log", + "lru", + "md-5", + "ndk 0.7.0", + "ndk-sys 0.4.1+23.1.7779620", + "rayon", + "regex", + "send_wrapper", + "serde", + "serde_json", + "serde_yaml", + "simplelog 0.12.2", + "slug", + "tempfile", + "unicode-normalization", + "winapi", +] + +[[package]] +name = "stracciatella-toolset" +version = "0.22.0" +dependencies = [ + "anyhow", + "base64 0.13.1", + "directories", + "image 0.23.14", + "log", + "neon", + "reqwest", + "serde", + "serde_json", + "simplelog 0.11.2", + "stracciatella", + "zip", +] + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.43.2", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-mid" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea305d57546cc8cd04feb14b62ec84bf17f50e3f7b12560d7bfa9265f39d9ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639627c87f43b9181c811f40a6296409e093a17bc761214cba3c15df74f86b99" +dependencies = [ + "arrayvec", + "serde", + "slotmap", + "smallvec", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.0", +] + +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a53f4706d65497df0c4349241deddf35f84cee19c87ed86ea8ca590f4464437" +dependencies = [ + "jpeg-decoder", + "miniz_oxide 0.4.4", + "weezl", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.0", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0db3bae107c9522f86d361697dee1d7386a2ddcf659d5aea5159819a21a3c4a7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-oslog" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950" +dependencies = [ + "cc", + "cfg-if", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "unsynn" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" +dependencies = [ + "mutants", + "proc-macro2", + "rustc-hash 2.1.3", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "variadics_please" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53d0f7f2d0182759a549f1f313ce16b07d8f9ba93098157b5fa10ee3e61117f" +dependencies = [ + "quote", + "unsynn", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix 1.1.2", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.2", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.2", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "weak-table" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eba2ec791141b0bf4db542f6b038a8f9310cee33e076cb181245780b09a24ff" +dependencies = [ + "cfg-if", + "hashbrown 0.16.1", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-task" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857768654b93b6cb494318742307d98217746ab982d53cd3738e199020e0be9f" +dependencies = [ + "async-task", + "js-sys", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + +[[package]] +name = "wesl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd8f9619baa4c7bde4df0899d6f626b70ee92d6e9c18c5f74473419b5bb6fa6" +dependencies = [ + "annotate-snippets", + "anstream", + "derive_more", + "glob", + "half", + "itertools 0.15.0", + "num-traits", + "serde", + "thiserror 2.0.21", + "toml", + "wesl-core", + "wesl-macros", + "wgsl-parse", + "wgsl-types", +] + +[[package]] +name = "wesl-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84dd3e600497eda0b47afcd21bd84c8953a9dfce7e93ed27a38ba97ac0747e03" + +[[package]] +name = "wesl-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8767ec26e75262a8e581d5f188b1f63a288baaf885bb33c9f4f73a693e06fc" +dependencies = [ + "itertools 0.15.0", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "portable-atomic", + "profiling", + "raw-window-handle 0.6.2", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle 0.6.2", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.10.0", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "naga", + "naga-types", + "ndk-sys 0.6.0+11769913", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle 0.6.2", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result 0.4.0", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle 0.6.2", + "serde", + "static_assertions", + "web-sys", +] + +[[package]] +name = "wgsl-parse" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37cee524ba5cfabfd65d10a316b646f8a4c50f3663e7e787471f21b783261f47" +dependencies = [ + "annotate-snippets", + "derive_more", + "itertools 0.15.0", + "lalrpop-util", + "lexical", + "logos", + "thiserror 2.0.21", + "wgsl-types", +] + +[[package]] +name = "wgsl-types" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e119dd12e57d937397535201959ddddb5dbf26505402db5ef305ebb1ca9a7e5f" +dependencies = [ + "half", + "itertools 0.15.0", + "num-traits", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e6c4a1f363c8210c6f77ba24f645c61c6fb941eccf013da691f7e09515b8ac" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "123e712f464a8a60ce1a13f4c446d2d43ab06464cb5842ff68f5c71b6fb7852e" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.0", + "windows-result 0.4.0", + "windows-strings 0.5.0", +] + +[[package]] +name = "windows-future" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f3db6b24b120200d649cd4811b4947188ed3a8d2626f7075146c5d178a9a4a" +dependencies = [ + "windows-core", + "windows-link 0.2.0", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-numerics" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce3498fe0aba81e62e477408383196b4b0363db5e0c27646f932676283b43d8" +dependencies = [ + "windows-core", + "windows-link 0.2.0", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.4", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +dependencies = [ + "windows-link 0.2.0", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab47f085ad6932defa48855254c758cdd0e2f2d48e62a34118a268d8f345e118" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash 0.8.12", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle 0.6.2", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.2", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure 0.14.0", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f852905151ac8d4d06fdca66520a661c09730a74c6d4e2b0f27b436b382e532" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq 0.3.1", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.3.3", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9341c69 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[workspace] +resolver = "2" +members = ["src-rust", "src-map-editor"] +# Plain `cargo build` should not build Bevy natively, the map editor is built for wasm only +default-members = ["src-rust"] + +[workspace.package] +edition = "2021" +rust-version = "1.97.1" + +[workspace.dependencies] +stracciatella = { git = "https://github.com/ja2-stracciatella/ja2-stracciatella.git", rev = "11e9430b67d788b73f7a57d22166e8b500c23dab" } + +# Bevy is very slow without optimizations, keep our own code quick to compile +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 3 + +# Keep the wasm binary small +[profile.release.package.ja2-map-editor] +opt-level = "s" +strip = true + +# Smaller wasm for packaging, but much slower to build than `release` +[profile.wasm-dist] +inherits = "release" +opt-level = "s" +lto = true +codegen-units = 1 diff --git a/README.md b/README.md index e5d30bf..bd948ff 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,11 @@ Install build dependencies: - [NodeJS / NPM](https://nodejs.org/) - [https://rustup.rs/](rustup) +- [wasm-bindgen CLI](https://github.com/wasm-bindgen/wasm-bindgen) for the tactical map viewer, in the exact version pinned in `src-map-editor/Cargo.toml`: + + ```bash + cargo install wasm-bindgen-cli --version 0.2.129 --locked + ``` Clone the repo and install node dependencies: diff --git a/eslint.config.mjs b/eslint.config.mjs index 8dc8ba9..15eca17 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ export default defineConfig( 'release/**/*', 'target/**/*', 'src-rust/target/**/*', + 'src-map-editor/pkg/**/*', '.erb/dll/**/*', ]), eslint.configs.recommended, diff --git a/package-lock.json b/package-lock.json index d7f1310..9b45545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@rjsf/utils": "^6.1.1", "@rjsf/validator-ajv8": "^6.1.1", "antd": "^5.25.2", - "electron-debug": "^4.0.0", + "electron-debug": "^5.0.0", "fast-json-patch": "^3.1.1", "monaco-editor-webpack-plugin": "^7.1.1", "proxy-memoize": "^3.0.1", @@ -34,7 +34,7 @@ }, "devDependencies": { "@electron/notarize": "^3.0.1", - "@electron/rebuild": "^4.0.1", + "@electron/rebuild": "^4.2.0", "@eslint/js": "^9.39.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.1", "@svgr/webpack": "^8.1.0", @@ -42,7 +42,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", - "@types/node": "^24.5.2", + "@types/node": "^24.9.0", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "@types/source-map-support": "^0.5.2", @@ -59,8 +59,8 @@ "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", "detect-port": "^2.1.0", - "electron": "^38.1.2", - "electron-builder": "^26.0.12", + "electron": "^44.4.5", + "electron-builder": "^26.15.3", "electron-devtools-installer": "^4.0.0", "electronmon": "^2.0.3", "eslint": "^9.39.1", @@ -241,25 +241,25 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.4.tgz", - "integrity": "sha512-cKjSKvWGmAziQWbCouOsFwb14mp1betm8Y7Fn+yglDMUUu3r9DCbJ9iJbeFDenLMqFbIMC0pQP8K+B8LAxX3OQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.1.0" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz", - "integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -315,7 +315,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2047,9 +2046,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", "dev": true, "funding": [ { @@ -2063,13 +2062,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", "dev": true, "funding": [ { @@ -2083,17 +2082,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", "dev": true, "funding": [ { @@ -2107,21 +2106,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", "dev": true, "funding": [ { @@ -2134,18 +2133,17 @@ } ], "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", - "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", "dev": true, "funding": [ { @@ -2158,17 +2156,19 @@ } ], "license": "MIT-0", - "engines": { - "node": ">=18" - }, "peerDependencies": { - "postcss": "^8.4" + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } } }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", "dev": true, "funding": [ { @@ -2181,64 +2181,10 @@ } ], "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@develar/schema-utils/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@develar/schema-utils/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" + "node": ">=20.19.0" } }, - "node_modules/@develar/schema-utils/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -2249,10 +2195,20 @@ "node": ">=10.0.0" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { - "version": "3.2.18", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.18.tgz", - "integrity": "sha512-2XyvMe3N3Nrs8cV39IKELRHTYUWFKrmqqSY1U+GMlc0jvqjIVnoxhNd2H4JolWQncbJi1DCvb5TNxZuI2fEjWg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", "dev": true, "license": "MIT", "dependencies": { @@ -2268,9 +2224,9 @@ } }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2279,9 +2235,9 @@ } }, "node_modules/@electron/asar/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2339,748 +2295,168 @@ "node": ">=10" } }, - "node_modules/@electron/fuses/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/fuses/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "undici": "^7.24.4" } }, - "node_modules/@electron/node-gyp": { - "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", - "integrity": "sha512-lBSgDMQqt7QWMuIjS8zNAq5FI5o5RVBAcJUGWGI6GgoQITJt3msAkUrHp8YHj3RTVE+h70ndqMGqURjp3IfRyQ==", + "node_modules/@electron/notarize": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.0.tgz", + "integrity": "sha512-DHePlWvmUKTNok2+yoOp2ZZ+3ZaLlDWdWD43BfzZhRlUPrBs+EwXDBq3kTJlO+jw2KZzeC3GGlypgw4HQZTD5w==", "dev": true, "license": "MIT", "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^8.1.0", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.2.1", - "nopt": "^6.0.0", - "proc-log": "^2.0.1", - "semver": "^7.3.5", - "tar": "^6.2.1", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" + "debug": "^4.4.0", + "promise-retry": "^2.0.1" }, "engines": { - "node": ">=12.13.0" + "node": ">= 22.12.0" } }, - "node_modules/@electron/node-gyp/node_modules/@npmcli/fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", - "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", "dev": true, - "license": "ISC", + "license": "BSD-2-Clause", "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=12.0.0" } }, - "node_modules/@electron/node-gyp/node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/@electron/node-gyp/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "4" - }, "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/@electron/node-gyp/node_modules/cacache": { - "version": "16.1.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", - "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^2.1.0", - "@npmcli/move-file": "^2.0.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "glob": "^8.0.1", - "infer-owner": "^1.0.4", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^9.0.0", - "tar": "^6.1.11", - "unique-filename": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@electron/node-gyp/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" + "node": ">= 8.0.0" }, - "engines": { - "node": ">= 8" + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@electron/node-gyp/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, - "engines": { - "node": ">=12" + "bin": { + "electron-rebuild": "lib/cli.js" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@electron/node-gyp/node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", "dev": true, "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" }, "engines": { - "node": ">= 6" + "node": ">=16.4" } }, - "node_modules/@electron/node-gyp/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "6", - "debug": "4" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/@electron/node-gyp/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" + "node": ">=14.14" } }, - "node_modules/@electron/node-gyp/node_modules/make-fetch-happen": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", - "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", "dev": true, - "license": "ISC", + "license": "BSD-2-Clause", + "optional": true, + "peer": true, "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^16.1.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.3", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^9.0.0" + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=14.14" } }, - "node_modules/@electron/node-gyp/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, - "license": "ISC", + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "brace-expansion": "^2.0.1" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/node-gyp/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@electron/node-gyp/node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@electron/node-gyp/node_modules/minipass-fetch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", - "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/@electron/node-gyp/node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@electron/node-gyp/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@electron/node-gyp/node_modules/nopt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", - "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^1.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@electron/node-gyp/node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@electron/node-gyp/node_modules/proc-log": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", - "integrity": "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@electron/node-gyp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@electron/node-gyp/node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@electron/node-gyp/node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@electron/node-gyp/node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@electron/node-gyp/node_modules/socks-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", - "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@electron/node-gyp/node_modules/ssri": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", - "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@electron/node-gyp/node_modules/unique-filename": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", - "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^3.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@electron/node-gyp/node_modules/unique-slug": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", - "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@electron/node-gyp/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/@electron/notarize": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.0.tgz", - "integrity": "sha512-DHePlWvmUKTNok2+yoOp2ZZ+3ZaLlDWdWD43BfzZhRlUPrBs+EwXDBq3kTJlO+jw2KZzeC3GGlypgw4HQZTD5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": ">= 22.12.0" - } - }, - "node_modules/@electron/osx-sign": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", - "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "compare-version": "^0.1.2", - "debug": "^4.3.4", - "fs-extra": "^10.0.0", - "isbinaryfile": "^4.0.8", - "minimist": "^1.2.6", - "plist": "^3.0.5" - }, - "bin": { - "electron-osx-flat": "bin/electron-osx-flat.js", - "electron-osx-sign": "bin/electron-osx-sign.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@electron/osx-sign/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "node_modules/@electron/osx-sign/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/osx-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/rebuild": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.1.tgz", - "integrity": "sha512-iMGXb6Ib7H/Q3v+BKZJoETgF9g6KMNZVbsO4b7Dmpgb5qTFqyFTzqW9F3TOSHdybv2vKYKzSS9OiZL+dcJb+1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", - "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "got": "^11.7.0", - "graceful-fs": "^4.2.11", - "node-abi": "^4.2.0", - "node-api-version": "^0.2.1", - "node-gyp": "^11.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^6.0.5", - "yargs": "^17.0.1" - }, - "bin": { - "electron-rebuild": "lib/cli.js" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@electron/rebuild/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@electron/universal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", - "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron/asar": "^3.2.7", - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.3.1", - "dir-compare": "^4.2.0", - "fs-extra": "^11.1.1", - "minimatch": "^9.0.3", - "plist": "^3.1.0" - }, - "engines": { - "node": ">=16.4" - } - }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/universal/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/universal/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10.0.0" + "node": ">=14.14" } }, "node_modules/@emotion/hash": { @@ -3757,13 +3133,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4243,29 +3612,6 @@ "node": ">=10" } }, - "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@monaco-editor/loader": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.6.1.tgz", @@ -4289,6 +3635,19 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4327,75 +3686,6 @@ "node": ">= 8" } }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/move-file": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", - "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, - "license": "MIT", - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@npmcli/move-file/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -4728,6 +4018,61 @@ "license": "MIT", "optional": true }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.5.tgz", + "integrity": "sha512-Ez3wLKVjaxdsLcgeWN4OE31QkM7oBOgKuuBJxldRYAkfYw2C+8zJPcSd/SThnbhszsEOlAmoaS5kIIkN29fGKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -5023,7 +4368,6 @@ "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.1.1.tgz", "integrity": "sha512-HkaKQJwcLyb7zTRB6H1PFYbStxKrw+53/x/Ho2tNeIiZpw1hm8qvNm5EQEAARFjffdyF/RdDszHtmBOESwT7fQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "lodash": "^4.17.21", "lodash-es": "^4.17.21", @@ -5043,7 +4387,6 @@ "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.1.1.tgz", "integrity": "sha512-608UcT5rgsQLqc2ZVSktjeycdQ8lWmsXtNYshIMdk6VfQ7YTps8rVEvbzBqfaytLrv4xQXsHr+2cIDmYypgXIQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "fast-uri": "^3.1.0", "json-schema-merge-allof": "^0.8.1", @@ -5593,7 +4936,6 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5956,7 +5298,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6020,7 +5361,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -6090,16 +5430,6 @@ } } }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -6203,7 +5533,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -6287,9 +5616,9 @@ "license": "MIT" }, "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", "dev": true, "license": "MIT" }, @@ -6376,12 +5705,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", "license": "MIT", "dependencies": { - "undici-types": "~7.12.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/node-forge": { @@ -6394,24 +5723,6 @@ "@types/node": "*" } }, - "node_modules/@types/node/node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", - "license": "MIT" - }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" - } - }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -6431,7 +5742,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6442,7 +5752,6 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -6550,14 +5859,6 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/@types/webpack-bundle-analyzer": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@types/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.7.0.tgz", @@ -6597,17 +5898,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", @@ -6644,7 +5934,6 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -7210,9 +6499,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "dev": true, "license": "MIT", "engines": { @@ -7231,21 +6520,14 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0" }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/accepts": { @@ -7277,7 +6559,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7340,39 +6621,11 @@ "node": ">= 14" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7464,7 +6717,6 @@ "resolved": "https://registry.npmjs.org/antd/-/antd-5.27.4.tgz", "integrity": "sha512-rhArohoAUCxhkPjGI/BXthOrrjaElL4Fb7d4vEHnIR3DpxFXfegd4rN21IgGdiF+Iz4EFuUZu8MdS8NuJHLSVQ==", "license": "MIT", - "peer": true, "dependencies": { "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^1.23.0", @@ -7569,60 +6821,128 @@ "node": ">=0.10.0" } }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.12", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", - "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", - "dev": true, - "license": "MIT" - }, "node_modules/app-builder-lib": { - "version": "26.0.12", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.0.12.tgz", - "integrity": "sha512-+/CEPH1fVKf6HowBUs6LcAIoRcjeqgvAeoSE+cl7Y7LndyQ9ViGPYibNk7wmhMHzNgHIuIbw4nWADPO+4mjgWw==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", "dev": true, "license": "MIT", "dependencies": { - "@develar/schema-utils": "~2.6.5", - "@electron/asar": "3.2.18", + "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.1", - "@electron/rebuild": "3.7.0", - "@electron/universal": "2.0.1", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "builder-util": "26.0.11", - "builder-util-runtime": "9.3.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", - "config-file-ts": "0.2.8-rc1", + "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.0.11", + "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", - "is-ci": "^3.0.0", "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", - "minimatch": "^10.0.0", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", "plist": "3.1.0", + "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", - "semver": "^7.3.8", - "tar": "^6.1.12", + "semver": "~7.7.3", + "tar": "^7.5.7", "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0" + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.0.12", - "electron-builder-squirrel-windows": "26.0.12" + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" } }, "node_modules/app-builder-lib/node_modules/@electron/notarize": { @@ -7653,120 +6973,111 @@ "universalify": "^2.0.0" }, "engines": { - "node": ">=10" + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/app-builder-lib/node_modules/@electron/rebuild": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.0.tgz", - "integrity": "sha512-VW++CNSlZwMYP7MyXEbrKjpzEwhB5kDNbzGtiPEjwYysqyTCF+YbNJ210Dj3AjWsGSV4iEEwNkmJN9yGZmVvmw==", + "node_modules/app-builder-lib/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", - "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", - "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "fs-extra": "^10.0.0", - "got": "^11.7.0", - "node-abi": "^3.45.0", - "node-api-version": "^0.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^6.0.5", - "yargs": "^17.0.1" - }, - "bin": { - "electron-rebuild": "lib/cli.js" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=12.13.0" + "node": "20 || >=22" } }, - "node_modules/app-builder-lib/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=8" } }, - "node_modules/app-builder-lib/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, "engines": { - "node": ">=12" + "node": ">=6" } }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, "node_modules/app-builder-lib/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/app-builder-lib/node_modules/node-abi": { - "version": "3.77.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz", - "integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==", + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/argparse": { @@ -7931,15 +7242,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", "dev": true, - "license": "MIT", - "optional": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, "engines": { - "node": ">=0.8" + "node": ">=12.0.0" } }, "node_modules/assertion-error": { @@ -7971,17 +7286,6 @@ "dev": true, "license": "MIT" }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -8042,6 +7346,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", @@ -8180,17 +7491,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } + "license": "MIT" }, "node_modules/body-parser": { "version": "1.20.3", @@ -8316,7 +7622,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -8331,41 +7636,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8373,35 +7643,35 @@ "license": "MIT" }, "node_modules/builder-util": { - "version": "26.0.11", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.0.11.tgz", - "integrity": "sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", "dev": true, "license": "MIT", "dependencies": { "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.3.1", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", - "is-ci": "^3.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/builder-util-runtime": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz", - "integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "dev": true, "license": "MIT", "dependencies": { @@ -8412,201 +7682,67 @@ "node": ">=12.0.0" } }, - "node_modules/builder-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/builder-util/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/builder-util/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/builder-util/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacache/node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "node_modules/builder-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cacache/node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" + "run-applescript": "^7.0.0" }, "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/cacheable-lookup": { @@ -8872,13 +8008,13 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/chrome-trace-event": { @@ -8942,60 +8078,6 @@ "node": ">=0.10.0" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -9011,16 +8093,6 @@ "node": ">=12" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -9299,38 +8371,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/config-file-ts": { - "version": "0.2.8-rc1", - "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", - "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^10.3.12", - "typescript": "^5.4.3" - } - }, - "node_modules/config-file-ts/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/connect-history-api-fallback": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", @@ -9456,24 +8496,14 @@ } } }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } - }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.0.0", @@ -9620,14 +8650,14 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -9782,20 +8812,31 @@ "license": "CC0-1.0" }, "node_modules/cssstyle": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.0.tgz", - "integrity": "sha512-RveJPnk3m7aarYQ2bJ6iw+Urh55S6FzUiqtBq+TihnTDP4cI8y/TYDqGOyqgnG1J1a6BxJXZsV9JFSTulm9Z7g==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" }, "engines": { "node": ">=20" } }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -9874,8 +8915,7 @@ "version": "1.11.18", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debounce": { "version": "1.2.1", @@ -10007,19 +9047,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -10119,16 +9146,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-libc": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", - "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -10178,138 +9195,41 @@ } }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/dir-compare/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/dmg-builder": { - "version": "26.0.12", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.0.12.tgz", - "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "26.0.12", - "builder-util": "26.0.11", - "builder-util-runtime": "9.3.1", - "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", - "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" - } - }, - "node_modules/dmg-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/dmg-builder/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/dmg-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/dmg-license/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", - "optional": true, + "license": "ISC", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "brace-expansion": "^1.1.7" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": "*" } }, - "node_modules/dmg-license/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", - "optional": true + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } }, "node_modules/dns-packet": { "version": "5.6.1", @@ -10412,7 +9332,8 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)" + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true }, "node_modules/domutils": { "version": "2.8.0", @@ -10491,6 +9412,56 @@ "dev": true, "license": "MIT" }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -10522,38 +9493,38 @@ } }, "node_modules/electron": { - "version": "38.1.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-38.1.2.tgz", - "integrity": "sha512-WXUcN3W8h8NTTZViA3KNX0rV2YBU0X0mEUM3ubupXTDY4QtIN7tmiqYVOKSKpR2LckTmBWGuEeY4D6xVoffwKQ==", + "version": "44.4.5", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.4.5.tgz", + "integrity": "sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { - "version": "26.0.12", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.0.12.tgz", - "integrity": "sha512-cD1kz5g2sgPTMFHjLxfMjUK5JABq3//J4jPswi93tOPFz6btzXYtK5NrDt717NRbukCUDOrrvmYVOWERlqoiXA==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.0.12", - "builder-util": "26.0.11", - "builder-util-runtime": "9.3.1", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", - "dmg-builder": "26.0.12", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", - "is-ci": "^3.0.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" @@ -10567,15 +9538,15 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.0.12", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.0.12.tgz", - "integrity": "sha512-kpwXM7c/ayRUbYVErQbsZ0nQZX4aLHQrPEG9C4h9vuJCXylwFH8a7Jgi2VpKIObzCXO7LKHiCw4KdioFLFOgqA==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.0.12", - "builder-util": "26.0.11", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, @@ -10596,55 +9567,32 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/electron-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/electron-builder/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">=8" } }, "node_modules/electron-debug": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/electron-debug/-/electron-debug-4.1.0.tgz", - "integrity": "sha512-rdbvmotqbaNcSuinPe1tzB5zK+JKal+4LSDbguBcqTLARNqWrGoRS/TkR1gGH4+63boYH3HUaf9r9ECAxgIe9g==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/electron-debug/-/electron-debug-5.0.0.tgz", + "integrity": "sha512-OJURKDBbEMcejThLVqFuTM3rvv0J4JU3OK7wp0JAV59fusU0kgZTuRqZPHSVS362onE7xuEC4VKBnziAswIXwg==", "license": "MIT", "dependencies": { - "electron-is-dev": "^3.0.1", - "electron-localshortcut": "^3.2.1" + "electron-is-dev": "^3.0.1" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10660,12 +9608,6 @@ "unzip-crx-3": "^0.2.0" } }, - "node_modules/electron-is-accelerator": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/electron-is-accelerator/-/electron-is-accelerator-0.1.2.tgz", - "integrity": "sha512-fLGSAjXZtdn1sbtZxx52+krefmtNuVwnJCV2gNiVt735/ARUboMl8jnNC9fZEqQdlAv2ZrETfmBUsoQci5evJA==", - "license": "MIT" - }, "node_modules/electron-is-dev": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-3.0.1.tgz", @@ -10678,30 +9620,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/electron-localshortcut": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/electron-localshortcut/-/electron-localshortcut-3.2.1.tgz", - "integrity": "sha512-DWvhKv36GsdXKnaFFhEiK8kZZA+24/yFLgtTwJJHc7AFgDjNRIBJZ/jq62Y/dWv9E4ypYwrVWN2bVrCYw1uv7Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.0.1", - "electron-is-accelerator": "^0.1.0", - "keyboardevent-from-electron-accelerator": "^2.0.0", - "keyboardevents-areequal": "^0.2.1" - } - }, "node_modules/electron-publish": { - "version": "26.0.11", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.0.11.tgz", - "integrity": "sha512-a8QRH0rAPIWH9WyyS5LbNvW9Ark6qe63/LqDB7vu2JXYpi0Gma5Q60Dh4tmTqhOBQt0xsrzD8qE7C+D7j+B24A==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", - "builder-util": "26.0.11", - "builder-util-runtime": "9.3.1", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", - "form-data": "^4.0.0", + "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" @@ -10724,44 +9655,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/electron-publish/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-publish/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-publish/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.222", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", @@ -10775,6 +9668,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -10795,6 +9689,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -10804,14 +9699,26 @@ "node": ">=6 <7 || >=8" } }, - "node_modules/electron/node_modules/@types/node": { - "version": "22.18.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", - "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" } }, "node_modules/electronmon": { @@ -10873,17 +9780,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -10908,13 +9804,16 @@ } }, "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/envinfo": { @@ -11225,7 +10124,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11286,7 +10184,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -11699,9 +10596,9 @@ } }, "node_modules/exponential-backoff": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", - "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "dev": true, "license": "Apache-2.0" }, @@ -11785,38 +10682,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "optional": true - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -11929,16 +10794,6 @@ "node": ">=0.8.0" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -11953,9 +10808,9 @@ } }, "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11963,9 +10818,9 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -12140,17 +10995,17 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -12177,31 +11032,18 @@ } }, "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=12" } }, "node_modules/fs.realpath": { @@ -12364,7 +11206,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -12419,9 +11261,9 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -12430,9 +11272,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -12647,9 +11489,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -13087,16 +11929,6 @@ "node": ">= 14" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/hyperdyperid": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", @@ -13107,24 +11939,6 @@ "node": ">=10.18" } }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -13151,27 +11965,6 @@ "postcss": "^8.1.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -13286,13 +12079,6 @@ "node": ">=8" } }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, - "license": "ISC" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -13333,16 +12119,6 @@ "node": ">= 0.4" } }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", @@ -13481,19 +12257,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -13668,23 +12431,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -13888,19 +12634,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -13971,9 +12704,9 @@ "license": "MIT" }, "node_modules/isbinaryfile": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.6.tgz", - "integrity": "sha512-I+NmIfBHUl+r2wcDd6JwE9yWje/PIVY/R5/CmV8dXLZd5K+L9X2klAOwfAHNnondLXkbHyTAleQAWonpTJBTtw==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", "dev": true, "license": "MIT", "engines": { @@ -14173,6 +12906,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -14198,7 +12941,6 @@ "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.5.4", "cssstyle": "^5.3.0", @@ -14325,11 +13067,14 @@ } }, "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -14412,18 +13157,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/keyboardevent-from-electron-accelerator": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/keyboardevent-from-electron-accelerator/-/keyboardevent-from-electron-accelerator-2.0.0.tgz", - "integrity": "sha512-iQcmNA0M4ETMNi0kG/q0h/43wZk7rMeKYrXP7sqKIJbHkTU8Koowgzv+ieR/vWJbOwxx5nDC3UnudZ0aLSu4VA==", - "license": "MIT" - }, - "node_modules/keyboardevents-areequal": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/keyboardevents-areequal/-/keyboardevents-areequal-0.2.2.tgz", - "integrity": "sha512-Nv+Kr33T0mEjxR500q+I6IWisOQ0lK1GGOncV0kWE6n4KFmpcu7RUX5/2B0EUtX51Cb0HjZ9VJsSY3u4cBa0kw==", - "license": "MIT" - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -14606,40 +13339,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -14747,29 +13446,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/markdown-to-jsx": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-8.0.0.tgz", @@ -14792,6 +13468,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -14977,9 +13654,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, "license": "CC0-1.0" }, @@ -15535,240 +14212,100 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mini-css-extract-plugin": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz", - "integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" + "mime-db": "1.52.0" }, "engines": { - "node": ">=8" + "node": ">= 0.6" } }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "node": ">=4" + } }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/mini-css-extract-plugin": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz", + "integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "yallist": "^4.0.0" + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" }, "engines": { - "node": ">=8" + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" } }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "dev": true, "license": "ISC" }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { - "minipass": "^3.0.0" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -15779,16 +14316,16 @@ } }, "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, "bin": { "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/monaco-editor": { @@ -15885,16 +14422,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -15925,14 +14452,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -15954,109 +14473,82 @@ } }, "node_modules/node-gyp": { - "version": "11.4.2", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.4.2.tgz", - "integrity": "sha512-3gD+6zsrLQH7DyYOUIutaauuXrcyxeTPyQuZQCQoNPZMHMMS5m4y0xclNpvYzoK3VNzuyxT6eF4mkIL4WSZ1eQ==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^7.4.3", + "tar": "^7.5.4", "tinyglobby": "^0.2.12", - "which": "^5.0.0" + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/node-gyp/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=6" } }, "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=20" } }, - "node_modules/node-gyp/node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "node_modules/node-gyp/node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "dev": true, "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/node-gyp/node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, "engines": { - "node": ">=18" + "node": ">=18.17" } }, "node_modules/node-gyp/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/node-loader": { "version": "2.1.0", @@ -16100,19 +14592,19 @@ "license": "MIT" }, "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -16301,22 +14793,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -16364,47 +14840,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -16465,19 +14900,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", - "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-retry": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", @@ -16755,13 +15177,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -16860,6 +15275,40 @@ "node": ">=8" } }, + "node_modules/pkijs": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", + "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], + "dependencies": { + "@noble/hashes": "1.8.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -16905,7 +15354,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17543,6 +15991,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -17560,6 +16009,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -17580,7 +16030,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -17651,13 +16100,13 @@ "license": "MIT" }, "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/process-nextick-args": { @@ -17677,13 +16126,6 @@ "node": ">=0.4.0" } }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, - "license": "ISC" - }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -17715,6 +16157,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -17765,9 +16219,9 @@ } }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, "license": "MIT", "dependencies": { @@ -17785,6 +16239,26 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -18494,7 +16968,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -18504,7 +16977,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -18575,7 +17047,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -18716,8 +17187,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -19046,20 +17516,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -19392,9 +17848,9 @@ "license": "MIT" }, "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", "dev": true, "license": "WTFPL OR ISC", "dependencies": { @@ -19407,7 +17863,6 @@ "integrity": "sha512-CQi5/AzCwiubU3dSqRDJ93RfOfg/hhpW1l6wCIvolmehfwgCI35R/0QDs1+R+Ygrl8jFawwwIojE2w47/mf94A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -20003,33 +18458,6 @@ "node": ">= 10" } }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -20053,36 +18481,6 @@ "websocket-driver": "^0.7.4" } }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -20172,19 +18570,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -20753,99 +19138,39 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/tar/node_modules/minipass": { + "node_modules/tar/node_modules/yallist": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/temp": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -20865,57 +19190,6 @@ "fs-extra": "^10.0.0" } }, - "node_modules/temp-file/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/temp-file/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/temp-file/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/temp/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/temp/node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -20923,6 +19197,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -21161,7 +19436,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -21220,9 +19494,9 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -21431,8 +19705,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -21557,7 +19830,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21609,11 +19881,21 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -21679,32 +19961,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/unist-util-is": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", @@ -21774,13 +20030,13 @@ } }, "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": ">= 10.0.0" } }, "node_modules/unixify": { @@ -21818,17 +20074,33 @@ "yaku": "^0.16.6" } }, - "node_modules/unzip-crx-3/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.6" + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, - "bin": { - "mkdirp": "bin/cmd.js" + "engines": { + "node": ">=14.14" } }, "node_modules/update-browserslist-db": { @@ -21964,22 +20236,6 @@ "node": ">= 0.8" } }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -22014,7 +20270,6 @@ "integrity": "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -22131,7 +20386,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -22145,7 +20399,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -22278,14 +20531,18 @@ "minimalistic-assert": "^1.0.0" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", "dev": true, "license": "MIT", "dependencies": { - "defaults": "^1.0.3" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" } }, "node_modules/webidl-conversions": { @@ -22303,7 +20560,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -22538,7 +20794,6 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -23074,17 +21329,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -23103,7 +21347,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.10.tgz", "integrity": "sha512-XuQhz4bDMHS66VWMD/ZQI+BT4DYbOYGjhV/sIlSA4vAzMuxjLT31Oa7m6Z7lEdCGDkt1VCt0x4Ro2PHonEdKFA==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b1206f0..b6f364e 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,16 @@ "scripts": { "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", "build:main": "npm run rebuild:rust && cross-env NODE_ENV=production webpack --config ./.erb/configs/webpack.config.main.prod.mts", - "build:renderer": "cross-env NODE_ENV=production webpack --config ./.erb/configs/webpack.config.renderer.prod.mts", + "build:renderer": "npm run rebuild:map-editor -- --dist && cross-env NODE_ENV=production webpack --config ./.erb/configs/webpack.config.renderer.prod.mts", "postinstall": "node .erb/scripts/check-native-dep.mts && electron-builder install-app-deps && cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.mts", "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx,.mts,.mtsx", "tsc": "cross-env NODE_ENV=development tsc", "tsc:check": "cross-env NODE_ENV=development tsc --noEmit && cross-env NODE_ENV=development tsc --project ./.erb", "package": "node ./.erb/scripts/clean.mts dist && npm run build && electron-builder build --publish never", "rebuild": "npm run rebuild:rust && electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", + "rebuild:map-editor": "node ./.erb/scripts/build-map-editor.mts", "rebuild:rust": "node ./.erb/scripts/ensure-rust-dep.mts", - "prestart": "npm run rebuild:rust && cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.main.dev.mts", + "prestart": "npm run rebuild:rust && npm run rebuild:map-editor && cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.main.dev.mts", "start": "node ./.erb/scripts/check-port-in-use.mts && npm run prestart && npm run start:renderer", "start:main": "concurrently -k \"cross-env NODE_ENV=development webpack --watch --config ./.erb/configs/webpack.config.main.dev.mts\" \"electronmon .\"", "start:preload": "cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.preload.dev.mts", @@ -47,7 +48,7 @@ "@rjsf/utils": "^6.1.1", "@rjsf/validator-ajv8": "^6.1.1", "antd": "^5.25.2", - "electron-debug": "^4.0.0", + "electron-debug": "^5.0.0", "fast-json-patch": "^3.1.1", "monaco-editor-webpack-plugin": "^7.1.1", "proxy-memoize": "^3.0.1", @@ -63,7 +64,7 @@ }, "devDependencies": { "@electron/notarize": "^3.0.1", - "@electron/rebuild": "^4.0.1", + "@electron/rebuild": "^4.2.0", "@eslint/js": "^9.39.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.1", "@svgr/webpack": "^8.1.0", @@ -71,7 +72,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", - "@types/node": "^24.5.2", + "@types/node": "^24.9.0", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "@types/source-map-support": "^0.5.2", @@ -88,8 +89,8 @@ "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", "detect-port": "^2.1.0", - "electron": "^38.1.2", - "electron-builder": "^26.0.12", + "electron": "^44.4.5", + "electron-builder": "^26.15.3", "electron-devtools-installer": "^4.0.0", "electronmon": "^2.0.3", "eslint": "^9.39.1", @@ -190,5 +191,12 @@ ".erb/dll/**" ], "logLevel": "quiet" + }, + "allowScripts": { + "electron-winstaller@5.4.0": true, + "esbuild@0.25.10": true, + "core-js-pure@3.45.1": true, + "@swc/core@1.13.5": true, + "@parcel/watcher@2.5.1": true } } diff --git a/rust-toolchain b/rust-toolchain deleted file mode 100644 index 82e24bf..0000000 --- a/rust-toolchain +++ /dev/null @@ -1 +0,0 @@ -1.90.0 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5568821 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.97.1" +targets = ["wasm32-unknown-unknown"] diff --git a/src-map-editor/COPYING b/src-map-editor/COPYING new file mode 100644 index 0000000..c0c6ec6 --- /dev/null +++ b/src-map-editor/COPYING @@ -0,0 +1,6 @@ +Copyright 2016 The Amethyst Project Developers + +Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be +copied, modified, or distributed except according to those terms. \ No newline at end of file diff --git a/src-map-editor/Cargo.toml b/src-map-editor/Cargo.toml new file mode 100644 index 0000000..6be3f57 --- /dev/null +++ b/src-map-editor/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "ja2-map-editor" +version = "0.0.1" +authors = ["Stefan Lau "] +description = "Tactical map viewer of the stracciatella toolset, compiled to wasm" +license = "MIT OR Apache-2.0" +edition.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +bevy = { version = "0.20.0", default-features = false, features = [ + "default_app", + "default_platform", + "2d_bevy_render", + "ui_api", + "ui_bevy_render", + "webgpu", + "web", +] } +bitflags = "2" +byteorder = "1.5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Must match the wasm-bindgen CLI, see .erb/scripts/build-map-editor.mts +wasm-bindgen = "=0.2.129" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["console"] } diff --git a/src-map-editor/README.md b/src-map-editor/README.md new file mode 100644 index 0000000..de23fa1 --- /dev/null +++ b/src-map-editor/README.md @@ -0,0 +1,109 @@ +# ja2-map-editor + +Tactical map viewer and editor for Jagged Alliance 2 maps, built with [Bevy](https://bevyengine.org) 0.19. It is based on `ja2-next` and is compiled to WebAssembly. The toolset renderer runs it on a canvas in the "Tactical Maps" page. Bevy is used with a trimmed feature set (2D render and UI only, plus `webgpu` and `web`; see `Cargo.toml`). It needs WebGPU, because the map is drawn by a fragment shader that reads storage buffers. On Linux, `src/main/main.ts` enables Vulkan in Chromium for that. + +## Building + +The crate only targets `wasm32-unknown-unknown`. It is a member of the toolset's Cargo workspace, but not a default member, so plain `cargo build` in the repo root only builds the Neon addon. + +```bash +npm run rebuild:map-editor # release build + wasm-bindgen into src-map-editor/pkg (gitignored) +npm run rebuild:map-editor -- --dist # smaller wasm with LTO (`wasm-dist` profile), used for packaging +cargo check -p ja2-map-editor --target wasm32-unknown-unknown +cargo clippy -p ja2-map-editor --target wasm32-unknown-unknown +cargo test -p ja2-map-editor # unit tests run natively, they don't need JA2 data +``` + +The build needs the `wasm-bindgen` CLI in exactly the version pinned for the `wasm-bindgen` crate in `Cargo.toml` (`cargo install wasm-bindgen-cli --version --locked`). The build script checks this. `rust-toolchain.toml` installs the wasm target. + +The workspace dev profile builds this crate at opt-level 1 and all dependencies at opt-level 3, because Bevy is too slow without optimization. + +## Architecture + +**Host API (`src/lib.rs`)**: the `#[wasm_bindgen]` exports used by `src/renderer/lib/mapViewer.ts`: + +- `start(canvasSelector, readFile, filesExist, onStatus, onEditorEvent)` builds and runs the Bevy app on the given canvas. `run()` returns immediately on wasm. It must only be called once per page, because winit can only create its event loop once. The toolset keeps the app alive and moves the canvas between the page and a hidden holder. +- `load_map(path)` queues a map (e.g. `maps/A9.dat`). `switch_map` in `states/loading.rs` picks it up in `PreUpdate`, removes the resources of the current map, replaces the scene with an empty one (which clears the baked regions), resets the camera position (the zoom is kept) and restarts `AppState::Loading`. +- `set_render_options(json)` sets the visible layers and day or night lighting (`RenderOptions` in `render/mod.rs`, see the doc comment in `lib.rs`). It is picked up in `PreUpdate` like `load_map` and kept when loading other maps. +- `set_active(active)` throttles updates (`WinitSettings`) while the viewer is not shown. +- `set_editor_mode(json)` sets the editing mode (`EditorMode` in `editor/mode.rs`, see the doc comment in `lib.rs`). Only the latest mode counts, and it is kept when loading other maps. +- `undo()`, `set_map_background(texture)`, `set_map_smoothing(type)`, `roof_all_rooms()`, `set_door(json)` and the commands of the soldiers, items, lights and traversal modes (`set_soldier`, `soldier_command`, `set_item`, `item_command`, `set_light`, `light_command`, `set_traversal`) are queued (`EditorCommand`), so none of them gets lost. Commands that arrive before a map is shown are dropped. +- `onEditorEvent` is called with JSON objects: `{ type: 'palette', map, tileset, ground, roads, cliffs, debris, trees, rocks, misc, buildings }` once a map is loaded (`buildings` has the palettes of the buildings mode, e.g. `room`, `walls` and `smartDoors`), where every palette entry is `{ tileId, tileType, typeName, subIndex, file, subImage }` (`editor/palette.rs`), `{ type: 'state', map, canUndo, undoDepth, background, nextRoomNumber, smoothing }` whenever one of these changes (`editor/commands.rs`), `{ type: 'door', map, gridNo, door }` when a door is clicked with the door locks tool (`editor/doors.rs`), `{ type: 'light', map, light, count, color, ambient, radii }` when the selected light or the lighting of the map changes (`editor/light_events.rs`), and `{ type: 'traversal', map, exitGrids, selected, queried, entryPoints, edgePoints }` when the exit grids, the entry points, the selected exit grid or entry point, the right clicked exit grid or the edge points change (`editor/traversal_events.rs`). +- `save_map()` resolves with the map file of the edited map (`editor/save.rs`), after generating the edge points again if edits made them out of date. +- `render_radar_map(map)` resolves with the radar map STI of the loaded map (`systems/radar.rs`, `render/radar.rs`): the map is drawn once more offscreen in the order and lighting of the game's map utility, read back, shrunk to 88×44 like the game does and reduced to a palette. + +The TypeScript declaration of this API lives in `src/renderer/lib/ja2-map-editor.d.ts` and has to be kept in sync by hand. + +**File access (`src/host.rs`)**: `HostPlugin` registers `HostAssetReader` as the `ja2://` asset source, and must be added before `DefaultPlugins`. It calls the JS `readFile(path)`, which resolves to a `Uint8Array` read with the `resources/read` invokable, or to `null` if the file does not exist (this becomes `AssetReaderError::NotFound`). That invokable reads the selected mod's files, with the vanilla data (via the stracciatella VFS) as a fallback. Examples are `ja2://maps/B13.DAT`, `ja2://binarydata/ja2set.dat`, `ja2://tilesets//.sti` the optional structure data `ja2://tilesets//.jsd`, the light templates (`ja2://L-R03.LHT`), the item files (`ja2://weapons.json` etc.), the soldier palettes (`ja2://binarydata/ja2pal.dat`) and the standing soldier animations (`ja2://anims/...`). `onStatus` is called with `{ state: 'loading' | 'loaded' | 'error', map, message? }`. The JS callbacks are not `Send`, so they are kept in a thread local (wasm is single threaded). The UI font is embedded with `include_bytes!` (`states/mod.rs`), so no assets are served. + +**File formats (`src/file_formats/`)** are parsers for JA2 binary formats: map `.DAT`, the tileset definition file `JA2SET.DAT` (validated like the game: 50 vanilla or 70 Unfinished Business tilesets), structure data `.jsd` (`ja2_structure.rs`: aux data, tile locations, structures and z-strip info) and STCI (`stci/`: indexed/palette, RGB565/888, ETRLE). The STCI parser is a copy of the one in the `stracciatella` crate. The crate itself cannot be used here because it doesn't compile for `wasm32-unknown-unknown`: its `jsonschema` dependency uses `Url::to_file_path`. Each format is exposed as a Bevy `Asset` + `AssetLoader`, and all of them are registered in `Ja2AssetsPlugin`. `StciLoader` keeps an indexed STCI as it is: a `StciSheet` has the palette, the palette indices of every sub image and a `SubImageInfo` with offset, size and app data per sub image. RGB STCIs can't be drawn. + +**Tile database (`ja2_tile_types.rs`, `ja2_tile_database.rs`)** is a port of the game's tile database (`TileDat.cc`, `TileDef.cc`). `ja2_tile_types.rs` has the tile type constants and tables (names, tiles per type, shadow buddies, door animations). Every tile type has exactly `NUM_TILES_PER_TYPE` entries, so the database index of a tile only depends on its type and sub index (`start(type) + sub_index - 1`, which spills into the next type when out of range, like in the game). `Ja2TileDatabase::build` takes one loaded surface (STI metadata + optional JSD) per tile type, cuts off extra sprites, pads missing ones with underflow fillers that show region 0, and computes the per-tile data: flags (wall, roof, a-frame, multi-z, animated, ...), terrain, wall orientation, fullness, shadow buddies, animation frames, structure and z-strips. The renderer and the lighting use it. `advance_animations` advances the animated tiles like the game's `ANIMATETILES` timer. `AssetMetaCheck::Never` is set because JA2 assets have no `.meta` files. + +**State machine (`src/states/`)**: `AppState { Idle, Loading, Map }`, with sub-states `LoadingPhase { MapAndTilesets, TileSheets, Failed }` (under Loading) and `DebugOverlay { Off, On }` (under Map, toggled with F3). The app starts `Idle` until the host requests a map. + +- `loading.rs` loads the map and `JA2SET.DAT`, then queues the STI sheet and the JSD file of every tile type, like the game. Each type falls back to the default tileset (0, or 50 with 70 tilesets) if the map's tileset has no file for it. A JSD that does not exist is not an error. It also loads the light templates of the map lights and the ones the editor places (`L-R01.LHT` to `L-R08.LHT`, kept as the `LightTemplates` resource), the item files and the soldier files (the standing surfaces and palette files come from the soldier registries); if one of these is missing, the lights, items or soldiers that need it are left out. When everything is loaded, it builds the `Ja2TileDatabase` and the `Tileset`, converts the map into a `TacticalMap` (see below), builds the level nodes (`MapWorld::build`), adds the items and soldiers, compiles the movement costs (which the lights need), inserts the `TacticalMap` resource and switches to `AppState::Map`. + +**Runtime map (`src/map/`)**: `TacticalMap::from_ja2` converts the parsed `Ja2Map` into the `TacticalMap` resource once the tileset, the item files (`src/item/`), the light templates (`src/light/`) and the soldier palettes are loaded (`MapContext`). After that only `states/loading.rs` and `map/convert.rs` know about the file format. Grid numbers are `GridNo`s, which are always on the map (`NOWHERE` and invalid numbers become `None`), and per tile data is kept in `GridArray`s indexed by them. `GridNo::offset` is the game's plain grid number arithmetic, `GridNo::step` is `NewGridNo`. Tiles are `Arc`s of the tileset, items, light templates, soldier definitions and palette replacements are resolved, the enums of the game (orders, attitudes, visibility, schedule actions, door traps, ...) are Rust enums, and the version specific fix-ups of the game (phantom menace structs, the ambient light of old caves maps) are applied. Anything that can't be converted is left out with a warning. + +**Soldier definitions (`src/soldier/`)**: the game's teams, soldier classes, body types and standing animation surfaces are tables of structs (`Team`, `SoldierClass`, `SoldierBodyType`, `StandingSurface`) that carry their behaviour, e.g. whether a team is placed by the editor or hostile, the uniform of a class, and the standing animation, palette and vehicle flag of a body type. `SoldierPlugin` registers them as `Registry` resources (`Teams`, `SoldierClasses`, `SoldierBodyTypes`, `StandingSurfaces`) when `AppState::Loading` is entered, before `start_loading` runs. A registry stores its definitions as `Arc`, and everything that refers to a definition holds such an `Arc` (e.g. a body type's standing animation, a placement's team, a soldier's surface), so the body types are built from the registered surfaces. Every definition has an id, a newtype with a private field (`TeamId`, `SoldierClassId`, ...) with named constants for comparisons. Ids are dense (id `n` is at index `n`, which is also the index of a surface's sprite sheet). `Registry::validate` turns a raw number into the `Arc` of its definition. The map parser keeps the raw numbers. The map conversion (`map/convert.rs`) validates the placements against the registries. A placement with an unknown team, class or body type is skipped with a warning, and loading continues. `SoldierPlacement::index` keeps the index in the map file, so that skipping a placement does not change the random choices of the others. The game's random choices are made with a seeded `Random` (`src/random.rs`, a SplitMix64 generator that the editor uses as well) per placement (`placement_random` in `soldier/random.rs`). `choose_body_type` (`body_type.rs`) makes the first of them, the body type, and `soldier_palette` (`palette.rs`) continues with the same generator for skin, hair and clothes. The result is a `SoldierPalette`: the animation's own palette, a palette file, or the names of the `ja2pal.dat` replacements, which the conversion looks up in the loaded `PaletteReplacements` (`palette_files.rs`, `ResolvedSoldierPalette`). The renderer (`render/soldiers.rs`) only works with the converted placements; it picks the animation frame and applies the palette with the loaded files. + +**Coordinates (`src/components/map.rs`)**: the map is 160×160 tiles (`GridPosition`, and `GridNo` `y * 160 + x`), and tiles are isometric 40×20 px. The renderer works in the game's screen pixels ("game pixels", y down), where the bounding box of tile (x, y) starts at `(20 * (x - y), 10 * (x + y))`. `game_to_world`/`world_to_game` convert them to Bevy world space (y up, the map center at the origin, every game pixel an integer unit square), and `game_to_tile` finds the tile under a position. The cursor, the bake regions and the debug overlay use these. + +**Rendering (`src/render/`)** reproduces the game's software renderer pixel for pixel, with 8 bit colour channels instead of the game's 16 bit frame buffer. A pixel's colour only depends on the ordered list of blits that cover it, so: + +- `world.rs` builds the level node lists of every tile in the order of the game's linked lists (`LoadWorld`, `WorldMan.cc`): land from the topmost full tile towards the head, buddy shadows of outdoor structs, and structure placement (`AddStructureToWorld`), which drops structs that overlap others like the game does. +- `items.rs` and `soldiers.rs` add the world items (to the struct and on-roof lists, `InternalAddItemToPool`) and the soldier placements (standing, first frame, with palette replacements; random choices of the game are made with a seeded generator). +- `reachable.rs` ports the reachable tests of `FindBestPath` (`PathAI.cc`) for the soldier the edge point functions use, including the order of its queue, which decides about the tiles behind fences, and its limits. `edge_points.rs` ports `GenerateMapEdgepoints` (`Map_Edgepoints.cc`) with it: the two diagonal rows of each side that are reachable from the first entry point (and not in a room on the surface), the secondary set reachable from the isolated entry point, the removal of the edge points that can't reach the edge of the map and the classification against the entry points, with the quirks of the middle indices and the larger range of I14. With the game data in `JA2_DATA_DIR`, a test compares the generated edge points with the ones of the maps of the game (222 of the 261 vanilla maps match exactly; the others were changed after their edge points were saved, and I14 was saved without its special case). +- `movement_costs.rs` (`CompileTileMovementCosts`, `DoorTravelCost`) and `lighting.rs` (`LightSetBaseLevel`, `LightDraw`, `IlluminationFilter`) compute the shade level of every node for day (ambient 3) or night (ambient 12, with the night lights on). Static shadows are only drawn below ambient 8. The nodes of the `MapWorld` resource are lit in place (`light_all`: `base_light` for every tile, then `draw_light` for every light that is on). Lights combine by their maximum, so drawing a light again changes nothing. +- `draw_list.rs` ports `RenderTiles`: the passes of `RenderStaticWorld` and `RenderDynamicWorld`, row by row with the layers of a pass interleaved per row, and per node the position, z value (16 bit, with the game's `INT16` wrap) and blitter (`Blitter`). Because of this order, the draws of the map are sorted by pass, row, layer, x and node, so the draws of every tile can be computed on their own (`DrawContext::tile_draws`, with a key for that order). `DrawCache` keeps them per tile: `all()` is the draw list of the map, and `ordered(rect)` the draws that overlap a rectangle, in the same order, which produce the same pixels in that rectangle. +- `regions.rs` splits the area that the map can cover (`map_bounds`) into regions of at most 512×512 px (`RegionLayout`). Each region has its own draws: the draws that overlap it, in draw list order. +- `bins.rs` sorts the draws of a region into 32×32 px bins, and `atlas.rs` packs them into `u32` storage buffers (`RegionScene`). The used sprites (as palette indices), the shaded palettes (`palette.rs`) and the animation table are shared by all regions (`SceneAtlas`). The atlas only grows, so the draws of a region stay valid when other regions add sprites. +- `shaders/ja2_replay.wesl` (`replay_material.rs`, a `Material2d`) starts every pixel with the clear colour and z 0 and applies the draws of its bin with the depth rules of each blitter. `reference.rs` is the same function on the CPU, for tests. + +`update_scene` brings the scene (`ReplayScene`: the shared buffers and one `RegionScene` per region, each with a version) up to date with the changes in `PendingSceneUpdate`. Loading a map and changing the render options request a full rebuild. Edits add the tiles whose level nodes changed (`MapWorld::sync_tiles`), and the update only redoes what they affect: `MovementCosts::recompile_area` compiles the costs around them again, `relight_area` relights the lights that reach them, `DrawContext::tile_draws` recomputes the draws of these tiles, and only the regions that the old or new draws overlap are built again. Each of these steps gives the same result as doing it for the whole map, which `render/tests.rs` checks with random edits. Above 4000 changed tiles everything is rebuilt. The update waits until the bake is idle (`MapBake::is_idle`), because the bake waits for the materials of every change; updating while it waits would delay it again and again. Changes that arrive in the meantime are handled together. What the scene was built from (the `DrawCache`, the `SceneAtlas` and the lighting) is kept in `SceneSources`. Animated tiles only update a small buffer of current frames every 200 ms. + +**Baking (`systems/bake.rs`)**: replaying the draws for every pixel is too expensive to do every frame. Every region of the `RegionLayout` has a texture (even sizes, integer centers), a quad with its replay material on render layer 2 and a bake camera that renders the quad into the texture. Storage buffers and materials are never modified; every change creates new ones, and the bake cameras only run once the render world reports that the new materials (including those of an earlier generation that was not ready yet) are prepared and no pipeline is compiling (`ReadyReplayMaterials`), so a region is never cleared by an unfinished bake. Only the buffers of regions whose version changed are uploaded, and only these regions are baked again. New shared buffers or animation frames replace the materials of all regions without baking them, except that animation updates bake the regions with animated draws. `warn_about_draws_outside` warns when draws reach outside of the baked area. + +**Rendering pipeline (`systems/pixel_view.rs`)**: the world camera (`TileCamera`, render layer 0) renders the baked regions into an off-screen texture at exactly 1 world px = 1 texel, with MSAA off and from an integer position, so the nearest-sampled region sprites can't leave gaps between each other. The texture is sized for the window at the minimum zoom and has no CPU-side data. The world camera renders into a centered `Camera.viewport` that fits the current zoom, and the screen sprite shows that part (`Sprite.rect`). This way, the texture only changes size with the window: Bevy sees a resized render target one frame late, which would distort the image for a frame while zooming. The screen camera (`ScreenCamera`, render layer 1, 1 unit = 1 physical px) draws the texture as a single bilinear filtered sprite, scaled by the zoom and shifted by the sub-pixel part of the camera position, so scrolling and zooming stay smooth. The UI is rendered by the screen camera at screen resolution. The view (`PixelView`: world position at the screen center, zoom in physical px per world px) is the source of truth for the camera. `systems/camera.rs` moves and zooms it and keeps it inside the part of the map that the game can scroll over plus a margin (`scrollable_area` in `render/movement_costs.rs`, which depends on `ubRestrictedScrollID`), and `screen_to_world` converts the cursor into world coordinates. Gizmos render into the texture along with the map. + +**Editing (`src/editor/`)** ports the terrain, buildings, soldiers, items, lights and traversal modes of the original editor (`src/game/Editor/` of the game). The document is the `TacticalMap`: every edit changes its tile layers through an `EditContext` (`document.rs`), which records the tile for undo, and `commit` syncs the world with `MapWorld::sync_tiles` and collects the touched tiles for `PendingSceneUpdate`. Structures are committed right away, so the next placement of the same brush sees them. The `EditContext` helpers are ports of the `WorldMan.cc` functions, e.g. `set_land_index` (the head of `MapTile::land` is its last element, the heads of the other layers are their first elements, see `MapTile`). The game sets the logical heights of all tile types to 1 before the editor runs, so the code for "higher" land is left out. + +- `smooth.rs` has the smoothing tables of `Smooth.cc`, which pick the edge tile of a texture from which of its neighbours have it (water looks at all 8 neighbours). +- `ground.rs` paints textures (`PasteTextureCommon`), fills areas (a breadth first search instead of the recursion, which could never end), erases the topmost texture and sets the background texture of the whole map. +- `cliffs.rs` places the cliff pieces with their overhanging edges (`PasteBanks`) and sets the height of the tiles from the cliffs of the map after every cliff edit, a port of `RaiseWorldLand` that the original ran when saving. The pieces are picked by hand, the original has no cliff smoothing. +- `roads.rs` has the road macro table of `Road_Smoothing.cc`, `placement.rs` the palettes of `SelectWin.cc`, the weighted random selection and placing and erasing debris and structures. +- `walls.rs` places wall pieces for a floor tile and fixes up the corners they make (`BuildWallPiece`), and has the wall helpers of `Smoothing_Utils.cc`. A horizontal wall stored on a tile is on its edge to the next row, a vertical one on its edge to the next column, so the top and left walls of a room are stored on the tiles above and left of it. `building.rs` adds and saws rooms, kills buildings and rebuilds roofs (`NewSmooth.cc`, `EditorBuildings.cc`). A room whose materials have a slant roof but no flat roof gets a slant roof with A-frames, which is 8 tiles wide, so the room tool forces a line brush of that width. What the editor needs to know about building tiles (the wall piece, door state, roof piece, style, ...) is the `building` attribute of the `Tile`, set when the tileset is built, and the wall orientations come from the tile database. +- `caves.rs` fills areas with rock and carves caves out of it on cave maps: the rock piece of a tile follows which of its 8 neighbours are rock (the perimeter table of `NewSmooth.cc`). The map file doesn't know which walls are rock, so `MapTile::cave` marks the pieces the editor placed, and the pieces surrounded by rock are marked when a cave map is loaded. Basements are rock made of floors and roofs, the building functions carve rooms out of it (`is_basement`). The map type is `MapInfo::editor_smoothing`, which `set_map_smoothing` changes. +- `soldiers.rs` is the soldiers mode (the Mercs tab of the original): the place tool places a soldier of the mode's team with the mode's defaults with a left click, on flat roofs if the mode or Page Up says so, or erases the soldiers of the shown teams on the tiles of the brush, with their schedules. The select tool selects the clicked soldier (clicking it again selects the next soldier of the tile), dragging moves it and Ctrl+dragging copies it (with its team, values, detailed placement and patrol, but without its schedule), both keep its level. Delete removes it, 1 to 9 set its patrol points (Shift removes them). The host edits the selected soldier, its detailed placement and its schedule (`soldier_events.rs`, `schedules.rs`). `soldier_overlay.rs` draws the markers, the patrol points and the slot, name and patrol warnings of the shown soldiers. +- `items.rs` is the items mode (the Items tab of the original): the place tool places the chosen item, trigger, action item or key with the defaults of the original with a left click, or erases the items shown on the tiles of the brush. The select tool selects the clicked tile with all items shown on it (its pool, which can be the base tile of the structure the items lie on), dragging moves them and Ctrl+dragging copies them, both keep their levels and refuse items that would sink in water or a second trigger on the tile. Delete removes all items of the tile. The host lists the items of the selected tile, edits them and removes single ones (`item_events.rs`). `item_overlay.rs` draws a ring on every tile with items, the selected tile and the pits of its action items. +- `lights.rs` is the lights mode (the lights of the Map Info tab of the original): the place tool places a light of the mode's radius (the template `L-R0N.LHT`) and time (prime time, night or always) with a left click, replacing the lights of the tile, or erases the lights of the tiles of the brush. The select tool selects the clicked light (clicking it again selects the next light of the tile), dragging moves it and Ctrl+dragging copies it, Delete removes it. The host edits the selected light, the light colour and the light level of basements and caves (`light_events.rs`, the level is 16 minus the ambient level). `sync_light_scene` relights the tiles that the changed lights reach (`light_tiles`); a new light colour or ambient level rebuilds the scene, because the shaded palettes and the base light of every tile depend on them. `light_overlay.rs` draws a ring on every light in the colour of its time. Saving writes the lights of the map file again: unchanged lights keep their record, the ones that could not be converted are kept, and the ambient light is only written when it differs from the file's (old caves maps use another level in the game than the saved one). Like in the original, only basements and caves have an ambient light: the map type sets it (`set_smoothing`), maps that become basements or caves start with level 12 (ambient 4) or keep theirs, and maps that become normal lose it, also in the saved file. +- `traversal.rs` is the traversal mode (the exit grids and entry points of the Map Info tab of the original): the place tool paints exit grids to the mode's destination with square brushes or areas (`DRAW_MODE_EXITGRID`), replacing the ones of the tiles, a right click reports the clicked exit grid so the host can copy its values, and a left click sets the chosen entry point, moved into the area the game can show (`ValidateEntryPointGridNo`). The select tool selects, moves and deletes entry points and exit grids and copies exit grids, like the lights mode does with lights. `sync_traversal_scene` compiles the movement costs of the changed exit grids again. The edge points are derived data: the edits that can move them (tiles, which includes rooms, exit grids, entry points, the map type and door locks) set `EditorDocument::edge_points_dirty`, and `regenerate_edge_points` generates them again (`render/edge_points.rs`) once the movement costs caught up, while the traversal mode is shown and before saving. Undo only marks them again. They are only saved when they were generated again (`TacticalMap::edge_points_stale`), so untouched maps keep their edge points byte for byte. `traversal_overlay.rs` draws the exit grids, the entry points with their letters and, with the select tool unless an exit grid is selected, the edge points, the ones of the selected entry point highlighted (both sets of its side, the secondary sets for the isolated one, none for the center one). +- `doors.rs` edits the door table: clicking a door or another openable structure with the door locks tool reports a `door` event, the host shows a dialog and sets the lock and trap with `set_door`. Undo keeps the door table of a command. The doors of the table get a key marker while the tool is active. +- `layout.rs` copies and moves buildings: the layout is the tiles of the floors and roofs and the walls around them, the tiles are copied in an order that never overwrites a tile before it is copied. Moved buildings leave the background texture behind, where the original leaves tiles without land. The tools drag with `Brush::Drag`, the tile where the mouse is pressed and where it is released, and the brush cursor shows the layout where it goes. +- `scroll_area.rs` draws the part of the map that the game can scroll over as a white box with gizmos, and rebuilds the scene and the movement costs when the host changes the scroll restriction (`set_restricted_scroll`), because the visible tiles and the z values depend on it. +- `rooms_overlay.rs` shows the room numbers of the tiles in view as text on the screen camera when the `roomNumbers` layer is on. That layer and the room numbers are not part of the scene, toggling it doesn't rebuild it. +- `smart.rs` is the smart method (`SmartMethod.cc`): walls that pick vertical or horizontal pieces from the walls around them, and doors, windows and broken walls that replace the walls of a tile. They are looked up by their building piece and the orientation of the wall, instead of the original's sub index arithmetic, which has been wrong since the orientations became bit flags. +- `building_mode.rs` is the buildings mode: the tools, how they restrict the brush (`EnsureSelectionType`), placing single tiles in their layers (`PasteSingleWallCommon`), erasing them (`EraseMapTile`) and the right button of the smart tool. +- `brush.rs` has the brushes (square and circle up to 15×15, line and area) and the density test, `mode.rs` the mode from the host and how it is applied to the tiles of the brush. `undo.rs` keeps the first state of every changed tile per stroke, and the background, the map type and the door table if the stroke or command changed them, for the last 10 strokes. Commands of the soldiers, items, lights and traversal modes keep the whole list they changed (the entry points of the map info). +- `input.rs` paints while a mouse button is held (the right one only for the tools that use it): the brush is applied once to every tile the cursor enters (and to the tiles in between, so fast strokes have no gaps), the line and area brushes when the button is released. It also draws the brush cursor with gizmos. `preview.rs` shows tinted sprites of what the next click places or removes: the objects with a copy of the random numbers, the roads, the cliffs, and the single tile and smart building tools with a dry run that applies the tool, collects the changed tiles and undoes it again. `commands.rs` applies the mode and the commands of the host and reports the state of the editor, `palette.rs` reports the palettes. + +Some bugs of the original are fixed: the road macros replace the road pieces of the tiles they go to, debris only replaces debris, weighted textures are picked without bias, fills always end, adding a room to a building whose land head is not a floor ends, broken walls replace the wall of their own alignment, and every edit (including erasing roads and setting the background) can be undone. + +**System ordering**: every per-frame map system goes into one of the chained `MapSystems` sets (`Scene → Input → Camera → View → Cursor → Edit → Debug`), configured in `systems/mod.rs`. The loading pollers run `.before(MapSystems::Scene)` so the scene systems see a complete map. New systems should join the appropriate set. Every feature is a `Plugin` registered in `lib.rs`. + +`lib.rs` allows `clippy::too_many_arguments` and `clippy::type_complexity` crate-wide because Bevy systems need them. + +## Controls + +| Key | Action | +| ------------------------ | -------------------------- | +| W / A / S / D | Move the camera | +| Mouse at the canvas edge | Move the camera | +| Mouse wheel / + / - | Zoom | +| F3 | Toggle tile debug overlay | +| Left mouse button | Paint with the editor mode | +| Left mouse drag | Move the selected building, soldier, items, light, entry point or exit grid with the select tools, Ctrl+drag (Cmd+drag on macOS) copies them (except entry points) | +| Right mouse button | Remove or restore walls with the smart building tool, copy the values of an exit grid when placing exit grids | + +The canvas only receives keyboard input while it is focused. Therefore the toolset forwards the keys pressed elsewhere on the page with `key_event` (except while typing into an input) and releases them with `release_keys` when the page loses the focus or the viewer is hidden. diff --git a/src-map-editor/assets/fonts/OFL.txt b/src-map-editor/assets/fonts/OFL.txt new file mode 100644 index 0000000..64071e3 --- /dev/null +++ b/src-map-editor/assets/fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright 2010 The Bangers Project Authors (contact@sansoxygen.com), +with Reserved Font Name Bangers. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src-map-editor/assets/fonts/SourceSansPro-Regular.ttf b/src-map-editor/assets/fonts/SourceSansPro-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b422bf43226f3c5f8a7821b5bda8e8effae586b2 GIT binary patch literal 269108 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 Kife4pfAxO|Kpl1f literal 0 HcmV?d00001 diff --git a/src-map-editor/src/components/camera.rs b/src-map-editor/src/components/camera.rs new file mode 100644 index 0000000..9ee5382 --- /dev/null +++ b/src-map-editor/src/components/camera.rs @@ -0,0 +1,9 @@ +use bevy::prelude::*; + +/// Marks the camera that is used to look at the map +#[derive(Component)] +pub struct TileCamera; + +/// Marks the camera that draws the rendered map and the UI to the window +#[derive(Component)] +pub struct ScreenCamera; diff --git a/src-map-editor/src/components/map.rs b/src-map-editor/src/components/map.rs new file mode 100644 index 0000000..a57b00a --- /dev/null +++ b/src-map-editor/src/components/map.rs @@ -0,0 +1,185 @@ +//! Map coordinates: grid positions, game screen pixels and Bevy world space +//! +//! The game places the tile at column `x` and row `y` so that the top left corner of its 40×20 +//! bounding box is at `(20 * (x - y), 10 * (x + y))` screen pixels, with y pointing down. The +//! renderer works in these "game pixels". Bevy world space has y pointing up and puts the center of +//! the map (the center of tile (80, 80)) at the origin, so every game pixel covers exactly one +//! world unit square with integer corners. + +use bevy::prelude::*; + +/// Number of rows and columns of a map (`WORLD_ROWS`, `WORLD_COLS`) +pub const JA2_MAP_SIZE: u8 = 160; +/// Number of tiles of a map (`WORLD_MAX`) +pub const JA2_MAP_TILES: usize = JA2_MAP_SIZE as usize * JA2_MAP_SIZE as usize; + +/// The tile width of a single Ja2 tile +pub const JA2_TILE_WIDTH: i32 = 40; +/// The tile height of a single Ja2 tile +pub const JA2_TILE_HEIGHT: i32 = 20; + +/// Game pixel that is at the world origin: the center of the map +const WORLD_ORIGIN: IVec2 = IVec2::new(20, 1610); + +/// Position of a tile on the map, in columns (x) and rows (y) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GridPosition { + pub x: u8, + pub y: u8, +} + +impl GridPosition { + pub fn new(x: u8, y: u8) -> Self { + Self { x, y } + } + + /// The tile at a position, `None` if it is outside of the map + pub fn from_xy(x: i32, y: i32) -> Option { + let size = i32::from(JA2_MAP_SIZE); + ((0..size).contains(&x) && (0..size).contains(&y)).then(|| Self::new(x as u8, y as u8)) + } + + /// Top left corner of the tile's bounding box in game pixels + pub fn tile_box(&self) -> IVec2 { + tile_box(i32::from(self.x), i32::from(self.y)) + } +} + +/// Top left corner of the bounding box of the tile at column `x` and row `y`, in game pixels +pub fn tile_box(x: i32, y: i32) -> IVec2 { + IVec2::new(JA2_TILE_WIDTH / 2 * (x - y), JA2_TILE_HEIGHT / 2 * (x + y)) +} + +/// World position of the top left corner of a game pixel +pub fn game_to_world(game: IVec2) -> Vec2 { + let world = game - WORLD_ORIGIN; + Vec2::new(world.x as f32, -world.y as f32) +} + +/// Game pixel position (with fractions) of a world position +pub fn world_to_game(world: Vec2) -> Vec2 { + Vec2::new(world.x, -world.y) + WORLD_ORIGIN.as_vec2() +} + +/// World rectangle covered by a rectangle of game pixels +pub fn game_rect_to_world(min: IVec2, max: IVec2) -> Rect { + Rect::from_corners(game_to_world(min), game_to_world(max)) +} + +/// Fractional tile coordinates of a game pixel position, ignoring the terrain height +/// +/// The tile (x, y) covers the coordinates `[x, x + 1) × [y, y + 1)`. +pub fn game_to_tile(game: Vec2) -> Vec2 { + let half_tile = Vec2::new(JA2_TILE_WIDTH as f32, JA2_TILE_HEIGHT as f32) / 2.; + // x - y and x + y of the tile whose center is at `game` + let diff = (game.x - half_tile.x) / half_tile.x; + let sum = (game.y - half_tile.y) / half_tile.y; + Vec2::new((sum + diff) / 2. + 0.5, (sum - diff) / 2. + 0.5) +} + +/// Highest terrain height of a tile in game pixels (`sHeight` is stored in a byte) +const MAX_TERRAIN_HEIGHT: i32 = u8::MAX as i32; + +/// The tile below a game position, whose diamond is raised by its terrain height like the +/// renderer draws it +/// +/// Tiles further down are drawn over the ones above them, so the one lowest on the map that is +/// raised up to the position wins. Where no raised tile covers the position (e.g. on the face of +/// a cliff), it is the tile of the flat ground. +pub fn raised_tile_at(game: Vec2, height: impl Fn(GridPosition) -> i32) -> Option { + let flat = |game: Vec2| { + let tile = game_to_tile(game).floor(); + GridPosition::from_xy(tile.x as i32, tile.y as i32) + }; + (1..=MAX_TERRAIN_HEIGHT) + .rev() + .filter_map(|lift| flat(game + Vec2::new(0., lift as f32))) + .find(|&tile| { + let lift = height(tile); + lift > 0 && flat(game + Vec2::new(0., lift as f32)) == Some(tile) + }) + .or_else(|| flat(game)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positions() { + assert_eq!(GridPosition::from_xy(3, 2), Some(GridPosition::new(3, 2))); + assert_eq!(GridPosition::from_xy(-1, 0), None); + assert_eq!(GridPosition::from_xy(0, 160), None); + } + + #[test] + fn raised_tiles_are_picked_where_they_are_drawn() { + let center = |x, y| tile_box(x, y).as_vec2() + Vec2::new(20., 10.); + let flat = |_| 0; + assert_eq!( + raised_tile_at(center(10, 10), flat), + Some(GridPosition::new(10, 10)) + ); + // (12, 12) is raised by 40 px, so it is drawn where (10, 10) would be + let hill = |p: GridPosition| { + if p == GridPosition::new(12, 12) { + 40 + } else { + 0 + } + }; + assert_eq!( + raised_tile_at(center(10, 10), hill), + Some(GridPosition::new(12, 12)) + ); + // Its flat place is empty, the ground there is picked + assert_eq!( + raised_tile_at(center(12, 12), hill), + Some(GridPosition::new(12, 12)) + ); + assert_eq!( + raised_tile_at(center(11, 11), hill), + Some(GridPosition::new(11, 11)) + ); + // A raised plateau is shifted as a whole + let plateau = |_| 20; + assert_eq!( + raised_tile_at(center(10, 10), plateau), + Some(GridPosition::new(11, 11)) + ); + } + + #[test] + fn tile_boxes() { + assert_eq!(tile_box(0, 0), IVec2::new(0, 0)); + assert_eq!(tile_box(1, 0), IVec2::new(20, 10)); + assert_eq!(tile_box(0, 1), IVec2::new(-20, 10)); + assert_eq!(tile_box(80, 80), IVec2::new(0, 1600)); + } + + #[test] + fn world_origin_is_map_center() { + let center = tile_box(80, 80) + IVec2::new(20, 10); + assert_eq!(game_to_world(center), Vec2::ZERO); + assert_eq!(game_to_world(center + IVec2::new(1, 1)), Vec2::new(1., -1.)); + assert_eq!(world_to_game(Vec2::new(1., -1.)), center.as_vec2() + 1.); + let rect = game_rect_to_world(center, center + IVec2::new(2, 3)); + assert_eq!(rect, Rect::new(0., -3., 2., 0.)); + } + + #[test] + fn tile_under_game_pixel() { + let tile = |game: Vec2| game_to_tile(game).floor(); + let center = |x, y| (tile_box(x, y) + IVec2::new(20, 10)).as_vec2(); + assert_eq!(game_to_tile(center(5, 7)), Vec2::new(5.5, 7.5)); + assert_eq!(tile(center(5, 7)), Vec2::new(5., 7.)); + // Just inside of the corners of the diamond + assert_eq!(tile(center(5, 7) + Vec2::new(-19.5, 0.)), Vec2::new(5., 7.)); + assert_eq!(tile(center(5, 7) + Vec2::new(19.5, 0.)), Vec2::new(5., 7.)); + assert_eq!(tile(center(5, 7) + Vec2::new(0., -9.5)), Vec2::new(5., 7.)); + assert_eq!(tile(center(5, 7) + Vec2::new(0., 9.5)), Vec2::new(5., 7.)); + // Neighbours + assert_eq!(tile(center(5, 7) + Vec2::new(10., 5.5)), Vec2::new(6., 7.)); + assert_eq!(tile(center(5, 7) + Vec2::new(-10., 5.5)), Vec2::new(5., 8.)); + } +} diff --git a/src-map-editor/src/components/mod.rs b/src-map-editor/src/components/mod.rs new file mode 100644 index 0000000..84072c4 --- /dev/null +++ b/src-map-editor/src/components/mod.rs @@ -0,0 +1,2 @@ +pub mod camera; +pub mod map; diff --git a/src-map-editor/src/editor/brush.rs b/src-map-editor/src/editor/brush.rs new file mode 100644 index 0000000..7b595a2 --- /dev/null +++ b/src-map-editor/src/editor/brush.rs @@ -0,0 +1,387 @@ +//! The tiles a brush covers (`UpdateCursorAreas` in `Cursor_Modes.cc`) and the density of +//! placements (`PerformDensityTest`) + +use bevy::math::Vec2; +use serde::Deserialize; + +use crate::{ + components::map::{GridPosition, JA2_MAP_SIZE}, + map::GridNo, + random::Random, +}; + +/// The largest size of the square and circle brushes and width of the line brush +pub const MAX_BRUSH_SIZE: u8 = 15; + +/// Size and shape of the brush (`gusSelectionType`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Brush { + /// `size`×`size` tiles around the cursor + Square { size: u8 }, + /// The tiles of the `size`×`size` square that are within the circle inside it + Circle { size: u8 }, + /// A line from where the mouse was pressed, `width` tiles wide, applied on release + Line { width: u8 }, + /// A rectangle from where the mouse was pressed, applied on release + Area, + /// The connected area of the topmost texture of the clicked tile, only for the terrain + Fill, +} + +impl Default for Brush { + fn default() -> Self { + Brush::Square { size: 1 } + } +} + +impl Brush { + /// Whether the brush is applied once when the mouse is released, instead of while it moves + pub fn applies_on_release(self) -> bool { + matches!(self, Brush::Line { .. } | Brush::Area) + } + + /// The tiles the brush covers with the cursor on `cursor`, `anchor` is where the mouse was + /// pressed for the brushes that are applied on release + pub fn region(self, anchor: Option, cursor: GridPosition) -> BrushArea { + let tiles = match (self, anchor) { + (Brush::Fill, _) | (Brush::Line { .. } | Brush::Area, None) => { + TileRect::single(cursor).tiles().collect() + } + (Brush::Square { size }, _) => TileRect::square(cursor, size).tiles().collect(), + (Brush::Circle { size }, _) => circle(cursor, size), + (Brush::Area, Some(anchor)) => TileRect::between(anchor, cursor).tiles().collect(), + (Brush::Line { width }, Some(anchor)) => { + TileRect::line(anchor, cursor, width).tiles().collect() + } + }; + BrushArea { tiles } + } +} + +/// The offsets from the cursor of a square brush: the cursor is in the middle, or left of and +/// above the middle for even sizes +fn square_offsets(size: u8) -> (i32, i32) { + let size = i32::from(size.clamp(1, MAX_BRUSH_SIZE)); + (-(size - 1) / 2, size / 2) +} + +/// The tiles of a circle brush, column by column and clamped to the map +fn circle(cursor: GridPosition, size: u8) -> Vec { + let (min, max) = square_offsets(size); + // The middle of the square, relative to the cursor tile + let center = (min + max) as f32 / 2.; + let radius = (max - min + 1) as f32 / 2.; + let (x, y) = (i32::from(cursor.x), i32::from(cursor.y)); + (min..=max) + .flat_map(|dx| (min..=max).map(move |dy| (dx, dy))) + .filter(|&(dx, dy)| { + let offset = Vec2::new(dx as f32 - center, dy as f32 - center); + offset.length_squared() <= radius * radius + }) + .filter_map(|(dx, dy)| GridPosition::from_xy(x + dx, y + dy)) + .map(GridNo::from) + .collect() +} + +/// The tiles a brush covers, column by column +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BrushArea { + pub tiles: Vec, +} + +impl BrushArea { + /// The tile in the first column and row, the clicked tile for roads and fills, which cover + /// a single tile, and the tile where a drag started + pub fn first(&self) -> Option { + self.tiles.first().copied() + } + + /// The tile where a drag ended + pub fn last(&self) -> Option { + self.tiles.last().copied() + } + + /// Whether the brush covers a single tile + pub fn is_single(&self) -> bool { + self.tiles.len() == 1 + } + + pub fn tiles(&self) -> impl Iterator + '_ { + self.tiles.iter().copied() + } +} + +/// A rectangle of tiles, the maximum is included (`gSelectRegion`) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TileRect { + pub min: GridPosition, + pub max: GridPosition, +} + +/// Clamps a column or row to the map (`ValidateSelectionRegionBoundaries`) +fn clamp(value: i32) -> u8 { + value.clamp(0, i32::from(JA2_MAP_SIZE) - 1) as u8 +} + +impl TileRect { + fn single(position: GridPosition) -> Self { + Self { + min: position, + max: position, + } + } + + /// The tiles of a square brush, clamped to the map + fn square(cursor: GridPosition, size: u8) -> Self { + let (min, max) = square_offsets(size); + let (x, y) = (i32::from(cursor.x), i32::from(cursor.y)); + Self { + min: GridPosition::new(clamp(x + min), clamp(y + min)), + max: GridPosition::new(clamp(x + max), clamp(y + max)), + } + } + + /// The rectangle with two tiles in its corners + fn between(a: GridPosition, b: GridPosition) -> Self { + Self { + min: GridPosition::new(a.x.min(b.x), a.y.min(b.y)), + max: GridPosition::new(a.x.max(b.x), a.y.max(b.y)), + } + } + + /// A line of `width` tiles from the anchor towards the cursor, along the axis on which the + /// cursor is farther away (`ForceAreaSelectionWidth`) + fn line(anchor: GridPosition, cursor: GridPosition, width: u8) -> Self { + let rect = Self::between(anchor, cursor); + let extra = i32::from(width.max(1)) - 1; + let (ax, ay) = (i32::from(anchor.x), i32::from(anchor.y)); + let (cx, cy) = (i32::from(cursor.x), i32::from(cursor.y)); + let (min, max) = if (cx - ax).abs() < (cy - ay).abs() { + // Restrict the columns + let (left, right) = if cx < ax { + (ax - extra, ax) + } else { + (ax, ax + extra) + }; + ( + GridPosition::new(clamp(left), rect.min.y), + GridPosition::new(clamp(right), rect.max.y), + ) + } else { + // Restrict the rows + let (top, bottom) = if cy < ay { + (ay - extra, ay) + } else { + (ay, ay + extra) + }; + ( + GridPosition::new(rect.min.x, clamp(top)), + GridPosition::new(rect.max.x, clamp(bottom)), + ) + }; + Self { min, max } + } + + /// The tiles, column by column like the original places them + fn tiles(self) -> impl Iterator { + (self.min.x..=self.max.x).flat_map(move |x| { + (self.min.y..=self.max.y).map(move |y| GridNo::from(GridPosition::new(x, y))) + }) + } +} + +/// Whether a tile gets a placement, `density` is in percent (`PerformDensityTest`) +pub fn density_test(random: &mut Random, density: u8) -> bool { + random.next(100) < u32::from(density) +} + +/// The tiles on the line between two tiles, without the first one (Bresenham) +/// +/// While the mouse moves fast, it skips tiles. The brush is applied to the tiles in between, so +/// that strokes have no gaps. +pub fn tiles_between(from: GridPosition, to: GridPosition) -> Vec { + let (mut x, mut y) = (i32::from(from.x), i32::from(from.y)); + let (x1, y1) = (i32::from(to.x), i32::from(to.y)); + let dx = (x1 - x).abs(); + let dy = -(y1 - y).abs(); + let sx = if x < x1 { 1 } else { -1 }; + let sy = if y < y1 { 1 } else { -1 }; + let mut error = dx + dy; + let mut tiles = vec![]; + while (x, y) != (x1, y1) { + let e2 = 2 * error; + if e2 >= dy { + error += dy; + x += sx; + } + if e2 <= dx { + error += dx; + y += sy; + } + tiles.extend(GridPosition::from_xy(x, y)); + } + tiles +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(x: u8, y: u8) -> GridPosition { + GridPosition::new(x, y) + } + + fn rect(min: (u8, u8), max: (u8, u8)) -> TileRect { + TileRect { + min: p(min.0, min.1), + max: p(max.0, max.1), + } + } + + fn area(rect: TileRect) -> BrushArea { + BrushArea { + tiles: rect.tiles().collect(), + } + } + + fn square(size: u8) -> Brush { + Brush::Square { size } + } + + #[test] + fn square_brushes_are_centered_and_clamped() { + assert_eq!(square(1).region(None, p(5, 5)), area(rect((5, 5), (5, 5)))); + assert_eq!(square(3).region(None, p(5, 5)), area(rect((4, 4), (6, 6)))); + assert_eq!(square(5).region(None, p(5, 5)), area(rect((3, 3), (7, 7)))); + assert_eq!(square(7).region(None, p(5, 5)), area(rect((2, 2), (8, 8)))); + // Even sizes reach farther right and down + assert_eq!(square(2).region(None, p(5, 5)), area(rect((5, 5), (6, 6)))); + assert_eq!(square(4).region(None, p(5, 5)), area(rect((4, 4), (7, 7)))); + assert_eq!( + square(7).region(None, p(1, 158)), + area(rect((0, 155), (4, 159))) + ); + assert_eq!(square(5).region(None, p(5, 5)).tiles().count(), 25); + assert_eq!( + square(MAX_BRUSH_SIZE) + .region(None, p(80, 80)) + .tiles() + .count(), + 225 + ); + // Out of range sizes are clamped + assert_eq!( + square(0).region(None, p(5, 5)), + square(1).region(None, p(5, 5)) + ); + assert_eq!( + square(200).region(None, p(80, 80)), + square(MAX_BRUSH_SIZE).region(None, p(80, 80)) + ); + assert!(square(1).region(None, p(5, 5)).is_single()); + assert!(!square(2).region(None, p(5, 5)).is_single()); + assert_eq!(Brush::default(), square(1)); + } + + #[test] + fn circle_brushes_leave_out_the_corners() { + let circle = |size| Brush::Circle { size }.region(None, p(50, 50)); + assert_eq!(circle(1), square(1).region(None, p(50, 50))); + assert_eq!(circle(2), square(2).region(None, p(50, 50))); + let five = circle(5); + assert_eq!(five.tiles().count(), 21); + for corner in [(48, 48), (52, 48), (48, 52), (52, 52)] { + assert!(!five.tiles.contains(&GridNo::from(p(corner.0, corner.1)))); + } + assert!(five.tiles.contains(&GridNo::from(p(52, 50)))); + assert!(five.tiles.contains(&GridNo::from(p(51, 52)))); + // Symmetric around the middle of the square + let four = circle(4); + assert_eq!(four.tiles().count(), 12); + assert!(!four.tiles.contains(&GridNo::from(p(49, 49)))); + assert!(!four.tiles.contains(&GridNo::from(p(52, 52)))); + assert!(four.tiles.contains(&GridNo::from(p(49, 50)))); + assert!(four.tiles.contains(&GridNo::from(p(52, 51)))); + // Clamped to the map + let edge = Brush::Circle { size: 5 }.region(None, p(0, 0)); + assert_eq!(edge.tiles().count(), 8); + assert_eq!(edge.first(), Some(GridNo::from(p(0, 0)))); + } + + #[test] + fn area_and_line_brushes_start_at_the_anchor() { + let rect = |min, max| area(rect(min, max)); + assert_eq!(Brush::Area.region(None, p(5, 5)), rect((5, 5), (5, 5))); + assert_eq!( + Brush::Area.region(Some(p(9, 2)), p(5, 5)), + rect((5, 2), (9, 5)) + ); + let line = Brush::Line { width: 2 }; + // Farther away horizontally, so the rows are restricted + assert_eq!( + line.region(Some(p(10, 10)), p(20, 12)), + rect((10, 10), (20, 11)) + ); + assert_eq!( + line.region(Some(p(10, 10)), p(2, 8)), + rect((2, 9), (10, 10)) + ); + // Farther away vertically + assert_eq!( + line.region(Some(p(10, 10)), p(8, 30)), + rect((9, 10), (10, 30)) + ); + assert_eq!( + Brush::Line { width: 3 }.region(Some(p(0, 10)), p(0, 30)), + rect((0, 10), (2, 30)) + ); + assert_eq!(Brush::Fill.region(None, p(7, 8)), rect((7, 8), (7, 8))); + assert!(Brush::Area.applies_on_release()); + assert!(!square(3).applies_on_release()); + assert!(!Brush::Circle { size: 3 }.applies_on_release()); + } + + #[test] + fn rect_tiles_go_column_by_column() { + let tiles: Vec<_> = rect((1, 1), (2, 2)) + .tiles() + .map(|g| (g.position().x, g.position().y)) + .collect(); + assert_eq!(tiles, [(1, 1), (1, 2), (2, 1), (2, 2)]); + } + + #[test] + fn density_is_a_percentage() { + let mut random = Random::new(1); + let hits = (0..1000).filter(|_| density_test(&mut random, 20)).count(); + assert!((150..250).contains(&hits), "{hits}"); + assert!((0..100).all(|_| density_test(&mut random, 100))); + } + + #[test] + fn lines_between_tiles_have_no_gaps() { + assert!(tiles_between(p(3, 3), p(3, 3)).is_empty()); + assert_eq!(tiles_between(p(0, 0), p(3, 0)), [p(1, 0), p(2, 0), p(3, 0)]); + let diagonal = tiles_between(p(10, 10), p(4, 13)); + assert_eq!(diagonal.last(), Some(&p(4, 13))); + let mut previous = p(10, 10); + for tile in diagonal { + assert!(tile.x.abs_diff(previous.x) <= 1 && tile.y.abs_diff(previous.y) <= 1); + previous = tile; + } + } + + #[test] + fn brushes_are_read_from_json() { + let brush: Brush = serde_json::from_str(r#"{"kind":"line","width":4}"#).unwrap(); + assert_eq!(brush, Brush::Line { width: 4 }); + let brush: Brush = serde_json::from_str(r#"{"kind":"square","size":3}"#).unwrap(); + assert_eq!(brush, square(3)); + let brush: Brush = serde_json::from_str(r#"{"kind":"circle","size":5}"#).unwrap(); + assert_eq!(brush, Brush::Circle { size: 5 }); + let brush: Brush = serde_json::from_str(r#"{"kind":"area"}"#).unwrap(); + assert_eq!(brush, Brush::Area); + assert!(serde_json::from_str::(r#"{"kind":"xlarge"}"#).is_err()); + } +} diff --git a/src-map-editor/src/editor/building.rs b/src-map-editor/src/editor/building.rs new file mode 100644 index 0000000..21a6972 --- /dev/null +++ b/src-map-editor/src/editor/building.rs @@ -0,0 +1,1052 @@ +//! Rooms and roofs of buildings (`NewSmooth.cc` and `EditorBuildings.cc`) +//! +//! A building is made of floors, the walls around them and roofs above them. Adding a room +//! extends the building it touches, erasing a room saws it out of the building and closes it with +//! new walls. +//! The roof pieces follow the walls or floors around them. + +use std::sync::Arc; + +use crate::{ + components::map::GridPosition, + map::{EditorSmoothing, GridNo}, + render::movement_costs::grid_no_on_visible_world_tile, + tileset::{BuildingPiece, RoofPiece, SlantPiece, SlantSide, Tile, WallPiece}, +}; + +use super::{ + brush::BrushArea, + document::EditContext, + placement::SelectionList, + walls::{ + at, build_wall_piece, erase_horizontal_wall, erase_vertical_wall, erase_walls, has_floor, + horizontal_wall, is_basement, is_door, is_wall, search_for_roof_type, search_for_wall_type, + vertical_wall, WallSide, + }, +}; + +/// Number of floor variants a room picks from (`FLOOR_VARIANTS`) +const FLOOR_VARIANTS: u16 = 8; + +/// The walls, floors and roofs a new room is built of, picked from the selection of the room +/// palette by their counts (`GetRandomIndexByRange`) +#[derive(Clone, Debug, Default)] +pub struct Materials(SelectionList); + +impl Materials { + pub fn new(list: SelectionList) -> Self { + Self(list) + } + + fn pick(&self, context: &mut EditContext, piece: impl Fn(BuildingPiece) -> bool) -> Option { + let tile = self.0.pick_where(&mut context.document.random, |tile| { + tile.building.is_some_and(|b| piece(b.piece)) + })?; + tile.building.map(|b| b.style) + } + + /// The style of a wall type of the selection + pub fn wall(&self, context: &mut EditContext) -> Option { + self.pick(context, BuildingPiece::is_wall) + } + + pub fn floor(&self, context: &mut EditContext) -> Option { + self.pick(context, |piece| piece == BuildingPiece::Floor) + } + + /// The style of a flat roof type of the selection + pub fn roof(&self, context: &mut EditContext) -> Option { + self.pick(context, BuildingPiece::is_flat_roof) + } + + pub fn slant_roof(&self, context: &mut EditContext) -> Option { + self.pick(context, |piece| { + matches!(piece, BuildingPiece::SlantRoof(_)) + }) + } + + /// Whether new rooms get a slant roof: the selection has a slant roof, but no flat roof + /// (`CheckForSlantRoofs`) + pub fn has_slant_roof_only(&self) -> bool { + let has = |piece: fn(BuildingPiece) -> bool| { + self.0 + .contains(|tile| tile.building.is_some_and(|b| piece(b.piece))) + }; + has(|piece| matches!(piece, BuildingPiece::SlantRoof(_))) + && !has(BuildingPiece::is_flat_roof) + } +} + +/// The rectangle of tiles an area tool works on (`gSelectRegion`), the maximum is included +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TileRect { + pub left: i32, + pub top: i32, + pub right: i32, + pub bottom: i32, +} + +impl TileRect { + /// The rectangle around the tiles of a brush, `None` without tiles + pub fn around(area: &BrushArea) -> Option { + let positions = || area.tiles().map(GridNo::position); + Some(Self { + left: i32::from(positions().map(|p| p.x).min()?), + top: i32::from(positions().map(|p| p.y).min()?), + right: i32::from(positions().map(|p| p.x).max()?), + bottom: i32::from(positions().map(|p| p.y).max()?), + }) + } + + /// The tiles row by row, like the original loops over them, without those off the map + pub fn tiles(self) -> impl Iterator { + (self.top..=self.bottom).flat_map(move |y| { + (self.left..=self.right).filter_map(move |x| { + GridPosition::from_xy(x, y).map(|position| (x, y, GridNo::from(position))) + }) + }) + } + + /// The rectangle one tile larger on every side + pub fn grown(self) -> Self { + Self { + left: self.left - 1, + top: self.top - 1, + right: self.right + 1, + bottom: self.bottom + 1, + } + } + + /// The tiles one tile outside of the rectangle, row by row + pub fn ring(self) -> impl Iterator { + let grown = self.grown(); + grown + .tiles() + .filter(move |&(x, y, _)| { + x == grown.left || x == grown.right || y == grown.top || y == grown.bottom + }) + .map(|(_, _, grid_no)| grid_no) + } +} + +/// A random tile of a building piece of a style +fn pick_piece(context: &mut EditContext, style: u8, piece: BuildingPiece) -> Option> { + let tiles = context.tileset.building_tiles(style, piece); + if tiles.is_empty() { + return None; + } + let index = context.random(tiles.len() as u32) as usize; + Some(tiles[index].clone()) +} + +/// Adds a random floor variant of a style to the head of the land +pub fn add_floor(context: &mut EditContext, grid_no: GridNo, style: u8) { + let variants: Vec> = context + .tileset + .building_tiles(style, BuildingPiece::Floor) + .iter() + .filter(|tile| tile.sub_index <= FLOOR_VARIANTS) + .cloned() + .collect(); + if variants.is_empty() { + return; + } + let index = context.random(variants.len() as u32) as usize; + context.add_land_to_head(grid_no, variants[index].clone()); +} + +/// Whether a tile is a flat or slant roof (`FIRSTROOF` to `LASTSLANTROOF`) +pub fn is_roof(tile: &Tile) -> bool { + tile.building.is_some_and(|b| b.piece.is_roof()) +} + +/// Whether a tile is a flat roof (`FIRSTROOF` to `LASTROOF`) +pub fn is_flat_roof(tile: &Tile) -> bool { + tile.building.is_some_and(|b| b.piece.is_flat_roof()) +} + +/// Removes the roofs, the tiles on them and the roof shadows of a tile (`EraseRoof`) +pub fn erase_roof(context: &mut EditContext, grid_no: GridNo) { + context.remove_roofs_where(grid_no, |_| true); + context.remove_on_roofs_where(grid_no, |_| true); + context.remove_shadows_where(grid_no, is_roof); +} + +/// Removes the floors of a tile (`EraseFloor`) +pub fn erase_floor(context: &mut EditContext, grid_no: GridNo) { + context.remove_land_where(grid_no, Tile::is_floor); +} + +/// Removes everything of a building from a tile (`EraseBuilding`) +pub fn erase_building(context: &mut EditContext, grid_no: GridNo) { + erase_roof(context, grid_no); + erase_floor(context, grid_no); + erase_walls(context, grid_no); + context.set_room(grid_no, None); +} + +/// Removes the trees, rocks, structures, their shadows and the debris of a tile +/// (`BulldozeNature`) +pub fn bulldoze_nature(context: &mut EditContext, grid_no: GridNo) { + let bulldozed = |tile: &Tile| tile.bulldozed; + context.remove_structs_where(grid_no, bulldozed); + context.remove_shadows_where(grid_no, bulldozed); + context.remove_objects_where(grid_no, bulldozed); +} + +/// The roof piece of a tile, by the sides of the building it is on: the top and left sides win, +/// a tile at no side is a center piece +fn roof_piece(top: bool, left: bool, bottom: bool, right: bool) -> RoofPiece { + match (top, left, bottom, right) { + (true, true, _, _) => RoofPiece::TopLeft, + (true, _, _, true) => RoofPiece::TopRight, + (_, true, true, _) => RoofPiece::BottomLeft, + (_, _, true, true) => RoofPiece::BottomRight, + (true, _, _, _) => RoofPiece::Top, + (_, _, true, _) => RoofPiece::Bottom, + (_, true, _, _) => RoofPiece::Left, + (_, _, _, true) => RoofPiece::Right, + _ => RoofPiece::Center, + } +} + +/// Replaces the roof of a tile by a piece of a style +fn set_roof(context: &mut EditContext, grid_no: GridNo, style: u8, piece: RoofPiece) { + erase_roof(context, grid_no); + if let Some(tile) = pick_piece(context, style, BuildingPiece::Roof(Some(piece))) { + context.add_roof_to_head(grid_no, tile); + } +} + +/// Replaces the roof of a tile by the piece of a style that fits the walls around it +/// (`RebuildRoof`), nothing happens without a style +pub fn rebuild_roof(context: &mut EditContext, grid_no: GridNo, style: Option) { + let Some(style) = style else { + return; + }; + let top = horizontal_wall(context, at(grid_no, 0, -1)).is_some(); + let left = vertical_wall(context, at(grid_no, -1, 0)).is_some(); + let bottom = context.horizontal_wall(grid_no).is_some(); + let right = context.vertical_wall(grid_no).is_some(); + set_roof( + context, + grid_no, + style, + roof_piece(top, left, bottom, right), + ); +} + +/// Replaces the roof of a tile by the piece of a style that fits the floors around it +/// (`RebuildRoofUsingFloorInfo`) +pub fn rebuild_roof_using_floor_info(context: &mut EditContext, grid_no: GridNo, style: u8) { + let edge = |dx, dy| !has_floor(context, at(grid_no, dx, dy)); + let (top, left, bottom, right) = (edge(0, -1), edge(-1, 0), edge(0, 1), edge(1, 0)); + set_roof( + context, + grid_no, + style, + roof_piece(top, left, bottom, right), + ); +} + +/// Whether the edge of a floor tile that a wall piece goes to has a wall +fn has_wall(context: &EditContext, grid_no: GridNo, side: WallSide) -> bool { + match side { + WallSide::ExteriorTop | WallSide::InteriorTop => { + horizontal_wall(context, at(grid_no, 0, -1)).is_some() + } + WallSide::ExteriorBottom | WallSide::InteriorBottom => { + context.horizontal_wall(grid_no).is_some() + } + WallSide::ExteriorLeft | WallSide::InteriorLeft => { + vertical_wall(context, at(grid_no, -1, 0)).is_some() + } + WallSide::ExteriorRight | WallSide::InteriorRight => { + context.vertical_wall(grid_no).is_some() + } + } +} + +/// The style of the floor of a tile +fn floor_style(context: &EditContext, grid_no: GridNo) -> Option { + context + .land(grid_no) + .iter() + .find_map(|tile| tile.building.filter(|b| b.piece == BuildingPiece::Floor)) + .map(|b| b.style) +} + +/// Adds a room to the world, or extends the building it touches (`AddBuildingSectionToWorld`) +/// +/// A new building bulldozes the nature of the area and takes its walls, floor and roof from the +/// materials, an extended one those of the building. The walls between the building and the new +/// part are removed. Without walls in the materials, only the floors are replaced. The original +/// never ends if the head of the land of a floor tile is not a floor, here the floor is looked +/// up in all of the land. +pub fn add_building_section(context: &mut EditContext, rect: TileRect, materials: &Materials) { + let basement = is_basement(context); + let tiles: Vec<(i32, i32, GridNo)> = rect.tiles().collect(); + let mut wall = materials.wall(context); + let mut floor = materials.floor(context); + if wall.is_none() { + // Only floors, e.g. to give a kitchen another floor + if let Some(floor) = floor { + for &(_, _, grid_no) in &tiles { + erase_floor(context, grid_no); + add_floor(context, grid_no, floor); + } + } + return; + } + + // A floor in the area means that an existing building is extended + let mut new_building = true; + let mut roof = None; + for &(_, _, grid_no) in &tiles { + if !context.has_floor(grid_no) { + continue; + } + new_building = false; + floor = floor_style(context, grid_no); + wall = search_for_wall_type(context, grid_no, materials); + roof = search_for_roof_type(context, grid_no); + if wall.is_some() && roof.is_some() && floor.is_some() { + break; + } + } + + let mut slant_roof = None; + if new_building { + wall = materials.wall(context); + floor = materials.floor(context); + roof = materials.roof(context); + if roof.is_none() { + if let Some(slant) = materials.slant_roof(context) { + if basement { + roof = Some(0); + } else { + slant_roof = Some(slant); + } + } + } + } + // The building's walls weren't found, use the materials + let Some(wall) = wall.or_else(|| materials.wall(context)) else { + return; + }; + + // Remove the walls between the building and the new part. The floor and roof of the tiles + // whose walls are removed are rebuilt below. Outside of buildings the nature goes. + for &(_, _, grid_no) in &tiles { + if basement { + erase_building(context, grid_no); + } else if !new_building && context.has_floor(grid_no) { + let open = |context: &EditContext, dx, dy| !has_floor(context, at(grid_no, dx, dy)); + let sides = [ + (open(context, 0, -1), at(grid_no, 0, -1), true), + (open(context, 0, 1), Some(grid_no), true), + (open(context, -1, 0), at(grid_no, -1, 0), false), + (open(context, 1, 0), Some(grid_no), false), + ]; + for (open, wall_tile, horizontal) in sides { + if !open { + continue; + } + if horizontal { + erase_horizontal_wall(context, wall_tile); + } else { + erase_vertical_wall(context, wall_tile); + } + erase_floor(context, grid_no); + erase_roof(context, grid_no); + } + } else { + bulldoze_nature(context, grid_no); + } + } + + // The walls around the tiles without floors, seen from inside. Top and bottom first. + for &(x, y, grid_no) in &tiles { + if context.has_floor(grid_no) { + continue; + } + let sides = [ + (y == rect.top, (0, -1), WallSide::InteriorTop), + (y == rect.bottom, (0, 1), WallSide::InteriorBottom), + (x == rect.left, (-1, 0), WallSide::InteriorLeft), + (x == rect.right, (1, 0), WallSide::InteriorRight), + ]; + for (edge, (dx, dy), side) in sides { + if edge + && !has_wall(context, grid_no, side) + && basement == has_floor(context, at(grid_no, dx, dy)) + { + build_wall_piece(context, grid_no, side, wall); + } + } + } + + if let Some(slant) = slant_roof { + // Slant roofs are always 8 tiles wide or high + let vertical = rect.bottom - rect.top != 7; + build_slant_roof(context, rect, wall, slant, vertical); + } + + // The tiles without floors get their roofs and floors + for &(_, _, grid_no) in &tiles { + if context.has_floor(grid_no) { + continue; + } + if slant_roof.is_none() { + rebuild_roof(context, grid_no, roof); + } + if let (Some(floor), false) = (floor, basement) { + add_floor(context, grid_no, floor); + } + } +} + +/// Places the slant roof of a room 8 tiles wide or high, with the A-frame walls at the side the +/// viewer looks at (`BuildSlantRoof`) +/// +/// A vertical roof runs from the bottom to the top of the room, its pieces are 3, 4, 7 and 8 +/// columns right of its left side. A horizontal one runs from the right to the left, 3, 4, 7 and +/// 8 rows below its top. +fn build_slant_roof(context: &mut EditContext, rect: TileRect, wall: u8, roof: u8, vertical: bool) { + let tileset = context.tileset; + let aframe = |vertical, left| { + let piece = BuildingPiece::Wall(WallPiece::AFrame { vertical, left }); + tileset.building_tiles(wall, piece).first().cloned() + }; + let slant = |thick, side| { + let piece = BuildingPiece::SlantRoof(Some(SlantPiece { thick, side })); + tileset.building_tiles(roof, piece).first().cloned() + }; + let mut add = |x: i32, y: i32, tile: Option>| { + if let (Some(position), Some(tile)) = (GridPosition::from_xy(x, y), tile) { + context.add_roof_to_head(GridNo::from(position), tile); + } + }; + if vertical { + let (x, bottom) = (rect.left, rect.bottom); + // The closest pieces: the A-frames and the extended roof pieces + add(x + 4, bottom, aframe(true, true)); + add(x + 8, bottom, aframe(true, false)); + for y in (rect.top..=bottom).rev() { + let thick = y == bottom || y == rect.top; + add(x + 3, y, slant(thick, SlantSide::Left)); + add(x + 7, y, slant(thick, SlantSide::Right)); + } + } else { + let (right, y) = (rect.right, rect.top); + add(right, y + 4, aframe(false, true)); + add(right, y + 8, aframe(false, false)); + for x in (rect.left..=right).rev() { + let thick = x == right || x == rect.left; + add(x, y + 3, slant(thick, SlantSide::Top)); + add(x, y + 7, slant(thick, SlantSide::Bottom)); + } + } +} + +/// Removes what a floor tile owns: its building and the walls on its top and left edges, which +/// are stored on the tiles above and left of it (`EraseFloorOwnedBuildingPieces`) +pub fn erase_floor_owned_pieces(context: &mut EditContext, grid_no: GridNo) { + if !is_basement(context) && !context.has_floor(grid_no) { + return; + } + erase_building(context, grid_no); + let owned = [(at(grid_no, -1, 0), true), (at(grid_no, 0, -1), false)]; + for (neighbour, vertical) in owned { + let Some(neighbour) = neighbour else { + continue; + }; + let index = context.structs(neighbour).iter().position(|tile| { + let orientation = tile.wall_orientation(); + (is_wall(tile) || is_door(tile)) + && if vertical { + orientation.is_vertical() + } else { + orientation.is_horizontal() + } + }); + if let Some(index) = index { + context.remove_struct(neighbour, index); + context.remove_shadows_where(neighbour, is_wall); + } + } +} + +/// Saws a room out of the buildings (`RemoveBuildingSectionFromWorld`) +/// +/// The area loses what its floor tiles own, the buildings around it get new walls where they +/// border it and their roofs are rebuilt along it. In basements the area is filled with rock. +pub fn remove_building_section(context: &mut EditContext, rect: TileRect, materials: &Materials) { + let basement = is_basement(context); + let tiles: Vec<(i32, i32, GridNo)> = rect.tiles().collect(); + for &(_, _, grid_no) in &tiles { + erase_floor_owned_pieces(context, grid_no); + } + + // New walls wherever the neighbours keep their floors. Top and bottom first. + for &(x, y, grid_no) in &tiles { + let sides = [ + (y == rect.top, (0, -1), WallSide::ExteriorTop), + (y == rect.bottom, (0, 1), WallSide::ExteriorBottom), + (x == rect.left, (-1, 0), WallSide::ExteriorLeft), + (x == rect.right, (1, 0), WallSide::ExteriorRight), + ]; + for (edge, (dx, dy), side) in sides { + if !edge { + continue; + } + let floor = has_floor(context, at(grid_no, dx, dy)); + if (basement && !floor) || (!basement && floor && !has_wall(context, grid_no, side)) { + if let Some(style) = search_for_wall_type(context, grid_no, materials) { + build_wall_piece(context, grid_no, side, style); + } + } + } + } + + if basement { + let floor = materials.floor(context).unwrap_or(0); + for &(_, _, grid_no) in &tiles { + add_floor(context, grid_no, floor); + } + } + for grid_no in rect.ring() { + if basement != context.has_floor(grid_no) { + let style = search_for_roof_type(context, grid_no); + rebuild_roof(context, grid_no, style); + } + } +} + +/// The neighbours of a tile the flood fills go to, on the visible part of the map +fn fill_neighbours(context: &EditContext, grid_no: GridNo) -> Vec { + let restricted_scroll = context.world.restricted_scroll; + [(0, -1), (0, 1), (1, 0), (-1, 0)] + .into_iter() + .filter_map(|(dx, dy)| at(grid_no, dx, dy)) + .filter(|&g| grid_no_on_visible_world_tile(g, restricted_scroll)) + .collect() +} + +/// Removes the building at a tile with its room numbers, going from tile to tile while there +/// are floors or roofs (`KillBuilding`) +/// +/// In basements, the rock is restored. +pub fn kill_building(context: &mut EditContext, grid_no: GridNo) { + let basement = is_basement(context); + // The original recurses and rebuilds the roofs of basements after the neighbours + enum Step { + Visit(GridNo), + Finish(GridNo), + } + let mut stack = vec![Step::Visit(grid_no)]; + while let Some(step) = stack.pop() { + let grid_no = match step { + Step::Visit(grid_no) => grid_no, + Step::Finish(grid_no) => { + let style = search_for_roof_type(context, grid_no); + rebuild_roof(context, grid_no, style); + continue; + } + }; + let mut found = false; + if !basement { + found |= context.remove_roofs_where(grid_no, |_| true); + } + found |= context.remove_land_where(grid_no, Tile::is_floor); + erase_building(context, grid_no); + if basement { + stack.push(Step::Finish(grid_no)); + } + if !found { + continue; + } + // Visited in the order of the original: up, down, right, left + for neighbour in fill_neighbours(context, grid_no).into_iter().rev() { + stack.push(Step::Visit(neighbour)); + } + } +} + +/// Gives every tile with a room a center piece of a flat roof (the F7 key of the original in +/// basements), so that the rooms carved into the rock have a ceiling +pub fn roof_all_rooms(context: &mut EditContext, style: u8) { + for grid_no in GridNo::all() { + if context.map.tiles[grid_no].room.is_some() { + set_roof(context, grid_no, style, RoofPiece::Center); + } + } +} + +/// Replaces the roof of the building at a tile by a new style, going from floor to floor +/// (`ReplaceBuildingWithNewRoof`) +/// +/// Only on normal maps and on floors. +pub fn replace_with_new_roof(context: &mut EditContext, grid_no: GridNo, style: u8) { + if context.map.info.editor_smoothing != EditorSmoothing::Normal || !context.has_floor(grid_no) { + return; + } + let mut visited = std::collections::HashSet::from([grid_no]); + let mut stack = vec![grid_no]; + while let Some(grid_no) = stack.pop() { + rebuild_roof_using_floor_info(context, grid_no, style); + for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] { + let Some(neighbour) = at(grid_no, dx, dy) else { + continue; + }; + if context.has_floor(neighbour) && visited.insert(neighbour) { + stack.push(neighbour); + } + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{ + editor::document::tests::{grid, Fixture}, + map::RoomNo, + tileset::{tests::building_tileset, TileIndex, WallPiece}, + }; + + /// The fixture with the building tileset + pub(crate) fn building_fixture() -> Fixture { + Fixture::with_tileset(building_tileset()) + } + + /// Materials of the given tiles, by type name and sub index + pub(crate) fn materials(fixture: &Fixture, tiles: &[(&str, u16)]) -> Materials { + let ids: Vec<(TileIndex, u32)> = tiles + .iter() + .map(|&(name, sub_index)| (fixture.tileset.tile_of(name, sub_index).unwrap(), 1)) + .collect(); + Materials::new(SelectionList::new(&fixture.tileset, ids)) + } + + pub(crate) fn rect(left: i32, top: i32, right: i32, bottom: i32) -> TileRect { + TileRect { + left, + top, + right, + bottom, + } + } + + /// Adds a room of `WALL1`, `FLOOR1` and `ROOF1` + pub(crate) fn add_room(fixture: &mut Fixture, rect: TileRect) { + let materials = materials(fixture, &[("WALL1", 1), ("FLOOR1", 1), ("ROOF1", 1)]); + let mut context = fixture.context(); + add_building_section(&mut context, rect, &materials); + context.finish(); + } + + /// The wall pieces of the structs of a tile, with whether they are horizontal + pub(crate) fn walls(fixture: &Fixture, x: u8, y: u8) -> Vec<(WallPiece, bool)> { + fixture.map.tiles[grid(x, y)] + .structs + .iter() + .filter_map(|tile| match tile.building?.piece { + BuildingPiece::Wall(piece) => { + Some((piece, tile.wall_orientation().is_horizontal())) + } + _ => None, + }) + .collect() + } + + fn roof(fixture: &Fixture, x: u8, y: u8) -> Vec<(u8, BuildingPiece)> { + fixture.map.tiles[grid(x, y)] + .roofs + .iter() + .filter_map(|tile| tile.building.map(|b| (b.style, b.piece))) + .collect() + } + + fn shadows(fixture: &Fixture, x: u8, y: u8) -> Vec { + fixture.map.tiles[grid(x, y)] + .shadows + .iter() + .filter_map(|tile| tile.building.map(|b| b.piece)) + .collect() + } + + fn has_floor(fixture: &Fixture, x: u8, y: u8) -> bool { + fixture.map.tiles[grid(x, y)] + .land + .iter() + .any(|tile| tile.is_floor()) + } + + /// Whether nothing of a building is on a tile + fn is_empty(fixture: &Fixture, x: u8, y: u8) -> bool { + let tile = &fixture.map.tiles[grid(x, y)]; + tile.structs.is_empty() + && tile.roofs.is_empty() + && tile.shadows.is_empty() + && !has_floor(fixture, x, y) + && tile.room.is_none() + } + + use WallPiece::*; + + #[test] + fn a_new_room_gets_walls_roofs_and_floors() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + for y in 50..=52 { + for x in 50..=52 { + assert!(has_floor(&fixture, x, y), "{x} {y}"); + } + } + // The top walls are stored on the row above, the left walls on the column left + for x in 50..=52 { + assert_eq!(walls(&fixture, x, 49), [(InteriorLeft, true)]); + } + assert_eq!(walls(&fixture, 49, 50), [(InteriorExtended, false)]); + assert_eq!(walls(&fixture, 49, 51), [(InteriorRight, false)]); + assert_eq!(walls(&fixture, 49, 52), [(InteriorRight, false)]); + assert_eq!(walls(&fixture, 52, 50), [(ExteriorRight, false)]); + assert_eq!(walls(&fixture, 52, 51), [(ExteriorRight, false)]); + assert_eq!(walls(&fixture, 50, 52), [(ExteriorLeft, true)]); + let mut corner = walls(&fixture, 52, 52); + corner.sort_by_key(|&(_, horizontal)| horizontal); + assert_eq!(corner, [(ExteriorBottomEnd, false), (ExteriorLeft, true)]); + assert!(walls(&fixture, 51, 51).is_empty()); + // The shadows of the bottom walls and of the bottom left corner + assert_eq!( + shadows(&fixture, 51, 52), + [BuildingPiece::Wall(ExteriorLeftShadow)] + ); + assert_eq!( + shadows(&fixture, 49, 52), + [BuildingPiece::Wall(InteriorBottomEndShadow)] + ); + + let piece = |piece| (0, BuildingPiece::Roof(Some(piece))); + assert_eq!(roof(&fixture, 50, 50), [piece(RoofPiece::TopLeft)]); + assert_eq!(roof(&fixture, 51, 50), [piece(RoofPiece::Top)]); + assert_eq!(roof(&fixture, 52, 50), [piece(RoofPiece::TopRight)]); + assert_eq!(roof(&fixture, 50, 51), [piece(RoofPiece::Left)]); + assert_eq!(roof(&fixture, 51, 51), [piece(RoofPiece::Center)]); + assert_eq!(roof(&fixture, 52, 51), [piece(RoofPiece::Right)]); + assert_eq!(roof(&fixture, 50, 52), [piece(RoofPiece::BottomLeft)]); + assert_eq!(roof(&fixture, 51, 52), [piece(RoofPiece::Bottom)]); + assert_eq!(roof(&fixture, 52, 52), [piece(RoofPiece::BottomRight)]); + assert!(roof(&fixture, 53, 51).is_empty()); + + // The world has the structures of the walls + let world = &fixture.world.tiles[grid(52, 52)]; + assert_eq!(world.structures.len(), 2); + } + + #[test] + fn new_rooms_bulldoze_nature() { + let mut fixture = building_fixture(); + let mut context = fixture.context(); + let tree = context.tile("OFSTRUCT1", 1).unwrap(); + let rock = context.tile("ROCKS", 1).unwrap(); + let road = context.tile("ROADPIECES", 1).unwrap(); + context.add_struct_to_head(grid(51, 51), tree); + context.add_object_to_head(grid(51, 51), rock); + context.add_object_to_head(grid(51, 51), road); + context.finish(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let tile = &fixture.map.tiles[grid(51, 51)]; + assert!(tile.structs.is_empty()); + let objects: Vec<&str> = tile.objects.iter().map(|t| t.type_name.as_str()).collect(); + assert_eq!(objects, ["ROADPIECES"]); + } + + #[test] + fn only_floors_replace_the_floors() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let materials = materials(&fixture, &[("FLOOR3", 1)]); + let mut context = fixture.context(); + add_building_section(&mut context, rect(51, 51, 51, 51), &materials); + context.finish(); + let floors: Vec<&str> = fixture.map.tiles[grid(51, 51)] + .land + .iter() + .filter(|t| t.is_floor()) + .map(|t| t.type_name.as_str()) + .collect(); + assert_eq!(floors, ["FLOOR3"]); + // The walls and roofs stay + assert_eq!(walls(&fixture, 51, 49), [(InteriorLeft, true)]); + assert_eq!(roof(&fixture, 51, 51).len(), 1); + } + + #[test] + fn extending_a_room_removes_the_shared_wall() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + // Overlaps the right column of the room, the style comes from the building + let other = materials(&fixture, &[("WALL2", 1), ("FLOOR2", 1), ("ROOF2", 1)]); + let mut context = fixture.context(); + add_building_section(&mut context, rect(52, 50, 55, 52), &other); + context.finish(); + for y in 50..=52 { + for x in 50..=55 { + assert!(has_floor(&fixture, x, y), "{x} {y}"); + } + // No walls inside of the building + for x in 50..=54 { + assert!( + walls(&fixture, x, y) + .iter() + .all(|&(_, horizontal)| horizontal), + "{x} {y}: {:?}", + walls(&fixture, x, y) + ); + } + assert!(walls(&fixture, 55, y) + .iter() + .any(|&(_, horizontal)| !horizontal)); + } + for x in 50..=55 { + assert_eq!(walls(&fixture, x, 49), [(InteriorLeft, true)], "{x}"); + } + // The new part has the materials of the building + let styles: Vec = fixture.map.tiles[grid(55, 50)] + .structs + .iter() + .filter_map(|t| t.building.map(|b| b.style)) + .collect(); + assert_eq!(styles, [0]); + assert_eq!( + roof(&fixture, 55, 50), + [(0, BuildingPiece::Roof(Some(RoofPiece::TopRight)))] + ); + assert_eq!( + roof(&fixture, 52, 51), + [(0, BuildingPiece::Roof(Some(RoofPiece::Center)))] + ); + } + + #[test] + fn sawing_a_room_closes_the_building() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let materials = materials(&fixture, &[]); + let mut context = fixture.context(); + context.set_room(grid(52, 51), RoomNo::new(3)); + remove_building_section(&mut context, rect(52, 50, 52, 52), &materials); + context.finish(); + for y in 50..=52 { + assert!(!has_floor(&fixture, 52, y)); + assert!(fixture.map.tiles[grid(52, y)].roofs.is_empty()); + // The new right wall of the rest + assert!( + walls(&fixture, 51, y) + .iter() + .any(|&(_, horizontal)| !horizontal), + "{y}" + ); + } + assert_eq!(fixture.map.tiles[grid(52, 51)].room, None); + assert!(walls(&fixture, 52, 49).is_empty()); + // The roof along the cut follows the new walls + assert_eq!( + roof(&fixture, 51, 51), + [(0, BuildingPiece::Roof(Some(RoofPiece::Right)))] + ); + + // Sawing the rest removes everything + let mut context = fixture.context(); + remove_building_section(&mut context, rect(50, 50, 51, 52), &materials); + context.finish(); + for y in 49..=53 { + for x in 49..=53 { + assert!(is_empty(&fixture, x, y), "{x} {y}"); + } + } + } + + #[test] + fn killing_a_building_restores_the_ground() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + add_room(&mut fixture, rect(60, 50, 61, 51)); + let mut context = fixture.context(); + context.set_room(grid(51, 51), RoomNo::new(1)); + kill_building(&mut context, grid(51, 51)); + context.finish(); + for y in 49..=53 { + for x in 49..=53 { + assert!(is_empty(&fixture, x, y), "{x} {y}"); + assert_eq!(fixture.land(grid(x, y)), [("TEXTURE1", 1)]); + } + } + // Other buildings stay + assert!(has_floor(&fixture, 60, 50)); + } + + #[test] + fn new_roofs_follow_the_floors() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let mut context = fixture.context(); + replace_with_new_roof(&mut context, grid(51, 51), 2); + // Nothing without a floor + replace_with_new_roof(&mut context, grid(60, 60), 2); + context.finish(); + let piece = |piece| (2, BuildingPiece::Roof(Some(piece))); + assert_eq!(roof(&fixture, 50, 50), [piece(RoofPiece::TopLeft)]); + assert_eq!(roof(&fixture, 52, 51), [piece(RoofPiece::Right)]); + assert_eq!(roof(&fixture, 51, 51), [piece(RoofPiece::Center)]); + assert!(fixture.map.tiles[grid(60, 60)].roofs.is_empty()); + } + + /// The slant roof pieces of a tile, as `(thick, side)`, and the A-frames + fn slant(fixture: &Fixture, x: u8, y: u8) -> Vec { + fixture.map.tiles[grid(x, y)] + .roofs + .iter() + .filter_map(|tile| tile.building.map(|b| b.piece)) + .collect() + } + + fn slant_piece(thick: bool, side: SlantSide) -> BuildingPiece { + BuildingPiece::SlantRoof(Some(SlantPiece { thick, side })) + } + + #[test] + fn slant_roofs_run_along_the_room() { + let mut fixture = building_fixture(); + let materials = materials(&fixture, &[("WALL1", 1), ("FLOOR1", 1), ("SROOF1", 1)]); + // 8 rows high: the roof runs from the right to the left + let mut context = fixture.context(); + add_building_section(&mut context, rect(50, 50, 55, 57), &materials); + context.finish(); + assert_eq!(slant(&fixture, 55, 53), [slant_piece(true, SlantSide::Top)]); + assert_eq!( + slant(&fixture, 52, 53), + [slant_piece(false, SlantSide::Top)] + ); + assert_eq!( + slant(&fixture, 50, 57), + [slant_piece(true, SlantSide::Bottom)] + ); + let aframe = |vertical, left| BuildingPiece::Wall(WallPiece::AFrame { vertical, left }); + assert_eq!(slant(&fixture, 55, 54), [aframe(false, true)]); + assert_eq!(slant(&fixture, 55, 58), [aframe(false, false)]); + // No flat roofs, but floors + assert!(slant(&fixture, 52, 51).is_empty()); + assert!(has_floor(&fixture, 52, 51)); + assert_eq!(walls(&fixture, 52, 49), [(InteriorLeft, true)]); + + // 8 columns wide: the roof runs from the bottom to the top + let mut context = fixture.context(); + add_building_section(&mut context, rect(70, 50, 77, 54), &materials); + context.finish(); + assert_eq!( + slant(&fixture, 73, 54), + [slant_piece(true, SlantSide::Left)] + ); + assert_eq!( + slant(&fixture, 73, 52), + [slant_piece(false, SlantSide::Left)] + ); + assert_eq!( + slant(&fixture, 77, 50), + [slant_piece(true, SlantSide::Right)] + ); + assert_eq!(slant(&fixture, 74, 54), [aframe(true, true)]); + assert_eq!(slant(&fixture, 78, 54), [aframe(true, false)]); + } + + #[test] + fn basements_get_flat_roofs_for_slant_roofs() { + let mut fixture = building_fixture(); + fixture.map.info.editor_smoothing = EditorSmoothing::Basement; + let materials = materials(&fixture, &[("WALL1", 1), ("FLOOR1", 1), ("SROOF1", 1)]); + let mut context = fixture.context(); + add_building_section(&mut context, rect(50, 50, 55, 57), &materials); + context.finish(); + assert!(slant(&fixture, 55, 53) + .iter() + .all(|piece| piece.is_flat_roof())); + assert!(!slant(&fixture, 55, 53).is_empty()); + } + + #[test] + fn basement_rooms_are_carved_out_of_rock() { + let mut fixture = building_fixture(); + fixture.map.info.editor_smoothing = EditorSmoothing::Basement; + // Rock is floor and roof + let mut context = fixture.context(); + for (_, _, grid_no) in rect(45, 45, 60, 60).tiles() { + add_floor(&mut context, grid_no, 0); + set_roof(&mut context, grid_no, 0, RoofPiece::Center); + } + context.finish(); + let materials = materials(&fixture, &[("WALL2", 1), ("FLOOR1", 1), ("ROOF1", 1)]); + let mut context = fixture.context(); + add_building_section(&mut context, rect(50, 50, 52, 52), &materials); + context.finish(); + // The room has no floors, and walls towards the rock without shadows + assert!(!has_floor(&fixture, 51, 51)); + assert_eq!(walls(&fixture, 51, 49), [(InteriorLeft, true)]); + assert_eq!(walls(&fixture, 52, 51), [(ExteriorRight, false)]); + assert!(shadows(&fixture, 51, 52).is_empty()); + // The walls take the style of the materials + assert_eq!( + fixture.map.tiles[grid(51, 49)].structs[0].type_name, + "WALL2" + ); + assert_eq!( + roof(&fixture, 51, 51), + [(0, BuildingPiece::Roof(Some(RoofPiece::Center)))] + ); + + // Sawing refills the rock + let mut context = fixture.context(); + remove_building_section(&mut context, rect(50, 50, 52, 52), &materials); + context.finish(); + assert!(has_floor(&fixture, 51, 51)); + assert!(walls(&fixture, 51, 49).is_empty()); + } + + #[test] + fn all_rooms_get_roofs() { + let mut fixture = building_fixture(); + let mut context = fixture.context(); + context.set_room(grid(5, 5), RoomNo::new(2)); + let old = context.tile("ROOF1", 3).unwrap(); + context.add_roof_to_head(grid(5, 5), old); + roof_all_rooms(&mut context, 3); + context.finish(); + assert_eq!( + roof(&fixture, 5, 5), + [(3, BuildingPiece::Roof(Some(RoofPiece::Center)))] + ); + assert!(roof(&fixture, 6, 5).is_empty()); + } + + #[test] + fn slant_roofs_are_picked_without_flat_roofs() { + let fixture = building_fixture(); + assert!(materials(&fixture, &[("WALL1", 1), ("SROOF1", 1)]).has_slant_roof_only()); + assert!(!materials(&fixture, &[("SROOF1", 1), ("ROOF1", 1)]).has_slant_roof_only()); + assert!(!materials(&fixture, &[("WALL1", 1)]).has_slant_roof_only()); + } + + #[test] + fn rectangles_have_rings() { + let rect = rect(0, 5, 1, 5); + let tiles: Vec<(i32, i32)> = rect.tiles().map(|(x, y, _)| (x, y)).collect(); + assert_eq!(tiles, [(0, 5), (1, 5)]); + // Off the map is left out + assert_eq!(rect.ring().count(), 3 + 3 + 1); + let area = crate::editor::brush::Brush::Area + .region(Some(GridPosition::new(3, 9)), GridPosition::new(1, 7)); + assert_eq!(TileRect::around(&area), Some(self::rect(1, 7, 3, 9))); + } +} diff --git a/src-map-editor/src/editor/building_mode.rs b/src-map-editor/src/editor/building_mode.rs new file mode 100644 index 0000000..e8ba776 --- /dev/null +++ b/src-map-editor/src/editor/building_mode.rs @@ -0,0 +1,982 @@ +//! The buildings mode (`TASK_BUILDINGS` of the original editor): placing single building tiles +//! (`PasteSingleWallCommon` of `Edit_Sys.cc`), erasing them (`EraseMapTile`) and the building +//! tools + +use std::sync::Arc; + +use bevy::input::mouse::MouseButton; +use serde::Deserialize; + +use crate::{ + map::{EditorSmoothing, GridNo, RoomNo}, + random::Random, + tileset::{BuildingPiece, Tile, TileIndex, Tileset, WallPiece}, +}; + +use super::{ + brush::{Brush, BrushArea}, + building::{ + add_building_section, is_flat_roof, remove_building_section, replace_with_new_roof, + Materials, TileRect, + }, + caves::{add_cave_section, remove_cave_section}, + document::EditContext, + doors::request_door_editing, + mode::SelectionEntry, + placement::SelectionList, + smart::{ + erase_smart, paste_smart_broken_wall, paste_smart_door, paste_smart_wall, + paste_smart_window, SmartBrokenWall, + }, + walls::{ + erase_horizontal_wall, erase_vertical_wall, is_basement, is_door, is_door_shadow, is_wall, + valid_decal_placement, + }, +}; + +/// The tools of the buildings mode, the buttons of the original's buildings taskbar +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BuildingTool { + /// Single tiles of the walls, doors, windows, roofs, broken walls, decals and floors + /// palettes, each in the layer of its piece. Decals only go onto walls and fences, broken + /// walls replace the wall of their alignment. Erasing clears the layers of the selected + /// kinds. + #[default] + Tiles, + /// Places what the smart value says: a wall that picks its piece from the walls around it, + /// doors, windows, broken walls or a hole that replace the walls of the tile, or a roof + /// style that replaces the roof of the clicked building. Erasing restores doors, windows and + /// broken walls and removes plain walls. + Smart, + /// A new room of the room palette, or an extension of the building it touches. Erasing saws + /// the room out of the buildings and closes them with new walls. + Room, + /// Selects the clicked building, clicking the ground clears the selection. Delete removes + /// the selected building, dragging it moves it and Ctrl+dragging (Cmd on macOS) copies it. The mouse is + /// handled by `selection.rs`, not by the strokes of the brush. + Select, + /// Gives the tiles of the brush the room number, erasing removes it (`PasteRoomNumber`) + RoomNumber, + /// Fills the area with rock on cave maps, the right button carves a cave out of it + Caves, + /// Asks the host to edit the lock and trap of the clicked door + DoorLocks, +} + +impl BuildingTool { + /// The tools that place single tiles of their palette + fn is_selection(self) -> bool { + self == BuildingTool::Tiles + } + + /// Whether the erase flag makes the tool remove what it places + pub fn can_erase(self) -> bool { + self.is_selection() + || matches!( + self, + BuildingTool::Room | BuildingTool::RoomNumber | BuildingTool::Smart + ) + } + + /// Whether the preview shows what a click does, for the tools on single tiles + pub fn previews(self) -> bool { + self.is_selection() || self.is_smart() + } + + fn is_smart(self) -> bool { + self == BuildingTool::Smart + } + + /// Whether the tool does something with a mouse button, the right button is only used by + /// the caves. Selecting buildings doesn't paint with any. + pub fn uses_button(self, button: MouseButton) -> bool { + if self == BuildingTool::Select { + return false; + } + match button { + MouseButton::Left => true, + MouseButton::Right => self == BuildingTool::Caves, + _ => false, + } + } +} + +/// The value of the smart method (`gubWallUIValue` and the others), as the host sends it +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SmartValue { + /// A tile of the smart palettes, whose piece tells what to place. Roofs replace the roof of + /// the building in their style. + Tile { + #[serde(rename = "tileId")] + tile_id: TileIndex, + }, + /// A hole in the wall + Hole, +} + +/// What the smart method places +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SmartPlacement { + /// A wall of a style + Wall(u8), + /// A door of a style, open or closed + Door { + style: u8, + open: bool, + }, + /// A window of a style + Window(u8), + BrokenWall(SmartBrokenWall), + /// The roof of the clicked building, in a style + Roof(u8), +} + +impl SmartValue { + /// What the value places, `None` for tiles that aren't of the smart palettes + pub fn placement(self, tileset: &Tileset) -> Option { + let tile_id = match self { + SmartValue::Hole => return Some(SmartPlacement::BrokenWall(SmartBrokenWall::Hole)), + SmartValue::Tile { tile_id } => tile_id, + }; + let building = tileset.tile(tile_id)?.building?; + // The windows and broken walls are wall pieces too, so they go first + match building.piece { + BuildingPiece::Door { open } => Some(SmartPlacement::Door { + style: building.style, + open, + }), + BuildingPiece::Wall(WallPiece::Window { style }) => Some(SmartPlacement::Window(style)), + BuildingPiece::Wall( + piece @ (WallPiece::Broken { .. } | WallPiece::Cracked | WallPiece::Smudged), + ) => Some(SmartPlacement::BrokenWall(SmartBrokenWall::Piece(piece))), + piece if piece.is_wall() => Some(SmartPlacement::Wall(building.style)), + piece if piece.is_flat_roof() => Some(SmartPlacement::Roof(building.style)), + _ => None, + } + } +} + +/// The tiles of the palettes of the buildings mode and how often each is picked +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BuildingSelections { + /// The walls, floors and roofs of new rooms + pub room: Vec, + /// The single tiles of the tiles tool, of any of its palettes + pub tiles: Vec, +} + +/// The buildings mode, set by the host with `set_editor_mode` +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BuildingMode { + pub tool: BuildingTool, + /// Removes what the tool places instead + pub erase: bool, + pub brush: Brush, + pub selections: BuildingSelections, + pub smart: Option, + /// The room the room number tool paints, 0 is no room + pub room_number: u8, +} + +/// The selection list of some entries +fn selection_list(tileset: &Tileset, entries: &[SelectionEntry]) -> SelectionList { + SelectionList::new(tileset, entries.iter().map(|e| (e.tile_id, e.count))) +} + +impl BuildingMode { + /// The entries of the palette of a tool + fn entries(&self, tool: BuildingTool) -> &[SelectionEntry] { + let selections = &self.selections; + match tool { + BuildingTool::Tiles => &selections.tiles, + _ => &selections.room, + } + } + + /// A style of the flat roofs of the tiles selection + pub fn roof_style(&self, context: &mut EditContext) -> Option { + let list = selection_list(context.tileset, &self.selections.tiles); + let tile = list.pick_where(&mut context.document.random, is_flat_roof)?; + tile.building.map(|b| b.style) + } + + /// The materials of new rooms, from the room palette + pub fn materials(&self, tileset: &Tileset) -> Materials { + Materials::new(selection_list(tileset, &self.selections.room)) + } + + /// Whether new rooms get a slant roof, which is 8 tiles wide + fn builds_slant_roofs(&self, tileset: &Tileset, smoothing: EditorSmoothing) -> bool { + self.tool == BuildingTool::Room + && !self.erase + && smoothing != EditorSmoothing::Basement + && self.materials(tileset).has_slant_roof_only() + } + + /// Whether the tiles tool places only floors, which take any brush + fn places_floors_only(&self, tileset: &Tileset) -> bool { + let tiles = &self.selections.tiles; + self.tool == BuildingTool::Tiles + && !tiles.is_empty() + && tiles + .iter() + .all(|e| tileset.tile(e.tile_id).is_some_and(|t| t.is_floor())) + } + + /// The kinds of tiles of the tiles selection, whose layers erasing clears + fn selected_kinds(&self, tileset: &Tileset) -> Vec { + let mut kinds: Vec = vec![]; + for entry in &self.selections.tiles { + let piece = tileset.tile(entry.tile_id).and_then(|t| t.building); + if let Some(building) = piece { + if !kinds.contains(&building.piece) { + kinds.push(building.piece); + } + } + } + kinds + } + + /// The brush, restricted like `EnsureSelectionType`: erasing and floors take any brush, the + /// room tools take the rectangle brushes also when erasing, slant roofs a line 8 tiles wide + /// and everything else the clicked tile + pub fn brush(&self, tileset: &Tileset, smoothing: EditorSmoothing) -> Brush { + let single = Brush::Square { size: 1 }; + if self.builds_slant_roofs(tileset, smoothing) { + return Brush::Line { width: 8 }; + } + let erase = self.erase && self.tool.can_erase(); + match (self.tool, self.brush) { + (_, Brush::Fill) => single, + (BuildingTool::Tiles, brush) if self.places_floors_only(tileset) => brush, + (BuildingTool::RoomNumber, brush) => brush, + (BuildingTool::Room | BuildingTool::Caves, Brush::Circle { size }) => { + Brush::Square { size } + } + (BuildingTool::Room | BuildingTool::Caves, brush) => brush, + (_, brush) if erase => brush, + _ => single, + } + } + + /// The tiles a click places on the tiles of the brush, for the tools that place single + /// tiles of their palette. The preview runs this with a copy of the random numbers. + pub fn plan_tiles( + &self, + random: &mut Random, + tileset: &Tileset, + area: &BrushArea, + ) -> Vec<(GridNo, Arc)> { + if !self.tool.is_selection() { + return vec![]; + } + let list = selection_list(tileset, self.entries(self.tool)); + area.tiles() + .filter_map(|grid_no| Some((grid_no, list.pick(random)?.clone()))) + .collect() + } + + /// Applies the mode to the tiles of the brush, with the left or the right button + pub fn apply(&self, context: &mut EditContext, area: &BrushArea, button: MouseButton) { + if !self.tool.uses_button(button) { + return; + } + if button == MouseButton::Right { + self.apply_right(context, area); + return; + } + let tileset = context.tileset; + if self.erase && self.tool.can_erase() { + if self.tool == BuildingTool::Room { + if let Some(rect) = TileRect::around(area) { + remove_building_section(context, rect, &self.materials(tileset)); + } + return; + } + if self.tool.is_selection() { + let kinds = self.selected_kinds(tileset); + for grid_no in area.tiles() { + erase_tiles(context, grid_no, &kinds); + } + return; + } + let materials = self.tool.is_smart().then(|| self.materials(tileset)); + for grid_no in area.tiles() { + match &materials { + Some(materials) => erase_smart(context, grid_no, materials), + None => erase(context, grid_no, self.tool), + } + } + return; + } + match self.tool { + BuildingTool::Tiles => { + let plan = self.plan_tiles(&mut context.document.random, tileset, area); + for (grid_no, tile) in plan { + let Some(building) = tile.building else { + continue; + }; + match building.piece { + // Decals only go onto walls and fences + BuildingPiece::WallDecal | BuildingPiece::Switch + if !valid_decal_placement(context, grid_no) => + { + continue; + } + BuildingPiece::Decoration + | BuildingPiece::Wall( + WallPiece::Broken { .. } | WallPiece::Cracked | WallPiece::Smudged, + ) => { + // The broken wall replaces the wall of its alignment and its shadows + if tile.wall_orientation().is_horizontal() { + erase_horizontal_wall(context, Some(grid_no)); + } else if tile.wall_orientation().is_vertical() { + erase_vertical_wall(context, Some(grid_no)); + } + } + _ => {} + } + paste_single(context, grid_no, tile); + } + } + BuildingTool::Room => { + if let Some(rect) = TileRect::around(area) { + add_building_section(context, rect, &self.materials(tileset)); + } + } + BuildingTool::Select => {} + BuildingTool::Caves => { + if let Some(rect) = TileRect::around(area) { + add_cave_section(context, rect); + } + } + BuildingTool::DoorLocks => { + if let Some(first) = area.first() { + request_door_editing(context, first); + } + } + BuildingTool::RoomNumber => { + for grid_no in area.tiles() { + context.set_room(grid_no, RoomNo::new(self.room_number)); + } + } + BuildingTool::Smart => { + let placement = self.smart.and_then(|smart| smart.placement(tileset)); + let (Some(first), Some(placement)) = (area.first(), placement) else { + return; + }; + let materials = self.materials(tileset); + match placement { + SmartPlacement::Wall(style) => { + paste_smart_wall(context, first, style, &materials); + } + SmartPlacement::Door { style, open } => { + paste_smart_door(context, first, style, open); + } + SmartPlacement::Window(window) => { + paste_smart_window(context, first, window, &materials); + } + SmartPlacement::BrokenWall(broken) => { + paste_smart_broken_wall(context, first, broken, &materials); + } + SmartPlacement::Roof(style) => replace_with_new_roof(context, first, style), + } + } + } + } + + /// The right button: the caves carve caves + fn apply_right(&self, context: &mut EditContext, area: &BrushArea) { + if self.tool == BuildingTool::Caves { + if let Some(rect) = TileRect::around(area) { + remove_cave_section(context, rect); + } + } + } +} + +/// Places a single tile of a palette of the buildings mode in the layer it belongs to +/// (`PasteSingleWallCommon`) +/// +/// Walls, doors and decorations replace the wall of their alignment, roofs the flat roof and +/// floors the floor of their type. Structures that don't fit are left out. +pub fn paste_single(context: &mut EditContext, grid_no: GridNo, tile: Arc) { + let Some(building) = tile.building else { + return; + }; + let basement = is_basement(context); + match building.piece { + BuildingPiece::OnRoof => context.add_on_roof_to_tail(grid_no, tile), + // A-frames belong to the roofs + BuildingPiece::Wall(WallPiece::AFrame { .. }) => context.add_roof_to_tail(grid_no, tile), + BuildingPiece::Wall(piece) if piece.is_shadow() => { + if !basement { + context.add_exclusive_shadow(grid_no, tile); + } + } + BuildingPiece::Door { .. } | BuildingPiece::DoorFrame => { + if let (Some(shadow), false) = (tile.door_shadow(), basement) { + context.add_exclusive_shadow(grid_no, shadow.clone()); + } + context.add_wall_to_struct_layer(grid_no, tile, true); + } + BuildingPiece::Wall(_) | BuildingPiece::Decoration => { + context.add_wall_to_struct_layer(grid_no, tile, true); + } + BuildingPiece::Roof(_) | BuildingPiece::SlantRoof(_) => { + context.remove_roofs_where(grid_no, is_flat_roof); + context.add_roof_to_tail(grid_no, tile); + } + BuildingPiece::Floor => { + if let Some(index) = context.find_type_in_land(grid_no, &tile.type_name) { + context.remove_land(grid_no, index); + } + context.add_land_to_head(grid_no, tile); + } + BuildingPiece::WallDecal => { + context.remove_structs_where(grid_no, |t| { + t.building + .is_some_and(|b| b.piece == BuildingPiece::WallDecal) + }); + if context.struct_fits(grid_no, &tile) { + context.add_struct_to_tail(grid_no, tile); + } + } + BuildingPiece::Switch => { + if context.struct_fits(grid_no, &tile) { + context.add_struct_to_tail(grid_no, tile); + } + } + // Furniture is placed with the objects of the terrain mode + BuildingPiece::DoorShadow | BuildingPiece::Furniture | BuildingPiece::GenericFurniture => {} + } +} + +fn is_decoration(tile: &Tile) -> bool { + tile.building.is_some_and(|b| b.piece.is_decoration()) +} + +/// Removes what a tool places from a tile (`EraseMapTile`) +pub fn erase(context: &mut EditContext, grid_no: GridNo, tool: BuildingTool) { + // Erasing rooms saws them out of the buildings instead, the tiles tool erases by the kinds + // of its selection + if tool == BuildingTool::RoomNumber { + context.set_room(grid_no, None); + } +} + +fn erase_roofs(context: &mut EditContext, grid_no: GridNo) { + context.remove_roofs_where(grid_no, |_| true); + context.remove_on_roofs_where(grid_no, |_| true); +} + +/// Removes the layers of some kinds of building tiles from a tile (`EraseMapTile`): walls, doors +/// and windows the walls and doors with their shadows, roof pieces the roofs and the tiles on +/// them, floors the floors and decals and decorations the decorations +pub fn erase_tiles(context: &mut EditContext, grid_no: GridNo, kinds: &[BuildingPiece]) { + let is_roof = |piece: BuildingPiece| { + matches!( + piece, + BuildingPiece::Roof(_) + | BuildingPiece::SlantRoof(_) + | BuildingPiece::OnRoof + | BuildingPiece::Wall(WallPiece::AFrame { .. }) + ) + }; + let walls = kinds + .iter() + .any(|&piece| !is_roof(piece) && (piece.is_wall() || piece.is_door())); + let roofs = kinds.iter().any(|&piece| is_roof(piece)); + let floors = kinds.contains(&BuildingPiece::Floor); + let decorations = kinds.iter().any(|piece| { + matches!( + piece, + BuildingPiece::WallDecal | BuildingPiece::Switch | BuildingPiece::Decoration + ) + }); + if walls { + context.remove_structs_where(grid_no, |tile| is_wall(tile) || is_door(tile)); + context.remove_shadows_where(grid_no, |tile| is_wall(tile) || is_door_shadow(tile)); + } + if roofs { + erase_roofs(context, grid_no); + } + if floors { + context.remove_land_where(grid_no, Tile::is_floor); + } + if decorations { + context.remove_structs_where(grid_no, is_decoration); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + components::map::GridPosition, + editor::{ + building::tests::{add_room, building_fixture, rect, walls}, + document::tests::{grid, Fixture}, + mode::EditorMode, + }, + tileset::TileIndex, + }; + + fn mode(json: &str) -> BuildingMode { + match serde_json::from_str(json).unwrap() { + EditorMode::Buildings(mode) => *mode, + other => panic!("not the buildings mode: {other:?}"), + } + } + + fn id(fixture: &Fixture, name: &str, sub_index: u16) -> TileIndex { + fixture.tileset.tile_of(name, sub_index).unwrap() + } + + fn entry(tile_id: TileIndex) -> SelectionEntry { + SelectionEntry { tile_id, count: 1 } + } + + fn apply(fixture: &mut Fixture, mode: &BuildingMode, x: u8, y: u8) { + let brush = mode.brush(&fixture.tileset, EditorSmoothing::Normal); + let area = brush.region(None, GridPosition::new(x, y)); + let mut context = fixture.context(); + mode.apply(&mut context, &area, MouseButton::Left); + context.finish(); + } + + fn structs(fixture: &Fixture, x: u8, y: u8) -> Vec<(String, u16)> { + fixture.map.tiles[grid(x, y)] + .structs + .iter() + .map(|t| (t.type_name.clone(), t.sub_index)) + .collect() + } + + #[test] + fn modes_are_read_from_json() { + let mode = mode( + r#"{"mode":"buildings","tool":"tiles","erase":true, + "brush":{"kind":"area"}, + "selections":{"room":[{"tileId":3,"count":2}],"tiles":[{"tileId":4,"count":1}]}}"#, + ); + assert_eq!(mode.tool, BuildingTool::Tiles); + assert!(mode.erase); + assert_eq!(mode.brush, Brush::Area); + assert_eq!( + mode.selections.room, + [SelectionEntry { + tile_id: 3, + count: 2 + }] + ); + assert_eq!(mode.selections.tiles, [entry(4)]); + } + + #[test] + fn brushes_are_restricted_like_the_original() { + let fixture = building_fixture(); + let tileset = &fixture.tileset; + let brush = |json: &str, smoothing| mode(json).brush(tileset, smoothing); + let normal = EditorSmoothing::Normal; + let single = Brush::Square { size: 1 }; + assert_eq!( + brush( + r#"{"mode":"buildings","tool":"tiles","brush":{"kind":"area"}}"#, + normal + ), + single + ); + // Erasing takes any brush + assert_eq!( + brush( + r#"{"mode":"buildings","tool":"tiles","erase":true,"brush":{"kind":"area"}}"#, + normal + ), + Brush::Area + ); + // Only floors take any brush + let tiles = |names: &[(&str, u16)]| { + let entries: Vec = names + .iter() + .map(|&(name, sub_index)| { + format!( + r#"{{"tileId":{},"count":1}}"#, + id(&fixture, name, sub_index) + ) + }) + .collect(); + format!( + r#"{{"mode":"buildings","tool":"tiles","brush":{{"kind":"circle","size":3}}, + "selections":{{"tiles":[{}]}}}}"#, + entries.join(",") + ) + }; + assert_eq!( + brush(&tiles(&[("FLOOR1", 1), ("FLOOR2", 3)]), normal), + Brush::Circle { size: 3 } + ); + assert_eq!(brush(&tiles(&[("WALL1", 1)]), normal), single); + assert_eq!( + brush(&tiles(&[("WALL1", 1), ("FLOOR1", 1)]), normal), + single + ); + assert_eq!(brush(&tiles(&[]), normal), single); + assert_eq!( + brush( + r#"{"mode":"buildings","tool":"room","brush":{"kind":"circle","size":3}}"#, + normal + ), + Brush::Square { size: 3 } + ); + // Erasing rooms saws them, which keeps the rectangle brushes + assert_eq!( + brush( + r#"{"mode":"buildings","tool":"room","erase":true,"brush":{"kind":"circle","size":3}}"#, + normal + ), + Brush::Square { size: 3 } + ); + assert_eq!( + brush( + r#"{"mode":"buildings","tool":"select","erase":true,"brush":{"kind":"area"}}"#, + normal + ), + single + ); + // A room with a slant roof is a line 8 tiles wide, but not in basements + let slant = format!( + r#"{{"mode":"buildings","tool":"room","brush":{{"kind":"area"}}, + "selections":{{"room":[{{"tileId":{},"count":1}}]}}}}"#, + id(&fixture, "SROOF1", 1) + ); + assert_eq!(brush(&slant, normal), Brush::Line { width: 8 }); + assert_eq!(brush(&slant, EditorSmoothing::Basement), Brush::Area); + } + + #[test] + fn walls_and_doors_replace_the_wall_of_their_alignment() { + let mut fixture = building_fixture(); + let mut walls_mode = mode(r#"{"mode":"buildings","tool":"tiles"}"#); + // Inside left, then outside left: the second replaces the first + walls_mode.selections.tiles = vec![entry(id(&fixture, "WALL1", 10))]; + apply(&mut fixture, &walls_mode, 50, 50); + walls_mode.selections.tiles = vec![entry(id(&fixture, "WALL1", 4))]; + apply(&mut fixture, &walls_mode, 50, 50); + // A vertical wall goes next to it + walls_mode.selections.tiles = vec![entry(id(&fixture, "WALL1", 1))]; + apply(&mut fixture, &walls_mode, 50, 50); + let mut placed = structs(&fixture, 50, 50); + placed.sort(); + assert_eq!(placed, [("WALL1".to_owned(), 1), ("WALL1".to_owned(), 4)]); + assert_eq!(fixture.world.tiles[grid(50, 50)].structures.len(), 2); + + // A closed door replaces the vertical wall and adds its shadow + let mut doors = mode(r#"{"mode":"buildings","tool":"tiles"}"#); + doors.selections.tiles = vec![entry(id(&fixture, "DOOR1", 1))]; + apply(&mut fixture, &doors, 50, 50); + let mut placed = structs(&fixture, 50, 50); + placed.sort(); + assert_eq!(placed, [("DOOR1".to_owned(), 1), ("WALL1".to_owned(), 4)]); + let shadows: Vec<&str> = fixture.map.tiles[grid(50, 50)] + .shadows + .iter() + .map(|t| t.type_name.as_str()) + .collect(); + assert_eq!(shadows, ["DOORSH1"]); + + // Erasing walls also removes the doors and their shadows + walls_mode.erase = true; + apply(&mut fixture, &walls_mode, 50, 50); + let tile = &fixture.map.tiles[grid(50, 50)]; + assert!(tile.structs.is_empty() && tile.shadows.is_empty()); + } + + #[test] + fn tiles_go_to_their_layers() { + let mut fixture = building_fixture(); + let mut roofs = mode(r#"{"mode":"buildings","tool":"tiles"}"#); + for (name, sub_index) in [("ROOF1", 1), ("ROOF2", 3), ("WALL1", 32), ("ONROOF1", 1)] { + roofs.selections.tiles = vec![entry(id(&fixture, name, sub_index))]; + apply(&mut fixture, &roofs, 50, 50); + } + let tile = &fixture.map.tiles[grid(50, 50)]; + let names = |tiles: &[Arc]| { + tiles + .iter() + .map(|t| format!("{} {}", t.type_name, t.sub_index)) + .collect::>() + }; + // The second roof replaced the first, the A-frame is on the roof layer + assert_eq!(names(&tile.roofs), ["ROOF2 3", "WALL1 32"]); + assert_eq!(names(&tile.on_roofs), ["ONROOF1 1"]); + + let mut floors = + mode(r#"{"mode":"buildings","tool":"tiles","brush":{"kind":"square","size":3}}"#); + floors.selections.tiles = vec![entry(id(&fixture, "FLOOR2", 3))]; + apply(&mut fixture, &floors, 50, 50); + floors.selections.tiles = vec![entry(id(&fixture, "FLOOR2", 5))]; + apply(&mut fixture, &floors, 50, 50); + assert_eq!(fixture.land(grid(49, 51)), [("TEXTURE1", 1), ("FLOOR2", 5)]); + + // Wall shadows go to the shadows + let mut walls_mode = mode(r#"{"mode":"buildings","tool":"tiles"}"#); + walls_mode.selections.tiles = vec![entry(id(&fixture, "WALL1", 30))]; + apply(&mut fixture, &walls_mode, 61, 61); + apply(&mut fixture, &walls_mode, 61, 61); + assert_eq!(fixture.map.tiles[grid(61, 61)].shadows.len(), 1); + assert!(fixture.map.tiles[grid(61, 61)].structs.is_empty()); + } + + #[test] + fn mixed_selections_place_every_kind() { + let mut fixture = building_fixture(); + let mut tiles = mode(r#"{"mode":"buildings","tool":"tiles"}"#); + tiles.selections.tiles = vec![ + entry(id(&fixture, "WALL1", 1)), + entry(id(&fixture, "FLOOR2", 3)), + ]; + // The random pick decides the tile of each click, so click until both are placed + for _ in 0..50 { + apply(&mut fixture, &tiles, 50, 50); + } + assert_eq!(structs(&fixture, 50, 50), [("WALL1".to_owned(), 1)]); + assert!(fixture.land(grid(50, 50)).contains(&("FLOOR2", 3))); + } + + #[test] + fn erasing_clears_the_layers_of_the_selected_kinds() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let tile = |fixture: &Fixture| { + let tile = &fixture.map.tiles[grid(52, 51)]; + ( + !tile.structs.is_empty(), + tile.land.iter().any(|t| t.is_floor()), + !tile.roofs.is_empty(), + ) + }; + assert_eq!(tile(&fixture), (true, true, true)); + let mut erase = mode(r#"{"mode":"buildings","tool":"tiles","erase":true}"#); + // Nothing selected erases nothing + apply(&mut fixture, &erase, 52, 51); + assert_eq!(tile(&fixture), (true, true, true)); + // Floors leave the walls and roofs + erase.selections.tiles = vec![entry(id(&fixture, "FLOOR1", 1))]; + apply(&mut fixture, &erase, 52, 51); + assert_eq!(tile(&fixture), (true, false, true)); + erase.selections.tiles = vec![entry(id(&fixture, "ROOF1", 1))]; + apply(&mut fixture, &erase, 52, 51); + assert_eq!(tile(&fixture), (true, false, false)); + erase.selections.tiles = vec![entry(id(&fixture, "WALL1", 1))]; + apply(&mut fixture, &erase, 52, 51); + assert_eq!(tile(&fixture), (false, false, false)); + } + + #[test] + fn decals_need_a_wall() { + let mut fixture = building_fixture(); + let mut decals = mode(r#"{"mode":"buildings","tool":"tiles"}"#); + decals.selections.tiles = vec![entry(id(&fixture, "WALLDECAL1", 1))]; + apply(&mut fixture, &decals, 50, 50); + assert!(structs(&fixture, 50, 50).is_empty()); + // The left fence of the structure tileset + let fence = fixture + .tileset + .tile(id(&fixture, "FENCESTRUCT", 1)) + .unwrap() + .clone(); + let mut context = fixture.context(); + context.add_struct_to_head(grid(50, 50), fence); + context.finish(); + apply(&mut fixture, &decals, 50, 50); + decals.selections.tiles = vec![entry(id(&fixture, "WALLDECAL2", 1))]; + apply(&mut fixture, &decals, 50, 50); + // The second decal replaced the first, at the tail + assert_eq!( + structs(&fixture, 50, 50), + [("FENCESTRUCT".to_owned(), 1), ("WALLDECAL2".to_owned(), 1)] + ); + } + + #[test] + fn whole_building_tools_use_the_room_palette() { + let mut fixture = building_fixture(); + let mut room = + mode(r#"{"mode":"buildings","tool":"room","brush":{"kind":"square","size":3}}"#); + room.selections.room = ["WALL2", "FLOOR1", "ROOF1"] + .iter() + .map(|name| entry(id(&fixture, name, 1))) + .collect(); + apply(&mut fixture, &room, 51, 51); + assert_eq!(walls(&fixture, 51, 49).len(), 1); + let wall = &fixture.map.tiles[grid(51, 49)].structs[0]; + assert_eq!(wall.type_name, "WALL2"); + + // Smart roofs replace the roof of the whole building + let mut new_roof = mode(r#"{"mode":"buildings","tool":"smart"}"#); + new_roof.smart = Some(SmartValue::Tile { + tile_id: id(&fixture, "ROOF4", 10), + }); + apply(&mut fixture, &new_roof, 50, 52); + assert!((50..=52).all(|x| fixture.map.tiles[grid(x, 50)].roofs[0].type_name == "ROOF4")); + } + + #[test] + fn smart_values_are_tiles_of_the_smart_palettes() { + let fixture = building_fixture(); + let tileset = &fixture.tileset; + let placement = |name, sub_index| { + let json = format!( + r#"{{"mode":"buildings","tool":"smart", + "smart":{{"kind":"tile","tileId":{}}}}}"#, + id(&fixture, name, sub_index) + ); + let mode = mode(&json); + assert_eq!(mode.tool, BuildingTool::Smart); + mode.smart.unwrap().placement(tileset) + }; + assert_eq!(placement("WALL3", 1), Some(SmartPlacement::Wall(2))); + assert_eq!( + placement("DOOR2", 5), + Some(SmartPlacement::Door { + style: 1, + open: true + }) + ); + assert_eq!(placement("WALL1", 46), Some(SmartPlacement::Window(1))); + assert_eq!( + placement("WALL1", 65), + Some(SmartPlacement::BrokenWall(SmartBrokenWall::Piece( + WallPiece::Smudged + ))) + ); + let hole = mode(r#"{"mode":"buildings","smart":{"kind":"hole"}}"#); + assert_eq!( + hole.smart.unwrap().placement(tileset), + Some(SmartPlacement::BrokenWall(SmartBrokenWall::Hole)) + ); + assert_eq!(placement("ROOF4", 10), Some(SmartPlacement::Roof(3))); + // Tiles of other palettes are no values + assert_eq!(placement("FLOOR1", 1), None); + } + + #[test] + fn smart_places_by_the_piece_and_erases_with_the_brush() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let right = |fixture: &mut Fixture, mode: &BuildingMode, x, y| { + let area = Brush::default().region(None, GridPosition::new(x, y)); + let mut context = fixture.context(); + mode.apply(&mut context, &area, MouseButton::Right); + context.finish(); + }; + let mut smart = mode(r#"{"mode":"buildings","tool":"smart","brush":{"kind":"area"}}"#); + assert_eq!( + smart.brush(&fixture.tileset, EditorSmoothing::Normal), + Brush::Square { size: 1 } + ); + smart.smart = Some(SmartValue::Tile { + tile_id: id(&fixture, "DOOR1", 1), + }); + apply(&mut fixture, &smart, 52, 51); + assert_eq!(structs(&fixture, 52, 51), [("DOOR1".to_owned(), 1)]); + // The right button does nothing + right(&mut fixture, &smart, 52, 51); + assert_eq!(structs(&fixture, 52, 51), [("DOOR1".to_owned(), 1)]); + assert!(!BuildingTool::Smart.uses_button(MouseButton::Right)); + assert!(!BuildingTool::Room.uses_button(MouseButton::Right)); + assert!(BuildingTool::Caves.uses_button(MouseButton::Right)); + + // Erasing takes the brush, restores the door and removes the plain walls + smart.erase = true; + smart.brush = Brush::Square { size: 1 }; + apply(&mut fixture, &smart, 52, 51); + assert_eq!(walls(&fixture, 52, 51).len(), 1); + assert_eq!(structs(&fixture, 52, 51)[0].0, "WALL1"); + apply(&mut fixture, &smart, 52, 51); + assert!(structs(&fixture, 52, 51).is_empty()); + } + + #[test] + fn room_numbers_take_any_brush() { + let mut fixture = building_fixture(); + let mut rooms = mode( + r#"{"mode":"buildings","tool":"roomNumber","roomNumber":7, + "brush":{"kind":"square","size":3}}"#, + ); + apply(&mut fixture, &rooms, 50, 50); + let room = |fixture: &Fixture, x, y| fixture.map.tiles[grid(x, y)].room.map(|r| r.number()); + assert_eq!(room(&fixture, 49, 51), Some(7)); + assert_eq!(room(&fixture, 52, 50), None); + rooms.brush = Brush::Square { size: 1 }; + rooms.erase = true; + apply(&mut fixture, &rooms, 50, 50); + assert_eq!(room(&fixture, 50, 50), None); + assert_eq!(room(&fixture, 51, 51), Some(7)); + // Room 0 is no room + rooms.erase = false; + rooms.room_number = 0; + apply(&mut fixture, &rooms, 51, 51); + assert_eq!(room(&fixture, 51, 51), None); + } + + #[test] + fn caves_are_filled_and_carved() { + let mut fixture = Fixture::new(); + let caves = + mode(r#"{"mode":"buildings","tool":"caves","brush":{"kind":"square","size":5}}"#); + assert_eq!( + caves.brush(&fixture.tileset, EditorSmoothing::Caves), + Brush::Square { size: 5 } + ); + apply(&mut fixture, &caves, 50, 50); + assert!(!structs(&fixture, 50, 50).is_empty()); + let area = Brush::Square { size: 3 }.region(None, GridPosition::new(50, 50)); + let mut context = fixture.context(); + caves.apply(&mut context, &area, MouseButton::Right); + context.finish(); + assert!(structs(&fixture, 50, 50).is_empty()); + assert!(!structs(&fixture, 48, 48).is_empty()); + } + + #[test] + fn erasing_rooms_saws_them() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + // Furniture is placed with the objects of the terrain mode + let furniture = fixture + .tileset + .tile(id(&fixture, "ISTRUCT1", 1)) + .unwrap() + .clone(); + let mut context = fixture.context(); + context.add_struct_to_head(grid(52, 51), furniture); + context.finish(); + assert!(structs(&fixture, 52, 51).contains(&("ISTRUCT1".to_owned(), 1))); + let erase = + mode(r#"{"mode":"buildings","tool":"room","erase":true,"brush":{"kind":"area"}}"#); + let brush = erase.brush(&fixture.tileset, EditorSmoothing::Normal); + let area = brush.region(Some(GridPosition::new(52, 50)), GridPosition::new(52, 52)); + let mut context = fixture.context(); + erase.apply(&mut context, &area, MouseButton::Left); + context.finish(); + for y in 50..=52 { + let tile = &fixture.map.tiles[grid(52, y)]; + assert!(!tile.land.iter().any(|tile| tile.is_floor()), "{y}"); + assert!(tile.roofs.is_empty(), "{y}"); + // The new right wall of the rest + assert!( + walls(&fixture, 51, y) + .iter() + .any(|&(_, horizontal)| !horizontal), + "{y}" + ); + } + assert!(structs(&fixture, 52, 51).is_empty()); + } +} diff --git a/src-map-editor/src/editor/caves.rs b/src-map-editor/src/editor/caves.rs new file mode 100644 index 0000000..3f227eb --- /dev/null +++ b/src-map-editor/src/editor/caves.rs @@ -0,0 +1,290 @@ +//! Caves (`AddCaveSectionToWorld` and `RemoveCaveSectionFromWorld` of `NewSmooth.cc`) +//! +//! Cave maps are rock, the caves are carved out of it. The rock is made of the pieces of the +//! first two wall types of the cave tilesets, and the piece of a tile follows which of its 8 +//! neighbours are rock. + +use std::sync::Arc; + +use crate::{ + map::GridNo, + tileset::{StructureFlags, Tile}, +}; + +use super::{ + building::TileRect, + document::EditContext, + walls::{at, is_wall}, +}; + +/// The rock piece of each perimeter value (`GetCaveTileIndexFromPerimeterValue`): the tile +/// type, the first sub index and the number of variants. `None` is a lone piece, which is +/// removed. +/// +/// The perimeter value has a bit for each neighbour that is rock: +/// +/// ```text +/// 16 | 1 | 32 +/// ---+---+--- +/// 8 | | 2 +/// ---+---+--- +/// 128| 4 | 64 +/// ``` +#[rustfmt::skip] +const CAVE_PIECES: [Option<(&str, u16, u16)>; 256] = [ + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 1, 1)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 4, 1)), Some(("WALL1", 5, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 6, 1)), Some(("WALL1", 7, 2)), Some(("WALL1", 9, 1)), + Some(("WALL1", 10, 1)), Some(("WALL1", 11, 1)), Some(("WALL1", 12, 1)), Some(("WALL1", 13, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 1, 1)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 4, 1)), Some(("WALL1", 5, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 14, 2)), Some(("WALL1", 7, 2)), Some(("WALL1", 16, 1)), + Some(("WALL1", 10, 1)), Some(("WALL1", 17, 1)), Some(("WALL1", 12, 1)), Some(("WALL1", 18, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 19, 2)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 4, 1)), Some(("WALL1", 21, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 6, 1)), Some(("WALL1", 7, 2)), Some(("WALL1", 22, 1)), + Some(("WALL1", 10, 1)), Some(("WALL1", 11, 1)), Some(("WALL1", 12, 1)), Some(("WALL1", 23, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 19, 2)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 4, 1)), Some(("WALL1", 21, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 14, 2)), Some(("WALL1", 7, 2)), Some(("WALL1", 24, 3)), + Some(("WALL1", 10, 1)), Some(("WALL1", 17, 1)), Some(("WALL1", 12, 1)), Some(("WALL1", 27, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 1, 1)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 28, 2)), Some(("WALL1", 30, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 6, 1)), Some(("WALL1", 7, 2)), Some(("WALL1", 9, 1)), + Some(("WALL1", 10, 1)), Some(("WALL1", 11, 1)), Some(("WALL1", 31, 1)), Some(("WALL1", 32, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 1, 1)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 28, 2)), Some(("WALL1", 30, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 14, 2)), Some(("WALL1", 7, 2)), Some(("WALL1", 16, 1)), + Some(("WALL1", 10, 1)), Some(("WALL1", 17, 1)), Some(("WALL1", 31, 1)), Some(("WALL1", 33, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 19, 2)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 28, 2)), Some(("WALL1", 34, 3)), + Some(("WALL2", 13, 4)), Some(("WALL1", 6, 1)), Some(("WALL1", 7, 2)), Some(("WALL1", 22, 1)), + Some(("WALL1", 10, 1)), Some(("WALL1", 11, 1)), Some(("WALL1", 31, 1)), Some(("WALL1", 37, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 19, 2)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 28, 2)), Some(("WALL1", 34, 3)), + Some(("WALL2", 13, 4)), Some(("WALL1", 14, 2)), Some(("WALL1", 7, 2)), Some(("WALL1", 24, 3)), + Some(("WALL1", 10, 1)), Some(("WALL1", 17, 1)), Some(("WALL1", 31, 1)), Some(("WALL1", 38, 2)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 1, 1)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 4, 1)), Some(("WALL1", 5, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 6, 1)), Some(("WALL1", 7, 2)), Some(("WALL1", 9, 1)), + Some(("WALL1", 40, 2)), Some(("WALL1", 42, 1)), Some(("WALL1", 43, 1)), Some(("WALL1", 44, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 1, 1)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 4, 1)), Some(("WALL1", 5, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 14, 2)), Some(("WALL1", 7, 2)), Some(("WALL1", 16, 1)), + Some(("WALL1", 40, 2)), Some(("WALL1", 45, 3)), Some(("WALL1", 43, 1)), Some(("WALL1", 48, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 19, 2)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 4, 1)), Some(("WALL1", 21, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 6, 1)), Some(("WALL1", 7, 2)), Some(("WALL1", 22, 1)), + Some(("WALL1", 40, 2)), Some(("WALL1", 42, 1)), Some(("WALL1", 43, 1)), Some(("WALL1", 49, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 19, 2)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 4, 1)), Some(("WALL1", 21, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 14, 2)), Some(("WALL1", 7, 2)), Some(("WALL1", 24, 3)), + Some(("WALL1", 40, 2)), Some(("WALL1", 45, 3)), Some(("WALL1", 43, 1)), Some(("WALL1", 50, 2)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 1, 1)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 28, 2)), Some(("WALL1", 30, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 6, 1)), Some(("WALL1", 7, 2)), Some(("WALL1", 9, 1)), + Some(("WALL1", 40, 2)), Some(("WALL1", 42, 1)), Some(("WALL1", 52, 3)), Some(("WALL1", 55, 1)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 1, 1)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 28, 2)), Some(("WALL1", 30, 1)), + Some(("WALL2", 13, 4)), Some(("WALL1", 14, 2)), Some(("WALL1", 7, 2)), Some(("WALL1", 16, 1)), + Some(("WALL1", 40, 2)), Some(("WALL1", 45, 3)), Some(("WALL1", 52, 3)), Some(("WALL1", 56, 2)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 19, 2)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 28, 2)), Some(("WALL1", 34, 3)), + Some(("WALL2", 13, 4)), Some(("WALL1", 6, 1)), Some(("WALL1", 7, 2)), Some(("WALL1", 22, 1)), + Some(("WALL1", 40, 2)), Some(("WALL1", 42, 1)), Some(("WALL1", 52, 3)), Some(("WALL1", 58, 2)), + None, Some(("WALL2", 1, 4)), Some(("WALL2", 5, 4)), Some(("WALL1", 19, 2)), + Some(("WALL2", 9, 4)), Some(("WALL1", 2, 2)), Some(("WALL1", 28, 2)), Some(("WALL1", 34, 3)), + Some(("WALL2", 13, 4)), Some(("WALL1", 14, 2)), Some(("WALL1", 7, 2)), Some(("WALL1", 24, 3)), + Some(("WALL1", 40, 2)), Some(("WALL1", 45, 3)), Some(("WALL1", 52, 3)), Some(("WALL1", 60, 6)), +]; +/// The perimeter value of a tile surrounded by rock +const SURROUNDED: u8 = 0xff; + +/// Whether a tile is rock (`CaveAtGridNo`): off the map, a cave wall structure, or a wall the +/// editor placed as rock +pub fn cave_at(context: &EditContext, grid_no: Option) -> bool { + let Some(grid_no) = grid_no else { + return true; + }; + let tile = &context.map.tiles[grid_no]; + tile.structs.iter().any(|t| { + t.structure + .as_ref() + .is_some_and(|s| s.flags.contains(StructureFlags::CAVEWALL)) + || (tile.cave && is_wall(t)) + }) +} + +/// Which of the 8 neighbours of a tile are rock (`CalcNewCavePerimeterValue`) +fn perimeter_value(context: &EditContext, grid_no: GridNo) -> u8 { + const NEIGHBOURS: [(i32, i32, u8); 8] = [ + (0, -1, 0x01), + (1, 0, 0x02), + (0, 1, 0x04), + (-1, 0, 0x08), + (-1, -1, 0x10), + (1, -1, 0x20), + (1, 1, 0x40), + (-1, 1, 0x80), + ]; + NEIGHBOURS + .iter() + .filter(|&&(dx, dy, _)| cave_at(context, at(grid_no, dx, dy))) + .map(|&(_, _, bit)| bit) + .sum() +} + +/// A random variant of the rock piece of a perimeter value, `None` for a lone piece or if the +/// tileset lacks it +fn cave_piece(context: &mut EditContext, perimeter: u8) -> Option> { + let (type_name, first, variants) = CAVE_PIECES[usize::from(perimeter)]?; + let sub_index = first + context.random(u32::from(variants)) as u16; + context.tile(type_name, sub_index) +} + +/// Removes the rock of a tile +fn remove_cave(context: &mut EditContext, grid_no: GridNo) { + context.remove_structs_where(grid_no, is_wall); + if context.map.tiles[grid_no].cave { + context.modify(grid_no).cave = false; + } +} + +/// Replaces the rock of a tile by a piece (`AddCave`) +fn add_cave(context: &mut EditContext, grid_no: GridNo, tile: Arc) { + remove_cave(context, grid_no); + if context.add_wall_to_struct_layer(grid_no, tile, true) { + context.modify(grid_no).cave = true; + } +} + +/// Gives the rock of the area and the ring around it the pieces that fit their neighbours, lone +/// pieces are removed +fn smooth_caves(context: &mut EditContext, rect: TileRect) { + for (_, _, grid_no) in rect.grown().tiles() { + if !cave_at(context, Some(grid_no)) { + continue; + } + let perimeter = perimeter_value(context, grid_no); + match cave_piece(context, perimeter) { + Some(tile) => add_cave(context, grid_no, tile), + None => remove_cave(context, grid_no), + } + } +} + +/// Fills an area with rock (`AddCaveSectionToWorld`) +pub fn add_cave_section(context: &mut EditContext, rect: TileRect) { + for (_, _, grid_no) in rect.tiles() { + if let Some(tile) = cave_piece(context, SURROUNDED) { + add_cave(context, grid_no, tile); + } + } + smooth_caves(context, rect); +} + +/// Carves a cave out of the rock (`RemoveCaveSectionFromWorld`) +pub fn remove_cave_section(context: &mut EditContext, rect: TileRect) { + for (_, _, grid_no) in rect.tiles() { + remove_cave(context, grid_no); + } + smooth_caves(context, rect); +} + +/// Marks the rock pieces of a cave map, which the map file doesn't +/// (`AnalyseCaveMapForStructureInfo`): the pieces surrounded by rock, `WALL1` 60 to 65 +pub fn mark_caves(map: &mut crate::map::TacticalMap) { + for tile in map.tiles.values_mut() { + tile.cave = tile + .structs + .iter() + .any(|t| t.type_name == "WALL1" && (60..=65).contains(&t.sub_index)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::{ + building::tests::rect, + document::tests::{grid, Fixture}, + }; + + fn rock(fixture: &Fixture, x: u8, y: u8) -> Vec<(&str, u16)> { + fixture.map.tiles[grid(x, y)] + .structs + .iter() + .map(|t| (t.type_name.as_str(), t.sub_index)) + .collect() + } + + #[test] + fn every_perimeter_has_a_piece_or_is_lone() { + for (perimeter, piece) in CAVE_PIECES.iter().enumerate() { + // Without neighbours at the sides, a piece is lone + assert_eq!(piece.is_none(), perimeter & 0x0f == 0, "{perimeter:#x}"); + } + assert_eq!(CAVE_PIECES[0xff], Some(("WALL1", 60, 6))); + assert_eq!(CAVE_PIECES[0x46], Some(("WALL1", 28, 2))); + } + + #[test] + fn rock_is_smoothed_by_its_neighbours() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + add_cave_section(&mut context, rect(50, 50, 54, 54)); + // A lone piece is removed again + add_cave_section(&mut context, rect(70, 70, 70, 70)); + for (_, _, grid_no) in rect(50, 50, 54, 54).tiles() { + assert!(cave_at(&context, Some(grid_no))); + } + assert!(!cave_at(&context, Some(grid(55, 52)))); + // Off the map is rock + assert!(cave_at(&context, None)); + context.finish(); + let center = rock(&fixture, 52, 52); + assert_eq!(center.len(), 1); + assert!(center[0].0 == "WALL1" && (60..=65).contains(¢er[0].1)); + // East, south and south east are rock + let corner = rock(&fixture, 50, 50); + assert!( + corner == [("WALL1", 28)] || corner == [("WALL1", 29)], + "{corner:?}" + ); + assert!(rock(&fixture, 70, 70).is_empty()); + assert!(!fixture.map.tiles[grid(70, 70)].cave); + + // Carving the middle smooths the rock around it + let mut context = fixture.context(); + remove_cave_section(&mut context, rect(51, 51, 53, 53)); + context.finish(); + assert!(rock(&fixture, 52, 52).is_empty()); + assert!(!fixture.map.tiles[grid(52, 52)].cave); + // Only east and south are rock now + assert_eq!(rock(&fixture, 50, 50), [("WALL1", 4)]); + } + + #[test] + fn loaded_rock_is_marked() { + let mut fixture = Fixture::new(); + let rock_tile = fixture + .tileset + .tile(fixture.tileset.tile_of("WALL1", 62).unwrap()) + .unwrap() + .clone(); + let wall = fixture + .tileset + .tile(fixture.tileset.tile_of("WALL1", 3).unwrap()) + .unwrap() + .clone(); + fixture.map.tiles[grid(5, 5)].structs.push(rock_tile); + fixture.map.tiles[grid(6, 5)].structs.push(wall); + mark_caves(&mut fixture.map); + assert!(fixture.map.tiles[grid(5, 5)].cave); + assert!(!fixture.map.tiles[grid(6, 5)].cave); + // The pieces around rock count as rock once they are marked + let context = fixture.context(); + assert!(cave_at(&context, Some(grid(5, 5)))); + assert!(!cave_at(&context, Some(grid(6, 5)))); + } +} diff --git a/src-map-editor/src/editor/cliffs.rs b/src-map-editor/src/editor/cliffs.rs new file mode 100644 index 0000000..ba6f8e5 --- /dev/null +++ b/src-map-editor/src/editor/cliffs.rs @@ -0,0 +1,432 @@ +//! Placing cliffs (`PasteBanks`) and raising the land behind them (`RaiseWorldLand`) +//! +//! A cliff piece is a `FIRSTCLIFF` structure with the `FIRSTCLIFFHANG` object of the same sub +//! index, the overhanging edge of the land above. The world adds the `FIRSTCLIFFSHADOW` buddy +//! shadow of the structure outdoors. The pieces are picked by hand like in the original editor, +//! there is no smoothing. The height of the tiles is worked out from the pieces of the whole map +//! afterwards. + +use std::sync::Arc; + +use crate::{ + components::map::{JA2_MAP_SIZE, JA2_MAP_TILES}, + map::{grid::out_of_bounds, EditorSmoothing, GridNo, TacticalMap}, + tileset::{Tile, Tileset}, +}; + +use super::document::EditContext; + +/// Type of the cliff structures +const CLIFF: &str = "FIRSTCLIFF"; +/// Type of the overhanging edges of the cliffs, in the objects layer +const CLIFF_HANG: &str = "FIRSTCLIFFHANG"; +/// Type of the shadows of the cliffs +const CLIFF_SHADOW: &str = "FIRSTCLIFFSHADOW"; + +/// Height of one cliff in pixels (`WORLD_CLIFF_HEIGHT`) +pub const WORLD_CLIFF_HEIGHT: i32 = 80; + +// The cliff pieces where the land drops instead of rising (`LAND_DROP_1` to `LAND_DROP_5`) +const LAND_DROP_1: u16 = 1; +const LAND_DROP_2: u16 = 11; +const LAND_DROP_3: u16 = 12; +const LAND_DROP_4: u16 = 15; +const LAND_DROP_5: u16 = 8; + +pub fn is_cliff(tile: &Tile) -> bool { + tile.type_name == CLIFF +} + +pub fn is_cliff_hang(tile: &Tile) -> bool { + tile.type_name == CLIFF_HANG +} + +pub fn is_cliff_shadow(tile: &Tile) -> bool { + tile.type_name == CLIFF_SHADOW +} + +/// The cliff structure and its overhanging edge that placing a cliff piece on a tile adds, the +/// edge is left out in basements (`PasteBanks`) +/// +/// Nothing is placed if the piece is already the head of the structs. +pub fn planned_cliff( + map: &TacticalMap, + tileset: &Tileset, + grid_no: GridNo, + sub_index: u16, +) -> Option<(Arc, Option>)> { + let tile = |type_name| { + tileset + .tile(tileset.tile_of(type_name, sub_index)?) + .cloned() + }; + let cliff = tile(CLIFF)?; + if map.tiles[grid_no] + .structs + .first() + .is_some_and(|head| head.id == cliff.id) + { + return None; + } + let basement = map.info.editor_smoothing == EditorSmoothing::Basement; + let hang = tile(CLIFF_HANG).filter(|_| !basement); + Some((cliff, hang)) +} + +/// Places a cliff piece on a tile, see [`planned_cliff`] +pub fn place_cliff(context: &mut EditContext, grid_no: GridNo, sub_index: u16) { + let Some((cliff, hang)) = planned_cliff(context.map, context.tileset, grid_no, sub_index) + else { + return; + }; + context.add_struct_to_head(grid_no, cliff); + if let Some(hang) = hang { + context.add_object_to_head(grid_no, hang); + } +} + +/// Removes the cliffs of a tile with their shadows and overhanging edges (`EraseMapTile` for +/// banks) +pub fn erase_cliffs(context: &mut EditContext, grid_no: GridNo) { + context.remove_structs_where(grid_no, is_cliff); + context.remove_shadows_where(grid_no, is_cliff_shadow); + context.remove_objects_where(grid_no, is_cliff_hang); +} + +/// Sets the height of the tiles from the cliffs of the map (`RaiseWorldLand`), only the tiles +/// whose height changes are modified +pub fn raise_world_land(context: &mut EditContext) { + let heights = world_land_heights(|g| &context.map.tiles[g].structs); + for grid_no in GridNo::all() { + let height = heights[grid_no.index()]; + if context.map.tiles[grid_no].height != height { + context.modify(grid_no).height = height; + } + } +} + +/// The marks of the cliffs on the tiles (`MAPELEMENT_RAISE_LAND_START` and `..._END`) +#[derive(Clone, Copy, Default)] +struct Marks { + start: bool, + end: bool, +} + +/// The height of each tile in grid number order, from the cliff structs of the tiles +/// (`RaiseWorldLand`) +/// +/// Each row is scanned from its end: the pieces where the land rises mark the start of a raised +/// stretch, the ones where it drops mark its end, and the tiles in between are one cliff higher. +/// This is a port of the original with its quirks, so that the maps get the heights the original +/// editor gave them. Unlike the original, a drop at the start of a row doesn't hang the editor and +/// tiles off the map are left out. +fn world_land_heights<'a>(structs: impl Fn(GridNo) -> &'a [Arc]) -> Vec { + const ROWS: i32 = JA2_MAP_SIZE as i32; + const MAX: i32 = JA2_MAP_TILES as i32; + + let mut marks = vec![Marks::default(); JA2_MAP_TILES]; + let mut heights = vec![0u8; JA2_MAP_TILES]; + let in_map = |g: i32| (0..MAX).contains(&g); + + // The sub index of the last piece of several tiles, the original also uses it for the single + // tile pieces + let mut sub_index = u16::MAX; + let mut something_raised = false; + for grid_no in GridNo::all() { + let cnt = grid_no.index() as i32; + for tile in structs(grid_no).iter().filter(|t| is_cliff(t)) { + something_raised = true; + if tile.covered_tiles.len() > 1 { + for (part, &(x, y)) in tile.covered_tiles.iter().enumerate() { + sub_index = tile.sub_index; + let temp = cnt + i32::from(x) + i32::from(y) * ROWS; + if out_of_bounds(cnt, temp) || !in_map(temp) { + continue; + } + let temp = temp as usize; + if matches!(sub_index, LAND_DROP_1 | LAND_DROP_2 | LAND_DROP_4) + || (sub_index == LAND_DROP_3 && part <= 2) + { + marks[temp] = Marks { + start: false, + end: true, + }; + } else if sub_index == LAND_DROP_5 && part == 4 { + marks[temp] = Marks { + start: false, + end: true, + }; + if let Some(next) = marks.get_mut(temp + 1) { + next.start = true; + } + } else { + marks[temp].start = true; + } + } + } else if matches!(sub_index, LAND_DROP_1 | LAND_DROP_3) { + marks[cnt as usize] = Marks { + start: false, + end: true, + }; + } else { + marks[cnt as usize].start = true; + } + } + } + if !something_raised { + return heights; + } + + // Rows with a drop but no rise start raised at their end + let mut raise_set = false; + for cnt in (0..MAX).rev() { + if cnt % ROWS == ROWS - 1 { + raise_set = false; + } + if marks[cnt as usize].start { + raise_set = true; + } else if marks[cnt as usize].end && !raise_set { + let row_end = cnt + (ROWS - 1 - cnt % ROWS); + marks[row_end as usize] = Marks { + start: true, + end: false, + }; + if row_end - ROWS > 0 { + marks[(row_end - ROWS) as usize] = Marks { + start: true, + end: false, + }; + } + raise_set = true; + } + } + + // A cliff along the lower or the right edge of the map, along the diagonal from x = 159, + // y = 80 to x = 80, y = 0 + let mut raise_set = false; + let mut about_to_raise = false; + let mut cnt = MAX - (ROWS / 2) * (ROWS - 2) - 1; + while cnt > ROWS - 1 { + if about_to_raise { + raise_set = true; + about_to_raise = false; + } + let around = [cnt, cnt - 1, cnt + 1].map(|g| marks[g as usize]); + if around.iter().any(|m| m.start) { + about_to_raise = true; + raise_set = false; + } else if around.iter().any(|m| m.end) { + raise_set = false; + } + if raise_set { + marks[(cnt + (ROWS - 1 - cnt % ROWS)) as usize].start = true; + } + cnt -= ROWS + 1; + } + + // Count the rises and drops of each row from its end + let mut raises = 0; + let mut start_raises = 0; + let mut cnt = MAX - 1; + while cnt >= 0 { + let row_start = cnt % ROWS == 0; + if row_start { + raises = 0; + start_raises = 0; + } + let mark = |g: i32| marks[g as usize]; + if mark(cnt).end { + if cnt > 1 && !mark(cnt - 1).end && !mark(cnt - 2).end { + raises -= 1; + } + } else if mark(cnt).start && cnt < MAX - 2 && !mark(cnt + 1).start && !mark(cnt + 2).start { + raises += 1; + } + + // More drops than rises, the row starts again one cliff higher + if raises < 0 { + if row_start { + // The original starts the row again forever + raises = 0; + } else { + start_raises += 1; + cnt += ROWS - cnt % ROWS - 1; + raises = start_raises; + continue; + } + } + heights[cnt as usize] = (raises * WORLD_CLIFF_HEIGHT) as u8; + cnt -= 1; + } + + // Tiles that differ from the tiles of the rows before and after them + for cnt in (0..MAX).rev() { + if cnt < MAX - ROWS && cnt > ROWS { + let (cnt, above, below) = (cnt as usize, (cnt - ROWS) as usize, (cnt + ROWS) as usize); + if heights[below] == heights[above] && heights[cnt] != heights[above] { + heights[cnt] = heights[below]; + } else if heights[cnt] > heights[above] + && heights[below] != heights[above] + && heights[cnt] > heights[below] + { + heights[cnt] = heights[above].max(heights[below]); + } + } + } + heights +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::document::tests::{grid, Fixture}; + + fn types(tiles: &[Arc]) -> Vec<(&str, u16)> { + tiles + .iter() + .map(|t| (t.type_name.as_str(), t.sub_index)) + .collect() + } + + /// A cliff piece covering the given tiles + fn piece(fixture: &Fixture, sub_index: u16, covered: &[(i8, i8)]) -> Arc { + let index = fixture.tileset.tile_of(CLIFF, sub_index).unwrap(); + let mut tile = (**fixture.tileset.tile(index).unwrap()).clone(); + tile.covered_tiles = covered.to_vec(); + Arc::new(tile) + } + + fn heights_of(structs: &[(GridNo, Arc)]) -> Vec { + let mut tiles = vec![Vec::new(); JA2_MAP_TILES]; + for (grid_no, tile) in structs { + tiles[grid_no.index()].push(tile.clone()); + } + world_land_heights(|g| &tiles[g.index()]) + } + + fn height(heights: &[u8], x: u8, y: u8) -> u8 { + heights[grid(x, y).index()] + } + + #[test] + fn a_cliff_is_placed_with_its_hang() { + let mut fixture = Fixture::new(); + let g = grid(50, 50); + let mut context = fixture.context(); + place_cliff(&mut context, g, 3); + // The same piece again changes nothing + place_cliff(&mut context, g, 3); + context.finish(); + assert_eq!(types(&fixture.map.tiles[g].structs), [(CLIFF, 3)]); + assert_eq!(types(&fixture.map.tiles[g].objects), [(CLIFF_HANG, 3)]); + + let mut context = fixture.context(); + erase_cliffs(&mut context, g); + context.finish(); + assert!(fixture.map.tiles[g].structs.is_empty()); + assert!(fixture.map.tiles[g].objects.is_empty()); + assert!(fixture.map.tiles[g].shadows.is_empty()); + } + + #[test] + fn erasing_removes_the_cliff_shadows() { + let mut fixture = Fixture::new(); + let g = grid(50, 50); + let shadow = fixture.context().tile(CLIFF_SHADOW, 3).unwrap(); + fixture.map.tiles[g].shadows.push(shadow); + let mut context = fixture.context(); + erase_cliffs(&mut context, g); + context.finish(); + assert!(fixture.map.tiles[g].shadows.is_empty()); + } + + #[test] + fn basements_have_no_hangs() { + let mut fixture = Fixture::new(); + fixture.map.info.editor_smoothing = EditorSmoothing::Basement; + let g = grid(50, 50); + let mut context = fixture.context(); + place_cliff(&mut context, g, 3); + context.finish(); + assert_eq!(types(&fixture.map.tiles[g].structs), [(CLIFF, 3)]); + assert!(fixture.map.tiles[g].objects.is_empty()); + } + + #[test] + fn the_land_is_flat_without_cliffs() { + assert!(heights_of(&[]).iter().all(|&h| h == 0)); + } + + #[test] + fn the_land_between_a_rise_and_a_drop_is_raised() { + let fixture = Fixture::new(); + // Rows are scanned from their end: a rise at x = 60 and a drop at x = 40 + let mut structs = Vec::new(); + for y in 40..60 { + structs.push((grid(60, y), piece(&fixture, 2, &[(0, 0), (0, 1)]))); + structs.push((grid(40, y), piece(&fixture, LAND_DROP_1, &[(0, 0), (0, 1)]))); + } + let heights = heights_of(&structs); + assert_eq!(height(&heights, 50, 50), WORLD_CLIFF_HEIGHT as u8); + assert_eq!(height(&heights, 41, 50), WORLD_CLIFF_HEIGHT as u8); + assert_eq!(height(&heights, 60, 50), WORLD_CLIFF_HEIGHT as u8); + assert_eq!(height(&heights, 40, 50), 0); + assert_eq!(height(&heights, 61, 50), 0); + assert_eq!(height(&heights, 50, 30), 0); + assert_eq!(height(&heights, 50, 70), 0); + } + + #[test] + fn a_drop_without_a_rise_raises_the_rest_of_the_row() { + let fixture = Fixture::new(); + let structs: Vec<_> = (40..60) + .map(|y| (grid(40, y), piece(&fixture, LAND_DROP_1, &[(0, 0), (0, 1)]))) + .collect(); + let heights = heights_of(&structs); + assert_eq!(height(&heights, 159, 50), WORLD_CLIFF_HEIGHT as u8); + assert_eq!(height(&heights, 41, 50), WORLD_CLIFF_HEIGHT as u8); + assert_eq!(height(&heights, 39, 50), 0); + } + + #[test] + fn a_drop_at_the_start_of_a_row_ends() { + let fixture = Fixture::new(); + let heights = heights_of(&[(grid(0, 50), piece(&fixture, LAND_DROP_1, &[(0, 0), (0, 1)]))]); + assert_eq!(height(&heights, 0, 50), 0); + } + + #[test] + fn placing_and_erasing_cliffs_raises_the_land_with_undo() { + let mut fixture = Fixture::new(); + // The test tileset has no tile locations, single tile pieces use the last sub index of + // the pieces of several tiles, which is none here: they all rise + let mut context = fixture.context(); + for y in 40..60 { + place_cliff(&mut context, grid(60, y), 2); + } + raise_world_land(&mut context); + context.finish(); + fixture.document.undo.end(); + assert_eq!(fixture.map.tiles[grid(60, 50)].height, 80); + assert_eq!(fixture.map.tiles[grid(1, 50)].height, 80); + // The count starts again on the first tile of a row + assert_eq!(fixture.map.tiles[grid(0, 50)].height, 0); + assert_eq!(fixture.map.tiles[grid(61, 50)].height, 0); + + let mut context = fixture.context(); + for y in 40..60 { + erase_cliffs(&mut context, grid(60, y)); + } + raise_world_land(&mut context); + context.finish(); + fixture.document.undo.end(); + assert!(fixture.map.tiles.values().all(|t| t.height == 0)); + + let command = fixture.document.undo.pop().unwrap(); + let mut context = fixture.context(); + context.restore(command); + context.finish(); + assert_eq!(fixture.map.tiles[grid(60, 50)].height, 80); + assert_eq!(fixture.world.tiles[grid(60, 50)].height, 80); + } +} diff --git a/src-map-editor/src/editor/commands.rs b/src-map-editor/src/editor/commands.rs new file mode 100644 index 0000000..780ac68 --- /dev/null +++ b/src-map-editor/src/editor/commands.rs @@ -0,0 +1,327 @@ +//! The requests of the host: the mode, undo and the map background, and the state of the editor +//! that the host is told about + +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{ + host::report_editor_event, + map::{EditorSmoothing, GridNo, TacticalMap}, + states::loading::MapToLoad, + take_editor_commands, take_requested_editor_mode, +}; + +use super::{ + building::roof_all_rooms, + caves::mark_caves, + document::EditorDocument, + doors::DoorLock, + ground::set_background_texture, + input::EditorStroke, + item_events::{run_item_command, update_item, ItemCommand, ItemCommandParams, ItemEdit}, + light_events::{run_light_command, update_light, LightCommand, LightCommandParams, LightEdit}, + mode::EditorMode, + soldier_events::{ + run_soldier_command, update_soldier, SoldierCommand, SoldierCommandParams, SoldierEdit, + }, + soldiers::edit_once, + traversal::TraversalSelection, + traversal_events::{update_traversal, TraversalEdit}, + undo::UndoCommand, + MapEdit, +}; + +/// A request of the host that must not get lost, unlike the mode, which is replaced +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditorCommand { + /// Undoes the last stroke or background change + Undo, + /// Redoes the last undone command + Redo, + /// Replaces the ground of the whole map by a texture, by the name of its tile type + /// (`SetBackgroundTexture`) + SetBackground(String), + /// Sets how the editor smooths the map (`SetEditorSmoothingMode`) + SetSmoothing(EditorSmoothing), + /// Sets whether the game only scrolls over the middle of the map (`ubRestrictedScrollID`) + SetRestrictedScroll(bool), + /// Gives the rooms a flat roof of the roofs selection of the buildings mode + RoofAllRooms, + /// Adds, replaces or removes the door of a tile in the door table + SetDoor(DoorRequest), + /// Edits the selected soldier + UpdateSoldier(Box), + Soldier(SoldierCommand), + /// Edits the selected world item + UpdateItem(Box), + Item(ItemCommand), + /// Edits the selected light + UpdateLight(LightEdit), + Light(LightCommand), + /// Edits the selected entry point or exit grid + UpdateTraversal(TraversalEdit), +} + +/// The door of a tile as the host sets it, `door` is `None` to remove it +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DoorRequest { + pub grid_no: u16, + pub door: Option, +} + +/// The mode the mouse edits with +#[derive(Resource, Clone, Debug, Default)] +pub struct CurrentMode(pub EditorMode); + +pub fn apply_requested_mode(mut mode: ResMut) { + if let Some(requested) = take_requested_editor_mode() { + if mode.0 != requested { + mode.0 = requested; + } + } +} + +/// Starts editing a newly loaded map +pub fn start_editing( + time: Res>, + mut map: ResMut, + mut document: ResMut, + mut stroke: ResMut, + mut reported: ResMut, +) { + if map.info.editor_smoothing == EditorSmoothing::Caves { + mark_caves(&mut map); + } + *document = EditorDocument::for_map(time.elapsed().as_nanos() as u64, &map); + *stroke = EditorStroke::default(); + reported.0 = None; + // The commands were meant for the map before + take_editor_commands(); +} + +pub fn run_editor_commands( + mut edit: MapEdit, + mut stroke: ResMut, + mode: Res, + mut soldiers: SoldierCommandParams, + mut items: ItemCommandParams, + mut lights: LightCommandParams, + mut traversal: ResMut, +) { + for command in take_editor_commands() { + // A stroke in progress is finished first + *stroke = EditorStroke::default(); + let document = edit.document(); + document.undo.end(); + match command { + EditorCommand::Undo => { + if let Some(command) = document.undo.pop() { + let mut inverse = UndoCommand::default(); + edit.edit(|context| inverse = context.restore(command)); + // Restoring is not recorded, but it changes the map + let document = edit.document(); + document.revision += 1; + document.undo.push_redo(inverse); + } + } + EditorCommand::Redo => { + if let Some(command) = document.undo.pop_redo() { + let mut inverse = UndoCommand::default(); + edit.edit(|context| inverse = context.restore(command)); + let document = edit.document(); + document.revision += 1; + document.undo.push_undo(inverse); + } + } + EditorCommand::SetBackground(background) => { + document.undo.begin(); + edit.edit(|context| set_background_texture(context, &background)); + edit.document().undo.end(); + } + EditorCommand::SetSmoothing(smoothing) => { + document.undo.begin(); + edit.edit(|context| context.set_smoothing(smoothing)); + edit.document().undo.end(); + } + EditorCommand::SetRestrictedScroll(restricted_scroll) => { + document.undo.begin(); + edit.edit(|context| context.set_restricted_scroll(restricted_scroll)); + edit.document().undo.end(); + } + EditorCommand::SetDoor(request) => { + let Some(grid_no) = GridNo::new(usize::from(request.grid_no)) else { + continue; + }; + document.undo.begin(); + edit.edit(|context| context.set_door(grid_no, request.door)); + edit.document().undo.end(); + } + EditorCommand::RoofAllRooms => { + document.undo.begin(); + edit.edit(|context| { + let style = match &mode.0 { + EditorMode::Buildings(buildings) => buildings.roof_style(context), + _ => None, + }; + roof_all_rooms(context, style.unwrap_or(0)); + }); + edit.document().undo.end(); + } + EditorCommand::UpdateSoldier(soldier) => { + if let Some(sc) = soldiers.sources.context() { + edit_once(&mut edit, |context| update_soldier(context, &sc, &soldier)); + } + } + EditorCommand::Soldier(command) => { + run_soldier_command(command, &mut edit, &mut soldiers); + } + EditorCommand::UpdateItem(item) => { + if let Some(table) = &items.items { + edit_once(&mut edit, |context| update_item(context, table, &item)); + } + } + EditorCommand::Item(command) => { + let view = soldiers.view.as_deref_mut(); + run_item_command(command, &mut edit, &mut items, view); + } + EditorCommand::UpdateLight(light) => { + if let Some(templates) = &lights.templates { + let mut result = Ok(()); + edit_once(&mut edit, |context| { + result = update_light(context, templates, &light) + }); + if let Err(notice) = result { + lights.selection.notice = Some(notice); + } + } + } + EditorCommand::Light(command) => { + run_light_command(command, &mut edit, &mut lights); + } + EditorCommand::UpdateTraversal(traversal) => { + edit_once(&mut edit, |context| update_traversal(context, &traversal)); + } + } + soldiers.selection.validate(&edit.map); + lights.selection.validate(&edit.map); + traversal.validate(&edit.map); + } +} + +/// The state of the editor, reported to the host whenever it changes +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename = "state", rename_all = "camelCase")] +pub struct EditorStateEvent { + pub map: String, + pub can_undo: bool, + pub undo_depth: usize, + pub can_redo: bool, + pub redo_depth: usize, + /// Name of the tile type of the background texture + pub background: String, + /// One more than the highest room number of the map, the number of a new room + pub next_room_number: u8, + /// How the editor smooths the map, which decides the building tools + pub smoothing: EditorSmoothing, + /// Whether the game only scrolls over the middle of the map + pub restricted_scroll: bool, + /// Counts the changes of the map since it was loaded + pub revision: u64, +} + +/// One more than the highest room number of the map, at most 255 (`gubMaxRoomNumber`) +pub fn next_room_number(map: &TacticalMap) -> u8 { + let max = map + .tiles + .values() + .filter_map(|tile| tile.room) + .map(|room| room.number()) + .max() + .unwrap_or(0); + max.saturating_add(1) +} + +/// The state that was reported last, `None` for a new map +#[derive(Resource, Clone, Debug, Default)] +pub struct ReportedEditorState(Option); + +pub fn report_editor_state( + map_to_load: Res, + map: Res, + document: Res, + mut reported: ResMut, + mut room_number: Local, +) { + if map.is_changed() || reported.0.is_none() { + *room_number = next_room_number(&map); + } + let depth = document.undo.depth(); + let redo_depth = document.undo.redo_depth(); + let state = EditorStateEvent { + map: map_to_load.0.clone(), + can_undo: depth > 0, + undo_depth: depth, + can_redo: redo_depth > 0, + redo_depth, + background: document.background.clone(), + next_room_number: *room_number, + smoothing: map.info.editor_smoothing, + restricted_scroll: map.info.restricted_scroll, + revision: document.revision, + }; + if reported.0.as_ref() != Some(&state) { + report_editor_event(&state); + reported.0 = Some(state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + editor::document::tests::{grid, Fixture}, + map::RoomNo, + }; + + #[test] + fn new_rooms_get_the_next_number() { + let mut fixture = Fixture::new(); + assert_eq!(next_room_number(&fixture.map), 1); + fixture.map.tiles[grid(3, 3)].room = RoomNo::new(4); + fixture.map.tiles[grid(5, 3)].room = RoomNo::new(2); + assert_eq!(next_room_number(&fixture.map), 5); + fixture.map.tiles[grid(6, 3)].room = RoomNo::new(255); + assert_eq!(next_room_number(&fixture.map), 255); + + let state = EditorStateEvent { + map: "maps/a9.dat".to_owned(), + can_undo: false, + undo_depth: 0, + can_redo: false, + redo_depth: 0, + background: "TEXTURE1".to_owned(), + next_room_number: 5, + smoothing: EditorSmoothing::Basement, + restricted_scroll: true, + revision: 3, + }; + let json = serde_json::to_value(&state).unwrap(); + assert_eq!(json["type"], "state"); + assert_eq!(json["nextRoomNumber"], 5); + assert_eq!(json["smoothing"], "basement"); + assert_eq!(json["restrictedScroll"], true); + assert_eq!(json["revision"], 3); + } + + #[test] + fn door_requests_are_read_from_json() { + let request: DoorRequest = serde_json::from_str(r#"{"gridNo":805,"door":null}"#).unwrap(); + assert_eq!(request.grid_no, 805); + assert_eq!(request.door, None); + let request: DoorRequest = + serde_json::from_str(r#"{"gridNo":1,"door":{"lockId":2,"trap":null,"locked":true}}"#) + .unwrap(); + assert!(request.door.unwrap().locked); + } +} diff --git a/src-map-editor/src/editor/document.rs b/src-map-editor/src/editor/document.rs new file mode 100644 index 0000000..ddc64d7 --- /dev/null +++ b/src-map-editor/src/editor/document.rs @@ -0,0 +1,1007 @@ +//! The map being edited and the node list helpers of the original editor +//! +//! The editor changes the tile layers of the [`TacticalMap`], which is the document. Every change +//! goes the same way: the tile is recorded for undo, changed, and once a change is committed, +//! [`MapWorld::sync_tiles`] updates the level nodes of the world, which the scene is built from. +//! +//! The helpers are ports of the ones in `WorldMan.cc` and `TileDef.cc` that the editor uses, see +//! [`MapTile`] for the order of the layers. The game keeps a logical height per tile type, but it +//! sets all of them to 1 before the editor runs (`gTileTypeLogicalHeight`), so "higher" land never +//! exists and those code paths are left out. + +use std::{collections::BTreeSet, sync::Arc}; + +use bevy::prelude::Resource; + +use crate::{ + map::{ + AmbientLight, EditorSmoothing, EntryPoint, ExitGrid, GridNo, Level, LightId, MapInfo, + MapItem, MapLight, MapTile, PlacementId, RoomNo, Schedule, SoldierPlacement, TacticalMap, + WorldItemId, + }, + random::Random, + render::world::MapWorld, + tileset::{Tile, TileCategory, Tileset}, +}; + +use super::{ + lights::SURFACE_AMBIENT_LEVEL, + undo::{UndoCommand, UndoStack}, +}; + +/// The state of the editor that belongs to the loaded map +#[derive(Resource, Clone, Debug)] +pub struct EditorDocument { + pub random: Random, + pub undo: UndoStack, + /// Name of the tile type of the land below everything else (`gCurrentBackground`) + pub background: String, + /// The door the host is asked to edit, see `doors.rs` + pub door_request: Option, + /// Counts the changes of the map, so the host can tell whether it was changed since it was + /// saved + pub revision: u64, + /// Whether the soldiers changed since the world was last synced with them + pub soldiers_dirty: bool, + /// Whether the world items changed since the world was last synced with them + pub items_dirty: bool, + /// Whether the lights, the light colour or the ambient light changed since the scene was + /// last synced with them + pub lights_dirty: bool, + /// Whether the exit grids changed since the scene was last synced with them + pub traversal_dirty: bool, + /// Whether the map changed in a way that can move the edge points since they were last + /// generated: the tiles, the exit grids, the entry points, the map type or the door locks + pub edge_points_dirty: bool, + /// Whether the scroll restriction changed since the world was last synced with it + pub restricted_scroll_dirty: bool, + /// The id of the next new placement, ids are never used twice + next_placement_id: u32, + /// The id of the next new world item + next_item_id: u32, + /// The id of the next new light + next_light_id: u32, +} + +impl EditorDocument { + pub fn new(seed: u64) -> Self { + Self { + random: Random::new(seed), + undo: UndoStack::default(), + background: "TEXTURE1".to_owned(), + door_request: None, + revision: 0, + soldiers_dirty: false, + items_dirty: false, + lights_dirty: false, + traversal_dirty: false, + edge_points_dirty: false, + restricted_scroll_dirty: false, + next_placement_id: 0, + next_item_id: 0, + next_light_id: 0, + } + } + + /// Starts editing a map: new placements, items and lights get ids after the ones of the map + pub fn for_map(seed: u64, map: &TacticalMap) -> Self { + let next_placement_id = map + .soldiers + .iter() + .map(|placement| placement.id.0 + 1) + .max() + .unwrap_or(0); + let next_item_id = map + .items + .iter() + .map(|item| item.id.0 + 1) + .max() + .unwrap_or(0); + let next_light_id = map + .lights + .iter() + .map(|light| light.id.0 + 1) + .max() + .unwrap_or(0); + Self { + next_placement_id, + next_item_id, + next_light_id, + ..Self::new(seed) + } + } + + /// A new placement id + pub fn new_placement_id(&mut self) -> PlacementId { + let id = PlacementId(self.next_placement_id); + self.next_placement_id += 1; + id + } +} + +impl EditorDocument { + /// A new world item id + pub fn new_item_id(&mut self) -> WorldItemId { + let id = WorldItemId(self.next_item_id); + self.next_item_id += 1; + id + } + + /// A new light id + /// The id that the next new light gets, without taking it + pub fn next_light_id(&self) -> LightId { + LightId(self.next_light_id) + } + + pub fn new_light_id(&mut self) -> LightId { + let id = LightId(self.next_light_id); + self.next_light_id += 1; + id + } +} + +impl Default for EditorDocument { + fn default() -> Self { + Self::new(0) + } +} + +/// Changes the map and keeps the world in sync with it +pub struct EditContext<'a> { + pub map: &'a mut TacticalMap, + pub world: &'a mut MapWorld, + pub tileset: &'a Tileset, + pub document: &'a mut EditorDocument, + /// Tiles changed since the last commit + changed: BTreeSet, + /// Tiles whose level nodes changed with the commits so far + touched: BTreeSet, +} + +impl<'a> EditContext<'a> { + pub fn new( + map: &'a mut TacticalMap, + world: &'a mut MapWorld, + tileset: &'a Tileset, + document: &'a mut EditorDocument, + ) -> Self { + Self { + map, + world, + tileset, + document, + changed: BTreeSet::new(), + touched: BTreeSet::new(), + } + } + + /// Updates the world with the changed tiles, so that e.g. the structures placed so far block + /// the next ones + pub fn commit(&mut self) { + if self.changed.is_empty() { + return; + } + let changed = std::mem::take(&mut self.changed); + self.touched + .extend(self.world.sync_tiles(self.map, &changed)); + } + + /// Commits the changes, returns the tiles whose level nodes changed + pub fn finish(mut self) -> BTreeSet { + self.commit(); + self.touched + } + + /// A number in `0..range` (`Random`) + pub fn random(&mut self, range: u32) -> u32 { + self.document.random.next(range) + } + + /// A tile of the tileset, `None` if it does not exist + pub fn tile(&self, type_name: &str, sub_index: u16) -> Option> { + let index = self.tileset.tile_of(type_name, sub_index)?; + self.tileset.tile(index).cloned() + } + + /// What the tiles of a type are to the editor, `None` if the type has no tiles + pub fn category_of(&self, type_name: &str) -> Option { + let tile = self.tile(type_name, 1)?; + if tile.type_name != type_name { + return None; + } + tile.category + } + + /// A random full tile of the background texture + pub fn background_tile(&mut self) -> Option> { + let sub_index = 1 + self.random(10) as u16; + self.tile(&self.document.background, sub_index) + } + + /// The tile, to be changed: it is recorded for undo and synced with the next commit + pub fn modify(&mut self, grid_no: GridNo) -> &mut MapTile { + self.document + .undo + .record_tile(grid_no, &self.map.tiles[grid_no]); + self.document.edge_points_dirty = true; + self.changed.insert(grid_no); + &mut self.map.tiles[grid_no] + } + + /// Restores the tiles and the background of an undo command, without recording it, returns + /// the command that restores what it replaced (to redo an undo and the other way round) + /// + /// The edge points are generated again afterwards, whatever the command changed. + pub fn restore(&mut self, command: UndoCommand) -> UndoCommand { + self.document.edge_points_dirty = true; + let mut inverse = UndoCommand::default(); + for (grid_no, tile) in command.tiles { + let previous = std::mem::replace(&mut self.map.tiles[grid_no], tile); + inverse.tiles.insert(grid_no, previous); + self.changed.insert(grid_no); + } + if let Some(background) = command.background { + inverse.background = Some(std::mem::replace(&mut self.document.background, background)); + } + if let Some(smoothing) = command.smoothing { + inverse.smoothing = Some(std::mem::replace( + &mut self.map.info.editor_smoothing, + smoothing, + )); + } + if let Some(restricted_scroll) = command.restricted_scroll { + inverse.restricted_scroll = Some(std::mem::replace( + &mut self.map.info.restricted_scroll, + restricted_scroll, + )); + self.document.restricted_scroll_dirty = true; + } + if let Some(doors) = command.doors { + inverse.doors = Some(std::mem::replace(&mut self.map.doors, doors)); + } + if let Some((soldiers, schedules)) = command.soldiers { + inverse.soldiers = Some(( + std::mem::replace(&mut self.map.soldiers, soldiers), + std::mem::replace(&mut self.map.schedules, schedules), + )); + self.document.soldiers_dirty = true; + } + if let Some(items) = command.items { + inverse.items = Some(std::mem::replace(&mut self.map.items, items)); + self.document.items_dirty = true; + } + if let Some((lights, color, ambient)) = command.lights { + inverse.lights = Some(( + std::mem::replace(&mut self.map.lights, lights), + std::mem::replace(&mut self.map.light_color, color), + std::mem::replace(&mut self.map.ambient_light, ambient), + )); + self.document.lights_dirty = true; + } + if let Some(exit_grids) = command.exit_grids { + inverse.exit_grids = Some(std::mem::replace(&mut self.map.exit_grids, exit_grids)); + self.document.traversal_dirty = true; + } + if let Some(info) = command.entry_points { + inverse.entry_points = Some(self.map.info.clone()); + for point in EntryPoint::ALL { + *self.map.info.entry_mut(point) = info.entry(point); + } + } + inverse + } + + /// The exit grids, to be changed: they are recorded for undo, the movement costs of their + /// tiles are compiled again and the edge points are generated again + pub fn modify_exit_grids(&mut self) -> &mut Vec { + self.document.undo.record_exit_grids(&self.map.exit_grids); + self.document.traversal_dirty = true; + self.document.edge_points_dirty = true; + &mut self.map.exit_grids + } + + /// The map info, to change its entry points: they are recorded for undo and the edge points + /// are generated again + pub fn modify_map_info(&mut self) -> &mut MapInfo { + self.document.undo.record_entry_points(&self.map.info); + self.document.edge_points_dirty = true; + &mut self.map.info + } + + /// Records the lights, the light colour and the ambient light for undo, the scene is synced + /// with them + fn record_lights(&mut self) { + self.document.undo.record_lights( + &self.map.lights, + self.map.light_color, + self.map.ambient_light, + ); + self.document.lights_dirty = true; + } + + /// The lights, to be changed: they are recorded for undo and the scene is synced with them + pub fn modify_lights(&mut self) -> &mut Vec { + self.record_lights(); + &mut self.map.lights + } + + /// The light colour and the ambient light, to be changed: they are recorded for undo and + /// the scene is rebuilt with them + pub fn modify_light_settings(&mut self) -> (&mut [u8; 3], &mut Option) { + self.record_lights(); + (&mut self.map.light_color, &mut self.map.ambient_light) + } + + /// The world items, to be changed: they are recorded for undo and the world is synced with + /// them + pub fn modify_items(&mut self) -> &mut Vec { + self.document.undo.record_items(&self.map.items); + self.document.items_dirty = true; + &mut self.map.items + } + + /// The soldier placements and the schedules, to be changed: they are recorded for undo and + /// the world is synced with them + pub fn modify_soldiers(&mut self) -> (&mut Vec, &mut Vec) { + self.document + .undo + .record_soldiers(&self.map.soldiers, &self.map.schedules); + self.document.soldiers_dirty = true; + (&mut self.map.soldiers, &mut self.map.schedules) + } + + /// Sets how the editor smooths the map, it is recorded for undo + /// + /// Like `SetEditorSmoothingMode`, it also decides whether the map is a basement or caves for + /// the ambient light: only those have a light level, which they keep when they switch. + pub fn set_smoothing(&mut self, smoothing: EditorSmoothing) { + let previous = self.map.info.editor_smoothing; + if previous != smoothing { + self.document.undo.record_smoothing(previous); + // Rooms on the edges of basements have edge points + self.document.edge_points_dirty = true; + self.map.info.editor_smoothing = smoothing; + } + let ambient = match smoothing { + EditorSmoothing::Normal => None, + EditorSmoothing::Basement | EditorSmoothing::Caves => Some(AmbientLight { + basement: smoothing == EditorSmoothing::Basement, + caves: smoothing == EditorSmoothing::Caves, + level: self + .map + .ambient_light + .map_or(SURFACE_AMBIENT_LEVEL, |ambient| ambient.level), + }), + }; + if ambient != self.map.ambient_light { + *self.modify_light_settings().1 = ambient; + } + } + + /// Sets whether the game only scrolls over the middle of the map (`ubRestrictedScrollID`), + /// it is recorded for undo + /// + /// The visible tiles depend on it, so the scene is rebuilt and the edge points are generated + /// again. + pub fn set_restricted_scroll(&mut self, restricted_scroll: bool) { + let previous = self.map.info.restricted_scroll; + if previous == restricted_scroll { + return; + } + self.document.undo.record_restricted_scroll(previous); + self.document.restricted_scroll_dirty = true; + self.document.edge_points_dirty = true; + self.map.info.restricted_scroll = restricted_scroll; + } + + pub fn land(&self, grid_no: GridNo) -> &[Arc] { + &self.map.tiles[grid_no].land + } + + /// Index in `MapTile::land` of the land of a type closest to the head (`FindTypeInLandLayer`) + pub fn find_type_in_land(&self, grid_no: GridNo, type_name: &str) -> Option { + self.land(grid_no) + .iter() + .rposition(|tile| tile.type_name == type_name) + } + + /// Index in `MapTile::land` of the land of a category closest to the head + pub fn find_category_in_land(&self, grid_no: GridNo, category: TileCategory) -> Option { + self.land(grid_no) + .iter() + .rposition(|tile| tile.category == Some(category)) + } + + /// Whether any land matches (`TypeRangeExistsInLandLayer`) + pub fn land_exists(&self, grid_no: GridNo, matches: impl Fn(&Tile) -> bool) -> bool { + self.land(grid_no).iter().any(|tile| matches(tile)) + } + + /// Type of the topmost land tile + pub fn land_head_type(&self, grid_no: GridNo) -> Option<&str> { + self.land(grid_no) + .last() + .map(|tile| tile.type_name.as_str()) + } + + /// How far a type is below the head, 0 for the head (`GetTypeLandLevel`) + pub fn land_level(&self, grid_no: GridNo, type_name: &str) -> Option { + let index = self.find_type_in_land(grid_no, type_name)?; + Some(self.land(grid_no).len() - 1 - index) + } + + /// Replaces the land of the type of the tile, or adds the tile to the head (`SetLandIndex`) + pub fn set_land_index(&mut self, grid_no: GridNo, tile: Arc) { + match self.find_type_in_land(grid_no, &tile.type_name) { + Some(index) => { + // `ReplaceLandIndex` + self.modify(grid_no).land[index] = tile; + self.adjust_for_full_tile(grid_no); + } + None => self.add_land_to_head(grid_no, tile), + } + } + + /// `AddLandToHead` + pub fn add_land_to_head(&mut self, grid_no: GridNo, tile: Arc) { + self.modify(grid_no).land.push(tile); + } + + /// `AddLandToTail` + pub fn add_land_to_tail(&mut self, grid_no: GridNo, tile: Arc) { + self.modify(grid_no).land.insert(0, tile); + } + + /// Removes a land tile by its index in `MapTile::land` (`RemoveLand`) + pub fn remove_land(&mut self, grid_no: GridNo, index: usize) { + self.modify(grid_no).land.remove(index); + self.adjust_for_full_tile(grid_no); + } + + /// Adds a background tile to the tail if no land covers the whole tile (`AdjustForFullTile`) + fn adjust_for_full_tile(&mut self, grid_no: GridNo) { + if self.land(grid_no).iter().any(|tile| tile.is_full_land()) { + return; + } + if let Some(tile) = self.background_tile() { + self.add_land_to_tail(grid_no, tile); + } + } + + /// `DeleteAllLandLayers` + pub fn delete_all_land(&mut self, grid_no: GridNo) { + self.modify(grid_no).land.clear(); + } + + /// Removes all land that matches, without adjusting for a full tile + /// (`RemoveAllLandsOfTypeRange`), returns whether there was any + pub fn remove_land_where(&mut self, grid_no: GridNo, matches: impl Fn(&Tile) -> bool) -> bool { + remove_where(self, grid_no, matches, |tile| &mut tile.land) + } + + /// Whether the tile has a floor (`FloorAtGridNo`) + pub fn has_floor(&self, grid_no: GridNo) -> bool { + self.land_exists(grid_no, Tile::is_floor) + } + + /// Removes the objects that match (`RemoveAllObjectsOfTypeRange`), returns whether there were + /// any + pub fn remove_objects_where( + &mut self, + grid_no: GridNo, + matches: impl Fn(&Tile) -> bool, + ) -> bool { + remove_where(self, grid_no, matches, |tile| &mut tile.objects) + } + + /// Removes the structs that match (`RemoveAllStructsOfTypeRange`), returns whether there were + /// any. Their buddy shadows go with them when the world is synced. + pub fn remove_structs_where( + &mut self, + grid_no: GridNo, + matches: impl Fn(&Tile) -> bool, + ) -> bool { + remove_where(self, grid_no, matches, |tile| &mut tile.structs) + } + + /// `AddObjectToHead` + pub fn add_object_to_head(&mut self, grid_no: GridNo, tile: Arc) { + self.modify(grid_no).objects.insert(0, tile); + } + + /// `AddObjectToTail` + pub fn add_object_to_tail(&mut self, grid_no: GridNo, tile: Arc) { + self.modify(grid_no).objects.push(tile); + } + + /// `AddStructToHead`, the world adds the structure and the buddy shadow when it is synced + pub fn add_struct_to_head(&mut self, grid_no: GridNo, tile: Arc) { + self.modify(grid_no).structs.insert(0, tile); + } + + /// `AddStructToTail` + pub fn add_struct_to_tail(&mut self, grid_no: GridNo, tile: Arc) { + self.modify(grid_no).structs.push(tile); + } + + pub fn structs(&self, grid_no: GridNo) -> &[Arc] { + &self.map.tiles[grid_no].structs + } + + /// Removes a struct by its index in `MapTile::structs` (`RemoveStruct`) + pub fn remove_struct(&mut self, grid_no: GridNo, index: usize) { + self.modify(grid_no).structs.remove(index); + } + + /// Whether the structure of a tile can be placed on the ground level of a tile, the world is + /// synced with the changes so far first + pub fn struct_fits(&mut self, grid_no: GridNo, tile: &Tile) -> bool { + let Some(structure) = &tile.structure else { + return true; + }; + self.commit(); + self.world + .okay_to_add_structure_to_world(grid_no, Level::Ground, structure) + } + + /// Adds a wall, door or wall decoration in the order the game draws them: before the walls + /// of a lower orientation value at right angles, else before a roof or at the tail + /// (`AddWallToStructLayer`) + /// + /// A wall of the same alignment is replaced if `replace` is set, otherwise nothing is added. + /// Like the game, nothing is added if the structure doesn't fit. Returns whether the tile + /// was added. + pub fn add_wall_to_struct_layer( + &mut self, + grid_no: GridNo, + tile: Arc, + replace: bool, + ) -> bool { + let orientation = tile.wall_orientation(); + let mut insert_found = false; + let mut roof_level = None; + for (level, existing) in self.structs(grid_no).iter().enumerate() { + let check = existing.wall_orientation(); + // A wall at right angles to the new one + if check.bits() > orientation.bits() + && ((orientation.is_vertical() && check.is_horizontal()) + || (orientation.is_horizontal() && check.is_vertical())) + { + insert_found = true; + } + if existing.building.is_some_and(|b| b.piece.is_flat_roof()) { + roof_level = Some(level); + } + // Parallel walls, e.g. inside and outside left, can't coexist + if (orientation.is_vertical() && check.is_vertical()) + || (orientation.is_horizontal() && check.is_horizontal()) + { + if !replace { + return false; + } + // `ReplaceStructIndex` + self.remove_struct(grid_no, level); + return self.add_wall_to_struct_layer(grid_no, tile, false); + } + } + if !self.struct_fits(grid_no, &tile) { + return false; + } + let index = if insert_found { + 0 + } else { + roof_level.unwrap_or(self.structs(grid_no).len()) + }; + self.modify(grid_no).structs.insert(index, tile); + true + } + + /// Replaces the struct at an index by a wall piece (`ReplaceStructIndex`), which is added + /// like [`EditContext::add_wall_to_struct_layer`] without replacing + pub fn replace_struct(&mut self, grid_no: GridNo, index: usize, tile: Arc) { + self.remove_struct(grid_no, index); + self.add_wall_to_struct_layer(grid_no, tile, false); + } + + /// Adds a shadow to the head, unless the tile already has it (`AddExclusiveShadow`) + pub fn add_exclusive_shadow(&mut self, grid_no: GridNo, tile: Arc) { + if self.map.tiles[grid_no] + .shadows + .iter() + .any(|shadow| shadow.id == tile.id) + { + return; + } + self.modify(grid_no).shadows.insert(0, tile); + } + + /// Removes the shadows that match (`RemoveAllShadowsOfTypeRange`), returns whether there + /// were any + pub fn remove_shadows_where( + &mut self, + grid_no: GridNo, + matches: impl Fn(&Tile) -> bool, + ) -> bool { + remove_where(self, grid_no, matches, |tile| &mut tile.shadows) + } + + pub fn roofs(&self, grid_no: GridNo) -> &[Arc] { + &self.map.tiles[grid_no].roofs + } + + /// `AddRoofToHead` + pub fn add_roof_to_head(&mut self, grid_no: GridNo, tile: Arc) { + self.modify(grid_no).roofs.insert(0, tile); + } + + /// `AddRoofToTail` + pub fn add_roof_to_tail(&mut self, grid_no: GridNo, tile: Arc) { + self.modify(grid_no).roofs.push(tile); + } + + /// Removes the roofs that match (`RemoveAllRoofsOfTypeRange`), returns whether there were + /// any + pub fn remove_roofs_where(&mut self, grid_no: GridNo, matches: impl Fn(&Tile) -> bool) -> bool { + remove_where(self, grid_no, matches, |tile| &mut tile.roofs) + } + + /// `AddOnRoofToTail` + pub fn add_on_roof_to_tail(&mut self, grid_no: GridNo, tile: Arc) { + self.modify(grid_no).on_roofs.push(tile); + } + + /// Removes the tiles on the roofs that match (`RemoveAllOnRoofsOfTypeRange`), returns whether + /// there were any + pub fn remove_on_roofs_where( + &mut self, + grid_no: GridNo, + matches: impl Fn(&Tile) -> bool, + ) -> bool { + remove_where(self, grid_no, matches, |tile| &mut tile.on_roofs) + } + + /// Sets the room of a tile, it is only recorded if it changes + pub fn set_room(&mut self, grid_no: GridNo, room: Option) { + if self.map.tiles[grid_no].room != room { + self.modify(grid_no).room = room; + } + } + + /// The first wall or door along the edge to the next column (`GetVerticalWall`) + pub fn vertical_wall(&self, grid_no: GridNo) -> Option { + self.wall_node(grid_no, |tile| tile.wall_orientation().is_vertical()) + } + + /// The first wall or door along the edge to the next row (`GetHorizontalWall`) + pub fn horizontal_wall(&self, grid_no: GridNo) -> Option { + self.wall_node(grid_no, |tile| tile.wall_orientation().is_horizontal()) + } + + fn wall_node(&self, grid_no: GridNo, aligned: impl Fn(&Tile) -> bool) -> Option { + wall_node(&self.map.tiles[grid_no], aligned) + } +} + +/// The first wall or door of a map tile with an alignment +pub fn wall_node(tile: &MapTile, aligned: impl Fn(&Tile) -> bool) -> Option { + let index = tile.structs.iter().position(|tile| { + tile.building + .is_some_and(|b| b.piece.is_wall() || b.piece.is_door()) + && aligned(tile) + })?; + Some(WallNode { + index, + tile: tile.structs[index].clone(), + }) +} + +/// A wall or door of the structs of a tile +#[derive(Clone, Debug)] +pub struct WallNode { + /// Index in `MapTile::structs` + pub index: usize, + pub tile: Arc, +} + +/// Removes the tiles of a layer that match, the tile is only recorded for undo if any do +fn remove_where( + context: &mut EditContext, + grid_no: GridNo, + matches: impl Fn(&Tile) -> bool, + layer: impl Fn(&mut MapTile) -> &mut Vec>, +) -> bool { + let tile = &mut context.map.tiles[grid_no]; + if !layer(tile).iter().any(|tile| matches(tile)) { + return false; + } + layer(context.modify(grid_no)).retain(|tile| !matches(tile)); + true +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{ + components::map::GridPosition, + file_formats::ja2_tile_types::FIRSTTEXTURE, + tileset::{tests::test_tileset, TilesetId}, + }; + + /// Everything an `EditContext` needs, with a map covered by `FIRSTTEXTURE` 1 + pub(crate) struct Fixture { + pub map: TacticalMap, + pub world: MapWorld, + pub tileset: Tileset, + pub document: EditorDocument, + } + + impl Fixture { + pub(crate) fn new() -> Self { + Self::with_tileset(test_tileset()) + } + + pub(crate) fn with_tileset(tileset: Tileset) -> Self { + let mut map = TacticalMap::empty(TilesetId::new(0)); + let grass = tileset + .tile(tileset.index(FIRSTTEXTURE, 1).unwrap()) + .unwrap() + .clone(); + for tile in map.tiles.values_mut() { + tile.land = vec![grass.clone()]; + } + let world = MapWorld::build(&map); + Self { + map, + world, + tileset, + document: EditorDocument::new(1), + } + } + + pub(crate) fn context(&mut self) -> EditContext<'_> { + EditContext::new( + &mut self.map, + &mut self.world, + &self.tileset, + &mut self.document, + ) + } + + /// `(type, sub index)` of the land of a tile, from the tail to the head + pub(crate) fn land(&self, grid_no: GridNo) -> Vec<(&str, u16)> { + self.map.tiles[grid_no] + .land + .iter() + .map(|tile| (tile.type_name.as_str(), tile.sub_index)) + .collect() + } + } + + pub(crate) fn grid(x: u8, y: u8) -> GridNo { + GridNo::from(GridPosition::new(x, y)) + } + + #[test] + fn land_helpers_follow_the_order_of_the_land_list() { + let mut fixture = Fixture::new(); + let g = grid(10, 10); + let mut context = fixture.context(); + let sand = context.tile("TEXTURE2", 19).unwrap(); + context.set_land_index(g, sand); + assert_eq!(context.land_head_type(g), Some("TEXTURE2")); + assert_eq!(context.land_level(g, "TEXTURE2"), Some(0)); + assert_eq!(context.land_level(g, "TEXTURE1"), Some(1)); + + // Replacing keeps the position + let grass = context.tile("TEXTURE1", 12).unwrap(); + context.set_land_index(g, grass); + let touched = context.finish(); + assert!(touched.contains(&g)); + let land = fixture.land(g); + assert_eq!(land[1..], [("TEXTURE1", 12), ("TEXTURE2", 19)]); + // No full tile is left, so a background tile is added to the tail + assert_eq!(land[0].0, "TEXTURE1"); + assert!((1..=10).contains(&land[0].1)); + } + + #[test] + fn removing_the_only_full_tile_adds_a_background_tile() { + let mut fixture = Fixture::new(); + let g = grid(10, 10); + let mut context = fixture.context(); + let edge = context.tile("TEXTURE2", 19).unwrap(); + context.add_land_to_head(g, edge); + context.remove_land(g, 0); + context.finish(); + let land = fixture.land(g); + assert_eq!(land.len(), 2); + assert_eq!(land[1], ("TEXTURE2", 19)); + assert_eq!(land[0].0, "TEXTURE1"); + assert!((1..=10).contains(&land[0].1)); + // The sync updated the world + assert_eq!(fixture.world.tiles[g].land.len(), 2); + } + + #[test] + fn restoring_an_undo_command_brings_back_the_tiles() { + let mut fixture = Fixture::new(); + let g = grid(10, 10); + let mut context = fixture.context(); + let rock = context.tile("ROCKS", 1).unwrap(); + context.add_object_to_head(g, rock.clone()); + context.add_object_to_head(g, rock); + context.finish(); + assert_eq!(fixture.world.tiles[g].objects.len(), 2); + + let command = fixture.document.undo.pop().unwrap(); + let mut context = fixture.context(); + context.restore(command); + let touched = context.finish(); + assert!(touched.contains(&g)); + assert!(fixture.map.tiles[g].objects.is_empty()); + assert!(fixture.world.tiles[g].objects.is_empty()); + // Restoring is not recorded + assert_eq!(fixture.document.undo.depth(), 0); + } + + #[test] + fn the_smoothing_can_be_undone() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + context.set_smoothing(EditorSmoothing::Caves); + context.set_smoothing(EditorSmoothing::Basement); + context.finish(); + assert_eq!(fixture.map.info.editor_smoothing, EditorSmoothing::Basement); + let command = fixture.document.undo.pop().unwrap(); + let mut context = fixture.context(); + context.restore(command); + context.finish(); + assert_eq!(fixture.map.info.editor_smoothing, EditorSmoothing::Normal); + // Setting it again changes nothing + let mut context = fixture.context(); + context.set_smoothing(EditorSmoothing::Normal); + context.finish(); + assert_eq!(fixture.document.undo.depth(), 0); + } + + #[test] + fn the_scroll_restriction_can_be_undone() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + context.set_restricted_scroll(true); + context.finish(); + assert!(fixture.map.info.restricted_scroll); + assert!(fixture.document.restricted_scroll_dirty && fixture.document.edge_points_dirty); + fixture.document.restricted_scroll_dirty = false; + let command = fixture.document.undo.pop().unwrap(); + let mut context = fixture.context(); + context.restore(command); + context.finish(); + assert!(!fixture.map.info.restricted_scroll); + assert!(fixture.document.restricted_scroll_dirty); + // Setting it again changes nothing + let mut context = fixture.context(); + context.set_restricted_scroll(false); + context.finish(); + assert_eq!(fixture.document.undo.depth(), 0); + } + + #[test] + fn the_smoothing_decides_the_ambient_light() { + let mut fixture = Fixture::new(); + assert_eq!(fixture.map.ambient_light, None); + // Maps above ground become basements with the level of the original editor + let mut context = fixture.context(); + context.set_smoothing(EditorSmoothing::Basement); + context.finish(); + let basement = AmbientLight { + basement: true, + caves: false, + level: SURFACE_AMBIENT_LEVEL, + }; + assert_eq!(fixture.map.ambient_light, Some(basement)); + assert!(fixture.document.lights_dirty); + + // Caves keep the level + fixture.map.ambient_light = Some(AmbientLight { + level: 9, + ..basement + }); + let mut context = fixture.context(); + context.set_smoothing(EditorSmoothing::Caves); + context.finish(); + assert_eq!( + fixture.map.ambient_light, + Some(AmbientLight { + basement: false, + caves: true, + level: 9 + }) + ); + + // Maps above ground have none, undo brings it back with the smoothing + fixture.document.undo.begin(); + let mut context = fixture.context(); + context.set_smoothing(EditorSmoothing::Normal); + context.finish(); + assert_eq!(fixture.map.ambient_light, None); + let command = fixture.document.undo.pop().unwrap(); + let mut context = fixture.context(); + context.restore(command); + context.finish(); + assert_eq!(fixture.map.info.editor_smoothing, EditorSmoothing::Caves); + assert_eq!(fixture.map.ambient_light.map(|a| a.level), Some(9)); + } + + #[test] + fn undo_restores_the_items() { + use crate::{ + item::ItemId, + map::{ItemHeight, ItemStack, MapItem, Visibility, WorldItemFlags}, + }; + let mut fixture = Fixture::new(); + let item = MapItem { + id: WorldItemId(0), + grid_no: grid(10, 10), + file_grid_no: None, + level: Level::Ground, + stack: ItemStack { + item: ItemId::new(1).unwrap(), + number: 1, + data: [0; 12], + attachments: vec![], + flags: 0, + mission: 0, + trap: 0, + imprint_id: 0, + weight: 0, + used: 0, + }, + file_object: None, + graphic: None, + render_height: ItemHeight::Above(0), + visibility: Visibility::Invisible, + flags: WorldItemFlags::empty(), + non_exist_chance: 0, + }; + fixture.map.items.push(item.clone()); + fixture.document = EditorDocument::for_map(1, &fixture.map); + assert_eq!(fixture.document.new_item_id(), WorldItemId(1)); + + let mut context = fixture.context(); + context.modify_items().clear(); + context.finish(); + assert!(fixture.map.items.is_empty()); + assert!(fixture.document.items_dirty); + fixture.document.items_dirty = false; + + let command = fixture.document.undo.pop().unwrap(); + let mut context = fixture.context(); + context.restore(command); + context.finish(); + assert_eq!(fixture.map.items, [item]); + assert!(fixture.document.items_dirty); + } + + #[test] + fn removing_by_type_only_records_real_changes() { + let mut fixture = Fixture::new(); + let g = grid(10, 10); + let mut context = fixture.context(); + let is_rock = |tile: &Tile| tile.type_name == "ROCKS"; + assert!(!context.remove_objects_where(g, is_rock)); + let rock = context.tile("ROCKS", 1).unwrap(); + let other = context.tile("OSTRUCT1", 1).unwrap(); + context.add_object_to_tail(g, rock); + context.add_object_to_head(g, other); + assert!(context.remove_objects_where(g, is_rock)); + context.finish(); + let objects: Vec<_> = fixture.map.tiles[g] + .objects + .iter() + .map(|tile| tile.type_name.as_str()) + .collect(); + assert_eq!(objects, ["OSTRUCT1"]); + assert_eq!(fixture.document.undo.depth(), 1); + } +} diff --git a/src-map-editor/src/editor/doors.rs b/src-map-editor/src/editor/doors.rs new file mode 100644 index 0000000..f1d5e57 --- /dev/null +++ b/src-map-editor/src/editor/doors.rs @@ -0,0 +1,277 @@ +//! Locks and traps of doors (`InitDoorEditing` and `ExtractAndUpdateDoorInfo` of +//! `EditorBuildings.cc`) +//! +//! Clicking a door with the door locks tool asks the host to edit its lock with a `door` +//! event. The host sets the lock with `set_door`, which can be undone. While the tool is +//! active, the doors with a lock or trap are marked (`AddLockedDoorCursors`). + +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{ + components::map::{game_to_world, JA2_TILE_HEIGHT, JA2_TILE_WIDTH}, + host::report_editor_event, + map::{Door, DoorTrap, DoorTrapKind, GridNo, LockId, TacticalMap}, + render::world::MapWorld, + states::loading::MapToLoad, + tileset::StructureFlags, +}; + +use super::{ + building_mode::BuildingTool, commands::CurrentMode, document::EditContext, + document::EditorDocument, mode::EditorMode, +}; + +/// Number of locks of the lock table (`NUM_LOCKS`) +pub const NUM_LOCKS: u8 = 64; +/// Highest difficulty of a trap +pub const MAX_TRAP_LEVEL: u8 = 20; + +/// A trap of a door as the host edits it +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DoorTrapValue { + pub kind: DoorTrapKind, + /// 1 to 20 + pub level: u8, +} + +/// The lock and trap of a door as the host edits them +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DoorLock { + /// The lock of the lock table, 0 for none + pub lock_id: u8, + pub trap: Option, + pub locked: bool, +} + +impl DoorLock { + pub fn of(door: &Door) -> Self { + Self { + lock_id: door.lock.map_or(0, LockId::number), + trap: door.trap.map(|trap| DoorTrapValue { + kind: trap.kind, + level: trap.level, + }), + locked: door.locked, + } + } + + /// The door of the door table, with the values clamped like the original: the lock to the + /// lock table and the trap level to 1 to 20. What the player believes about the door is + /// reset. + pub fn door(self, grid_no: GridNo) -> Door { + Door { + grid_no, + locked: self.locked, + trap: self.trap.map(|trap| DoorTrap { + kind: trap.kind, + level: trap.level.clamp(1, MAX_TRAP_LEVEL), + }), + lock: LockId::new(self.lock_id.min(NUM_LOCKS - 1)), + perceived_locked: 0, + perceived_trapped: 0, + lock_damage: 0, + } + } +} + +/// The door of a tile of the door table +pub fn door_at(map: &TacticalMap, grid_no: GridNo) -> Option<&Door> { + map.doors.iter().find(|door| door.grid_no == grid_no) +} + +/// Whether a tile has a door or another structure that can be opened (`DoorAtGridNo` and +/// `OpenableAtGridNo`) +pub fn openable_at(world: &MapWorld, grid_no: GridNo) -> bool { + world.tiles[grid_no] + .find_structure(StructureFlags::ANYDOOR | StructureFlags::OPENABLE) + .is_some() +} + +/// Asks the host to edit the door of a tile, if the tile has one +pub fn request_door_editing(context: &mut EditContext, grid_no: GridNo) { + context.commit(); + if openable_at(context.world, grid_no) { + context.document.door_request = Some(grid_no); + } +} + +impl EditContext<'_> { + /// Adds, replaces or removes the door of a tile in the door table, it is recorded for undo + pub fn set_door(&mut self, grid_no: GridNo, lock: Option) { + let door = lock.map(|lock| lock.door(grid_no)); + if door_at(self.map, grid_no) == door.as_ref() { + return; + } + self.document.undo.record_doors(&self.map.doors); + // Enemies can't open locked doors + self.document.edge_points_dirty = true; + let doors = &mut self.map.doors; + match (doors.iter().position(|d| d.grid_no == grid_no), door) { + (Some(index), Some(door)) => doors[index] = door, + (Some(index), None) => { + doors.remove(index); + } + (None, Some(door)) => doors.push(door), + (None, None) => {} + } + } +} + +/// Asks the host to edit a door: `door` is the lock of its door, `null` if it has none +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename = "door", rename_all = "camelCase")] +pub struct DoorEvent { + pub map: String, + pub grid_no: u16, + pub door: Option, +} + +/// Sends the door editing request of the last click to the host +pub fn report_door_request( + map_to_load: Res, + map: Res, + mut document: ResMut, +) { + let Some(grid_no) = document.door_request.take() else { + return; + }; + report_editor_event(&DoorEvent { + map: map_to_load.0.clone(), + grid_no: grid_no.index() as u16, + door: door_at(&map, grid_no).map(DoorLock::of), + }); +} + +/// Marks the doors of the door table while the door locks tool is active +pub fn draw_door_markers(mut gizmos: Gizmos, mode: Res, map: Res) { + let EditorMode::Buildings(buildings) = &mode.0 else { + return; + }; + if buildings.tool != BuildingTool::DoorLocks { + return; + } + let color = Srgba::rgb(1., 0.85, 0.); + for door in &map.doors { + // Raised by the terrain height, like the door is drawn + let height = i32::from(map.tiles[door.grid_no].height); + let center = game_to_world( + door.grid_no.position().tile_box() + IVec2::new(JA2_TILE_WIDTH, JA2_TILE_HEIGHT) / 2 + - IVec2::new(0, height), + ); + // A key: a ring and a bit + let ring = center + Vec2::new(-5., 0.); + gizmos.circle_2d(Isometry2d::from_translation(ring), 3., color); + gizmos.line_2d(ring + Vec2::new(3., 0.), center + Vec2::new(7., 0.), color); + gizmos.line_2d( + center + Vec2::new(5., 0.), + center + Vec2::new(5., -3.), + color, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::{ + building::tests::{add_room, building_fixture, rect}, + building_mode::paste_single, + document::tests::grid, + }; + + fn lock() -> DoorLock { + DoorLock { + lock_id: 3, + trap: Some(DoorTrapValue { + kind: DoorTrapKind::SilentAlarm, + level: 0, + }), + locked: true, + } + } + + #[test] + fn door_locks_are_json_for_the_host() { + let json = r#"{"lockId":70,"trap":{"kind":"brothelSiren","level":30},"locked":false}"#; + let parsed: DoorLock = serde_json::from_str(json).unwrap(); + // Clamped like the original + let door = parsed.door(grid(5, 5)); + assert_eq!(door.lock.map(LockId::number), Some(63)); + assert_eq!(door.trap.unwrap().level, 20); + assert_eq!(parsed.trap.unwrap().kind, DoorTrapKind::BrothelSiren); + // A trap needs a level + assert_eq!(lock().door(grid(5, 5)).trap.unwrap().level, 1); + // Lock 0 is no lock + let open = DoorLock { + lock_id: 0, + trap: None, + locked: false, + }; + assert_eq!(open.door(grid(5, 5)).lock, None); + assert_eq!(DoorLock::of(&open.door(grid(5, 5))), open); + + let event = DoorEvent { + map: "maps/a9.dat".to_owned(), + grid_no: 805, + door: Some(lock()), + }; + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "door"); + assert_eq!(json["gridNo"], 805); + assert_eq!(json["door"]["trap"]["kind"], "silentAlarm"); + assert_eq!(json["door"]["lockId"], 3); + } + + #[test] + fn doors_are_set_and_undone() { + let mut fixture = building_fixture(); + let mut context = fixture.context(); + context.set_door(grid(5, 5), Some(lock())); + context.set_door(grid(6, 5), Some(lock())); + context.finish(); + assert_eq!(fixture.map.doors.len(), 2); + fixture.document.undo.end(); + + let mut context = fixture.context(); + context.set_door(grid(5, 5), None); + let unlocked = DoorLock { + locked: false, + ..lock() + }; + context.set_door(grid(6, 5), Some(unlocked)); + context.finish(); + assert_eq!(fixture.map.doors.len(), 1); + assert!(!fixture.map.doors[0].locked); + assert_eq!(fixture.document.undo.depth(), 2); + + // Undo restores the whole table of the command + let command = fixture.document.undo.pop().unwrap(); + assert!(command.tiles.is_empty()); + let mut context = fixture.context(); + context.restore(command); + context.finish(); + assert_eq!(fixture.map.doors.len(), 2); + assert!(fixture.map.doors.iter().all(|door| door.locked)); + + // Setting the same door changes nothing + let mut context = fixture.context(); + context.set_door(grid(5, 5), Some(lock())); + context.finish(); + assert_eq!(fixture.document.undo.depth(), 1); + } + + #[test] + fn only_doors_can_be_edited() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let mut context = fixture.context(); + request_door_editing(&mut context, grid(52, 51)); + assert_eq!(context.document.door_request, None); + let door = context.tile("DOOR1", 1).unwrap(); + paste_single(&mut context, grid(52, 51), door); + request_door_editing(&mut context, grid(52, 51)); + assert_eq!(context.document.door_request, Some(grid(52, 51))); + } +} diff --git a/src-map-editor/src/editor/ground.rs b/src-map-editor/src/editor/ground.rs new file mode 100644 index 0000000..8708937 --- /dev/null +++ b/src-map-editor/src/editor/ground.rs @@ -0,0 +1,422 @@ +//! Painting the ground textures (`PasteTexture` and `TerrainFill` of the original editor) + +use std::collections::VecDeque; + +use crate::{ + map::{Direction, GridArray, GridNo}, + random::Random, + render::movement_costs::grid_no_on_visible_world_tile, + tileset::{Tile, TileCategory}, +}; + +use super::{ + document::EditContext, + smooth::{smooth_all_terrain_type_radius, smooth_terrain_radius}, +}; + +/// Sub index of the tile a texture starts with before it is smoothed (`REQUIRES_SMOOTHING_TILE`) +const REQUIRES_SMOOTHING_TILE: u16 = 19; + +/// Which textures the ground brush paints +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GroundSelection { + /// Name of the tile type that is painted while no texture has a weight + pub selected: Option, + /// The tile types with a weight, in the order of the tile types + pub weights: Vec<(String, u8)>, +} + +impl GroundSelection { + /// The selection of the host, without what is not a texture of the tileset + pub fn new( + context: &EditContext, + selected: Option, + weights: impl IntoIterator, + ) -> Self { + let mut weights: Vec<(String, u8)> = weights + .into_iter() + .filter(|(type_name, weight)| *weight > 0 && is_texture(context, type_name)) + .collect(); + weights.sort_by_key(|(type_name, _)| context.tileset.type_start(type_name)); + Self { + selected: selected.filter(|type_name| is_texture(context, type_name)), + weights, + } + } +} + +/// Whether the ground brush can paint a tile type +pub fn is_texture(context: &EditContext, type_name: &str) -> bool { + context + .category_of(type_name) + .is_some_and(TileCategory::is_texture) +} + +/// Picks the texture to paint (`ChooseWeightedTerrainTile`) +/// +/// Unlike the original, which gives the first texture with a weight one more chance, every +/// texture is picked in proportion to its weight. +pub fn choose_weighted_texture<'a>( + random: &mut Random, + selection: &'a GroundSelection, +) -> Option<&'a str> { + let total: u32 = selection.weights.iter().map(|&(_, w)| u32::from(w)).sum(); + if total == 0 { + return selection.selected.as_deref(); + } + let mut pick = random.next(total); + for (type_name, weight) in &selection.weights { + let weight = u32::from(*weight); + if pick < weight { + return Some(type_name); + } + pick -= weight; + } + selection.selected.as_deref() +} + +fn is_floor(tile: &Tile) -> bool { + tile.category == Some(TileCategory::Floor) +} + +/// Paints a texture on a tile and smooths it with the tiles around it (`PasteTextureCommon`) +pub fn paste_texture_common(context: &mut EditContext, grid_no: GridNo, paste: &str) { + if context.category_of(paste) == Some(TileCategory::DeepWater) { + // Deep water only goes onto full regular water + let on_water = context + .find_category_in_land(grid_no, TileCategory::Water) + .is_some_and(|index| context.land(grid_no)[index].is_full_land()); + if !on_water { + return; + } + } + // Don't draw over floors + if context.land_exists(grid_no, is_floor) { + return; + } + paste_texture_ex(context, grid_no, paste); + smooth_terrain_radius(context, grid_no, paste, 1, true); +} + +/// `PasteTextureEx` +fn paste_texture_ex(context: &mut EditContext, grid_no: GridNo, paste: &str) { + match context.find_type_in_land(grid_no, paste) { + Some(index) => { + // The texture is below the head, it becomes the head + if context.land_level(grid_no, paste) != Some(0) { + let tile = context.land(grid_no)[index].clone(); + paste_existing_texture(context, grid_no, tile); + } + } + None => { + // Smoothing picks the right tile later + if let Some(tile) = context.tile(paste, REQUIRES_SMOOTHING_TILE) { + context.set_land_index(grid_no, tile); + } + } + } +} + +/// Makes a texture that is below the head the head (`PasteExistingTexture`): the land is replaced +/// by the background and the texture, and everything around is smoothed again +fn paste_existing_texture(context: &mut EditContext, grid_no: GridNo, tile: std::sync::Arc) { + context.delete_all_land(grid_no); + if let Some(background) = context.background_tile() { + context.add_land_to_head(grid_no, background); + } + context.set_land_index(grid_no, tile); + smooth_all_terrain_type_radius(context, grid_no, 2, true); +} + +/// Removes the topmost texture of a tile, unless it is the only one (`EraseMapTile` for ground) +pub fn erase_ground(context: &mut EditContext, grid_no: GridNo) { + let land = context.land(grid_no); + if land.len() < 2 { + return; + } + let type_name = land[land.len() - 1].type_name.clone(); + context.remove_land(grid_no, land.len() - 1); + smooth_terrain_radius(context, grid_no, &type_name, 1, true); +} + +/// Paints the connected area of tiles with the same topmost texture (`TerrainFill`) +/// +/// The original fills recursively and never ends if the texture can't be painted, e.g. deep water +/// onto land. Here every tile is visited once. +pub fn terrain_fill(context: &mut EditContext, start: GridNo, paste: &str) { + let Some(search) = context.land_head_type(start).map(str::to_owned) else { + return; + }; + if search == paste { + return; + } + let restricted_scroll = context.world.restricted_scroll; + let mut visited = GridArray::filled(false); + let mut queue = VecDeque::from([start]); + visited[start] = true; + while let Some(grid_no) = queue.pop_front() { + if !grid_no_on_visible_world_tile(grid_no, restricted_scroll) + || context.land_head_type(grid_no) != Some(search.as_str()) + { + continue; + } + paste_texture_common(context, grid_no, paste); + let position = grid_no.position(); + let neighbours = [ + (position.y > 0, Direction::North), + (position.y < 159, Direction::South), + (position.x > 0, Direction::West), + (position.x < 159, Direction::East), + ]; + for (on_map, direction) in neighbours { + let neighbour = grid_no.step(direction); + if on_map && !visited[neighbour] { + visited[neighbour] = true; + queue.push_back(neighbour); + } + } + } +} + +/// Replaces the textures of the whole map by the background texture (`SetBackgroundTexture`), +/// nothing happens if the tile type is not a texture +pub fn set_background_texture(context: &mut EditContext, background: &str) { + if !is_texture(context, background) { + return; + } + let previous = std::mem::replace(&mut context.document.background, background.to_owned()); + context.document.undo.record_background(&previous); + for grid_no in GridNo::all() { + context.remove_land_where(grid_no, |tile| { + tile.category.is_some_and(TileCategory::is_texture) + }); + let Some(tile) = context.background_tile() else { + continue; + }; + if context.land_exists(grid_no, is_floor) { + // Show the land below the floor + context.add_land_to_tail(grid_no, tile); + } else { + context.add_land_to_head(grid_no, tile); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::document::tests::{grid, Fixture}; + + fn selection( + context: &EditContext, + selected: Option<&str>, + weights: &[(&str, u8)], + ) -> GroundSelection { + let weights = weights.iter().map(|&(name, w)| (name.to_owned(), w)); + GroundSelection::new(context, selected.map(str::to_owned), weights) + } + + fn weights(selection: &GroundSelection) -> Vec<(&str, u8)> { + let weights = selection.weights.iter(); + weights.map(|(name, w)| (name.as_str(), *w)).collect() + } + + #[test] + fn weighted_textures_are_picked_in_proportion() { + let mut fixture = Fixture::new(); + let context = fixture.context(); + let mut random = Random::new(3); + let selection = selection(&context, Some("TEXTURE4"), &[]); + assert_eq!( + choose_weighted_texture(&mut random, &selection), + Some("TEXTURE4") + ); + + let selection = self::selection( + &context, + Some("TEXTURE4"), + &[("WATER1", 3), ("TEXTURE2", 1), ("TEXTURE3", 0)], + ); + assert_eq!(weights(&selection), [("TEXTURE2", 1), ("WATER1", 3)]); + let mut counts = [0; 2]; + for _ in 0..4000 { + match choose_weighted_texture(&mut random, &selection) { + Some("TEXTURE2") => counts[0] += 1, + Some("WATER1") => counts[1] += 1, + other => panic!("{other:?}"), + } + } + assert!((800..1200).contains(&counts[0]), "{counts:?}"); + } + + #[test] + fn only_textures_are_selected() { + let mut fixture = Fixture::new(); + let context = fixture.context(); + let selection = selection( + &context, + Some("FLOOR1"), + &[("FLOOR1", 2), ("ROCKS", 1), ("DEEP WATER", 1)], + ); + assert_eq!(selection.selected, None); + assert_eq!(weights(&selection), [("DEEP WATER", 1)]); + assert_eq!( + choose_weighted_texture(&mut Random::new(1), &GroundSelection::default()), + None + ); + } + + #[test] + fn pasting_a_new_texture_smooths_it() { + let mut fixture = Fixture::new(); + let center = grid(50, 50); + let mut context = fixture.context(); + paste_texture_common(&mut context, center, "TEXTURE2"); + context.finish(); + // Alone, so the edge tile for all sides + assert_eq!(fixture.land(center), [("TEXTURE1", 1), ("TEXTURE2", 20)]); + + // A second tile next to it changes both + let mut context = fixture.context(); + paste_texture_common(&mut context, grid(51, 50), "TEXTURE2"); + context.finish(); + assert_eq!(fixture.land(center).last(), Some(&("TEXTURE2", 22))); + assert_eq!(fixture.land(grid(51, 50)).last(), Some(&("TEXTURE2", 19))); + } + + #[test] + fn a_buried_texture_becomes_the_head() { + let mut fixture = Fixture::new(); + let center = grid(50, 50); + let mut context = fixture.context(); + paste_texture_common(&mut context, center, "TEXTURE2"); + // Grass is below the sand now + paste_texture_common(&mut context, center, "TEXTURE1"); + context.finish(); + let land = fixture.land(center); + assert_eq!(land.len(), 1, "{land:?}"); + assert_eq!(land[0].0, "TEXTURE1"); + } + + #[test] + fn deep_water_only_goes_onto_full_water() { + let mut fixture = Fixture::new(); + let center = grid(50, 50); + let mut context = fixture.context(); + paste_texture_common(&mut context, center, "DEEP WATER"); + assert_eq!(context.land(center).len(), 1); + // Not onto the shore + let shore = context.tile("WATER1", 11).unwrap(); + context.set_land_index(center, shore); + paste_texture_common(&mut context, center, "DEEP WATER"); + assert_eq!(context.land_head_type(center), Some("WATER1")); + + let water = context.tile("WATER1", 1).unwrap(); + context.set_land_index(center, water); + paste_texture_common(&mut context, center, "DEEP WATER"); + assert_eq!(context.land_head_type(center), Some("DEEP WATER")); + } + + #[test] + fn floors_are_not_painted_over() { + let mut fixture = Fixture::new(); + let center = grid(50, 50); + let mut context = fixture.context(); + let floor = context.tile("FLOOR1", 1).unwrap(); + context.add_land_to_head(center, floor); + paste_texture_common(&mut context, center, "TEXTURE2"); + context.finish(); + assert_eq!(fixture.land(center), [("TEXTURE1", 1), ("FLOOR1", 1)]); + } + + #[test] + fn erasing_removes_the_head_and_smooths_the_rest() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + paste_texture_common(&mut context, grid(50, 50), "TEXTURE2"); + paste_texture_common(&mut context, grid(51, 50), "TEXTURE2"); + erase_ground(&mut context, grid(51, 50)); + // The only land is kept + erase_ground(&mut context, grid(51, 50)); + context.finish(); + assert_eq!(fixture.land(grid(51, 50)), [("TEXTURE1", 1)]); + assert_eq!(fixture.land(grid(50, 50)).last(), Some(&("TEXTURE2", 20))); + } + + #[test] + fn fill_paints_the_connected_area_and_ends() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + // A ring of sand around (50, 50) separates it from the rest of the grass + for (x, y) in [(49, 49), (50, 49), (51, 49), (49, 50), (51, 50), (49, 51)] { + paste_texture_common(&mut context, grid(x, y), "TEXTURE2"); + } + for (x, y) in [(50, 51), (51, 51)] { + paste_texture_common(&mut context, grid(x, y), "TEXTURE2"); + } + terrain_fill(&mut context, grid(50, 50), "WATER1"); + assert_eq!(context.land_head_type(grid(50, 50)), Some("WATER1")); + assert_eq!(context.land_head_type(grid(60, 60)), Some("TEXTURE1")); + + // Deep water can't be painted onto grass, the fill still ends + terrain_fill(&mut context, grid(60, 60), "DEEP WATER"); + assert_eq!(context.land_head_type(grid(60, 60)), Some("TEXTURE1")); + } + + #[test] + fn the_background_replaces_all_textures() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + paste_texture_common(&mut context, grid(50, 50), "TEXTURE2"); + let floor = context.tile("FLOOR1", 1).unwrap(); + context.add_land_to_head(grid(60, 60), floor); + set_background_texture(&mut context, "TEXTURE2"); + let touched = context.finish(); + assert_eq!(touched.len(), GridNo::all().len()); + for grid_no in [grid(0, 0), grid(50, 50)] { + let land = fixture.land(grid_no); + assert_eq!(land.len(), 1); + assert_eq!(land[0].0, "TEXTURE2"); + assert!((1..=10).contains(&land[0].1)); + } + let land = fixture.land(grid(60, 60)); + assert_eq!((land[0].0, land[1]), ("TEXTURE2", ("FLOOR1", 1))); + + let command = fixture.document.undo.pop().unwrap(); + assert_eq!(command.background.as_deref(), Some("TEXTURE1")); + assert_eq!(command.tiles.len(), GridNo::all().len()); + + // Only textures can be the background + let mut context = fixture.context(); + set_background_texture(&mut context, "FLOOR1"); + assert!(context.finish().is_empty()); + assert_eq!(fixture.document.background, "TEXTURE2"); + assert_eq!(fixture.document.undo.depth(), 0); + } + + #[test] + fn restoring_returns_the_command_that_redoes_it() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + set_background_texture(&mut context, "TEXTURE2"); + context.finish(); + let undo = fixture.document.undo.pop().unwrap(); + + let mut context = fixture.context(); + let redo = context.restore(undo); + context.finish(); + assert_eq!(fixture.document.background, "TEXTURE1"); + assert_eq!(fixture.land(grid(50, 50))[0].0, "TEXTURE1"); + assert_eq!(redo.background.as_deref(), Some("TEXTURE2")); + assert_eq!(redo.tiles.len(), GridNo::all().len()); + + let mut context = fixture.context(); + let undo = context.restore(redo); + context.finish(); + assert_eq!(fixture.document.background, "TEXTURE2"); + assert_eq!(fixture.land(grid(50, 50))[0].0, "TEXTURE2"); + assert_eq!(undo.background.as_deref(), Some("TEXTURE1")); + // Restoring is not recorded + assert_eq!(fixture.document.undo.depth(), 0); + } +} diff --git a/src-map-editor/src/editor/input.rs b/src-map-editor/src/editor/input.rs new file mode 100644 index 0000000..fbb4239 --- /dev/null +++ b/src-map-editor/src/editor/input.rs @@ -0,0 +1,336 @@ +//! Painting with the mouse and the brush cursor +//! +//! A stroke lasts from pressing a mouse button until it is released or the cursor leaves the +//! viewer, and it is undone as a whole. All modes paint with the left button, some building +//! tools also do something with the right one. The brush is applied once to every tile the cursor +//! enters, not every frame like in the original editor. The line and area brushes are applied once +//! when the button is released, leaving the viewer cancels them. On the roof floor the modes that +//! only edit the ground don't paint. +//! +//! The cursor is drawn on the floor: on the roof it is raised to the roofs, with lines down to +//! the tiles on the ground. + +use std::collections::HashSet; + +use bevy::prelude::*; + +use crate::{ + components::map::{game_to_world, GridPosition, JA2_TILE_HEIGHT, JA2_TILE_WIDTH}, + map::{GridNo, Level, TacticalMap}, + render::{soldiers::ROOF_HEIGHT_ADJUSTMENT, RenderOptions}, + systems::cursor::CursorInfo, + tileset::Tileset, +}; + +use super::{ + brush::{tiles_between, Brush}, + commands::CurrentMode, + mode::EditorMode, + MapEdit, +}; + +/// The stroke in progress +#[derive(Resource, Clone, Debug, Default)] +pub struct EditorStroke { + pub active: bool, + /// Where the stroke started + pub anchor: Option, + /// The last tile the brush was on, `None` while the cursor is off the map + pub last: Option, + /// The button that started the stroke + pub button: Option, +} + +/// The mode and its brush, `None` while only viewing and for the modes that don't edit the floor +fn editing_mode<'a>( + mode: &'a CurrentMode, + edit: &MapEdit, + floor: Level, +) -> Option<(&'a EditorMode, Brush)> { + if floor == Level::Roof && mode.0.is_ground_only() { + return None; + } + let brush = mode.0.brush(edit.tileset(), edit.smoothing())?; + Some((&mode.0, brush)) +} + +pub fn paint( + mouse: Res>, + cursor: Res, + mode: Res, + options: Res, + mut stroke: ResMut, + mut edit: MapEdit, +) { + let floor = options.floor; + let mode = editing_mode(&mode, &edit, floor); + let in_viewer = cursor.world_position.is_some(); + let button = stroke.button.unwrap_or(MouseButton::Left); + let pressed = mouse.pressed(button); + + if stroke.active { + let Some((mode, brush)) = mode.filter(|_| pressed && in_viewer) else { + // The stroke ends + if let (Some((mode, brush)), false, Some(anchor), Some(last)) = + (mode, pressed, stroke.anchor, stroke.last) + { + if brush.applies_on_release() { + let area = brush.region(Some(anchor), last); + edit.edit(|context| mode.apply(context, &area, button, &options)); + } + } + *stroke = EditorStroke::default(); + edit.document().undo.end(); + return; + }; + let Some(tile) = cursor.tile else { + if !brush.applies_on_release() { + // Painting starts again where the cursor comes back + stroke.last = None; + } + return; + }; + if stroke.last == Some(tile) { + return; + } + if !brush.applies_on_release() { + let tiles = match stroke.last { + Some(last) => tiles_between(last, tile), + None => vec![tile], + }; + edit.edit(|context| { + for tile in tiles { + mode.apply(context, &brush.region(None, tile), button, &options); + } + }); + } + stroke.last = Some(tile); + return; + } + + let (Some((mode, brush)), Some(tile)) = (mode, cursor.tile) else { + return; + }; + let Some(button) = [MouseButton::Left, MouseButton::Right] + .into_iter() + .find(|&button| mouse.just_pressed(button) && mode.uses_button(button)) + else { + return; + }; + *stroke = EditorStroke { + active: true, + anchor: Some(tile), + last: Some(tile), + button: Some(button), + }; + edit.document().undo.begin(); + if !brush.applies_on_release() { + edit.edit(|context| mode.apply(context, &brush.region(None, tile), button, &options)); + } +} + +/// The edges around a set of tiles in world space +/// +/// Each tile is a diamond, raised by its terrain height like the renderer draws it. Its edges are +/// drawn where the neighbouring tile is not in the set or lies at another height, so that only +/// the outline of the brush is drawn, whatever its shape. +pub(super) fn area_outline(tiles: &[GridNo], map: &TacticalMap) -> Vec<(Vec2, Vec2)> { + let set: HashSet = tiles.iter().copied().collect(); + let size = IVec2::new(JA2_TILE_WIDTH, JA2_TILE_HEIGHT); + let top = IVec2::new(size.x / 2, 0); + let right = IVec2::new(size.x, size.y / 2); + let bottom = IVec2::new(size.x / 2, size.y); + let left = IVec2::new(0, size.y / 2); + // The neighbour across each edge of the diamond + let edges = [ + ((0, -1), top, right), + ((1, 0), right, bottom), + ((0, 1), bottom, left), + ((-1, 0), left, top), + ]; + let mut lines = vec![]; + for &grid_no in tiles { + let position = grid_no.position(); + let height = map.tiles[grid_no].height; + let raised = position.tile_box() - IVec2::new(0, i32::from(height)); + let corner = |offset: IVec2| game_to_world(raised + offset); + for ((dx, dy), from, to) in edges { + let neighbour = + GridPosition::from_xy(i32::from(position.x) + dx, i32::from(position.y) + dy) + .map(GridNo::from); + let joined = + neighbour.is_some_and(|n| set.contains(&n) && map.tiles[n].height == height); + if !joined { + lines.push((corner(from), corner(to))); + } + } + } + lines +} + +/// How much higher the roof level is drawn, in world pixels (`SECOND_LEVEL_Z_OFFSET`) +pub const ROOF_RAISE: f32 = ROOF_HEIGHT_ADJUSTMENT as f32; + +/// Lines raised onto the roof for the roof level +pub fn raised(lines: Vec<(Vec2, Vec2)>, level: Level) -> Vec<(Vec2, Vec2)> { + if level == Level::Ground { + return lines; + } + let raise = Vec2::new(0., ROOF_RAISE); + lines + .into_iter() + .map(|(from, to)| (from + raise, to + raise)) + .collect() +} + +/// The outline of a cursor on a floor: unchanged on the ground, on the roof raised with +/// vertical lines from its leftmost, rightmost, top and bottom corners down to the ground +pub fn floor_outline(lines: Vec<(Vec2, Vec2)>, floor: Level) -> Vec<(Vec2, Vec2)> { + if floor == Level::Ground { + return lines; + } + let corners: Vec = lines.iter().flat_map(|&(from, to)| [from, to]).collect(); + let extreme = |better: fn(Vec2, Vec2) -> bool| { + corners + .iter() + .copied() + .reduce(|best, corner| if better(corner, best) { corner } else { best }) + }; + let mut drops: Vec = vec![]; + for corner in [ + extreme(|a, b| a.x < b.x), + extreme(|a, b| a.x > b.x), + extreme(|a, b| a.y > b.y), + extreme(|a, b| a.y < b.y), + ] + .into_iter() + .flatten() + { + if !drops.contains(&corner) { + drops.push(corner); + } + } + let raise = Vec2::new(0., ROOF_RAISE); + let mut outline = raised(lines, floor); + outline.extend(drops.into_iter().map(|corner| (corner + raise, corner))); + outline +} + +/// The colour of a cursor of a mode that doesn't edit the floor +pub const INACTIVE_CURSOR: Srgba = Srgba::new(0.6, 0.6, 0.6, 0.8); + +/// Outlines the tiles the brush covers, red while erasing, grey on the roof for the modes that +/// only edit the ground +pub fn draw_brush_cursor( + mut gizmos: Gizmos, + cursor: Res, + mode: Res, + options: Res, + stroke: Res, + tileset: Res, + map: Res, +) { + let Some(tile) = cursor.tile else { + return; + }; + // The selection draws the buildings instead + if mode.0.selects_buildings() { + return; + } + let Some(brush) = mode.0.brush(&tileset, map.info.editor_smoothing) else { + return; + }; + let floor = options.floor; + let color = if mode.0.erases() { + Srgba::RED + } else if floor == Level::Roof && mode.0.is_ground_only() { + INACTIVE_CURSOR + } else { + Srgba::GREEN + }; + let anchor = stroke.anchor.filter(|_| stroke.active); + let outline = area_outline(&brush.region(anchor, tile).tiles, &map); + for (from, to) in floor_outline(outline, floor) { + gizmos.line_2d(from, to, color); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{editor::brush::Brush, tileset::TilesetId}; + + fn outline(brush: Brush) -> Vec<(Vec2, Vec2)> { + let map = TacticalMap::empty(TilesetId::new(0)); + area_outline(&brush.region(None, GridPosition::new(10, 10)).tiles, &map) + } + + #[test] + fn outlines_go_around_the_tiles() { + let single = outline(Brush::Square { size: 1 }); + assert_eq!(single.len(), 4); + // A closed diamond, 40×20 + for (i, (_, to)) in single.iter().enumerate() { + assert_eq!(*to, single[(i + 1) % 4].0); + } + assert_eq!(single[1].0 - single[0].0, Vec2::new(20., -10.)); + + let square = outline(Brush::Square { size: 3 }); + assert_eq!(square.len(), 12); + // No edge inside the square: every edge is on the diamond around the 3×3 tiles + let area = Brush::Square { size: 3 }.region(None, GridPosition::new(10, 10)); + let center = game_to_world( + GridPosition::new(10, 10).tile_box() + IVec2::new(JA2_TILE_WIDTH, JA2_TILE_HEIGHT) / 2, + ); + for (from, to) in &square { + let middle = (*from + *to) / 2. - center; + // On the diamond with the half sizes 60 and 30 + assert_eq!(middle.x.abs() + 2. * middle.y.abs(), 60.); + } + assert_eq!(area.tiles.len(), 9); + + // Every tile of a circle is on the outline or surrounded + let circle = outline(Brush::Circle { size: 5 }); + assert_eq!(circle.len(), 5 * 4); + + // Raised tiles are outlined where they are drawn, apart from their neighbours + let mut map = TacticalMap::empty(TilesetId::new(0)); + map.tiles[GridNo::from(GridPosition::new(10, 10))].height = 30; + let area = Brush::Square { size: 3 }.region(None, GridPosition::new(10, 10)); + let raised = area_outline(&area.tiles, &map); + assert_eq!(raised.len(), 12 + 4 + 4); + let flat = area_outline(&area.tiles[..1], &map); + let top = game_to_world(GridPosition::new(9, 9).tile_box() + IVec2::new(20, 0)); + assert_eq!(flat[0].0, top); + let center = GridPosition::new(10, 10).tile_box() + IVec2::new(20, -30); + assert!(raised + .iter() + .any(|&(from, _)| from == game_to_world(center))); + } + + #[test] + fn floor_outlines_are_raised_onto_the_roof() { + let single = outline(Brush::Square { size: 1 }); + assert_eq!(floor_outline(single.clone(), Level::Ground), single); + + let roof = floor_outline(single.clone(), Level::Roof); + assert_eq!(roof.len(), 4 + 4); + let raise = Vec2::new(0., ROOF_RAISE); + for (line, (from, to)) in single.iter().zip(&roof) { + assert_eq!((line.0 + raise, line.1 + raise), (*from, *to)); + } + // Vertical lines from every corner of the raised tile down to the ground + let mut drops: Vec<(Vec2, Vec2)> = roof[4..].to_vec(); + let mut corners: Vec = single.iter().map(|&(from, _)| from).collect(); + drops.sort_by(|a, b| a.1.x.total_cmp(&b.1.x).then(a.1.y.total_cmp(&b.1.y))); + corners.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + for ((from, to), corner) in drops.iter().zip(&corners) { + assert_eq!(*to, *corner); + assert_eq!(*from, *corner + raise); + } + + // Larger areas only get the lines at their outermost corners + let square = floor_outline(outline(Brush::Square { size: 3 }), Level::Roof); + assert_eq!(square.len(), 12 + 4); + } +} diff --git a/src-map-editor/src/editor/item_events.rs b/src-map-editor/src/editor/item_events.rs new file mode 100644 index 0000000..249710e --- /dev/null +++ b/src-map-editor/src/editor/item_events.rs @@ -0,0 +1,384 @@ +//! What the host is told about the world items and how it edits them +//! +//! Whenever the selected tile, its items or the number of placed items change, the host gets the +//! items of the selected tile (`item`) and how many items of each choice of the palette the map +//! has. The host sends the edited values of an item back with `set_item` and everything else with +//! `item_command`. Failed edits are reported with a `notice`. + +use std::collections::BTreeMap; + +use bevy::{ecs::system::SystemParam, prelude::*}; +use serde::{Deserialize, Serialize}; + +use crate::{ + host::report_editor_event, + item::{ItemTable, IC_KEY}, + map::{ + item_data::KeyData, Level, MapItem, TacticalMap, Visibility, WorldItemFlags, WorldItemId, + }, + render::{world::MapWorld, PendingSceneUpdate, RenderOptions}, + states::loading::MapToLoad, + systems::pixel_view::PixelView, +}; + +use super::{ + document::{EditContext, EditorDocument}, + items::{center_view, matches_choice, ItemChoice, ItemPools, ItemSelection, NUMBER_TRIGGERS}, + soldier_events::{ItemStackValue, NoticeEvent}, + soldiers::edit_once, + MapEdit, +}; + +/// A world item as the host shows it +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemValue { + pub id: u32, + pub grid_no: u16, + pub on_roof: bool, + pub stack: ItemStackValue, + pub visibility: Visibility, + /// `WorldItemFlags`, e.g. `REALISTIC_ONLY` (0x100) and `SCIFI_ONLY` (0x80) + pub flags: u16, + /// Chance of the item not existing in the game, in percent + pub non_exist_chance: u8, +} + +impl ItemValue { + pub fn of(item: &MapItem) -> Self { + Self { + id: item.id.0, + grid_no: item.grid_no.index() as u16, + on_roof: item.level == Level::Roof, + stack: ItemStackValue::of(&item.stack), + visibility: item.visibility, + flags: item.flags.bits(), + non_exist_chance: item.non_exist_chance, + } + } +} + +/// The values of an item sent with `set_item` +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemEdit { + pub id: u32, + pub stack: ItemStackValue, + pub visibility: Visibility, + pub flags: u16, + pub non_exist_chance: u8, +} + +/// How often the map has an item, and how many items its stacks hold +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct ItemCount { + pub item: u16, + pub placed: u16, + pub quantity: u32, +} + +/// How often the map has the key of a lock +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyCount { + pub lock_id: u8, + pub placed: u16, +} + +/// How many items of each choice of the palette the map has +/// (`CountNumberOfEditorPlacementsInWorld`) +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct ItemCounts { + /// By item, in the order of the items + pub items: Vec, + /// By trigger slot + pub triggers: Vec, + /// By lock, in the order of the locks + pub keys: Vec, +} + +impl ItemCounts { + pub fn of(map: &TacticalMap, items: Option<&ItemTable>) -> Self { + let mut by_item: BTreeMap = BTreeMap::new(); + let mut by_lock: BTreeMap = BTreeMap::new(); + let mut triggers = vec![0; usize::from(NUMBER_TRIGGERS)]; + for item in &map.items { + let stack = &item.stack; + let count = by_item.entry(stack.item.index()).or_insert(ItemCount { + item: stack.item.index(), + placed: 0, + quantity: 0, + }); + count.placed += 1; + count.quantity += u32::from(stack.number); + let kind = items.and_then(|items| items.info(stack.item)); + if kind.is_some_and(|kind| kind.class == IC_KEY) { + *by_lock + .entry(KeyData::read(&stack.data).key_id) + .or_default() += 1; + } + for (slot, count) in triggers.iter_mut().enumerate() { + if matches_choice(stack, kind, ItemChoice::Trigger { slot: slot as u8 }) { + *count += 1; + } + } + } + Self { + items: by_item.into_values().collect(), + triggers, + keys: by_lock + .into_iter() + .map(|(lock_id, placed)| KeyCount { lock_id, placed }) + .collect(), + } + } +} + +/// The selected tile and its items +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename = "item", rename_all = "camelCase")] +pub struct ItemEvent { + pub map: String, + /// The selected tile, `null` if none is + pub tile: Option, + /// The items shown on the selected tile, in the order of the map + pub items: Vec, + pub counts: ItemCounts, +} + +/// What the host asks of the items besides editing them +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ItemCommand { + /// Removes an item of the selected tile + Delete { id: u32 }, + /// Centers the view on the selected tile + Center, +} + +/// What item commands need besides the map and the view +#[derive(SystemParam)] +pub struct ItemCommandParams<'w> { + pub selection: ResMut<'w, ItemSelection>, + pub items: Option>, +} + +/// Edits an item with the values of the host, ignored if the item is gone +pub fn update_item(context: &mut EditContext, items: &ItemTable, edit: &ItemEdit) { + let Some(stack) = edit.stack.stack() else { + return; + }; + context.change_item( + items, + WorldItemId(edit.id), + stack, + edit.visibility, + WorldItemFlags::from_bits_retain(edit.flags), + edit.non_exist_chance, + ); +} + +/// Runs an item command of the host +pub fn run_item_command( + command: ItemCommand, + edit: &mut MapEdit, + params: &mut ItemCommandParams, + view: Option<&mut PixelView>, +) { + let selection = &mut *params.selection; + match command { + ItemCommand::Delete { id } => { + edit_once(edit, |context| context.delete_item(WorldItemId(id))) + } + ItemCommand::Center => { + if let Some(tile) = selection.selected { + center_view(view, &edit.map, tile); + } + } + } +} + +/// The item event that was reported last, `None` for a new map +#[derive(Resource, Clone, Debug, Default)] +pub struct ReportedItem(Option); + +pub fn reset_reported_item(mut reported: ResMut) { + reported.0 = None; +} + +/// Tells the host about the selected tile when it or its items change, and about failed edits +/// +/// The selection is dropped here when its tile has no items on the floor any more, after the +/// world caught up with the edits of the frame. +pub fn report_item( + map_to_load: Res, + map: Res, + world: Res, + options: Res, + items: Option>, + mut selection: ResMut, + mut reported: ResMut, +) { + if let Some(message) = selection.notice.take() { + report_editor_event(&NoticeEvent { + map: map_to_load.0.clone(), + message, + }); + } + let pools = ItemPools::new(&map, &world, options.floor); + selection.validate(&pools); + let pool = selection + .selected + .map(|tile| pools.pool(tile)) + .unwrap_or_default(); + let event = ItemEvent { + map: map_to_load.0.clone(), + tile: selection.selected.map(|tile| tile.index() as u16), + items: pool + .into_iter() + .map(|index| ItemValue::of(&map.items[index])) + .collect(), + counts: ItemCounts::of(&map, items.as_deref()), + }; + if reported.0.as_ref() != Some(&event) { + report_editor_event(&event); + reported.0 = Some(event); + } +} + +/// Brings the item nodes of the world up to date after the world items changed, the scene +/// catches up with the tiles they were and are on +pub fn sync_item_scene( + mut document: ResMut, + map: Res, + mut world: ResMut, + mut pending: ResMut, +) { + if !document.items_dirty { + return; + } + document.items_dirty = false; + let touched = world.sync_items(&map); + pending.tiles.extend(touched); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::{ + document::tests::grid, + items::{ + tests::{mode, ItemFixture}, + world_item, ItemMode, + }, + }; + + #[test] + fn commands_are_read_from_json() { + let parse = |json: &str| serde_json::from_str::(json).unwrap(); + assert_eq!( + parse(r#"{"kind":"delete","id":4}"#), + ItemCommand::Delete { id: 4 } + ); + assert_eq!(parse(r#"{"kind":"center"}"#), ItemCommand::Center); + assert!(serde_json::from_str::(r#"{"kind":"nextPool"}"#).is_err()); + } + + #[test] + fn the_host_edits_the_selected_item() { + let mut fixture = ItemFixture::new(); + let placed = mode(r#"{"mode":"items","choice":{"kind":"item","item":202}}"#); + let id = fixture + .edit(|context, items| context.place_item(items, &placed, grid(30, 30), Level::Ground)) + .unwrap(); + let value = ItemValue::of(world_item(&fixture.fixture.map, id).unwrap()); + let mut json = serde_json::to_value(&value).unwrap(); + assert_eq!(json["gridNo"], 30 * 160 + 30); + assert_eq!(json["onRoof"], false); + assert_eq!(json["visibility"], "invisible"); + assert_eq!(json["stack"]["item"], 202); + + // The host sends the item back with its changes + json["visibility"] = "hiddenItem".into(); + json["flags"] = 0x80.into(); + json["nonExistChance"] = 25.into(); + json["stack"]["data"][0] = 55.into(); + let edit: ItemEdit = serde_json::from_value(json).unwrap(); + fixture.edit(|context, items| update_item(context, items, &edit)); + let edited = world_item(&fixture.fixture.map, id).unwrap(); + assert_eq!(edited.visibility, Visibility::HiddenItem); + assert_eq!(edited.flags, WorldItemFlags::SCIFI_ONLY); + assert_eq!(edited.non_exist_chance, 25); + assert_eq!(edited.stack.data[0], 55); + assert_eq!(fixture.fixture.document.undo.depth(), 2); + // Edits of other items are ignored + let other = ItemEdit { id: 99, ..edit }; + fixture.edit(|context, items| update_item(context, items, &other)); + assert_eq!(fixture.fixture.document.undo.depth(), 2); + + // Removing a single item of the tile + fixture.edit(|context, _| context.delete_item(WorldItemId(id.0))); + assert!(fixture.fixture.map.items.is_empty()); + fixture.undo(); + assert!(world_item(&fixture.fixture.map, id).is_some()); + } + + #[test] + fn counts_have_the_choices_of_the_palette() { + let mut fixture = ItemFixture::new(); + let place = |fixture: &mut ItemFixture, choice: ItemChoice, x| { + let mode = ItemMode { + choice: Some(choice), + ..Default::default() + }; + fixture + .edit(|context, items| context.place_item(items, &mode, grid(x, 30), Level::Ground)) + .unwrap(); + }; + place(&mut fixture, ItemChoice::Item { item: 71 }, 10); + place(&mut fixture, ItemChoice::Item { item: 71 }, 11); + place(&mut fixture, ItemChoice::Trigger { slot: 3 }, 12); + place(&mut fixture, ItemChoice::Trigger { slot: 26 }, 13); + let key = ItemChoice::Key { + lock_id: 5, + item: 271, + }; + place(&mut fixture, key, 14); + let counts = ItemCounts::of(&fixture.fixture.map, Some(&fixture.items)); + assert_eq!( + counts.items[0], + ItemCount { + item: 71, + placed: 2, + quantity: 2 + } + ); + assert_eq!(counts.triggers.len(), 27); + assert_eq!(counts.triggers[3], 1); + assert_eq!(counts.triggers[26], 1); + assert_eq!(counts.triggers.iter().sum::(), 2); + assert_eq!( + counts.keys, + [KeyCount { + lock_id: 5, + placed: 1 + }] + ); + let first = ItemValue::of(&fixture.fixture.map.items[0]); + let json = serde_json::to_value(&ItemEvent { + map: "maps/a9.dat".to_owned(), + tile: Some(30 * 160 + 10), + items: vec![first], + counts, + }) + .unwrap(); + assert_eq!(json["type"], "item"); + assert_eq!(json["tile"], 30 * 160 + 10); + assert_eq!(json["items"][0]["stack"]["item"], 71); + assert_eq!(json["counts"]["keys"][0]["lockId"], 5); + } +} diff --git a/src-map-editor/src/editor/item_overlay.rs b/src-map-editor/src/editor/item_overlay.rs new file mode 100644 index 0000000..fff1145 --- /dev/null +++ b/src-map-editor/src/editor/item_overlay.rs @@ -0,0 +1,166 @@ +//! What the items mode draws over the map: a ring on every tile with items (`SELRING1` of the +//! original editor), the selected tile, the areas of the pits its action items dig and the tile +//! under the cursor, which depends on the tool + +use bevy::prelude::*; + +use crate::{ + item::{ItemId, ItemTable}, + map::{GridNo, Level, TacticalMap}, + render::{world::MapWorld, RenderOptions}, + systems::cursor::CursorInfo, +}; + +use super::{ + commands::CurrentMode, + document::EditorDocument, + input::{area_outline, raised}, + items::{is_water, item_mode, pit_size, ItemChoice, ItemPools, ItemSelection, ItemTool}, + soldier_events::tile_center, + soldier_overlay::{cursor_outline, tile_outline}, + soldiers::has_level, +}; + +/// The tiles of a square around a tile, `size` tiles wide, that are on the map +fn square(grid_no: GridNo, size: u8) -> Vec { + let reach = i32::from(size / 2); + let mut tiles = vec![]; + for dy in -reach..=reach { + for dx in -reach..=reach { + if let Some(tile) = grid_no.offset(dy * 160 + dx) { + tiles.push(tile); + } + } + } + tiles +} + +/// A small ring in the middle of a tile, a diamond of a third of its size +pub fn ring(map: &TacticalMap, grid_no: GridNo) -> Vec<(Vec2, Vec2)> { + let center = tile_center(map, grid_no); + tile_outline(map, grid_no, Level::Ground) + .into_iter() + .map(|(from, to)| (center + (from - center) / 3., center + (to - center) / 3.)) + .collect() +} + +/// Whether a click on a level of a tile can place the chosen item, without the random choices +pub fn can_place( + map: &TacticalMap, + world: &MapWorld, + items: &ItemTable, + choice: Option, + grid_no: GridNo, + level: Level, +) -> bool { + if !has_level(world, grid_no, level) { + return false; + } + let item = match choice { + None => return false, + Some(ItemChoice::Item { item } | ItemChoice::Key { item, .. }) => ItemId::new(item), + Some(ItemChoice::Trigger { slot }) => { + let trigger = slot % 2 == 0 && slot < super::items::PRESSURE_ACTION_SLOT; + if trigger + && map.items.iter().any(|i| { + i.grid_no == grid_no && i.level == level && i.stack.item == ItemId::SWITCH + }) + { + return false; + } + Some(if trigger { + ItemId::SWITCH + } else { + ItemId::ACTION_ITEM + }) + } + }; + let Some(kind) = item.and_then(|item| items.info(item)) else { + return false; + }; + !(kind.sinks && is_water(world, map.tileset, grid_no)) +} + +/// The tiles with items on the floor, kept until the items, the world or the floor change +#[derive(Default)] +pub struct PoolTiles { + revision: Option, + pools: ItemPools, +} + +/// Draws the rings of the pools of the floor in white, the selected tile in yellow with the areas +/// of the pits of its action items in red, and the tile under the cursor on the floor: for +/// placing green where a click places the item, red where it can't, for selecting green where the +/// selected items are dragged to and faintly a tile with items that a click selects. The brush +/// that erases is drawn by `draw_brush_cursor`. +pub fn draw_item_markers( + mut gizmos: Gizmos, + cursor: Res, + mode: Res, + options: Res, + selection: Res, + document: Res, + map: Res, + world: Res, + items: Option>, + mut cache: Local, +) { + let Some(mode) = item_mode(&mode) else { + return; + }; + let floor = options.floor; + if cache.revision != Some(document.revision) + || map.is_changed() + || world.is_changed() + || cache.pools.level() != floor + { + cache.revision = Some(document.revision); + cache.pools = ItemPools::new(&map, &world, floor); + } + let mut lines = |lines: Vec<(Vec2, Vec2)>, color: Srgba| { + for (from, to) in lines { + gizmos.line_2d(from, to, color); + } + }; + let pool_tiles = cache.pools.pool_tiles(); + for &tile in &pool_tiles { + lines(raised(ring(&map, tile), floor), Srgba::WHITE); + } + if let Some(selected) = selection.selected { + for index in cache.pools.pool(selected) { + let item = &map.items[index]; + if let Some(size) = pit_size(&item.stack) { + lines(area_outline(&square(item.grid_no, size), &map), Srgba::RED); + } + } + lines( + tile_outline(&map, selected, floor), + Srgba::rgb(1., 0.85, 0.), + ); + } + let Some(tile) = cursor.tile.map(GridNo::from) else { + return; + }; + match mode.tool { + ItemTool::Place if mode.erase => {} + ItemTool::Place => { + let can_place = items + .is_some_and(|items| can_place(&map, &world, &items, mode.choice, tile, floor)); + let color = if can_place { Srgba::GREEN } else { Srgba::RED }; + lines(cursor_outline(&map, tile, floor), color); + } + ItemTool::Select if selection.dragging() => { + if selection.selected != Some(tile) { + lines(cursor_outline(&map, tile, floor), Srgba::GREEN); + } + } + ItemTool::Select => { + if pool_tiles.binary_search(&tile).is_ok() && selection.selected != Some(tile) { + lines( + cursor_outline(&map, tile, floor), + Srgba::new(1., 1., 1., 0.35), + ); + } + } + } +} diff --git a/src-map-editor/src/editor/items.rs b/src-map-editor/src/editor/items.rs new file mode 100644 index 0000000..946813f --- /dev/null +++ b/src-map-editor/src/editor/items.rs @@ -0,0 +1,1604 @@ +//! World items: placing, erasing, selecting, moving and deleting them with the items mode (the +//! Items tab of the original editor, `EditorItems.cc`) +//! +//! The `place` tool places the chosen item, trigger, action item or key with a left click, with +//! the defaults of the original (`AddSelectedItemToWorld`). With `erase` its brush removes the +//! items shown on the tiles instead. The `select` tool selects the clicked tile with all items +//! shown on it (its pool, `ITEM_POOL`). Only the items on the floor of the render options are +//! edited, on the roof floor items are placed on flat roofs. Dragging the selected tile moves its items, dragging it +//! while Ctrl (Cmd on macOS) was held on the press copies them. Delete removes all items of the +//! selected tile and Esc deselects it. Every change can be undone. The host edits the items of +//! the selected tile with `set_item` and removes single ones with `item_command`, see +//! `item_events.rs`. +//! +//! Unlike the original, selecting an item doesn't change whether it is hidden, the status of a +//! new item doesn't overwrite the amount of money or the values of bombs, keys and ownership, new +//! items always exist (there is no default exist chance) and items that sink can't be placed in +//! water. + +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{ + item::{ + ItemId, ItemKind, ItemTable, IC_AMMO, IC_ARMOUR, IC_BLADE, IC_BOMB, IC_FACE, IC_GRENADE, + IC_GUN, IC_KEY, IC_KIT, IC_LAUNCHER, IC_MEDKIT, IC_MISC, IC_MONEY, IC_PUNCH, + IC_THROWING_KNIFE, IC_THROWN, + }, + map::{ + item_data::{BombData, GunData, KeyData, MoneyData, OwnerData, BOMB_PRESSURE, BOMB_REMOTE}, + item_graphic, GridNo, ItemHeight, ItemStack, Level, MapItem, TacticalMap, Visibility, + WorldItemFlags, WorldItemId, + }, + render::{ + items::item_placement, + world::{MapWorld, NodeFlags}, + RenderOptions, + }, + systems::{cursor::CursorInfo, pixel_view::PixelView}, + tileset::{Terrain, TilesetId}, +}; + +use super::{ + brush::{Brush, BrushArea}, + commands::CurrentMode, + document::EditContext, + mode::EditorMode, + soldier_events::tile_center, + soldiers::{edit_once, has_level, NO_FLAT_ROOF}, + MapEdit, +}; + +/// The trigger slots of the editor: pairs of a panic trigger and its action, then pairs of a +/// trigger and its action, the last one is the pressure action (`NUMBER_TRIGGERS`) +pub const NUMBER_TRIGGERS: u8 = 27; +/// The slot of the pressure action (`PRESSURE_ACTION_ID`) +pub const PRESSURE_ACTION_SLOT: u8 = NUMBER_TRIGGERS - 1; +/// Frequencies of the panic triggers (`PANIC_FREQUENCY` and so on), from 125 the trigger can be +/// an alarm trigger +pub const PANIC_FREQUENCY_3: i8 = 125; +/// Frequency of the first trigger the map places (`FIRST_MAP_PLACED_FREQUENCY`) +const FIRST_MAP_PLACED_FREQUENCY: i8 = 50; + +/// Profile of items that nobody owns (`NO_PROFILE`) +pub const NO_PROFILE: u8 = 200; +/// Flags of item stacks (`OBJECT_UNDROPPABLE`, `OBJECT_ARMED_BOMB`) +const OBJECT_UNDROPPABLE: i8 = 0x01; +const OBJECT_ARMED_BOMB: i8 = 0x08; +/// The side that placed the bombs of the map (`ubBombOwner`) +const MAP_BOMB_OWNER: u8 = 1; +/// The most money of a stack (`MAX_MONEY_PER_SLOT`) +pub const MAX_MONEY: u32 = 20000; +/// The highest trap level +pub const MAX_TRAP_LEVEL: i8 = 20; +/// The highest tolerance of a panic trigger +const MAX_TOLERANCE: u8 = 99; +/// Number of civilian groups an ownership item can name (`NUM_CIV_GROUPS`) +const NUM_CIV_GROUPS: u8 = super::soldiers::NUM_CIV_GROUPS; + +/// What an action item does (`ItemActionType`), `ACTION_ITEM_BLOW_UP` explodes its bomb item +const ACTION_ITEM_BLOW_UP: u8 = 3; +const ACTION_ITEM_SMALL_PIT: u8 = 4; +const ACTION_ITEM_LARGE_PIT: u8 = 5; + +/// The actions the editor offers (`ACTIONITEM_*`, `ChangeActionItem`): the item that explodes, or +/// the action of the action item +pub const ACTION_ITEMS: [(Option, u8); 34] = [ + // Klaxon mine, flare mine, teargas, stun, smoke, mustard gas, land mine + (Some(144), ACTION_ITEM_BLOW_UP), + (Some(143), ACTION_ITEM_BLOW_UP), + (Some(132), ACTION_ITEM_BLOW_UP), + (Some(131), ACTION_ITEM_BLOW_UP), + (Some(151), ACTION_ITEM_BLOW_UP), + (Some(133), ACTION_ITEM_BLOW_UP), + (Some(141), ACTION_ITEM_BLOW_UP), + // Open door, close door, 3x3 pit, 5x5 pit + (None, 1), + (None, 2), + (None, ACTION_ITEM_SMALL_PIT), + (None, ACTION_ITEM_LARGE_PIT), + // Small (hand grenade), medium (TNT) and large (C4) explosion + (Some(135), ACTION_ITEM_BLOW_UP), + (Some(137), ACTION_ITEM_BLOW_UP), + (Some(142), ACTION_ITEM_BLOW_UP), + // Toggle door, toggle actions 1 to 4, enter and exit brothel, Kingpin alarm, sex, reveal + // room, local and global alarm, klaxon, unlock door, toggle lock, untrap door, toggle + // pressure items, museum alarm, bloodcat alarm + (None, 6), + (None, 7), + (None, 8), + (None, 9), + (None, 10), + (None, 11), + (None, 12), + (None, 13), + (None, 14), + (None, 15), + (None, 16), + (None, 17), + (None, 18), + (None, 19), + (None, 20), + (None, 21), + (None, 22), + (None, 23), + (None, 24), + // Big teargas + (Some(157), ACTION_ITEM_BLOW_UP), +]; +/// The action of new action items in the original (`ACTIONITEM_MEDIUM`) +const DEFAULT_ACTION: u8 = 12; + +/// What a click places +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ItemChoice { + /// An item of the weapons, ammo, armour, explosives or equipment + Item { item: u16 }, + /// A slot of the triggers: even slots are triggers, odd ones and the last one actions + Trigger { slot: u8 }, + /// A key of a lock of `Locks.bin`, `item` is the key item of the lock + Key { lock_id: u8, item: u16 }, +} + +/// What a click in the items mode does +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ItemTool { + /// Selects the items of a tile, moves, copies and deletes them + #[default] + Select, + /// Places items, or erases them with the brush + Place, +} + +/// The items mode (`TASK_ITEMS` of the original editor) +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ItemMode { + pub tool: ItemTool, + /// Removes the items of the brush instead of placing them + pub erase: bool, + /// The brush that erases items + pub brush: Brush, + pub choice: Option, + /// Trap level of new action items (`gbDefaultBombTrapLevel`) + pub trap_level: i8, + /// What new action items do, an index of [`ACTION_ITEMS`] (`gbActionItemIndex`) + pub action: u8, +} + +impl Default for ItemMode { + fn default() -> Self { + Self { + tool: ItemTool::Select, + erase: false, + brush: Brush::default(), + choice: None, + trap_level: 9, + action: DEFAULT_ACTION, + } + } +} + +impl ItemMode { + /// The brush that erases items, `None` while placing or selecting, which use clicks + pub fn brush(&self) -> Option { + match (self.tool, self.erase, self.brush) { + (ItemTool::Place, true, Brush::Fill) => Some(Brush::default()), + (ItemTool::Place, true, brush) => Some(brush), + _ => None, + } + } + + /// Erases the items on the floor shown on the tiles of the brush + pub fn apply(&self, context: &mut EditContext, area: &BrushArea, floor: Level) { + if self.brush().is_some() { + context.erase_items(area, floor); + } + } +} + +/// The frequency of the triggers and actions of a slot (`CalcItemFrequency`) +pub fn trigger_frequency(slot: u8) -> i8 { + match slot { + 0..=1 => 127, + 2..=3 => 126, + 4..=5 => PANIC_FREQUENCY_3, + _ => FIRST_MAP_PLACED_FREQUENCY + ((slot - 4) / 2) as i8, + } +} + +/// Whether the editor offers items of a class (`InitEditorItemsInfo`) +pub fn is_editable_class(class: u32) -> bool { + [ + IC_GUN, + IC_BLADE, + IC_THROWING_KNIFE, + IC_LAUNCHER, + IC_THROWN, + IC_PUNCH, + IC_AMMO, + IC_ARMOUR, + IC_GRENADE, + IC_BOMB, + IC_MEDKIT, + IC_KIT, + IC_FACE, + IC_MISC, + IC_MONEY, + IC_KEY, + ] + .contains(&class) +} + +/// Whether the land of a tile is water, which sinking items are lost in (`Water`) +pub fn is_water(world: &MapWorld, tileset: TilesetId, grid_no: GridNo) -> bool { + let tile = &world.tiles[grid_no]; + let object = tile.objects.first(); + if object.is_some_and(|node| tileset.is_pool_without_terrain(node.tile.id)) { + return true; + } + let terrain = object + .map(|node| node.tile.terrain) + .filter(|&terrain| terrain != Terrain::NoTerrain) + .or_else(|| tile.land.first().map(|node| node.tile.terrain)); + matches!( + terrain, + Some(Terrain::LowWater | Terrain::MedWater | Terrain::DeepWater) + ) +} + +/// Sets what an action item does (`ChangeActionItem`) +pub fn change_action_item(data: &mut [u8; 12], action: u8) { + let (bomb_item, action_value) = ACTION_ITEMS + .get(usize::from(action)) + .copied() + .unwrap_or(ACTION_ITEMS[usize::from(DEFAULT_ACTION)]); + let mut bomb = BombData::read(data); + bomb.bomb_item = bomb_item.unwrap_or(0); + bomb.action_value = action_value; + bomb.write(data); +} + +/// The pit an action item digs, its size in tiles +pub fn pit_size(stack: &ItemStack) -> Option { + if stack.item != ItemId::ACTION_ITEM { + return None; + } + match BombData::read(&stack.data).action_value { + ACTION_ITEM_SMALL_PIT => Some(3), + ACTION_ITEM_LARGE_PIT => Some(5), + _ => None, + } +} + +/// An empty stack of an item +fn empty_stack(item: ItemId) -> ItemStack { + ItemStack { + item, + number: 1, + data: [0; 12], + attachments: vec![], + flags: 0, + mission: 0, + trap: 0, + imprint_id: 0, + weight: 0, + used: 0, + } +} + +/// A new item stack like the original creates it with the status 100 (`CreateItem`): guns are +/// loaded with their default magazine, magazines are full +pub fn create_item(items: &ItemTable, item: ItemId, kind: &ItemKind) -> ItemStack { + let mut stack = empty_stack(item); + match kind.class { + IC_GUN => { + stack.imprint_id = NO_PROFILE; + let mut gun = GunData { + status: 100, + ammo_status: 100, + ..default() + }; + if item == ItemId::ROCKET_LAUNCHER { + gun.shots_left = 1; + } else if item == ItemId::TANK_CANNON { + gun.shots_left = 0; + } else if let Some(magazine) = items.default_magazine(item) { + let mag = items.info(magazine); + gun.ammo_item = magazine.index(); + gun.ammo_type = mag.map_or(0, |mag| mag.ammo_type); + gun.shots_left = mag.map_or(kind.mag_size, |mag| mag.capacity); + } else { + // Monster spit and guns without magazines + gun.shots_left = kind.mag_size; + } + gun.write(&mut stack.data); + } + IC_AMMO => stack.data[0] = kind.capacity, + IC_MONEY => MoneyData { + status: 100, + amount: 100 * 50, + } + .write(&mut stack.data), + _ => stack.data[0] = 100, + } + if kind.default_undroppable { + stack.flags |= OBJECT_UNDROPPABLE; + } + stack +} + +/// The index of an item in `TacticalMap::items` +pub fn item_index(map: &TacticalMap, id: WorldItemId) -> Option { + map.items.iter().position(|item| item.id == id) +} + +/// The item with an id +pub fn world_item(map: &TacticalMap, id: WorldItemId) -> Option<&MapItem> { + map.items.iter().find(|item| item.id == id) +} + +/// Whether an item is what a choice of the palette places, for counting them +/// (`CountNumberOfEditorPlacementsInWorld`) +pub fn matches_choice(stack: &ItemStack, kind: Option<&ItemKind>, choice: ItemChoice) -> bool { + match choice { + ItemChoice::Item { item } => stack.item.index() == item, + ItemChoice::Trigger { slot } => { + let bomb = BombData::read(&stack.data); + if slot >= PRESSURE_ACTION_SLOT { + stack.item == ItemId::ACTION_ITEM && bomb.detonator == BOMB_PRESSURE + } else { + let item = if slot % 2 == 0 { + ItemId::SWITCH + } else { + ItemId::ACTION_ITEM + }; + stack.item == item && bomb.frequency == trigger_frequency(slot) + } + } + ItemChoice::Key { lock_id, .. } => { + kind.is_some_and(|kind| kind.class == IC_KEY) + && KeyData::read(&stack.data).key_id == lock_id + } + } +} + +/// The tiles the items of a level are shown on: their own tile or the base tile of the structure +/// they lie on, by their index in `TacticalMap::items` +/// +/// Items of the same tile form a pool, in the order of the map (`ITEM_POOL`). The items of the +/// other level have no tile. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ItemPools { + level: Level, + tiles: Vec>, +} + +impl ItemPools { + /// The pools of the items on a level of the map, after the world was synced with them + pub fn new(map: &TacticalMap, world: &MapWorld, level: Level) -> Self { + let mut tiles: Vec> = map + .items + .iter() + .map(|item| (item.level == level).then_some(item.grid_no)) + .collect(); + for (grid_no, tile) in world.tiles.iter() { + for node in tile.structs.iter().chain(&tile.on_roofs) { + if let (Some(index), true) = (node.item, node.flags.contains(NodeFlags::ITEM)) { + if let Some(Some(shown)) = tiles.get_mut(usize::from(index)) { + *shown = grid_no; + } + } + } + } + Self { level, tiles } + } + + /// The level of the items + pub fn level(&self) -> Level { + self.level + } + + /// The tile an item is shown on, by its index, `None` for the items of the other level + pub fn tile(&self, index: usize) -> Option { + self.tiles.get(index).copied().flatten() + } + + /// The indices of the items shown on a tile + pub fn pool(&self, grid_no: GridNo) -> Vec { + (0..self.tiles.len()) + .filter(|&index| self.tiles[index] == Some(grid_no)) + .collect() + } + + /// The tiles with items, in the order of their grid numbers (`BuildItemPoolList`) + pub fn pool_tiles(&self) -> Vec { + let mut tiles: Vec = self.tiles.iter().copied().flatten().collect(); + tiles.sort(); + tiles.dedup(); + tiles + } +} + +impl EditContext<'_> { + /// Places what the mode chose on a level of a tile, on the roof only on a flat roof, returns + /// the new item (`AddSelectedItemToWorld`) + pub fn place_item( + &mut self, + items: &ItemTable, + mode: &ItemMode, + grid_no: GridNo, + level: Level, + ) -> Result { + let Some(choice) = mode.choice else { + return Err("Choose an item to place first".to_owned()); + }; + if !has_level(self.world, grid_no, level) { + return Err(NO_FLAT_ROOF.to_owned()); + } + let mut visibility = Visibility::Invisible; + let mut flags = WorldItemFlags::empty(); + let (stack, kind) = match choice { + ItemChoice::Item { item } => { + let id = ItemId::new(item).ok_or("Nothing can't be placed")?; + let kind = items.info(id).ok_or("The item doesn't exist")?; + if !is_editable_class(kind.class) + || kind.not_editor + || id == ItemId::SWITCH + || id == ItemId::ACTION_ITEM + { + return Err("The editor can't place this item".to_owned()); + } + let mut stack = create_item(items, id, kind); + match kind.class { + IC_MONEY => { + let amount = 100 + self.random(19901); + MoneyData { + status: 100, + amount, + } + .write(&mut stack.data); + } + _ if id == ItemId::OWNERSHIP => { + OwnerData { + profile: NO_PROFILE, + civ_group: 0, + } + .write(&mut stack.data); + visibility = Visibility::Buried; + } + _ => self.randomize_status(&mut stack, kind), + } + (stack, kind) + } + ItemChoice::Trigger { slot } => { + let slot = slot.min(PRESSURE_ACTION_SLOT); + let is_trigger = slot < PRESSURE_ACTION_SLOT && slot % 2 == 0; + let id = if is_trigger { + ItemId::SWITCH + } else { + ItemId::ACTION_ITEM + }; + let kind = items.info(id).ok_or("The item doesn't exist")?; + let mut stack = empty_stack(id); + let mut bomb = BombData { + status: 100, + owner: MAP_BOMB_OWNER, + ..default() + }; + if is_trigger { + // Restricted to one trigger per tile + let taken = self.map.items.iter().any(|item| { + item.grid_no == grid_no + && item.level == level + && item.stack.item == ItemId::SWITCH + }); + if taken { + return Err("There already is a trigger on this tile".to_owned()); + } + bomb.frequency = trigger_frequency(slot); + } else { + stack.trap = mode.trap_level.clamp(0, MAX_TRAP_LEVEL); + if slot < PRESSURE_ACTION_SLOT { + bomb.detonator = BOMB_REMOTE; + bomb.frequency = trigger_frequency(slot); + } else { + bomb.detonator = BOMB_PRESSURE; + } + stack.flags |= OBJECT_ARMED_BOMB; + } + bomb.write(&mut stack.data); + if !is_trigger { + change_action_item(&mut stack.data, mode.action); + } + visibility = Visibility::Buried; + flags |= WorldItemFlags::ARMED_BOMB; + (stack, kind) + } + ItemChoice::Key { lock_id, item } => { + let id = ItemId::new(item).ok_or("The lock has no key")?; + let kind = items.info(id).ok_or("The key doesn't exist")?; + let mut stack = create_item(items, id, kind); + KeyData { + status: 100, + key_id: lock_id, + } + .write(&mut stack.data); + self.randomize_status(&mut stack, kind); + (stack, kind) + } + }; + if kind.sinks && is_water(self.world, self.map.tileset, grid_no) { + return Err("The item would sink in the water".to_owned()); + } + let id = self.document.new_item_id(); + let graphic = item_graphic(self.tileset, items, stack.item); + self.modify_items().push(MapItem { + id, + grid_no, + file_grid_no: None, + level, + stack, + file_object: None, + graphic, + render_height: ItemHeight::Above(0), + visibility, + flags, + non_exist_chance: 0, + }); + Ok(id) + } + + /// The random status of a new item, 70 to 95, only in the status of its class: magazines get + /// a random number of rounds and guns random rounds (`AddSelectedItemToWorld`) + fn randomize_status(&mut self, stack: &mut ItemStack, kind: &ItemKind) { + if kind.class == IC_AMMO { + let capacity = u32::from(kind.capacity); + stack.data[0] = if self.random(2) == 1 { + kind.capacity + } else { + self.random(capacity.max(1)) as u8 + }; + return; + } + stack.data[0] = (70 + self.random(26)) as u8; + if kind.class == IC_GUN { + let mut gun = GunData::read(&stack.data); + gun.shots_left = if stack.item == ItemId::ROCKET_LAUNCHER { + 1 + } else { + self.random(u32::from(kind.mag_size).max(1)) as u8 + }; + gun.write(&mut stack.data); + } + } + + /// Removes an item (`DeleteSelectedItem`) + pub fn delete_item(&mut self, id: WorldItemId) { + if let Some(index) = item_index(self.map, id) { + self.modify_items().remove(index); + } + } + + /// Removes the items on a level shown on the tiles of the brush, only recorded if there are + /// any + pub fn erase_items(&mut self, area: &BrushArea, level: Level) { + let pools = ItemPools::new(self.map, self.world, level); + let erased: Vec = (0..self.map.items.len()) + .filter(|&index| pools.tile(index).is_some_and(|t| area.tiles.contains(&t))) + .map(|index| self.map.items[index].id) + .collect(); + if erased.is_empty() { + return; + } + self.modify_items() + .retain(|item| !erased.contains(&item.id)); + } + + /// Removes the items shown on a tile, only recorded if there are any (`DeleteSelectedItem`) + pub fn delete_pool(&mut self, pools: &ItemPools, grid_no: GridNo) { + let deleted: Vec = pools + .pool(grid_no) + .into_iter() + .filter_map(|index| self.map.items.get(index).map(|item| item.id)) + .collect(); + if deleted.is_empty() { + return; + } + self.modify_items() + .retain(|item| !deleted.contains(&item.id)); + } + + /// Moves the items on a level shown on a tile to another tile, or copies them there with new + /// ids, they keep their level. Returns the tile the first of them is shown on afterwards, + /// `None` if there is nothing to move. + /// + /// Nothing is moved if an item would sink in water, the tile would get a second trigger or + /// the items are on the roof and the tile has no flat roof. + pub fn move_items( + &mut self, + items: &ItemTable, + level: Level, + from: GridNo, + to: GridNo, + copy: bool, + ) -> Result, String> { + let pool = ItemPools::new(self.map, self.world, level).pool(from); + if from == to || pool.is_empty() { + return Ok(None); + } + if !has_level(self.world, to, level) { + return Err(NO_FLAT_ROOF.to_owned()); + } + let water = is_water(self.world, self.map.tileset, to); + let switch = |item: &MapItem| item.level == level && item.stack.item == ItemId::SWITCH; + let mut switches = 0; + for (index, item) in self.map.items.iter().enumerate() { + let moved = pool.contains(&index); + if moved && water && items.info(item.stack.item).is_some_and(|kind| kind.sinks) { + return Err("The items would sink in the water".to_owned()); + } + if switch(item) && (moved || item.grid_no == to) { + switches += 1; + } + } + if switches > 1 { + return Err("There already is a trigger on this tile".to_owned()); + } + let mut moved = Vec::with_capacity(pool.len()); + for &index in &pool { + let mut item = self.map.items[index].clone(); + item.grid_no = to; + // The tile and the height of the map file were where the item was + item.file_grid_no = None; + if let ItemHeight::Above(_) = item.render_height { + item.render_height = ItemHeight::Above(0); + } + if copy { + item.id = self.document.new_item_id(); + } + moved.push((index, item)); + } + let shown = match &moved[0].1 { + item if item.graphic.is_some() => item_placement(self.world, item).grid_no, + item => item.grid_no, + }; + let map_items = self.modify_items(); + for (index, item) in moved { + if copy { + map_items.push(item); + } else { + map_items[index] = item; + } + } + Ok(Some(shown)) + } + + /// Changes an item, it is recorded for undo if the change changes it + pub fn change_world_item( + &mut self, + id: WorldItemId, + change: impl FnOnce(&mut MapItem), + ) -> bool { + let Some(index) = item_index(self.map, id) else { + return false; + }; + let mut changed = self.map.items[index].clone(); + change(&mut changed); + if changed == self.map.items[index] { + return false; + } + self.modify_items()[index] = changed; + true + } + + /// Sets the values the host edited, clamped like the original does + /// (`ExtractAndUpdate...GUI` of `Item_Statistics.cc`), ignored if they change nothing + pub fn change_item( + &mut self, + items: &ItemTable, + id: WorldItemId, + mut stack: ItemStack, + visibility: Visibility, + flags: WorldItemFlags, + non_exist_chance: u8, + ) -> bool { + let Some(current) = world_item(self.map, id) else { + return false; + }; + // The item itself can't be changed + stack.item = current.stack.item; + let kind = items.info(stack.item).cloned().unwrap_or_default(); + if stack.number != current.stack.number { + stack.number = stack.number.clamp(1, kind.per_pocket.max(1)); + } + stack.trap = stack.trap.clamp(0, MAX_TRAP_LEVEL); + stack.attachments.truncate(4); + for (_, status) in &mut stack.attachments { + *status = (*status).clamp(1, 100); + } + let clamp_status = |data: &mut [u8; 12]| data[0] = (data[0] as i8).clamp(0, 100) as u8; + let mut non_exist_chance = non_exist_chance.min(99); + if stack.item == ItemId::OWNERSHIP { + let mut owner = OwnerData::read(&stack.data); + owner.civ_group = owner.civ_group.min(NUM_CIV_GROUPS - 1); + owner.write(&mut stack.data); + non_exist_chance = 0; + } else if stack.item == ItemId::SWITCH { + let mut bomb = BombData::read(&stack.data); + bomb.tolerance = bomb.tolerance.min(MAX_TOLERANCE); + bomb.write(&mut stack.data); + } else if stack.item == ItemId::ACTION_ITEM { + clamp_status(&mut stack.data); + } else { + match kind.class { + IC_MONEY => { + let mut money = MoneyData::read(&stack.data); + money.amount = money.amount.clamp(1, MAX_MONEY); + money.status = 100; + money.write(&mut stack.data); + } + IC_GUN => { + clamp_status(&mut stack.data); + let mut gun = GunData::read(&stack.data); + if kind.mag_size > 0 { + gun.shots_left = gun.shots_left.min(kind.mag_size); + } + gun.write(&mut stack.data); + } + IC_AMMO => {} + _ => clamp_status(&mut stack.data), + } + } + self.change_world_item(id, |item| { + item.stack = stack; + item.visibility = visibility; + item.flags = flags; + item.non_exist_chance = non_exist_chance; + }) + } +} + +/// A drag of the selected tile in progress +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ItemDrag { + /// Where the mouse was pressed + from: GridNo, + /// Whether Ctrl or Cmd was held on the press, which copies the items instead of moving them + copy: bool, + /// The level of the items + level: Level, +} + +/// The selected tile, whose items the host edits, and what the host is told after a failed edit +#[derive(Resource, Clone, Debug, Default)] +pub struct ItemSelection { + /// The tile the selected items are shown on + pub selected: Option, + pub notice: Option, + drag: Option, +} + +impl ItemSelection { + /// Drops the selection when the tile has no items any more, e.g. after undo + pub fn validate(&mut self, pools: &ItemPools) { + if self + .selected + .is_some_and(|tile| pools.pool(tile).is_empty()) + { + self.clear(); + } + } + + /// Drops the selection and the drag + pub fn clear(&mut self) { + self.selected = None; + self.drag = None; + } + + /// Whether the selected items are being dragged + pub fn dragging(&self) -> bool { + self.drag.is_some() + } + + /// Selects a tile with items and starts dragging them, or clears the selection if the tile + /// has none + pub fn press(&mut self, pools: &ItemPools, tile: GridNo, copy: bool) { + if pools.pool(tile).is_empty() { + self.clear(); + return; + } + self.selected = Some(tile); + self.drag = Some(ItemDrag { + from: tile, + copy, + level: pools.level(), + }); + } + + /// Ends the drag at a tile, moving or copying the items there and selecting the tile they + /// are shown on + pub fn release(&mut self, context: &mut EditContext, items: &ItemTable, to: GridNo) { + let Some(drag) = self.drag.take() else { + return; + }; + match context.move_items(items, drag.level, drag.from, to, drag.copy) { + Ok(Some(shown)) => self.selected = Some(shown), + Ok(None) => {} + Err(notice) => self.notice = Some(notice), + } + } + + /// Removes all items of the selected tile (`DeleteSelectedItem`) + pub fn delete(&mut self, context: &mut EditContext, pools: &ItemPools) { + if let Some(selected) = self.selected { + context.delete_pool(pools, selected); + } + self.clear(); + } +} + +pub fn reset_item_selection(mut selection: ResMut) { + *selection = ItemSelection::default(); +} + +/// The items mode, `None` for the others +pub fn item_mode(mode: &CurrentMode) -> Option<&ItemMode> { + match &mode.0 { + EditorMode::Items(mode) => Some(mode), + _ => None, + } +} + +/// Centers the view on a tile +pub fn center_view(view: Option<&mut PixelView>, map: &TacticalMap, grid_no: GridNo) { + if let Some(view) = view { + view.position = tile_center(map, grid_no); + } +} + +/// The mouse and the keys of the items mode, erasing is done by `paint` with the brush +/// +/// The selection is checked by `report_item`, once the world caught up with the edits. +pub fn edit_items( + mouse: Res>, + keys: Res>, + cursor: Res, + mode: Res, + options: Res, + items: Option>, + mut selection: ResMut, + mut edit: MapEdit, +) { + let Some(mode) = item_mode(&mode) else { + // Leaving the items mode deselects the tile + if selection.selected.is_some() || selection.drag.is_some() { + selection.clear(); + } + return; + }; + let Some(items) = items else { + return; + }; + let selection = &mut *selection; + let floor = options.floor; + let tile = cursor.tile.map(GridNo::from); + if mode.tool != ItemTool::Select { + // Only the select tool has a selection + if selection.selected.is_some() || selection.drag.is_some() { + selection.clear(); + } + let (Some(tile), false) = (tile, mode.erase) else { + return; + }; + if mouse.just_pressed(MouseButton::Left) { + let mut result = Ok(()); + edit_once(&mut edit, |context| { + result = context.place_item(&items, mode, tile, floor).map(|_| ()) + }); + if let Err(notice) = result { + selection.notice = Some(notice); + } + } + return; + } + if let Some(tile) = tile.filter(|_| mouse.just_pressed(MouseButton::Left)) { + // Cmd on macOS, where Ctrl+click is a right click + let copy = keys.any_pressed([ + KeyCode::ControlLeft, + KeyCode::ControlRight, + KeyCode::SuperLeft, + KeyCode::SuperRight, + ]); + selection.press(&ItemPools::new(&edit.map, &edit.world, floor), tile, copy); + } + if selection.drag.is_some() { + if cursor.world_position.is_none() { + // Leaving the viewer cancels the drag, like the area brushes + selection.drag = None; + } else if !mouse.pressed(MouseButton::Left) { + match tile { + Some(to) => edit_once(&mut edit, |context| selection.release(context, &items, to)), + None => selection.drag = None, + } + } + } + if selection.drag.is_none() && keys.any_just_pressed([KeyCode::Delete, KeyCode::Backspace]) { + let pools = ItemPools::new(&edit.map, &edit.world, floor); + edit_once(&mut edit, |context| selection.delete(context, &pools)); + } + if keys.just_pressed(KeyCode::Escape) { + selection.clear(); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::Arc; + + use super::*; + use crate::{ + editor::document::tests::{grid, Fixture}, + file_formats::ja2_tile_types::FIRSTTEXTURE, + item::ItemFile, + map::item_data::BOMB_REMOTE, + tileset::tests::structure_tileset, + }; + + /// A map to place items on, with a few items of every kind + pub(crate) struct ItemFixture { + pub fixture: Fixture, + pub items: ItemTable, + } + + pub(crate) fn id(index: u16) -> ItemId { + ItemId::new(index).unwrap() + } + + impl ItemFixture { + pub(crate) fn new() -> Self { + let weapons = br#"[ + { "itemIndex": 2, "internalType": "M_PISTOL", "calibre": "AMMO9", "ubMagSize": 15, + "ubPerPocket": 1, "bSinks": true, "tileGraphic": { "type": "GUNS", "subIndex": 3 } }, + { "itemIndex": 51, "internalType": "LAW", "ubMagSize": 1 }, + { "itemIndex": 43, "internalType": "BLADE", "bNotEditor": true } + ]"#; + let magazines = br#"[ + { "itemIndex": 71, "calibre": "AMMO9", "capacity": 15, "ammoType": "AMMO_HP", + "ubPerPocket": 8 } + ]"#; + let explosives = br#"[ + { "itemIndex": 135, "itemClass": "GRENADE", "ubPerPocket": 4 }, + { "itemIndex": 137, "itemClass": "BOMB", "ubPerPocket": 4 } + ]"#; + let items = br#"[ + { "itemIndex": 202, "usItemClass": 4096, "bDefaultUndroppable": true }, + { "itemIndex": 219, "usItemClass": 536870912, "ubPerPocket": 1 }, + { "itemIndex": 233, "usItemClass": 268435456, "ubPerPocket": 99 }, + { "itemIndex": 234, "usItemClass": 268435456, "ubPerPocket": 99 }, + { "itemIndex": 240, "usItemClass": 268435456 }, + { "itemIndex": 271, "usItemClass": 65536, "ubClassIndex": 0, "ubPerPocket": 8 }, + { "itemIndex": 300, "usItemClass": 1 } + ]"#; + let items = ItemTable::new(&[ + ItemFile::parse(weapons, "weapons.json").unwrap(), + ItemFile::parse(magazines, "magazines.json").unwrap(), + ItemFile::parse(explosives, "explosives.json").unwrap(), + ItemFile::parse(items, "items.json").unwrap(), + ]); + Self { + fixture: Fixture::with_tileset(structure_tileset()), + items, + } + } + + /// Edits the map as one undo command, the world is synced with the items afterwards + pub(crate) fn edit( + &mut self, + edit: impl FnOnce(&mut EditContext, &ItemTable) -> R, + ) -> R { + self.fixture.document.undo.begin(); + let mut context = self.fixture.context(); + let result = edit(&mut context, &self.items); + context.finish(); + self.fixture.document.undo.end(); + self.sync(); + result + } + + pub(crate) fn sync(&mut self) { + if std::mem::take(&mut self.fixture.document.items_dirty) { + self.fixture.world.sync_items(&self.fixture.map); + } + } + + pub(crate) fn place( + &mut self, + mode: &ItemMode, + grid_no: GridNo, + ) -> Result { + self.place_on(mode, grid_no, Level::Ground) + } + + pub(crate) fn place_on( + &mut self, + mode: &ItemMode, + grid_no: GridNo, + level: Level, + ) -> Result { + let id = self.edit(|context, items| context.place_item(items, mode, grid_no, level))?; + Ok(world_item(&self.fixture.map, id).unwrap().clone()) + } + + /// The pools of the ground + pub(crate) fn pools(&self) -> ItemPools { + self.pools_on(Level::Ground) + } + + pub(crate) fn pools_on(&self, level: Level) -> ItemPools { + ItemPools::new(&self.fixture.map, &self.fixture.world, level) + } + + pub(crate) fn undo(&mut self) { + let command = self.fixture.document.undo.pop().unwrap(); + let mut context = self.fixture.context(); + context.restore(command); + context.finish(); + self.sync(); + } + } + + pub(crate) fn mode(json: &str) -> ItemMode { + match serde_json::from_str(json).unwrap() { + EditorMode::Items(mode) => mode, + _ => panic!("not the items mode"), + } + } + + fn choose(choice: ItemChoice) -> ItemMode { + ItemMode { + choice: Some(choice), + ..default() + } + } + + fn item(item: u16) -> ItemMode { + choose(ItemChoice::Item { item }) + } + + #[test] + fn modes_are_read_from_json() { + let read = mode(r#"{"mode":"items","choice":{"kind":"key","lockId":4,"item":271}}"#); + assert_eq!( + read.choice, + Some(ItemChoice::Key { + lock_id: 4, + item: 271 + }) + ); + // Missing values are the defaults of the original + assert_eq!((read.trap_level, read.action), (9, 12)); + let read = mode(r#"{"mode":"items","choice":{"kind":"trigger","slot":26}}"#); + assert_eq!(read.choice, Some(ItemChoice::Trigger { slot: 26 })); + assert_eq!(mode(r#"{"mode":"items"}"#), ItemMode::default()); + + let erasing = mode( + r#"{"mode":"items","tool":"place","erase":true, + "brush":{"kind":"circle","size":5}}"#, + ); + assert_eq!(erasing.brush(), Some(Brush::Circle { size: 5 })); + let defaults = mode(r#"{"mode":"items"}"#); + assert_eq!( + (defaults.tool, defaults.erase, defaults.brush), + (ItemTool::Select, false, Brush::default()) + ); + // Only erasing uses the brush + assert_eq!(defaults.brush(), None); + let select = mode(r#"{"mode":"items","tool":"select","erase":true}"#); + assert_eq!(select.brush(), None); + let place = mode(r#"{"mode":"items","tool":"place"}"#); + assert_eq!(place.brush(), None); + let fill = mode(r#"{"mode":"items","tool":"place","erase":true,"brush":{"kind":"fill"}}"#); + assert_eq!(fill.brush(), Some(Brush::default())); + } + + #[test] + fn new_items_get_the_defaults_of_the_original() { + let mut fixture = ItemFixture::new(); + let gun = fixture.place(&item(2), grid(50, 50)).unwrap(); + assert_eq!(gun.visibility, Visibility::Invisible); + // New items always exist + assert_eq!(gun.non_exist_chance, 0); + assert_eq!(gun.level, Level::Ground); + assert!(gun.graphic.is_some()); + assert_eq!(gun.stack.imprint_id, NO_PROFILE); + let data = GunData::read(&gun.stack.data); + assert!((70..=95).contains(&data.status)); + assert!(data.shots_left < 15); + // Loaded with the default magazine + assert_eq!( + (data.ammo_item, data.ammo_type, data.ammo_status), + (71, 1, 100) + ); + let law = fixture.place(&item(51), grid(51, 50)).unwrap(); + assert_eq!(GunData::read(&law.stack.data).shots_left, 1); + + let ammo = fixture.place(&item(71), grid(52, 50)).unwrap(); + assert!(ammo.stack.data[0] <= 15); + assert_eq!(ammo.stack.data[1..], [0; 11]); + + // The status doesn't overwrite the money, the owner, the bombs and the keys + let money = fixture.place(&item(219), grid(53, 50)).unwrap(); + let amount = MoneyData::read(&money.stack.data); + assert_eq!(amount.status, 100); + assert!((100..=20000).contains(&amount.amount)); + let owner = fixture.place(&item(240), grid(54, 50)).unwrap(); + assert_eq!(owner.visibility, Visibility::Buried); + assert_eq!(owner.non_exist_chance, 0); + assert_eq!(OwnerData::read(&owner.stack.data).profile, NO_PROFILE); + let key = fixture + .place( + &choose(ItemChoice::Key { + lock_id: 7, + item: 271, + }), + grid(55, 50), + ) + .unwrap(); + let key_data = KeyData::read(&key.stack.data); + assert_eq!(key_data.key_id, 7); + assert!((70..=95).contains(&key_data.status)); + let kit = fixture.place(&item(202), grid(56, 50)).unwrap(); + assert_eq!(kit.stack.flags, OBJECT_UNDROPPABLE); + + // Triggers and actions are buried, armed bombs of the map + let trigger = fixture + .place(&choose(ItemChoice::Trigger { slot: 2 }), grid(57, 50)) + .unwrap(); + assert_eq!(trigger.stack.item, ItemId::SWITCH); + assert_eq!(trigger.visibility, Visibility::Buried); + assert_eq!(trigger.flags, WorldItemFlags::ARMED_BOMB); + let bomb = BombData::read(&trigger.stack.data); + assert_eq!((bomb.status, bomb.owner, bomb.frequency), (100, 1, 126)); + let mut action_mode = choose(ItemChoice::Trigger { slot: 9 }); + action_mode.trap_level = 4; + let action = fixture.place(&action_mode, grid(58, 50)).unwrap(); + assert_eq!(action.stack.item, ItemId::ACTION_ITEM); + assert_eq!(action.stack.trap, 4); + assert_eq!(action.stack.flags, OBJECT_ARMED_BOMB); + let bomb = BombData::read(&action.stack.data); + assert_eq!(bomb.detonator, BOMB_REMOTE); + assert_eq!(bomb.frequency, 52); + // A medium explosion by default + assert_eq!( + (bomb.bomb_item, bomb.action_value), + (137, ACTION_ITEM_BLOW_UP) + ); + let mut pressure_mode = choose(ItemChoice::Trigger { slot: 26 }); + pressure_mode.action = 9; + let pressure = fixture.place(&pressure_mode, grid(59, 50)).unwrap(); + let bomb = BombData::read(&pressure.stack.data); + assert_eq!((bomb.detonator, bomb.bomb_item), (BOMB_PRESSURE, 0)); + assert_eq!(pit_size(&pressure.stack), Some(3)); + assert_eq!(fixture.fixture.document.undo.depth(), 10); + assert_eq!(fixture.fixture.map.items.len(), 10); + } + + #[test] + fn some_items_can_not_be_placed() { + let mut fixture = ItemFixture::new(); + let g = grid(50, 50); + assert!(fixture.place(&ItemMode::default(), g).is_err()); + // Items of no class, items not for the editor and unknown items + for index in [300, 43, 999, 233] { + assert!(fixture.place(&item(index), g).is_err(), "{}", index); + } + // One trigger per tile, actions can share it + let trigger = choose(ItemChoice::Trigger { slot: 0 }); + fixture.place(&trigger, g).unwrap(); + assert!(fixture.place(&trigger, g).is_err()); + fixture + .place(&choose(ItemChoice::Trigger { slot: 1 }), g) + .unwrap(); + + // Items that sink are not placed in water + let water = grid(60, 60); + let mut tile = (**fixture + .fixture + .tileset + .tile(fixture.fixture.tileset.index(FIRSTTEXTURE, 1).unwrap()) + .unwrap()) + .clone(); + tile.terrain = Terrain::LowWater; + fixture.fixture.map.tiles[water].land = vec![Arc::new(tile)]; + fixture.fixture.world = MapWorld::build(&fixture.fixture.map); + assert!(fixture.place(&item(2), water).unwrap_err().contains("sink")); + fixture.place(&item(71), water).unwrap(); + assert_eq!(fixture.fixture.map.items.len(), 3); + } + + #[test] + fn pressing_selects_the_tile() { + let mut fixture = ItemFixture::new(); + let (a, b) = (grid(50, 50), grid(40, 60)); + for g in [a, b, a, a] { + fixture.place(&item(71), g).unwrap(); + } + let pools = fixture.pools(); + assert_eq!(pools.pool(a), [0, 2, 3]); + assert_eq!(pools.pool_tiles(), [a, b]); + + let mut selection = ItemSelection::default(); + selection.press(&pools, a, false); + assert_eq!(selection.selected, Some(a)); + assert!(selection.dragging()); + // Releasing where it was pressed changes nothing + let depth = fixture.fixture.document.undo.depth(); + release(&mut fixture, &mut selection, a); + assert_eq!(selection.selected, Some(a)); + assert!(!selection.dragging()); + assert_eq!(fixture.fixture.document.undo.depth(), depth); + selection.press(&pools, b, false); + assert_eq!(selection.selected, Some(b)); + // Tiles without items deselect + selection.press(&pools, grid(10, 10), false); + assert_eq!(selection.selected, None); + assert!(!selection.dragging()); + } + + #[test] + fn items_on_structures_are_in_the_pool_of_the_structure() { + let mut fixture = ItemFixture::new(); + let g = grid(50, 50); + // A 2×2 obstacle, its base tile is the one south east + let obstacle = fixture + .fixture + .context() + .tile("OSTRUCT1", 3) + .expect("the 2x2 obstacle"); + let base = grid(51, 51); + fixture.edit(|context, _| context.add_struct_to_tail(base, obstacle)); + let placed = fixture.place(&item(71), g).unwrap(); + let pools = fixture.pools(); + let shown = pools.tile(0).unwrap(); + assert_eq!(placed.grid_no, g); + let mut selection = ItemSelection::default(); + selection.press(&pools, shown, false); + assert_eq!(selection.selected, Some(shown)); + + // Moving items onto the structure selects the tile they are shown on + let other = fixture.place(&item(2), grid(60, 60)).unwrap(); + selection.press(&fixture.pools(), grid(60, 60), false); + release(&mut fixture, &mut selection, g); + assert_eq!( + world_item(&fixture.fixture.map, other.id).unwrap().grid_no, + g + ); + assert_eq!(selection.selected, Some(shown)); + selection.validate(&fixture.pools()); + assert_eq!(selection.selected, Some(shown)); + } + + /// Releases the drag of the selection as one undo command + fn release(fixture: &mut ItemFixture, selection: &mut ItemSelection, to: GridNo) { + fixture.edit(|context, items| selection.release(context, items, to)); + } + + fn ids(fixture: &ItemFixture) -> Vec { + fixture.fixture.map.items.iter().map(|i| i.id).collect() + } + + #[test] + fn dragging_moves_or_copies_the_items_of_the_tile() { + let mut fixture = ItemFixture::new(); + let (a, b, c) = (grid(50, 50), grid(55, 50), grid(60, 50)); + let gun = fixture.place(&item(2), a).unwrap(); + fixture.place(&item(71), a).unwrap(); + // Items of the map file keep the tile and the height of the file until they are moved + fixture.fixture.map.items[1].file_grid_no = Some(-1); + fixture.fixture.map.items[1].render_height = ItemHeight::Above(12); + let roof = fixture.fixture.map.items[1].clone(); + let staying = fixture.place(&item(71), b).unwrap(); + let mut selection = ItemSelection::default(); + + selection.press(&fixture.pools(), a, false); + release(&mut fixture, &mut selection, b); + assert_eq!(selection.selected, Some(b)); + assert_eq!(ids(&fixture), [gun.id, roof.id, staying.id]); + let moved = &fixture.fixture.map.items; + assert_eq!((moved[0].grid_no, moved[1].grid_no), (b, b)); + assert_eq!(moved[1].file_grid_no, None); + assert_eq!(moved[1].render_height, ItemHeight::Above(0)); + assert_eq!(moved[0].stack, gun.stack); + assert_eq!(fixture.pools().pool(b), [0, 1, 2]); + + // Copying copies the whole pool with new ids + selection.press(&fixture.pools(), b, true); + release(&mut fixture, &mut selection, c); + assert_eq!(selection.selected, Some(c)); + assert_eq!(fixture.fixture.map.items.len(), 6); + assert_eq!(fixture.pools().pool(c), [3, 4, 5]); + assert_eq!(fixture.pools().pool(b), [0, 1, 2]); + let copies = &fixture.fixture.map.items[3..]; + assert!(copies + .iter() + .all(|copy| ![gun.id, roof.id, staying.id].contains(©.id))); + assert_eq!(copies[0].stack, gun.stack); + + // Each drag is undone as a whole + fixture.undo(); + assert_eq!(ids(&fixture), [gun.id, roof.id, staying.id]); + selection.validate(&fixture.pools()); + assert_eq!(selection.selected, None); + fixture.undo(); + assert_eq!(fixture.fixture.map.items[0], gun); + assert_eq!(fixture.fixture.map.items[1], roof); + } + + #[test] + fn items_are_edited_on_their_floor() { + let mut fixture = ItemFixture::new(); + let (a, b, c) = (grid(50, 50), grid(55, 50), grid(60, 50)); + let tileset = &fixture.fixture.tileset; + let roof = tileset + .tile( + tileset + .tile_of("FIRSTROOF", 1) + .or_else(|| tileset.tile_of("ROOF1", 1)) + .unwrap(), + ) + .unwrap() + .clone(); + assert!(roof.is_flat_roof()); + fixture.edit(|context, _| { + context.add_roof_to_head(a, roof.clone()); + context.add_roof_to_head(b, roof); + }); + let ground = fixture.place(&item(71), a).unwrap(); + let above = fixture.place_on(&item(71), a, Level::Roof).unwrap(); + assert_eq!(above.level, Level::Roof); + // Not on the roof without a flat roof + assert_eq!( + fixture.place_on(&item(71), c, Level::Roof), + Err(NO_FLAT_ROOF.to_owned()) + ); + // A trigger per tile and level + let trigger = choose(ItemChoice::Trigger { slot: 2 }); + fixture.place(&trigger, a).unwrap(); + fixture.place_on(&trigger, a, Level::Roof).unwrap(); + assert!(fixture.place_on(&trigger, a, Level::Roof).is_err()); + + // Each floor only has its own pools + assert_eq!(fixture.pools().pool(a), [0, 2]); + assert_eq!(fixture.pools_on(Level::Roof).pool(a), [1, 3]); + assert_eq!(fixture.pools_on(Level::Roof).tile(0), None); + let mut selection = ItemSelection::default(); + selection.press(&fixture.pools_on(Level::Roof), a, false); + assert_eq!(selection.selected, Some(a)); + + // Roof items only move onto flat roofs + release(&mut fixture, &mut selection, c); + assert_eq!(selection.notice.take(), Some(NO_FLAT_ROOF.to_owned())); + selection.press(&fixture.pools_on(Level::Roof), a, false); + release(&mut fixture, &mut selection, b); + assert_eq!(fixture.pools_on(Level::Roof).pool(b), [1, 3]); + assert_eq!(fixture.fixture.map.items[1].level, Level::Roof); + assert_eq!(fixture.pools().pool(a), [0, 2]); + + // Erasing only removes the items of the floor + let erase = mode(r#"{"mode":"items","tool":"place","erase":true}"#); + let area = erase.brush().unwrap().region(None, b.position()); + fixture.edit(|context, _| erase.apply(context, &area, Level::Ground)); + assert_eq!(fixture.fixture.map.items.len(), 4); + fixture.edit(|context, _| erase.apply(context, &area, Level::Roof)); + let left = &fixture.fixture.map.items; + assert_eq!(left.len(), 2); + assert_eq!(left[0].id, ground.id); + assert!(left.iter().all(|item| item.level == Level::Ground)); + } + + #[test] + fn items_are_not_moved_into_water_or_onto_a_second_trigger() { + let mut fixture = ItemFixture::new(); + let (a, b, c) = (grid(50, 50), grid(55, 50), grid(60, 50)); + let water = grid(60, 60); + let mut tile = (**fixture + .fixture + .tileset + .tile(fixture.fixture.tileset.index(FIRSTTEXTURE, 1).unwrap()) + .unwrap()) + .clone(); + tile.terrain = Terrain::LowWater; + fixture.fixture.map.tiles[water].land = vec![Arc::new(tile)]; + fixture.fixture.world = MapWorld::build(&fixture.fixture.map); + + let trigger = choose(ItemChoice::Trigger { slot: 0 }); + fixture.place(&trigger, a).unwrap(); + fixture.place(&item(2), a).unwrap(); + fixture.place(&trigger, b).unwrap(); + fixture.place(&item(71), c).unwrap(); + let before = fixture.fixture.map.items.clone(); + let depth = fixture.fixture.document.undo.depth(); + + let mut selection = ItemSelection::default(); + // The gun sinks + selection.press(&fixture.pools(), a, false); + release(&mut fixture, &mut selection, water); + assert!(selection.notice.take().unwrap().contains("sink")); + assert_eq!(selection.selected, Some(a)); + // Both tiles have a trigger, also when copying + for copy in [false, true] { + selection.press(&fixture.pools(), a, copy); + release(&mut fixture, &mut selection, b); + assert!(selection.notice.take().unwrap().contains("trigger")); + } + assert_eq!(fixture.fixture.map.items, before); + assert_eq!(fixture.fixture.document.undo.depth(), depth); + + // Magazines don't sink, triggers can go where none is + selection.press(&fixture.pools(), c, false); + release(&mut fixture, &mut selection, water); + assert_eq!(selection.selected, Some(water)); + selection.press(&fixture.pools(), b, false); + release(&mut fixture, &mut selection, c); + assert_eq!(selection.notice, None); + assert_eq!(fixture.fixture.map.items[2].grid_no, c); + } + + #[test] + fn deleting_removes_the_items_of_the_tile_and_can_be_undone() { + let mut fixture = ItemFixture::new(); + let (a, b) = (grid(50, 50), grid(40, 60)); + for g in [a, b, a] { + fixture.place(&item(71), g).unwrap(); + } + let placed = ids(&fixture); + let mut selection = ItemSelection::default(); + selection.press(&fixture.pools(), a, false); + release(&mut fixture, &mut selection, a); + let pools = fixture.pools(); + fixture.edit(|context, _| selection.delete(context, &pools)); + assert_eq!(selection.selected, None); + assert_eq!(ids(&fixture), [placed[1]]); + // Nothing selected deletes nothing + let depth = fixture.fixture.document.undo.depth(); + let pools = fixture.pools(); + fixture.edit(|context, _| selection.delete(context, &pools)); + assert_eq!(fixture.fixture.document.undo.depth(), depth); + + fixture.undo(); + assert_eq!(ids(&fixture), placed); + assert_eq!(fixture.pools().pool(a), [0, 2]); + } + + #[test] + fn erasing_removes_the_items_of_the_brush() { + let mut fixture = ItemFixture::new(); + fixture.place(&item(71), grid(50, 50)).unwrap(); + fixture.place(&item(2), grid(51, 49)).unwrap(); + let outside = fixture.place(&item(71), grid(60, 50)).unwrap(); + let erase = ItemMode { + tool: ItemTool::Place, + erase: true, + brush: Brush::Square { size: 3 }, + ..default() + }; + let area = erase.brush().unwrap().region(None, grid(50, 50).position()); + fixture.edit(|context, _| erase.apply(context, &area, Level::Ground)); + assert_eq!(ids(&fixture), [outside.id]); + // Nothing to erase is not recorded + let depth = fixture.fixture.document.undo.depth(); + fixture.edit(|context, _| erase.apply(context, &area, Level::Ground)); + assert_eq!(fixture.fixture.document.undo.depth(), depth); + + fixture.undo(); + assert_eq!(fixture.fixture.map.items.len(), 3); + } + + #[test] + fn edits_are_clamped_like_the_original() { + let mut fixture = ItemFixture::new(); + let gun = fixture.place(&item(2), grid(50, 50)).unwrap(); + let mut stack = gun.stack.clone(); + stack.item = id(71); + stack.trap = 40; + stack.number = 5; + let mut data = GunData::read(&stack.data); + data.status = 120; + data.shots_left = 40; + data.write(&mut stack.data); + let changed = fixture.edit(|context, items| { + context.change_item( + items, + gun.id, + stack.clone(), + Visibility::HiddenItem, + WorldItemFlags::REALISTIC_ONLY, + 150, + ) + }); + assert!(changed); + let edited = world_item(&fixture.fixture.map, gun.id).unwrap(); + // The item stays a gun + assert_eq!(edited.stack.item, id(2)); + assert_eq!(edited.stack.trap, 20); + assert_eq!(edited.stack.number, 1); + let data = GunData::read(&edited.stack.data); + assert_eq!((data.status, data.shots_left), (100, 15)); + assert_eq!(edited.visibility, Visibility::HiddenItem); + assert_eq!(edited.flags, WorldItemFlags::REALISTIC_ONLY); + assert_eq!(edited.non_exist_chance, 99); + let depth = fixture.fixture.document.undo.depth(); + + // The same values change nothing + let same = edited.clone(); + let changed = fixture.edit(|context, items| { + context.change_item( + items, + same.id, + same.stack.clone(), + same.visibility, + same.flags, + same.non_exist_chance, + ) + }); + assert!(!changed); + assert_eq!(fixture.fixture.document.undo.depth(), depth); + + let money = fixture.place(&item(219), grid(51, 50)).unwrap(); + let mut stack = money.stack.clone(); + MoneyData { + status: 3, + amount: 50000, + } + .write(&mut stack.data); + fixture.edit(|context, items| { + context.change_item(items, money.id, stack, money.visibility, money.flags, 0) + }); + let edited = world_item(&fixture.fixture.map, money.id).unwrap(); + assert_eq!( + MoneyData::read(&edited.stack.data), + MoneyData { + status: 100, + amount: MAX_MONEY + } + ); + + let trigger = fixture + .place(&choose(ItemChoice::Trigger { slot: 4 }), grid(52, 50)) + .unwrap(); + let mut stack = trigger.stack.clone(); + stack.data[7] = 200; + fixture.edit(|context, items| { + context.change_item( + items, + trigger.id, + stack, + trigger.visibility, + trigger.flags, + 0, + ) + }); + let edited = world_item(&fixture.fixture.map, trigger.id).unwrap(); + assert_eq!(BombData::read(&edited.stack.data).tolerance, 99); + } + + #[test] + fn trigger_slots_have_the_frequencies_of_the_original() { + let frequencies: Vec = (0..PRESSURE_ACTION_SLOT).map(trigger_frequency).collect(); + assert_eq!(frequencies[..8], [127, 127, 126, 126, 125, 125, 51, 51]); + assert_eq!(frequencies[25], 60); + let mut data = [0; 12]; + change_action_item(&mut data, 33); + assert_eq!(BombData::read(&data).bomb_item, 157); + change_action_item(&mut data, 7); + let bomb = BombData::read(&data); + assert_eq!((bomb.bomb_item, bomb.action_value), (0, 1)); + } +} diff --git a/src-map-editor/src/editor/layout.rs b/src-map-editor/src/editor/layout.rs new file mode 100644 index 0000000..dfb7c88 --- /dev/null +++ b/src-map-editor/src/editor/layout.rs @@ -0,0 +1,247 @@ +//! Copying and moving buildings (`CopyBuilding`, `PasteBuilding` and `MoveBuilding` of +//! `EditorBuildings.cc`) +//! +//! The layout of a building is the tiles with its floors and roofs, the tiles east and south of +//! them, which hold the walls on those sides, and the tiles north and west of them with walls +//! towards the building. Pasting replaces the tiles at the target by the tiles of the layout. + +use std::collections::HashSet; + +use crate::{ + components::map::GridPosition, + map::{GridNo, MapTile, TacticalMap}, + tileset::Tile, +}; + +use super::document::{wall_node, EditContext}; + +/// Whether a tile has a floor or a flat or slant roof (`BuildingAtGridNo`) +pub fn building_at(tile: &MapTile) -> bool { + tile.land.iter().any(|t| t.is_floor()) + || tile + .roofs + .iter() + .any(|t| t.building.is_some_and(|b| b.piece.is_roof())) +} + +/// The tiles of the building at a tile, `None` if there is none (`CopyBuilding`) +pub fn building_layout(map: &TacticalMap, anchor: GridNo) -> Option> { + if !building_at(&map.tiles[anchor]) { + return None; + } + let vertical = |grid_no| { + wall_node(&map.tiles[grid_no], |t: &Tile| { + t.wall_orientation().is_vertical() + }) + }; + let horizontal = |grid_no| { + wall_node(&map.tiles[grid_no], |t: &Tile| { + t.wall_orientation().is_horizontal() + }) + }; + let mut layout = vec![anchor]; + let mut visited = HashSet::from([anchor]); + let mut stack = vec![anchor]; + // `BuildLayout` + while let Some(from) = stack.pop() { + for (dx, dy) in [(0, -1), (-1, 0), (1, 0), (0, 1)] { + let Some(to) = from.neighbour(dx, dy) else { + continue; + }; + if visited.contains(&to) { + continue; + } + // Outside of the building, only the tiles with its walls belong to it + let belongs = building_at(&map.tiles[to]) + || match (dx, dy) { + (1, 0) | (0, 1) => building_at(&map.tiles[from]), + (-1, 0) => vertical(to).is_some(), + _ => horizontal(to).is_some(), + }; + if belongs { + visited.insert(to); + layout.push(to); + stack.push(to); + } + } + } + Some(layout) +} + +/// The tile `dx` columns and `dy` rows from a tile +fn shifted(grid_no: GridNo, (dx, dy): (i32, i32)) -> Option { + let position = grid_no.position(); + GridPosition::from_xy(i32::from(position.x) + dx, i32::from(position.y) + dy).map(GridNo::from) +} + +/// The columns and rows from one tile to another +pub fn drag_offset(from: GridNo, to: GridNo) -> (i32, i32) { + let (from, to) = (from.position(), to.position()); + ( + i32::from(to.x) - i32::from(from.x), + i32::from(to.y) - i32::from(from.y), + ) +} + +/// The tiles of a layout moved by an offset, those that would leave the map are left out +pub fn shifted_layout(layout: &[GridNo], offset: (i32, i32)) -> Vec { + layout + .iter() + .filter_map(|&grid_no| shifted(grid_no, offset)) + .collect() +} + +/// Removes everything from a tile, the land becomes the background (`DeleteStuffFromMapTile`) +/// +/// The original leaves the tile without any land. +fn clear_tile(context: &mut EditContext, grid_no: GridNo) { + let tile = context.modify(grid_no); + tile.land.clear(); + tile.objects.clear(); + tile.structs.clear(); + tile.shadows.clear(); + tile.roofs.clear(); + tile.on_roofs.clear(); + tile.room = None; + tile.cave = false; + if let Some(background) = context.background_tile() { + context.add_land_to_head(grid_no, background); + } +} + +/// Copies the layers of a tile onto another (`PasteMapElementToNewMapElement`), without its +/// room number +fn copy_tile(context: &mut EditContext, from: GridNo, to: GridNo) { + let source = context.map.tiles[from].clone(); + let target = context.modify(to); + target.land = source.land; + target.objects = source.objects; + target.structs = source.structs; + target.shadows = source.shadows; + target.roofs = source.roofs; + target.on_roofs = source.on_roofs; + target.cave = source.cave; + target.room = None; +} + +/// Copies the building at `anchor` to `target`, and removes it from where it was if `moving` +/// (`PasteBuilding` and `MoveBuilding`) +/// +/// The tiles are processed in the order that never overwrites a tile before it is copied. +/// Tiles that would leave the map are left out. +pub fn paste_building(context: &mut EditContext, anchor: GridNo, target: GridNo, moving: bool) { + let Some(mut layout) = building_layout(context.map, anchor) else { + return; + }; + let offset = drag_offset(anchor, target); + if offset == (0, 0) { + return; + } + // `SortBuildingLayout`: towards smaller grid numbers, the smallest tiles go first + let towards_start = offset.1 * 160 + offset.0 < 0; + layout.sort_by_key(|grid_no| grid_no.index()); + if !towards_start { + layout.reverse(); + } + for from in layout { + let Some(to) = shifted(from, offset) else { + continue; + }; + copy_tile(context, from, to); + if moving { + clear_tile(context, from); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + editor::{ + building::tests::{add_room, building_fixture, rect}, + document::tests::{grid, Fixture}, + }, + map::RoomNo, + tileset::TileIndex, + }; + + /// The ids of the layers of a tile, without the land below the floors + fn layers(fixture: &Fixture, grid_no: GridNo) -> Vec> { + let tile = &fixture.map.tiles[grid_no]; + let ids = |tiles: &[std::sync::Arc]| tiles.iter().map(|t| t.id).collect(); + let floors: Vec<_> = tile.land.iter().filter(|t| t.is_floor()).cloned().collect(); + vec![ + ids(&floors), + ids(&tile.structs), + ids(&tile.shadows), + ids(&tile.roofs), + ] + } + + #[test] + fn layouts_have_the_walls_around_the_floors() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + assert!(building_layout(&fixture.map, grid(60, 60)).is_none()); + let mut layout = building_layout(&fixture.map, grid(51, 51)).unwrap(); + layout.sort(); + // The floors, the tiles east and south of them, and those north and west with walls + assert_eq!(layout.len(), 9 + 3 + 3 + 3 + 3); + assert!(layout.contains(&grid(53, 50))); + assert!(layout.contains(&grid(50, 53))); + assert!(layout.contains(&grid(51, 49))); + assert!(layout.contains(&grid(49, 52))); + assert!(!layout.contains(&grid(53, 53))); + assert!(!layout.contains(&grid(49, 49))); + } + + #[test] + fn copies_keep_the_original() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let mut context = fixture.context(); + context.set_room(grid(51, 51), RoomNo::new(4)); + context.finish(); + let layout = building_layout(&fixture.map, grid(51, 51)).unwrap(); + let before: Vec<_> = layout.iter().map(|&g| layers(&fixture, g)).collect(); + + let mut context = fixture.context(); + paste_building(&mut context, grid(51, 51), grid(61, 51), false); + context.finish(); + for (&grid_no, before) in layout.iter().zip(&before) { + assert_eq!(&layers(&fixture, grid_no), before); + let target = grid_no.neighbour(10, 0).unwrap(); + assert_eq!(&layers(&fixture, target), before); + } + // Room numbers are not copied + assert_eq!(fixture.map.tiles[grid(61, 51)].room, None); + assert_eq!(fixture.map.tiles[grid(51, 51)].room, RoomNo::new(4)); + assert_eq!(fixture.world.tiles[grid(61, 49)].structures.len(), 1); + } + + #[test] + fn moves_overlapping_the_building_leave_the_ground() { + for (dx, dy) in [(2, 1), (-1, -2)] { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let layout = building_layout(&fixture.map, grid(51, 51)).unwrap(); + let before: Vec<_> = layout.iter().map(|&g| layers(&fixture, g)).collect(); + let target = grid(51, 51).neighbour(dx, dy).unwrap(); + let mut context = fixture.context(); + paste_building(&mut context, grid(51, 51), target, true); + context.finish(); + for (&grid_no, before) in layout.iter().zip(&before) { + let moved = grid_no.neighbour(dx, dy).unwrap(); + assert_eq!(&layers(&fixture, moved), before, "{dx} {dy}"); + } + // Where the building was, there is only ground + let shifted = shifted_layout(&layout, (dx, dy)); + for &grid_no in layout.iter().filter(|g| !shifted.contains(g)) { + assert!(layers(&fixture, grid_no).iter().all(Vec::is_empty)); + assert_eq!(fixture.land(grid_no).len(), 1); + } + assert_eq!(fixture.document.undo.depth(), 1); + } + } +} diff --git a/src-map-editor/src/editor/light_events.rs b/src-map-editor/src/editor/light_events.rs new file mode 100644 index 0000000..40f6a0b --- /dev/null +++ b/src-map-editor/src/editor/light_events.rs @@ -0,0 +1,517 @@ +//! What the host is told about the lights and how it edits them, and how the scene catches up +//! with them +//! +//! Whenever the selected light, the number of lights, the light colour or the ambient light +//! change, the host gets a `light` event. The host sends the radius and the time of the selected +//! light with `set_light` and everything else with `light_command`. Failed edits are reported +//! with a `notice`. + +use std::collections::BTreeSet; + +use bevy::{ecs::system::SystemParam, prelude::*}; +use serde::{Deserialize, Serialize}; + +use crate::{ + host::report_editor_event, + light::LightTemplates, + map::{AmbientLight, GridNo, LightId, LightTime, MapLight, TacticalMap}, + render::{ + lighting::{light_tiles, scene_lights, PreviewLight}, + PendingSceneUpdate, + }, + states::loading::MapToLoad, + systems::bake::MapBake, +}; + +use super::{ + document::{EditContext, EditorDocument}, + lights::{editor_light_level, map_light, LightSelection}, + soldier_events::NoticeEvent, + soldiers::edit_once, + MapEdit, +}; + +/// A light as the host shows it +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LightValue { + pub id: u32, + pub grid_no: u16, + pub x: u8, + pub y: u8, + /// The radius of the editor's templates, `None` for other templates + pub radius: Option, + /// File name of the template + pub template: String, + pub time: LightTime, +} + +impl LightValue { + pub fn of(light: &MapLight) -> Self { + Self { + id: light.id.0, + grid_no: GridNo::from(light.position).index() as u16, + x: light.position.x, + y: light.position.y, + radius: light.radius(), + template: light.template_name.clone(), + time: light.time, + } + } +} + +/// The ambient light of basements and caves as the editor shows it +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct AmbientValue { + pub basement: bool, + pub caves: bool, + /// The light level of the original editor, 1 to 15, higher is brighter + pub level: u8, +} + +impl AmbientValue { + pub fn of(ambient: &AmbientLight) -> Self { + Self { + basement: ambient.basement, + caves: ambient.caves, + level: editor_light_level(ambient.level), + } + } +} + +/// The selected light, `null` if none is, and the lighting of the map +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename = "light", rename_all = "camelCase")] +pub struct LightEvent { + pub map: String, + pub light: Option, + /// The number of lights of the map + pub count: usize, + /// The light colour as red, green and blue + pub color: [u8; 3], + /// `None` for maps that are neither basements nor caves + pub ambient: Option, + /// The radii whose templates are loaded, only these can be placed + pub radii: Vec, +} + +/// The values of the selected light sent with `set_light` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LightEdit { + pub id: u32, + /// `None` keeps the template + pub radius: Option, + pub time: LightTime, +} + +/// What the host asks of the lights besides editing the selected one, the selected light is +/// removed with the Delete key +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum LightCommand { + Deselect, + /// Sets the light colour and the light level of basements and caves (1 to 15), `None` + /// keeps the level + Settings { + color: [u8; 3], + ambient_level: Option, + }, +} + +/// What light commands need besides the map +#[derive(SystemParam)] +pub struct LightCommandParams<'w> { + pub selection: ResMut<'w, LightSelection>, + pub templates: Option>, +} + +/// Edits the selected light with the values of the host, ignored if the light is gone +pub fn update_light( + context: &mut EditContext, + templates: &LightTemplates, + edit: &LightEdit, +) -> Result<(), String> { + context + .change_light(templates, LightId(edit.id), edit.radius, edit.time) + .map(|_| ()) +} + +/// Runs a light command of the host +pub fn run_light_command( + command: LightCommand, + edit: &mut MapEdit, + params: &mut LightCommandParams, +) { + let selection = &mut *params.selection; + match command { + LightCommand::Deselect => selection.clear(), + LightCommand::Settings { + color, + ambient_level, + } => edit_once(edit, |context| { + context.change_light_settings(color, ambient_level); + }), + } +} + +/// The light event that was reported last, `None` for a new map +#[derive(Resource, Clone, Debug, Default)] +pub struct ReportedLight(Option); + +pub fn reset_reported_light(mut reported: ResMut) { + reported.0 = None; +} + +/// Tells the host about the selected light and the lighting when they change, and about failed +/// edits +pub fn report_light( + map_to_load: Res, + map: Res, + templates: Option>, + mut selection: ResMut, + mut reported: ResMut, +) { + if let Some(message) = selection.notice.take() { + report_editor_event(&NoticeEvent { + map: map_to_load.0.clone(), + message, + }); + } + if !map.is_changed() && !selection.is_changed() && reported.0.is_some() { + return; + } + let event = LightEvent { + map: map_to_load.0.clone(), + light: selection + .selected + .and_then(|id| map_light(&map, id)) + .map(LightValue::of), + count: map.lights.len(), + color: map.light_color, + ambient: map.ambient_light.as_ref().map(AmbientValue::of), + radii: templates.map(|t| t.radii()).unwrap_or_default(), + }; + if reported.0.as_ref() != Some(&event) { + report_editor_event(&event); + reported.0 = Some(event); + } +} + +/// The lights, the light colour and the ambient light that the scene was last synced with +#[derive(Resource, Clone, Debug, Default)] +pub struct SyncedLights { + /// The scene lights, with the preview light (`scene_lights`) + lights: Vec, + color: [u8; 3], + ambient: Option, + /// The preview light changed since the last sync + preview_dirty: bool, +} + +impl SyncedLights { + fn of(map: &TacticalMap, preview: &PreviewLight) -> Self { + Self { + lights: scene_lights(map, preview.0.as_ref()).cloned().collect(), + color: map.light_color, + ambient: map.ambient_light, + preview_dirty: false, + } + } + + /// Syncs with the lights of the map and the preview light, queueing what the scene has to + /// update + fn sync( + &mut self, + map: &TacticalMap, + preview: &PreviewLight, + pending: &mut PendingSceneUpdate, + ) { + let new = Self::of(map, preview); + if self.color != new.color || self.ambient != new.ambient { + pending.full = true; + } else { + pending + .tiles + .extend(changed_light_tiles(&self.lights, &new.lights)); + } + *self = new; + } +} + +pub fn reset_synced_lights( + map: Res, + mut preview: ResMut, + mut synced: ResMut, +) { + *preview = PreviewLight::default(); + *synced = SyncedLights::of(&map, &preview); +} + +/// Whether two lights light the same tiles in the same way, whatever their ids +fn same_lighting(a: &MapLight, b: &MapLight) -> bool { + a.position == b.position && a.time == b.time && a.template_name == b.template_name +} + +/// The tiles to relight after the lights changed from `old` to `new`: the tiles of the lights +/// that were removed, added or changed +pub fn changed_light_tiles(old: &[MapLight], new: &[MapLight]) -> BTreeSet { + let missing = |light: &MapLight, lights: &[MapLight]| { + !lights.iter().any(|other| same_lighting(light, other)) + }; + old.iter() + .filter(|light| missing(light, new)) + .chain(new.iter().filter(|light| missing(light, old))) + .flat_map(light_tiles) + .collect() +} + +/// Brings the scene up to date after the lights or the preview light changed: the tiles of the +/// changed lights are lit again, a new light colour or ambient light rebuilds everything, +/// because the shaded palettes and the base light of every tile depend on them +/// +/// A change of only the preview waits until the scene is idle, so that moving the cursor +/// quickly only lights the latest position. +pub fn sync_light_scene( + mut document: ResMut, + map: Res, + preview: Res, + bake: Res, + mut synced: ResMut, + mut pending: ResMut, +) { + if preview.is_changed() { + synced.preview_dirty = true; + } + let preview_due = synced.preview_dirty && pending.is_empty() && bake.is_idle(); + if !document.lights_dirty && !preview_due { + return; + } + document.lights_dirty = false; + synced.sync(&map, &preview, &mut pending); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::{ + document::tests::grid, + lights::{ + new_light, + tests::{mode, LightFixture}, + LightMode, + }, + }; + + #[test] + fn commands_are_read_from_json() { + let parse = |json: &str| serde_json::from_str::(json).unwrap(); + assert_eq!(parse(r#"{"kind":"deselect"}"#), LightCommand::Deselect); + assert_eq!( + parse(r#"{"kind":"settings","color":[1,2,3],"ambientLevel":7}"#), + LightCommand::Settings { + color: [1, 2, 3], + ambient_level: Some(7) + } + ); + assert_eq!( + parse(r#"{"kind":"settings","color":[1,2,3],"ambientLevel":null}"#), + LightCommand::Settings { + color: [1, 2, 3], + ambient_level: None + } + ); + } + + #[test] + fn the_host_edits_the_selected_light() { + let mut fixture = LightFixture::new(); + let id = fixture + .place(&mode(2, LightTime::PrimeTime), grid(30, 31)) + .unwrap(); + let value = LightValue::of(map_light(fixture.map(), id).unwrap()); + let json = serde_json::to_value(&value).unwrap(); + assert_eq!(json["gridNo"], 31 * 160 + 30); + assert_eq!( + (json["x"].clone(), json["y"].clone()), + (30.into(), 31.into()) + ); + assert_eq!(json["radius"], 2); + assert_eq!(json["template"], "L-R02.LHT"); + assert_eq!(json["time"], "primeTime"); + + let edit: LightEdit = + serde_json::from_str(&format!(r#"{{"id":{},"radius":3,"time":"night"}}"#, id.0)) + .unwrap(); + fixture + .edit(|context, templates| update_light(context, templates, &edit)) + .unwrap(); + let light = map_light(fixture.map(), id).unwrap(); + assert_eq!((light.radius(), light.time), (Some(3), LightTime::Night)); + // Missing templates fail, other lights are ignored + let missing = LightEdit { + radius: Some(8), + ..edit + }; + assert!(fixture + .edit(|context, templates| update_light(context, templates, &missing)) + .is_err()); + let other = LightEdit { id: 99, ..edit }; + fixture + .edit(|context, templates| update_light(context, templates, &other)) + .unwrap(); + assert_eq!(fixture.fixture.document.undo.depth(), 2); + } + + #[test] + fn events_have_the_lighting_of_the_map() { + let event = LightEvent { + map: "maps/a9_b1.dat".to_owned(), + light: None, + count: 3, + color: [10, 20, 30], + ambient: Some(AmbientValue::of(&AmbientLight { + basement: true, + caves: false, + level: 12, + })), + radii: vec![1, 2, 3], + }; + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "light"); + assert_eq!(json["light"], serde_json::Value::Null); + assert_eq!(json["color"], serde_json::json!([10, 20, 30])); + assert_eq!( + json["ambient"], + serde_json::json!({ "basement": true, "caves": false, "level": 4 }) + ); + assert_eq!(json["radii"], serde_json::json!([1, 2, 3])); + } + + #[test] + fn changed_lights_relight_their_tiles() { + let mut fixture = LightFixture::new(); + let a = fixture + .place(&mode(1, LightTime::Always), grid(30, 30)) + .unwrap(); + fixture + .place(&mode(1, LightTime::Always), grid(60, 60)) + .unwrap(); + let old = fixture.map().lights.clone(); + assert!(changed_light_tiles(&old, &old).is_empty()); + + fixture + .edit(|context, templates| context.change_light(templates, a, None, LightTime::Night)) + .unwrap(); + let tiles = changed_light_tiles(&old, &fixture.map().lights); + // The template reaches 2 tiles further than its single node + assert_eq!(tiles.len(), 25); + assert!(tiles.contains(&grid(30, 30)) && tiles.contains(&grid(32, 28))); + assert!(!tiles.contains(&grid(60, 60))); + } + + /// The tiles that syncing with the map and the preview queues + fn sync( + synced: &mut SyncedLights, + fixture: &LightFixture, + preview: &PreviewLight, + ) -> BTreeSet { + let mut pending = PendingSceneUpdate::default(); + synced.sync(fixture.map(), preview, &mut pending); + assert!(!pending.full); + pending.tiles + } + + /// The light that placing with the mode would add next + fn preview(fixture: &LightFixture, mode: &LightMode, grid_no: GridNo) -> PreviewLight { + PreviewLight(Some( + new_light( + &fixture.templates, + mode, + grid_no, + fixture.fixture.document.next_light_id(), + ) + .unwrap(), + )) + } + + #[test] + fn the_preview_light_relights_its_tiles() { + let mut fixture = LightFixture::new(); + let always = mode(1, LightTime::Always); + let other = fixture.place(&always, grid(60, 60)).unwrap(); + let none = PreviewLight::default(); + let mut synced = SyncedLights::of(fixture.map(), &none); + + let at_a = preview(&fixture, &always, grid(30, 30)); + let tiles_a = light_tiles(at_a.0.as_ref().unwrap()); + assert_eq!( + sync(&mut synced, &fixture, &at_a), + BTreeSet::from_iter(tiles_a.clone()) + ); + // Moving it relights the old and the new tiles + let at_b = preview(&fixture, &always, grid(40, 30)); + let tiles_b = light_tiles(at_b.0.as_ref().unwrap()); + let moved = sync(&mut synced, &fixture, &at_b); + assert_eq!(moved, tiles_a.iter().chain(&tiles_b).copied().collect()); + // Clearing it relights the old tiles + assert_eq!( + sync(&mut synced, &fixture, &none), + BTreeSet::from_iter(tiles_b) + ); + + // A preview on a light hides it, other changes relight the tiles of both + let on_other = preview(&fixture, &mode(2, LightTime::Always), grid(60, 60)); + let tiles = sync(&mut synced, &fixture, &on_other); + assert_eq!(tiles.len(), 25); + assert!(tiles.contains(&grid(60, 60))); + assert!(map_light(fixture.map(), other).is_some()); + + // Placing the previewed light changes nothing, nor does the preview of the next light + let at_c = preview(&fixture, &always, grid(30, 30)); + sync(&mut synced, &fixture, &at_c); + fixture.place(&always, grid(30, 30)).unwrap(); + assert!(sync(&mut synced, &fixture, &at_c).is_empty()); + let next = preview(&fixture, &always, grid(30, 30)); + assert_ne!(next, at_c); + assert!(sync(&mut synced, &fixture, &next).is_empty()); + } + + #[test] + fn preview_changes_wait_for_the_scene() { + use crate::tileset::TilesetId; + use bevy::ecs::system::RunSystemOnce; + + let fixture = LightFixture::new(); + let light = preview(&fixture, &mode(1, LightTime::Always), grid(30, 30)); + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(TacticalMap::empty(TilesetId::new(0))); + world.insert_resource(PendingSceneUpdate { + full: false, + tiles: BTreeSet::from([grid(1, 1)]), + }); + world.run_system_once(reset_synced_lights).unwrap(); + let system = world.register_system(sync_light_scene); + world.run_system(system).unwrap(); + + // The pending update of other edits comes first + *world.resource_mut::() = light.clone(); + world.run_system(system).unwrap(); + assert_eq!(world.resource::().tiles.len(), 1); + world.resource_mut::().tiles.clear(); + world.run_system(system).unwrap(); + let tiles = &world.resource::().tiles; + assert_eq!( + *tiles, + BTreeSet::from_iter(light_tiles(light.0.as_ref().unwrap())) + ); + } +} diff --git a/src-map-editor/src/editor/light_overlay.rs b/src-map-editor/src/editor/light_overlay.rs new file mode 100644 index 0000000..6bfe3a8 --- /dev/null +++ b/src-map-editor/src/editor/light_overlay.rs @@ -0,0 +1,118 @@ +//! What the lights mode draws over the map: a ring on every light (`GOODRING1` of the original +//! editor) in the colour of its time like the labels of the original (prime time orange, night +//! red, always yellow), the selected light and the tile under the cursor, which depends on the +//! tool +//! +//! The rings show all lights, the lighting of the scene only the ones that are on at the time of +//! day of the render options. + +use bevy::prelude::*; + +use crate::{ + light::LightTemplates, + map::{light_template_name, GridNo, Level, LightTime, TacticalMap}, + render::RenderOptions, + systems::cursor::CursorInfo, +}; + +use super::{ + commands::CurrentMode, + input::INACTIVE_CURSOR, + item_overlay::ring, + lights::{light_mode, map_light, LightSelection, LightTool}, + soldier_events::tile_center, + soldier_overlay::{cursor_outline, tile_outline}, +}; + +/// The colour of the ring of a light +pub fn time_color(time: LightTime) -> Srgba { + match time { + LightTime::PrimeTime => Srgba::rgb(1., 0.55, 0.), + LightTime::Night => Srgba::rgb(1., 0.15, 0.15), + LightTime::Always => Srgba::rgb(1., 0.95, 0.2), + } +} + +/// A ring around the middle of a tile, twice as large as the ring of the lights +pub fn outer_ring(map: &TacticalMap, grid_no: GridNo) -> Vec<(Vec2, Vec2)> { + let center = tile_center(map, grid_no); + ring(map, grid_no) + .into_iter() + .map(|(from, to)| (center + (from - center) * 2., center + (to - center) * 2.)) + .collect() +} + +/// Draws the rings of the lights, the selected light in white and the tile under the cursor: +/// for placing green where a click places a light, red where the template of the radius is +/// missing, for selecting green where the selected light is dragged to and faintly a tile with +/// lights that a click selects. On the roof floor, where lights aren't edited, the cursor is +/// grey. The brush that erases is drawn by `draw_brush_cursor`. +pub fn draw_light_markers( + mut gizmos: Gizmos, + cursor: Res, + mode: Res, + options: Res, + selection: Res, + map: Res, + templates: Option>, +) { + let Some(mode) = light_mode(&mode) else { + return; + }; + let mut lines = |lines: Vec<(Vec2, Vec2)>, color: Srgba| { + for (from, to) in lines { + gizmos.line_2d(from, to, color); + } + }; + for light in &map.lights { + lines( + ring(&map, GridNo::from(light.position)), + time_color(light.time), + ); + } + let selected = selection + .selected + .and_then(|id| map_light(&map, id)) + .map(|light| GridNo::from(light.position)); + if let Some(grid_no) = selected { + lines(outer_ring(&map, grid_no), Srgba::WHITE); + lines(tile_outline(&map, grid_no, Level::Ground), Srgba::WHITE); + } + let Some(tile) = cursor.tile.map(GridNo::from) else { + return; + }; + if options.floor == Level::Roof { + if mode.tool == LightTool::Select || !mode.erase { + lines(cursor_outline(&map, tile, Level::Roof), INACTIVE_CURSOR); + } + return; + } + match mode.tool { + LightTool::Place if mode.erase => {} + LightTool::Place => { + let template = light_template_name(mode.radius); + let can_place = templates.is_some_and(|templates| templates.get(&template).is_some()); + let color = if can_place { Srgba::GREEN } else { Srgba::RED }; + lines(tile_outline(&map, tile, Level::Ground), color); + if can_place { + lines(ring(&map, tile), time_color(mode.time)); + } + } + LightTool::Select if selection.dragging() => { + if selected.is_some_and(|selected| selected != tile) { + lines(outer_ring(&map, tile), Srgba::GREEN); + lines(tile_outline(&map, tile, Level::Ground), Srgba::GREEN); + } + } + LightTool::Select => { + let position = tile.position(); + let has_lights = map.lights.iter().any(|light| light.position == position); + if has_lights && selected != Some(tile) { + lines( + tile_outline(&map, tile, Level::Ground), + Srgba::new(1., 1., 1., 0.35), + ); + } + } + } +} diff --git a/src-map-editor/src/editor/lights.rs b/src-map-editor/src/editor/lights.rs new file mode 100644 index 0000000..deaee4a --- /dev/null +++ b/src-map-editor/src/editor/lights.rs @@ -0,0 +1,878 @@ +//! Light sprites: placing, erasing, selecting, moving and deleting them with the lights mode, +//! and the light colour and the ambient light of the map (the Map Info tab of the original +//! editor, `PlaceLight` and `RemoveLight` of `EditScreen.cc`, `EditorMapInfo.cc`) +//! +//! The `place` tool places a light with the radius and the time of the mode with a left click, it +//! replaces the lights of the tile. With `erase` its brush removes the lights of the tiles +//! instead. The `select` tool selects the light of the clicked tile, clicking it again selects +//! the next light of the tile. Dragging the selected light moves it, dragging it while Ctrl (Cmd +//! on macOS) was held on the press copies it, both replace the lights of the target tile. Delete +//! removes the selected light and Esc deselects it. Every change can be undone. The host edits +//! the selected light with `set_light` and the map lighting with `light_command`, see +//! `light_events.rs`. +//! +//! The light colour is the first colour of the lights of the map file, which the game adds to +//! the palettes. The ambient light is only saved for basements and caves, the editor shows it as +//! the "light level" (`EDITOR_LIGHT_MAX` minus the ambient level, so higher is brighter). + +use std::sync::Arc; + +use bevy::prelude::*; +use serde::Deserialize; + +use crate::{ + light::{LightTemplate, LightTemplates, LIGHT_RADII}, + map::{light_template_name, GridNo, Level, LightId, LightTime, MapLight, TacticalMap}, + render::{lighting::PreviewLight, RenderOptions}, + systems::cursor::CursorInfo, +}; + +use super::{ + brush::{Brush, BrushArea}, + commands::CurrentMode, + document::{EditContext, EditorDocument}, + mode::EditorMode, + soldiers::edit_once, + MapEdit, +}; + +/// The light level of the original editor is this minus the ambient level (`EDITOR_LIGHT_MAX`) +pub const EDITOR_LIGHT_MAX: u8 = 16; +/// The light levels the editor accepts +pub const LIGHT_LEVELS: std::ops::RangeInclusive = 1..=15; +/// The ambient level the original editor gives maps above ground, they keep it when they become +/// basements or caves (light level 12) +pub const SURFACE_AMBIENT_LEVEL: u8 = 4; + +/// What a click in the lights mode does +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LightTool { + /// Selects, moves, copies and deletes lights + #[default] + Select, + /// Places lights, or erases them with the brush + Place, +} + +/// The lights mode (the lights of `TASK_MAPINFO` of the original editor) +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LightMode { + pub tool: LightTool, + /// Removes the lights of the brush instead of placing them + pub erase: bool, + /// The brush that erases lights + pub brush: Brush, + /// Radius of new lights, the template `L-R0N.LHT` (`gsLightRadius`) + pub radius: u8, + /// When new lights are on (`gbDefaultLightType`) + pub time: LightTime, +} + +impl Default for LightMode { + fn default() -> Self { + Self { + tool: LightTool::Select, + erase: false, + brush: Brush::default(), + radius: 5, + time: LightTime::PrimeTime, + } + } +} + +impl LightMode { + /// The brush that erases lights, `None` while placing or selecting, which use clicks + pub fn brush(&self) -> Option { + match (self.tool, self.erase, self.brush) { + (LightTool::Place, true, Brush::Fill) => Some(Brush::default()), + (LightTool::Place, true, brush) => Some(brush), + _ => None, + } + } + + /// Erases the lights of the tiles of the brush + pub fn apply(&self, context: &mut EditContext, area: &BrushArea) { + if self.brush().is_some() { + context.erase_lights(area); + } + } +} + +/// The light level of the editor of an ambient level +pub fn editor_light_level(ambient: u8) -> u8 { + EDITOR_LIGHT_MAX - ambient.clamp(*LIGHT_LEVELS.start(), *LIGHT_LEVELS.end()) +} + +/// The index of a light in `TacticalMap::lights` +pub fn light_index(map: &TacticalMap, id: LightId) -> Option { + map.lights.iter().position(|light| light.id == id) +} + +/// The light with an id +pub fn map_light(map: &TacticalMap, id: LightId) -> Option<&MapLight> { + map.lights.iter().find(|light| light.id == id) +} + +/// The lights of a tile, in the order of the map +fn lights_on(map: &TacticalMap, grid_no: GridNo) -> Vec { + let position = grid_no.position(); + map.lights + .iter() + .filter(|light| light.position == position) + .map(|light| light.id) + .collect() +} + +/// The name and the template of a radius, an error if it is out of range or not loaded +fn radius_template( + templates: &LightTemplates, + radius: u8, +) -> Result<(String, Arc), String> { + if !LIGHT_RADII.contains(&radius) { + return Err(format!( + "Lights have a radius of {} to {}", + LIGHT_RADII.start(), + LIGHT_RADII.end() + )); + } + let name = light_template_name(radius); + let template = templates + .get(&name) + .ok_or_else(|| format!("The light template {} doesn't exist", name))?; + Ok((name, template.clone())) +} + +/// A new light with the radius and the time of the mode +pub fn new_light( + templates: &LightTemplates, + mode: &LightMode, + grid_no: GridNo, + id: LightId, +) -> Result { + let (template_name, template) = radius_template(templates, mode.radius)?; + Ok(MapLight { + id, + position: grid_no.position(), + time: mode.time, + template_name, + template, + file_sprite: None, + }) +} + +impl EditContext<'_> { + /// Places a light with the radius and the time of the mode, replacing the lights of the + /// tile, returns the new light (`RemoveLight`, `PlaceLight`) + /// + /// Unlike the original, a missing template is not created. + pub fn place_light( + &mut self, + templates: &LightTemplates, + mode: &LightMode, + grid_no: GridNo, + ) -> Result { + let light = new_light(templates, mode, grid_no, self.document.next_light_id())?; + // The id is only taken once the light can be placed + let (id, position) = (self.document.new_light_id(), light.position); + let lights = self.modify_lights(); + lights.retain(|light| light.position != position); + lights.push(light); + Ok(id) + } + + /// Removes the lights of the tiles of the brush (`RemoveLight`), only recorded if there are + /// any + pub fn erase_lights(&mut self, area: &BrushArea) { + let on_area = |light: &MapLight| area.tiles.contains(&GridNo::from(light.position)); + if !self.map.lights.iter().any(on_area) { + return; + } + self.modify_lights().retain(|light| !on_area(light)); + } + + /// Moves a light to a tile, or copies it there with a new id, replacing the lights of the + /// tile, returns the light on the tile, `None` if the light is gone or already there + pub fn move_light(&mut self, id: LightId, to: GridNo, copy: bool) -> Option { + let index = light_index(self.map, id)?; + let position = to.position(); + if self.map.lights[index].position == position { + return None; + } + let mut light = self.map.lights[index].clone(); + light.position = position; + if copy { + light.id = self.document.new_light_id(); + } + let moved = light.id; + let lights = self.modify_lights(); + lights.retain(|light| light.position != position); + match lights.iter_mut().find(|light| light.id == moved) { + Some(existing) => *existing = light, + None => lights.push(light), + } + Some(moved) + } + + /// Removes a light (`RemoveLight`) + pub fn delete_light(&mut self, id: LightId) { + if let Some(index) = light_index(self.map, id) { + self.modify_lights().remove(index); + } + } + + /// Changes the radius and the time of a light, it is recorded for undo if this changes it + /// + /// A radius of `None` keeps the template, e.g. of lights that are not of the editor. + pub fn change_light( + &mut self, + templates: &LightTemplates, + id: LightId, + radius: Option, + time: LightTime, + ) -> Result { + let Some(index) = light_index(self.map, id) else { + return Ok(false); + }; + let mut changed = self.map.lights[index].clone(); + changed.time = time; + if let Some(radius) = radius.filter(|&radius| Some(radius) != changed.radius()) { + let (name, template) = radius_template(templates, radius)?; + changed.template_name = name; + changed.template = template; + } + if changed == self.map.lights[index] { + return Ok(false); + } + self.modify_lights()[index] = changed; + Ok(true) + } + + /// Sets the light colour and the light level of basements and caves (1 to 15, higher is + /// brighter), only what changes is recorded (`ExtractAndUpdateMapInfo`) + /// + /// Maps without an ambient light ignore the level, like the original only saves it for + /// basements and caves. + pub fn change_light_settings(&mut self, color: [u8; 3], light_level: Option) -> bool { + let ambient = self.map.ambient_light.map(|mut ambient| { + if let Some(level) = light_level { + let level = level.clamp(*LIGHT_LEVELS.start(), *LIGHT_LEVELS.end()); + ambient.level = EDITOR_LIGHT_MAX - level; + } + ambient + }); + if color == self.map.light_color && ambient == self.map.ambient_light { + return false; + } + let (light_color, ambient_light) = self.modify_light_settings(); + *light_color = color; + *ambient_light = ambient; + true + } +} + +/// A drag of the selected light in progress +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct LightDrag { + /// Where the mouse was pressed + from: GridNo, + /// Whether Ctrl or Cmd was held on the press, which copies the light instead of moving it + copy: bool, + /// Whether the light was selected before the press, releasing on the same tile then selects + /// the next light of the tile + was_selected: bool, +} + +/// The selected light and what the host is told after a failed edit +#[derive(Resource, Clone, Debug, Default)] +pub struct LightSelection { + pub selected: Option, + pub notice: Option, + drag: Option, +} + +impl LightSelection { + /// Drops the selection when the light is gone, e.g. after undo + pub fn validate(&mut self, map: &TacticalMap) { + if self.selected.is_some_and(|id| map_light(map, id).is_none()) { + self.selected = None; + self.drag = None; + } + } + + /// Drops the selection and the drag + pub fn clear(&mut self) { + self.selected = None; + self.drag = None; + } + + /// Whether the selected light is being dragged + pub fn dragging(&self) -> bool { + self.drag.is_some() + } + + /// Selects the light of a tile, or keeps the selected one if it is there, and starts + /// dragging it, or clears the selection if the tile has no lights + pub fn press(&mut self, map: &TacticalMap, tile: GridNo, copy: bool) { + let on_tile = lights_on(map, tile); + let was_selected = self.selected.is_some_and(|id| on_tile.contains(&id)); + if !was_selected { + let Some(&first) = on_tile.first() else { + self.clear(); + return; + }; + self.selected = Some(first); + } + self.drag = Some(LightDrag { + from: tile, + copy, + was_selected, + }); + } + + /// Ends the drag at a tile, moving or copying the light there and selecting it. Releasing + /// where the selected light already was selects the next light of the tile. + pub fn release(&mut self, context: &mut EditContext, to: GridNo) { + let (Some(drag), Some(selected)) = (self.drag.take(), self.selected) else { + return; + }; + if to != drag.from { + if let Some(id) = context.move_light(selected, to, drag.copy) { + self.selected = Some(id); + } + return; + } + if drag.was_selected { + let on_tile = lights_on(context.map, to); + if let Some(position) = on_tile.iter().position(|&id| id == selected) { + self.selected = Some(on_tile[(position + 1) % on_tile.len()]); + } + } + } + + /// Removes the selected light + pub fn delete(&mut self, context: &mut EditContext) { + if let Some(selected) = self.selected.take() { + context.delete_light(selected); + } + self.drag = None; + } +} + +pub fn reset_light_selection(mut selection: ResMut) { + *selection = LightSelection::default(); +} + +/// The lights mode, `None` for the others +pub fn light_mode(mode: &CurrentMode) -> Option<&LightMode> { + match &mode.0 { + EditorMode::Lights(mode) => Some(mode), + _ => None, + } +} + +/// The mouse and the keys of the lights mode, erasing is done by `paint` with the brush +pub fn edit_lights( + mouse: Res>, + keys: Res>, + cursor: Res, + mode: Res, + options: Res, + templates: Option>, + mut selection: ResMut, + mut edit: MapEdit, +) { + // Lights are only edited on the ground floor + let mode = light_mode(&mode).filter(|_| options.floor == Level::Ground); + let Some(mode) = mode else { + // Leaving the lights mode or the ground deselects the light + if selection.selected.is_some() || selection.drag.is_some() { + selection.clear(); + } + return; + }; + let selection = &mut *selection; + let tile = cursor.tile.map(GridNo::from); + if mode.tool != LightTool::Select { + // Only the select tool has a selection + if selection.selected.is_some() || selection.drag.is_some() { + selection.clear(); + } + let (Some(tile), Some(templates), false) = (tile, templates, mode.erase) else { + return; + }; + if mouse.just_pressed(MouseButton::Left) { + let mut result = Ok(()); + edit_once(&mut edit, |context| { + result = context.place_light(&templates, mode, tile).map(|_| ()) + }); + if let Err(notice) = result { + selection.notice = Some(notice); + } + } + return; + } + if let Some(tile) = tile.filter(|_| mouse.just_pressed(MouseButton::Left)) { + // Cmd on macOS, where Ctrl+click is a right click + let copy = keys.any_pressed([ + KeyCode::ControlLeft, + KeyCode::ControlRight, + KeyCode::SuperLeft, + KeyCode::SuperRight, + ]); + selection.press(&edit.map, tile, copy); + } + if selection.drag.is_some() { + if cursor.world_position.is_none() { + // Leaving the viewer cancels the drag, like the area brushes + selection.drag = None; + } else if !mouse.pressed(MouseButton::Left) { + match tile { + Some(to) => edit_once(&mut edit, |context| selection.release(context, to)), + None => selection.drag = None, + } + } + } + if selection.drag.is_none() && keys.any_just_pressed([KeyCode::Delete, KeyCode::Backspace]) { + edit_once(&mut edit, |context| selection.delete(context)); + } + if keys.just_pressed(KeyCode::Escape) { + selection.clear(); + } + selection.validate(&edit.map); +} + +/// The light that a click would place, `None` if a click places none +fn preview_light( + mode: &CurrentMode, + floor: Level, + tile: Option, + templates: Option<&LightTemplates>, + id: LightId, +) -> Option { + let mode = light_mode(mode) + .filter(|mode| mode.tool == LightTool::Place && !mode.erase && floor == Level::Ground)?; + new_light(templates?, mode, tile?, id).ok() +} + +/// Lights the scene with the light that a click of the place tool would place, see +/// `sync_light_scene` +pub fn update_light_preview( + cursor: Res, + mode: Res, + options: Res, + templates: Option>, + document: Res, + mut preview: ResMut, +) { + // The light equals the placed one after a click, so that the scene isn't lit again + let light = preview_light( + &mode, + options.floor, + cursor.tile.map(GridNo::from), + templates.as_deref(), + document.next_light_id(), + ); + preview.set_if_neq(PreviewLight(light)); +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{ + editor::document::tests::{grid, Fixture}, + light::LightNode, + map::AmbientLight, + }; + + /// A map to place lights on, with the templates of the radii 1 to 5 + pub(crate) struct LightFixture { + pub fixture: Fixture, + pub templates: LightTemplates, + } + + impl LightFixture { + pub(crate) fn new() -> Self { + let templates = (1..=5) + .map(|radius| { + let template = LightTemplate { + lights: vec![LightNode { + dx: 0, + dy: 0, + light: radius, + }], + rays: vec![], + }; + ( + light_template_name(radius).to_lowercase(), + Arc::new(template), + ) + }) + .collect(); + Self { + fixture: Fixture::new(), + templates: LightTemplates(templates), + } + } + + /// Edits the map as one undo command + pub(crate) fn edit( + &mut self, + edit: impl FnOnce(&mut EditContext, &LightTemplates) -> R, + ) -> R { + self.fixture.document.undo.begin(); + let mut context = self.fixture.context(); + let result = edit(&mut context, &self.templates); + context.finish(); + self.fixture.document.undo.end(); + result + } + + pub(crate) fn place( + &mut self, + mode: &LightMode, + grid_no: GridNo, + ) -> Result { + self.edit(|context, templates| context.place_light(templates, mode, grid_no)) + } + + pub(crate) fn map(&self) -> &TacticalMap { + &self.fixture.map + } + + pub(crate) fn undo(&mut self) { + let command = self.fixture.document.undo.pop().unwrap(); + let mut context = self.fixture.context(); + context.restore(command); + context.finish(); + } + } + + pub(crate) fn mode(radius: u8, time: LightTime) -> LightMode { + LightMode { + radius, + time, + ..LightMode::default() + } + } + + #[test] + fn only_the_place_tool_previews_its_light() { + let fixture = LightFixture::new(); + let tile = Some(grid(30, 30)); + let place = LightMode { + tool: LightTool::Place, + ..mode(3, LightTime::Night) + }; + let preview = |mode: &LightMode, floor, tile| { + let mode = CurrentMode(EditorMode::Lights(mode.clone())); + preview_light(&mode, floor, tile, Some(&fixture.templates), LightId(7)) + }; + let light = preview(&place, Level::Ground, tile).unwrap(); + assert_eq!(light.id, LightId(7)); + assert_eq!(GridNo::from(light.position), grid(30, 30)); + assert_eq!((light.radius(), light.time), (Some(3), LightTime::Night)); + + assert!(preview(&place, Level::Roof, tile).is_none()); + assert!(preview(&place, Level::Ground, None).is_none()); + let erase = LightMode { + erase: true, + ..place.clone() + }; + assert!(preview(&erase, Level::Ground, tile).is_none()); + let select = LightMode { + tool: LightTool::Select, + ..place.clone() + }; + assert!(preview(&select, Level::Ground, tile).is_none()); + let missing = LightMode { + radius: 8, + ..place.clone() + }; + assert!(preview(&missing, Level::Ground, tile).is_none()); + let other = CurrentMode::default(); + assert!(preview_light( + &other, + Level::Ground, + tile, + Some(&fixture.templates), + LightId(7) + ) + .is_none()); + let mode = CurrentMode(EditorMode::Lights(place)); + assert!(preview_light(&mode, Level::Ground, tile, None, LightId(7)).is_none()); + } + + #[test] + fn modes_are_read_from_json() { + let read = |json: &str| match serde_json::from_str(json).unwrap() { + EditorMode::Lights(mode) => mode, + _ => panic!("not the lights mode"), + }; + assert_eq!( + read(r#"{"mode":"lights","radius":3,"time":"night"}"#), + mode(3, LightTime::Night) + ); + // The defaults of the original + assert_eq!(read(r#"{"mode":"lights"}"#), mode(5, LightTime::PrimeTime)); + assert_eq!( + read(r#"{"mode":"lights","time":"always"}"#).time, + LightTime::Always + ); + let erasing = read( + r#"{"mode":"lights","tool":"place","erase":true, + "brush":{"kind":"circle","size":5}}"#, + ); + assert_eq!(erasing.brush(), Some(Brush::Circle { size: 5 })); + let defaults = read(r#"{"mode":"lights"}"#); + assert_eq!( + (defaults.tool, defaults.erase, defaults.brush), + (LightTool::Select, false, Brush::default()) + ); + // Only erasing uses the brush + assert_eq!(defaults.brush(), None); + let select = read(r#"{"mode":"lights","tool":"select","erase":true}"#); + assert_eq!(select.tool, LightTool::Select); + assert_eq!(select.brush(), None); + let fill = read(r#"{"mode":"lights","tool":"place","erase":true,"brush":{"kind":"fill"}}"#); + assert_eq!(fill.brush(), Some(Brush::default())); + } + + #[test] + fn erasing_removes_the_lights_of_the_brush() { + let mut fixture = LightFixture::new(); + let always = mode(1, LightTime::Always); + let inside = fixture.place(&always, grid(50, 50)).unwrap(); + fixture.place(&always, grid(51, 49)).unwrap(); + let outside = fixture.place(&always, grid(60, 50)).unwrap(); + let erase = LightMode { + tool: LightTool::Place, + erase: true, + brush: Brush::Square { size: 3 }, + ..always.clone() + }; + let area = erase.brush().unwrap().region(None, grid(50, 50).position()); + fixture.edit(|context, _| erase.apply(context, &area)); + let ids: Vec<_> = fixture.map().lights.iter().map(|l| l.id).collect(); + assert_eq!(ids, [outside]); + // Nothing to erase is not recorded + let depth = fixture.fixture.document.undo.depth(); + fixture.edit(|context, _| erase.apply(context, &area)); + assert_eq!(fixture.fixture.document.undo.depth(), depth); + + fixture.undo(); + assert_eq!(fixture.map().lights.len(), 3); + assert!(map_light(fixture.map(), inside).is_some()); + } + + #[test] + fn placing_replaces_the_lights_of_the_tile() { + let mut fixture = LightFixture::new(); + let (a, b) = (grid(50, 50), grid(60, 50)); + let first = fixture.place(&mode(3, LightTime::Night), a).unwrap(); + let other = fixture.place(&mode(1, LightTime::Always), b).unwrap(); + let light = map_light(fixture.map(), first).unwrap(); + assert_eq!(light.template_name, "L-R03.LHT"); + assert_eq!(light.radius(), Some(3)); + assert_eq!(light.time, LightTime::Night); + assert!(light.file_sprite.is_none()); + assert_eq!(light.template.lights[0].light, 3); + + let replaced = fixture.place(&mode(2, LightTime::PrimeTime), a).unwrap(); + let ids: Vec<_> = fixture.map().lights.iter().map(|l| l.id).collect(); + assert_eq!(ids, [other, replaced]); + assert!(fixture.fixture.document.lights_dirty); + + // Missing templates and radii out of range are not placed + for radius in [6, 0, 9] { + assert!(fixture.place(&mode(radius, LightTime::Always), a).is_err()); + } + assert_eq!(fixture.map().lights.len(), 2); + assert_eq!(fixture.fixture.document.undo.depth(), 3); + + fixture.undo(); + let ids: Vec<_> = fixture.map().lights.iter().map(|l| l.id).collect(); + assert_eq!(ids, [first, other]); + } + + /// Releases the drag of the selection as one undo command + fn release(fixture: &mut LightFixture, selection: &mut LightSelection, to: GridNo) { + fixture.edit(|context, _| selection.release(context, to)); + } + + #[test] + fn pressing_selects_the_lights() { + let mut fixture = LightFixture::new(); + let (a, b) = (grid(50, 50), grid(40, 60)); + let first = fixture.place(&mode(4, LightTime::Always), a).unwrap(); + let second = fixture.place(&mode(4, LightTime::Always), b).unwrap(); + let mut selection = LightSelection::default(); + selection.press(fixture.map(), b, false); + assert_eq!(selection.selected, Some(second)); + assert!(selection.dragging()); + release(&mut fixture, &mut selection, b); + assert_eq!(selection.selected, Some(second)); + assert!(!selection.dragging()); + selection.press(fixture.map(), a, false); + assert_eq!(selection.selected, Some(first)); + // Tiles without lights deselect + selection.press(fixture.map(), grid(10, 10), false); + assert_eq!(selection.selected, None); + assert!(!selection.dragging()); + // Nothing was changed + assert_eq!(fixture.fixture.document.undo.depth(), 2); + + selection.press(fixture.map(), a, false); + release(&mut fixture, &mut selection, a); + fixture.edit(|context, _| selection.delete(context)); + assert_eq!(selection.selected, None); + assert_eq!(fixture.map().lights.len(), 1); + fixture.undo(); + assert_eq!(fixture.map().lights.len(), 2); + selection.selected = Some(second); + fixture.undo(); + selection.validate(fixture.map()); + assert_eq!(selection.selected, None); + } + + #[test] + fn releasing_on_the_selected_light_selects_the_next_one_of_the_tile() { + let mut fixture = LightFixture::new(); + let a = grid(50, 50); + let first = fixture.place(&mode(4, LightTime::Always), a).unwrap(); + // Old maps can have more than one light on a tile + let mut stacked = fixture.map().lights[0].clone(); + stacked.id = fixture.fixture.document.new_light_id(); + let second = stacked.id; + fixture.fixture.map.lights.push(stacked); + + let mut selection = LightSelection::default(); + // The first press only selects + selection.press(fixture.map(), a, false); + release(&mut fixture, &mut selection, a); + assert_eq!(selection.selected, Some(first)); + selection.press(fixture.map(), a, false); + release(&mut fixture, &mut selection, a); + assert_eq!(selection.selected, Some(second)); + selection.press(fixture.map(), a, false); + release(&mut fixture, &mut selection, a); + assert_eq!(selection.selected, Some(first)); + assert_eq!(fixture.map().lights.len(), 2); + } + + #[test] + fn dragging_moves_or_copies_the_light() { + let mut fixture = LightFixture::new(); + let (a, b, c) = (grid(50, 50), grid(55, 50), grid(60, 50)); + let id = fixture.place(&mode(3, LightTime::Night), a).unwrap(); + let replaced = fixture.place(&mode(1, LightTime::Always), b).unwrap(); + let mut selection = LightSelection::default(); + + selection.press(fixture.map(), a, false); + release(&mut fixture, &mut selection, b); + assert_eq!(selection.selected, Some(id)); + assert_eq!(fixture.map().lights.len(), 1); + let light = map_light(fixture.map(), id).unwrap(); + assert_eq!(GridNo::from(light.position), b); + assert_eq!((light.radius(), light.time), (Some(3), LightTime::Night)); + assert!(map_light(fixture.map(), replaced).is_none()); + + selection.press(fixture.map(), b, true); + release(&mut fixture, &mut selection, c); + let copy = selection.selected.unwrap(); + assert_ne!(copy, id); + let copied = map_light(fixture.map(), copy).unwrap(); + assert_eq!(GridNo::from(copied.position), c); + assert_eq!(copied.template_name, "L-R03.LHT"); + assert_eq!( + GridNo::from(map_light(fixture.map(), id).unwrap().position), + b + ); + + // The move is undone as a whole + fixture.undo(); + fixture.undo(); + selection.validate(fixture.map()); + assert_eq!(selection.selected, None); + let light = map_light(fixture.map(), id).unwrap(); + assert_eq!(GridNo::from(light.position), a); + assert!(map_light(fixture.map(), replaced).is_some()); + } + + #[test] + fn the_selected_light_can_be_edited() { + let mut fixture = LightFixture::new(); + let id = fixture + .place(&mode(2, LightTime::Always), grid(50, 50)) + .unwrap(); + let changed = fixture.edit(|context, templates| { + context.change_light(templates, id, Some(4), LightTime::Night) + }); + assert_eq!(changed, Ok(true)); + let light = map_light(fixture.map(), id).unwrap(); + assert_eq!((light.radius(), light.time), (Some(4), LightTime::Night)); + assert_eq!(light.template.lights[0].light, 4); + let depth = fixture.fixture.document.undo.depth(); + + // The same values change nothing, missing templates fail + let same = fixture.edit(|context, templates| { + context.change_light(templates, id, Some(4), LightTime::Night) + }); + assert_eq!(same, Ok(false)); + let missing = fixture.edit(|context, templates| { + context.change_light(templates, id, Some(8), LightTime::Always) + }); + assert!(missing.is_err()); + assert_eq!(fixture.fixture.document.undo.depth(), depth); + + // Lights with other templates keep them + fixture.fixture.map.lights[0].template_name = "LAMP.LHT".to_owned(); + fixture + .edit(|context, templates| context.change_light(templates, id, None, LightTime::Always)) + .unwrap(); + let light = map_light(fixture.map(), id).unwrap(); + assert_eq!(light.template_name, "LAMP.LHT"); + assert_eq!(light.time, LightTime::Always); + + fixture.undo(); + assert_eq!(map_light(fixture.map(), id).unwrap().time, LightTime::Night); + } + + #[test] + fn the_colour_and_the_light_level_can_be_changed() { + let mut fixture = LightFixture::new(); + // Maps without an ambient light ignore the level + assert!(fixture.edit(|context, _| context.change_light_settings([1, 2, 3], Some(4)))); + assert_eq!(fixture.map().light_color, [1, 2, 3]); + assert_eq!(fixture.map().ambient_light, None); + assert!(!fixture.edit(|context, _| context.change_light_settings([1, 2, 3], Some(4)))); + + fixture.fixture.map.ambient_light = Some(AmbientLight { + basement: true, + caves: false, + level: 10, + }); + assert_eq!(editor_light_level(10), 6); + assert!(fixture.edit(|context, _| context.change_light_settings([1, 2, 3], Some(12)))); + assert_eq!(fixture.map().ambient_light.unwrap().level, 4); + // Clamped like the original + fixture.edit(|context, _| context.change_light_settings([1, 2, 3], Some(40))); + assert_eq!(fixture.map().ambient_light.unwrap().level, 1); + fixture.edit(|context, _| context.change_light_settings([1, 2, 3], Some(0))); + assert_eq!(fixture.map().ambient_light.unwrap().level, 15); + // `None` keeps the level + fixture.edit(|context, _| context.change_light_settings([9, 9, 9], None)); + assert_eq!(fixture.map().ambient_light.unwrap().level, 15); + + for _ in 0..4 { + fixture.undo(); + } + assert_eq!(fixture.map().ambient_light.unwrap().level, 10); + assert_eq!(fixture.map().light_color, [1, 2, 3]); + // The first edit had no ambient light + fixture.undo(); + assert_eq!(fixture.map().light_color, [0, 0, 0]); + assert_eq!(fixture.map().ambient_light, None); + } +} diff --git a/src-map-editor/src/editor/mod.rs b/src-map-editor/src/editor/mod.rs new file mode 100644 index 0000000..1a5e1ee --- /dev/null +++ b/src-map-editor/src/editor/mod.rs @@ -0,0 +1,230 @@ +//! Editing of the loaded map, ported from the original editor (`src/game/Editor/` of the game) +//! +//! The terrain mode (ground textures, roads and cliffs), the objects mode (debris, trees, rocks, +//! other junk and furniture), the buildings mode, the soldiers mode, the items mode, the lights +//! mode and the traversal mode exist so far. The host chooses the mode (`set_editor_mode`), the +//! mouse paints with it (`input.rs`) and the objects mode and the roads and cliffs tools preview +//! what a click does (`preview.rs`). The soldiers mode places and selects +//! soldiers instead (`soldiers.rs`), the host edits the selected one (`soldier_events.rs`), and +//! the items mode does the same for world items (`items.rs`, `item_events.rs`) and the lights mode +//! for lights and the lighting of the map (`lights.rs`, `light_events.rs`). The traversal mode +//! places and selects exit grids and entry points, and the edge points are generated again after the +//! edits that move them (`traversal.rs`, `traversal_events.rs`). The edits +//! change the [`TacticalMap`] through an [`EditContext`], which keeps the world in sync and +//! records undo, and the scene catches up with the touched tiles (`PendingSceneUpdate`). + +use bevy::{ecs::system::SystemParam, prelude::*}; + +use crate::{ + map::{EditorSmoothing, TacticalMap}, + render::{world::MapWorld, PendingSceneUpdate}, + states::AppState, + systems::MapSystems, + tileset::Tileset, +}; + +use document::{EditContext, EditorDocument}; + +pub mod brush; +pub mod building; +pub mod building_mode; +pub mod caves; +pub mod cliffs; +pub mod commands; +pub mod document; +pub mod doors; +pub mod ground; +pub mod input; +pub mod item_events; +pub mod item_overlay; +pub mod items; +pub mod layout; +pub mod light_events; +pub mod light_overlay; +pub mod lights; +pub mod mode; +pub mod palette; +pub mod placement; +pub mod preview; +pub mod roads; +pub mod rooms_overlay; +pub mod save; +pub mod schedules; +pub mod scroll_area; +pub mod selection; +pub mod smart; +pub mod smooth; +pub mod soldier_events; +pub mod soldier_overlay; +pub mod soldiers; +pub mod traversal; +pub mod traversal_events; +pub mod traversal_overlay; +pub mod undo; +pub mod walls; + +pub struct EditorPlugin; + +impl Plugin for EditorPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(PreUpdate, commands::apply_requested_mode) + .add_systems( + OnEnter(AppState::Map), + ( + commands::start_editing, + palette::report_palette, + preview::reset_preview, + rooms_overlay::reset_room_labels, + selection::reset_selection, + soldiers::reset_soldier_selection, + soldier_events::report_soldier_palette, + soldier_events::reset_shown_teams, + soldier_overlay::reset_soldier_labels, + items::reset_item_selection, + item_events::reset_reported_item, + lights::reset_light_selection, + light_events::reset_reported_light, + light_events::reset_synced_lights, + ( + traversal::reset_traversal_selection, + traversal_events::reset_reported_traversal, + traversal_events::reset_synced_exit_grids, + traversal_overlay::reset_traversal_labels, + ), + ), + ) + .add_systems( + Update, + ( + // Edits and the scene catching up with them + ( + commands::run_editor_commands, + input::paint, + selection::select_buildings, + soldiers::edit_soldiers, + items::edit_items, + lights::edit_lights, + lights::update_light_preview, + traversal::edit_traversal, + soldier_events::sync_soldier_scene, + item_events::sync_item_scene, + light_events::sync_light_scene, + traversal_events::sync_traversal_scene, + scroll_area::sync_restricted_scroll, + // After the scene update of this frame compiled the movement costs + traversal_events::regenerate_edge_points, + ) + .chain(), + // Overlays and reports + ( + scroll_area::draw_scrollable_area, + input::draw_brush_cursor, + soldier_overlay::draw_soldier_markers, + item_overlay::draw_item_markers, + light_overlay::draw_light_markers, + traversal_overlay::draw_traversal_markers, + traversal_overlay::update_traversal_labels, + selection::draw_selection, + preview::update_preview, + rooms_overlay::update_room_labels, + soldier_overlay::update_soldier_labels, + doors::draw_door_markers, + doors::report_door_request, + soldier_events::report_soldier, + item_events::report_item, + light_events::report_light, + traversal_events::report_traversal, + commands::report_editor_state, + ) + .chain(), + ) + .chain() + .in_set(MapSystems::Edit) + .run_if(in_state(AppState::Map)), + ) + .add_systems( + Update, + save::save_requested_map.after(commands::run_editor_commands), + ); + } +} + +/// Everything that edits of the map change +#[derive(SystemParam)] +pub struct MapEdit<'w> { + map: ResMut<'w, TacticalMap>, + world: ResMut<'w, MapWorld>, + tileset: Res<'w, Tileset>, + document: ResMut<'w, EditorDocument>, + pending: ResMut<'w, PendingSceneUpdate>, +} + +impl MapEdit<'_> { + /// Edits the map, the scene is updated with the touched tiles + pub fn edit(&mut self, edit: impl FnOnce(&mut EditContext)) { + let records = self.document.undo.records(); + let mut context = EditContext::new( + &mut self.map, + &mut self.world, + &self.tileset, + &mut self.document, + ); + edit(&mut context); + let touched = context.finish(); + if !touched.is_empty() || self.document.undo.records() != records { + self.document.revision += 1; + } + self.pending.tiles.extend(touched); + } + + pub fn document(&mut self) -> &mut EditorDocument { + &mut self.document + } + + pub fn tileset(&self) -> &Tileset { + &self.tileset + } + + /// How the editor smooths the map + pub fn smoothing(&self) -> EditorSmoothing { + self.map.info.editor_smoothing + } +} + +#[cfg(test)] +mod tests { + /// Bevy checks that the parameters of a system don't access the same data mutably twice when + /// it builds the schedules, which only happens in the app + #[test] + fn editor_systems_have_no_conflicting_access() { + use bevy::{prelude::*, state::app::StatesPlugin}; + let mut app = App::new(); + app.add_plugins((MinimalPlugins, StatesPlugin, AssetPlugin::default())) + .init_asset::() + .init_state::() + .add_plugins(super::EditorPlugin); + // Builds the schedules, which checks the access of the systems + app.update(); + app.update(); + } +} diff --git a/src-map-editor/src/editor/mode.rs b/src-map-editor/src/editor/mode.rs new file mode 100644 index 0000000..872b303 --- /dev/null +++ b/src-map-editor/src/editor/mode.rs @@ -0,0 +1,680 @@ +//! The editing mode chosen in the host, and applying it to the map + +use std::sync::Arc; + +use bevy::input::mouse::MouseButton; +use serde::Deserialize; + +use crate::{ + map::{EditorSmoothing, GridNo, Level}, + random::Random, + render::RenderOptions, + tileset::{Tile, TileIndex, Tileset, NUM_ROAD_MACROS}, +}; + +use super::{ + brush::{density_test, Brush, BrushArea}, + building_mode::{BuildingMode, BuildingTool}, + cliffs::{erase_cliffs, place_cliff, raise_world_land}, + document::EditContext, + ground::{ + choose_weighted_texture, erase_ground, paste_texture_common, terrain_fill, GroundSelection, + }, + items::{ItemMode, ItemTool}, + lights::{LightMode, LightTool}, + placement::{erase_debris, erase_structures, paste_tile, SelectionList}, + roads::{erase_roads, place_road_macro}, + soldiers::{SoldierMode, SoldierTool}, + traversal::TraversalMode, +}; + +/// The mode, set by the host with `set_editor_mode` +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +#[serde(tag = "mode", rename_all = "lowercase")] +pub enum EditorMode { + /// Only viewing, the mouse doesn't edit + #[default] + View, + /// Painting ground textures or placing roads + Terrain(TerrainMode), + Objects(ObjectsMode), + Buildings(Box), + /// Placing, erasing and editing soldiers, see `soldiers.rs` + Soldiers(SoldierMode), + /// Placing, erasing and editing world items, see `items.rs` + Items(ItemMode), + /// Placing, erasing and editing lights, see `lights.rs` + Lights(LightMode), + /// Painting exit grids and setting entry points, see `traversal.rs` + Traversal(TraversalMode), +} + +impl EditorMode { + /// The brush of the modes that edit, `None` for viewing + pub fn brush(&self, tileset: &Tileset, smoothing: EditorSmoothing) -> Option { + match self { + EditorMode::View => None, + EditorMode::Terrain(mode) => Some(mode.brush()), + EditorMode::Objects(mode) => Some(mode.brush()), + EditorMode::Buildings(mode) => Some(mode.brush(tileset, smoothing)), + EditorMode::Traversal(mode) => mode.brush(), + EditorMode::Lights(mode) => mode.brush(), + EditorMode::Soldiers(mode) => mode.brush(), + EditorMode::Items(mode) => mode.brush(), + } + } + + /// Whether the mode removes what it places + pub fn erases(&self) -> bool { + match self { + EditorMode::View => false, + EditorMode::Terrain(mode) => mode.erase, + EditorMode::Objects(mode) => mode.erase, + EditorMode::Buildings(mode) => mode.erase && mode.tool.can_erase(), + EditorMode::Traversal(mode) => mode.erase && mode.brush().is_some(), + EditorMode::Lights(mode) => mode.erase && mode.tool == LightTool::Place, + EditorMode::Soldiers(mode) => mode.erase && mode.tool == SoldierTool::Place, + EditorMode::Items(mode) => mode.erase && mode.tool == ItemTool::Place, + } + } + + /// Whether a stroke with a mouse button edits, all modes paint with the left button + pub fn uses_button(&self, button: MouseButton) -> bool { + match self { + EditorMode::View => false, + EditorMode::Terrain(_) | EditorMode::Objects(_) => button == MouseButton::Left, + EditorMode::Buildings(mode) => mode.tool.uses_button(button), + // Only painting exit grids uses the brush, the right button queries them, see + // `traversal.rs` + EditorMode::Traversal(mode) => mode.brush().is_some() && button == MouseButton::Left, + // Only erasing paints, see `lights.rs`, `soldiers.rs` and `items.rs` + EditorMode::Lights(mode) => mode.brush().is_some() && button == MouseButton::Left, + EditorMode::Soldiers(mode) => mode.brush().is_some() && button == MouseButton::Left, + EditorMode::Items(mode) => mode.brush().is_some() && button == MouseButton::Left, + } + } + + /// Whether the mouse selects buildings instead of painting, see `selection.rs` + pub fn selects_buildings(&self) -> bool { + matches!(self, EditorMode::Buildings(mode) if mode.tool == BuildingTool::Select) + } + + /// Whether the mode only edits the ground: terrain, roads, objects, lights and the + /// traversal points have no roof level, so the mode edits nothing on the roof floor + pub fn is_ground_only(&self) -> bool { + matches!( + self, + EditorMode::Terrain(_) + | EditorMode::Objects(_) + | EditorMode::Lights(_) + | EditorMode::Traversal(_) + ) + } + + /// Applies the mode to the tiles of the brush, with the button of the stroke, on the floor + /// of the options + pub fn apply( + &self, + context: &mut EditContext, + area: &BrushArea, + button: MouseButton, + options: &RenderOptions, + ) { + let floor = options.floor; + let left = button == MouseButton::Left; + if floor == Level::Roof && self.is_ground_only() { + return; + } + match self { + EditorMode::View => {} + EditorMode::Terrain(mode) if left => mode.apply(context, area), + EditorMode::Objects(mode) if left => mode.apply(context, area), + EditorMode::Buildings(mode) => mode.apply(context, area, button), + EditorMode::Traversal(mode) if left => mode.apply(context, area), + EditorMode::Lights(mode) if left => mode.apply(context, area), + EditorMode::Soldiers(mode) if left => { + mode.apply(context, area, &options.layers.soldier_teams, floor) + } + EditorMode::Items(mode) if left => mode.apply(context, area, floor), + EditorMode::Terrain(_) + | EditorMode::Objects(_) + | EditorMode::Traversal(_) + | EditorMode::Lights(_) + | EditorMode::Soldiers(_) + | EditorMode::Items(_) => {} + } + } +} + +/// The ground textures, as the host sends them +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct GroundTextures { + /// Name of the tile type of the texture that is painted while no texture has a weight + pub selected: Option, + pub weights: Vec, +} + +/// A ground texture and how often it is painted +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextureWeight { + /// Name of the tile type of the texture + pub type_name: String, + pub weight: u8, +} + +/// A tile of a palette and how often it is picked +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectionEntry { + pub tile_id: TileIndex, + pub count: u32, +} + +/// What a click in the terrain mode does +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TerrainTool { + /// Paints or erases ground textures with the brush + #[default] + Ground, + /// Places road macros on the clicked tile, or erases roads + Roads, + /// Places cliff pieces on the clicked tiles, or erases cliffs, and raises the land behind them + Cliffs, +} + +/// The terrain mode, painting ground textures and placing roads and cliffs (`TASK_TERRAIN` of the +/// original editor) +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +#[serde(default)] +pub struct TerrainMode { + pub tool: TerrainTool, + /// Removes the ground textures, roads or cliffs instead + pub erase: bool, + /// The brush of the ground tool, the only one of the terrain modes that fills + pub brush: Brush, + /// Chance of a placement on each tile of the brush, in percent, ground textures ignore it + pub density: u8, + pub ground: GroundTextures, + /// The road macro of the roads tool + pub road: u16, + /// The sub index of the `FIRSTCLIFF` piece of the cliffs tool + pub cliff: u16, +} + +impl TerrainMode { + fn ground(&self, context: &EditContext) -> GroundSelection { + let weights = self.ground.weights.iter(); + let weights = weights.map(|w| (w.type_name.clone(), w.weight)); + GroundSelection::new(context, self.ground.selected.clone(), weights) + } + + /// The brush, roads and cliffs only work on the clicked tile + pub fn brush(&self) -> Brush { + match self.tool { + TerrainTool::Ground => self.brush, + TerrainTool::Roads | TerrainTool::Cliffs => Brush::Square { size: 1 }, + } + } + + /// Applies the tool to the tiles of the brush + pub fn apply(&self, context: &mut EditContext, area: &BrushArea) { + match self.tool { + TerrainTool::Ground => self.apply_ground(context, area), + TerrainTool::Roads => self.apply_roads(context, area), + TerrainTool::Cliffs => self.apply_cliffs(context, area), + } + } + + /// Paints or erases the ground textures on the tiles of the brush, fills from the first tile + /// (`EraseMapTile`) + fn apply_ground(&self, context: &mut EditContext, area: &BrushArea) { + if self.erase { + for grid_no in area.tiles() { + erase_ground(context, grid_no); + } + return; + } + + let ground = self.ground(context); + if self.brush == Brush::Fill { + if let (Some(first), Some(paste)) = ( + area.first(), + choose_weighted_texture(&mut context.document.random, &ground), + ) { + terrain_fill(context, first, paste); + } + return; + } + for grid_no in area.tiles() { + let Some(paste) = choose_weighted_texture(&mut context.document.random, &ground) else { + return; + }; + paste_texture_common(context, grid_no, paste); + } + } +} + +impl TerrainMode { + /// Places the road macro on the first tile of the brush or erases the roads of its tiles + fn apply_roads(&self, context: &mut EditContext, area: &BrushArea) { + if self.erase { + for grid_no in area.tiles() { + erase_roads(context, grid_no); + } + return; + } + if let Some(first) = area.first().filter(|_| self.road < NUM_ROAD_MACROS) { + place_road_macro(context, first, self.road); + } + } + + /// Places the cliff piece on the tiles of the brush or erases their cliffs, then works out the + /// height of the land again + fn apply_cliffs(&self, context: &mut EditContext, area: &BrushArea) { + for grid_no in area.tiles() { + if self.erase { + erase_cliffs(context, grid_no); + } else { + place_cliff(context, grid_no, self.cliff); + } + } + raise_world_land(context); + } +} + +/// The objects mode, placing debris, trees, rocks, other junk and furniture (part of +/// `TASK_TERRAIN` of the original editor) +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +#[serde(default)] +pub struct ObjectsMode { + /// Removes debris and structures instead + pub erase: bool, + pub brush: Brush, + /// Chance of a placement on each tile of the brush, in percent + pub density: u8, + /// The selected debris, trees, rocks, other junk and furniture + pub objects: Vec, +} + +impl ObjectsMode { + /// The brush, objects don't fill + pub fn brush(&self) -> Brush { + match self.brush { + Brush::Fill => Brush::Square { size: 1 }, + brush => brush, + } + } + + /// The debris and structures a click places on the tiles of the brush, with the density + /// test and the random picks of the objects palette + /// + /// The preview runs this with a copy of the random numbers of the document, so it shows what + /// the click places. + pub fn plan_objects( + &self, + random: &mut Random, + tileset: &Tileset, + area: &BrushArea, + ) -> Vec<(GridNo, Arc)> { + let entries = self.objects.iter().map(|e| (e.tile_id, e.count)); + let list = SelectionList::new(tileset, entries); + if list.is_empty() { + return vec![]; + } + // Single tiles always get a placement + let every_tile = area.is_single(); + area.tiles() + .filter_map(|grid_no| { + if !every_tile && !density_test(random, self.density) { + return None; + } + Some((grid_no, list.pick(random)?.clone())) + }) + .collect() + } + + /// Places or erases objects on the tiles of the brush (`DrawObjectsBasedOnSelectionRegion` + /// and `EraseMapTile`) + pub fn apply(&self, context: &mut EditContext, area: &BrushArea) { + if self.erase { + for grid_no in area.tiles() { + erase_debris(context, grid_no); + erase_structures(context, grid_no); + } + return; + } + let plan = self.plan_objects(&mut context.document.random, context.tileset, area); + for (grid_no, tile) in plan { + paste_tile(context, grid_no, tile); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + components::map::GridPosition, + editor::document::tests::{grid, Fixture}, + file_formats::ja2_tile_types::{DEBRISROCKS, FIRSTFULLSTRUCT, FIRSTISTRUCT}, + tileset::tests::structure_tileset, + }; + + fn mode(json: &str) -> EditorMode { + serde_json::from_str(json).unwrap() + } + + fn terrain(json: &str) -> TerrainMode { + match mode(json) { + EditorMode::Terrain(mode) => mode, + _ => panic!("not the terrain mode"), + } + } + + fn objects(json: &str) -> ObjectsMode { + match mode(json) { + EditorMode::Objects(mode) => mode, + _ => panic!("not the objects mode"), + } + } + + #[test] + fn modes_are_read_from_json() { + assert_eq!(mode(r#"{"mode":"view"}"#), EditorMode::View); + let terrain_mode = terrain( + r#"{"mode":"terrain","erase":false, + "brush":{"kind":"line","width":3},"density":20, + "ground":{"selected":"TEXTURE3","weights":[{"typeName":"TEXTURE2","weight":3}]}}"#, + ); + assert_eq!(terrain_mode.brush, Brush::Line { width: 3 }); + assert_eq!(terrain_mode.density, 20); + assert_eq!(terrain_mode.ground.selected.as_deref(), Some("TEXTURE3")); + assert_eq!( + terrain_mode.ground.weights, + [TextureWeight { + type_name: "TEXTURE2".to_owned(), + weight: 3 + }] + ); + let objects_mode = objects( + r#"{"mode":"objects","brush":{"kind":"line","width":3},"density":20, + "objects":[{"tileId":7,"count":2}]}"#, + ); + assert_eq!(objects_mode.brush(), Brush::Line { width: 3 }); + assert_eq!(objects_mode.density, 20); + assert_eq!( + objects_mode.objects, + [SelectionEntry { + tile_id: 7, + count: 2 + }] + ); + assert_eq!(terrain_mode.tool, TerrainTool::Ground); + let roads = terrain(r#"{"mode":"terrain","tool":"roads","erase":true,"road":4}"#); + assert_eq!(roads.tool, TerrainTool::Roads); + assert!(roads.erase); + assert_eq!(roads.road, 4); + let cliffs = terrain(r#"{"mode":"terrain","tool":"cliffs","cliff":3}"#); + assert_eq!(cliffs.tool, TerrainTool::Cliffs); + assert_eq!(cliffs.cliff, 3); + // Only the terrain fills + let fixture = Fixture::new(); + let brush = |json| mode(json).brush(&fixture.tileset, EditorSmoothing::Normal); + let single = Some(Brush::Square { size: 1 }); + assert_eq!( + brush(r#"{"mode":"terrain","brush":{"kind":"fill"}}"#), + Some(Brush::Fill) + ); + assert_eq!( + brush(r#"{"mode":"objects","brush":{"kind":"fill"}}"#), + single + ); + // Roads and cliffs ignore the brush + assert_eq!( + brush(r#"{"mode":"terrain","tool":"roads","brush":{"kind":"fill"}}"#), + single + ); + assert_eq!( + brush(r#"{"mode":"terrain","tool":"cliffs","brush":{"kind":"fill"}}"#), + single + ); + } + + #[test] + fn the_cliffs_tool_places_and_erases_cliffs_and_raises_the_land() { + let mut fixture = Fixture::new(); + let column = [grid(50, 49), grid(50, 50), grid(50, 51)]; + let place = terrain(r#"{"mode":"terrain","tool":"cliffs","cliff":2}"#); + let mut context = fixture.context(); + for g in column { + place.apply(&mut context, &place.brush().region(None, g.position())); + } + context.finish(); + let cliffs = &fixture.map.tiles[grid(50, 50)].structs; + assert_eq!(cliffs.len(), 1); + assert_eq!( + (cliffs[0].type_name.as_str(), cliffs[0].sub_index), + ("FIRSTCLIFF", 2) + ); + // The land before the cliff in the row is raised + assert_eq!(fixture.map.tiles[grid(40, 50)].height, 80); + assert_eq!(fixture.map.tiles[grid(60, 50)].height, 0); + + let erase = terrain(r#"{"mode":"terrain","tool":"cliffs","erase":true,"cliff":2}"#); + let mut context = fixture.context(); + for g in column { + erase.apply(&mut context, &erase.brush().region(None, g.position())); + } + context.finish(); + assert!(fixture.map.tiles[grid(50, 50)].structs.is_empty()); + assert!(fixture.map.tiles.values().all(|t| t.height == 0)); + } + + fn debris_count(fixture: &Fixture, area: &BrushArea) -> usize { + area.tiles() + .filter(|&g| !fixture.map.tiles[g].objects.is_empty()) + .count() + } + + #[test] + fn density_applies_to_larger_brushes_only() { + let mut fixture = Fixture::new(); + let rock = fixture.tileset.index(DEBRISROCKS, 1).unwrap(); + let mut mode = objects(r#"{"mode":"objects","density":0}"#); + mode.objects = vec![SelectionEntry { + tile_id: rock, + count: 1, + }]; + let center = GridPosition::new(50, 50); + + let mut context = fixture.context(); + let single = mode.brush().region(None, center); + mode.apply(&mut context, &single); + context.finish(); + assert_eq!(debris_count(&fixture, &single), 1); + + mode.brush = Brush::Square { size: 7 }; + let region = mode.brush().region(None, GridPosition::new(80, 80)); + let mut context = fixture.context(); + mode.apply(&mut context, ®ion); + context.finish(); + assert_eq!(debris_count(&fixture, ®ion), 0); + + mode.density = 100; + let mut context = fixture.context(); + mode.apply(&mut context, ®ion); + context.finish(); + assert_eq!(debris_count(&fixture, ®ion), 49); + + mode.erase = true; + let mut context = fixture.context(); + mode.apply(&mut context, ®ion); + context.finish(); + assert_eq!(debris_count(&fixture, ®ion), 0); + } + + #[test] + fn ground_ignores_the_density() { + let mut fixture = Fixture::new(); + let mut mode = terrain( + r#"{"mode":"terrain","brush":{"kind":"square","size":3},"density":0, + "ground":{"selected":"TEXTURE2","weights":[]}}"#, + ); + let mut context = fixture.context(); + mode.apply( + &mut context, + &mode.brush.region(None, GridPosition::new(50, 50)), + ); + context.finish(); + assert_eq!(fixture.land(grid(49, 49)).last().unwrap().0, "TEXTURE2"); + assert_eq!(fixture.land(grid(50, 50)).last().unwrap().0, "TEXTURE2"); + // Fills from the clicked tile + mode.brush = Brush::Fill; + mode.ground.selected = Some("TEXTURE1".to_owned()); + let mut context = fixture.context(); + mode.apply( + &mut context, + &mode.brush.region(None, GridPosition::new(50, 50)), + ); + context.finish(); + assert_eq!(fixture.land(grid(50, 50)).last().unwrap().0, "TEXTURE1"); + + // Nothing is painted without a texture + mode.brush = Brush::Square { size: 3 }; + mode.ground.selected = None; + let mut context = fixture.context(); + mode.apply( + &mut context, + &mode.brush.region(None, GridPosition::new(80, 80)), + ); + assert!(context.finish().is_empty()); + } + + #[test] + fn objects_place_debris_and_structures() { + let mut fixture = Fixture::with_tileset(structure_tileset()); + let rock = fixture.tileset.index(DEBRISROCKS, 1).unwrap(); + let tree = fixture.tileset.index(FIRSTFULLSTRUCT, 1).unwrap(); + let mut mode = objects(r#"{"mode":"objects"}"#); + mode.objects = vec![ + SelectionEntry { + tile_id: rock, + count: 1, + }, + SelectionEntry { + tile_id: tree, + count: 1, + }, + ]; + let positions: Vec<_> = (0..40).map(|x| GridPosition::new(10 + x * 2, 50)).collect(); + let tiles: Vec = positions.iter().map(|&p| GridNo::from(p)).collect(); + let mut context = fixture.context(); + for &p in &positions { + mode.apply(&mut context, &Brush::default().region(None, p)); + } + context.finish(); + let with_debris = tiles + .iter() + .filter(|&&g| !fixture.map.tiles[g].objects.is_empty()); + let with_trees = tiles + .iter() + .filter(|&&g| !fixture.map.tiles[g].structs.is_empty()); + let (debris, trees) = (with_debris.count(), with_trees.count()); + assert!(debris > 0 && trees > 0, "{debris} debris, {trees} trees"); + assert_eq!(debris + trees, tiles.len()); + + // Erasing removes both + mode.erase = true; + let mut context = fixture.context(); + for &p in &positions { + mode.apply(&mut context, &Brush::default().region(None, p)); + } + context.finish(); + assert!(tiles + .iter() + .all(|&g| fixture.map.tiles[g].objects.is_empty() + && fixture.map.tiles[g].structs.is_empty())); + } + + #[test] + fn objects_place_and_erase_furniture() { + let mut fixture = Fixture::with_tileset(structure_tileset()); + let furniture = fixture.tileset.index(FIRSTISTRUCT, 2).unwrap(); + let mut mode = objects(r#"{"mode":"objects"}"#); + mode.objects = vec![SelectionEntry { + tile_id: furniture, + count: 1, + }]; + let area = Brush::default().region(None, GridPosition::new(60, 60)); + let mut context = fixture.context(); + mode.apply(&mut context, &area); + context.finish(); + let structs = |fixture: &Fixture| -> Vec<(String, u16)> { + fixture.map.tiles[grid(60, 60)] + .structs + .iter() + .map(|t| (t.type_name.clone(), t.sub_index)) + .collect() + }; + assert_eq!(structs(&fixture), [("ISTRUCT1".to_owned(), 2)]); + + mode.erase = true; + let mut context = fixture.context(); + mode.apply(&mut context, &area); + context.finish(); + assert!(structs(&fixture).is_empty()); + } + + #[test] + fn the_plan_matches_what_is_placed() { + let mut fixture = Fixture::with_tileset(structure_tileset()); + let rock = fixture.tileset.index(DEBRISROCKS, 1).unwrap(); + let tree = fixture.tileset.index(FIRSTFULLSTRUCT, 1).unwrap(); + let mut mode = objects( + r#"{"mode":"objects","brush":{"kind":"circle","size":9}, + "density":30}"#, + ); + mode.objects = vec![ + SelectionEntry { + tile_id: rock, + count: 3, + }, + SelectionEntry { + tile_id: tree, + count: 1, + }, + ]; + let area = mode.brush().region(None, GridPosition::new(60, 60)); + let mut random = fixture.document.random.clone(); + let plan = mode.plan_objects(&mut random, &fixture.tileset, &area); + assert!(!plan.is_empty() && plan.len() < area.tiles.len()); + + let mut context = fixture.context(); + mode.apply(&mut context, &area); + context.finish(); + let placed: Vec<(GridNo, TileIndex)> = area + .tiles() + .flat_map(|g| { + let tile = &fixture.map.tiles[g]; + tile.objects + .iter() + .chain(&tile.structs) + .map(move |t| (g, t.id)) + }) + .collect(); + let planned: Vec<(GridNo, TileIndex)> = plan.iter().map(|(g, t)| (*g, t.id)).collect(); + assert!(placed.iter().all(|p| planned.contains(p))); + // Debris always fits, structures that overlap others of the plan are left out + let planned_debris = planned.iter().filter(|&&(_, id)| id == rock); + assert!(planned_debris.clone().count() > 0); + assert!(planned_debris.into_iter().all(|p| placed.contains(p))); + // The document went through the same random numbers + assert_eq!( + fixture.document.random.clone().next(1000), + random.next(1000) + ); + } +} diff --git a/src-map-editor/src/editor/palette.rs b/src-map-editor/src/editor/palette.rs new file mode 100644 index 0000000..281123f --- /dev/null +++ b/src-map-editor/src/editor/palette.rs @@ -0,0 +1,172 @@ +//! The palettes of the loaded map, which the host shows to pick tiles from + +use bevy::prelude::*; +use serde::Serialize; + +use crate::{ + host::report_editor_event, + map::TacticalMap, + states::loading::MapToLoad, + tileset::{BuildingPalette, Tile, TileCategory, TileIndex, Tileset}, +}; + +/// A tile of a palette +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaletteEntry { + pub tile_id: TileIndex, + pub type_name: String, + pub sub_index: u16, + /// The image file, relative to the data root + pub file: String, + pub sub_image: u16, +} + +impl PaletteEntry { + fn new(tile: &Tile) -> Self { + let image = &tile.image; + Self { + tile_id: tile.id, + type_name: tile.type_name.clone(), + sub_index: tile.sub_index, + file: format!( + "tilesets/{}/{}", + image.surface.tileset_id, image.surface.filename + ), + sub_image: image.sub_image, + } + } +} + +/// The palettes of the buildings mode, see [`BuildingPalette`] +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildingPalettes { + pub room: Vec, + pub walls: Vec, + pub doors: Vec, + pub windows: Vec, + pub roofs: Vec, + pub broken_walls: Vec, + pub decals: Vec, + pub floors: Vec, + pub smart_walls: Vec, + pub smart_doors: Vec, + pub smart_windows: Vec, + pub smart_broken_walls: Vec, + pub smart_roofs: Vec, +} + +impl BuildingPalettes { + pub fn new(tileset: &Tileset) -> Self { + let entries = |palette| { + tileset + .building_palette(palette) + .iter() + .map(|t| PaletteEntry::new(t)) + .collect() + }; + Self { + room: entries(BuildingPalette::Room), + walls: entries(BuildingPalette::Walls), + doors: entries(BuildingPalette::Doors), + windows: entries(BuildingPalette::Windows), + roofs: entries(BuildingPalette::Roofs), + broken_walls: entries(BuildingPalette::BrokenWalls), + decals: entries(BuildingPalette::Decals), + floors: entries(BuildingPalette::Floors), + smart_walls: entries(BuildingPalette::SmartWalls), + smart_doors: entries(BuildingPalette::SmartDoors), + smart_windows: entries(BuildingPalette::SmartWindows), + smart_broken_walls: entries(BuildingPalette::SmartBrokenWalls), + smart_roofs: entries(BuildingPalette::SmartRoofs), + } + } +} + +/// The palettes of the terrain and buildings modes +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "type", rename = "palette")] +pub struct PaletteEvent { + pub map: String, + pub tileset: u8, + /// The first tile of each ground texture and of the water that exists, in the order of the + /// tile types + pub ground: Vec, + pub roads: Vec, + pub cliffs: Vec, + pub debris: Vec, + pub trees: Vec, + pub rocks: Vec, + pub misc: Vec, + pub furniture: Vec, + #[serde(rename = "genericFurniture")] + pub generic_furniture: Vec, + pub buildings: BuildingPalettes, +} + +impl PaletteEvent { + pub fn new(map: String, tileset_id: u8, tileset: &Tileset) -> Self { + let entries = |categories: &[TileCategory]| { + categories + .iter() + .flat_map(|&category| tileset.palette(category)) + .map(|t| PaletteEntry::new(t)) + .collect() + }; + Self { + map, + tileset: tileset_id, + ground: entries(&[ + TileCategory::Ground, + TileCategory::Water, + TileCategory::DeepWater, + ]), + roads: entries(&[TileCategory::Roads]), + cliffs: entries(&[TileCategory::Cliffs]), + debris: entries(&[TileCategory::Debris]), + trees: entries(&[TileCategory::Trees]), + rocks: entries(&[TileCategory::Rocks]), + misc: entries(&[TileCategory::Misc]), + furniture: entries(&[TileCategory::Furniture]), + generic_furniture: entries(&[TileCategory::GenericFurniture]), + buildings: BuildingPalettes::new(tileset), + } + } +} + +pub fn report_palette(map_to_load: Res, map: Res, tileset: Res) { + let event = PaletteEvent::new(map_to_load.0.clone(), map.tileset.index() as u8, &tileset); + report_editor_event(&event); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tileset::tests::test_tileset; + + #[test] + fn palette_events_are_json_for_the_host() { + let tileset = test_tileset(); + let event = PaletteEvent::new("maps/a9.dat".to_owned(), 0, &tileset); + // The land textures, the water and the deep water + assert_eq!(event.ground.len(), 9); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "palette"); + assert_eq!(json["map"], "maps/a9.dat"); + let tree = &json["trees"][1]; + assert_eq!(tree["typeName"], "OFSTRUCT1"); + assert_eq!(tree["subIndex"], 4); + assert_eq!(tree["subImage"], 3); + assert_eq!(tree["file"], "tilesets/0/type20.sti"); + assert!(tree["tileId"].is_u64()); + assert_eq!(json["roads"].as_array().unwrap().len(), 32); + assert_eq!(json["cliffs"].as_array().unwrap().len(), 17); + assert_eq!(json["cliffs"][0]["typeName"], "FIRSTCLIFF"); + let buildings = &json["buildings"]; + assert_eq!(buildings["room"][0]["typeName"], "WALL1"); + assert_eq!(buildings["smartRoofs"][0]["subIndex"], 10); + assert_eq!(buildings["smartBrokenWalls"].as_array().unwrap().len(), 4); + assert_eq!(json["genericFurniture"][0]["typeName"], "FIFTHISTRUCT"); + } +} diff --git a/src-map-editor/src/editor/placement.rs b/src-map-editor/src/editor/placement.rs new file mode 100644 index 0000000..83d751e --- /dev/null +++ b/src-map-editor/src/editor/placement.rs @@ -0,0 +1,233 @@ +//! Placing debris and structures (`PasteDebris` and `PasteStructure` in `Edit_Sys.cc`) + +use std::sync::Arc; + +use crate::{ + map::{GridNo, Level}, + random::Random, + tileset::{Tile, TileCategory, TileIndex, Tileset}, +}; + +use super::document::EditContext; + +/// Whether a tile of the objects is debris, which placing debris replaces and erasing debris +/// removes +pub fn is_debris(tile: &Tile) -> bool { + tile.category == Some(TileCategory::Debris) +} + +/// Whether a tile of the structs is removed by erasing structures +pub fn is_erased_structure(tile: &Tile) -> bool { + tile.category.is_some_and(TileCategory::is_structure) +} + +/// Whether a tile of the objects is removed by erasing structures, like the original +/// `DEBRIS2MISC` and `ANOTHERDEBRIS` +pub fn is_erased_with_structures(tile: &Tile) -> bool { + tile.erased_with_structures +} + +/// Tiles to place and how often each is picked (`Selections`) +#[derive(Clone, Debug, Default)] +pub struct SelectionList(Vec<(Arc, u32)>); + +impl SelectionList { + /// The tiles with the given ids and counts, unknown tiles are left out + pub fn new(tileset: &Tileset, entries: impl IntoIterator) -> Self { + Self( + entries + .into_iter() + .filter(|&(_, count)| count > 0) + .filter_map(|(id, count)| Some((tileset.tile(id)?.clone(), count))) + .collect(), + ) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Picks a tile, the higher its count the more often (`GetRandomSelection`) + pub fn pick(&self, random: &mut Random) -> Option<&Arc> { + self.pick_where(random, |_| true) + } + + /// Picks one of the tiles that match like [`SelectionList::pick`], `None` if none does + /// (`GetRandomIndexByRange`, which ignores the counts) + pub fn pick_where( + &self, + random: &mut Random, + matches: impl Fn(&Tile) -> bool, + ) -> Option<&Arc> { + let candidates = || self.0.iter().filter(|(tile, _)| matches(tile)); + let total: u32 = candidates().map(|(_, count)| count).sum(); + if total == 0 { + return None; + } + let mut pick = random.next(total); + for (tile, count) in candidates() { + if pick < *count { + return Some(tile); + } + pick -= count; + } + None + } + + /// Whether any of the tiles matches + pub fn contains(&self, matches: impl Fn(&Tile) -> bool) -> bool { + self.0.iter().any(|(tile, _)| matches(tile)) + } +} + +/// Places a tile of the objects palette, debris like [`paste_debris`] and everything else like +/// [`paste_structure`] +pub fn paste_tile(context: &mut EditContext, grid_no: GridNo, tile: Arc) { + if is_debris(&tile) { + paste_debris(context, grid_no, tile); + } else { + paste_structure(context, grid_no, tile); + } +} + +/// Places debris at the tail of the objects, replacing the debris of the tile (`PasteDebris`) +/// +/// The original removes all objects from `ANOTHERDEBRIS` on, including e.g. roads. Only debris is +/// removed here. +pub fn paste_debris(context: &mut EditContext, grid_no: GridNo, tile: Arc) { + context.remove_objects_where(grid_no, is_debris); + context.add_object_to_tail(grid_no, tile); +} + +/// Places a structure at the head of the structs, if it fits (`PasteStructureCommon`) +/// +/// The world is synced right away, so the next structure sees this one. +pub fn paste_structure(context: &mut EditContext, grid_no: GridNo, tile: Arc) { + if let Some(structure) = &tile.structure { + if !context + .world + .okay_to_add_structure_to_world(grid_no, Level::Ground, structure) + { + return; + } + } + context.add_struct_to_head(grid_no, tile); + context.commit(); +} + +/// Removes the debris of a tile (`EraseMapTile` for debris) +pub fn erase_debris(context: &mut EditContext, grid_no: GridNo) { + context.remove_objects_where(grid_no, is_debris); +} + +/// Removes the structures of a tile (`EraseMapTile` for structures), and like the original also +/// `DEBRIS2MISC` and `ANOTHERDEBRIS` +pub fn erase_structures(context: &mut EditContext, grid_no: GridNo) { + context.remove_structs_where(grid_no, is_erased_structure); + context.remove_objects_where(grid_no, is_erased_with_structures); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + editor::document::tests::{grid, Fixture}, + file_formats::ja2_tile_types::{DEBRISROCKS, FIRSTFULLSTRUCT, FIRSTOSTRUCT}, + tileset::tests::{structure_tileset, test_tileset}, + }; + + #[test] + fn picks_follow_the_counts() { + let tileset = test_tileset(); + let id = |sub_index| tileset.index(DEBRISROCKS, sub_index).unwrap(); + let list = SelectionList::new(&tileset, [(id(1), 1), (id(2), 0), (id(3), 3)]); + let mut random = Random::new(7); + let mut counts = [0; 4]; + for _ in 0..4000 { + counts[usize::from(list.pick(&mut random).unwrap().sub_index)] += 1; + } + assert_eq!(counts[2], 0); + assert!((800..1200).contains(&counts[1]), "{counts:?}"); + assert!(SelectionList::new(&tileset, []).pick(&mut random).is_none()); + + // The same seed picks the same tiles + let picks = |seed| { + let mut random = Random::new(seed); + (0..20) + .map(|_| list.pick(&mut random).unwrap().sub_index) + .collect::>() + }; + assert_eq!(picks(9), picks(9)); + } + + #[test] + fn debris_only_replaces_debris() { + let mut fixture = Fixture::new(); + let g = grid(50, 50); + let rock = fixture.tileset.index(DEBRISROCKS, 2).unwrap(); + let rock = fixture.tileset.tile(rock).unwrap().clone(); + let mut context = fixture.context(); + let road = context.tile("ROADPIECES", 1).unwrap(); + let old = context.tile("ANOTHERDEBRIS", 1).unwrap(); + context.add_object_to_head(g, road); + context.add_object_to_tail(g, old); + paste_debris(&mut context, g, rock.clone()); + paste_debris(&mut context, g, rock); + context.finish(); + let objects: Vec<_> = fixture.map.tiles[g] + .objects + .iter() + .map(|tile| (tile.type_name.as_str(), tile.sub_index)) + .collect(); + assert_eq!(objects, [("ROADPIECES", 1), ("ROCKS", 2)]); + + let mut context = fixture.context(); + erase_debris(&mut context, g); + context.finish(); + assert_eq!(fixture.map.tiles[g].objects.len(), 1); + } + + #[test] + fn structures_do_not_overlap() { + let mut fixture = Fixture::with_tileset(structure_tileset()); + // A 2×2 obstacle + let big = fixture.tileset.index(FIRSTOSTRUCT, 3).unwrap(); + let big = fixture.tileset.tile(big).unwrap().clone(); + let mut context = fixture.context(); + for (x, y) in [(50, 50), (51, 50), (50, 51), (52, 52)] { + paste_structure(&mut context, grid(x, y), big.clone()); + } + // Off the map + paste_structure(&mut context, grid(159, 159), big); + context.finish(); + let placed: Vec = GridNo::all() + .filter(|&g| !fixture.map.tiles[g].structs.is_empty()) + .collect(); + assert_eq!(placed, [grid(50, 50), grid(52, 52)]); + assert_eq!(fixture.world.tiles[grid(51, 51)].structures.len(), 1); + } + + #[test] + fn erasing_structures_removes_their_shadows() { + let mut fixture = Fixture::with_tileset(structure_tileset()); + let g = grid(50, 50); + let tree = fixture.tileset.index(FIRSTFULLSTRUCT, 1).unwrap(); + assert!(fixture.tileset.tile(tree).unwrap().shadow_buddy.is_some()); + let tree = fixture.tileset.tile(tree).unwrap().clone(); + let mut context = fixture.context(); + paste_structure(&mut context, g, tree); + let junk = context.tile("MISC2", 1).unwrap(); + context.add_object_to_tail(g, junk); + context.finish(); + assert_eq!(fixture.world.tiles[g].shadows.len(), 1); + assert_eq!(fixture.world.tiles[g].structures.len(), 1); + + let mut context = fixture.context(); + erase_structures(&mut context, g); + context.finish(); + assert!(fixture.map.tiles[g].structs.is_empty()); + assert!(fixture.map.tiles[g].objects.is_empty()); + assert!(fixture.world.tiles[g].shadows.is_empty()); + assert!(fixture.world.tiles[g].structures.is_empty()); + } +} diff --git a/src-map-editor/src/editor/preview.rs b/src-map-editor/src/editor/preview.rs new file mode 100644 index 0000000..f39c5cd --- /dev/null +++ b/src-map-editor/src/editor/preview.rs @@ -0,0 +1,729 @@ +//! Shows what a click in the objects mode, with the roads or cliffs tool of the terrain mode or +//! with a single tile tool of the buildings mode places or removes +//! +//! Objects are picked at random, so the preview runs the placement with a copy of the random +//! numbers of the document: the tinted sprites are exactly what the next click places. Roads show +//! the pieces of the road macro around the cursor, cliffs the piece and its overhanging edge. While erasing, what the click removes is +//! tinted red. The building tools are applied to the map and undone again (`dry_run`), and the +//! tiles they add and remove are shown. + +use std::{collections::HashMap, sync::Arc}; + +use bevy::input::mouse::MouseButton; + +use bevy::{ + asset::RenderAssetUsages, + image::ImageSampler, + prelude::*, + render::render_resource::{Extent3d, TextureDimension, TextureFormat}, + sprite::Anchor, +}; + +use crate::{ + components::map::{game_to_world, GridPosition}, + map::MapTile, + map::{GridNo, Level, TacticalMap}, + render::{ + atlas::SpriteSheets, + draw_list::{ + ground_draw_position, map_top_y, map_world_y, PaletteKey, SpriteKey, WALL_HEIGHT, + }, + world::MapWorld, + RenderOptions, + }, + states::{loading::MapAssets, AppState}, + systems::cursor::CursorInfo, + tileset::{StciSheet, Tile, TileIndex, Tileset}, +}; + +use super::{ + brush::BrushArea, + cliffs::{is_cliff, is_cliff_hang, is_cliff_shadow, planned_cliff}, + commands::CurrentMode, + document::{EditContext, EditorDocument}, + input::EditorStroke, + mode::{EditorMode, ObjectsMode, TerrainMode, TerrainTool}, + placement::{is_debris, is_erased_structure, is_erased_with_structures}, + roads::{erased_road_pieces, road_macro_pieces}, +}; + +/// Alpha of the preview sprites +const PREVIEW_ALPHA: u8 = 153; + +/// The colour the preview sprites are tinted towards +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Tint { + /// Green, the click places the object + Place, + /// Red, the click removes the object + Remove, +} + +impl Tint { + fn color(self) -> [u8; 3] { + match self { + Tint::Place => [0, 255, 0], + Tint::Remove => [255, 0, 0], + } + } +} + +/// The tinted images of the preview and what it shows +#[derive(Resource, Default)] +pub struct PreviewSprites { + images: HashMap<(TileIndex, Tint), Handle>, + /// The tiles of the preview that is shown and whether it erases, `None` if none is shown + shown: Option<(BrushArea, bool)>, +} + +/// A sprite of the preview +#[derive(Component)] +pub struct PreviewSprite; + +/// The sprites of the last map are not valid for the next one +pub fn reset_preview(mut preview: ResMut) { + *preview = PreviewSprites::default(); +} + +/// Blends the colours of a sprite halfway towards the tint, index 0 is transparent +fn tint_pixels(pixels: &[u8], palette: &[[u8; 3]; 256], tint: Tint) -> Vec { + let target = tint.color(); + pixels + .iter() + .flat_map(|&index| { + if index == 0 { + return [0; 4]; + } + let [r, g, b] = palette[usize::from(index)]; + let blend = |c: u8, t: u8| ((u16::from(c) + u16::from(t)) / 2) as u8; + [ + blend(r, target[0]), + blend(g, target[1]), + blend(b, target[2]), + PREVIEW_ALPHA, + ] + }) + .collect() +} + +/// The layer of a node of the preview, in the order the game draws the layers of a row +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PreviewLayer { + Land, + Object, + Shadow, + Struct, + /// Drawn at the height of the roofs + Roof, + OnRoof, +} + +/// A node of the map that the preview shows +struct PreviewNode { + grid_no: GridNo, + tile: Arc, + layer: PreviewLayer, + tint: Tint, +} + +/// What a click in the objects mode places on the tiles of the brush +fn planned_nodes( + area: &BrushArea, + mode: &ObjectsMode, + document: &EditorDocument, + tileset: &Tileset, + world: &MapWorld, +) -> Vec { + let mut random = document.random.clone(); + mode.plan_objects(&mut random, tileset, area) + .into_iter() + .filter(|(grid_no, tile)| { + // Structures overlapping others of the same plan are not checked + is_debris(tile) + || tile.structure.as_ref().is_none_or(|structure| { + world.okay_to_add_structure_to_world(*grid_no, Level::Ground, structure) + }) + }) + .map(|(grid_no, tile)| PreviewNode { + grid_no, + layer: if is_debris(&tile) { + PreviewLayer::Object + } else { + PreviewLayer::Struct + }, + tile, + tint: Tint::Place, + }) + .collect() +} + +/// The pieces of the road macro that a click with the roads tool places +fn planned_road_nodes(area: &BrushArea, mode: &TerrainMode, tileset: &Tileset) -> Vec { + let Some(first) = area.first() else { + return vec![]; + }; + road_macro_pieces(tileset, first, mode.road) + .into_iter() + .map(|(grid_no, tile)| PreviewNode { + grid_no, + tile, + layer: PreviewLayer::Object, + tint: Tint::Place, + }) + .collect() +} + +/// The cliff pieces that a click with the cliffs tool places, with their overhanging edges +fn planned_cliff_nodes( + area: &BrushArea, + mode: &TerrainMode, + map: &TacticalMap, + tileset: &Tileset, +) -> Vec { + let mut nodes = vec![]; + for grid_no in area.tiles() { + let Some((cliff, hang)) = planned_cliff(map, tileset, grid_no, mode.cliff) else { + continue; + }; + let node = |tile, layer| PreviewNode { + grid_no, + tile, + layer, + tint: Tint::Place, + }; + nodes.push(node(cliff, PreviewLayer::Struct)); + nodes.extend(hang.map(|hang| node(hang, PreviewLayer::Object))); + } + nodes +} + +/// What erasing with the cliffs tool removes from the tiles of the brush, like `erase_cliffs` +fn erased_cliff_nodes(area: &BrushArea, map: &TacticalMap) -> Vec { + let mut nodes = vec![]; + for grid_no in area.tiles() { + let tile = &map.tiles[grid_no]; + let layers = [ + ( + &tile.structs, + PreviewLayer::Struct, + is_cliff as fn(&Tile) -> bool, + ), + (&tile.shadows, PreviewLayer::Shadow, is_cliff_shadow), + (&tile.objects, PreviewLayer::Object, is_cliff_hang), + ]; + for (tiles, layer, matches) in layers { + nodes.extend(tiles.iter().filter(|t| matches(t)).map(|t| PreviewNode { + grid_no, + tile: t.clone(), + layer, + tint: Tint::Remove, + })); + } + } + nodes +} + +/// What erasing with the roads tool removes from the tiles of the brush, like `erase_roads` +fn erased_road_nodes(area: &BrushArea, map: &TacticalMap, tileset: &Tileset) -> Vec { + area.tiles() + .flat_map(|grid_no| erased_road_pieces(map, tileset, grid_no)) + .map(|(grid_no, tile)| PreviewNode { + grid_no, + tile, + layer: PreviewLayer::Object, + tint: Tint::Remove, + }) + .collect() +} + +/// What erasing in the objects mode removes from the tiles of the brush, like +/// `erase_debris` and `erase_structures` +fn erased_nodes(area: &BrushArea, map: &TacticalMap) -> Vec { + let mut nodes = vec![]; + for grid_no in area.tiles() { + let tile = &map.tiles[grid_no]; + let objects = tile + .objects + .iter() + .filter(|t| is_debris(t) || is_erased_with_structures(t)); + nodes.extend(objects.map(|t| PreviewNode { + grid_no, + tile: t.clone(), + layer: PreviewLayer::Object, + tint: Tint::Remove, + })); + let structs = tile.structs.iter().filter(|t| is_erased_structure(t)); + nodes.extend(structs.map(|t| PreviewNode { + grid_no, + tile: t.clone(), + layer: PreviewLayer::Struct, + tint: Tint::Remove, + })); + } + nodes +} + +/// The layers of a map tile with the preview layers of their tiles +fn layers(tile: &MapTile) -> [(PreviewLayer, &[Arc]); 6] { + [ + (PreviewLayer::Land, &tile.land), + (PreviewLayer::Object, &tile.objects), + (PreviewLayer::Shadow, &tile.shadows), + (PreviewLayer::Struct, &tile.structs), + (PreviewLayer::Roof, &tile.roofs), + (PreviewLayer::OnRoof, &tile.on_roofs), + ] +} + +/// The tiles of `from` that are not in `other`, as often as they are missing +fn missing(from: &[Arc], other: &[Arc]) -> Vec> { + let mut other: Vec = other.iter().map(|tile| tile.id).collect(); + from.iter() + .filter(|tile| match other.iter().position(|&id| id == tile.id) { + Some(index) => { + other.swap_remove(index); + false + } + None => true, + }) + .cloned() + .collect() +} + +/// The nodes an edit added to and removed from a tile +fn changed_nodes(grid_no: GridNo, before: &MapTile, after: &MapTile) -> Vec { + let mut nodes = vec![]; + for ((layer, before), (_, after)) in layers(before).into_iter().zip(layers(after)) { + for (tint, tiles) in [ + (Tint::Place, missing(after, before)), + (Tint::Remove, missing(before, after)), + ] { + nodes.extend(tiles.into_iter().map(|tile| PreviewNode { + grid_no, + tile, + layer, + tint, + })); + } + } + nodes +} + +/// What an edit adds and removes: it is applied to the map and undone again +/// +/// The map is restored from the undo record of the edit, the world from a copy, and the random +/// numbers and the undo stack of the document are kept, so nothing changes. +fn dry_run( + map: &mut TacticalMap, + world: &mut MapWorld, + tileset: &Tileset, + document: &mut EditorDocument, + edit: impl FnOnce(&mut EditContext), +) -> Vec { + let saved_world = world.clone(); + let saved_random = document.random.clone(); + let saved_background = document.background.clone(); + let saved_undo = std::mem::take(&mut document.undo); + let saved_edge_points_dirty = document.edge_points_dirty; + let mut context = EditContext::new(map, world, tileset, document); + edit(&mut context); + drop(context); + let command = document.undo.pop().unwrap_or_default(); + let mut nodes = vec![]; + for (grid_no, before) in command.tiles { + nodes.extend(changed_nodes(grid_no, &before, &map.tiles[grid_no])); + map.tiles[grid_no] = before; + } + *world = saved_world; + document.random = saved_random; + document.background = saved_background; + document.undo = saved_undo; + document.edge_points_dirty = saved_edge_points_dirty; + nodes +} + +/// Spawns tinted sprites of the objects or roads the next click places or removes +#[allow(clippy::too_many_arguments)] +pub fn update_preview( + mut commands: Commands, + mode: Res, + cursor: Res, + stroke: Res, + mut map: ResMut, + mut world: ResMut, + tileset: Res, + mut document: ResMut, + (map_assets, sheets, options): (Res, Res>, Res), + mut images: ResMut>, + mut preview: ResMut, + sprites: Query>, +) { + let previewed = match &mode.0 { + EditorMode::Terrain(TerrainMode { + tool: TerrainTool::Roads | TerrainTool::Cliffs, + .. + }) + | EditorMode::Objects(_) => { + // They don't edit the roof floor + (options.floor != Level::Roof).then_some(&mode.0) + } + EditorMode::Buildings(buildings) if buildings.tool.previews() => Some(&mode.0), + _ => None, + }; + let smoothing = map.info.editor_smoothing; + let wanted = previewed.zip(cursor.tile).and_then(|(previewed, tile)| { + let brush = previewed.brush(&tileset, smoothing)?; + let on_release = brush.applies_on_release(); + let erase = previewed.erases(); + // What was just painted is already on the map + if stroke.active && !on_release && !erase { + return None; + } + let anchor = stroke.anchor.filter(|_| stroke.active && on_release); + Some((brush.region(anchor, tile), erase)) + }); + let changed = + mode.is_changed() || options.is_changed() || map.is_changed() || document.is_changed(); + if !changed && wanted == preview.shown { + return; + } + for entity in &sprites { + commands.entity(entity).despawn(); + } + preview.shown = wanted.clone(); + let (Some(previewed), Some((area, erase))) = (previewed, wanted) else { + return; + }; + + let mut nodes = match previewed { + EditorMode::Terrain(terrain) if terrain.tool == TerrainTool::Roads => { + if erase { + erased_road_nodes(&area, &map, &tileset) + } else { + planned_road_nodes(&area, terrain, &tileset) + } + } + EditorMode::Terrain(terrain) if terrain.tool == TerrainTool::Cliffs => { + if erase { + erased_cliff_nodes(&area, &map) + } else { + planned_cliff_nodes(&area, terrain, &map, &tileset) + } + } + EditorMode::Objects(_) if erase => erased_nodes(&area, &map), + EditorMode::Objects(objects) => planned_nodes(&area, objects, &document, &tileset, &world), + EditorMode::Buildings(buildings) => dry_run( + // The preview doesn't change the map, nothing has to catch up with it + map.bypass_change_detection(), + world.bypass_change_detection(), + &tileset, + document.bypass_change_detection(), + |context| buildings.apply(context, &area, MouseButton::Left), + ), + EditorMode::View + | EditorMode::Terrain(_) + | EditorMode::Soldiers(_) + | EditorMode::Items(_) + | EditorMode::Lights(_) + | EditorMode::Traversal(_) => vec![], + }; + let top_y = map_top_y(world.restricted_scroll); + let order = |node: &PreviewNode| { + let GridPosition { x, y } = node.grid_no.position(); + (map_world_y(i32::from(x), i32::from(y), top_y), node.layer) + }; + nodes.sort_by_key(order); + + let sheets = SpriteSheets { + tileset: &tileset, + surface_sheets: map_assets + .surface_sheets + .iter() + .map(|sheet| sheets.get(sheet)) + .collect(), + soldier_sheets: vec![], + soldier_palettes: vec![], + }; + for (index, node) in nodes.iter().enumerate() { + let key = SpriteKey::Tile(node.tile.id); + let Some(geometry) = sheets.geometry(key) else { + continue; + }; + let tint = node.tint; + let image = match preview.images.get(&(node.tile.id, tint)) { + Some(image) => image.clone(), + None => { + let (Some(pixels), Some(palette)) = ( + sheets.pixels(key), + sheets.palette(PaletteKey::Tile(node.tile.image.surface.id)), + ) else { + continue; + }; + let mut image = Image::new( + Extent3d { + width: geometry.size.x, + height: geometry.size.y, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + tint_pixels(pixels, palette, tint), + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::RENDER_WORLD, + ); + image.sampler = ImageSampler::nearest(); + let image = images.add(image); + preview.images.insert((node.tile.id, tint), image.clone()); + image + } + }; + let position = node.grid_no.position(); + let mut draw_position = + ground_draw_position(position, &world.tiles[node.grid_no], &node.tile); + if matches!(node.layer, PreviewLayer::Roof | PreviewLayer::OnRoof) { + draw_position.y -= WALL_HEIGHT; + } + let top_left = game_to_world(draw_position + geometry.offset); + // Above the regions of the map at z 0, in the order the game draws them + let z = 1. + index as f32 * 0.001; + commands.spawn(( + PreviewSprite, + DespawnOnExit(AppState::Map), + Sprite::from_image(image), + Anchor::TOP_LEFT, + Transform::from_translation(top_left.extend(z)), + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + components::map::GridPosition, + editor::{ + brush::Brush, + document::tests::{grid, Fixture}, + mode::EditorMode, + placement::{erase_debris, erase_structures, paste_debris, paste_structure}, + roads::is_road_piece, + }, + file_formats::ja2_tile_types::{DEBRISROCKS, FIRSTFULLSTRUCT}, + tileset::tests::structure_tileset, + }; + + #[test] + fn dry_runs_show_the_changes_and_keep_the_map() { + use crate::editor::{ + building::tests::{add_room, building_fixture, rect}, + smart::paste_smart_door, + }; + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let before: Vec = fixture.map.tiles[grid(52, 51)] + .structs + .iter() + .map(|t| t.id) + .collect(); + let random = fixture.document.random.clone(); + let depth = fixture.document.undo.depth(); + let nodes = dry_run( + &mut fixture.map, + &mut fixture.world, + &fixture.tileset, + &mut fixture.document, + |context| paste_smart_door(context, grid(52, 51), 0, false), + ); + let mut shown: Vec<_> = nodes + .iter() + .map(|n| (n.tile.type_name.as_str(), n.layer, n.tint)) + .collect(); + shown.sort_by_key(|&(name, _, _)| name); + assert_eq!( + shown, + [ + ("DOOR1", PreviewLayer::Struct, Tint::Place), + ("WALL1", PreviewLayer::Struct, Tint::Remove) + ] + ); + let after: Vec = fixture.map.tiles[grid(52, 51)] + .structs + .iter() + .map(|t| t.id) + .collect(); + assert_eq!(after, before); + assert_eq!(fixture.world.tiles[grid(52, 51)].structs.len(), 1); + assert_eq!(fixture.document.undo.depth(), depth); + assert_eq!( + fixture.document.random.clone().next(1000), + random.clone().next(1000) + ); + } + + #[test] + fn tints_blend_towards_the_colour() { + let mut palette = [[0; 3]; 256]; + palette[1] = [100, 100, 100]; + let pixels = tint_pixels(&[0, 1], &palette, Tint::Place); + assert_eq!(pixels, [0, 0, 0, 0, 50, 177, 50, PREVIEW_ALPHA]); + let pixels = tint_pixels(&[1], &palette, Tint::Remove); + assert_eq!(pixels, [177, 50, 50, PREVIEW_ALPHA]); + } + + #[test] + fn the_erase_preview_shows_what_erasing_removes() { + let mut fixture = Fixture::with_tileset(structure_tileset()); + let rock = fixture.tileset.index(DEBRISROCKS, 1).unwrap(); + let rock = fixture.tileset.tile(rock).unwrap().clone(); + let tree = fixture.tileset.index(FIRSTFULLSTRUCT, 1).unwrap(); + let tree = fixture.tileset.tile(tree).unwrap().clone(); + let mut context = fixture.context(); + paste_debris(&mut context, grid(50, 50), rock); + paste_structure(&mut context, grid(51, 50), tree); + let road = context.tile("ROADPIECES", 1).unwrap(); + let junk = context.tile("MISC2", 1).unwrap(); + context.add_object_to_head(grid(50, 50), road); + context.add_object_to_tail(grid(51, 50), junk); + context.finish(); + + let area = Brush::Square { size: 3 }.region(None, GridPosition::new(51, 51)); + let names = |nodes: Vec| { + let mut names: Vec<_> = nodes + .iter() + .map(|n| { + let is_struct = n.layer == PreviewLayer::Struct; + (n.grid_no, n.tile.type_name.clone(), is_struct) + }) + .collect(); + names.sort(); + names + }; + let shown = names(erased_nodes(&area, &fixture.map)); + assert_eq!(shown.len(), 3, "{shown:?}"); + assert!(shown.iter().all(|(_, name, _)| name != "ROADPIECES")); + assert!(shown + .iter() + .any(|(g, _, is_struct)| *g == grid(51, 50) && *is_struct)); + + // Erasing removes exactly these + let mut context = fixture.context(); + for grid_no in area.tiles() { + erase_debris(&mut context, grid_no); + erase_structures(&mut context, grid_no); + } + context.finish(); + assert!(erased_nodes(&area, &fixture.map).is_empty()); + assert_eq!(fixture.map.tiles[grid(50, 50)].objects.len(), 1); + } + + #[test] + fn the_cliff_preview_shows_the_piece_and_its_hang() { + let mut fixture = Fixture::new(); + let json = r#"{"mode":"terrain","tool":"cliffs","cliff":4}"#; + let EditorMode::Terrain(mode) = serde_json::from_str(json).unwrap() else { + unreachable!() + }; + let area = mode.brush().region(None, GridPosition::new(50, 50)); + let nodes = |nodes: Vec| -> Vec<_> { + nodes + .into_iter() + .map(|n| (n.grid_no, n.tile.type_name.clone(), n.layer, n.tint)) + .collect() + }; + let g = grid(50, 50); + let planned = vec![ + ( + g, + "FIRSTCLIFF".to_owned(), + PreviewLayer::Struct, + Tint::Place, + ), + ( + g, + "FIRSTCLIFFHANG".to_owned(), + PreviewLayer::Object, + Tint::Place, + ), + ]; + assert_eq!( + nodes(planned_cliff_nodes( + &area, + &mode, + &fixture.map, + &fixture.tileset + )), + planned + ); + + let mut context = fixture.context(); + mode.apply(&mut context, &area); + context.finish(); + // The piece is already there + assert!(planned_cliff_nodes(&area, &mode, &fixture.map, &fixture.tileset).is_empty()); + let erased = vec![ + ( + g, + "FIRSTCLIFF".to_owned(), + PreviewLayer::Struct, + Tint::Remove, + ), + ( + g, + "FIRSTCLIFFHANG".to_owned(), + PreviewLayer::Object, + Tint::Remove, + ), + ]; + assert_eq!(nodes(erased_cliff_nodes(&area, &fixture.map)), erased); + } + + fn roads_mode(road: u16, erase: bool) -> TerrainMode { + let json = format!(r#"{{"mode":"terrain","tool":"roads","road":{road},"erase":{erase}}}"#); + match serde_json::from_str(&json).unwrap() { + EditorMode::Terrain(mode) => mode, + _ => unreachable!(), + } + } + + #[test] + fn the_road_preview_shows_the_pieces_of_the_macro() { + let mut fixture = Fixture::new(); + let mode = roads_mode(5, false); + let area = mode.brush().region(None, GridPosition::new(50, 50)); + let mut preview: Vec<_> = planned_road_nodes(&area, &mode, &fixture.tileset) + .iter() + .map(|n| (n.grid_no, n.tile.id)) + .collect(); + assert!(preview.len() > 1); + + let mut context = fixture.context(); + mode.apply(&mut context, &area); + context.finish(); + let mut placed: Vec<_> = GridNo::all() + .flat_map(|g| { + let objects = fixture.map.tiles[g].objects.iter(); + objects.filter(|t| is_road_piece(t)).map(move |t| (g, t.id)) + }) + .collect(); + preview.sort(); + placed.sort(); + assert_eq!(preview, placed); + + // Erasing shows all pieces of the macro + let erase = roads_mode(5, true); + let (piece, _) = placed[0]; + let area = erase.brush().region(None, piece.position()); + let mut erased: Vec<_> = erased_road_nodes(&area, &fixture.map, &fixture.tileset) + .iter() + .map(|n| (n.grid_no, n.tile.id)) + .collect(); + erased.sort(); + assert_eq!(erased, placed); + // Nothing without road pieces + assert!(erased_road_nodes( + &Brush::default().region(None, GridPosition::new(10, 10)), + &fixture.map, + &fixture.tileset + ) + .is_empty()); + } +} diff --git a/src-map-editor/src/editor/roads.rs b/src-map-editor/src/editor/roads.rs new file mode 100644 index 0000000..cc3a65f --- /dev/null +++ b/src-map-editor/src/editor/roads.rs @@ -0,0 +1,328 @@ +//! Placing roads (`Road_Smoothing.cc`) +//! +//! Roads are made of single tile pieces (`ROADPIECES`). A road macro places the pieces of one of +//! the 32 road images of `FIRSTROAD` around the clicked tile. The sub index of a piece tells its +//! macro and where it is relative to the clicked tile, so erasing removes whole macros. + +use std::sync::Arc; + +use crate::{ + components::map::GridPosition, + map::{GridNo, TacticalMap}, + tileset::{Tile, Tileset}, +}; + +use super::document::EditContext; + +// The road macros, in the order of the `FIRSTROAD` images +const L1: u8 = 0; +const R1: u8 = 1; +const B1: u8 = 2; +const T1: u8 = 3; +const TTR: u8 = 4; +const BTR: u8 = 5; +const LBL: u8 = 6; +const RBL: u8 = 7; +const LTL: u8 = 8; +const RTL: u8 = 9; +const RBR: u8 = 10; +const LBR: u8 = 11; +const L2: u8 = 12; +const R2: u8 = 13; +const L3: u8 = 14; +const R3: u8 = 15; +const B2: u8 = 16; +const T2: u8 = 17; +const B3: u8 = 18; +const T3: u8 = 19; +const BI: u8 = 20; +const LI: u8 = 21; +const TI: u8 = 22; +const RI: u8 = 23; +const L4: u8 = 24; +const R4: u8 = 25; +const B4: u8 = 26; +const T4: u8 = 27; +const RE: u8 = 28; +const LE: u8 = 29; +const BE: u8 = 30; +const TE: u8 = 31; + +/// The pieces of the road macros (`gRoadMacros`): the macro and the grid number offset from the +/// clicked tile. The index of a piece is its sub index in `ROADPIECES` minus 1. +#[rustfmt::skip] +const ROAD_MACROS: [(u8, i16); 312] = [ + (L1, -2), (L1, -162), (L1, -322), (L1, -1), (L1, -161), (L1, -321), (L1, 0), (L1, -160), (L1, -320), + (R1, -2), (R1, -162), (R1, -322), (R1, -1), (R1, -161), (R1, -321), (R1, 0), (R1, -160), (R1, -320), + (B1, -2), (B1, -162), (B1, -322), (B1, -1), (B1, -161), (B1, -321), (B1, 0), (B1, -160), (B1, -320), + (T1, -2), (T1, -162), (T1, -322), (T1, -1), (T1, -161), (T1, -321), (T1, 0), (T1, -160), (T1, -320), + (TTR, -4), (TTR, -164), (TTR, -324), (TTR, -3), (TTR, -163), (TTR, -323), (TTR, -2), (TTR, -162), (TTR, -1), (TTR, -161), (TTR, 0), + (BTR, -5), (BTR, -165), (BTR, -325), (BTR, -4), (BTR, -164), (BTR, -324), (BTR, -3), (BTR, -163), (BTR, -323), (BTR, -2), (BTR, -162), (BTR, -322), (BTR, -1), (BTR, -161), (BTR, -321), (BTR, 0), (BTR, -160), + (LBL, -322), (LBL, -482), (LBL, -642), (LBL, -161), (LBL, -321), (LBL, -481), (LBL, -641), (LBL, 0), (LBL, -160), (LBL, -320), (LBL, -480), (LBL, -640), + (RBL, -162), (RBL, -322), (RBL, -482), (RBL, -642), (RBL, -802), (RBL, -1), (RBL, -161), (RBL, -321), (RBL, -481), (RBL, -641), (RBL, -801), (RBL, 0), (RBL, -160), (RBL, -320), (RBL, -480), (RBL, -640), (RBL, -800), + (LTL, -2), (LTL, -162), (LTL, -322), (LTL, -1), (LTL, -161), (LTL, -321), (LTL, -481), (LTL, 0), (LTL, -160), (LTL, -320), (LTL, -480), (LTL, -640), + (RTL, -2), (RTL, -162), (RTL, -322), (RTL, -482), (RTL, -642), (RTL, -802), (RTL, -1), (RTL, -161), (RTL, -321), (RTL, -481), (RTL, -641), (RTL, -801), (RTL, 0), (RTL, -160), (RTL, -320), (RTL, -480), (RTL, -640), (RTL, -800), + (RBR, 159), (RBR, -1), (RBR, -161), (RBR, -321), (RBR, -481), (RBR, -641), (RBR, 0), (RBR, -160), (RBR, -320), (RBR, -480), (RBR, -640), (RBR, -159), (RBR, -319), (RBR, -479), (RBR, -639), + (LBR, 158), (LBR, -2), (LBR, -162), (LBR, -322), (LBR, -482), (LBR, -642), (LBR, 159), (LBR, -1), (LBR, -161), (LBR, -321), (LBR, -481), (LBR, -641), (LBR, 160), (LBR, 0), (LBR, -160), (LBR, -320), (LBR, -480), (LBR, -640), + (L2, -2), (L2, -162), (L2, -322), (L2, -1), (L2, -161), (L2, -321), (L2, 0), (L2, -160), (L2, -320), + (R2, -2), (R2, -162), (R2, -322), (R2, -1), (R2, -161), (R2, -321), (R2, 0), (R2, -160), (R2, -320), + (L3, -2), (L3, -162), (L3, -322), (L3, -1), (L3, -161), (L3, -321), (L3, 0), (L3, -160), (L3, -320), + (R3, -2), (R3, -162), (R3, -322), (R3, -1), (R3, -161), (R3, -321), (R3, 0), (R3, -160), (R3, -320), + (B2, -2), (B2, -162), (B2, -322), (B2, -1), (B2, -161), (B2, -321), (B2, 0), (B2, -160), (B2, -320), + (T2, -2), (T2, -162), (T2, -322), (T2, -1), (T2, -161), (T2, -321), (T2, 0), (T2, -160), (T2, -320), + (B3, -2), (B3, -162), (B3, -322), (B3, -1), (B3, -161), (B3, -321), (B3, 0), (B3, -160), (B3, -320), + (T3, -2), (T3, -162), (T3, -322), (T3, -1), (T3, -161), (T3, -321), (T3, 0), (T3, -160), (T3, -320), + (BI, -2), (BI, -162), (BI, -322), (BI, -1), (BI, -161), (BI, -321), (BI, 0), (BI, -160), (BI, -320), + (LI, -2), (LI, -162), (LI, -322), (LI, -1), (LI, -161), (LI, -321), (LI, 0), (LI, -160), (LI, -320), + (TI, -2), (TI, -162), (TI, -322), (TI, -1), (TI, -161), (TI, -321), (TI, 0), (TI, -160), (TI, -320), + (RI, -2), (RI, -162), (RI, -322), (RI, -1), (RI, -161), (RI, -321), (RI, 0), (RI, -160), (RI, -320), + (L4, -2), (L4, -162), (L4, -322), (L4, -1), (L4, -161), (L4, -321), (L4, 0), (L4, -160), (L4, -320), + (R4, -2), (R4, -162), (R4, -322), (R4, -1), (R4, -161), (R4, -321), (R4, 0), (R4, -160), (R4, -320), + (B4, -2), (B4, -162), (B4, -322), (B4, -1), (B4, -161), (B4, -321), (B4, 0), (B4, -160), (B4, -320), + (T4, -2), (T4, -162), (T4, -322), (T4, -1), (T4, -161), (T4, -321), (T4, 0), (T4, -160), (T4, -320), + (RE, 0), (RE, -160), (RE, -320), + (LE, 0), (LE, -160), (LE, -320), + (BE, -2), (BE, -1), (BE, 0), + (TE, -2), (TE, -1), (TE, 0), +]; + +/// Column and row offset of a grid number offset, the game adds them as one number +fn offset_to_xy(offset: i16) -> (i32, i32) { + let offset = i32::from(offset); + let dy = (offset + 80).div_euclid(160); + (offset - dy * 160, dy) +} + +/// The pieces of a road macro placed at a position and the tiles they go to, pieces off the map +/// are left out +fn pieces_at(tileset: &Tileset, x: i32, y: i32, macro_id: u16) -> Vec<(GridNo, Arc)> { + ROAD_MACROS + .iter() + .enumerate() + .filter(|&(_, &(id, _))| u16::from(id) == macro_id) + .filter_map(|(index, &(_, offset))| { + let (dx, dy) = offset_to_xy(offset); + let target = GridPosition::from_xy(x + dx, y + dy)?; + let piece = tileset.tile_of("ROADPIECES", index as u16 + 1)?; + Some((GridNo::from(target), tileset.tile(piece)?.clone())) + }) + .collect() +} + +/// The pieces of a road macro around a tile and the tiles they go to +/// +/// Pieces that would be off the map or wrap into another row are left out. +pub fn road_macro_pieces( + tileset: &Tileset, + grid_no: GridNo, + macro_id: u16, +) -> Vec<(GridNo, Arc)> { + let position = grid_no.position(); + pieces_at( + tileset, + i32::from(position.x), + i32::from(position.y), + macro_id, + ) +} + +/// All pieces of the road macro a piece on a tile belongs to, where the macro would have put them +/// +/// The clicked tile of the macro may be off the map. A piece outside the table only stands for +/// itself. +fn macro_of_piece( + tileset: &Tileset, + grid_no: GridNo, + piece: &Arc, +) -> Vec<(GridNo, Arc)> { + let entry = usize::from(piece.sub_index) + .checked_sub(1) + .and_then(|index| ROAD_MACROS.get(index)); + let Some(&(macro_id, offset)) = entry else { + return vec![(grid_no, piece.clone())]; + }; + let position = grid_no.position(); + let (dx, dy) = offset_to_xy(offset); + pieces_at( + tileset, + i32::from(position.x) - dx, + i32::from(position.y) - dy, + u16::from(macro_id), + ) +} + +/// The road pieces that erasing a tile removes: the pieces of the macros its road pieces belong +/// to that are still on the map +pub fn erased_road_pieces( + map: &TacticalMap, + tileset: &Tileset, + grid_no: GridNo, +) -> Vec<(GridNo, Arc)> { + let mut erased: Vec<(GridNo, Arc)> = vec![]; + let pieces = map.tiles[grid_no] + .objects + .iter() + .filter(|t| is_road_piece(t)); + for piece in pieces { + for (target, tile) in macro_of_piece(tileset, grid_no, piece) { + let on_map = map.tiles[target].objects.iter().any(|t| t.id == tile.id); + let known = erased.iter().any(|(g, t)| *g == target && t.id == tile.id); + if on_map && !known { + erased.push((target, tile)); + } + } + } + erased +} + +/// Places the pieces of a road macro around a tile (`PlaceRoadMacroAtGridNo`) +/// +/// Unlike the original, the road pieces are removed from the tiles the pieces go to instead of +/// the tiles with the index of the piece as grid number, and pieces that would be off the map or +/// wrap into another row are left out. +pub fn place_road_macro(context: &mut EditContext, grid_no: GridNo, macro_id: u16) { + for (target, piece) in road_macro_pieces(context.tileset, grid_no, macro_id) { + context.remove_objects_where(target, is_road_piece); + context.add_object_to_head(target, piece); + } +} + +/// Whether a tile of the objects is a road piece, which placing and erasing roads removes +pub fn is_road_piece(tile: &Tile) -> bool { + tile.type_name == "ROADPIECES" +} + +/// Removes the roads on a tile (`EraseMapTile` for roads) +/// +/// Unlike the original, which only removes the road pieces of the tile, this removes all pieces +/// of their road macros, so no partial roads are left behind. Pieces another macro replaced +/// stay. +pub fn erase_roads(context: &mut EditContext, grid_no: GridNo) { + for (target, piece) in erased_road_pieces(context.map, context.tileset, grid_no) { + context.remove_objects_where(target, |t| t.id == piece.id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + editor::document::tests::{grid, Fixture}, + file_formats::ja2_tile_types::{NUM_TILES_PER_TYPE, ROADPIECES}, + tileset::NUM_ROAD_MACROS, + }; + + /// Sub indices of the road pieces of a tile + fn pieces(fixture: &Fixture, grid_no: GridNo) -> Vec { + fixture.map.tiles[grid_no] + .objects + .iter() + .filter(|tile| tile.type_name == "ROADPIECES") + .map(|tile| tile.sub_index) + .collect() + } + + fn all_pieces(fixture: &Fixture) -> usize { + GridNo::all().map(|g| pieces(fixture, g).len()).sum() + } + + #[test] + fn the_table_matches_the_road_pieces() { + assert!(ROAD_MACROS.len() <= usize::from(NUM_TILES_PER_TYPE[ROADPIECES])); + // The pieces of each macro are together, in the order of the macros + let ids: Vec = ROAD_MACROS.iter().map(|&(id, _)| u16::from(id)).collect(); + assert!(ids.windows(2).all(|w| w[0] <= w[1])); + assert_eq!(ids.last(), Some(&(NUM_ROAD_MACROS - 1))); + assert_eq!(offset_to_xy(-2), (-2, 0)); + assert_eq!(offset_to_xy(-162), (-2, -1)); + assert_eq!(offset_to_xy(159), (-1, 1)); + assert_eq!(offset_to_xy(-159), (1, -1)); + assert_eq!(offset_to_xy(-802), (-2, -5)); + } + + #[test] + fn a_macro_places_its_pieces_around_the_tile() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + let rock = context.tile("ROCKS", 1).unwrap(); + context.add_object_to_head(grid(49, 49), rock); + place_road_macro(&mut context, grid(50, 50), u16::from(L1)); + // Placing it again replaces the pieces + place_road_macro(&mut context, grid(50, 50), u16::from(L1)); + context.finish(); + // L1 is the first macro: -2, -162, -322, -1, -161, -321, 0, -160, -320 + assert_eq!(pieces(&fixture, grid(48, 50)), [1]); + assert_eq!(pieces(&fixture, grid(48, 49)), [2]); + assert_eq!(pieces(&fixture, grid(48, 48)), [3]); + assert_eq!(pieces(&fixture, grid(50, 50)), [7]); + assert_eq!(pieces(&fixture, grid(50, 48)), [9]); + // Other objects stay + assert_eq!(fixture.map.tiles[grid(49, 49)].objects.len(), 2); + assert_eq!(pieces(&fixture, grid(49, 49)), [5]); + assert_eq!(all_pieces(&fixture), 9); + // Pieces of other macros follow the pieces of the ones before them + let mut context = fixture.context(); + place_road_macro(&mut context, grid(80, 80), u16::from(R1)); + context.finish(); + assert_eq!(pieces(&fixture, grid(78, 80)), [10]); + } + + #[test] + fn pieces_off_the_map_are_left_out() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + place_road_macro(&mut context, grid(1, 0), u16::from(L1)); + context.finish(); + // Only the row of the tile, and not the piece that would wrap into the row before + assert_eq!(pieces(&fixture, grid(0, 0)), [4]); + assert_eq!(pieces(&fixture, grid(1, 0)), [7]); + assert!(pieces(&fixture, grid(159, 0)).is_empty()); + assert_eq!(all_pieces(&fixture), 2); + + fixture.document.undo.end(); + let mut context = fixture.context(); + erase_roads(&mut context, grid(1, 0)); + context.finish(); + assert_eq!(all_pieces(&fixture), 0); + assert_eq!(fixture.document.undo.depth(), 2); + } + + #[test] + fn erasing_removes_the_whole_macro() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + let rock = context.tile("ROCKS", 1).unwrap(); + context.add_object_to_head(grid(49, 49), rock); + place_road_macro(&mut context, grid(50, 50), u16::from(L1)); + erase_roads(&mut context, grid(48, 49)); + context.finish(); + assert_eq!(all_pieces(&fixture), 0); + assert_eq!(fixture.map.tiles[grid(49, 49)].objects.len(), 1); + } + + #[test] + fn erasing_keeps_overlapping_macros() { + let mut fixture = Fixture::new(); + let mut context = fixture.context(); + place_road_macro(&mut context, grid(50, 50), u16::from(L1)); + // Replaces the pieces of L1 in the column 50 + place_road_macro(&mut context, grid(52, 50), u16::from(R1)); + context.finish(); + assert_eq!(all_pieces(&fixture), 15); + + let mut context = fixture.context(); + erase_roads(&mut context, grid(48, 49)); + context.finish(); + assert_eq!(all_pieces(&fixture), 9); + assert_eq!(pieces(&fixture, grid(50, 50)), [10]); + + // Erasing where R1 replaced L1 removes R1 + let mut context = fixture.context(); + place_road_macro(&mut context, grid(50, 50), u16::from(L1)); + place_road_macro(&mut context, grid(52, 50), u16::from(R1)); + erase_roads(&mut context, grid(50, 50)); + context.finish(); + assert_eq!(all_pieces(&fixture), 6); + assert!(pieces(&fixture, grid(52, 50)).is_empty()); + } +} diff --git a/src-map-editor/src/editor/rooms_overlay.rs b/src-map-editor/src/editor/rooms_overlay.rs new file mode 100644 index 0000000..97b0ec6 --- /dev/null +++ b/src-map-editor/src/editor/rooms_overlay.rs @@ -0,0 +1,160 @@ +//! The room numbers of the tiles (`RenderRoomInfo`), shown with the `roomNumbers` layer +//! +//! The numbers are text on the screen camera, so they stay sharp at every zoom. Only the tiles +//! in view get a label, and the labels follow the view. + +use std::collections::HashMap; + +use bevy::{camera::visibility::RenderLayers, prelude::*, window::PrimaryWindow}; + +use crate::{ + components::map::{game_to_world, JA2_TILE_HEIGHT, JA2_TILE_WIDTH}, + map::{GridNo, RoomNo, TacticalMap}, + render::{world::MapWorld, RenderOptions}, + states::{AppState, UiFont}, + systems::pixel_view::{PixelView, SCREEN_LAYER}, +}; + +/// Size of the numbers in logical pixels +const FONT_SIZE: f32 = 11.; +/// Labels of tiles this far outside of the view are kept, in world pixels +const VIEW_MARGIN: f32 = 40.; + +/// The labels that are shown, by their tiles +#[derive(Resource, Default)] +pub struct RoomLabels { + shown: HashMap, +} + +/// The text of a room number +#[derive(Component)] +pub struct RoomLabel { + /// Where the number is drawn, in world coordinates + position: Vec2, +} + +/// The colour of a room number, like the original picks it by the number modulo 5 +pub fn room_color(room: RoomNo) -> Color { + match room.number() % 5 { + 0 => Color::srgb(0.63, 0.63, 0.63), + 1 => Color::srgb(1., 1., 0.), + 2 => Color::srgb(1., 0.4, 0.4), + 3 => Color::srgb(0.45, 0.65, 1.), + _ => Color::srgb(0.45, 1., 0.45), + } +} + +/// The labels of a new map are not shown yet +pub fn reset_room_labels(mut labels: ResMut) { + *labels = RoomLabels::default(); +} + +/// The center of a tile in world coordinates, raised by its height +fn tile_center(world: &MapWorld, grid_no: GridNo) -> Vec2 { + let mut game = grid_no.position().tile_box() + IVec2::new(JA2_TILE_WIDTH, JA2_TILE_HEIGHT) / 2; + game.y -= i32::from(world.tiles[grid_no].height); + game_to_world(game) +} + +/// Spawns and removes the labels of the tiles in view, and moves them with the view +pub fn update_room_labels( + mut commands: Commands, + options: Res, + map: Res, + world: Res, + view: Res, + window: Single<&Window, With>, + font: Res, + mut labels: ResMut, + mut transforms: Query<(&RoomLabel, &mut Transform)>, +) { + if !options.layers.room_numbers { + for (_, (_, entity)) in labels.shown.drain() { + commands.entity(entity).despawn(); + } + return; + } + let size = window.physical_size().as_vec2(); + if map.is_changed() || view.is_changed() || options.is_changed() { + let corners = [Vec2::ZERO, size].map(|corner| view.screen_to_world(size, corner)); + let area = Rect::from_corners(corners[0], corners[1]).inflate(VIEW_MARGIN); + let wanted: HashMap = map + .tiles + .iter() + .filter_map(|(grid_no, tile)| Some((grid_no, tile.room?))) + .filter(|&(grid_no, _)| area.contains(tile_center(&world, grid_no))) + .collect(); + labels.shown.retain(|grid_no, (room, entity)| { + let keep = wanted.get(grid_no) == Some(room); + if !keep { + commands.entity(*entity).despawn(); + } + keep + }); + let text_font = font.text_font(FONT_SIZE * window.scale_factor()); + for (grid_no, room) in wanted { + if labels.shown.contains_key(&grid_no) { + continue; + } + let position = tile_center(&world, grid_no); + let entity = commands + .spawn(( + RoomLabel { position }, + Text2d::new(room.number().to_string()), + text_font.clone(), + TextColor(room_color(room)), + RenderLayers::layer(SCREEN_LAYER), + Transform::from_translation(view.world_to_screen(position).extend(10.)), + DespawnOnExit(AppState::Map), + )) + .id(); + labels.shown.insert(grid_no, (room, entity)); + } + } + // 1 unit of the screen camera is 1 physical pixel, above the map + for (label, mut transform) in &mut transforms { + let translation = view.world_to_screen(label.position).extend(10.); + if transform.translation != translation { + transform.translation = translation; + } + } +} + +#[cfg(test)] +mod tests { + use bevy::ecs::system::RunSystemOnce; + + use super::*; + use crate::{editor::document::tests::grid, tileset::TilesetId}; + + /// New labels must not show up at the center of the screen for a frame while scrolling + #[test] + fn labels_are_spawned_at_their_tiles() { + let grid_no = grid(40, 40); + let mut map = TacticalMap::empty(TilesetId::new(0)); + map.tiles[grid_no].room = RoomNo::new(3); + let world = MapWorld::new(false); + let position = tile_center(&world, grid_no); + let view = PixelView::new(position + Vec2::new(20., 10.), 2.); + let mut options = RenderOptions::default(); + options.layers.room_numbers = true; + + let mut app = World::new(); + app.init_resource::>(); + app.init_resource::(); + app.init_resource::(); + app.insert_resource(map); + app.insert_resource(world); + app.insert_resource(view); + app.insert_resource(options); + app.spawn((Window::default(), PrimaryWindow)); + app.run_system_once(update_room_labels).unwrap(); + + let transforms: Vec = app + .query_filtered::<&Transform, With>() + .iter(&app) + .map(|transform| transform.translation) + .collect(); + assert_eq!(transforms, [Vec3::new(-40., -20., 10.)]); + } +} diff --git a/src-map-editor/src/editor/save.rs b/src-map-editor/src/editor/save.rs new file mode 100644 index 0000000..1c77e06 --- /dev/null +++ b/src-map-editor/src/editor/save.rs @@ -0,0 +1,89 @@ +//! Saving the edited map for the host +//! +//! The map file that was loaded is still an asset. The edits are put into a copy of it, which is +//! written in the version it was loaded in, so everything the editor can't change stays as it was. +//! Edge points that are out of date are generated again first, like the original editor does +//! before it saves. + +use bevy::prelude::*; + +use crate::{ + file_formats::ja2_map::Ja2Map, + host::{finish_saves, save_requested, SavedMap}, + map::TacticalMap, + render::{movement_costs::MovementCosts, world::MapWorld, PendingSceneUpdate}, + states::{ + loading::{MapAssets, MapToLoad}, + AppState, + }, +}; + +use super::{document::EditorDocument, traversal_events::regenerate}; + +/// What generating the edge points needs +#[derive(bevy::ecs::system::SystemParam)] +pub struct EdgePointSources<'w> { + world: Option>, + costs: Option>, + pending: Option>, +} + +/// Saves the map if the host asked for it, after the edits requested before were applied +pub fn save_requested_map( + state: Res>, + map_to_load: Option>, + map: Option>, + mut document: ResMut, + map_assets: Option>, + mut maps: ResMut>, + sources: EdgePointSources, +) { + if !save_requested() { + return; + } + let (Some(map_to_load), Some(mut map), Some(map_assets), AppState::Map) = + (map_to_load, map, map_assets, state.get()) + else { + finish_saves(Err("No map is loaded".to_owned())); + return; + }; + if document.edge_points_dirty { + if let Some(mut world) = sources.world { + // The movement costs only include the last edits once the scene caught up with them + let caught_up = sources.pending.is_none_or(|pending| pending.is_empty()); + match sources.costs.filter(|_| caught_up) { + Some(costs) => regenerate(&mut map, &world, &costs, &mut document, &map_to_load.0), + None => { + let costs = MovementCosts::compile(&mut world, &map); + regenerate(&mut map, &world, &costs, &mut document, &map_to_load.0); + } + } + } + } + let result = save_map(&map, &map_assets.map, &mut maps).map(|data| SavedMap { + map: map_to_load.0.clone(), + revision: document.revision, + data, + }); + if let Err(error) = &result { + error!("Error saving {}: {}", map_to_load.0, error); + } + finish_saves(result); +} + +/// Puts the map into its map file and writes it, the map file asset is replaced by the saved one +fn save_map( + map: &TacticalMap, + handle: &Handle, + maps: &mut Assets, +) -> Result, String> { + let mut raw = maps + .get(handle) + .cloned() + .ok_or_else(|| "The map file is not loaded".to_owned())?; + map.apply_to_ja2(&mut raw)?; + let data = raw.to_bytes().map_err(|e| e.to_string())?; + // Later saves and reopening the map start from what was saved + maps.insert(handle, raw).map_err(|e| e.to_string())?; + Ok(data) +} diff --git a/src-map-editor/src/editor/schedules.rs b/src-map-editor/src/editor/schedules.rs new file mode 100644 index 0000000..6fa09ed --- /dev/null +++ b/src-map-editor/src/editor/schedules.rs @@ -0,0 +1,649 @@ +//! Schedules of civilians (the schedule mode of the original's Mercs tab, `EditorMercs.cc`) +//! +//! The host edits the 4 actions of the schedule of the selected soldier. An action that needs a +//! tile is not complete until the tiles are picked on the map: a click picks the tile next to the +//! door and then where the soldier goes (`RegisterCurrentScheduleAction`). The rows the host sees +//! are a draft that keeps the incomplete actions, the schedule of the map only has the complete +//! ones, like the game only loads those. Escape cancels a pick, an action whose tiles are missing +//! is removed then (`CancelCurrentScheduleAction`). + +use serde::{Deserialize, Serialize}; + +use crate::map::{ + GridNo, Level, PlacementId, Schedule, ScheduleAction, ScheduleEntry, ScheduleFlags, ScheduleId, + TacticalMap, +}; + +use super::{ + document::EditContext, + soldiers::{is_sittable, placement}, +}; + +/// The actions of a schedule (`SCHEDULE_ACTION_*`) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScheduleActionKind { + #[default] + None, + LockDoor, + UnlockDoor, + OpenDoor, + CloseDoor, + GridNo, + LeaveSector, + EnterSector, + StayInSector, + Sleep, + Wake, +} + +/// Which tiles an action needs +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActionTiles { + None, + /// The tile it goes to (A) + One, + /// The tile next to the door (A) and where it goes afterwards (B) + Door, +} + +impl ScheduleActionKind { + pub fn tiles(self) -> ActionTiles { + match self { + Self::LockDoor | Self::UnlockDoor | Self::OpenDoor | Self::CloseDoor => { + ActionTiles::Door + } + Self::GridNo | Self::EnterSector | Self::Sleep => ActionTiles::One, + Self::None | Self::LeaveSector | Self::StayInSector | Self::Wake => ActionTiles::None, + } + } + + /// The action of a schedule entry and its tiles + fn of(action: &ScheduleAction) -> (Self, Option, Option) { + match *action { + ScheduleAction::LockDoor { door, destination } => { + (Self::LockDoor, Some(door), Some(destination)) + } + ScheduleAction::UnlockDoor { door, destination } => { + (Self::UnlockDoor, Some(door), Some(destination)) + } + ScheduleAction::OpenDoor { door, destination } => { + (Self::OpenDoor, Some(door), Some(destination)) + } + ScheduleAction::CloseDoor { door, destination } => { + (Self::CloseDoor, Some(door), Some(destination)) + } + ScheduleAction::GridNo(grid_no) => (Self::GridNo, Some(grid_no), None), + ScheduleAction::LeaveSector => (Self::LeaveSector, None, None), + ScheduleAction::EnterSector(grid_no) => (Self::EnterSector, Some(grid_no), None), + ScheduleAction::StayInSector => (Self::StayInSector, None, None), + ScheduleAction::Sleep(grid_no) => (Self::Sleep, Some(grid_no), None), + ScheduleAction::Wake => (Self::Wake, None, None), + } + } + + /// The schedule action, `None` without an action or while tiles are missing + fn action(self, data1: Option, data2: Option) -> Option { + let door = || Some((data1?, data2?)); + Some(match self { + Self::None => return None, + Self::LockDoor => { + let (door, destination) = door()?; + ScheduleAction::LockDoor { door, destination } + } + Self::UnlockDoor => { + let (door, destination) = door()?; + ScheduleAction::UnlockDoor { door, destination } + } + Self::OpenDoor => { + let (door, destination) = door()?; + ScheduleAction::OpenDoor { door, destination } + } + Self::CloseDoor => { + let (door, destination) = door()?; + ScheduleAction::CloseDoor { door, destination } + } + Self::GridNo => ScheduleAction::GridNo(data1?), + Self::LeaveSector => ScheduleAction::LeaveSector, + Self::EnterSector => ScheduleAction::EnterSector(data1?), + Self::StayInSector => ScheduleAction::StayInSector, + Self::Sleep => ScheduleAction::Sleep(data1?), + Self::Wake => ScheduleAction::Wake, + }) + } +} + +/// An action of a schedule as the host edits it +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduleRow { + pub action: ScheduleActionKind, + /// Minutes since midnight, `None` without a time + pub time: Option, + /// The time varies by up to 15 minutes + pub variance: bool, + /// The tile next to the door or the tile it goes to (A) + pub data1: Option, + /// Where it goes after the door (B) + pub data2: Option, +} + +/// Minutes of a day +const MINUTES_PER_DAY: u16 = 24 * 60; + +impl ScheduleRow { + /// The row with only the values its action uses (`StartScheduleAction`) + fn normalized(self) -> Self { + let tiles = self.action.tiles(); + let grid = |data: Option| data.filter(|&d| GridNo::new(usize::from(d)).is_some()); + Self { + action: self.action, + time: self.time.filter(|&time| time < MINUTES_PER_DAY), + variance: self.variance, + data1: grid(self.data1).filter(|_| tiles != ActionTiles::None), + data2: grid(self.data2).filter(|_| tiles == ActionTiles::Door), + } + } + + fn grid(data: Option) -> Option { + GridNo::new(usize::from(data?)) + } + + /// The entry of the schedule, `None` without an action or while tiles are missing + fn entry(&self) -> Option { + let action = self + .action + .action(Self::grid(self.data1), Self::grid(self.data2))?; + Some(ScheduleEntry { + time: self.time, + action, + }) + } + + /// Whether the action still needs tiles + fn is_incomplete(&self) -> bool { + self.action != ScheduleActionKind::None && self.entry().is_none() + } +} + +/// Which tile a click picks +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SchedulePoint { + A, + B, +} + +/// The tile of an action that the next click picks +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SchedulePick { + /// The action, 0 to 3 + pub slot: usize, + pub point: SchedulePoint, + /// Only this tile is picked, otherwise picking the tile next to a door goes on with where + /// the soldier goes afterwards (`gfSingleAction`) + #[serde(default)] + pub single: bool, +} + +/// The variance flag of a slot +fn variance_flag(slot: usize) -> ScheduleFlags { + [ + ScheduleFlags::VARIANCE1, + ScheduleFlags::VARIANCE2, + ScheduleFlags::VARIANCE3, + ScheduleFlags::VARIANCE4, + ][slot] +} + +/// The rows of the schedule of a soldier, with the actions whose tiles are missing +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScheduleDraft { + pub placement: PlacementId, + pub rows: [ScheduleRow; 4], +} + +impl ScheduleDraft { + /// The rows of the schedule of a placement, `None` if it has no detailed placement + pub fn of(map: &TacticalMap, id: PlacementId) -> Option { + let detailed = placement(map, id)?.detailed.as_ref()?; + let schedule = detailed + .schedule + .and_then(|schedule| map.schedules.iter().find(|s| s.id == schedule)); + let rows = std::array::from_fn(|slot| { + let Some(schedule) = schedule else { + return ScheduleRow::default(); + }; + let variance = schedule.flags.contains(variance_flag(slot)); + match &schedule.actions[slot] { + Some(entry) => { + let (action, data1, data2) = ScheduleActionKind::of(&entry.action); + let raw = |grid: Option| grid.map(|g| g.index() as u16); + ScheduleRow { + action, + time: entry.time, + variance, + data1: raw(data1), + data2: raw(data2), + } + } + None => ScheduleRow { + variance, + ..Default::default() + }, + } + }); + Some(Self { + placement: id, + rows, + }) + } + + /// Whether the draft gives the schedule the map has, it is out of date otherwise, e.g. after + /// undo + pub fn matches(&self, map: &TacticalMap) -> bool { + let Some(current) = Self::of(map, self.placement) else { + return false; + }; + // Only the complete actions are in the map + let complete = |rows: &[ScheduleRow; 4]| { + rows.map(|row| { + let entry = row.entry(); + (entry, entry.is_some() && row.variance) + }) + }; + complete(&self.rows) == complete(¤t.rows) + } +} + +/// The first schedule id that is not used +fn free_schedule_id(map: &TacticalMap) -> Option { + (1..=u8::MAX) + .filter_map(ScheduleId::new) + .find(|id| map.schedules.iter().all(|s| s.id != *id)) +} + +impl EditContext<'_> { + /// Gives the soldier of the draft the schedule of its complete rows, or removes its schedule + /// if no row has an action (`ExtractAndUpdateMercSchedule`) + pub fn apply_schedule(&mut self, draft: &ScheduleDraft) { + let Some(soldier) = placement(self.map, draft.placement) else { + return; + }; + let Some(detailed) = &soldier.detailed else { + return; + }; + let current_id = detailed.schedule; + let current = current_id.and_then(|id| self.map.schedules.iter().find(|s| s.id == id)); + let has_actions = draft + .rows + .iter() + .any(|row| row.action != ScheduleActionKind::None); + if !has_actions { + if current_id.is_none() { + return; + } + let index = self + .map + .soldiers + .iter() + .position(|p| p.id == draft.placement); + let (soldiers, schedules) = self.modify_soldiers(); + if let Some(detailed) = index.and_then(|i| soldiers[i].detailed.as_mut()) { + detailed.schedule = None; + } + schedules.retain(|s| Some(s.id) != current_id); + return; + } + let Some(id) = current.map(|s| s.id).or_else(|| free_schedule_id(self.map)) else { + return; + }; + let mut flags = current.map_or(ScheduleFlags::empty(), |s| s.flags); + for (slot, row) in draft.rows.iter().enumerate() { + flags.set( + variance_flag(slot), + row.variance && row.action != ScheduleActionKind::None, + ); + } + let schedule = Schedule { + id, + soldier_id: current.map_or(0, |s| s.soldier_id), + flags, + actions: draft.rows.map(|row| row.entry()), + }; + if current == Some(&schedule) { + return; + } + let index = self + .map + .soldiers + .iter() + .position(|p| p.id == draft.placement); + let (soldiers, schedules) = self.modify_soldiers(); + match schedules.iter_mut().find(|s| s.id == id) { + Some(existing) => *existing = schedule, + None => schedules.push(schedule), + } + if let Some(detailed) = index.and_then(|i| soldiers[i].detailed.as_mut()) { + detailed.schedule = Some(id); + } + } +} + +/// The schedule draft of the selected soldier and the tile a click picks +#[derive(Clone, Debug, Default)] +pub struct ScheduleEditing { + pub draft: Option, + pub pick: Option, +} + +impl ScheduleEditing { + /// Follows the selected soldier and the map, e.g. after undo + pub fn sync(&mut self, map: &TacticalMap, selected: Option) { + let current = self + .draft + .as_ref() + .filter(|draft| Some(draft.placement) == selected && draft.matches(map)); + if current.is_none() { + self.draft = selected.and_then(|id| ScheduleDraft::of(map, id)); + self.pick = None; + } + } + + /// Sets the rows of the host, the action of a row decides which values it keeps + pub fn set_rows(&mut self, context: &mut EditContext, rows: [ScheduleRow; 4]) { + let Some(draft) = &mut self.draft else { + return; + }; + draft.rows = rows.map(ScheduleRow::normalized); + // A pick of an action that has no such tile any more ends + if let Some(pick) = self.pick { + let tiles = draft.rows[pick.slot].action.tiles(); + if tiles == ActionTiles::None + || (pick.point == SchedulePoint::B && tiles != ActionTiles::Door) + { + self.pick = None; + } + } + context.apply_schedule(draft); + } + + /// Starts picking a tile of an action that needs it (`StartScheduleAction`) + pub fn start_pick(&mut self, pick: SchedulePick) { + let Some(draft) = &self.draft else { + return; + }; + let Some(row) = draft.rows.get(pick.slot) else { + return; + }; + let allowed = matches!( + (row.action.tiles(), pick.point), + (ActionTiles::Door, _) | (ActionTiles::One, SchedulePoint::A) + ); + if allowed { + self.pick = Some(pick); + } + } + + /// Cancels the pick, the action is removed if it misses tiles (`CancelCurrentScheduleAction`) + pub fn cancel_pick(&mut self, context: &mut EditContext) { + let (Some(pick), Some(draft)) = (self.pick.take(), &mut self.draft) else { + return; + }; + if draft.rows[pick.slot].is_incomplete() { + draft.rows[pick.slot] = ScheduleRow::default(); + context.apply_schedule(draft); + } + } + + /// A click on a tile while a tile is picked, returns whether it was used + /// (`RegisterCurrentScheduleAction`) + pub fn click(&mut self, context: &mut EditContext, grid_no: GridNo) -> bool { + let (Some(pick), Some(draft)) = (self.pick, &mut self.draft) else { + return false; + }; + // Soldiers walk there, others can stand there already + if !is_sittable(context.map, context.world, grid_no, Level::Ground, false) { + return true; + } + let row = &mut draft.rows[pick.slot]; + let raw = Some(grid_no.index() as u16); + match pick.point { + SchedulePoint::A => row.data1 = raw, + SchedulePoint::B => row.data2 = raw, + } + self.pick = (pick.point == SchedulePoint::A + && row.action.tiles() == ActionTiles::Door + && !pick.single) + .then_some(SchedulePick { + point: SchedulePoint::B, + ..pick + }); + context.apply_schedule(draft); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::{ + document::tests::grid, + soldiers::tests::{mode, SoldierFixture}, + }; + + fn row(action: ScheduleActionKind, time: Option) -> ScheduleRow { + ScheduleRow { + action, + time, + ..Default::default() + } + } + + /// A detailed civilian, selected + fn civilian(fixture: &mut SoldierFixture) -> (PlacementId, ScheduleEditing) { + let civilians = mode(r#"{"mode":"soldiers","team":"civilian"}"#); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &civilians, grid(30, 30), Level::Ground)) + .unwrap(); + fixture.edit(|context, sc| context.set_detailed(sc, id, true)); + let mut editing = ScheduleEditing::default(); + editing.sync(fixture.map(), Some(id)); + (id, editing) + } + + fn schedule(fixture: &SoldierFixture, id: PlacementId) -> Option<&Schedule> { + let schedule = fixture.soldier(id).detailed.as_ref()?.schedule?; + fixture.map().schedules.iter().find(|s| s.id == schedule) + } + + #[test] + fn rows_are_json_for_the_host() { + let row: ScheduleRow = serde_json::from_str( + r#"{"action":"lockDoor","time":480,"variance":true,"data1":100,"data2":null}"#, + ) + .unwrap(); + assert_eq!(row.action, ScheduleActionKind::LockDoor); + assert_eq!(row.time, Some(480)); + let pick: SchedulePick = serde_json::from_str(r#"{"slot":2,"point":"b"}"#).unwrap(); + assert_eq!(pick.point, SchedulePoint::B); + assert!(!pick.single); + } + + #[test] + fn complete_actions_make_the_schedule() { + let mut fixture = SoldierFixture::new(); + let (id, mut editing) = civilian(&mut fixture); + assert_eq!( + editing.draft.as_ref().unwrap().rows, + [ScheduleRow::default(); 4] + ); + + let mut rows = [ScheduleRow::default(); 4]; + rows[0] = row(ScheduleActionKind::LeaveSector, Some(600)); + rows[0].variance = true; + rows[1] = row(ScheduleActionKind::Sleep, Some(1320)); + // Data that the action doesn't use is dropped + rows[2] = ScheduleRow { + data1: Some(5), + ..row(ScheduleActionKind::StayInSector, None) + }; + fixture.edit(|context, _| editing.set_rows(context, rows)); + let saved = schedule(&fixture, id).unwrap().clone(); + assert_eq!(saved.id, ScheduleId::new(1).unwrap()); + assert!(saved.flags.contains(ScheduleFlags::VARIANCE1)); + assert_eq!( + saved.actions[0], + Some(ScheduleEntry { + time: Some(600), + action: ScheduleAction::LeaveSector + }) + ); + // Sleeping needs a tile + assert_eq!(saved.actions[1], None); + assert_eq!( + saved.actions[2].map(|entry| entry.action), + Some(ScheduleAction::StayInSector) + ); + let draft = editing.draft.as_ref().unwrap(); + assert_eq!(draft.rows[1].action, ScheduleActionKind::Sleep); + assert_eq!(draft.rows[2].data1, None); + // The draft keeps the incomplete action while it matches the map + editing.sync(fixture.map(), Some(id)); + assert_eq!( + editing.draft.as_ref().unwrap().rows[1].action, + ScheduleActionKind::Sleep + ); + + // Without actions, the schedule is removed + fixture.edit(|context, _| editing.set_rows(context, [ScheduleRow::default(); 4])); + assert!(schedule(&fixture, id).is_none()); + assert!(fixture.map().schedules.is_empty()); + } + + #[test] + fn door_actions_pick_two_tiles() { + let mut fixture = SoldierFixture::new(); + let (id, mut editing) = civilian(&mut fixture); + let mut rows = [ScheduleRow::default(); 4]; + rows[1] = row(ScheduleActionKind::OpenDoor, Some(480)); + fixture.edit(|context, _| editing.set_rows(context, rows)); + // Picking B of an action without tiles or A of no action does nothing + editing.start_pick(SchedulePick { + slot: 0, + point: SchedulePoint::A, + single: false, + }); + assert_eq!(editing.pick, None); + editing.start_pick(SchedulePick { + slot: 1, + point: SchedulePoint::A, + single: false, + }); + fixture.edit(|context, _| assert!(editing.click(context, grid(40, 40)))); + // The door was picked, then where it goes + assert_eq!( + editing.pick, + Some(SchedulePick { + slot: 1, + point: SchedulePoint::B, + single: false + }) + ); + assert_eq!(schedule(&fixture, id).unwrap().actions[1], None); + fixture.edit(|context, _| assert!(editing.click(context, grid(41, 41)))); + assert_eq!(editing.pick, None); + assert_eq!( + schedule(&fixture, id).unwrap().actions[1], + Some(ScheduleEntry { + time: Some(480), + action: ScheduleAction::OpenDoor { + door: grid(40, 40), + destination: grid(41, 41) + } + }) + ); + // Without a pick, clicks are not used + fixture.edit(|context, _| assert!(!editing.click(context, grid(42, 42)))); + + // Picking only the door again + editing.start_pick(SchedulePick { + slot: 1, + point: SchedulePoint::A, + single: true, + }); + fixture.edit(|context, _| editing.click(context, grid(43, 43))); + assert_eq!(editing.pick, None); + let draft = editing.draft.as_ref().unwrap(); + assert_eq!(draft.rows[1].data1, Some(grid(43, 43).index() as u16)); + + // Undo brings back the door before, the draft follows + let command = fixture.fixture.document.undo.pop().unwrap(); + let mut context = fixture.fixture.context(); + context.restore(command); + context.finish(); + editing.sync(fixture.map(), Some(id)); + let draft = editing.draft.as_ref().unwrap(); + assert_eq!(draft.rows[1].data1, Some(grid(40, 40).index() as u16)); + } + + #[test] + fn cancelling_removes_actions_without_tiles() { + let mut fixture = SoldierFixture::new(); + let (id, mut editing) = civilian(&mut fixture); + let mut rows = [ScheduleRow::default(); 4]; + rows[0] = row(ScheduleActionKind::GridNo, Some(60)); + rows[3] = row(ScheduleActionKind::LeaveSector, Some(120)); + fixture.edit(|context, _| editing.set_rows(context, rows)); + editing.start_pick(SchedulePick { + slot: 0, + point: SchedulePoint::A, + single: false, + }); + fixture.edit(|context, _| editing.cancel_pick(context)); + assert_eq!(editing.pick, None); + let draft = editing.draft.as_ref().unwrap(); + assert_eq!(draft.rows[0], ScheduleRow::default()); + assert!(schedule(&fixture, id).unwrap().actions[3].is_some()); + + // Other soldiers have other drafts, selecting none has none + editing.sync(fixture.map(), None); + assert!(editing.draft.is_none()); + } + + #[test] + fn edited_schedules_are_saved() { + use crate::file_formats::ja2_map::{Ja2Map, Ja2MapElement}; + + let mut fixture = SoldierFixture::new(); + let (id, mut editing) = civilian(&mut fixture); + let mut rows = [ScheduleRow::default(); 4]; + rows[0] = row(ScheduleActionKind::GridNo, Some(900)); + rows[1] = row(ScheduleActionKind::LeaveSector, Some(60)); + rows[1].variance = true; + fixture.edit(|context, _| editing.set_rows(context, rows)); + editing.start_pick(SchedulePick { + slot: 0, + point: SchedulePoint::A, + single: false, + }); + fixture.edit(|context, _| editing.click(context, grid(50, 50))); + assert!(schedule(&fixture, id).unwrap().actions[0].is_some()); + + let mut raw = Ja2Map { + map_elements: vec![Ja2MapElement::default(); crate::components::map::JA2_MAP_TILES], + ..Default::default() + }; + fixture.map().apply_to_ja2(&mut raw).unwrap(); + let saved = &raw.schedules[0]; + // Sorted by time, the variance moves with the action + assert_eq!(saved.action[..2], [6, 5]); + assert_eq!(saved.time[..2], [60, 900]); + assert_eq!(saved.data1[1], grid(50, 50).index() as u16); + assert_eq!(saved.flags, ScheduleFlags::VARIANCE1.bits()); + assert_eq!(saved.soldier_id, 0); + let detailed = raw.placements[0].detailed.as_ref().unwrap(); + assert_eq!(detailed.schedule_id, saved.schedule_id); + } +} diff --git a/src-map-editor/src/editor/scroll_area.rs b/src-map-editor/src/editor/scroll_area.rs new file mode 100644 index 0000000..493a2e0 --- /dev/null +++ b/src-map-editor/src/editor/scroll_area.rs @@ -0,0 +1,53 @@ +//! The part of the map that the game can scroll over (`ubRestrictedScrollID`): it is drawn as a +//! white box, and the world is synced with it when it changes + +use bevy::prelude::*; + +use crate::{ + components::map::game_rect_to_world, + map::TacticalMap, + render::{ + movement_costs::{scrollable_area, MovementCosts}, + world::MapWorld, + PendingSceneUpdate, + }, +}; + +use super::document::EditorDocument; + +/// The world rectangle that the game can show of a map +pub fn scrollable_world_rect(restricted_scroll: bool) -> Rect { + let (min, max) = scrollable_area(restricted_scroll); + game_rect_to_world(min, max) +} + +/// Rebuilds the scene after the scroll restriction changed: the visible tiles, the movement +/// costs and the z values depend on it +pub fn sync_restricted_scroll( + mut document: ResMut, + map: Res, + mut world: ResMut, + mut costs: ResMut, + mut pending: ResMut, +) { + if !document.restricted_scroll_dirty { + return; + } + document.restricted_scroll_dirty = false; + if world.restricted_scroll == map.info.restricted_scroll { + return; + } + world.restricted_scroll = map.info.restricted_scroll; + *costs = MovementCosts::compile(&mut world, &map); + pending.full = true; +} + +/// Draws the part of the map that the game can show as a white box +pub fn draw_scrollable_area(mut gizmos: Gizmos, world: Res) { + let rect = scrollable_world_rect(world.restricted_scroll); + gizmos.rect_2d( + Isometry2d::from_translation(rect.center()), + rect.size(), + Color::WHITE, + ); +} diff --git a/src-map-editor/src/editor/selection.rs b/src-map-editor/src/editor/selection.rs new file mode 100644 index 0000000..61bac50 --- /dev/null +++ b/src-map-editor/src/editor/selection.rs @@ -0,0 +1,323 @@ +//! Selecting whole buildings with the `select` tool of the buildings mode +//! +//! Pressing the left button on a building selects it, pressing it on the ground clears the +//! selection. Dragging the building moves it (`MoveBuilding` of the original editor), dragging it +//! while Ctrl (Cmd on macOS) was held on the press copies it (`CopyBuilding` and `PasteBuilding`), and Delete +//! removes it (`KillBuilding`). Each of them is undone as a whole. Buildings aren't moved or +//! copied in basements, like in the original. + +use bevy::prelude::*; + +use crate::{ + map::{GridNo, TacticalMap}, + render::RenderOptions, + systems::cursor::CursorInfo, +}; + +use super::{ + building::kill_building, + commands::CurrentMode, + document::EditContext, + input::{area_outline, floor_outline}, + layout::{building_at, building_layout, drag_offset, paste_building, shifted_layout}, + walls::is_basement, + MapEdit, +}; + +/// A drag of the selected building in progress +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Drag { + /// Where the mouse was pressed + from: GridNo, + /// Whether Ctrl or Cmd was held on the press, which copies the building instead of moving it + copy: bool, +} + +/// The selected building +#[derive(Resource, Clone, Debug, Default)] +pub struct BuildingSelection { + /// Any tile of the selected building + selected: Option, + drag: Option, +} + +impl BuildingSelection { + /// Selects the building at a tile and starts dragging it, or clears the selection if there + /// is none + fn press(&mut self, map: &TacticalMap, tile: GridNo, ctrl: bool) { + if building_at(&map.tiles[tile]) { + self.selected = Some(tile); + self.drag = Some(Drag { + from: tile, + copy: ctrl, + }); + } else { + *self = Self::default(); + } + } + + /// Ends the drag at a tile, moving or copying the building there and selecting it + fn release(&mut self, context: &mut EditContext, to: GridNo) { + let Some(Drag { from, copy }) = self.drag.take() else { + return; + }; + if to == from || is_basement(context) { + return; + } + paste_building(context, from, to, !copy); + self.selected = Some(to); + } + + /// Removes the selected building + fn delete(&mut self, context: &mut EditContext) { + if let Some(selected) = self.selected.take() { + kill_building(context, selected); + } + self.drag = None; + } + + /// Drops the selection when the building is gone, e.g. after undo or edits with other modes + fn validate(&mut self, map: &TacticalMap) { + if self + .selected + .is_some_and(|selected| !building_at(&map.tiles[selected])) + { + *self = Self::default(); + } + } +} + +pub fn reset_selection(mut selection: ResMut) { + *selection = BuildingSelection::default(); +} + +pub fn select_buildings( + mouse: Res>, + keys: Res>, + cursor: Res, + mode: Res, + mut selection: ResMut, + mut edit: MapEdit, +) { + if !mode.0.selects_buildings() { + if selection.selected.is_some() || selection.drag.is_some() { + *selection = BuildingSelection::default(); + } + return; + } + let tile = cursor.tile.map(GridNo::from); + if let Some(tile) = tile.filter(|_| mouse.just_pressed(MouseButton::Left)) { + // Cmd on macOS, where Ctrl+click is a right click + let copy = keys.any_pressed([ + KeyCode::ControlLeft, + KeyCode::ControlRight, + KeyCode::SuperLeft, + KeyCode::SuperRight, + ]); + selection.press(&edit.map, tile, copy); + } + if selection.drag.is_some() { + if cursor.world_position.is_none() { + // Leaving the viewer cancels the drag, like the area brushes + selection.drag = None; + } else if !mouse.pressed(MouseButton::Left) { + match tile { + Some(to) => { + edit.document().undo.begin(); + edit.edit(|context| selection.release(context, to)); + edit.document().undo.end(); + } + None => selection.drag = None, + } + } + } + if selection.drag.is_none() && keys.any_just_pressed([KeyCode::Delete, KeyCode::Backspace]) { + edit.document().undo.begin(); + edit.edit(|context| selection.delete(context)); + edit.document().undo.end(); + } + selection.validate(&edit.map); +} + +/// Outlines the selected building in yellow, and on the floor where it is dragged to in green +/// and the building under the cursor faintly +pub fn draw_selection( + mut gizmos: Gizmos, + cursor: Res, + mode: Res, + options: Res, + selection: Res, + map: Res, +) { + if !mode.0.selects_buildings() { + return; + } + let mut lines = |lines: Vec<(Vec2, Vec2)>, color: Color| { + for (from, to) in lines { + gizmos.line_2d(from, to, color); + } + }; + let cursor_outline = |tiles: &[GridNo]| floor_outline(area_outline(tiles, &map), options.floor); + let selected = selection + .selected + .and_then(|selected| building_layout(&map, selected)); + if let Some(layout) = &selected { + lines(area_outline(layout, &map), Srgba::rgb(1., 0.85, 0.).into()); + } + let cursor = cursor.tile.map(GridNo::from); + match (selection.drag, cursor) { + (Some(drag), Some(cursor)) => { + let offset = drag_offset(drag.from, cursor); + if let (Some(layout), false) = (&selected, offset == (0, 0)) { + lines( + cursor_outline(&shifted_layout(layout, offset)), + Srgba::GREEN.into(), + ); + } + } + (None, Some(cursor)) => { + let hovered = selected + .as_ref() + .is_some_and(|layout| layout.contains(&cursor)); + if !hovered { + if let Some(layout) = building_layout(&map, cursor) { + lines(cursor_outline(&layout), Srgba::new(1., 1., 1., 0.35).into()); + } + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + editor::{ + building::tests::{add_room, building_fixture, rect, walls}, + document::tests::{grid, Fixture}, + }, + map::EditorSmoothing, + }; + + fn fixture() -> Fixture { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + fixture + } + + fn release(fixture: &mut Fixture, selection: &mut BuildingSelection, to: GridNo) { + fixture.document.undo.begin(); + let mut context = fixture.context(); + selection.release(&mut context, to); + context.finish(); + fixture.document.undo.end(); + } + + fn has_floor(fixture: &Fixture, x: u8, y: u8) -> bool { + fixture.map.tiles[grid(x, y)] + .land + .iter() + .any(|t| t.is_floor()) + } + + #[test] + fn pressing_selects_buildings_or_clears_the_selection() { + let fixture = fixture(); + let mut selection = BuildingSelection::default(); + selection.press(&fixture.map, grid(51, 51), false); + assert_eq!(selection.selected, Some(grid(51, 51))); + assert_eq!( + selection.drag, + Some(Drag { + from: grid(51, 51), + copy: false + }) + ); + selection.press(&fixture.map, grid(20, 20), false); + assert_eq!(selection.selected, None); + assert_eq!(selection.drag, None); + } + + #[test] + fn dragging_moves_the_building() { + let mut fixture = fixture(); + let mut selection = BuildingSelection::default(); + selection.press(&fixture.map, grid(51, 51), false); + release(&mut fixture, &mut selection, grid(51, 61)); + assert!(walls(&fixture, 51, 49).is_empty()); + assert!(!has_floor(&fixture, 51, 51)); + assert_eq!(walls(&fixture, 51, 59).len(), 1); + assert!(has_floor(&fixture, 51, 61)); + assert_eq!(selection.selected, Some(grid(51, 61))); + assert_eq!(selection.drag, None); + // Releasing where it was pressed changes nothing + let depth = fixture.document.undo.depth(); + selection.press(&fixture.map, grid(51, 61), false); + release(&mut fixture, &mut selection, grid(51, 61)); + assert!(has_floor(&fixture, 51, 61)); + assert_eq!(fixture.document.undo.depth(), depth); + } + + #[test] + fn ctrl_dragging_copies_the_building() { + let mut fixture = fixture(); + let mut selection = BuildingSelection::default(); + selection.press(&fixture.map, grid(51, 51), true); + release(&mut fixture, &mut selection, grid(61, 51)); + assert_eq!(walls(&fixture, 61, 49).len(), 1); + assert_eq!(walls(&fixture, 51, 49).len(), 1); + assert!(has_floor(&fixture, 51, 51)); + assert!(has_floor(&fixture, 61, 51)); + assert_eq!(selection.selected, Some(grid(61, 51))); + } + + #[test] + fn buildings_are_not_dragged_in_basements() { + let mut fixture = fixture(); + fixture.map.info.editor_smoothing = EditorSmoothing::Basement; + let mut selection = BuildingSelection::default(); + selection.press(&fixture.map, grid(51, 51), false); + release(&mut fixture, &mut selection, grid(51, 61)); + assert!(has_floor(&fixture, 51, 51)); + assert!(!has_floor(&fixture, 51, 61)); + assert_eq!(selection.selected, Some(grid(51, 51))); + } + + #[test] + fn delete_removes_the_selected_building() { + let mut fixture = fixture(); + let mut selection = BuildingSelection::default(); + // Nothing selected + let mut context = fixture.context(); + selection.delete(&mut context); + context.finish(); + assert!(has_floor(&fixture, 51, 51)); + + selection.press(&fixture.map, grid(51, 51), false); + let mut context = fixture.context(); + selection.delete(&mut context); + context.finish(); + assert!((50..=52).all(|x| (50..=52).all(|y| !has_floor(&fixture, x, y)))); + assert_eq!(selection.selected, None); + assert_eq!(selection.drag, None); + } + + #[test] + fn the_selection_is_dropped_when_the_building_is_gone() { + let mut fixture = fixture(); + let mut selection = BuildingSelection::default(); + selection.press(&fixture.map, grid(51, 51), false); + release(&mut fixture, &mut selection, grid(51, 61)); + selection.validate(&fixture.map); + assert_eq!(selection.selected, Some(grid(51, 61))); + // Undoing the move + let command = fixture.document.undo.pop().unwrap(); + let mut context = fixture.context(); + context.restore(command); + context.finish(); + assert!(has_floor(&fixture, 51, 51)); + selection.validate(&fixture.map); + assert_eq!(selection.selected, None); + } +} diff --git a/src-map-editor/src/editor/smart.rs b/src-map-editor/src/editor/smart.rs new file mode 100644 index 0000000..59fe7b2 --- /dev/null +++ b/src-map-editor/src/editor/smart.rs @@ -0,0 +1,540 @@ +//! The smart method of the buildings mode (`SmartMethod.cc`): walls that pick their piece from +//! the walls around them, and doors, windows and broken walls that replace the walls of a tile +//! +//! The original computes the sub indices of the doors, windows and broken walls from the wall +//! orientation, which has been wrong since the orientations became bit flags. Here the tiles are +//! looked up by their building piece and orientation. + +use std::sync::Arc; + +use crate::{ + map::GridNo, + tileset::{BuildingPiece, Tile, WallPiece}, +}; + +use super::{ + building::Materials, + document::{EditContext, WallNode}, + walls::{ + at, build_wall_piece, change_vertical_wall, erase_horizontal_wall, erase_vertical_wall, + erase_walls, has_floor, horizontal_wall, horizontal_wall_type, restore_walls, + vertical_wall, vertical_wall_type, wall_type, WallSide, + }, +}; + +/// Number of wall styles, `FIRSTWALL` to `LASTWALL` +const WALL_STYLES: usize = 4; + +/// What the smart broken walls method places +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SmartBrokenWall { + /// A broken, cracked or smudged wall piece + Piece(WallPiece), + /// Removes the wall + Hole, +} + +/// Places a wall piece of a style for a floor tile that may be off the map +fn build(context: &mut EditContext, grid_no: Option, side: WallSide, style: u8) { + if let Some(grid_no) = grid_no { + build_wall_piece(context, grid_no, side, style); + } +} + +/// Places a wall of a style, vertical or horizontal depending on the walls around it +/// (`PasteSmartWall`) +/// +/// The rules of the original, in this order: +/// - A: a tile with a vertical and a horizontal wall stays. +/// - B: on a horizontal wall of the style, a vertical piece is added if the wall above continues +/// down to it. +/// - C: on a vertical wall of the style, a horizontal piece is added if the wall to the left +/// continues to it. +/// - D and E: the walls of the style around the tile decide between a vertical and a horizontal +/// piece, the corners are fixed up. +/// - F: a wall of the style below or to the right is connected with two pieces. +/// +/// Whether a piece is seen from inside depends on the floors next to it. +pub fn paste_smart_wall(context: &mut EditContext, g: GridNo, style: u8, materials: &Materials) { + let w = Some(style); + let vwall = |context: &EditContext, dx, dy| vertical_wall(context, at(g, dx, dy)).is_some(); + let hwall = |context: &EditContext, dx, dy| horizontal_wall(context, at(g, dx, dy)).is_some(); + let floor = |context: &EditContext, dx, dy| has_floor(context, at(g, dx, dy)); + + // A + if vwall(context, 0, 0) && hwall(context, 0, 0) { + return; + } + // B + if let Some(horizontal) = horizontal_wall_type(context, Some(g), materials) { + if Some(horizontal) != w { + return; + } + let inside = floor(context, 1, 0); + let side = if inside { + WallSide::ExteriorRight + } else { + WallSide::InteriorRight + }; + if vertical_wall_type(context, at(g, 0, -1), materials) == w { + build_wall_piece(context, g, side, style); + return; + } + if horizontal_wall_type(context, at(g, 0, -1), materials) == w { + build_wall_piece(context, g, side, style); + if !hwall(context, 1, -1) { + let piece = if inside { + WallPiece::InteriorExtended + } else { + WallPiece::ExteriorExtended + }; + change_vertical_wall(context, Some(g), piece); + } + } + return; + } + // C + if let Some(vertical) = vertical_wall_type(context, Some(g), materials) { + if Some(vertical) == w && horizontal_wall_type(context, at(g, -1, 0), materials) == w { + let side = if floor(context, 0, 1) { + WallSide::ExteriorBottom + } else { + WallSide::InteriorBottom + }; + build_wall_piece(context, g, side, style); + } + return; + } + // D: the walls around the tile weigh towards vertical or horizontal pieces + let mut vertical_weights = [0u16; WALL_STYLES]; + let mut horizontal_weights = [0u16; WALL_STYLES]; + // Both walls of the tiles to the left and above count + let neighbours = [ + (at(g, -1, 0), &mut horizontal_weights), + (at(g, 0, -1), &mut vertical_weights), + ]; + for (neighbour, weights) in neighbours { + for style in [ + vertical_wall_type(context, neighbour, materials), + horizontal_wall_type(context, neighbour, materials), + ] + .into_iter() + .flatten() + { + if let Some(weight) = weights.get_mut(usize::from(style)) { + *weight += 1; + } + } + } + if let Some(style) = horizontal_wall_type(context, at(g, 1, 0), materials) { + if let Some(weight) = horizontal_weights.get_mut(usize::from(style)) { + *weight += 1; + } + } + if let Some(style) = vertical_wall_type(context, at(g, 0, 1), materials) { + if let Some(weight) = vertical_weights.get_mut(usize::from(style)) { + *weight += 1; + } + } + // E: the weights of the style decide + let index = usize::from(style); + let vertical_weight = vertical_weights.get(index).copied().unwrap_or(0); + let horizontal_weight = horizontal_weights.get(index).copied().unwrap_or(0); + if vertical_weight > 0 || horizontal_weight > 0 { + if vertical_weight >= horizontal_weight { + let inside = floor(context, 1, 0); + let (side, extended) = if inside { + (WallSide::ExteriorRight, WallPiece::InteriorExtended) + } else { + (WallSide::InteriorRight, WallPiece::ExteriorExtended) + }; + build_wall_piece(context, g, side, style); + // A new top right corner is covered by an extended piece + if hwall(context, 0, -1) && !hwall(context, 1, -1) && !vwall(context, 0, -1) { + change_vertical_wall(context, Some(g), extended); + } else if hwall(context, 0, -1) && !hwall(context, -1, -1) && !vwall(context, -1, -1) { + change_vertical_wall(context, Some(g), extended); + erase_horizontal_wall(context, at(g, 0, -1)); + } + } else { + if vwall(context, -1, 0) && !vwall(context, -1, -1) && !hwall(context, -1, -1) { + erase_vertical_wall(context, at(g, -1, 0)); + } + let inside = floor(context, 0, 1); + let (side, extended, bottom_end) = if inside { + ( + WallSide::ExteriorBottom, + WallPiece::InteriorExtended, + WallPiece::InteriorBottomEnd, + ) + } else { + ( + WallSide::InteriorBottom, + WallPiece::ExteriorExtended, + WallPiece::ExteriorBottomEnd, + ) + }; + build_wall_piece(context, g, side, style); + if vwall(context, 0, 1) { + change_vertical_wall(context, at(g, 0, 1), extended); + } + if vwall(context, -1, 1) && !vwall(context, -1, 0) { + change_vertical_wall(context, at(g, -1, 1), extended); + } else if vwall(context, -1, 0) && !vwall(context, -1, 1) && context.has_floor(g) { + change_vertical_wall(context, at(g, -1, 0), bottom_end); + } + } + return; + } + // F: connect to a wall of the style below or to the right + if horizontal_wall_type(context, at(g, 0, 1), materials) == w { + if !hwall(context, -1, 1) { + erase_horizontal_wall(context, at(g, 0, 1)); + } + let inside = floor(context, 1, 0); + let side = if inside { + WallSide::ExteriorRight + } else { + WallSide::InteriorRight + }; + build(context, at(g, 0, 1), side, style); + build_wall_piece(context, g, side, style); + // Overrides the smoothing of the pieces + let piece = match (inside, !vwall(context, 0, 2), floor(context, 1, 2)) { + (true, true, true) => WallPiece::InteriorBottomEnd, + (true, _, _) => WallPiece::InteriorRight, + (false, true, false) => WallPiece::ExteriorBottomEnd, + (false, _, _) => WallPiece::ExteriorRight, + }; + change_vertical_wall(context, at(g, 0, 1), piece); + return; + } + if vertical_wall_type(context, at(g, 1, 0), materials) == w { + let inside = floor(context, 0, 1); + let (side, extended, bottom_end) = if inside { + ( + WallSide::ExteriorBottom, + WallPiece::InteriorExtended, + WallPiece::InteriorBottomEnd, + ) + } else { + ( + WallSide::InteriorBottom, + WallPiece::ExteriorExtended, + WallPiece::ExteriorBottomEnd, + ) + }; + build(context, at(g, 1, 0), side, style); + build_wall_piece(context, g, side, style); + if !vwall(context, 1, -1) { + erase_vertical_wall(context, at(g, 1, 0)); + change_vertical_wall(context, at(g, 1, 1), extended); + } + if !vwall(context, 1, 1) { + let piece = if !hwall(context, 1, -1) && !vwall(context, 1, -1) && hwall(context, 2, -1) + { + extended + } else { + bottom_end + }; + change_vertical_wall(context, at(g, 1, 0), piece); + } + } +} + +/// The tile of a building piece of a style with the orientation of a wall +fn matching_tile( + context: &EditContext, + style: u8, + piece: BuildingPiece, + wall: &WallNode, +) -> Option> { + let orientation = wall.tile.wall_orientation(); + context + .tileset + .building_tiles(style, piece) + .iter() + .find(|tile| tile.wall_orientation() == orientation) + .cloned() +} + +/// Replaces the vertical and the horizontal wall of a tile by what `replacement` returns for +/// them, `None` removes the wall +fn replace_walls( + context: &mut EditContext, + grid_no: GridNo, + replacement: impl Fn(&mut EditContext, &WallNode) -> Option>>, +) { + for vertical in [true, false] { + let wall = if vertical { + context.vertical_wall(grid_no) + } else { + context.horizontal_wall(grid_no) + }; + let Some(wall) = wall else { + continue; + }; + match replacement(context, &wall) { + Some(Some(tile)) => context.replace_struct(grid_no, wall.index, tile), + Some(None) => context.remove_struct(grid_no, wall.index), + None => {} + } + } +} + +/// Replaces the walls of a tile by doors of a style with the same orientation (`PasteSmartDoor`) +pub fn paste_smart_door(context: &mut EditContext, grid_no: GridNo, style: u8, open: bool) { + replace_walls(context, grid_no, |context, wall| { + matching_tile(context, style, BuildingPiece::Door { open }, wall).map(Some) + }); +} + +/// Replaces the walls of a tile by windows of their wall style (`PasteSmartWindow`) +pub fn paste_smart_window( + context: &mut EditContext, + grid_no: GridNo, + window: u8, + materials: &Materials, +) { + replace_walls(context, grid_no, |context, wall| { + let style = wall_type(context, wall, grid_no, materials)?; + let piece = BuildingPiece::Wall(WallPiece::Window { style: window }); + matching_tile(context, style, piece, wall).map(Some) + }); +} + +/// Replaces the walls of a tile by broken, cracked or smudged walls of their style, or removes +/// them for a hole (`PasteSmartBrokenWall`) +pub fn paste_smart_broken_wall( + context: &mut EditContext, + grid_no: GridNo, + broken: SmartBrokenWall, + materials: &Materials, +) { + replace_walls(context, grid_no, |context, wall| match broken { + SmartBrokenWall::Hole => Some(None), + SmartBrokenWall::Piece(piece) => { + let style = wall_type(context, wall, grid_no, materials)?; + matching_tile(context, style, BuildingPiece::Wall(piece), wall).map(Some) + } + }); +} + +/// Whether a wall or door is something the smart method places instead of a plain wall +fn replaces_wall(wall: &WallNode) -> bool { + wall.tile.building.is_some_and(|b| { + matches!( + b.piece, + BuildingPiece::Door { .. } + | BuildingPiece::Wall( + WallPiece::Window { .. } + | WallPiece::Broken { .. } + | WallPiece::Cracked + | WallPiece::Smudged + ) + ) + }) +} + +/// Erasing with the smart method: doors, windows and broken walls become plain walls again, +/// plain walls are removed and tiles without walls stay as they are +pub fn erase_smart(context: &mut EditContext, grid_no: GridNo, materials: &Materials) { + let vertical = context.vertical_wall(grid_no); + let horizontal = context.horizontal_wall(grid_no); + if vertical.iter().chain(&horizontal).any(replaces_wall) { + restore_walls(context, grid_no, materials); + } else if vertical.is_some() || horizontal.is_some() { + erase_walls(context, grid_no); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + editor::{ + building::tests::{add_room, building_fixture, materials, rect, walls}, + building_mode::paste_single, + document::tests::{grid, Fixture}, + }, + tileset::Orientation, + }; + + fn place(fixture: &mut Fixture, x: u8, y: u8, name: &str, sub_index: u16) { + let mut context = fixture.context(); + let tile = context.tile(name, sub_index).unwrap(); + paste_single(&mut context, grid(x, y), tile); + context.finish(); + } + + fn smart_wall(fixture: &mut Fixture, x: u8, y: u8, style: u8) { + let materials = materials(fixture, &[]); + let mut context = fixture.context(); + paste_smart_wall(&mut context, grid(x, y), style, &materials); + context.finish(); + } + + /// `(type, sub index, orientation)` of the structs of a tile + fn structs(fixture: &Fixture, x: u8, y: u8) -> Vec<(String, u16, Orientation)> { + let mut structs: Vec<_> = fixture.map.tiles[grid(x, y)] + .structs + .iter() + .map(|t| (t.type_name.clone(), t.sub_index, t.wall_orientation())) + .collect(); + structs.sort_by_key(|(name, sub_index, _)| (name.clone(), *sub_index)); + structs + } + + #[test] + fn smart_walls_continue_the_walls_of_their_style() { + let mut fixture = building_fixture(); + // A horizontal wall continues horizontally + place(&mut fixture, 60, 60, "WALL1", 10); + smart_wall(&mut fixture, 61, 60, 0); + assert_eq!(walls(&fixture, 61, 60), [(WallPiece::ExteriorLeft, true)]); + // A vertical wall continues vertically + place(&mut fixture, 70, 60, "WALL1", 7); + smart_wall(&mut fixture, 70, 61, 0); + assert_eq!(walls(&fixture, 70, 61), [(WallPiece::ExteriorRight, false)]); + // Inside of a building, the pieces are seen from inside + let mut context = fixture.context(); + let floor = context.tile("FLOOR1", 1).unwrap(); + context.add_land_to_head(grid(71, 62), floor); + context.finish(); + smart_wall(&mut fixture, 70, 62, 0); + let (piece, horizontal) = walls(&fixture, 70, 62)[0]; + assert!(!horizontal); + assert!( + matches!( + piece, + WallPiece::InteriorRight | WallPiece::InteriorExtended + ), + "{piece:?}" + ); + + // Walls of other styles don't count + place(&mut fixture, 80, 60, "WALL2", 10); + smart_wall(&mut fixture, 81, 60, 0); + assert!(walls(&fixture, 81, 60).is_empty()); + // A tile with both walls stays + place(&mut fixture, 90, 60, "WALL1", 10); + place(&mut fixture, 90, 60, "WALL1", 7); + smart_wall(&mut fixture, 90, 60, 0); + assert_eq!(walls(&fixture, 90, 60).len(), 2); + } + + #[test] + fn smart_walls_connect_to_walls_below() { + let mut fixture = building_fixture(); + // A horizontal wall below: two vertical pieces lead down to it + place(&mut fixture, 60, 61, "WALL1", 10); + smart_wall(&mut fixture, 60, 60, 0); + assert!(walls(&fixture, 60, 60).iter().any(|&(_, h)| !h)); + assert!(walls(&fixture, 60, 61).iter().any(|&(_, h)| !h)); + } + + #[test] + fn smart_doors_windows_and_broken_walls_keep_the_orientation() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let materials = materials(&fixture, &[]); + let right = (52, 51); + let top = (51, 49); + assert_eq!( + structs(&fixture, right.0, right.1)[0].2, + Orientation::OUTSIDE_RIGHT + ); + assert_eq!( + structs(&fixture, top.0, top.1)[0].2, + Orientation::INSIDE_LEFT + ); + + let mut context = fixture.context(); + paste_smart_door(&mut context, grid(right.0, right.1), 1, false); + paste_smart_door(&mut context, grid(top.0, top.1), 2, true); + context.finish(); + assert_eq!( + structs(&fixture, right.0, right.1), + [("DOOR2".to_owned(), 1, Orientation::OUTSIDE_RIGHT)] + ); + assert_eq!( + structs(&fixture, top.0, top.1), + [("DOOR3".to_owned(), 20, Orientation::INSIDE_LEFT)] + ); + + // Windows take the style of the nearest wall for doors + let mut context = fixture.context(); + paste_smart_window(&mut context, grid(right.0, right.1), 2, &materials); + paste_smart_window(&mut context, grid(50, 49), 1, &materials); + context.finish(); + assert_eq!( + structs(&fixture, right.0, right.1), + [("WALL1".to_owned(), 47, Orientation::OUTSIDE_RIGHT)] + ); + assert_eq!( + structs(&fixture, 50, 49), + [("WALL1".to_owned(), 37, Orientation::INSIDE_LEFT)] + ); + + let mut context = fixture.context(); + let cracked = SmartBrokenWall::Piece(WallPiece::Cracked); + paste_smart_broken_wall(&mut context, grid(right.0, right.1), cracked, &materials); + let broken = SmartBrokenWall::Piece(WallPiece::Broken { variant: 1 }); + paste_smart_broken_wall(&mut context, grid(52, 50), broken, &materials); + paste_smart_broken_wall( + &mut context, + grid(52, 49), + SmartBrokenWall::Hole, + &materials, + ); + context.finish(); + assert_eq!( + structs(&fixture, right.0, right.1), + [("WALL1".to_owned(), 63, Orientation::OUTSIDE_RIGHT)] + ); + assert_eq!( + structs(&fixture, 52, 50), + [("WALL1".to_owned(), 51, Orientation::OUTSIDE_RIGHT)] + ); + assert!(structs(&fixture, 52, 49).is_empty()); + } + + #[test] + fn restoring_replaces_doors_by_walls() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let materials = materials(&fixture, &[]); + let mut context = fixture.context(); + paste_smart_door(&mut context, grid(52, 51), 0, false); + restore_walls(&mut context, grid(52, 51), &materials); + context.finish(); + let structs = structs(&fixture, 52, 51); + assert_eq!(structs.len(), 1); + assert_eq!(structs[0].0, "WALL1"); + assert!(structs[0].2.is_vertical()); + } + + #[test] + fn erasing_restores_doors_and_removes_walls() { + let mut fixture = building_fixture(); + add_room(&mut fixture, rect(50, 50, 52, 52)); + let materials = materials(&fixture, &[]); + let mut context = fixture.context(); + paste_smart_door(&mut context, grid(52, 51), 0, false); + erase_smart(&mut context, grid(52, 51), &materials); + context.finish(); + // The door went back to a wall + let restored = structs(&fixture, 52, 51); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].0, "WALL1"); + assert!(restored[0].2.is_vertical()); + + let mut context = fixture.context(); + erase_smart(&mut context, grid(52, 51), &materials); + // Next to the walls of the room, but without walls itself + erase_smart(&mut context, grid(53, 51), &materials); + context.finish(); + assert!(structs(&fixture, 52, 51).is_empty()); + assert!(structs(&fixture, 53, 51).is_empty()); + } +} diff --git a/src-map-editor/src/editor/smooth.rs b/src-map-editor/src/editor/smooth.rs new file mode 100644 index 0000000..944f569 --- /dev/null +++ b/src-map-editor/src/editor/smooth.rs @@ -0,0 +1,371 @@ +//! Smoothing of the ground textures (`Smooth.cc`) +//! +//! Each texture has full tiles (sub indices 1 to 10) and edge tiles that blend it with the +//! texture below. After a texture is painted, the tiles around it get the edge tile that matches +//! which of their neighbours have the texture. + +use std::sync::Arc; + +use crate::{ + components::map::{GridPosition, JA2_MAP_TILES}, + map::GridNo, + tileset::{Tile, TileCategory}, +}; + +use super::document::EditContext; + +/// Edge tiles of the land textures (`gbSmoothStruct`) +/// +/// Each row is: the sum of the keys of the neighbours without the texture (3 above, 5 right, +/// 7 below, 11 left), the number of variations, and the sub indices of the variations. The last +/// two columns are unused. +const SMOOTH_STRUCT: [[i16; 6]; 15] = [ + [3, 2, 12, 27, 12, 0], + [5, 2, 15, 30, 39, 0], + [7, 2, 17, 32, 41, 0], + [11, 2, 14, 29, 14, 0], + [8, 2, 13, 28, 38, 0], + [15, 1, 19, 0, 43, 0], + [26, 1, 20, 0, 44, 0], + [12, 2, 18, 33, 42, 0], + [23, 1, 21, 0, 45, 0], + [18, 2, 16, 31, 40, 0], + [21, 1, 22, 0, 46, 0], + [14, 2, 11, 26, 11, 0], + [19, 1, 23, 0, 47, 0], + [16, 2, 24, 34, 48, 0], + [10, 2, 25, 35, 49, 0], +]; + +/// Shore tiles of the water (`gbSmoothWaterStruct`) +/// +/// Each row is: the bits of the tiles around that have water, the number of variations, and the +/// sub indices of the variations. Some bit values appear more than once, the first row wins. +const SMOOTH_WATER_STRUCT: [[i16; 4]; 37] = [ + [1020, 1, 11, 0], + [1000, 1, 12, 0], + [510, 2, 13, 43], + [190, 1, 14, 0], + [894, 1, 15, 0], + [622, 1, 16, 0], + [1014, 2, 17, 41], + [944, 1, 18, 24], + [872, 1, 19, 0], + [992, 1, 20, 0], + [62, 1, 21, 0], + [190, 2, 22, 14], + [620, 1, 23, 0], + [944, 1, 24, 18], + [878, 2, 25, 32], + [434, 1, 28, 0], + [110, 1, 29, 0], + [1010, 1, 30, 0], + [876, 2, 31, 32], + [878, 2, 32, 31], + [1004, 2, 32, 31], + [1006, 2, 33, 34], + [1008, 2, 34, 33], + [1016, 2, 33, 34], + [126, 2, 35, 36], + [254, 2, 35, 26], + [638, 2, 36, 26], + [438, 2, 38, 27], + [446, 2, 37, 38], + [950, 2, 37, 27], + [864, 1, 39, 0], + [1040, 1, 40, 0], + [1014, 2, 41, 17], + [432, 1, 42, 0], + [510, 1, 43, 13], + [54, 1, 44, 0], + [108, 1, 45, 0], +]; + +const WORLD_COLS: i32 = 160; +const WORLD_MAX: i32 = JA2_MAP_TILES as i32; + +/// Whether the tile at a grid number (with the game's arithmetic) has land of a type +fn has_type(context: &EditContext, grid_no: i32, type_name: &str) -> bool { + GridNo::from_i32(grid_no) + .is_some_and(|grid_no| context.find_type_in_land(grid_no, type_name).is_some()) +} + +/// Picks a variation of a smoothing table row, `None` if the tile already is one of them +fn pick_variation(context: &mut EditContext, row: &[i16], old_sub_index: u16) -> Option { + let same_tile = i32::from(row[2]) == i32::from(old_sub_index) + || (row[3] != 0 && i32::from(row[3]) == i32::from(old_sub_index)); + if same_tile { + return None; + } + let variation = context.random(row[1] as u32) as usize; + Some(row[2 + variation] as u16) +} + +/// The tile that the land of a type should become, `None` if it stays (`SmoothTerrain`) +/// +/// Full tiles are only smoothed if `force` is set. +pub fn smooth_terrain( + context: &mut EditContext, + grid_no: GridNo, + type_name: &str, + force: bool, +) -> Option> { + // Deep water is smoothed like land, like in the original + if context.category_of(type_name) == Some(TileCategory::Water) { + return smooth_water_terrain(context, grid_no, type_name, true); + } + let index = context.find_type_in_land(grid_no, type_name)?; + let old_sub_index = context.land(grid_no)[index].sub_index; + let full_tile = (1..=10).contains(&old_sub_index); + if !force && full_tile { + return None; + } + + let g = grid_no.index() as i32; + let mut key = 0; + if g - WORLD_COLS >= 0 && !has_type(context, g - WORLD_COLS, type_name) { + key += 3; + } + if (g + 1) % WORLD_COLS != 0 && !has_type(context, g + 1, type_name) { + key += 5; + } + if g + WORLD_COLS < WORLD_MAX && !has_type(context, g + WORLD_COLS, type_name) { + key += 7; + } + if g % WORLD_COLS != 0 && !has_type(context, g - 1, type_name) { + key += 11; + } + + let sub_index = match SMOOTH_STRUCT.iter().find(|row| row[0] == key) { + Some(row) => pick_variation(context, row, old_sub_index)?, + None if full_tile => return None, + // A tile surrounded by its texture becomes a random full tile + None => 1 + context.random(10) as u16, + }; + context.tile(type_name, sub_index) +} + +/// `SmoothTerrain` for water (`SmoothWaterTerrain`) +fn smooth_water_terrain( + context: &mut EditContext, + grid_no: GridNo, + type_name: &str, + force: bool, +) -> Option> { + let index = context.find_type_in_land(grid_no, type_name)?; + let old_sub_index = context.land(grid_no)[index].sub_index; + let full_tile = (1..=10).contains(&old_sub_index); + if !force && full_tile { + return None; + } + + let g = grid_no.index() as i32; + let has_top = g - WORLD_COLS >= 0; + let has_right = (g + 1) % WORLD_COLS != 0; + let has_bottom = g + WORLD_COLS < WORLD_MAX; + let has_left = g % WORLD_COLS != 0; + let neighbours = [ + (has_top, -WORLD_COLS, 4), + (has_right, 1, 64), + (has_bottom, WORLD_COLS, 256), + (has_left, -1, 16), + (has_right && has_top, -WORLD_COLS + 1, 8), + (has_left && has_top, -WORLD_COLS - 1, 2), + (has_right && has_bottom, WORLD_COLS + 1, 512), + (has_left && has_bottom, WORLD_COLS - 1, 128), + (true, 0, 32), + ]; + let key = neighbours + .iter() + .filter(|&&(exists, offset, _)| exists && has_type(context, g + offset, type_name)) + .fold(0, |key, &(_, _, bit)| key | bit); + + let sub_index = match SMOOTH_WATER_STRUCT.iter().find(|row| row[0] == key) { + Some(row) => pick_variation(context, row, old_sub_index)?, + None if full_tile => return None, + None => 1 + context.random(10) as u16, + }; + context.tile(type_name, sub_index) +} + +/// The tiles within `radius` of a tile, row by row, without wrapping into other rows +fn square(grid_no: GridNo, radius: u8) -> impl Iterator { + let position = grid_no.position(); + let (x, y) = (i32::from(position.x), i32::from(position.y)); + let radius = i32::from(radius); + (-radius..=radius).flat_map(move |dy| { + (-radius..=radius) + .filter_map(move |dx| GridPosition::from_xy(x + dx, y + dy).map(GridNo::from)) + }) +} + +/// Smooths the land of a type around a tile (`SmoothTerrainRadius`) +pub fn smooth_terrain_radius( + context: &mut EditContext, + grid_no: GridNo, + type_name: &str, + radius: u8, + force: bool, +) { + // Floors don't need smoothing + if context.category_of(type_name) == Some(TileCategory::Floor) { + return; + } + for neighbour in square(grid_no, radius) { + if context.find_type_in_land(neighbour, type_name).is_none() { + continue; + } + if let Some(tile) = smooth_terrain(context, neighbour, type_name, force) { + context.set_land_index(neighbour, tile); + } + } +} + +/// Smooths the land textures (not the water) around a tile (`SmoothAllTerrainTypeRadius`) +pub fn smooth_all_terrain_type_radius( + context: &mut EditContext, + grid_no: GridNo, + radius: u8, + force: bool, +) { + // The land textures in the order of their types. The next one is looked up after each, so a + // background tile that smoothing adds is smoothed too if its type comes later. + let mut previous: Option = None; + while let Some(type_name) = next_ground_type(context, grid_no, radius, previous.as_deref()) { + for neighbour in square(grid_no, radius) { + if context.find_type_in_land(neighbour, &type_name).is_none() { + continue; + } + if let Some(tile) = smooth_terrain(context, neighbour, &type_name, force) { + context.set_land_index(neighbour, tile); + } + } + previous = Some(type_name); + } +} + +/// The first land texture type (in the order of the tile types) within `radius` of a tile that +/// comes after `previous` +fn next_ground_type( + context: &EditContext, + grid_no: GridNo, + radius: u8, + previous: Option<&str>, +) -> Option { + let start = |type_name: &str| context.tileset.type_start(type_name); + let previous = previous.and_then(start); + square(grid_no, radius) + .flat_map(|neighbour| context.land(neighbour)) + .filter(|tile| tile.category == Some(TileCategory::Ground)) + .filter_map(|tile| Some((start(&tile.type_name)?, &tile.type_name))) + .filter(|&(tile_start, _)| previous.is_none_or(|previous| tile_start > previous)) + .min() + .map(|(_, type_name)| type_name.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::document::tests::{grid, Fixture}; + + /// Adds a tile of a type to the head of the land of a tile + fn paint(fixture: &mut Fixture, grid_no: GridNo, type_name: &str, sub_index: u16) { + let mut context = fixture.context(); + let tile = context.tile(type_name, sub_index).unwrap(); + context.add_land_to_head(grid_no, tile); + context.finish(); + } + + fn smoothed(fixture: &mut Fixture, grid_no: GridNo, type_name: &str) -> Option { + smooth_terrain(&mut fixture.context(), grid_no, type_name, true).map(|tile| tile.sub_index) + } + + #[test] + fn edge_tiles_follow_the_neighbours_without_the_texture() { + let mut fixture = Fixture::new(); + let center = grid(50, 50); + // Alone: no neighbour has the texture, 3 + 5 + 7 + 11 = 26 + paint(&mut fixture, center, "TEXTURE2", 19); + assert_eq!(smoothed(&mut fixture, center, "TEXTURE2"), Some(20)); + // The tile is already the edge tile of its key + paint(&mut fixture, center, "TEXTURE2", 20); + assert_eq!(fixture.land(center).len(), 3); + assert_eq!(smoothed(&mut fixture, center, "TEXTURE2"), None); + + // Only the tile above lacks the texture: 3, two variations + let mut fixture = Fixture::new(); + for (x, y) in [(50, 50), (51, 50), (50, 51), (49, 50)] { + paint(&mut fixture, grid(x, y), "TEXTURE2", 1); + } + let sub_index = smoothed(&mut fixture, center, "TEXTURE2").unwrap(); + assert!([12, 27].contains(&sub_index)); + + // Surrounded by the texture: a random full tile, unless it already is one + paint(&mut fixture, grid(50, 49), "TEXTURE2", 1); + assert!(smooth_terrain(&mut fixture.context(), center, "TEXTURE2", false).is_none()); + let mut fixture = Fixture::new(); + for (x, y) in [(50, 49), (51, 50), (50, 51), (49, 50)] { + paint(&mut fixture, grid(x, y), "TEXTURE2", 1); + } + paint(&mut fixture, center, "TEXTURE2", 19); + let sub_index = smoothed(&mut fixture, center, "TEXTURE2").unwrap(); + assert!((1..=10).contains(&sub_index)); + } + + #[test] + fn neighbours_off_the_map_count_as_having_the_texture() { + let mut fixture = Fixture::new(); + // Top left corner: only right (5) and below (7) are checked + paint(&mut fixture, grid(0, 0), "TEXTURE2", 19); + let sub_index = smoothed(&mut fixture, grid(0, 0), "TEXTURE2").unwrap(); + assert!([18, 33].contains(&sub_index)); + // The right edge doesn't look at the next row + paint(&mut fixture, grid(159, 10), "TEXTURE2", 19); + paint(&mut fixture, grid(0, 11), "TEXTURE2", 1); + // 3 + 7 + 11 = 21 + assert_eq!(smoothed(&mut fixture, grid(159, 10), "TEXTURE2"), Some(22)); + } + + #[test] + fn water_takes_the_first_matching_row() { + let mut fixture = Fixture::new(); + let center = grid(50, 50); + // Water everywhere except the row above: 16 + 32 + 64 + 128 + 256 + 512 = 1008 + for y in 50..=51 { + for x in 49..=51 { + paint(&mut fixture, grid(x, y), "WATER1", 1); + } + } + let sub_index = smoothed(&mut fixture, center, "WATER1").unwrap(); + assert!([34, 33].contains(&sub_index)); + + // 944 appears twice, the first row has one variation + let mut fixture = Fixture::new(); + // 16 + 32 + 128 + 256 + 512 = 944 + for (x, y) in [(49, 50), (50, 50), (49, 51), (50, 51), (51, 51)] { + paint(&mut fixture, grid(x, y), "WATER1", 1); + } + assert_eq!(smoothed(&mut fixture, center, "WATER1"), Some(18)); + // Water is always smoothed, even full tiles and without force + assert_eq!( + smooth_terrain(&mut fixture.context(), center, "WATER1", false) + .map(|tile| tile.sub_index), + Some(18) + ); + } + + #[test] + fn radius_smoothing_changes_the_land_of_the_type_around() { + let mut fixture = Fixture::new(); + let center = grid(50, 50); + paint(&mut fixture, center, "TEXTURE2", 19); + paint(&mut fixture, grid(51, 50), "TEXTURE2", 19); + let mut context = fixture.context(); + smooth_terrain_radius(&mut context, center, "TEXTURE2", 1, true); + context.finish(); + // Right neighbour has it: 3 + 7 + 11 = 21, left neighbour has it: 3 + 5 + 7 = 15 + assert_eq!(fixture.land(center).last(), Some(&("TEXTURE2", 22))); + assert_eq!(fixture.land(grid(51, 50)).last(), Some(&("TEXTURE2", 19))); + assert_eq!(fixture.document.undo.depth(), 1); + } +} diff --git a/src-map-editor/src/editor/soldier_events.rs b/src-map-editor/src/editor/soldier_events.rs new file mode 100644 index 0000000..bbfcc67 --- /dev/null +++ b/src-map-editor/src/editor/soldier_events.rs @@ -0,0 +1,928 @@ +//! What the host is told about the soldiers and how it edits them +//! +//! Once a map is loaded, the host gets the choices of the soldiers panel (`soldierPalette`). +//! Whenever the selected soldier, the roof placement or the number of soldiers change, it gets +//! the selected soldier (`soldier`). The host sends the edited values of the selected soldier +//! back with `set_soldier` and everything else with `soldier_command`. Failed edits are reported +//! with a `notice`. + +use bevy::{ecs::system::SystemParam, prelude::*}; +use serde::{Deserialize, Serialize}; + +use crate::{ + components::map::{game_to_world, JA2_TILE_HEIGHT, JA2_TILE_WIDTH}, + host::report_editor_event, + item::ItemId, + map::{ + Attitude, DetailedPlacement, Direction, GridNo, ItemStack, Level, Orders, PlacementId, + ProfileId, SoldierPlacement, SoldierStats, TacticalMap, NUM_INV_SLOTS, + }, + render::{ + draw_list::VisibleTeams, world::MapWorld, MapSoldiers, PendingSceneUpdate, RenderOptions, + }, + soldier::{ + body_type::SoldierBodyTypeId, + palette::ResolvedSoldierPalette, + palette_files::{ColPalette, PaletteReplacements}, + }, + states::loading::{soldier_files, MapAssets, MapToLoad}, + systems::pixel_view::PixelView, + tileset::StciSheet, +}; + +use super::{ + document::{EditContext, EditorDocument}, + schedules::{SchedulePick, ScheduleRow}, + soldiers::{ + edit_once, is_shown, placement, soldier_slot, SoldierContext, SoldierSelection, + SoldierSources, SoldierTeam, ENEMY_CLASSES, MAX_SOLDIERS, MAX_TEAM_SOLDIERS, + NUM_CIV_GROUPS, + }, + MapEdit, +}; + +/// The names of the civilian groups (`gszCivGroupNames`) +pub const CIV_GROUP_NAMES: [&str; NUM_CIV_GROUPS as usize] = [ + "NONE", + "REBEL", + "KINGPIN", + "SANMONA ARMS", + "ANGELS", + "BEGGARS", + "TOURISTS", + "ALMA MIL", + "DOCTORS", + "COUPLE1", + "HICKS", + "WARDEN", + "JUNKYARD", + "FACTORY KIDS", + "QUEENS", + "UNUSED15", + "UNUSED16", + "UNUSED17", + "UNUSED18", + "UNUSED19", +]; + +/// The name the editor shows for a body type (`DisplayBodyTypeInfo`) +pub fn body_type_name(body: SoldierBodyTypeId) -> &'static str { + use SoldierBodyTypeId as B; + match body { + B::REGMALE => "Reg Male", + B::BIGMALE => "Big Male", + B::STOCKYMALE => "Stocky Male", + B::REGFEMALE => "Reg Female", + B::TANK_NE => "NE Tank", + B::TANK_NW => "NW Tank", + B::FATCIV => "Fat Civilian", + B::MANCIV => "M Civilian", + B::MINICIV => "Miniskirt", + B::DRESSCIV => "F Civilian", + B::HATKIDCIV => "Kid w/ Hat", + B::HUMVEE => "Humvee", + B::ELDORADO => "Eldorado", + B::ICECREAMTRUCK => "Icecream Truck", + B::JEEP => "Jeep", + B::KIDCIV => "Kid Civilian", + B::COW => "Domestic Cow", + B::CRIPPLECIV => "Cripple", + B::ROBOTNOWEAPON => "Unarmed Robot", + B::LARVAE_MONSTER => "Larvae", + B::INFANT_MONSTER => "Infant", + B::YAF_MONSTER => "Yng F Monster", + B::YAM_MONSTER => "Yng M Monster", + B::ADULTFEMALEMONSTER => "Adt F Monster", + B::AM_MONSTER => "Adt M Monster", + B::QUEENMONSTER => "Queen Monster", + B::BLOODCAT => "Bloodcat", + B::CROW => "Crow", + _ => "Unknown", + } +} + +/// An item stack as the host edits it, `item` 0 is no item +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemStackValue { + pub item: u16, + pub number: u8, + /// Item class specific data, e.g. the status of most items in the first byte + pub data: [u8; 12], + pub attachments: Vec, + /// `OBJECT_UNDROPPABLE` (0x01), `OBJECT_NO_OVERWRITE` (0x80) and the like + pub flags: u8, + pub mission: u8, + pub trap: i8, + pub imprint_id: u8, + pub weight: u8, + pub used: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AttachmentValue { + pub item: u16, + pub status: i8, +} + +impl ItemStackValue { + pub fn of(stack: &ItemStack) -> Self { + Self { + item: stack.item.index(), + number: stack.number, + data: stack.data, + attachments: stack + .attachments + .iter() + .map(|&(item, status)| AttachmentValue { + item: item.index(), + status, + }) + .collect(), + flags: stack.flags as u8, + mission: stack.mission, + trap: stack.trap, + imprint_id: stack.imprint_id, + weight: stack.weight, + used: stack.used, + } + } + + /// The item stack, `None` without an item + pub fn stack(&self) -> Option { + Some(ItemStack { + item: ItemId::new(self.item)?, + number: self.number, + data: self.data, + attachments: self + .attachments + .iter() + .take(4) + .filter_map(|a| Some((ItemId::new(a.item)?, a.status))) + .collect(), + flags: self.flags as i8, + mission: self.mission, + trap: self.trap, + imprint_id: self.imprint_id, + weight: self.weight, + used: self.used, + }) + } +} + +/// The values of a detailed placement that the host edits +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DetailedValue { + /// The profile of a named character, `None` for none + pub profile: Option, + pub copy_profile_items_over: bool, + pub stats: SoldierStats, + /// The 19 inventory slots of the game (`NUM_INV_SLOTS`) + pub inventory: Vec>, + /// Whether the soldier keeps the colours (`fVisible`) + pub visible: bool, + pub head_palette: String, + pub pants_palette: String, + pub vest_palette: String, + pub skin_palette: String, +} + +impl DetailedValue { + pub fn of(detailed: &DetailedPlacement) -> Self { + Self { + profile: detailed.profile.map(ProfileId::number), + copy_profile_items_over: detailed.copy_profile_items_over, + stats: detailed.stats, + inventory: detailed + .inventory + .iter() + .map(|slot| slot.as_ref().map(ItemStackValue::of)) + .collect(), + visible: detailed.visible, + head_palette: detailed.head_palette.clone(), + pants_palette: detailed.pants_palette.clone(), + vest_palette: detailed.vest_palette.clone(), + skin_palette: detailed.skin_palette.clone(), + } + } + + /// Puts the values into a detailed placement, clamped like the original + /// (`ExtractAndUpdateMercAttributes`), but the morale is the morale + fn apply(&self, detailed: &mut DetailedPlacement) { + detailed.profile = self.profile.and_then(ProfileId::new); + detailed.copy_profile_items_over = self.copy_profile_items_over; + detailed.stats = clamp_stats(self.stats); + if self.inventory.len() == NUM_INV_SLOTS { + for (slot, value) in detailed.inventory.iter_mut().zip(&self.inventory) { + *slot = value.as_ref().and_then(ItemStackValue::stack); + } + } + detailed.visible = self.visible; + detailed.head_palette = self.head_palette.clone(); + detailed.pants_palette = self.pants_palette.clone(); + detailed.vest_palette = self.vest_palette.clone(); + detailed.skin_palette = self.skin_palette.clone(); + } +} + +/// The attributes clamped to 100, the experience level to 1 to 9 and the life to the life +/// maximum, which is at least 1 (`ExtractAndUpdateMercAttributes`) +pub fn clamp_stats(stats: SoldierStats) -> SoldierStats { + let stat = |value: Option| value.map(|v| v.clamp(0, 100)); + let life_max = stat(stats.life_max).map(|v| v.max(1)); + let life = match (stat(stats.life), life_max) { + (Some(life), Some(max)) => Some(life.min(max)), + (life, _) => life, + }; + SoldierStats { + life_max, + life, + agility: stat(stats.agility), + dexterity: stat(stats.dexterity), + exp_level: stats.exp_level.map(|v| v.clamp(1, 9)), + marksmanship: stat(stats.marksmanship), + medical: stat(stats.medical), + mechanical: stat(stats.mechanical), + explosive: stat(stats.explosive), + leadership: stat(stats.leadership), + strength: stat(stats.strength), + wisdom: stat(stats.wisdom), + morale: stat(stats.morale), + ai_morale: stat(stats.ai_morale), + } +} + +/// The values of a placement that the host edits +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SoldierFields { + pub orders: Orders, + pub attitude: Attitude, + pub direction: Direction, + /// 0 to 4, from bad to great + pub relative_attribute_level: i8, + pub relative_equipment_level: i8, + pub soldier_class: u8, + pub civilian_group: u8, + /// The body type, `None` if it is chosen at random + pub body: Option, + pub priority_existence: bool, + pub has_keys: bool, + /// Only used if the placement is detailed, see the `detailed` command + pub detailed: Option, +} + +impl SoldierFields { + pub fn of(placement: &SoldierPlacement) -> Self { + Self { + orders: placement.orders, + attitude: placement.attitude, + direction: placement.direction, + relative_attribute_level: placement.relative_attribute_level, + relative_equipment_level: placement.relative_equipment_level, + soldier_class: placement.soldier_class.id.index() as u8, + civilian_group: placement.civilian_group, + body: placement + .given_body + .as_ref() + .map(|body| body.id.index() as u8), + priority_existence: placement.priority_existence, + has_keys: placement.has_keys, + detailed: placement.detailed.as_ref().map(DetailedValue::of), + } + } + + /// Puts the values into a placement, unknown classes and body types are ignored + pub fn apply(&self, placement: &mut SoldierPlacement, sc: &SoldierContext) { + let definitions = sc.definitions; + placement.orders = self.orders; + placement.attitude = self.attitude; + placement.direction = self.direction; + placement.relative_attribute_level = self.relative_attribute_level.clamp(0, 4); + placement.relative_equipment_level = self.relative_equipment_level.clamp(0, 4); + if let Some(class) = definitions.classes.validate(self.soldier_class) { + placement.soldier_class = class; + } + placement.civilian_group = self.civilian_group.min(NUM_CIV_GROUPS - 1); + match self.body { + None => placement.given_body = None, + Some(body) => { + if let Some(body) = definitions.bodies.validate(body) { + placement.given_body = Some(body); + } + } + } + placement.priority_existence = self.priority_existence; + placement.has_keys = self.has_keys; + if let (Some(value), Some(detailed)) = (&self.detailed, &mut placement.detailed) { + value.apply(detailed); + } + placement.resolve_appearance(&sc.appearance); + } +} + +/// The values of the selected soldier sent with `set_soldier` +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SoldierEdit { + pub id: u32, + #[serde(flatten)] + pub fields: SoldierFields, +} + +/// The palette names a soldier is drawn with +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PaletteNames { + pub head: String, + pub skin: String, + pub vest: String, + pub pants: String, +} + +/// A placement as the host shows it +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SoldierValue { + pub id: u32, + /// The number of the soldier in the game (`Slot #`) + pub slot: Option, + /// `None` for the teams of the player + pub team: Option, + pub grid_no: u16, + pub on_roof: bool, + /// The body type it is drawn with, the given one or the one chosen at random + pub drawn_body: u8, + /// The colours it is drawn with, `None` if the body type has no colours to choose + pub drawn_palette: Option, + pub patrol: Vec, + #[serde(flatten)] + pub fields: SoldierFields, +} + +impl SoldierValue { + pub fn of( + map: &TacticalMap, + placement: &SoldierPlacement, + palettes: Option<&PaletteReplacements>, + ) -> Self { + let drawn_palette = match (&placement.palette, palettes) { + (ResolvedSoldierPalette::Generated(ids), Some(palettes)) => { + let names: Vec<&str> = ids.iter().filter_map(|&id| palettes.name(id)).collect(); + match names[..] { + [head, vest, pants, skin] => Some(PaletteNames { + head: head.to_owned(), + skin: skin.to_owned(), + vest: vest.to_owned(), + pants: pants.to_owned(), + }), + _ => None, + } + } + _ => None, + }; + Self { + id: placement.id.0, + slot: soldier_slot(map, placement.id), + team: SoldierTeam::of(placement.team.id), + grid_no: placement.grid_no.index() as u16, + on_roof: placement.level == Level::Roof, + drawn_body: placement.body.id.index() as u8, + drawn_palette, + patrol: placement + .patrol + .iter() + .map(|grid_no| grid_no.index() as u16) + .collect(), + fields: SoldierFields::of(placement), + } + } +} + +/// How many soldiers each team has +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct TeamCounts { + pub enemy: usize, + pub creature: usize, + pub rebel: usize, + pub civilian: usize, + /// All soldiers of the map, also those of other teams and those that could not be loaded + pub total: usize, +} + +impl TeamCounts { + pub fn of(map: &TacticalMap) -> Self { + let count = |team: SoldierTeam| { + map.soldiers + .iter() + .filter(|p| p.team.id == team.team_id()) + .count() + }; + Self { + enemy: count(SoldierTeam::Enemy), + creature: count(SoldierTeam::Creature), + rebel: count(SoldierTeam::Rebel), + civilian: count(SoldierTeam::Civilian), + total: map.soldiers.len() + map.skipped_placements.len(), + } + } +} + +/// The selected soldier, `null` if none is +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename = "soldier", rename_all = "camelCase")] +pub struct SoldierEvent { + pub map: String, + pub soldier: Option, + pub counts: TeamCounts, + /// The actions of the schedule of the selected soldier, `None` without a detailed placement + pub schedule: Option<[ScheduleRow; 4]>, + /// The tile of the schedule that the next click picks + pub pick: Option, +} + +/// Why an edit did not happen, e.g. a soldier could not be placed +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename = "notice", rename_all = "camelCase")] +pub struct NoticeEvent { + pub map: String, + pub message: String, +} + +/// A body type the host offers, `id` is `None` for a random one +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct BodyOption { + pub id: Option, + pub name: &'static str, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ClassOption { + pub id: u8, + pub name: &'static str, +} + +/// Something of each team +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ByTeam { + pub enemy: T, + pub creature: T, + pub rebel: T, + pub civilian: T, +} + +impl ByTeam { + fn from_fn(f: impl Fn(SoldierTeam) -> T) -> Self { + Self { + enemy: f(SoldierTeam::Enemy), + creature: f(SoldierTeam::Creature), + rebel: f(SoldierTeam::Rebel), + civilian: f(SoldierTeam::Civilian), + } + } +} + +/// The palette replacements of the colour sets of the editor +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct ColourSets { + pub head: Vec, + pub skin: Vec, + pub vest: Vec, + pub pants: Vec, +} + +impl ColourSets { + /// The ranges of `ja2pal.dat` are the hair, the pants, the skin and the vest + /// (`EDIT_COLOR_HEAD` and so on) + pub fn of(palettes: &PaletteReplacements) -> Self { + let names = |range| palettes.names_in_range(range).map(str::to_owned).collect(); + Self { + head: names(0), + pants: names(1), + skin: names(2), + vest: names(3), + } + } +} + +/// The choices of the soldiers panel +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename = "soldierPalette", rename_all = "camelCase")] +pub struct SoldierPaletteEvent { + pub map: String, + pub max_soldiers: usize, + pub max_team_soldiers: usize, + pub enemy_classes: Vec, + /// The body types that soldiers of each team can have (`bEnemyArray` and so on) + pub bodies: ByTeam>, + pub civilian_groups: Vec<&'static str>, + pub colours: ColourSets, +} + +impl SoldierPaletteEvent { + pub fn new(map: String, palettes: Option<&PaletteReplacements>) -> Self { + Self { + map, + max_soldiers: MAX_SOLDIERS, + max_team_soldiers: MAX_TEAM_SOLDIERS, + enemy_classes: ENEMY_CLASSES + .iter() + .map(|&(id, name)| ClassOption { + id: id.index() as u8, + name, + }) + .collect(), + bodies: ByTeam::from_fn(|team| { + team.body_types() + .iter() + .map(|body| BodyOption { + id: body.map(|body| body.index() as u8), + name: body.map_or("Random", body_type_name), + }) + .collect() + }), + civilian_groups: CIV_GROUP_NAMES.to_vec(), + colours: palettes.map(ColourSets::of).unwrap_or_default(), + } + } +} + +/// What the host asks of the soldiers besides editing the selected one +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum SoldierCommand { + /// Selects a soldier, or none + Select { id: Option }, + /// Gives the selected soldier a detailed placement or removes it, a detailed placement with + /// the profile or without one + Detailed { + detailed: bool, + #[serde(default)] + profile: Option, + }, + /// Centers the view on a tile + Center { grid_no: u16 }, + /// Sets the actions of the schedule of the selected soldier + Schedule { rows: [ScheduleRow; 4] }, + /// Removes all actions of the schedule of the selected soldier + ClearSchedule, + /// The next left click picks a tile of an action of the schedule + Pick(SchedulePick), + /// Stops picking, the action is removed if its tiles are missing + CancelPick, +} + +/// What soldier commands need besides the map +#[derive(SystemParam)] +pub struct SoldierCommandParams<'w> { + pub selection: ResMut<'w, SoldierSelection>, + pub sources: SoldierSources<'w>, + pub view: Option>, + pub options: Res<'w, RenderOptions>, +} + +/// The center of a tile in world coordinates, raised by its height +pub fn tile_center(map: &TacticalMap, grid_no: GridNo) -> Vec2 { + let mut game = grid_no.position().tile_box() + IVec2::new(JA2_TILE_WIDTH, JA2_TILE_HEIGHT) / 2; + game.y -= i32::from(map.tiles[grid_no].height); + game_to_world(game) +} + +/// Edits the selected soldier with the values of the host, ignored if another one is selected +pub fn update_soldier(context: &mut EditContext, sc: &SoldierContext, edit: &SoldierEdit) { + let id = PlacementId(edit.id); + context.change_soldier(id, |placement| edit.fields.apply(placement, sc)); +} + +/// Runs a soldier command of the host +pub fn run_soldier_command( + command: SoldierCommand, + edit: &mut MapEdit, + params: &mut SoldierCommandParams, +) { + let visible = params.options.layers.soldier_teams; + let selection = &mut *params.selection; + match command { + SoldierCommand::Select { id } => { + selection.selected = id + .map(PlacementId) + .filter(|&id| placement(&edit.map, id).is_some_and(|p| is_shown(p, &visible))); + } + SoldierCommand::Detailed { detailed, profile } => { + let (Some(sc), Some(selected)) = (params.sources.context(), selection.selected) else { + return; + }; + edit_once(edit, |context| { + context.set_detailed(&sc, selected, detailed); + context.set_profile(&sc, selected, profile.and_then(ProfileId::new)); + }); + } + SoldierCommand::Schedule { rows } => { + selection.schedule.sync(&edit.map, selection.selected); + edit_once(edit, |context| selection.schedule.set_rows(context, rows)); + } + SoldierCommand::ClearSchedule => { + selection.schedule.sync(&edit.map, selection.selected); + selection.schedule.pick = None; + edit_once(edit, |context| { + selection + .schedule + .set_rows(context, [ScheduleRow::default(); 4]) + }); + } + SoldierCommand::Pick(pick) => { + selection.schedule.sync(&edit.map, selection.selected); + selection.schedule.start_pick(pick); + } + SoldierCommand::CancelPick => { + edit_once(edit, |context| selection.schedule.cancel_pick(context)); + } + SoldierCommand::Center { grid_no } => { + if let (Some(grid_no), Some(view)) = + (GridNo::new(usize::from(grid_no)), params.view.as_mut()) + { + view.position = tile_center(&edit.map, grid_no); + } + } + } +} + +/// The soldier event that was reported last, `None` for a new map +#[derive(Resource, Clone, Debug, Default)] +pub struct ReportedSoldier(Option); + +/// Sends the choices of the soldiers panel once a map is loaded +pub fn report_soldier_palette( + map_to_load: Res, + map_assets: Res, + replacements: Res>, + mut reported: ResMut, +) { + reported.0 = None; + let palettes = replacements.get(&map_assets.soldier_palettes); + report_editor_event(&SoldierPaletteEvent::new(map_to_load.0.clone(), palettes)); +} + +/// Tells the host about the selected soldier when it changes, and about failed edits +pub fn report_soldier( + map_to_load: Res, + map: Res, + map_assets: Res, + replacements: Res>, + mut selection: ResMut, + mut reported: ResMut, +) { + if let Some(message) = selection.notice.take() { + report_editor_event(&NoticeEvent { + map: map_to_load.0.clone(), + message, + }); + } + let palettes = replacements.get(&map_assets.soldier_palettes); + let event = SoldierEvent { + map: map_to_load.0.clone(), + soldier: selection + .selected + .and_then(|id| placement(&map, id)) + .map(|selected| SoldierValue::of(&map, selected, palettes)), + counts: TeamCounts::of(&map), + schedule: selection.schedule.draft.as_ref().map(|draft| draft.rows), + pick: selection.schedule.pick, + }; + if reported.0.as_ref() != Some(&event) { + report_editor_event(&event); + reported.0 = Some(event); + } +} + +/// The teams whose soldiers the world has, all of them for a new map +#[derive(Resource, Clone, Copy, Debug, Default)] +pub struct ShownTeams(pub VisibleTeams); + +pub fn reset_shown_teams(mut shown: ResMut) { + *shown = ShownTeams::default(); +} + +/// Brings the soldiers of the world up to date after the placements or the shown teams changed, +/// the scene catches up with the tiles they were and are on +pub fn sync_soldier_scene( + options: Res, + mut document: ResMut, + mut shown: ResMut, + map: Res, + mut world: ResMut, + mut soldiers: ResMut, + mut pending: ResMut, + map_assets: Res, + sheets: Res>, + replacements: Res>, + col_palettes: Res>, +) { + let visible = options.layers.soldier_teams; + if !document.soldiers_dirty && shown.0 == visible { + return; + } + document.soldiers_dirty = false; + shown.0 = visible; + let files = soldier_files(&map_assets, &sheets, &replacements, &col_palettes); + let touched = crate::render::soldiers::sync_soldiers( + &mut world, + &map.soldiers, + |placement| visible.shows(placement.team.id), + &files, + &mut soldiers, + ); + pending.tiles.extend(touched); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + editor::{ + document::tests::grid, + soldiers::tests::{mode, SoldierFixture}, + }, + map::HANDPOS, + soldier::standing_surface::StandingSurfaceId, + }; + + #[test] + fn the_host_edits_the_selected_soldier() { + let mut fixture = SoldierFixture::new(); + let enemies = mode(r#"{"mode":"soldiers"}"#); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &enemies, grid(30, 30), Level::Ground)) + .unwrap(); + fixture.edit(|context, sc| context.set_detailed(sc, id, true)); + let map = fixture.map(); + let value = SoldierValue::of(map, fixture.soldier(id), Some(&fixture.palettes)); + let mut json = serde_json::to_value(&value).unwrap(); + assert_eq!(json["slot"], 20); + assert_eq!(json["team"], "enemy"); + assert_eq!(json["orders"], "stationary"); + assert_eq!(json["direction"], "northWest"); + assert_eq!(json["detailed"]["stats"]["life"], serde_json::Value::Null); + assert_eq!(json["detailed"]["inventory"].as_array().unwrap().len(), 19); + assert!(json["drawnPalette"]["vest"].is_string()); + + // The host sends the soldier back with its changes + json["orders"] = "pointPatrol".into(); + json["soldierClass"] = 2.into(); + json["body"] = 3.into(); + json["relativeEquipmentLevel"] = 9.into(); + json["detailed"]["stats"]["life"] = 120.into(); + json["detailed"]["stats"]["lifeMax"] = 60.into(); + json["detailed"]["inventory"][HANDPOS] = serde_json::json!({ + "item": 1, "number": 1, "data": [90, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "attachments": [], "flags": 128, "mission": 0, "trap": 0, "imprintId": 200, + "weight": 0, "used": 0 + }); + let edit: SoldierEdit = serde_json::from_value(json).unwrap(); + fixture.edit(|context, sc| update_soldier(context, sc, &edit)); + let edited = fixture.soldier(id); + assert_eq!(edited.orders, Orders::PointPatrol); + assert_eq!(edited.soldier_class.id.index(), 2); + assert_eq!( + edited.given_body.as_ref().unwrap().id, + SoldierBodyTypeId::REGFEMALE + ); + assert_eq!(edited.body.id, SoldierBodyTypeId::REGFEMALE); + assert_eq!(edited.relative_equipment_level, 4); + let detailed = edited.detailed.as_ref().unwrap(); + assert_eq!(detailed.stats.life, Some(60)); + assert_eq!(detailed.inventory[HANDPOS].as_ref().unwrap().flags, -128); + // The pistol in the hand changes the standing animation + assert_eq!(edited.surface.id, StandingSurfaceId::REGFEMALE_PISTOL); + assert_eq!(fixture.fixture.document.undo.depth(), 3); + + // Sending the same values changes nothing, edits of other soldiers are ignored + let same = SoldierEdit { + id: id.0, + fields: SoldierFields::of(fixture.soldier(id)), + }; + fixture.edit(|context, sc| update_soldier(context, sc, &same)); + let other = SoldierEdit { id: 99, ..same }; + fixture.edit(|context, sc| update_soldier(context, sc, &other)); + assert_eq!(fixture.fixture.document.undo.depth(), 3); + } + + #[test] + fn commands_are_read_from_json() { + let parse = |json: &str| serde_json::from_str::(json).unwrap(); + assert_eq!( + parse(r#"{"kind":"select","id":null}"#), + SoldierCommand::Select { id: None } + ); + assert_eq!( + parse(r#"{"kind":"center","gridNo":1234}"#), + SoldierCommand::Center { grid_no: 1234 } + ); + assert_eq!( + parse(r#"{"kind":"detailed","detailed":true}"#), + SoldierCommand::Detailed { + detailed: true, + profile: None + } + ); + assert_eq!( + parse(r#"{"kind":"detailed","detailed":true,"profile":57}"#), + SoldierCommand::Detailed { + detailed: true, + profile: Some(57) + } + ); + assert_eq!( + parse(r#"{"kind":"pick","slot":1,"point":"a","single":true}"#), + SoldierCommand::Pick(SchedulePick { + slot: 1, + point: crate::editor::schedules::SchedulePoint::A, + single: true + }) + ); + let rows = r#"{"action":"none","time":null,"variance":false,"data1":null,"data2":null}"#; + let schedule = format!(r#"{{"kind":"schedule","rows":[{rows},{rows},{rows},{rows}]}}"#); + assert_eq!( + parse(&schedule), + SoldierCommand::Schedule { + rows: [ScheduleRow::default(); 4] + } + ); + } + + #[test] + fn stats_are_clamped_like_the_original() { + let stats = clamp_stats(SoldierStats { + life_max: Some(0), + life: Some(50), + exp_level: Some(12), + agility: Some(120), + morale: Some(70), + ..Default::default() + }); + assert_eq!(stats.life_max, Some(1)); + assert_eq!(stats.life, Some(1)); + assert_eq!(stats.exp_level, Some(9)); + assert_eq!(stats.agility, Some(100)); + // Unlike the original, the morale is not the mechanical skill + assert_eq!(stats.morale, Some(70)); + assert_eq!(stats.mechanical, None); + let stats = clamp_stats(SoldierStats { + life: Some(50), + exp_level: Some(0), + ..Default::default() + }); + assert_eq!(stats.life, Some(50)); + assert_eq!(stats.exp_level, Some(1)); + } + + #[test] + fn item_stacks_are_json_for_the_host() { + let stack = ItemStack { + item: ItemId::new(5).unwrap(), + number: 1, + data: [90, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + attachments: vec![(ItemId::new(7).unwrap(), 60)], + flags: -127, + mission: 0, + trap: 0, + imprint_id: 200, + weight: 0, + used: 0, + }; + let value = ItemStackValue::of(&stack); + assert_eq!(value.flags, 0x81); + let json = serde_json::to_value(&value).unwrap(); + assert_eq!(json["imprintId"], 200); + assert_eq!(json["attachments"][0]["status"], 60); + assert_eq!(value.stack(), Some(stack)); + let nothing = ItemStackValue { item: 0, ..value }; + assert_eq!(nothing.stack(), None); + } + + #[test] + fn the_palette_has_the_choices_of_the_original() { + let palettes = PaletteReplacements::with_ranges(&[ + (0, "BROWNHEAD"), + (1, "BLUEPANTS"), + (2, "PINKSKIN"), + (3, "REDVEST"), + (0, "BLONDHEAD"), + ]); + let event = SoldierPaletteEvent::new("maps/a9.dat".to_owned(), Some(&palettes)); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "soldierPalette"); + assert_eq!(json["maxSoldiers"], 148); + assert_eq!(json["enemyClasses"][2]["name"], "Elite"); + assert_eq!(json["bodies"]["enemy"][0]["id"], serde_json::Value::Null); + assert_eq!(json["bodies"]["creature"][0]["name"], "Bloodcat"); + assert_eq!(json["bodies"]["civilian"].as_array().unwrap().len(), 18); + assert_eq!(json["civilianGroups"][3], "SANMONA ARMS"); + assert_eq!(json["colours"]["head"][1], "BLONDHEAD"); + assert_eq!(json["colours"]["vest"][0], "REDVEST"); + } +} diff --git a/src-map-editor/src/editor/soldier_overlay.rs b/src-map-editor/src/editor/soldier_overlay.rs new file mode 100644 index 0000000..e62a31b --- /dev/null +++ b/src-map-editor/src/editor/soldier_overlay.rs @@ -0,0 +1,363 @@ +//! What the soldiers mode draws over the map: the selected soldier and the tile under the cursor +//! (`CONFIRMMOVE1`, `FIRSTPOINTERS1` and `BADMARKER1` of the original editor), the patrol points +//! of the selected soldier (`DisplayWayPoints`) and the slot, the name and the patrol warnings of +//! all soldiers (`RenderMercStrings`) +//! +//! The texts are on the screen camera like the room numbers, so they stay sharp at every zoom. + +use bevy::{camera::visibility::RenderLayers, prelude::*, window::PrimaryWindow}; + +use crate::{ + map::{GridNo, Level, PlacementId, TacticalMap}, + render::{draw_list::VisibleTeams, world::MapWorld, RenderOptions}, + states::{AppState, UiFont}, + systems::{ + cursor::CursorInfo, + pixel_view::{PixelView, SCREEN_LAYER}, + }, +}; + +use super::{ + commands::CurrentMode, + input::{area_outline, floor_outline, raised, ROOF_RAISE}, + schedules::ScheduleRow, + soldier_events::tile_center, + soldiers::{ + is_shown, is_sittable, placement, soldier_at, soldier_mode, soldier_slot, SoldierSelection, + SoldierTool, + }, +}; + +/// Size of the texts in logical pixels +const FONT_SIZE: f32 = 11.; +/// Distance of the lines of a soldier in logical pixels +const LINE_HEIGHT: f32 = 11.; +/// How far above its tile the texts of a soldier start, in world pixels +const SOLDIER_HEIGHT: f32 = 45.; + +/// The outline of a tile, raised onto the roof for the roof level +pub fn tile_outline(map: &TacticalMap, grid_no: GridNo, level: Level) -> Vec<(Vec2, Vec2)> { + raised(area_outline(&[grid_no], map), level) +} + +/// The outline of the tile under the cursor on a floor, see `floor_outline` +pub fn cursor_outline(map: &TacticalMap, grid_no: GridNo, floor: Level) -> Vec<(Vec2, Vec2)> { + floor_outline(area_outline(&[grid_no], map), floor) +} + +/// Outlines the selected soldier in yellow and the tile under the cursor on the floor: for +/// placing green where a click places a soldier and red where no soldier can stand, for +/// selecting green or red where the selected soldier is dragged to and faintly a soldier that a +/// click selects. The brush that erases is drawn by `draw_brush_cursor`. +pub fn draw_soldier_markers( + mut gizmos: Gizmos, + cursor: Res, + mode: Res, + options: Res, + selection: Res, + map: Res, + world: Res, +) { + let Some(mode) = soldier_mode(&mode) else { + return; + }; + let mut lines = |lines: Vec<(Vec2, Vec2)>, color: Srgba| { + for (from, to) in lines { + gizmos.line_2d(from, to, color); + } + }; + let selected = selection.selected.and_then(|id| placement(&map, id)); + if let Some(selected) = selected { + lines( + tile_outline(&map, selected.grid_no, selected.level), + Srgba::rgb(1., 0.85, 0.), + ); + } + let Some(tile) = cursor.tile.map(GridNo::from) else { + return; + }; + let floor = options.floor; + let sittable = |level| { + if is_sittable(&map, &world, tile, level, true) { + Srgba::GREEN + } else { + Srgba::RED + } + }; + match mode.tool { + SoldierTool::Place if mode.erase => {} + SoldierTool::Place => lines(cursor_outline(&map, tile, floor), sittable(floor)), + SoldierTool::Select if selection.dragging() => { + if let Some(selected) = selected.filter(|selected| selected.grid_no != tile) { + let color = sittable(selected.level); + lines(cursor_outline(&map, tile, floor), color); + } + } + SoldierTool::Select => { + let hovered = soldier_at(&map, tile, &options.layers.soldier_teams, floor) + .filter(|&hovered| Some(hovered) != selection.selected); + if hovered.is_some() { + lines( + cursor_outline(&map, tile, floor), + Srgba::new(1., 1., 1., 0.35), + ); + } + } + } +} + +/// A text over the map +#[derive(Clone, Debug, PartialEq)] +pub struct OverlayLabel { + pub text: String, + pub color: Color, + /// Where the text is, in world coordinates + pub anchor: Vec2, + /// The line below the anchor, for the texts of a soldier + pub line: u8, +} + +/// The patrol point labels in the colours of the original: red for soldiers on the ground, blue +/// for those on roofs +fn patrol_color(level: Level) -> Color { + match level { + Level::Ground => Color::srgb(1., 0.45, 0.45), + Level::Roof => Color::srgb(0.45, 0.65, 1.), + } +} + +/// Where the texts of a tile are, raised onto the roof for the roof level +fn label_anchor(map: &TacticalMap, grid_no: GridNo, level: Level) -> Vec2 { + let raise = if level == Level::Roof { ROOF_RAISE } else { 0. }; + tile_center(map, grid_no) + Vec2::new(0., raise) +} + +/// The texts of the soldiers mode: the patrol points of the selected soldier and the slot, the +/// name and the patrol warnings of the shown soldiers +pub fn soldier_labels( + map: &TacticalMap, + selected: Option, + visible: &VisibleTeams, + schedule: Option<&[ScheduleRow; 4]>, +) -> Vec { + let mut labels = vec![]; + for soldier in map.soldiers.iter().filter(|p| is_shown(p, visible)) { + let anchor = + label_anchor(map, soldier.grid_no, soldier.level) + Vec2::new(0., SOLDIER_HEIGHT); + let mut line = 0; + let mut add = |text: String, color: Color| { + labels.push(OverlayLabel { + text, + color, + anchor, + line, + }); + line += 1; + }; + let name = soldier + .detailed + .as_ref() + .filter(|detailed| detailed.profile.is_some() || !detailed.name.is_empty()) + .map(|detailed| detailed.name.clone()) + .filter(|name| !name.is_empty()); + if let Some(name) = name { + add(name, Color::WHITE); + } + if let Some(slot) = soldier_slot(map, soldier.id) { + add(format!("Slot #{}", slot), Color::srgb(0.75, 0.75, 0.75)); + } + // Patrol orders need patrol points, and patrol points need patrol orders + let patrol_orders = soldier.orders.uses_patrol_points(); + match (patrol_orders, soldier.patrol.is_empty()) { + (true, true) => add( + "Patrol orders with no waypoints".to_owned(), + Color::srgb(1., 0.2, 0.2), + ), + (false, false) => add( + "Waypoints with no patrol orders".to_owned(), + Color::srgb(1., 0.2, 0.2), + ), + _ => {} + } + } + if let Some(selected) = selected.and_then(|id| placement(map, id)) { + for (index, &grid_no) in selected.patrol.iter().enumerate() { + labels.push(OverlayLabel { + text: (index + 1).to_string(), + color: patrol_color(selected.level), + // Brought down a touch, like the original + anchor: label_anchor(map, grid_no, selected.level) - Vec2::new(0., 5.), + line: 0, + }); + } + } + // The tiles of the schedule (`RenderCurrentSchedule`) + for (slot, row) in schedule.into_iter().flatten().enumerate() { + for (point, data) in [('A', row.data1), ('B', row.data2)] { + let Some(grid_no) = data.and_then(|d| GridNo::new(usize::from(d))) else { + continue; + }; + labels.push(OverlayLabel { + text: format!("{}{}", slot + 1, point), + color: Color::srgb(0.94, 0.85, 0.55), + anchor: label_anchor(map, grid_no, Level::Ground) - Vec2::new(0., 5.), + line: 0, + }); + } + } + labels +} + +/// The texts that are shown +#[derive(Resource, Default)] +pub struct SoldierLabels { + shown: Vec<(OverlayLabel, Entity)>, +} + +/// A text of [`SoldierLabels`] +#[derive(Component)] +pub struct SoldierLabel { + anchor: Vec2, + line: u8, +} + +pub fn reset_soldier_labels(mut labels: ResMut) { + *labels = SoldierLabels::default(); +} + +/// Spawns the texts when they change and moves them with the view +pub fn update_soldier_labels( + mut commands: Commands, + mode: Res, + options: Res, + map: Res, + selection: Res, + view: Res, + window: Single<&Window, With>, + font: Res, + mut labels: ResMut, + mut transforms: Query<(&SoldierLabel, &mut Transform)>, +) { + let changed = mode.is_changed() + || options.is_changed() + || map.is_changed() + || selection.is_changed() + || labels.is_added(); + let shown = soldier_mode(&mode).is_some() && options.layers.soldiers; + // 1 unit of the screen camera is 1 physical pixel, above the map + let line_height = LINE_HEIGHT * window.scale_factor(); + let translation = |anchor: Vec2, line: u8| { + (view.world_to_screen(anchor) - Vec2::new(0., f32::from(line) * line_height)).extend(11.) + }; + if changed { + let wanted = if shown { + let schedule = selection.schedule.draft.as_ref().map(|draft| &draft.rows); + let visible = &options.layers.soldier_teams; + soldier_labels(&map, selection.selected, visible, schedule) + } else { + Vec::new() + }; + let unchanged = labels.shown.len() == wanted.len() + && labels + .shown + .iter() + .zip(&wanted) + .all(|((shown, _), w)| shown == w); + if !unchanged { + for (_, entity) in labels.shown.drain(..) { + commands.entity(entity).despawn(); + } + let text_font = font.text_font(FONT_SIZE * window.scale_factor()); + for label in wanted { + let entity = commands + .spawn(( + SoldierLabel { + anchor: label.anchor, + line: label.line, + }, + Text2d::new(label.text.clone()), + text_font.clone(), + TextColor(label.color), + RenderLayers::layer(SCREEN_LAYER), + // New labels are not in `transforms` yet, so they start at their place + Transform::from_translation(translation(label.anchor, label.line)), + DespawnOnExit(AppState::Map), + )) + .id(); + labels.shown.push((label, entity)); + } + } + } + for (label, mut transform) in &mut transforms { + let translation = translation(label.anchor, label.line); + if transform.translation != translation { + transform.translation = translation; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::{ + document::tests::grid, + soldiers::tests::{mode, SoldierFixture}, + }; + use crate::map::Orders; + + #[test] + fn soldiers_have_their_slot_and_patrol_warnings() { + let mut fixture = SoldierFixture::new(); + let enemies = mode(r#"{"mode":"soldiers","defaults":{"orders":"pointPatrol"}}"#); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &enemies, grid(30, 30), Level::Ground)) + .unwrap(); + let visible = VisibleTeams::default(); + let texts = |fixture: &SoldierFixture, selected| -> Vec<(String, u8)> { + soldier_labels(fixture.map(), selected, &visible, None) + .into_iter() + .map(|label| (label.text, label.line)) + .collect() + }; + assert_eq!( + texts(&fixture, None), + [ + ("Slot #20".to_owned(), 0), + ("Patrol orders with no waypoints".to_owned(), 1) + ] + ); + fixture.edit(|context, _| { + context.set_patrol_point(id, 2, grid(35, 35)); + context.change_soldier(id, |p| { + p.orders = Orders::Stationary; + let mut detailed = crate::editor::soldiers::static_detailed_placement(p); + detailed.name = "Kingpin".to_owned(); + p.detailed = Some(detailed); + }); + }); + let labels = texts(&fixture, Some(id)); + assert_eq!( + labels, + [ + ("Kingpin".to_owned(), 0), + ("Slot #20".to_owned(), 1), + ("Waypoints with no patrol orders".to_owned(), 2), + ("1".to_owned(), 0), + ("2".to_owned(), 0), + ] + ); + // Hidden teams have no texts + let hidden = VisibleTeams { + enemy: false, + ..visible + }; + assert!(soldier_labels(fixture.map(), None, &hidden, None).is_empty()); + // The tiles of the schedule + let mut rows = [ScheduleRow::default(); 4]; + rows[1].data1 = Some(grid(40, 40).index() as u16); + rows[1].data2 = Some(grid(41, 40).index() as u16); + let labels = soldier_labels(fixture.map(), None, &hidden, Some(&rows)); + let texts: Vec<_> = labels.iter().map(|label| label.text.as_str()).collect(); + assert_eq!(texts, ["2A", "2B"]); + } +} diff --git a/src-map-editor/src/editor/soldiers.rs b/src-map-editor/src/editor/soldiers.rs new file mode 100644 index 0000000..14f93ac --- /dev/null +++ b/src-map-editor/src/editor/soldiers.rs @@ -0,0 +1,1640 @@ +//! Soldier placements: placing, erasing, selecting, moving, copying and deleting them with the +//! soldiers mode (the Mercs tab of the original editor, `EditorMercs.cc`) +//! +//! Only the soldiers on the floor of the render options are edited. The `place` tool places a +//! soldier of the team of the mode with the defaults of the mode with a left click, on the roof +//! floor only on flat roofs. With `erase` its brush removes the shown soldiers of the floor on +//! the tiles instead. The `select` tool selects the soldier of the clicked tile, clicking it again +//! selects the next soldier of the tile. Dragging the selected soldier moves it, dragging it while +//! Ctrl (Cmd on macOS) was held on the press copies it, both keep its level. Delete removes the selected soldier, Esc deselects it and 1 to 9 set its +//! patrol points at the cursor (Shift removes them). Every change can be undone, the original +//! could not undo them. The host edits the selected soldier with the soldier commands, see +//! `soldier_events.rs`. + +use std::sync::Arc; + +use bevy::{ecs::system::SystemParam, prelude::*}; +use serde::Deserialize; + +use crate::{ + item::ItemTable, + map::{ + AppearanceContext, Attitude, DetailedPlacement, Direction, GridNo, Level, Orders, + PlacementId, ProfileId, SoldierPlacement, SoldierStats, TacticalMap, MAX_NUM_SOLDIERS, + MAX_PATROL_POINTS, + }, + render::{draw_list::VisibleTeams, soldiers::flat_roof_above, world::MapWorld, RenderOptions}, + soldier::{ + body_type::{SoldierBodyType, SoldierBodyTypeId}, + class::SoldierClassId, + palette_files::PaletteReplacements, + team::TeamId, + SoldierDefinitions, SoldierRegistries, + }, + states::loading::MapAssets, + systems::cursor::CursorInfo, + tileset::{StructureFlags, TilesetId, PROFILE_Z_SIZE}, +}; + +use super::{ + brush::{Brush, BrushArea}, + commands::CurrentMode, + document::EditContext, + mode::EditorMode, + schedules::ScheduleEditing, + MapEdit, +}; + +/// Why a soldier could not be placed, moved or copied +const CANT_STAND: &str = "A soldier can't stand there"; +/// Why a soldier or an item can't go onto the roof of a tile +pub const NO_FLAT_ROOF: &str = "There is no flat roof here"; + +/// Most soldiers of a team (the team sizes of `g_default_team_info`) +pub const MAX_TEAM_SOLDIERS: usize = 32; +/// Most soldiers of a map (`MAX_NUM_SOLDIERS`) +pub const MAX_SOLDIERS: usize = MAX_NUM_SOLDIERS; +/// Number of civilian groups (`NUM_CIV_GROUPS`) +pub const NUM_CIV_GROUPS: u8 = 20; +/// The tileset of the mines, whose civilians are miners (`CAVES_1`) +const CAVES_TILESET: TilesetId = TilesetId::new(1); + +/// The teams the editor places soldiers of (the draw modes `DRAW_MODE_ENEMY` and so on) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SoldierTeam { + #[default] + Enemy, + Creature, + Rebel, + Civilian, +} + +impl SoldierTeam { + pub const ALL: [Self; 4] = [Self::Enemy, Self::Creature, Self::Rebel, Self::Civilian]; + + pub fn team_id(self) -> TeamId { + match self { + Self::Enemy => TeamId::ENEMY, + Self::Creature => TeamId::CREATURE, + Self::Rebel => TeamId::MILITIA, + Self::Civilian => TeamId::CIV, + } + } + + /// The team of the editor of a team of the game, `None` for the player's teams + pub fn of(team: TeamId) -> Option { + Self::ALL.into_iter().find(|t| t.team_id() == team) + } + + /// The number of the first soldier of the team (`bFirstID`), the soldiers of the editor are + /// numbered like the game numbers them + pub fn first_slot(self) -> usize { + // Our team has 20 soldiers + 20 + MAX_TEAM_SOLDIERS * self as usize + } + + /// The body types the editor offers, `None` is a random one (`bEnemyArray` and so on) + pub fn body_types(self) -> &'static [Option] { + use SoldierBodyTypeId as B; + match self { + Self::Enemy => &[ + None, + Some(B::REGMALE), + Some(B::BIGMALE), + Some(B::STOCKYMALE), + Some(B::REGFEMALE), + Some(B::TANK_NW), + Some(B::TANK_NE), + ], + Self::Creature => &[ + Some(B::BLOODCAT), + Some(B::LARVAE_MONSTER), + Some(B::INFANT_MONSTER), + Some(B::YAF_MONSTER), + Some(B::YAM_MONSTER), + Some(B::ADULTFEMALEMONSTER), + Some(B::AM_MONSTER), + Some(B::QUEENMONSTER), + ], + Self::Rebel => &[ + None, + Some(B::FATCIV), + Some(B::MANCIV), + Some(B::REGMALE), + Some(B::BIGMALE), + Some(B::STOCKYMALE), + Some(B::REGFEMALE), + ], + Self::Civilian => &[ + None, + Some(B::FATCIV), + Some(B::MANCIV), + Some(B::MINICIV), + Some(B::DRESSCIV), + Some(B::HATKIDCIV), + Some(B::KIDCIV), + Some(B::REGMALE), + Some(B::BIGMALE), + Some(B::STOCKYMALE), + Some(B::REGFEMALE), + Some(B::HUMVEE), + Some(B::ELDORADO), + Some(B::ICECREAMTRUCK), + Some(B::JEEP), + Some(B::CRIPPLECIV), + Some(B::ROBOTNOWEAPON), + Some(B::COW), + ], + } + } +} + +/// The classes the editor offers for enemies, with their names (`SetEnemyColorCode`) +pub const ENEMY_CLASSES: [(SoldierClassId, &str); 3] = [ + (SoldierClassId::ARMY, "Army"), + (SoldierClassId::ADMINISTRATOR, "Admin"), + (SoldierClassId::ELITE, "Elite"), +]; + +/// What new soldiers get, the original keeps the values last set for a soldier +/// (`gbDefaultOrders` and so on) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SoldierDefaults { + pub orders: Orders, + pub attitude: Attitude, + pub direction: Direction, + /// 0 to 4, from bad to great + pub relative_equipment_level: i8, + pub relative_attribute_level: i8, + /// The class of new enemies (`gubSoldierClass`) + pub soldier_class: u8, + /// The civilian group of new civilians (`gubCivGroup`) + pub civilian_group: u8, + /// The body type of new creatures (`gbCurrCreature`) + pub creature_body: u8, +} + +impl Default for SoldierDefaults { + fn default() -> Self { + Self { + orders: Orders::Stationary, + attitude: Attitude::Defensive, + direction: Direction::NorthWest, + relative_equipment_level: 2, + relative_attribute_level: 2, + soldier_class: SoldierClassId::ARMY.index() as u8, + civilian_group: 0, + creature_body: SoldierBodyTypeId::BLOODCAT.index() as u8, + } + } +} + +/// What a click in the soldiers mode does +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SoldierTool { + /// Selects, moves, copies and deletes soldiers + #[default] + Select, + /// Places soldiers, or erases them with the brush + Place, +} + +/// The soldiers mode (`TASK_MERCS` of the original editor) +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SoldierMode { + pub tool: SoldierTool, + /// Removes the shown soldiers of the brush instead of placing them + pub erase: bool, + /// The brush that erases soldiers + pub brush: Brush, + /// The team of new soldiers + pub team: SoldierTeam, + pub defaults: SoldierDefaults, +} + +impl SoldierMode { + /// The brush that erases soldiers, `None` while placing or selecting, which use clicks + pub fn brush(&self) -> Option { + match (self.tool, self.erase, self.brush) { + (SoldierTool::Place, true, Brush::Fill) => Some(Brush::default()), + (SoldierTool::Place, true, brush) => Some(brush), + _ => None, + } + } + + /// Erases the soldiers of the shown teams of the floor on the tiles of the brush + pub fn apply( + &self, + context: &mut EditContext, + area: &BrushArea, + visible: &VisibleTeams, + floor: Level, + ) { + if self.brush().is_some() { + context.erase_soldiers(area, visible, floor); + } + } +} + +/// What edits of soldiers need besides the map +#[derive(Clone, Copy)] +pub struct SoldierContext<'a> { + pub definitions: SoldierDefinitions<'a>, + pub appearance: AppearanceContext<'a>, +} + +/// The soldier definitions, the item table and the palettes as a system parameter +#[derive(SystemParam)] +pub struct SoldierSources<'w> { + registries: SoldierRegistries<'w>, + items: Option>, + map_assets: Option>, + replacements: Res<'w, Assets>, +} + +impl SoldierSources<'_> { + /// `None` until a map is loaded + pub fn context(&self) -> Option> { + let definitions = self.registries.definitions(); + Some(SoldierContext { + definitions, + appearance: AppearanceContext { + items: self.items.as_deref()?, + bodies: definitions.bodies, + palettes: self + .map_assets + .as_ref() + .and_then(|assets| self.replacements.get(&assets.soldier_palettes)), + }, + }) + } +} + +/// Whether the soldier of a placement is shown with these teams visible +pub fn is_shown(placement: &SoldierPlacement, visible: &VisibleTeams) -> bool { + placement.team.placed_by_editor && visible.shows(placement.team.id) +} + +/// Whether a tile has a level that soldiers and items can be on: the ground always, the roof +/// only with a flat roof (`FlatRoofAboveGridNo`) +pub fn has_level(world: &MapWorld, grid_no: GridNo, level: Level) -> bool { + level == Level::Ground || flat_roof_above(world, grid_no) +} + +/// Whether a soldier can stand on a level of a tile: the level exists, no structure blocks it +/// and, unless `people` is false, no other soldier stands there (`IsLocationSittable`, +/// `IsLocationSittableExcludingPeople`) +pub fn is_sittable( + map: &TacticalMap, + world: &MapWorld, + grid_no: GridNo, + level: Level, + people: bool, +) -> bool { + if !has_level(world, grid_no, level) { + return false; + } + if people + && map + .soldiers + .iter() + .any(|placement| placement.grid_no == grid_no && placement.level == level) + { + return false; + } + let cube_offset = i16::from(level.number()) * PROFILE_Z_SIZE as i16; + !world.tiles[grid_no].structures.iter().any(|structure| { + structure.flags.intersects(StructureFlags::BLOCKSMOVES) + && !structure.flags.contains(StructureFlags::PASSABLE) + && structure.cube_offset == cube_offset + }) +} + +/// The first shown soldier on a level of a tile (`IsMercHere`) +pub fn soldier_at( + map: &TacticalMap, + grid_no: GridNo, + visible: &VisibleTeams, + level: Level, +) -> Option { + soldiers_on(map, grid_no, visible, level).first().copied() +} + +/// The placement with an id +pub fn placement(map: &TacticalMap, id: PlacementId) -> Option<&SoldierPlacement> { + map.soldiers.iter().find(|placement| placement.id == id) +} + +/// The shown soldiers on a level of a tile, in the order of the placements +fn soldiers_on( + map: &TacticalMap, + grid_no: GridNo, + visible: &VisibleTeams, + level: Level, +) -> Vec { + map.soldiers + .iter() + .filter(|p| p.grid_no == grid_no && p.level == level && is_shown(p, visible)) + .map(|p| p.id) + .collect() +} + +/// Why a soldier can't stand on a level of a tile, `None` if it can +fn standing_error( + map: &TacticalMap, + world: &MapWorld, + grid_no: GridNo, + level: Level, +) -> Option { + if !has_level(world, grid_no, level) { + Some(NO_FLAT_ROOF.to_owned()) + } else if !is_sittable(map, world, grid_no, level, true) { + Some(CANT_STAND.to_owned()) + } else { + None + } +} + +/// The slot of the game that a soldier gets, its number when the editor creates the soldiers +/// team by team (`ubID`, shown as `Slot #`) +pub fn soldier_slot(map: &TacticalMap, id: PlacementId) -> Option { + let placement = placement(map, id)?; + let team = SoldierTeam::of(placement.team.id)?; + let index = map + .soldiers + .iter() + .filter(|p| p.team.id == placement.team.id) + .position(|p| p.id == id)?; + Some(team.first_slot() + index) +} + +/// Why a soldier could not be added +pub fn can_add_soldier(map: &TacticalMap, team: TeamId) -> Result<(), String> { + if map.soldiers.len() + map.skipped_placements.len() >= MAX_SOLDIERS { + return Err(format!("The map already has {} soldiers", MAX_SOLDIERS)); + } + let in_team = map.soldiers.iter().filter(|p| p.team.id == team).count(); + if in_team >= MAX_TEAM_SOLDIERS { + return Err(format!( + "The team already has {} soldiers", + MAX_TEAM_SOLDIERS + )); + } + Ok(()) +} + +/// The class and body type that soldiers of the team of the mode get, and the civilian group +/// (`AddMercToWorld`) +fn team_values( + context: &EditContext, + sc: &SoldierContext, + mode: &SoldierMode, +) -> (SoldierClassId, Option>, u8) { + let defaults = &mode.defaults; + match mode.team { + SoldierTeam::Enemy => { + let class = ENEMY_CLASSES + .iter() + .map(|&(id, _)| id) + .find(|id| id.index() == usize::from(defaults.soldier_class)) + .unwrap_or(SoldierClassId::ARMY); + (class, None, 0) + } + SoldierTeam::Creature => { + let body = SoldierTeam::Creature + .body_types() + .iter() + .flatten() + .find(|id| id.index() == usize::from(defaults.creature_body)) + .unwrap_or(&SoldierBodyTypeId::BLOODCAT); + ( + SoldierClassId::NONE, + Some(sc.definitions.bodies.get(*body).clone()), + 0, + ) + } + SoldierTeam::Rebel => (SoldierClassId::NONE, None, 0), + SoldierTeam::Civilian => { + let class = if context.map.tileset == CAVES_TILESET { + SoldierClassId::MINER + } else { + SoldierClassId::NONE + }; + (class, None, defaults.civilian_group.min(NUM_CIV_GROUPS - 1)) + } + } +} + +/// A new placement of the team of the mode, with its defaults (`AddMercToWorld`) +fn new_placement( + context: &mut EditContext, + sc: &SoldierContext, + mode: &SoldierMode, + grid_no: GridNo, + level: Level, +) -> SoldierPlacement { + let defaults = &mode.defaults; + let (class, given_body, civilian_group) = team_values(context, sc, mode); + let id = context.document.new_placement_id(); + let team = sc.definitions.teams.get(mode.team.team_id()).clone(); + let soldier_class = sc.definitions.classes.get(class).clone(); + let mut placement = SoldierPlacement { + id, + index: id.0 as usize, + grid_no, + direction: defaults.direction, + level, + // Replaced by `resolve_appearance` + body: given_body.clone().unwrap_or_else(|| { + sc.definitions + .bodies + .get(SoldierBodyTypeId::REGMALE) + .clone() + }), + surface: sc + .definitions + .surfaces + .iter() + .next() + .expect("there are standing surfaces") + .clone(), + palette: crate::soldier::palette::ResolvedSoldierPalette::Own, + team, + soldier_class, + given_body, + relative_attribute_level: defaults.relative_attribute_level.clamp(0, 4), + relative_equipment_level: defaults.relative_equipment_level.clamp(0, 4), + orders: defaults.orders, + attitude: defaults.attitude, + patrol: vec![], + civilian_group, + priority_existence: false, + has_keys: false, + detailed: None, + }; + placement.resolve_appearance(&sc.appearance); + placement +} + +/// A detailed placement with nothing fixed yet, the soldier keeps what the game generates +/// (`CreateStaticDetailedPlacementGivenBasicPlacementInfo`) +pub fn static_detailed_placement(placement: &SoldierPlacement) -> DetailedPlacement { + DetailedPlacement { + is_static: true, + profile: None, + copy_profile_items_over: false, + sector: None, + insertion_grid_no: Some(placement.grid_no), + stats: SoldierStats::default(), + inventory: std::array::from_fn(|_| None), + head_palette: String::new(), + pants_palette: String::new(), + vest_palette: String::new(), + skin_palette: String::new(), + visible: false, + name: String::new(), + schedule: None, + vehicle_id: None, + } +} + +impl EditContext<'_> { + /// Changes a placement, it is recorded for undo if the change changes it + pub fn change_soldier( + &mut self, + id: PlacementId, + change: impl FnOnce(&mut SoldierPlacement), + ) -> bool { + let Some(index) = self.map.soldiers.iter().position(|p| p.id == id) else { + return false; + }; + let mut changed = self.map.soldiers[index].clone(); + change(&mut changed); + if changed == self.map.soldiers[index] { + return false; + } + self.modify_soldiers().0[index] = changed; + true + } + + /// Places a new soldier of the team of the mode on a level of a tile where a soldier can + /// stand, returns it (`AddMercToWorld`) + pub fn place_soldier( + &mut self, + sc: &SoldierContext, + mode: &SoldierMode, + grid_no: GridNo, + level: Level, + ) -> Result { + can_add_soldier(self.map, mode.team.team_id())?; + if let Some(error) = standing_error(self.map, self.world, grid_no, level) { + return Err(error); + } + let placement = new_placement(self, sc, mode, grid_no, level); + let id = placement.id; + self.modify_soldiers().0.push(placement); + Ok(id) + } + + /// Copies a soldier where a soldier can stand on its level, returns the copy + /// (`CopyMercPlacement`, `PasteMercPlacement`) + /// + /// Unlike the original, the copy keeps the team, the class, the civilian group and the body + /// type. It doesn't get the schedule, two soldiers can't follow the same one. + pub fn copy_soldier( + &mut self, + sc: &SoldierContext, + id: PlacementId, + grid_no: GridNo, + ) -> Result { + let Some(copy) = placement(self.map, id) else { + return Err("The soldier is gone".to_owned()); + }; + can_add_soldier(self.map, copy.team.id)?; + if let Some(error) = standing_error(self.map, self.world, grid_no, copy.level) { + return Err(error); + } + let mut placement = copy.clone(); + let id = self.document.new_placement_id(); + placement.id = id; + placement.index = id.0 as usize; + placement.grid_no = grid_no; + if let Some(detailed) = &mut placement.detailed { + detailed.insertion_grid_no = Some(grid_no); + detailed.schedule = None; + } + placement.resolve_appearance(&sc.appearance); + self.modify_soldiers().0.push(placement); + Ok(id) + } + + /// Moves a soldier where a soldier can stand on its level (`HandleRightClickOnMerc`) + pub fn move_soldier(&mut self, id: PlacementId, grid_no: GridNo) -> Result<(), String> { + let Some(level) = placement(self.map, id).map(|p| p.level) else { + return Ok(()); + }; + if let Some(error) = standing_error(self.map, self.world, grid_no, level) { + return Err(error); + } + self.change_soldier(id, |placement| { + placement.grid_no = grid_no; + if let Some(detailed) = &mut placement.detailed { + detailed.insertion_grid_no = Some(grid_no); + } + }); + Ok(()) + } + + /// Removes the soldiers that match and their schedules, only recorded if any match + fn remove_soldiers(&mut self, matches: impl Fn(&SoldierPlacement) -> bool) { + if !self.map.soldiers.iter().any(&matches) { + return; + } + let (soldiers, schedules) = self.modify_soldiers(); + let mut removed = vec![]; + soldiers.retain(|placement| { + if !matches(placement) { + return true; + } + removed.extend(placement.detailed.as_ref().and_then(|d| d.schedule)); + false + }); + schedules.retain(|schedule| !removed.contains(&schedule.id)); + } + + /// Removes a soldier and its schedule (`DeleteSelectedMerc`) + pub fn delete_soldier(&mut self, id: PlacementId) { + self.remove_soldiers(|placement| placement.id == id); + } + + /// Removes the shown soldiers on a level of the tiles of the brush and their schedules, only + /// recorded if there are any + pub fn erase_soldiers(&mut self, area: &BrushArea, visible: &VisibleTeams, level: Level) { + self.remove_soldiers(|placement| { + is_shown(placement, visible) + && placement.level == level + && area.tiles.contains(&placement.grid_no) + }); + } + + /// Sets patrol point `number` (1 to 9) to a tile, the points before it that are missing get + /// the same tile (`AddMercWaypoint`) + pub fn set_patrol_point(&mut self, id: PlacementId, number: usize, grid_no: GridNo) { + if !(1..=MAX_PATROL_POINTS).contains(&number) { + return; + } + self.change_soldier(id, |placement| { + let patrol = &mut placement.patrol; + if number > patrol.len() { + patrol.resize(number, grid_no); + } else { + patrol[number - 1] = grid_no; + } + }); + } + + /// Removes patrol point `number` (1 to 9), the later ones move up (`EraseMercWaypoint`) + pub fn remove_patrol_point(&mut self, id: PlacementId, number: usize) { + self.change_soldier(id, |placement| { + if (1..=placement.patrol.len()).contains(&number) { + placement.patrol.remove(number - 1); + } + }); + } + + /// Gives a soldier a detailed placement with nothing fixed, or removes it + /// (`InitDetailedPlacementForMerc`, `KillDetailedPlacementForMerc`) + pub fn set_detailed(&mut self, sc: &SoldierContext, id: PlacementId, detailed: bool) { + let Some(placement) = placement(self.map, id) else { + return; + }; + if placement.detailed.is_some() == detailed { + return; + } + let schedule = placement.detailed.as_ref().and_then(|d| d.schedule); + self.change_soldier(id, |placement| { + placement.detailed = detailed.then(|| static_detailed_placement(placement)); + if detailed { + placement.priority_existence = false; + } + placement.resolve_appearance(&sc.appearance); + }); + if let Some(schedule) = schedule { + self.modify_soldiers().1.retain(|s| s.id != schedule); + } + } + + /// Makes a detailed placement a named character or not. A profile brings its items and + /// makes the soldier always exist (`ExtractAndUpdateMercProfile`). + pub fn set_profile( + &mut self, + sc: &SoldierContext, + id: PlacementId, + profile: Option, + ) { + self.change_soldier(id, |placement| { + let Some(detailed) = &mut placement.detailed else { + return; + }; + detailed.profile = profile; + detailed.copy_profile_items_over = profile.is_some(); + if profile.is_some() { + placement.priority_existence = true; + } + placement.resolve_appearance(&sc.appearance); + }); + } +} + +/// A drag of the selected soldier in progress +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SoldierDrag { + /// Where the mouse was pressed + from: GridNo, + /// Whether Ctrl or Cmd was held on the press, which copies the soldier instead of moving it + copy: bool, + /// Whether the soldier was selected before the press, releasing on the same tile then + /// selects the next soldier of the tile + was_selected: bool, +} + +/// The selected soldier and what the mouse does next +#[derive(Resource, Clone, Debug, Default)] +pub struct SoldierSelection { + pub selected: Option, + /// What the host is told after a failed edit + pub notice: Option, + /// The schedule of the selected soldier + pub schedule: ScheduleEditing, + drag: Option, +} + +impl SoldierSelection { + /// Drops the selection when the soldier is gone, e.g. after undo, and keeps the schedule + /// draft up to date + pub fn validate(&mut self, map: &TacticalMap) { + if self.selected.is_some_and(|id| placement(map, id).is_none()) { + self.selected = None; + self.drag = None; + } + self.schedule.sync(map, self.selected); + } + + /// Drops the selection, the drag and the schedule draft + pub fn clear(&mut self) { + self.selected = None; + self.drag = None; + self.schedule = ScheduleEditing::default(); + } + + /// Whether the selected soldier is being dragged + pub fn dragging(&self) -> bool { + self.drag.is_some() + } + + /// Selects the first shown soldier on the floor of a tile, or keeps the selected one if it + /// is there, and starts dragging it, or clears the selection if the tile has no shown + /// soldiers on the floor + pub fn press( + &mut self, + map: &TacticalMap, + visible: &VisibleTeams, + floor: Level, + tile: GridNo, + copy: bool, + ) { + let on_tile = soldiers_on(map, tile, visible, floor); + let was_selected = self.selected.is_some_and(|id| on_tile.contains(&id)); + if !was_selected { + let Some(first) = soldier_at(map, tile, visible, floor) else { + self.selected = None; + self.drag = None; + return; + }; + self.selected = Some(first); + } + self.drag = Some(SoldierDrag { + from: tile, + copy, + was_selected, + }); + } + + /// Ends the drag at a tile, moving or copying the soldier there and selecting it. Releasing + /// where the selected soldier already was selects the next shown soldier on the floor of the + /// tile. + pub fn release( + &mut self, + context: &mut EditContext, + sc: &SoldierContext, + visible: &VisibleTeams, + floor: Level, + to: GridNo, + ) { + let (Some(drag), Some(selected)) = (self.drag.take(), self.selected) else { + return; + }; + if to != drag.from { + let result = if drag.copy { + context.copy_soldier(sc, selected, to) + } else { + context.move_soldier(selected, to).map(|()| selected) + }; + match result { + Ok(id) => self.selected = Some(id), + Err(notice) => self.notice = Some(notice), + } + return; + } + if drag.was_selected { + let on_tile = soldiers_on(context.map, to, visible, floor); + if let Some(position) = on_tile.iter().position(|&id| id == selected) { + self.selected = Some(on_tile[(position + 1) % on_tile.len()]); + } + } + } + + /// Removes the selected soldier + pub fn delete(&mut self, context: &mut EditContext) { + if let Some(selected) = self.selected.take() { + context.delete_soldier(selected); + } + self.drag = None; + } +} + +pub fn reset_soldier_selection(mut selection: ResMut) { + *selection = SoldierSelection::default(); +} + +/// The soldiers mode, `None` for the others +pub fn soldier_mode(mode: &CurrentMode) -> Option<&SoldierMode> { + match &mode.0 { + EditorMode::Soldiers(mode) => Some(mode), + _ => None, + } +} + +const DIGITS: [KeyCode; MAX_PATROL_POINTS] = [ + KeyCode::Digit1, + KeyCode::Digit2, + KeyCode::Digit3, + KeyCode::Digit4, + KeyCode::Digit5, + KeyCode::Digit6, + KeyCode::Digit7, + KeyCode::Digit8, + KeyCode::Digit9, +]; + +/// Edits the map with one undo command +pub fn edit_once(edit: &mut MapEdit, change: impl FnOnce(&mut EditContext)) { + edit.document().undo.begin(); + edit.edit(change); + edit.document().undo.end(); +} + +/// The mouse and the keys of the soldiers mode, erasing is done by `paint` with the brush +pub fn edit_soldiers( + mouse: Res>, + keys: Res>, + cursor: Res, + mode: Res, + options: Res, + sources: SoldierSources, + mut selection: ResMut, + mut edit: MapEdit, +) { + let Some(mode) = soldier_mode(&mode) else { + // Leaving the soldiers mode deselects the soldier + if selection.selected.is_some() || selection.drag.is_some() { + selection.clear(); + } + return; + }; + let Some(sc) = sources.context() else { + return; + }; + let selection = &mut *selection; + let floor = options.floor; + let tile = cursor.tile.map(GridNo::from); + if mode.tool != SoldierTool::Select { + // Only the select tool has a selection + if selection.selected.is_some() || selection.drag.is_some() { + selection.clear(); + } + let (Some(tile), false) = (tile, mode.erase) else { + return; + }; + if mouse.just_pressed(MouseButton::Left) { + let mut result = Ok(()); + edit_once(&mut edit, |context| { + result = context.place_soldier(&sc, mode, tile, floor).map(|_| ()) + }); + if let Err(notice) = result { + selection.notice = Some(notice); + } + } + return; + } + // Cmd on macOS, where Ctrl+click is a right click + let ctrl = keys.any_pressed([ + KeyCode::ControlLeft, + KeyCode::ControlRight, + KeyCode::SuperLeft, + KeyCode::SuperRight, + ]); + let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + let visible = &options.layers.soldier_teams; + if let Some(tile) = tile.filter(|_| mouse.just_pressed(MouseButton::Left)) { + // The click picks a tile of the schedule first + let mut picked = false; + edit_once(&mut edit, |context| { + picked = selection.schedule.click(context, tile) + }); + if !picked { + selection.press(&edit.map, visible, floor, tile, ctrl); + } + } + if selection.drag.is_some() { + if cursor.world_position.is_none() { + // Leaving the viewer cancels the drag, like the area brushes + selection.drag = None; + } else if !mouse.pressed(MouseButton::Left) { + match tile { + Some(to) => edit_once(&mut edit, |context| { + selection.release(context, &sc, visible, floor, to) + }), + None => selection.drag = None, + } + } + } + if let (Some(tile), Some(selected)) = (tile, selection.selected.filter(|_| !ctrl)) { + if let Some(number) = DIGITS.iter().position(|&key| keys.just_pressed(key)) { + edit_once(&mut edit, |context| { + if shift { + context.remove_patrol_point(selected, number + 1); + } else { + context.set_patrol_point(selected, number + 1, tile); + } + }); + } + } + if selection.drag.is_none() && keys.any_just_pressed([KeyCode::Delete, KeyCode::Backspace]) { + edit_once(&mut edit, |context| selection.delete(context)); + } + if keys.just_pressed(KeyCode::Escape) { + if selection.schedule.pick.is_some() { + edit_once(&mut edit, |context| selection.schedule.cancel_pick(context)); + } else { + selection.selected = None; + selection.drag = None; + } + } + selection.validate(&edit.map); + // Hidden soldiers and those on the other floor can't stay selected + // (`SetMercTeamVisibility`) + if selection + .selected + .and_then(|id| placement(&edit.map, id)) + .is_some_and(|p| !is_shown(p, visible) || p.level != floor) + { + selection.selected = None; + selection.drag = None; + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{ + components::map::GridPosition, + editor::document::tests::{grid, Fixture}, + file_formats::ja2_tile_types::FIRSTFULLSTRUCT, + item::{HandItem, ItemFile, ItemInfo, ItemTable}, + map::{Schedule, ScheduleFlags, ScheduleId}, + soldier::Registries, + tileset::tests::structure_tileset, + }; + + /// A map to edit soldiers on, with the soldier definitions + pub(crate) struct SoldierFixture { + pub fixture: Fixture, + pub registries: Registries, + items: ItemTable, + pub palettes: PaletteReplacements, + } + + impl SoldierFixture { + pub(crate) fn new() -> Self { + let items = ItemTable::new([&ItemFile { + items: vec![ItemInfo { + index: crate::item::ItemId::new(1).unwrap(), + graphic: None, + hand: Some(HandItem::OneHandedGun), + kind: Default::default(), + }], + }]); + Self { + fixture: Fixture::with_tileset(structure_tileset()), + registries: Registries::build(), + items, + palettes: PaletteReplacements::coloured(&[ + "PINKSKIN", + "TANSKIN", + "DARKSKIN", + "BLACKSKIN", + "BROWNHEAD", + "BLACKHEAD", + "WHITEHEAD", + "BLONDHEAD", + "REDHEAD", + "REDVEST", + "YELLOWVEST", + "BLACKSHIRT", + "WHITEVEST", + "GREENPANTS", + "BLACKPANTS", + "TANPANTS", + "BLUEPANTS", + ]), + } + } + + /// Edits the map as one undo command + pub(crate) fn edit( + &mut self, + edit: impl FnOnce(&mut EditContext, &SoldierContext) -> R, + ) -> R { + let sc = SoldierContext { + definitions: self.registries.definitions(), + appearance: AppearanceContext { + items: &self.items, + bodies: &self.registries.bodies, + palettes: Some(&self.palettes), + }, + }; + self.fixture.document.undo.begin(); + let mut context = self.fixture.context(); + let result = edit(&mut context, &sc); + context.finish(); + self.fixture.document.undo.end(); + result + } + + pub(crate) fn map(&self) -> &TacticalMap { + &self.fixture.map + } + + pub(crate) fn soldier(&self, id: PlacementId) -> &SoldierPlacement { + placement(self.map(), id).unwrap() + } + + pub(crate) fn undo(&mut self) { + let command = self.fixture.document.undo.pop().unwrap(); + let mut context = self.fixture.context(); + context.restore(command); + context.finish(); + } + } + + pub(crate) fn mode(json: &str) -> SoldierMode { + match serde_json::from_str(json).unwrap() { + EditorMode::Soldiers(mode) => mode, + _ => panic!("not the soldiers mode"), + } + } + + #[test] + fn modes_are_read_from_json() { + let mode = mode( + r#"{"mode":"soldiers","team":"civilian", + "defaults":{"orders":"pointPatrol","attitude":"cunningAid","direction":"south", + "relativeEquipmentLevel":4,"civilianGroup":3,"creatureBody":8}}"#, + ); + assert_eq!(mode.team, SoldierTeam::Civilian); + let defaults = mode.defaults; + assert_eq!(defaults.orders, Orders::PointPatrol); + assert_eq!(defaults.attitude, Attitude::CunningAid); + assert_eq!(defaults.direction, Direction::South); + assert_eq!(defaults.relative_equipment_level, 4); + // Missing values are the defaults of the original + assert_eq!(defaults.relative_attribute_level, 2); + assert_eq!(defaults.soldier_class, 3); + let default = super::tests::mode(r#"{"mode":"soldiers"}"#); + assert_eq!(default, SoldierMode::default()); + assert_eq!(default.defaults.direction, Direction::NorthWest); + assert_eq!( + (default.tool, default.erase, default.brush), + (SoldierTool::Select, false, Brush::default()) + ); + // Only erasing uses the brush + assert_eq!(default.brush(), None); + let erasing = super::tests::mode( + r#"{"mode":"soldiers","tool":"place","erase":true, + "brush":{"kind":"circle","size":5}}"#, + ); + assert_eq!(erasing.brush(), Some(Brush::Circle { size: 5 })); + let select = super::tests::mode(r#"{"mode":"soldiers","tool":"select","erase":true}"#); + assert_eq!(select.brush(), None); + let fill = super::tests::mode( + r#"{"mode":"soldiers","tool":"place","erase":true,"brush":{"kind":"fill"}}"#, + ); + assert_eq!(fill.brush(), Some(Brush::default())); + } + + #[test] + fn placing_uses_the_defaults_of_the_mode() { + let mut fixture = SoldierFixture::new(); + let mode = mode( + r#"{"mode":"soldiers","tool":"place","team":"enemy", + "defaults":{"orders":"onGuard","soldierClass":2,"direction":"east"}}"#, + ); + let placed = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(50, 50), Level::Ground)) + .unwrap(); + let soldier = fixture.soldier(placed); + assert_eq!(soldier.team.id, TeamId::ENEMY); + assert_eq!(soldier.soldier_class.id, SoldierClassId::ELITE); + assert_eq!(soldier.orders, Orders::OnGuard); + assert_eq!(soldier.direction, Direction::East); + assert_eq!(soldier.given_body, None); + assert!(soldier.detailed.is_none()); + assert_eq!(soldier_slot(fixture.map(), placed), Some(20)); + let second = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(52, 50), Level::Ground)) + .unwrap(); + assert_eq!(soldier_slot(fixture.map(), second), Some(21)); + + // Creatures get the body type of the mode + let creatures = super::tests::mode( + r#"{"mode":"soldiers","team":"creature","defaults":{"creatureBody":10}}"#, + ); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &creatures, grid(60, 60), Level::Ground)) + .unwrap(); + let creature = fixture.soldier(id); + assert_eq!(creature.body.id, SoldierBodyTypeId::QUEENMONSTER); + assert_eq!( + creature.given_body.as_ref().unwrap().id, + SoldierBodyTypeId::QUEENMONSTER + ); + assert_eq!(soldier_slot(fixture.map(), creature.id), Some(52)); + } + + /// Releases the drag of the selection as one undo command + fn release( + fixture: &mut SoldierFixture, + selection: &mut SoldierSelection, + visible: &VisibleTeams, + to: GridNo, + ) { + fixture.edit(|context, sc| selection.release(context, sc, visible, Level::Ground, to)); + } + + #[test] + fn pressing_selects_the_soldiers() { + let mut fixture = SoldierFixture::new(); + let enemies = mode(r#"{"mode":"soldiers","team":"enemy"}"#); + let civilians = mode(r#"{"mode":"soldiers","team":"civilian"}"#); + let (a, b) = (grid(50, 50), grid(40, 60)); + let first = fixture + .edit(|context, sc| context.place_soldier(sc, &enemies, a, Level::Ground)) + .unwrap(); + let second = fixture + .edit(|context, sc| context.place_soldier(sc, &civilians, b, Level::Ground)) + .unwrap(); + let all = VisibleTeams::default(); + let mut selection = SoldierSelection::default(); + selection.press(fixture.map(), &all, Level::Ground, b, false); + assert_eq!(selection.selected, Some(second)); + assert!(selection.dragging()); + release(&mut fixture, &mut selection, &all, b); + assert_eq!(selection.selected, Some(second)); + assert!(!selection.dragging()); + selection.press(fixture.map(), &all, Level::Ground, a, false); + assert_eq!(selection.selected, Some(first)); + // Tiles without shown soldiers deselect + selection.press(fixture.map(), &all, Level::Ground, grid(10, 10), false); + assert_eq!(selection.selected, None); + assert!(!selection.dragging()); + let no_enemies = VisibleTeams { + enemy: false, + ..all + }; + selection.press(fixture.map(), &no_enemies, Level::Ground, a, false); + assert_eq!(selection.selected, None); + // Nothing was changed + assert_eq!(fixture.fixture.document.undo.depth(), 2); + + selection.press(fixture.map(), &all, Level::Ground, a, false); + release(&mut fixture, &mut selection, &all, a); + fixture.edit(|context, _| selection.delete(context)); + assert_eq!(selection.selected, None); + assert_eq!(fixture.map().soldiers.len(), 1); + fixture.undo(); + assert_eq!(fixture.map().soldiers.len(), 2); + selection.selected = Some(second); + fixture.undo(); + selection.validate(fixture.map()); + assert_eq!(selection.selected, None); + } + + #[test] + fn releasing_on_the_selected_soldier_selects_the_next_one_of_the_tile() { + let mut fixture = SoldierFixture::new(); + let mode = mode(r#"{"mode":"soldiers"}"#); + let a = grid(30, 30); + let roof = fixture + .fixture + .tileset + .tile( + fixture + .fixture + .tileset + .tile_of("FIRSTROOF", 1) + .or_else(|| fixture.fixture.tileset.tile_of("ROOF1", 1)) + .unwrap(), + ) + .unwrap() + .clone(); + fixture.edit(|context, _| context.add_roof_to_head(a, roof)); + let below = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, a, Level::Ground)) + .unwrap(); + let above = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, a, Level::Roof)) + .unwrap(); + let next = fixture + .edit(|context, sc| { + context.place_soldier(sc, &mode, a.offset(1).unwrap(), Level::Ground) + }) + .unwrap(); + // A second soldier on the ground of the tile, which `place_soldier` doesn't allow + fixture.edit(|context, _| { + context.change_soldier(next, |p| p.grid_no = a); + }); + let all = VisibleTeams::default(); + let mut selection = SoldierSelection::default(); + // The first press only selects, then the next soldier on the floor of the tile + selection.press(fixture.map(), &all, Level::Ground, a, false); + release(&mut fixture, &mut selection, &all, a); + assert_eq!(selection.selected, Some(below)); + selection.press(fixture.map(), &all, Level::Ground, a, false); + release(&mut fixture, &mut selection, &all, a); + assert_eq!(selection.selected, Some(next)); + selection.press(fixture.map(), &all, Level::Ground, a, false); + release(&mut fixture, &mut selection, &all, a); + assert_eq!(selection.selected, Some(below)); + // The soldier on the roof is only selected on the roof floor + selection.press(fixture.map(), &all, Level::Roof, a, false); + fixture.edit(|context, sc| selection.release(context, sc, &all, Level::Roof, a)); + assert_eq!(selection.selected, Some(above)); + selection.press(fixture.map(), &all, Level::Roof, a, false); + fixture.edit(|context, sc| selection.release(context, sc, &all, Level::Roof, a)); + assert_eq!(selection.selected, Some(above)); + assert_eq!(fixture.map().soldiers.len(), 3); + } + + #[test] + fn dragging_moves_the_soldier() { + let mut fixture = SoldierFixture::new(); + let mode = mode(r#"{"mode":"soldiers"}"#); + let (a, b) = (grid(30, 30), grid(35, 35)); + let all = VisibleTeams::default(); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, a, Level::Ground)) + .unwrap(); + fixture.edit(|context, sc| context.set_detailed(sc, id, true)); + let other = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(40, 40), Level::Ground)) + .unwrap(); + let mut selection = SoldierSelection::default(); + // Without a press, nothing moves + release(&mut fixture, &mut selection, &all, b); + assert_eq!(fixture.soldier(id).grid_no, a); + + selection.press(fixture.map(), &all, Level::Ground, a, false); + release(&mut fixture, &mut selection, &all, b); + assert_eq!(selection.selected, Some(id)); + let moved = fixture.soldier(id); + assert_eq!(moved.grid_no, b); + let detailed = moved.detailed.as_ref().unwrap(); + assert_eq!(detailed.insertion_grid_no, Some(b)); + // Not onto another soldier + let depth = fixture.fixture.document.undo.depth(); + selection.press(fixture.map(), &all, Level::Ground, b, false); + release(&mut fixture, &mut selection, &all, grid(40, 40)); + assert!(selection.notice.take().is_some()); + assert_eq!(fixture.soldier(id).grid_no, b); + assert_eq!(fixture.soldier(other).grid_no, grid(40, 40)); + assert_eq!(fixture.fixture.document.undo.depth(), depth); + + fixture.undo(); + assert_eq!(fixture.soldier(id).grid_no, a); + } + + #[test] + fn dragging_with_ctrl_copies_the_soldier() { + let mut fixture = SoldierFixture::new(); + let civilians = + mode(r#"{"mode":"soldiers","team":"civilian","defaults":{"civilianGroup":4}}"#); + let all = VisibleTeams::default(); + let (a, b) = (grid(30, 30), grid(31, 31)); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &civilians, a, Level::Ground)) + .unwrap(); + let schedule = ScheduleId::new(4).unwrap(); + fixture.fixture.map.schedules = vec![Schedule { + id: schedule, + soldier_id: 0, + flags: ScheduleFlags::empty(), + actions: [None; 4], + }]; + fixture.edit(|context, sc| { + context.set_detailed(sc, id, true); + context.set_patrol_point(id, 2, grid(33, 33)); + context.change_soldier(id, |p| { + p.orders = Orders::PointPatrol; + let detailed = p.detailed.as_mut().unwrap(); + detailed.stats.agility = Some(77); + detailed.schedule = Some(schedule); + }); + }); + let mut selection = SoldierSelection::default(); + selection.press(fixture.map(), &all, Level::Ground, a, true); + release(&mut fixture, &mut selection, &all, b); + let copy = selection.selected.unwrap(); + assert_ne!(copy, id); + assert_eq!(fixture.map().soldiers.len(), 2); + assert_eq!(fixture.soldier(id).grid_no, a); + let copied = fixture.soldier(copy); + assert_eq!(copied.grid_no, b); + assert_eq!(copied.team.id, TeamId::CIV); + assert_eq!(copied.civilian_group, 4); + assert_eq!(copied.orders, Orders::PointPatrol); + assert_eq!(copied.patrol, [grid(33, 33), grid(33, 33)]); + assert_eq!(soldier_slot(fixture.map(), copy), Some(117)); + let detailed = copied.detailed.as_ref().unwrap(); + assert_eq!(detailed.stats.agility, Some(77)); + assert_eq!(detailed.insertion_grid_no, Some(b)); + // Two soldiers can't follow the same schedule + assert_eq!(detailed.schedule, None); + assert_eq!(fixture.map().schedules.len(), 1); + + // The copy is undone as a whole + fixture.undo(); + selection.validate(fixture.map()); + assert_eq!(selection.selected, None); + assert_eq!(fixture.map().soldiers.len(), 1); + } + + #[test] + fn erasing_removes_the_shown_soldiers_of_the_brush() { + let mut fixture = SoldierFixture::new(); + let enemies = mode(r#"{"mode":"soldiers","team":"enemy"}"#); + let civilians = mode(r#"{"mode":"soldiers","team":"civilian"}"#); + let mut place = |mode: &SoldierMode, x| { + fixture + .edit(|context, sc| context.place_soldier(sc, mode, grid(x, 50), Level::Ground)) + .unwrap() + }; + let inside = place(&enemies, 50); + let hidden = place(&civilians, 51); + let outside = place(&enemies, 60); + let schedule = ScheduleId::new(2).unwrap(); + fixture.fixture.map.schedules = vec![Schedule { + id: schedule, + soldier_id: 0, + flags: ScheduleFlags::empty(), + actions: [None; 4], + }]; + fixture.edit(|context, sc| { + context.set_detailed(sc, inside, true); + context.change_soldier(inside, |p| { + p.detailed.as_mut().unwrap().schedule = Some(schedule) + }); + }); + let erase = mode( + r#"{"mode":"soldiers","tool":"place","erase":true, + "brush":{"kind":"square","size":3}}"#, + ); + let no_civilians = VisibleTeams { + civilian: false, + ..VisibleTeams::default() + }; + let area = erase.brush().unwrap().region(None, grid(50, 50).position()); + let depth = fixture.fixture.document.undo.depth(); + fixture.edit(|context, _| erase.apply(context, &area, &no_civilians, Level::Ground)); + let ids: Vec<_> = fixture.map().soldiers.iter().map(|p| p.id).collect(); + assert_eq!(ids, [hidden, outside]); + assert!(fixture.map().schedules.is_empty()); + assert_eq!(fixture.fixture.document.undo.depth(), depth + 1); + // Nothing to erase is not recorded + fixture.edit(|context, _| erase.apply(context, &area, &no_civilians, Level::Ground)); + assert_eq!(fixture.fixture.document.undo.depth(), depth + 1); + + fixture.undo(); + assert_eq!(fixture.map().soldiers.len(), 3); + assert_eq!(fixture.map().schedules.len(), 1); + } + + #[test] + fn soldiers_only_stand_where_they_fit() { + let mut fixture = SoldierFixture::new(); + let mode = mode(r#"{"mode":"soldiers"}"#); + let tree = fixture + .fixture + .tileset + .tile(fixture.fixture.tileset.index(FIRSTFULLSTRUCT, 1).unwrap()) + .unwrap() + .clone(); + fixture.edit(|context, _| context.add_struct_to_head(grid(40, 40), tree)); + assert!(!is_sittable( + fixture.map(), + &fixture.fixture.world, + grid(40, 40), + Level::Ground, + true + )); + let result = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(40, 40), Level::Ground)); + assert!(result.is_err()); + + let placed = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(30, 30), Level::Ground)) + .unwrap(); + // Not on another soldier + let result = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(30, 30), Level::Ground)); + assert!(result.is_err()); + // Not on the roof without a flat roof + let roof = + fixture.edit(|context, sc| context.place_soldier(sc, &mode, grid(31, 30), Level::Roof)); + assert_eq!(roof, Err(NO_FLAT_ROOF.to_owned())); + assert_eq!(fixture.soldier(placed).level, Level::Ground); + } + + #[test] + fn teams_and_maps_have_a_limit() { + let mut fixture = SoldierFixture::new(); + let enemies = mode(r#"{"mode":"soldiers","team":"enemy"}"#); + let civilians = mode(r#"{"mode":"soldiers","team":"civilian"}"#); + for x in 0..MAX_TEAM_SOLDIERS as u8 { + fixture + .edit(|context, sc| { + context.place_soldier(sc, &enemies, grid(10 + x, 20), Level::Ground) + }) + .unwrap(); + } + let result = fixture + .edit(|context, sc| context.place_soldier(sc, &enemies, grid(10, 30), Level::Ground)); + assert!(result.unwrap_err().contains("team")); + fixture + .edit(|context, sc| context.place_soldier(sc, &civilians, grid(10, 30), Level::Ground)) + .unwrap(); + + let skipped = crate::file_formats::ja2_map::Ja2SoldierPlacement::default(); + fixture.fixture.map.skipped_placements = vec![skipped; MAX_SOLDIERS - 33]; + let result = fixture + .edit(|context, sc| context.place_soldier(sc, &civilians, grid(11, 30), Level::Ground)); + assert!(result.unwrap_err().contains("148")); + } + + #[test] + fn deleting_removes_the_soldier_and_its_schedule() { + let mut fixture = SoldierFixture::new(); + let mode = mode(r#"{"mode":"soldiers","team":"civilian"}"#); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(30, 30), Level::Ground)) + .unwrap(); + fixture.edit(|context, sc| context.set_detailed(sc, id, true)); + let schedule = ScheduleId::new(4).unwrap(); + fixture.fixture.map.schedules = vec![Schedule { + id: schedule, + soldier_id: 0, + flags: ScheduleFlags::empty(), + actions: [None; 4], + }]; + fixture + .fixture + .map + .soldiers + .iter_mut() + .for_each(|p| p.detailed.as_mut().unwrap().schedule = Some(schedule)); + let mut selection = SoldierSelection { + selected: Some(id), + ..Default::default() + }; + fixture.edit(|context, _| selection.delete(context)); + assert!(fixture.map().soldiers.is_empty()); + assert!(fixture.map().schedules.is_empty()); + assert_eq!(selection.selected, None); + + // Undo brings both back + fixture.undo(); + assert_eq!(fixture.map().soldiers.len(), 1); + assert_eq!(fixture.map().schedules.len(), 1); + assert!(fixture.fixture.document.soldiers_dirty); + selection.selected = Some(id); + selection.validate(fixture.map()); + assert_eq!(selection.selected, Some(id)); + // Undoing the placement drops the selection + fixture.undo(); + fixture.undo(); + selection.validate(fixture.map()); + assert_eq!(selection.selected, None); + } + + #[test] + fn detailed_placements_start_with_nothing_fixed() { + let mut fixture = SoldierFixture::new(); + let mode = mode(r#"{"mode":"soldiers"}"#); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(30, 30), Level::Ground)) + .unwrap(); + fixture.fixture.map.soldiers[0].priority_existence = true; + fixture.edit(|context, sc| context.set_detailed(sc, id, true)); + let soldier = fixture.soldier(id); + let detailed = soldier.detailed.as_ref().unwrap(); + assert!(detailed.is_static); + assert_eq!(detailed.profile, None); + assert_eq!(detailed.stats, SoldierStats::default()); + assert!(detailed.inventory.iter().all(Option::is_none)); + assert_eq!(detailed.insertion_grid_no, Some(grid(30, 30))); + assert!(!soldier.priority_existence); + fixture.edit(|context, sc| context.set_detailed(sc, id, false)); + assert!(fixture.soldier(id).detailed.is_none()); + // Setting it again changes nothing + let depth = fixture.fixture.document.undo.depth(); + fixture.edit(|context, sc| context.set_detailed(sc, id, false)); + assert_eq!(fixture.fixture.document.undo.depth(), depth); + } + + #[test] + fn profiles_make_named_characters() { + let mut fixture = SoldierFixture::new(); + let mode = mode(r#"{"mode":"soldiers"}"#); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(30, 30), Level::Ground)) + .unwrap(); + fixture.edit(|context, sc| { + context.set_detailed(sc, id, true); + context.set_profile(sc, id, ProfileId::new(57)); + }); + let soldier = fixture.soldier(id); + let detailed = soldier.detailed.as_ref().unwrap(); + assert_eq!(detailed.profile.map(ProfileId::number), Some(57)); + assert!(detailed.copy_profile_items_over); + assert!(soldier.priority_existence); + + fixture.edit(|context, sc| context.set_profile(sc, id, None)); + let soldier = fixture.soldier(id); + let detailed = soldier.detailed.as_ref().unwrap(); + assert_eq!(detailed.profile, None); + assert!(!detailed.copy_profile_items_over); + + // Basic placements have no profile + fixture.edit(|context, sc| { + context.set_detailed(sc, id, false); + context.set_profile(sc, id, ProfileId::new(57)); + }); + assert!(fixture.soldier(id).detailed.is_none()); + } + + #[test] + fn patrol_points_fill_the_missing_ones() { + let mut fixture = SoldierFixture::new(); + let mode = mode(r#"{"mode":"soldiers"}"#); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(30, 30), Level::Ground)) + .unwrap(); + fixture.edit(|context, _| { + context.set_patrol_point(id, 3, grid(40, 40)); + context.set_patrol_point(id, 1, grid(41, 41)); + // Only 9 points + context.set_patrol_point(id, 10, grid(42, 42)); + }); + assert_eq!( + fixture.soldier(id).patrol, + [grid(41, 41), grid(40, 40), grid(40, 40)] + ); + fixture.edit(|context, _| { + context.remove_patrol_point(id, 1); + context.remove_patrol_point(id, 5); + }); + assert_eq!(fixture.soldier(id).patrol, [grid(40, 40), grid(40, 40)]); + } + + #[test] + fn soldiers_on_flat_roofs() { + let mut fixture = SoldierFixture::new(); + let mode = mode(r#"{"mode":"soldiers"}"#); + let position = GridPosition::new(30, 30); + let roof = fixture + .fixture + .tileset + .tile( + fixture + .fixture + .tileset + .tile_of("FIRSTROOF", 1) + .or_else(|| fixture.fixture.tileset.tile_of("ROOF1", 1)) + .unwrap(), + ) + .unwrap() + .clone(); + assert!(roof.is_flat_roof()); + fixture.edit(|context, _| { + context.add_roof_to_head(GridNo::from(position), roof.clone()); + context.add_roof_to_head(grid(31, 30), roof); + }); + let id = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(30, 30), Level::Roof)) + .unwrap(); + assert_eq!(fixture.soldier(id).level, Level::Roof); + // A soldier can stand below it + let below = fixture + .edit(|context, sc| context.place_soldier(sc, &mode, grid(30, 30), Level::Ground)) + .unwrap(); + assert_eq!(fixture.soldier(below).level, Level::Ground); + // Only the soldier on the floor is found + let all = VisibleTeams::default(); + assert_eq!( + soldier_at(fixture.map(), grid(30, 30), &all, Level::Roof), + Some(id) + ); + assert_eq!( + soldier_at(fixture.map(), grid(30, 30), &all, Level::Ground), + Some(below) + ); + + // Moved and copied soldiers keep their level where there is a flat roof + let ground_copy = fixture + .edit(|context, sc| context.copy_soldier(sc, below, grid(31, 30))) + .unwrap(); + assert_eq!(fixture.soldier(ground_copy).level, Level::Ground); + fixture + .edit(|context, _| context.move_soldier(id, grid(31, 30))) + .unwrap(); + assert_eq!(fixture.soldier(id).level, Level::Roof); + // Not where there is no flat roof + let copy = fixture.edit(|context, sc| context.copy_soldier(sc, id, grid(40, 40))); + assert_eq!(copy, Err(NO_FLAT_ROOF.to_owned())); + let moved = fixture.edit(|context, _| context.move_soldier(id, grid(41, 40))); + assert_eq!(moved, Err(NO_FLAT_ROOF.to_owned())); + assert_eq!(fixture.soldier(id).grid_no, grid(31, 30)); + + // Erasing only removes the soldiers on the floor + let erase = self::mode(r#"{"mode":"soldiers","tool":"place","erase":true}"#); + let area = erase.brush().unwrap().region(None, grid(31, 30).position()); + let all = VisibleTeams::default(); + fixture.edit(|context, _| erase.apply(context, &area, &all, Level::Roof)); + assert!(fixture.map().soldiers.iter().all(|p| p.id != id)); + assert!(fixture.map().soldiers.iter().any(|p| p.id == ground_copy)); + } +} diff --git a/src-map-editor/src/editor/traversal.rs b/src-map-editor/src/editor/traversal.rs new file mode 100644 index 0000000..110b727 --- /dev/null +++ b/src-map-editor/src/editor/traversal.rs @@ -0,0 +1,868 @@ +//! The traversal mode: exit grids and entry points (the exit grids and entry points of the Map +//! Info tab of the original editor, `DRAW_MODE_EXITGRID` and `SpecifyEntryPoint`) +//! +//! The `place` tool paints exit grids with the brush like the original, with the destination of +//! the mode, they replace the exit grids of the tiles they are painted on. Erasing removes them. +//! A right click copies the values of the clicked exit grid into the mode of the host (`queried` +//! of the `traversal` event). Placing an entry point sets it with a left click, the mode picks +//! which one. Entry points are moved into the area the game can show +//! (`ValidateEntryPointGridNo`). +//! +//! The `select` tool selects the entry point or the exit grid of the clicked tile, clicking it +//! again selects the next one of the tile. Dragging the selection moves it, dragging an exit grid +//! while Ctrl (Cmd on macOS) was held on the press copies it, it replaces the exit grid of the +//! target tile. Delete removes the selected exit grid or clears the selected entry point, the +//! game then falls back to another entry point. Esc deselects. Every change can be undone. The +//! host edits the selection with `set_traversal`, see `traversal_events.rs`. +//! +//! The edge points depend on the entry points and the movement costs, they are generated again +//! after the edits that can move them, see `traversal_events.rs`. + +use bevy::prelude::*; +use serde::Deserialize; + +use crate::{ + components::map::JA2_MAP_TILES, + map::{EntryPoint, ExitGrid, GridNo, Level, SectorId, TacticalMap}, + render::{movement_costs::validate_entry_point_grid_no, RenderOptions}, + systems::cursor::CursorInfo, +}; + +use super::{ + brush::{Brush, BrushArea}, + commands::CurrentMode, + document::EditContext, + mode::EditorMode, + soldiers::edit_once, + MapEdit, +}; + +/// The levels below the surface that a sector can have +pub const MAX_SECTOR_LEVEL: u8 = 3; + +/// What a click in the traversal mode does +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TraversalTool { + /// Selects, moves, copies and deletes exit grids and entry points + #[default] + Select, + /// Paints exit grids or sets an entry point + Place, +} + +/// What the place tool places +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TraversalPlace { + /// Paints exit grids with the brush, or erases them + #[default] + Exit, + /// Sets the entry point of the mode + Entry, +} + +/// The traversal mode +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TraversalMode { + pub tool: TraversalTool, + pub place: TraversalPlace, + /// Removes exit grids instead + pub erase: bool, + /// The brush of the exit grids, square sizes and areas + pub brush: Brush, + /// Where new exit grids lead (`gExitGrid`) + pub dest_sector: SectorId, + pub dest_grid_no: u16, + /// The entry point a click sets + pub entry: EntryPoint, +} + +impl Default for TraversalMode { + fn default() -> Self { + Self { + tool: TraversalTool::Select, + place: TraversalPlace::Exit, + erase: false, + brush: Brush::default(), + dest_sector: SectorId { x: 1, y: 1, z: 0 }, + dest_grid_no: 0, + entry: EntryPoint::North, + } + } +} + +impl TraversalMode { + /// The brush of the exit grids, `None` for selecting and entry points, which use clicks + pub fn brush(&self) -> Option { + match (self.tool, self.place, self.brush) { + ( + TraversalTool::Place, + TraversalPlace::Exit, + brush @ (Brush::Square { .. } | Brush::Area), + ) => Some(brush), + (TraversalTool::Place, TraversalPlace::Exit, _) => Some(Brush::default()), + _ => None, + } + } + + /// The exit grid that is painted on a tile + pub fn exit_grid(&self, grid_no: GridNo) -> ExitGrid { + clamped_exit_grid(grid_no, self.dest_sector, self.dest_grid_no) + } + + /// Paints or erases exit grids on the tiles of the brush + pub fn apply(&self, context: &mut EditContext, area: &BrushArea) { + if self.brush().is_none() { + return; + } + if self.erase { + context.erase_exit_grids(area); + } else { + context.paint_exit_grids(area, self); + } + } +} + +/// An exit grid with the destination clamped like the original clamps the fields +/// (`ExtractAndUpdateMapInfo`) +pub fn clamped_exit_grid(grid_no: GridNo, dest_sector: SectorId, dest_grid_no: u16) -> ExitGrid { + let last = JA2_MAP_TILES - 1; + ExitGrid { + grid_no, + dest_grid_no: GridNo::new(usize::from(dest_grid_no).min(last)).unwrap(), + dest_sector: SectorId { + x: dest_sector.x.clamp(1, 16), + y: dest_sector.y.clamp(1, 16), + z: dest_sector.z.min(MAX_SECTOR_LEVEL), + }, + } +} + +/// The exit grid of a tile +pub fn exit_grid_at(map: &TacticalMap, grid_no: GridNo) -> Option<&ExitGrid> { + map.exit_grids.iter().find(|grid| grid.grid_no == grid_no) +} + +/// What the select tool selects: an entry point or the exit grid of a tile +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TraversalItem { + Entry(EntryPoint), + Exit(GridNo), +} + +impl TraversalItem { + /// The tile of the item, `None` if it is gone + pub fn grid_no(self, map: &TacticalMap) -> Option { + match self { + TraversalItem::Entry(point) => map.info.entry(point), + TraversalItem::Exit(grid_no) => exit_grid_at(map, grid_no).map(|grid| grid.grid_no), + } + } +} + +/// The entry points of a tile in their order, then its exit grid +pub fn items_on(map: &TacticalMap, tile: GridNo) -> Vec { + let entries = EntryPoint::ALL + .into_iter() + .filter(|&point| map.info.entry(point) == Some(tile)) + .map(TraversalItem::Entry); + let exit = exit_grid_at(map, tile).map(|grid| TraversalItem::Exit(grid.grid_no)); + entries.chain(exit).collect() +} + +impl EditContext<'_> { + /// Puts the exit grid of the mode on the tiles of the brush, replacing their exit grids + /// (`AddExitGridToWorld`), only recorded if this changes anything + pub fn paint_exit_grids(&mut self, area: &BrushArea, mode: &TraversalMode) { + let wanted: Vec = area.tiles().map(|g| mode.exit_grid(g)).collect(); + if wanted + .iter() + .all(|grid| exit_grid_at(self.map, grid.grid_no) == Some(grid)) + { + return; + } + let exit_grids = self.modify_exit_grids(); + exit_grids.retain(|grid| !area.tiles.contains(&grid.grid_no)); + exit_grids.extend(wanted); + } + + /// Removes the exit grids of the tiles of the brush (`RemoveExitGridFromWorld`) + pub fn erase_exit_grids(&mut self, area: &BrushArea) { + let on_area = |grid: &ExitGrid| area.tiles.contains(&grid.grid_no); + if !self.map.exit_grids.iter().any(on_area) { + return; + } + self.modify_exit_grids().retain(|grid| !on_area(grid)); + } + + /// Moves the exit grid of a tile to another tile, or copies it there, replacing the exit + /// grid of that tile, returns the tile, `None` if there is no exit grid or it is already there + pub fn move_exit_grid(&mut self, from: GridNo, to: GridNo, copy: bool) -> Option { + if from == to { + return None; + } + let mut grid = *exit_grid_at(self.map, from)?; + grid.grid_no = to; + let exit_grids = self.modify_exit_grids(); + exit_grids.retain(|g| g.grid_no != to && (copy || g.grid_no != from)); + exit_grids.push(grid); + Some(to) + } + + /// Removes the exit grid of a tile (`RemoveExitGridFromWorld`) + pub fn delete_exit_grid(&mut self, grid_no: GridNo) { + if exit_grid_at(self.map, grid_no).is_some() { + self.modify_exit_grids() + .retain(|grid| grid.grid_no != grid_no); + } + } + + /// Changes the destination of the exit grid of a tile, clamped like painted ones, only + /// recorded if this changes it + pub fn change_exit_grid(&mut self, grid_no: GridNo, dest_sector: SectorId, dest_grid_no: u16) { + let Some(index) = self + .map + .exit_grids + .iter() + .position(|g| g.grid_no == grid_no) + else { + return; + }; + let changed = clamped_exit_grid(grid_no, dest_sector, dest_grid_no); + if self.map.exit_grids[index] != changed { + self.modify_exit_grids()[index] = changed; + } + } + + /// Sets or clears an entry point, a tile is moved to where the game can show it + /// (`SpecifyEntryPoint`), only recorded if this changes it + pub fn set_entry_point(&mut self, point: EntryPoint, grid_no: Option) { + let restricted_scroll = self.map.info.restricted_scroll; + let grid_no = + grid_no.map(|grid_no| validate_entry_point_grid_no(grid_no, restricted_scroll)); + if self.map.info.entry(point) != grid_no { + *self.modify_map_info().entry_mut(point) = grid_no; + } + } +} + +/// A drag of the selection in progress +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TraversalDrag { + /// Where the mouse was pressed + from: GridNo, + /// Whether Ctrl or Cmd was held on the press, which copies exit grids instead of moving them + copy: bool, + /// Whether the item was selected before the press, releasing on the same tile then selects + /// the next item of the tile + was_selected: bool, +} + +/// The selected entry point or exit grid, and the exit grid that was right clicked last +#[derive(Resource, Clone, Debug, Default)] +pub struct TraversalSelection { + pub selected: Option, + /// The exit grid that was right clicked last, its values go to the mode of the host + pub queried: Option, + drag: Option, +} + +impl TraversalSelection { + /// Drops the selection when the item is gone, e.g. after undo + pub fn validate(&mut self, map: &TacticalMap) { + if self + .selected + .is_some_and(|item| item.grid_no(map).is_none()) + { + self.selected = None; + self.drag = None; + } + } + + /// Drops the selection and the drag + pub fn clear(&mut self) { + self.selected = None; + self.drag = None; + } + + /// Whether the selection is being dragged + pub fn dragging(&self) -> bool { + self.drag.is_some() + } + + /// Selects the first item of a tile, or keeps the selected one if it is there, and starts + /// dragging it, or clears the selection if the tile has none + pub fn press(&mut self, map: &TacticalMap, tile: GridNo, copy: bool) { + let on_tile = items_on(map, tile); + let was_selected = self.selected.is_some_and(|item| on_tile.contains(&item)); + if !was_selected { + let Some(&first) = on_tile.first() else { + self.clear(); + return; + }; + self.selected = Some(first); + } + self.drag = Some(TraversalDrag { + from: tile, + copy, + was_selected, + }); + } + + /// Ends the drag at a tile, moving the selection there or copying the exit grid there and + /// selecting the copy. Releasing where the selection already was selects the next item of + /// the tile. + pub fn release(&mut self, context: &mut EditContext, to: GridNo) { + let (Some(drag), Some(selected)) = (self.drag.take(), self.selected) else { + return; + }; + if to != drag.from { + match selected { + TraversalItem::Entry(point) => context.set_entry_point(point, Some(to)), + TraversalItem::Exit(from) => { + if let Some(moved) = context.move_exit_grid(from, to, drag.copy) { + self.selected = Some(TraversalItem::Exit(moved)); + } + } + } + return; + } + if drag.was_selected { + let on_tile = items_on(context.map, to); + if let Some(position) = on_tile.iter().position(|&item| item == selected) { + self.selected = Some(on_tile[(position + 1) % on_tile.len()]); + } + } + } + + /// Removes the selected exit grid or clears the selected entry point + pub fn delete(&mut self, context: &mut EditContext) { + match self.selected.take() { + Some(TraversalItem::Entry(point)) => context.set_entry_point(point, None), + Some(TraversalItem::Exit(grid_no)) => context.delete_exit_grid(grid_no), + None => {} + } + self.drag = None; + } + + /// A right click: the exit grid of the tile goes to the host + pub fn query(&mut self, map: &TacticalMap, grid_no: GridNo) { + if let Some(grid) = exit_grid_at(map, grid_no) { + self.queried = Some(*grid); + } + } +} + +pub fn reset_traversal_selection(mut selection: ResMut) { + *selection = TraversalSelection::default(); +} + +/// The traversal mode, `None` for the others +pub fn traversal_mode(mode: &CurrentMode) -> Option<&TraversalMode> { + match &mode.0 { + EditorMode::Traversal(mode) => Some(mode), + _ => None, + } +} + +/// The mouse and the keys of the traversal mode, painting exit grids is done by `input::paint` +pub fn edit_traversal( + mouse: Res>, + keys: Res>, + cursor: Res, + mode: Res, + options: Res, + mut selection: ResMut, + mut edit: MapEdit, +) { + // Exit grids and entry points are only edited on the ground floor + let mode = traversal_mode(&mode).filter(|_| options.floor == Level::Ground); + let Some(mode) = mode else { + // Leaving the traversal mode or the ground deselects + if selection.selected.is_some() || selection.drag.is_some() { + selection.clear(); + } + return; + }; + let selection = &mut *selection; + let tile = cursor.tile.map(GridNo::from); + if mode.tool != TraversalTool::Select { + // Only the select tool has a selection + if selection.selected.is_some() || selection.drag.is_some() { + selection.clear(); + } + let Some(tile) = tile else { + return; + }; + match mode.place { + TraversalPlace::Exit if mouse.just_pressed(MouseButton::Right) => { + selection.query(&edit.map, tile); + } + TraversalPlace::Entry if mouse.just_pressed(MouseButton::Left) => { + let point = mode.entry; + edit_once(&mut edit, |context| { + context.set_entry_point(point, Some(tile)) + }); + } + _ => {} + } + return; + } + if let Some(tile) = tile.filter(|_| mouse.just_pressed(MouseButton::Left)) { + // Cmd on macOS, where Ctrl+click is a right click + let copy = keys.any_pressed([ + KeyCode::ControlLeft, + KeyCode::ControlRight, + KeyCode::SuperLeft, + KeyCode::SuperRight, + ]); + selection.press(&edit.map, tile, copy); + } + if selection.drag.is_some() { + if cursor.world_position.is_none() { + // Leaving the viewer cancels the drag, like the area brushes + selection.drag = None; + } else if !mouse.pressed(MouseButton::Left) { + match tile { + Some(to) => edit_once(&mut edit, |context| selection.release(context, to)), + None => selection.drag = None, + } + } + } + if selection.drag.is_none() && keys.any_just_pressed([KeyCode::Delete, KeyCode::Backspace]) { + edit_once(&mut edit, |context| selection.delete(context)); + } + if keys.just_pressed(KeyCode::Escape) { + selection.clear(); + } + selection.validate(&edit.map); +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{ + components::map::GridPosition, + editor::document::tests::{grid, Fixture}, + }; + + pub(crate) fn mode(json: &str) -> TraversalMode { + match serde_json::from_str(json).unwrap() { + EditorMode::Traversal(mode) => mode, + _ => panic!("not the traversal mode"), + } + } + + /// Edits the map as one undo command + pub(crate) fn edit(fixture: &mut Fixture, change: impl FnOnce(&mut EditContext)) { + fixture.document.undo.begin(); + let mut context = fixture.context(); + change(&mut context); + context.finish(); + fixture.document.undo.end(); + } + + pub(crate) fn undo(fixture: &mut Fixture) { + let command = fixture.document.undo.pop().unwrap(); + let mut context = fixture.context(); + context.restore(command); + context.finish(); + } + + fn square(size: u8, x: u8, y: u8) -> BrushArea { + Brush::Square { size }.region(None, GridPosition::new(x, y)) + } + + #[test] + fn modes_are_read_from_json() { + let read = mode( + r#"{"mode":"traversal","tool":"place","place":"exit","erase":true, + "brush":{"kind":"square","size":3}, + "destSector":{"x":11,"y":10,"z":1},"destGridNo":12345,"entry":"isolated"}"#, + ); + assert_eq!( + read, + TraversalMode { + tool: TraversalTool::Place, + place: TraversalPlace::Exit, + erase: true, + brush: Brush::Square { size: 3 }, + dest_sector: SectorId { x: 11, y: 10, z: 1 }, + dest_grid_no: 12345, + entry: EntryPoint::Isolated, + } + ); + assert_eq!(read.brush(), Some(Brush::Square { size: 3 })); + let default = mode(r#"{"mode":"traversal"}"#); + assert_eq!(default, TraversalMode::default()); + // Selecting and entry points don't use the brush, exit grids no circles or lines + assert_eq!(default.brush(), None); + let entry = mode(r#"{"mode":"traversal","tool":"place","place":"entry","entry":"center"}"#); + assert_eq!((entry.brush(), entry.entry), (None, EntryPoint::Center)); + let circle = + mode(r#"{"mode":"traversal","tool":"place","brush":{"kind":"circle","size":5}}"#); + assert_eq!(circle.brush(), Some(Brush::default())); + let area = mode(r#"{"mode":"traversal","tool":"place","brush":{"kind":"area"}}"#); + assert_eq!(area.brush(), Some(Brush::Area)); + } + + #[test] + fn destinations_are_clamped() { + let mode = TraversalMode { + dest_sector: SectorId { x: 0, y: 20, z: 9 }, + dest_grid_no: 30000, + ..Default::default() + }; + let exit = mode.exit_grid(grid(1, 1)); + assert_eq!(exit.dest_sector, SectorId { x: 1, y: 16, z: 3 }); + assert_eq!(exit.dest_grid_no.index(), 25599); + } + + #[test] + fn exit_grids_are_painted_replaced_and_erased() { + let mut fixture = Fixture::new(); + let mut paint = mode( + r#"{"mode":"traversal","tool":"place","destSector":{"x":9,"y":1,"z":1},"destGridNo":500}"#, + ); + edit(&mut fixture, |context| { + paint.apply(context, &square(3, 50, 50)) + }); + assert_eq!(fixture.map.exit_grids.len(), 9); + assert!(fixture.document.traversal_dirty && fixture.document.edge_points_dirty); + let exit = exit_grid_at(&fixture.map, grid(49, 51)).unwrap(); + assert_eq!(exit.dest_sector, SectorId { x: 9, y: 1, z: 1 }); + assert_eq!(exit.dest_grid_no.index(), 500); + + // Painting the same again changes nothing + edit(&mut fixture, |context| { + paint.apply(context, &square(3, 50, 50)) + }); + assert_eq!(fixture.document.undo.depth(), 1); + + // Another destination replaces the grids of the tiles it covers + paint.dest_grid_no = 600; + edit(&mut fixture, |context| { + paint.apply(context, &square(1, 50, 50)) + }); + assert_eq!(fixture.map.exit_grids.len(), 9); + assert_eq!( + exit_grid_at(&fixture.map, grid(50, 50)) + .unwrap() + .dest_grid_no + .index(), + 600 + ); + + paint.erase = true; + edit(&mut fixture, |context| { + paint.apply(context, &square(1, 49, 49)) + }); + assert_eq!(fixture.map.exit_grids.len(), 8); + assert!(exit_grid_at(&fixture.map, grid(49, 49)).is_none()); + // Erasing where there are none changes nothing + edit(&mut fixture, |context| { + paint.apply(context, &square(1, 10, 10)) + }); + assert_eq!(fixture.document.undo.depth(), 3); + + undo(&mut fixture); + assert_eq!(fixture.map.exit_grids.len(), 9); + undo(&mut fixture); + assert_eq!( + exit_grid_at(&fixture.map, grid(50, 50)) + .unwrap() + .dest_grid_no + .index(), + 500 + ); + undo(&mut fixture); + assert!(fixture.map.exit_grids.is_empty()); + + // Selecting and placing entry points don't paint + for json in [ + r#"{"mode":"traversal"}"#, + r#"{"mode":"traversal","tool":"place","place":"entry"}"#, + ] { + edit(&mut fixture, |context| { + mode(json).apply(context, &square(1, 50, 50)) + }); + } + assert!(fixture.map.exit_grids.is_empty()); + } + + #[test] + fn entry_points_are_moved_onto_the_screen() { + let mut fixture = Fixture::new(); + // In the middle of the map nothing moves + edit(&mut fixture, |context| { + context.set_entry_point(EntryPoint::North, Some(grid(80, 80))) + }); + assert_eq!(fixture.map.info.north_entry, Some(grid(80, 80))); + assert!(fixture.document.edge_points_dirty); + assert!(!fixture.document.traversal_dirty); + // The top rows of the screen can't have entry points + edit(&mut fixture, |context| { + context.set_entry_point(EntryPoint::East, Some(grid(45, 40))) + }); + assert_eq!(fixture.map.info.east_entry, Some(grid(48, 43))); + // So can't the bottom ones + edit(&mut fixture, |context| { + context.set_entry_point(EntryPoint::South, Some(grid(155, 155))) + }); + let south = fixture.map.info.south_entry.unwrap().position(); + assert!(u16::from(south.x) + u16::from(south.y) < 300, "{south:?}"); + // The same tile again changes nothing + let depth = fixture.document.undo.depth(); + edit(&mut fixture, |context| { + context.set_entry_point(EntryPoint::North, Some(grid(80, 80))) + }); + assert_eq!(fixture.document.undo.depth(), depth); + + edit(&mut fixture, |context| { + context.set_entry_point(EntryPoint::North, None) + }); + assert_eq!(fixture.map.info.north_entry, None); + undo(&mut fixture); + assert_eq!(fixture.map.info.north_entry, Some(grid(80, 80))); + // Undo only restores the entry points + fixture.map.info.editor_smoothing = crate::map::EditorSmoothing::Caves; + undo(&mut fixture); + assert_eq!(fixture.map.info.south_entry, None); + assert_eq!( + fixture.map.info.editor_smoothing, + crate::map::EditorSmoothing::Caves + ); + } + + #[test] + fn tile_edits_doors_and_the_map_type_move_the_edge_points() { + let mut fixture = Fixture::new(); + edit(&mut fixture, |context| { + context.set_room(grid(10, 10), crate::map::RoomNo::new(1)) + }); + assert!(fixture.document.edge_points_dirty); + fixture.document.edge_points_dirty = false; + edit(&mut fixture, |context| { + context.set_smoothing(crate::map::EditorSmoothing::Basement) + }); + assert!(fixture.document.edge_points_dirty); + fixture.document.edge_points_dirty = false; + edit(&mut fixture, |context| { + context.set_door( + grid(10, 10), + Some(crate::editor::doors::DoorLock { + lock_id: 0, + trap: None, + locked: true, + }), + ) + }); + assert!(fixture.document.edge_points_dirty); + fixture.document.edge_points_dirty = false; + undo(&mut fixture); + assert!(fixture.document.edge_points_dirty); + } + + /// A map with exit grids to `dest_grid_no` on the tiles + fn with_exit_grids(fixture: &mut Fixture, tiles: &[(u8, u8, u16)]) { + for &(x, y, dest_grid_no) in tiles { + let paint = TraversalMode { + tool: TraversalTool::Place, + dest_grid_no, + ..Default::default() + }; + edit(fixture, |context| paint.apply(context, &square(1, x, y))); + } + } + + fn press_and_release( + fixture: &mut Fixture, + selection: &mut TraversalSelection, + from: GridNo, + to: GridNo, + copy: bool, + ) { + selection.press(&fixture.map, from, copy); + edit(fixture, |context| selection.release(context, to)); + } + + #[test] + fn pressing_selects_entry_points_and_exit_grids() { + let mut fixture = Fixture::new(); + with_exit_grids(&mut fixture, &[(80, 80, 1)]); + fixture.map.info.north_entry = Some(grid(80, 80)); + fixture.map.info.center = Some(grid(80, 80)); + assert_eq!( + items_on(&fixture.map, grid(80, 80)), + vec![ + TraversalItem::Entry(EntryPoint::North), + TraversalItem::Entry(EntryPoint::Center), + TraversalItem::Exit(grid(80, 80)), + ] + ); + let mut selection = TraversalSelection::default(); + let tile = grid(80, 80); + press_and_release(&mut fixture, &mut selection, tile, tile, false); + assert_eq!( + selection.selected, + Some(TraversalItem::Entry(EntryPoint::North)) + ); + // Clicking it again selects the next item of the tile, wrapping around + for expected in [ + TraversalItem::Entry(EntryPoint::Center), + TraversalItem::Exit(tile), + TraversalItem::Entry(EntryPoint::North), + ] { + press_and_release(&mut fixture, &mut selection, tile, tile, false); + assert_eq!(selection.selected, Some(expected)); + } + // An empty tile deselects + selection.press(&fixture.map, grid(10, 10), false); + assert_eq!(selection.selected, None); + assert!(!selection.dragging()); + assert_eq!(fixture.document.undo.depth(), 1); + } + + #[test] + fn dragging_moves_entry_points_and_moves_or_copies_exit_grids() { + let mut fixture = Fixture::new(); + with_exit_grids(&mut fixture, &[(50, 50, 1), (60, 60, 2)]); + fixture.map.info.west_entry = Some(grid(80, 80)); + let mut selection = TraversalSelection::default(); + + // Moving replaces the exit grid of the target tile + press_and_release( + &mut fixture, + &mut selection, + grid(50, 50), + grid(60, 60), + false, + ); + assert_eq!(selection.selected, Some(TraversalItem::Exit(grid(60, 60)))); + assert!(exit_grid_at(&fixture.map, grid(50, 50)).is_none()); + let moved = exit_grid_at(&fixture.map, grid(60, 60)).unwrap(); + assert_eq!(moved.dest_grid_no.index(), 1); + assert_eq!(fixture.map.exit_grids.len(), 1); + + // Copying keeps the original + press_and_release( + &mut fixture, + &mut selection, + grid(60, 60), + grid(70, 70), + true, + ); + assert_eq!(selection.selected, Some(TraversalItem::Exit(grid(70, 70)))); + assert_eq!(fixture.map.exit_grids.len(), 2); + assert_eq!( + exit_grid_at(&fixture.map, grid(70, 70)) + .unwrap() + .dest_grid_no + .index(), + 1 + ); + + // Entry points are moved, also with Ctrl + press_and_release( + &mut fixture, + &mut selection, + grid(80, 80), + grid(90, 90), + true, + ); + assert_eq!( + selection.selected, + Some(TraversalItem::Entry(EntryPoint::West)) + ); + assert_eq!(fixture.map.info.west_entry, Some(grid(90, 90))); + assert!(fixture.document.edge_points_dirty); + + // Undo restores everything + undo(&mut fixture); + assert_eq!(fixture.map.info.west_entry, Some(grid(80, 80))); + undo(&mut fixture); + assert!(exit_grid_at(&fixture.map, grid(70, 70)).is_none()); + undo(&mut fixture); + assert_eq!(fixture.map.exit_grids.len(), 2); + assert_eq!( + exit_grid_at(&fixture.map, grid(60, 60)) + .unwrap() + .dest_grid_no + .index(), + 2 + ); + // The selection is dropped when the undone exit grid is gone + selection.validate(&fixture.map); + assert_eq!( + selection.selected, + Some(TraversalItem::Entry(EntryPoint::West)) + ); + selection.selected = Some(TraversalItem::Exit(grid(70, 70))); + selection.validate(&fixture.map); + assert_eq!(selection.selected, None); + } + + #[test] + fn delete_removes_exit_grids_and_clears_entry_points() { + let mut fixture = Fixture::new(); + with_exit_grids(&mut fixture, &[(50, 50, 1)]); + fixture.map.info.isolated = Some(grid(80, 80)); + let mut selection = TraversalSelection::default(); + + selection.press(&fixture.map, grid(50, 50), false); + edit(&mut fixture, |context| selection.delete(context)); + assert!(fixture.map.exit_grids.is_empty()); + assert_eq!(selection.selected, None); + + selection.press(&fixture.map, grid(80, 80), false); + edit(&mut fixture, |context| selection.delete(context)); + assert_eq!(fixture.map.info.isolated, None); + + // Without a selection nothing is deleted + let depth = fixture.document.undo.depth(); + edit(&mut fixture, |context| selection.delete(context)); + assert_eq!(fixture.document.undo.depth(), depth); + + undo(&mut fixture); + assert_eq!(fixture.map.info.isolated, Some(grid(80, 80))); + undo(&mut fixture); + assert_eq!(fixture.map.exit_grids.len(), 1); + } + + #[test] + fn the_selected_exit_grid_can_be_edited() { + let mut fixture = Fixture::new(); + with_exit_grids(&mut fixture, &[(50, 50, 1)]); + let sector = SectorId { x: 20, y: 3, z: 7 }; + edit(&mut fixture, |context| { + context.change_exit_grid(grid(50, 50), sector, 30000) + }); + let changed = exit_grid_at(&fixture.map, grid(50, 50)).unwrap(); + assert_eq!(changed.dest_sector, SectorId { x: 16, y: 3, z: 3 }); + assert_eq!(changed.dest_grid_no.index(), 25599); + // The same again and tiles without exit grids change nothing + let depth = fixture.document.undo.depth(); + edit(&mut fixture, |context| { + context.change_exit_grid(grid(50, 50), sector, 30000); + context.change_exit_grid(grid(10, 10), sector, 5); + }); + assert_eq!(fixture.document.undo.depth(), depth); + undo(&mut fixture); + assert_eq!( + exit_grid_at(&fixture.map, grid(50, 50)) + .unwrap() + .dest_grid_no + .index(), + 1 + ); + + let mut selection = TraversalSelection::default(); + selection.query(&fixture.map, grid(50, 50)); + assert_eq!(selection.queried.unwrap().grid_no, grid(50, 50)); + // Tiles without exit grids keep the last one + selection.query(&fixture.map, grid(1, 1)); + assert_eq!(selection.queried.unwrap().grid_no, grid(50, 50)); + } +} diff --git a/src-map-editor/src/editor/traversal_events.rs b/src-map-editor/src/editor/traversal_events.rs new file mode 100644 index 0000000..d3441b4 --- /dev/null +++ b/src-map-editor/src/editor/traversal_events.rs @@ -0,0 +1,480 @@ +//! What the host is told about the exit grids, the entry points and the edge points, how it +//! edits them, and how the scene and the edge points catch up with the edits +//! +//! Whenever the exit grids, the entry points, the selection, the queried exit grid or the edge +//! points change, the host gets a `traversal` event. It edits the selected exit grid or entry +//! point with `set_traversal`. +//! +//! The edge points are generated again (`generate_edge_points`) once the movement costs caught +//! up with the edits that can move them, while the traversal mode is shown, and before the map +//! is saved. They are not part of undo, undo just generates them again. + +use std::collections::BTreeSet; + +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{ + host::report_editor_event, + map::{EdgePointSet, EntryPoint, ExitGrid, GridNo, SectorId, TacticalMap}, + render::{ + edge_points::generate_edge_points, movement_costs::MovementCosts, world::MapWorld, + PendingSceneUpdate, + }, + states::loading::MapToLoad, +}; + +use super::{ + commands::CurrentMode, + document::{EditContext, EditorDocument}, + traversal::{exit_grid_at, traversal_mode, TraversalItem, TraversalSelection}, +}; + +/// An exit grid as the host shows it +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExitGridValue { + pub grid_no: u16, + pub dest_sector: SectorId, + pub dest_grid_no: u16, +} + +impl ExitGridValue { + pub fn of(grid: &ExitGrid) -> Self { + Self { + grid_no: grid.grid_no.index() as u16, + dest_sector: grid.dest_sector, + dest_grid_no: grid.dest_grid_no.index() as u16, + } + } +} + +/// The grid numbers of the entry points, `None` for the ones that are not set +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct EntryPointsValue { + pub north: Option, + pub east: Option, + pub south: Option, + pub west: Option, + pub center: Option, + pub isolated: Option, +} + +/// The number of edge points of each side +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct SideCounts { + pub north: usize, + pub east: usize, + pub south: usize, + pub west: usize, +} + +impl SideCounts { + pub fn of(set: &EdgePointSet) -> Self { + let [north, east, south, west] = set.sides().map(|list| list.grid_nos.len()); + Self { + north, + east, + south, + west, + } + } +} + +/// The edge points as the host shows them +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EdgePointSummary { + pub primary: SideCounts, + pub secondary: SideCounts, + /// The sides with an entry point but without primary edge points, soldiers can't enter the + /// map there + pub missing: Vec, + /// Whether they are being generated again, the counts are the ones before the last edits + pub pending: bool, +} + +/// The selected exit grid or entry point as the host shows it +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum SelectedTraversalValue { + ExitGrid { + #[serde(flatten)] + grid: ExitGridValue, + }, + Entry { + point: EntryPoint, + grid_no: u16, + }, +} + +impl SelectedTraversalValue { + /// The selected item, `None` if it is gone + pub fn of(map: &TacticalMap, item: TraversalItem) -> Option { + match item { + TraversalItem::Exit(grid_no) => { + exit_grid_at(map, grid_no).map(|grid| SelectedTraversalValue::ExitGrid { + grid: ExitGridValue::of(grid), + }) + } + TraversalItem::Entry(point) => { + map.info + .entry(point) + .map(|grid_no| SelectedTraversalValue::Entry { + point, + grid_no: grid_no.index() as u16, + }) + } + } + } +} + +/// The exit grids, the entry points and the edge points of the map +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename = "traversal", rename_all = "camelCase")] +pub struct TraversalEvent { + pub map: String, + /// The number of exit grids of the map + pub exit_grids: usize, + /// The exit grid or entry point the select tool selected + pub selected: Option, + /// The exit grid that was right clicked last, its values go into the mode + pub queried: Option, + pub entry_points: EntryPointsValue, + pub edge_points: EdgePointSummary, +} + +impl TraversalEvent { + pub fn of( + map_name: &str, + map: &TacticalMap, + document: &EditorDocument, + selection: &TraversalSelection, + ) -> Self { + let entry = |point| map.info.entry(point).map(|g| g.index() as u16); + let primary = &map.edge_points.primary; + let sides = [ + EntryPoint::North, + EntryPoint::East, + EntryPoint::South, + EntryPoint::West, + ]; + let missing = sides + .into_iter() + .zip(primary.sides()) + .filter(|(point, list)| map.info.entry(*point).is_some() && list.grid_nos.is_empty()) + .map(|(point, _)| point) + .collect(); + Self { + map: map_name.to_owned(), + exit_grids: map.exit_grids.len(), + selected: selection + .selected + .and_then(|item| SelectedTraversalValue::of(map, item)), + queried: selection.queried.as_ref().map(ExitGridValue::of), + entry_points: EntryPointsValue { + north: entry(EntryPoint::North), + east: entry(EntryPoint::East), + south: entry(EntryPoint::South), + west: entry(EntryPoint::West), + center: entry(EntryPoint::Center), + isolated: entry(EntryPoint::Isolated), + }, + edge_points: EdgePointSummary { + primary: SideCounts::of(primary), + secondary: map + .edge_points + .secondary + .as_ref() + .map(SideCounts::of) + .unwrap_or_default(), + missing, + pending: document.edge_points_dirty, + }, + } + } +} + +/// How the host edits the selection with `set_traversal` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum TraversalEdit { + /// Moves an entry point to a tile + Entry { point: EntryPoint, grid_no: u16 }, + /// Changes the destination of the exit grid of a tile + ExitGrid { + grid_no: u16, + dest_sector: SectorId, + dest_grid_no: u16, + }, +} + +/// Edits an entry point or an exit grid, grid numbers off the map are ignored +pub fn update_traversal(context: &mut EditContext, edit: &TraversalEdit) { + match *edit { + TraversalEdit::Entry { point, grid_no } => { + if let Some(grid_no) = GridNo::new(usize::from(grid_no)) { + context.set_entry_point(point, Some(grid_no)); + } + } + TraversalEdit::ExitGrid { + grid_no, + dest_sector, + dest_grid_no, + } => { + if let Some(grid_no) = GridNo::new(usize::from(grid_no)) { + context.change_exit_grid(grid_no, dest_sector, dest_grid_no); + } + } + } +} + +/// The traversal event that was reported last, `None` for a new map +#[derive(Resource, Clone, Debug, Default)] +pub struct ReportedTraversal(Option); + +pub fn reset_reported_traversal(mut reported: ResMut) { + reported.0 = None; +} + +/// Tells the host about the exit grids, the entry points and the edge points when they change +pub fn report_traversal( + map_to_load: Res, + map: Res, + document: Res, + selection: Res, + mut reported: ResMut, +) { + if !map.is_changed() + && !selection.is_changed() + && !document.is_changed() + && reported.0.is_some() + { + return; + } + let event = TraversalEvent::of(&map_to_load.0, &map, &document, &selection); + if reported.0.as_ref() != Some(&event) { + report_editor_event(&event); + reported.0 = Some(event); + } +} + +/// The exit grids that the movement costs were last compiled with +#[derive(Resource, Clone, Debug, Default)] +pub struct SyncedExitGrids(Vec); + +pub fn reset_synced_exit_grids(map: Res, mut synced: ResMut) { + synced.0 = map.exit_grids.clone(); +} + +/// The tiles whose exit grids were added, removed or changed +pub fn changed_exit_grid_tiles(old: &[ExitGrid], new: &[ExitGrid]) -> BTreeSet { + old.iter() + .filter(|grid| !new.contains(grid)) + .chain(new.iter().filter(|grid| !old.contains(grid))) + .map(|grid| grid.grid_no) + .collect() +} + +/// Compiles the movement costs of the tiles whose exit grids changed again, with the scene +/// update (`TRAVELCOST_EXITGRID`) +pub fn sync_traversal_scene( + mut document: ResMut, + map: Res, + mut synced: ResMut, + mut pending: ResMut, +) { + if !document.traversal_dirty { + return; + } + document.traversal_dirty = false; + pending + .tiles + .extend(changed_exit_grid_tiles(&synced.0, &map.exit_grids)); + synced.0 = map.exit_grids.clone(); +} + +/// Generates the edge points of the map again and marks them to be saved +pub fn regenerate( + map: &mut TacticalMap, + world: &MapWorld, + costs: &MovementCosts, + document: &mut EditorDocument, + map_path: &str, +) { + map.edge_points = generate_edge_points(world, costs, map, SectorId::from_map_path(map_path)); + map.edge_points_stale = true; + document.edge_points_dirty = false; +} + +/// Generates the edge points again while the traversal mode is shown, once the movement costs +/// caught up with the edits +pub fn regenerate_edge_points( + mode: Res, + pending: Res, + world: Res, + costs: Res, + map_to_load: Res, + mut map: ResMut, + mut document: ResMut, +) { + if !document.edge_points_dirty || traversal_mode(&mode).is_none() || !pending.is_empty() { + return; + } + regenerate(&mut map, &world, &costs, &mut document, &map_to_load.0); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::{ + document::tests::{grid, Fixture}, + traversal::tests::{edit, mode}, + }; + + #[test] + fn edits_are_read_from_json() { + let parse = |json: &str| serde_json::from_str::(json).unwrap(); + assert_eq!( + parse(r#"{"kind":"entry","point":"center","gridNo":12880}"#), + TraversalEdit::Entry { + point: EntryPoint::Center, + grid_no: 12880 + } + ); + assert_eq!( + parse( + r#"{"kind":"exitGrid","gridNo":5,"destSector":{"x":9,"y":1,"z":1},"destGridNo":7}"# + ), + TraversalEdit::ExitGrid { + grid_no: 5, + dest_sector: SectorId { x: 9, y: 1, z: 1 }, + dest_grid_no: 7 + } + ); + } + + #[test] + fn the_host_edits_entry_points_and_exit_grids() { + let mut fixture = Fixture::new(); + let entry = |grid_no| TraversalEdit::Entry { + point: EntryPoint::Center, + grid_no, + }; + edit(&mut fixture, |context| { + update_traversal(context, &entry(12880)) + }); + assert_eq!(fixture.map.info.center, GridNo::new(12880)); + // Grid numbers off the map are ignored + edit(&mut fixture, |context| { + update_traversal(context, &entry(30000)) + }); + assert_eq!(fixture.map.info.center, GridNo::new(12880)); + + let paint = mode(r#"{"mode":"traversal","tool":"place"}"#); + let area = crate::editor::brush::Brush::default().region(None, grid(40, 40).position()); + edit(&mut fixture, |context| paint.apply(context, &area)); + let change = TraversalEdit::ExitGrid { + grid_no: grid(40, 40).index() as u16, + dest_sector: SectorId { x: 3, y: 4, z: 2 }, + dest_grid_no: 99, + }; + edit(&mut fixture, |context| update_traversal(context, &change)); + let changed = fixture.map.exit_grids[0]; + assert_eq!(changed.dest_sector, SectorId { x: 3, y: 4, z: 2 }); + assert_eq!(changed.dest_grid_no.index(), 99); + } + + #[test] + fn events_have_the_exit_grids_and_the_edge_points() { + let mut fixture = Fixture::new(); + let paint = mode( + r#"{"mode":"traversal","tool":"place","destSector":{"x":11,"y":10,"z":1},"destGridNo":7}"#, + ); + let area = crate::editor::brush::Brush::default().region(None, grid(40, 40).position()); + edit(&mut fixture, |context| paint.apply(context, &area)); + fixture.map.info.north_entry = Some(grid(80, 80)); + fixture.map.info.isolated = Some(grid(81, 80)); + fixture.map.edge_points.primary.west.grid_nos = vec![grid(10, 90)]; + let mut selection = TraversalSelection::default(); + selection.queried = fixture.map.exit_grids.first().copied(); + selection.selected = Some(TraversalItem::Entry(EntryPoint::Isolated)); + let event = TraversalEvent::of("maps/a9.dat", &fixture.map, &fixture.document, &selection); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "traversal"); + assert_eq!(json["exitGrids"], 1); + assert_eq!( + json["queried"], + serde_json::json!({ + "gridNo": 40 * 160 + 40, + "destSector": { "x": 11, "y": 10, "z": 1 }, + "destGridNo": 7 + }) + ); + assert_eq!( + json["selected"], + serde_json::json!({ "kind": "entry", "point": "isolated", "gridNo": 80 * 160 + 81 }) + ); + let exit = SelectedTraversalValue::of(&fixture.map, TraversalItem::Exit(grid(40, 40))); + assert_eq!( + serde_json::to_value(exit).unwrap(), + serde_json::json!({ + "kind": "exitGrid", + "gridNo": 40 * 160 + 40, + "destSector": { "x": 11, "y": 10, "z": 1 }, + "destGridNo": 7 + }) + ); + assert_eq!( + SelectedTraversalValue::of(&fixture.map, TraversalItem::Exit(grid(1, 1))), + None + ); + assert_eq!(json["entryPoints"]["north"], 80 * 160 + 80); + assert_eq!(json["entryPoints"]["east"], serde_json::Value::Null); + assert_eq!(json["edgePoints"]["primary"]["west"], 1); + assert_eq!(json["edgePoints"]["missing"], serde_json::json!(["north"])); + assert_eq!(json["edgePoints"]["pending"], true); + } + + #[test] + fn changed_exit_grids_compile_their_tiles_again() { + let mut fixture = Fixture::new(); + let old = fixture.map.exit_grids.clone(); + let paint = + mode(r#"{"mode":"traversal","tool":"place","brush":{"kind":"square","size":3}}"#); + let area = + crate::editor::brush::Brush::Square { size: 3 }.region(None, grid(40, 40).position()); + edit(&mut fixture, |context| paint.apply(context, &area)); + let tiles = changed_exit_grid_tiles(&old, &fixture.map.exit_grids); + assert_eq!(tiles.len(), 9); + assert!( + changed_exit_grid_tiles(&fixture.map.exit_grids, &fixture.map.exit_grids).is_empty() + ); + } + + #[test] + fn regenerating_marks_the_edge_points_to_be_saved() { + let mut fixture = Fixture::new(); + fixture.map.info.north_entry = Some(grid(80, 80)); + fixture.document.edge_points_dirty = true; + let costs = MovementCosts::compile(&mut fixture.world, &fixture.map); + regenerate( + &mut fixture.map, + &fixture.world, + &costs, + &mut fixture.document, + "maps/a9.dat", + ); + assert!(fixture.map.edge_points_stale && !fixture.document.edge_points_dirty); + assert!(!fixture.map.edge_points.primary.north.grid_nos.is_empty()); + assert!(fixture.map.edge_points.primary.east.grid_nos.is_empty()); + } +} diff --git a/src-map-editor/src/editor/traversal_overlay.rs b/src-map-editor/src/editor/traversal_overlay.rs new file mode 100644 index 0000000..48edb2a --- /dev/null +++ b/src-map-editor/src/editor/traversal_overlay.rs @@ -0,0 +1,335 @@ +//! What the traversal mode draws over the map: the exit grids, the entry points with their +//! letters, and the edge points +//! +//! Exit grids get a cyan outline (`FIRSTPOINTERS8` of the original), the one under the cursor and +//! the selected one are labelled with their destination. Entry points get a ring in their own colour +//! and a letter (N, E, S, W, C, I). Edge points are small diamonds, the primary ones green and +//! the secondary ones orange, the inner row dimmer than the outer one. The labels are text on +//! the screen camera like the room numbers (`rooms_overlay.rs`). + +use bevy::{camera::visibility::RenderLayers, prelude::*, window::PrimaryWindow}; + +use crate::{ + map::{EdgePointList, EntryPoint, GridNo, Level, TacticalMap}, + render::RenderOptions, + states::{AppState, UiFont}, + systems::{ + cursor::CursorInfo, + pixel_view::{PixelView, SCREEN_LAYER}, + }, +}; + +use super::{ + commands::CurrentMode, + input::INACTIVE_CURSOR, + item_overlay::ring, + light_overlay::outer_ring, + soldier_events::tile_center, + soldier_overlay::{cursor_outline, tile_outline}, + traversal::{ + exit_grid_at, items_on, traversal_mode, TraversalItem, TraversalPlace, TraversalSelection, + TraversalTool, + }, +}; + +/// Size of the labels in logical pixels +const FONT_SIZE: f32 = 12.; + +const EXIT_GRID_COLOR: Srgba = Srgba::rgb(0., 0.9, 0.9); + +/// The colour of an entry point +pub fn entry_color(point: EntryPoint) -> Srgba { + match point { + EntryPoint::North => Srgba::rgb(1., 0.3, 0.3), + EntryPoint::East => Srgba::rgb(1., 0.85, 0.2), + EntryPoint::South => Srgba::rgb(0.4, 0.6, 1.), + EntryPoint::West => Srgba::rgb(1., 0.5, 1.), + EntryPoint::Center => Srgba::WHITE, + EntryPoint::Isolated => Srgba::rgb(0.7, 0.4, 1.), + } +} + +/// The letter of an entry point +pub fn entry_letter(point: EntryPoint) -> &'static str { + match point { + EntryPoint::North => "N", + EntryPoint::East => "E", + EntryPoint::South => "S", + EntryPoint::West => "W", + EntryPoint::Center => "C", + EntryPoint::Isolated => "I", + } +} + +/// How much of the edge points that don't belong to the selected entry point is drawn +const UNRELATED_ALPHA: f32 = 0.15; + +/// Whether the edge points of a side (in the order of `EdgePointSet::sides`: north, east, south, +/// west) and set belong to an entry point: a side's entry point uses both sets of its side, the +/// isolated one all secondary sets, and the center one none (it only seeds the reachability test) +fn related_edge_points(point: EntryPoint, side: usize, secondary: bool) -> bool { + match point { + EntryPoint::North => side == 0, + EntryPoint::East => side == 1, + EntryPoint::South => side == 2, + EntryPoint::West => side == 3, + EntryPoint::Isolated => secondary, + EntryPoint::Center => false, + } +} + +/// A diamond of a fifth of the size of a tile in its middle +fn small_diamond(map: &TacticalMap, grid_no: GridNo) -> Vec<(Vec2, Vec2)> { + let center = tile_center(map, grid_no); + tile_outline(map, grid_no, Level::Ground) + .into_iter() + .map(|(from, to)| (center + (from - center) / 5., center + (to - center) / 5.)) + .collect() +} + +/// Draws the exit grids, the entry points, the edge points (with the select tool, unless an exit +/// grid is selected, the ones of the selected entry point highlighted), the selection in white +/// and the tile under the cursor: for placing an entry point in its colour, for selecting green where the +/// selection is dragged to and faintly a tile that a click selects something on, grey on the +/// roof floor where nothing is edited. The brush of the exit grids is drawn by +/// `draw_brush_cursor`. +pub fn draw_traversal_markers( + mut gizmos: Gizmos, + cursor: Res, + mode: Res, + options: Res, + selection: Res, + map: Res, +) { + let Some(mode) = traversal_mode(&mode) else { + return; + }; + let mut lines = |lines: Vec<(Vec2, Vec2)>, color: Srgba| { + for (from, to) in lines { + gizmos.line_2d(from, to, color); + } + }; + let cursor_tile = cursor.tile.map(GridNo::from); + + for grid in &map.exit_grids { + lines( + tile_outline(&map, grid.grid_no, Level::Ground), + EXIT_GRID_COLOR, + ); + } + let placing_exit_grids = + mode.tool == TraversalTool::Place && mode.place == TraversalPlace::Exit; + if let Some(grid) = cursor_tile + .filter(|_| placing_exit_grids) + .and_then(|tile| exit_grid_at(&map, tile)) + { + lines(ring(&map, grid.grid_no), Srgba::WHITE); + } + + let show_edge_points = mode.tool == TraversalTool::Select + && !matches!(selection.selected, Some(TraversalItem::Exit(_))); + let selected_entry = match selection.selected { + Some(TraversalItem::Entry(point)) => Some(point), + _ => None, + }; + let mut edge_points = |list: &EdgePointList, color: Srgba, side: usize, secondary: bool| { + let alpha = match selected_entry { + Some(point) if !related_edge_points(point, side, secondary) => UNRELATED_ALPHA, + _ => 1., + }; + for (index, &grid_no) in list.grid_nos.iter().enumerate() { + let inner = index >= usize::from(list.middle_index); + let alpha = if inner { alpha * 0.45 } else { alpha }; + lines(small_diamond(&map, grid_no), color.with_alpha(alpha)); + } + }; + if show_edge_points { + for (side, list) in map.edge_points.primary.sides().into_iter().enumerate() { + edge_points(list, Srgba::rgb(0.2, 1., 0.2), side, false); + } + if let Some(secondary) = &map.edge_points.secondary { + for (side, list) in secondary.sides().into_iter().enumerate() { + edge_points(list, Srgba::rgb(1., 0.55, 0.), side, true); + } + } + } + + for point in EntryPoint::ALL { + if let Some(grid_no) = map.info.entry(point) { + let color = entry_color(point); + lines(ring(&map, grid_no), color); + lines(tile_outline(&map, grid_no, Level::Ground), color); + } + } + let selected = selection.selected.and_then(|item| item.grid_no(&map)); + if let Some(grid_no) = selected { + lines(outer_ring(&map, grid_no), Srgba::WHITE); + lines(tile_outline(&map, grid_no, Level::Ground), Srgba::WHITE); + } + let Some(tile) = cursor_tile else { + return; + }; + if options.floor == Level::Roof { + if !placing_exit_grids { + lines(cursor_outline(&map, tile, Level::Roof), INACTIVE_CURSOR); + } + return; + } + match mode.tool { + TraversalTool::Place if mode.place == TraversalPlace::Entry => { + lines( + tile_outline(&map, tile, Level::Ground), + entry_color(mode.entry), + ); + } + TraversalTool::Place => {} + TraversalTool::Select if selection.dragging() => { + if selected.is_some_and(|selected| selected != tile) { + lines(outer_ring(&map, tile), Srgba::GREEN); + lines(tile_outline(&map, tile, Level::Ground), Srgba::GREEN); + } + } + TraversalTool::Select => { + if !items_on(&map, tile).is_empty() && selected != Some(tile) { + lines( + tile_outline(&map, tile, Level::Ground), + Srgba::new(1., 1., 1., 0.35), + ); + } + } + } +} + +/// A label of the overlay +#[derive(Component)] +pub struct TraversalLabel { + /// Where the label is drawn, in world coordinates + position: Vec2, +} + +/// The labels that are shown: the text and the tile of each +#[derive(Resource, Default)] +pub struct TraversalLabels { + shown: Vec<(String, GridNo, Entity)>, +} + +pub fn reset_traversal_labels(mut labels: ResMut) { + *labels = TraversalLabels::default(); +} + +/// Shows the letters of the entry points and the destination of the exit grids under the cursor +/// and of the selected one, and moves the labels with the view +#[allow(clippy::too_many_arguments)] +pub fn update_traversal_labels( + mut commands: Commands, + mode: Res, + cursor: Res, + selection: Res, + map: Res, + view: Res, + window: Single<&Window, With>, + font: Res, + mut labels: ResMut, + mut transforms: Query<(&TraversalLabel, &mut Transform)>, +) { + let mut wanted: Vec<(String, GridNo, Color)> = vec![]; + if traversal_mode(&mode).is_some() { + for point in EntryPoint::ALL { + if let Some(grid_no) = map.info.entry(point) { + wanted.push(( + entry_letter(point).to_owned(), + grid_no, + entry_color(point).into(), + )); + } + } + let selected = match selection.selected { + Some(TraversalItem::Exit(grid_no)) => Some(grid_no), + _ => None, + }; + let hovered = cursor + .tile + .map(GridNo::from) + .filter(|&tile| Some(tile) != selected); + for grid in [selected, hovered] + .into_iter() + .flatten() + .filter_map(|tile| exit_grid_at(&map, tile)) + { + wanted.push(( + format!("{} {}", grid.dest_sector, grid.dest_grid_no.index()), + grid.grid_no, + EXIT_GRID_COLOR.into(), + )); + } + } + let same = wanted.len() == labels.shown.len() + && wanted + .iter() + .zip(&labels.shown) + .all(|((text, grid_no, _), (shown, tile, _))| text == shown && grid_no == tile); + if !same { + for (_, _, entity) in labels.shown.drain(..) { + commands.entity(entity).despawn(); + } + let text_font = font.text_font(FONT_SIZE * window.scale_factor()); + for (text, grid_no, color) in wanted { + let position = tile_center(&map, grid_no) + Vec2::new(0., 14.); + let entity = commands + .spawn(( + TraversalLabel { position }, + Text2d::new(text.clone()), + text_font.clone(), + TextColor(color), + RenderLayers::layer(SCREEN_LAYER), + // New labels are not in `transforms` yet, so they start at their place + Transform::from_translation(view.world_to_screen(position).extend(10.)), + DespawnOnExit(AppState::Map), + )) + .id(); + labels.shown.push((text, grid_no, entity)); + } + } + // 1 unit of the screen camera is 1 physical pixel, above the map + for (label, mut transform) in &mut transforms { + let translation = view.world_to_screen(label.position).extend(10.); + if transform.translation != translation { + transform.translation = translation; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edge_points_of_entry_points() { + for secondary in [false, true] { + for (side, point) in [ + EntryPoint::North, + EntryPoint::East, + EntryPoint::South, + EntryPoint::West, + ] + .into_iter() + .enumerate() + { + for other in 0..4 { + assert_eq!( + related_edge_points(point, other, secondary), + other == side, + "{point:?} side {other} secondary {secondary}" + ); + } + } + for side in 0..4 { + assert_eq!( + related_edge_points(EntryPoint::Isolated, side, secondary), + secondary + ); + assert!(!related_edge_points(EntryPoint::Center, side, secondary)); + } + } + } +} diff --git a/src-map-editor/src/editor/undo.rs b/src-map-editor/src/editor/undo.rs new file mode 100644 index 0000000..a391868 --- /dev/null +++ b/src-map-editor/src/editor/undo.rs @@ -0,0 +1,358 @@ +//! Undoing edits of the map +//! +//! Like the undo list of the original editor (`Editor_Undo.cc`), a command keeps the state of +//! every tile before the command changed it. A command is everything that happened between +//! pressing and releasing the mouse button, i.e. one stroke. The tiles hold `Arc`s of the tiles of +//! the tileset, so keeping copies of them is cheap. + +use std::collections::{BTreeMap, VecDeque}; + +use crate::map::{ + AmbientLight, Door, EditorSmoothing, ExitGrid, GridNo, MapInfo, MapItem, MapLight, MapTile, + Schedule, SoldierPlacement, +}; + +/// The lights, the light colour and the ambient light of a map +pub type LightState = (Vec, [u8; 3], Option); + +/// How many commands can be undone (`MAX_UNDO_COMMAND_LENGTH` in the original editor) +pub const MAX_UNDO_COMMANDS: usize = 10; + +/// What a command changed, to restore it +#[derive(Clone, Debug, Default)] +pub struct UndoCommand { + /// The tiles before the command changed them + pub tiles: BTreeMap, + /// The background texture before the command changed it + pub background: Option, + /// The smoothing of the map before the command changed it + pub smoothing: Option, + /// The scroll restriction of the map before the command changed it + pub restricted_scroll: Option, + /// The door table before the command changed it + pub doors: Option>, + /// The soldier placements and the schedules before the command changed them + pub soldiers: Option<(Vec, Vec)>, + /// The world items before the command changed them + pub items: Option>, + /// The lights, the light colour and the ambient light before the command changed them + pub lights: Option, + /// The exit grids before the command changed them + pub exit_grids: Option>, + /// The map info before the command changed its entry points, only they are restored + pub entry_points: Option, +} + +impl UndoCommand { + fn is_empty(&self) -> bool { + self.tiles.is_empty() + && self.background.is_none() + && self.smoothing.is_none() + && self.restricted_scroll.is_none() + && self.doors.is_none() + && self.soldiers.is_none() + && self.items.is_none() + && self.lights.is_none() + && self.exit_grids.is_none() + && self.entry_points.is_none() + } +} + +/// The commands that can be undone and redone, and the one that is being recorded +#[derive(Clone, Debug, Default)] +pub struct UndoStack { + /// The oldest command first + commands: VecDeque, + /// The commands that restore what was undone, the last undone one last + redo: VecDeque, + current: Option, + /// How often anything was recorded, to tell whether an edit changed something + records: u64, +} + +impl UndoStack { + /// Starts recording a new command, ending the current one + pub fn begin(&mut self) { + self.end(); + self.current = Some(UndoCommand::default()); + } + + /// Ends recording the current command, commands that changed nothing are dropped + pub fn end(&mut self) { + let Some(command) = self.current.take() else { + return; + }; + if command.is_empty() { + return; + } + // A new edit can't be followed by what was undone before + self.redo.clear(); + self.push_undo(command); + } + + /// Adds a command that can be undone, e.g. the one that undoes a redo, keeping what can be + /// redone + pub fn push_undo(&mut self, command: UndoCommand) { + push_limited(&mut self.commands, command); + } + + /// Adds a command that redoes an undone command + pub fn push_redo(&mut self, command: UndoCommand) { + push_limited(&mut self.redo, command); + } + + /// Ends the current command and takes the last command that redoes an undone one, there is + /// none if the current command changed something + pub fn pop_redo(&mut self) -> Option { + self.end(); + self.redo.pop_back() + } + + /// Records a tile before it is changed, only the first state of a tile in a command is kept + /// + /// Starts a command if none is being recorded. + pub fn record_tile(&mut self, grid_no: GridNo, tile: &MapTile) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .tiles + .entry(grid_no) + .or_insert_with(|| tile.clone()); + } + + /// Records the background texture before it is changed + pub fn record_background(&mut self, background: &str) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .background + .get_or_insert_with(|| background.to_owned()); + } + + /// Records the smoothing of the map before it is changed + pub fn record_smoothing(&mut self, smoothing: EditorSmoothing) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .smoothing + .get_or_insert(smoothing); + } + + /// Records the scroll restriction of the map before it is changed + pub fn record_restricted_scroll(&mut self, restricted_scroll: bool) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .restricted_scroll + .get_or_insert(restricted_scroll); + } + + /// Records the door table before it is changed + pub fn record_doors(&mut self, doors: &[Door]) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .doors + .get_or_insert_with(|| doors.to_vec()); + } + + /// Records the soldier placements and the schedules before they are changed + pub fn record_soldiers(&mut self, soldiers: &[SoldierPlacement], schedules: &[Schedule]) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .soldiers + .get_or_insert_with(|| (soldiers.to_vec(), schedules.to_vec())); + } + + /// Records the world items before they are changed + pub fn record_items(&mut self, items: &[MapItem]) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .items + .get_or_insert_with(|| items.to_vec()); + } + + /// Records the lights, the light colour and the ambient light before they are changed + pub fn record_lights( + &mut self, + lights: &[MapLight], + color: [u8; 3], + ambient: Option, + ) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .lights + .get_or_insert_with(|| (lights.to_vec(), color, ambient)); + } + + /// Records the exit grids before they are changed + pub fn record_exit_grids(&mut self, exit_grids: &[ExitGrid]) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .exit_grids + .get_or_insert_with(|| exit_grids.to_vec()); + } + + /// Records the map info before its entry points are changed + pub fn record_entry_points(&mut self, info: &MapInfo) { + self.records += 1; + self.current + .get_or_insert_with(UndoCommand::default) + .entry_points + .get_or_insert_with(|| info.clone()); + } + + /// Ends the current command and takes the last one + pub fn pop(&mut self) -> Option { + self.end(); + self.commands.pop_back() + } + + /// How often anything was recorded so far + pub fn records(&self) -> u64 { + self.records + } + + /// Number of commands that can be undone + pub fn depth(&self) -> usize { + self.commands.len() + usize::from(self.is_recording()) + } + + /// Number of commands that can be redone, none while the current command changes something + pub fn redo_depth(&self) -> usize { + if self.is_recording() { + 0 + } else { + self.redo.len() + } + } + + /// Whether the current command changed something + fn is_recording(&self) -> bool { + self.current.as_ref().is_some_and(|c| !c.is_empty()) + } + + pub fn clear(&mut self) { + self.commands.clear(); + self.redo.clear(); + self.current = None; + } +} + +/// Adds a command, dropping the oldest ones beyond `MAX_UNDO_COMMANDS` +fn push_limited(commands: &mut VecDeque, command: UndoCommand) { + commands.push_back(command); + while commands.len() > MAX_UNDO_COMMANDS { + commands.pop_front(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tile(height: u8) -> MapTile { + MapTile { + height, + ..Default::default() + } + } + + fn grid(index: usize) -> GridNo { + GridNo::new(index).unwrap() + } + + #[test] + fn a_command_keeps_the_first_state_of_each_tile() { + let mut stack = UndoStack::default(); + stack.begin(); + stack.record_tile(grid(1), &tile(1)); + stack.record_tile(grid(1), &tile(2)); + stack.record_tile(grid(2), &tile(3)); + stack.end(); + // Empty commands are dropped + stack.begin(); + stack.end(); + assert_eq!(stack.depth(), 1); + + let command = stack.pop().unwrap(); + assert_eq!(command.tiles.len(), 2); + assert_eq!(command.tiles[&grid(1)].height, 1); + assert_eq!(command.tiles[&grid(2)].height, 3); + assert!(stack.pop().is_none()); + } + + #[test] + fn only_the_last_commands_are_kept() { + let mut stack = UndoStack::default(); + for height in 0..15 { + stack.begin(); + stack.record_tile(grid(0), &tile(height)); + } + // The current command counts once it changed something + assert_eq!(stack.depth(), MAX_UNDO_COMMANDS + 1); + stack.end(); + assert_eq!(stack.depth(), MAX_UNDO_COMMANDS); + let heights: Vec = std::iter::from_fn(|| stack.pop()) + .map(|command| command.tiles[&grid(0)].height) + .collect(); + assert_eq!(heights, (5..15).rev().collect::>()); + } + + #[test] + fn undone_commands_are_redone_until_something_is_edited() { + let mut stack = UndoStack::default(); + stack.begin(); + stack.record_tile(grid(0), &tile(1)); + stack.end(); + let command = stack.pop().unwrap(); + stack.push_redo(command); + assert_eq!((stack.depth(), stack.redo_depth()), (0, 1)); + + // Redoing keeps what else can be redone + stack.push_redo(UndoCommand::default()); + let command = stack.pop_redo().unwrap(); + stack.push_undo(command); + assert_eq!((stack.depth(), stack.redo_depth()), (1, 1)); + + // An edit in progress hides the redo commands, ending it drops them + stack.begin(); + stack.record_tile(grid(1), &tile(2)); + assert_eq!(stack.redo_depth(), 0); + assert!(stack.pop_redo().is_none()); + assert_eq!((stack.depth(), stack.redo_depth()), (2, 0)); + + // Commands that changed nothing keep them + stack.push_redo(UndoCommand::default()); + stack.begin(); + stack.end(); + assert_eq!(stack.redo_depth(), 1); + stack.clear(); + assert_eq!(stack.redo_depth(), 0); + } + + #[test] + fn only_the_last_redo_commands_are_kept() { + let mut stack = UndoStack::default(); + for height in 0..15 { + stack.push_redo(UndoCommand { + tiles: BTreeMap::from([(grid(0), tile(height))]), + ..Default::default() + }); + } + assert_eq!(stack.redo_depth(), MAX_UNDO_COMMANDS); + assert_eq!(stack.pop_redo().unwrap().tiles[&grid(0)].height, 14); + } + + #[test] + fn the_first_background_is_kept() { + let mut stack = UndoStack::default(); + stack.record_background("TEXTURE3"); + stack.record_background("TEXTURE4"); + assert_eq!(stack.pop().unwrap().background.as_deref(), Some("TEXTURE3")); + } +} diff --git a/src-map-editor/src/editor/walls.rs b/src-map-editor/src/editor/walls.rs new file mode 100644 index 0000000..e4e7a5d --- /dev/null +++ b/src-map-editor/src/editor/walls.rs @@ -0,0 +1,476 @@ +//! Placing and changing the walls of buildings (`BuildWallPiece` of `NewSmooth.cc` and +//! `Smoothing_Utils.cc`) +//! +//! A wall belongs to the edge of a tile: the horizontal walls stored on a tile are on the edge to +//! the next row, the vertical ones on the edge to the next column. So the walls on the top and +//! left edges of a tile are stored on the tiles above and left of it. The pieces are named from +//! the point of view of a floor tile that places them, e.g. `InteriorTop` is the wall above a +//! floor tile, seen from inside the building. + +use std::sync::Arc; + +use crate::{ + map::{EditorSmoothing, GridNo}, + render::movement_costs::grid_no_on_visible_world_tile, + tileset::{BuildingPiece, Orientation, StructureFlags, Tile, TileCategory, WallPiece}, +}; + +use super::{ + building::Materials, + document::{EditContext, WallNode}, + smart::paste_smart_wall, +}; + +/// Where a floor tile places a wall (`EXTERIOR_TOP` to `INTERIOR_RIGHT`) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WallSide { + ExteriorTop, + ExteriorBottom, + ExteriorLeft, + ExteriorRight, + InteriorTop, + InteriorBottom, + InteriorLeft, + InteriorRight, +} + +/// Searches farther than this for the wall and roof types of a building +const MAX_SEARCH_RADIUS: i32 = 32; + +/// Whether the map is a basement, whose buildings are carved out of rock (`gfBasement`) +pub fn is_basement(context: &EditContext) -> bool { + context.map.info.editor_smoothing == EditorSmoothing::Basement +} + +/// The tile `dx` columns and `dy` rows away, `None` off the map +pub fn at(grid_no: GridNo, dx: i32, dy: i32) -> Option { + grid_no.neighbour(dx, dy) +} + +/// [`EditContext::vertical_wall`] of a tile that may be off the map +pub fn vertical_wall(context: &EditContext, grid_no: Option) -> Option { + context.vertical_wall(grid_no?) +} + +/// [`EditContext::horizontal_wall`] of a tile that may be off the map +pub fn horizontal_wall(context: &EditContext, grid_no: Option) -> Option { + context.horizontal_wall(grid_no?) +} + +/// [`EditContext::has_floor`] of a tile that may be off the map +pub fn has_floor(context: &EditContext, grid_no: Option) -> bool { + grid_no.is_some_and(|grid_no| context.has_floor(grid_no)) +} + +/// Whether a tile is a piece of a wall, including the windows, A-frames and wall shadows +/// (`FIRSTWALL` to `LASTWALL`) +pub fn is_wall(tile: &Tile) -> bool { + tile.building.is_some_and(|b| b.piece.is_wall()) +} + +/// Whether a tile is a door in any state (`FIRSTDOOR` to `LASTDOOR`) +pub fn is_door(tile: &Tile) -> bool { + tile.building.is_some_and(|b| b.piece.is_door()) +} + +/// Whether a tile is the shadow of a door (`FIRSTDOORSHADOW` to `LASTDOORSHADOW`) +pub fn is_door_shadow(tile: &Tile) -> bool { + tile.building + .is_some_and(|b| b.piece == BuildingPiece::DoorShadow) +} + +/// A random variant of a wall piece of a style (`PickAWallPiece`) +pub fn pick_wall_piece( + context: &mut EditContext, + style: u8, + piece: WallPiece, +) -> Option> { + let tiles = context + .tileset + .building_tiles(style, BuildingPiece::Wall(piece)); + if tiles.is_empty() { + return None; + } + let index = context.random(tiles.len() as u32) as usize; + Some(tiles[index].clone()) +} + +/// Replaces a wall by a variant of a piece of a style (`ReplaceStructIndex`) +fn replace_wall( + context: &mut EditContext, + grid_no: GridNo, + wall: WallNode, + style: u8, + piece: WallPiece, +) { + if let Some(tile) = pick_wall_piece(context, style, piece) { + context.replace_struct(grid_no, wall.index, tile); + } +} + +/// Adds a wall shadow of a style, unless the tile has it +fn add_wall_shadow( + context: &mut EditContext, + grid_no: Option, + style: u8, + piece: WallPiece, +) { + let Some(grid_no) = grid_no else { + return; + }; + if let Some(tile) = pick_wall_piece(context, style, piece) { + context.add_exclusive_shadow(grid_no, tile); + } +} + +/// Places a wall piece of a style for the floor tile `grid_no` and fixes up the corners it makes +/// (`BuildWallPiece`) +/// +/// The top and left pieces go to the tiles above and left of the floor tile. +pub fn build_wall_piece(context: &mut EditContext, grid_no: GridNo, side: WallSide, style: u8) { + let basement = is_basement(context); + let target = match side { + WallSide::ExteriorTop | WallSide::InteriorTop => at(grid_no, 0, -1), + WallSide::ExteriorLeft | WallSide::InteriorLeft => at(grid_no, -1, 0), + _ => Some(grid_no), + }; + let Some(g) = target else { + return; + }; + let vwall = |context: &EditContext, dx, dy| vertical_wall(context, at(g, dx, dy)); + let hwall = |context: &EditContext, dx, dy| horizontal_wall(context, at(g, dx, dy)); + + let class = match side { + WallSide::ExteriorTop => { + // A new bottom left corner contributes to the bottom shadow + if !basement && vwall(context, -1, 0).is_some() && vwall(context, -1, 1).is_none() { + add_wall_shadow( + context, + at(g, -1, 0), + style, + WallPiece::InteriorBottomEndShadow, + ); + } + // A new corner to the right, the vertical piece becomes a bottom end + if let Some(wall) = vwall(context, 0, 0) { + replace_wall(context, g, wall, style, WallPiece::ExteriorBottomEnd); + } + if !basement { + // All exterior left walls have shadows + add_wall_shadow(context, Some(g), style, WallPiece::ExteriorLeftShadow); + } + WallPiece::ExteriorLeft + } + WallSide::ExteriorBottom => { + if let Some(wall) = vwall(context, -1, 1).filter(|_| vwall(context, -1, 0).is_none()) { + if let Some(target) = at(g, -1, 1) { + replace_wall(context, target, wall, style, WallPiece::InteriorExtended); + } + } + WallPiece::InteriorLeft + } + WallSide::ExteriorLeft => { + // A new corner, the piece becomes an exterior bottom end + let mut class = if hwall(context, 0, 0).is_some() { + WallPiece::ExteriorBottomEnd + } else { + WallPiece::ExteriorRight + }; + if hwall(context, 1, -1).is_some() { + class = if class == WallPiece::ExteriorBottomEnd { + WallPiece::ExteriorExtendedBottomEnd + } else { + WallPiece::ExteriorExtended + }; + } + class + } + WallSide::ExteriorRight => { + if hwall(context, 1, -1).is_some() && hwall(context, 0, -1).is_none() { + WallPiece::InteriorExtended + } else if hwall(context, 0, 0).is_some() && vwall(context, 0, 1).is_none() { + WallPiece::InteriorBottomEnd + } else { + WallPiece::InteriorRight + } + } + WallSide::InteriorTop => { + // A lower left corner, the piece becomes an extended piece + if let (Some(wall), Some(target)) = (vwall(context, -1, 1), at(g, -1, 1)) { + replace_wall(context, target, wall, style, WallPiece::InteriorExtended); + } + if let Some(wall) = vwall(context, 0, 0) { + replace_wall(context, g, wall, style, WallPiece::InteriorBottomEnd); + } + WallPiece::InteriorLeft + } + WallSide::InteriorBottom => { + // A new corner to the right, the vertical piece becomes a bottom end + if let Some(wall) = vwall(context, 0, 0) { + replace_wall(context, g, wall, style, WallPiece::ExteriorBottomEnd); + } + if let Some(wall) = vwall(context, -1, 1).filter(|_| vwall(context, -1, 0).is_none()) { + if let Some(target) = at(g, -1, 1) { + replace_wall(context, target, wall, style, WallPiece::ExteriorExtended); + } + } + if !basement { + add_wall_shadow(context, Some(g), style, WallPiece::ExteriorLeftShadow); + } + WallPiece::ExteriorLeft + } + WallSide::InteriorLeft => { + let mut class = if hwall(context, 0, 0).is_some() { + WallPiece::InteriorBottomEnd + } else { + WallPiece::InteriorRight + }; + if !basement && hwall(context, 1, 0).is_some() { + add_wall_shadow(context, Some(g), style, WallPiece::InteriorBottomEndShadow); + } + if hwall(context, 1, -1).is_some() { + class = if class == WallPiece::InteriorBottomEnd { + WallPiece::InteriorExtendedBottomEnd + } else { + WallPiece::InteriorExtended + }; + } + class + } + WallSide::InteriorRight => { + let mut class = if hwall(context, 0, 0).is_some() { + WallPiece::ExteriorBottomEnd + } else { + WallPiece::ExteriorRight + }; + if hwall(context, 1, -1).is_some() { + class = if class == WallPiece::ExteriorBottomEnd { + WallPiece::ExteriorExtendedBottomEnd + } else { + WallPiece::ExteriorExtended + }; + } + if !basement + && hwall(context, 1, 0).is_some() + && hwall(context, 0, 0).is_none() + && !has_floor(context, at(g, 0, 1)) + { + add_wall_shadow(context, Some(g), style, WallPiece::InteriorBottomEndShadow); + } + class + } + }; + if let Some(tile) = pick_wall_piece(context, style, class) { + context.add_wall_to_struct_layer(g, tile, false); + } +} + +/// Changes the vertical wall of a tile to another piece of its style, doors stay +/// (`ChangeVerticalWall`) +pub fn change_vertical_wall(context: &mut EditContext, grid_no: Option, piece: WallPiece) { + let Some(grid_no) = grid_no else { + return; + }; + let Some(wall) = context.vertical_wall(grid_no) else { + return; + }; + let Some(building) = wall.tile.building.filter(|b| b.piece.is_wall()) else { + return; + }; + replace_wall(context, grid_no, wall, building.style, piece); +} + +/// Removes a wall and the wall shadows of its tile +fn erase_wall(context: &mut EditContext, grid_no: GridNo, wall: WallNode) { + context.remove_struct(grid_no, wall.index); + context.remove_shadows_where(grid_no, is_wall); +} + +/// Removes the horizontal wall of a tile with the wall shadows (`EraseHorizontalWall`) +pub fn erase_horizontal_wall(context: &mut EditContext, grid_no: Option) { + if let Some(grid_no) = grid_no { + if let Some(wall) = context.horizontal_wall(grid_no) { + erase_wall(context, grid_no, wall); + } + } +} + +/// Removes the vertical wall of a tile with the wall shadows (`EraseVerticalWall`) +pub fn erase_vertical_wall(context: &mut EditContext, grid_no: Option) { + if let Some(grid_no) = grid_no { + if let Some(wall) = context.vertical_wall(grid_no) { + erase_wall(context, grid_no, wall); + } + } +} + +/// The visible tiles on the ring `radius` tiles around a tile, from the bottom row to the top +/// one if `bottom_first` +fn ring( + context: &EditContext, + grid_no: GridNo, + radius: i32, + bottom_first: bool, +) -> impl Iterator + use<> { + let rows: Vec = if bottom_first { + (-radius..=radius).rev().collect() + } else { + (-radius..=radius).collect() + }; + let restricted_scroll = context.world.restricted_scroll; + rows.into_iter() + .flat_map(move |dy| (-radius..=radius).map(move |dx| (dx, dy))) + .filter(move |&(dx, dy)| dx.abs() == radius || dy.abs() == radius) + .filter_map(move |(dx, dy)| at(grid_no, dx, dy)) + .filter(move |&g| grid_no_on_visible_world_tile(g, restricted_scroll)) +} + +/// The style of the nearest wall (`SearchForWallType`), in basements a wall of the materials +/// +/// The search starts at the bottom row of each ring, because another building can be one tile +/// north, but not one tile south. +pub fn search_for_wall_type( + context: &mut EditContext, + grid_no: GridNo, + materials: &Materials, +) -> Option { + if is_basement(context) { + return Some(materials.wall(context).unwrap_or(0)); + } + (0..MAX_SEARCH_RADIUS).find_map(|radius| { + ring(context, grid_no, radius, true).find_map(|g| { + context + .structs(g) + .iter() + .find_map(|tile| tile.building.filter(|b| b.piece.is_wall())) + .map(|b| b.style) + }) + }) +} + +/// The style of the nearest flat roof (`SearchForRoofType`) +pub fn search_for_roof_type(context: &EditContext, grid_no: GridNo) -> Option { + (0..MAX_SEARCH_RADIUS).find_map(|radius| { + ring(context, grid_no, radius, false).find_map(|g| { + context + .roofs(g) + .iter() + .find_map(|tile| tile.building.filter(|b| b.piece.is_flat_roof())) + .map(|b| b.style) + }) + }) +} + +/// The style of a wall, doors take the style of the nearest wall (`GetWallType`) +pub fn wall_type( + context: &mut EditContext, + wall: &WallNode, + grid_no: GridNo, + materials: &Materials, +) -> Option { + match wall.tile.building { + Some(building) if building.piece.is_wall() => Some(building.style), + _ => search_for_wall_type(context, grid_no, materials), + } +} + +/// The style of the vertical wall of a tile, `None` if it has none (`GetVerticalWallType`) +pub fn vertical_wall_type( + context: &mut EditContext, + grid_no: Option, + materials: &Materials, +) -> Option { + let grid_no = grid_no?; + let wall = context.vertical_wall(grid_no)?; + wall_type(context, &wall, grid_no, materials) +} + +/// The style of the horizontal wall of a tile, `None` if it has none +/// (`GetHorizontalWallType`) +pub fn horizontal_wall_type( + context: &mut EditContext, + grid_no: Option, + materials: &Materials, +) -> Option { + let grid_no = grid_no?; + let wall = context.horizontal_wall(grid_no)?; + wall_type(context, &wall, grid_no, materials) +} + +/// Replaces the walls of a tile by plain walls of their style and alignment (`RestoreWalls`) +/// +/// Without walls, a smart wall of the style of an adjacent wall is placed. +pub fn restore_walls(context: &mut EditContext, grid_no: GridNo, materials: &Materials) { + let mut done = false; + if let Some(wall) = context.horizontal_wall(grid_no) { + let style = wall_type(context, &wall, grid_no, materials); + let orientation = wall.tile.wall_orientation(); + erase_wall(context, grid_no, wall); + let side = match orientation { + Orientation::OUTSIDE_LEFT => Some(WallSide::InteriorBottom), + Orientation::INSIDE_LEFT => Some(WallSide::ExteriorBottom), + _ => None, + }; + if let (Some(side), Some(style)) = (side, style) { + build_wall_piece(context, grid_no, side, style); + } + done = true; + } + if let Some(wall) = context.vertical_wall(grid_no) { + let style = wall_type(context, &wall, grid_no, materials); + let orientation = wall.tile.wall_orientation(); + erase_wall(context, grid_no, wall); + let side = match orientation { + Orientation::OUTSIDE_RIGHT => Some(WallSide::InteriorRight), + Orientation::INSIDE_RIGHT => Some(WallSide::ExteriorRight), + _ => None, + }; + if let (Some(side), Some(style)) = (side, style) { + build_wall_piece(context, grid_no, side, style); + } + done = true; + } + if done { + return; + } + // Nothing to restore, place a smart wall of the style of an adjacent wall instead + let wall = horizontal_wall(context, at(grid_no, -1, 0)) + .or_else(|| horizontal_wall(context, at(grid_no, 1, 0))) + .or_else(|| vertical_wall(context, at(grid_no, 0, -1))) + .or_else(|| vertical_wall(context, at(grid_no, 0, 1))); + let Some(wall) = wall else { + return; + }; + if let Some(style) = wall_type(context, &wall, grid_no, materials) { + paste_smart_wall(context, grid_no, style, materials); + } +} + +/// Removes all structs of a tile, the wall and door shadows and the debris (`EraseWalls`) +pub fn erase_walls(context: &mut EditContext, grid_no: GridNo) { + context.remove_structs_where(grid_no, |_| true); + context.remove_shadows_where(grid_no, |tile| is_wall(tile) || is_door_shadow(tile)); + context.remove_objects_where(grid_no, |tile| tile.category == Some(TileCategory::Debris)); +} + +/// Whether a fence of a tile has the alignment +fn has_fence( + context: &EditContext, + grid_no: GridNo, + aligned: impl Fn(Orientation) -> bool, +) -> bool { + context.structs(grid_no).iter().any(|tile| { + tile.structure.as_ref().is_some_and(|structure| { + structure.flags.intersects(StructureFlags::ANYFENCE) + && aligned(structure.wall_orientation) + }) + }) +} + +/// Whether a decal can be placed on a tile: it needs a wall or fence (`ValidDecalPlacement`) +pub fn valid_decal_placement(context: &EditContext, grid_no: GridNo) -> bool { + context.vertical_wall(grid_no).is_some() + || context.horizontal_wall(grid_no).is_some() + || has_fence(context, grid_no, Orientation::is_vertical) + || has_fence(context, grid_no, Orientation::is_horizontal) +} diff --git a/src-map-editor/src/file_formats/ja2_map.rs b/src-map-editor/src/file_formats/ja2_map.rs new file mode 100644 index 0000000..4acd765 --- /dev/null +++ b/src-map-editor/src/file_formats/ja2_map.rs @@ -0,0 +1,1659 @@ +use std::io; + +use bevy::{ + asset::{io::Reader, AssetLoader, LoadContext}, + prelude::*, +}; +use bitflags::bitflags; +use byteorder::{ReadBytesExt, WriteBytesExt, LE}; + +use super::{StracciatellaReadExt, StracciatellaWriteExt}; + +pub const JA2_MAP_ROWS: usize = 160; +pub const JA2_MAP_COLS: usize = 160; +const JA2_TILE_COUNT: usize = JA2_MAP_ROWS * JA2_MAP_COLS; + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct Ja2MapFlags: u32 { + const MAP_FULLSOLDIER_SAVED = 0x00000001; + const MAP_WORLDLIGHTS_SAVED = 0x00000004; + const MAP_WORLDITEMS_SAVED = 0x00000008; + const MAP_EXITGRIDS_SAVED = 0x00000010; + const MAP_DOORTABLE_SAVED = 0x00000020; + const MAP_EDGEPOINTS_SAVED = 0x00000040; + const MAP_AMBIENTLIGHTLEVEL_SAVED = 0x00000080; + const MAP_NPCSCHEDULES_SAVED = 0x00000100; + } +} + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct Ja2MapElementFlags: u8 { + const MAPELEMENT_REDUNDANT = 0x0001; + const MAPELEMENT_REEVALUATE_REDUNDANCY = 0x0002; + const MAPELEMENT_ENEMY_MINE_PRESENT = 0x0004; + const MAPELEMENT_PLAYER_MINE_PRESENT = 0x0008; + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2MapVersion { + pub major: f32, + pub minor: u8, +} + +/// One entry of a tile layer (land, objects, structs, shadows, roofs, on roofs) +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2TileLayer { + pub kind: u8, + pub sub_index: u16, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2MapElement { + pub flags: Ja2MapElementFlags, + pub height: u8, + pub land: Vec, + pub objects: Vec, + pub structs: Vec, + pub shadows: Vec, + pub roofs: Vec, + pub on_roofs: Vec, + pub room: u8, +} + +/// OBJECTTYPE, an item stack as stored in world items and soldier inventories +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2Object { + pub item: u16, + pub number: u8, + /// Item class specific data (gun, ammo, money, bomb, key, owner), the meaning depends on the + /// item class which is not part of the map file + pub data: [u8; 12], + pub attach_items: [u16; 4], + pub attach_status: [i8; 4], + pub flags: i8, + pub mission: u8, + pub trap: i8, + pub imprint_id: u8, + pub weight: u8, + pub used: u8, +} + +/// WORLDITEM, an item placed in the world +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2WorldItem { + pub exists: bool, + pub grid_no: i16, + pub level: u8, + pub object: Ja2Object, + /// `WorldItemFlags` + pub flags: u16, + pub render_z_height_above_level: i8, + pub visible: i8, + /// Chance of the item not existing (reversed) + pub non_exist_chance: u8, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2AmbientLight { + pub basement: bool, + pub caves: bool, + pub level: u8, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2LightSprite { + pub x: i16, + pub y: i16, + pub flags: u32, + pub template_name: String, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2Lights { + /// Light colors as r, g, b, unused + pub colors: Vec<[u8; 4]>, + pub sprites: Vec, +} + +/// MAPCREATE_STRUCT +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2MapInfo { + pub north_grid_no: i16, + pub east_grid_no: i16, + pub south_grid_no: i16, + pub west_grid_no: i16, + pub num_individuals: u8, + pub map_version: u8, + pub restricted_scroll_id: u8, + pub editor_smoothing_type: u8, + pub center_grid_no: i16, + pub isolated_grid_no: i16, +} + +/// BASIC_SOLDIERCREATE_STRUCT +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2BasicSoldierPlacement { + pub detailed_placement: bool, + pub starting_grid_no: u16, + pub team: i8, + pub relative_attribute_level: i8, + pub relative_equipment_level: i8, + pub direction: i8, + pub orders: i8, + pub attitude: i8, + pub body_type: i8, + pub patrol_grid: [i16; 10], + pub patrol_cnt: i8, + pub on_roof: bool, + pub soldier_class: u8, + pub civilian_group: u8, + pub priority_existance: bool, + pub has_keys: bool, +} + +/// SOLDIERCREATE_STRUCT +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2DetailedSoldierPlacement { + pub is_static: bool, + pub profile: u8, + pub copy_profile_items_over: bool, + pub sector_x: i16, + pub sector_y: i16, + pub direction: i8, + pub insertion_grid_no: i16, + pub team: i8, + pub body_type: i8, + pub attitude: i8, + pub orders: i8, + pub life_max: i8, + pub life: i8, + pub agility: i8, + pub dexterity: i8, + pub exp_level: i8, + pub marksmanship: i8, + pub medical: i8, + pub mechanical: i8, + pub explosive: i8, + pub leadership: i8, + pub strength: i8, + pub wisdom: i8, + pub morale: i8, + pub ai_morale: i8, + pub inventory: Vec, + pub head_pal: String, + pub pants_pal: String, + pub vest_pal: String, + pub skin_pal: String, + pub patrol_grid: [i16; 10], + pub patrol_cnt: i8, + pub visible: bool, + pub name: String, + pub soldier_class: u8, + pub on_roof: bool, + pub sector_z: i8, + pub civilian_group: u8, + pub schedule_id: u8, + pub use_given_vehicle: bool, + pub use_given_vehicle_id: i8, + pub has_keys: bool, +} + +/// A soldier placement as stored in the map, see `map::placement` for the runtime form +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2SoldierPlacement { + pub basic: Ja2BasicSoldierPlacement, + pub detailed: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2ExitGrid { + pub grid_no: u16, + pub dest_grid_no: u16, + /// Destination sector as x, y, z + pub dest_sector: (u8, u8, u8), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2Door { + pub grid_no: i16, + pub locked: bool, + pub trap_level: u8, + pub trap_id: u8, + pub lock_id: u8, + pub perceived_locked: i8, + pub perceived_trapped: i8, + pub lock_damage: i8, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2EdgePointList { + pub middle_index: u16, + pub grid_nos: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2EdgePointSet { + pub north: Ja2EdgePointList, + pub east: Ja2EdgePointList, + pub south: Ja2EdgePointList, + pub west: Ja2EdgePointList, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2EdgePoints { + pub primary: Ja2EdgePointSet, + /// Only present in maps with map version >= 17 + pub secondary: Option, +} + +/// SCHEDULENODE +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ja2Schedule { + pub time: [u16; 4], + pub data1: [u16; 4], + pub data2: [u16; 4], + pub action: [u8; 4], + pub schedule_id: u8, + pub soldier_id: u8, + pub flags: u16, +} + +#[derive(Asset, TypePath, Clone, Debug, Default, PartialEq)] +pub struct Ja2Map { + pub version: Ja2MapVersion, + pub flags: Ja2MapFlags, + pub tileset_id: i32, + pub soldier_size: u32, + pub map_elements: Vec, + /// Unknown data present in russian 6.00/26 maps + pub russian_unknown: Option<[u8; 148]>, + pub items: Vec, + pub ambient_light: Option, + pub lights: Option, + pub info: Ja2MapInfo, + pub placements: Vec, + pub exit_grids: Vec, + pub doors: Vec, + pub edge_points: Option, + pub schedules: Vec, +} + +#[derive(Default, TypePath)] +pub struct Ja2MapLoader; + +impl AssetLoader for Ja2MapLoader { + type Asset = Ja2Map; + type Settings = (); + type Error = io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + _load_context: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + Ja2Map::parse(&bytes) + } +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +fn read_bool(r: &mut dyn io::Read) -> io::Result { + Ok(r.read_u8()? != 0) +} + +fn read_bytes(r: &mut dyn io::Read) -> io::Result<[u8; N]> { + let mut buffer = [0; N]; + r.read_exact(&mut buffer)?; + Ok(buffer) +} + +fn read_i8s(r: &mut dyn io::Read) -> io::Result<[i8; N]> { + let mut buffer = [0; N]; + r.read_i8_into(&mut buffer)?; + Ok(buffer) +} + +fn read_u16s(r: &mut dyn io::Read) -> io::Result<[u16; N]> { + let mut buffer = [0; N]; + r.read_u16_into::(&mut buffer)?; + Ok(buffer) +} + +fn read_i16s(r: &mut dyn io::Read) -> io::Result<[i16; N]> { + let mut buffer = [0; N]; + r.read_i16_into::(&mut buffer)?; + Ok(buffer) +} + +/// Reads a fixed size string that is nul terminated unless it fills the whole buffer. +/// Invalid utf8 is replaced. +fn read_lossy_string(r: &mut dyn io::Read, num_bytes: usize) -> io::Result { + let mut buffer = vec![0u8; num_bytes]; + r.read_exact(&mut buffer)?; + let end = buffer.iter().position(|&b| b == 0).unwrap_or(num_bytes); + Ok(String::from_utf8_lossy(&buffer[..end]).into_owned()) +} + +/// Reads a fixed size UTF-16LE string that is nul terminated unless it fills the whole buffer +fn read_utf16_string(r: &mut dyn io::Read, num_units: usize) -> io::Result { + let mut buffer = vec![0u16; num_units]; + r.read_u16_into::(&mut buffer)?; + let end = buffer.iter().position(|&c| c == 0).unwrap_or(num_units); + Ok(String::from_utf16_lossy(&buffer[..end])) +} + +fn invalid_input(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.into()) +} + +/// A count of `what` that must fit into the field it is saved in +fn count>(len: usize, what: &str) -> io::Result { + T::try_from(len).map_err(|_| invalid_input(format!("too many {} ({})", what, len))) +} + +/// A part of the map that the map flags say is saved +fn saved<'a, T>(value: &'a Option, what: &str) -> io::Result<&'a T> { + value.as_ref().ok_or_else(|| { + invalid_input(format!( + "the map flags say {} are saved, but there are none", + what + )) + }) +} + +fn write_i8s(w: &mut dyn io::Write, values: &[i8]) -> io::Result<()> { + values.iter().try_for_each(|&value| w.write_i8(value)) +} + +fn write_u16s(w: &mut dyn io::Write, values: &[u16]) -> io::Result<()> { + values + .iter() + .try_for_each(|&value| w.write_u16::(value)) +} + +fn write_i16s(w: &mut dyn io::Write, values: &[i16]) -> io::Result<()> { + values + .iter() + .try_for_each(|&value| w.write_i16::(value)) +} + +/// Writes a fixed size string like [`read_lossy_string`] reads it, it may fill the whole buffer +fn write_lossy_string(w: &mut dyn io::Write, num_bytes: usize, string: &str) -> io::Result<()> { + let bytes = string.as_bytes(); + if bytes.len() > num_bytes { + return Err(invalid_input(format!( + "\"{}\" is longer than {} bytes", + string, num_bytes + ))); + } + w.write_all(bytes)?; + w.write_unused(num_bytes - bytes.len()) +} + +/// Writes a fixed size UTF-16LE string like [`read_utf16_string`] reads it +fn write_utf16_string(w: &mut dyn io::Write, num_units: usize, string: &str) -> io::Result<()> { + let units: Vec = string.encode_utf16().collect(); + if units.len() > num_units { + return Err(invalid_input(format!( + "\"{}\" is longer than {} characters", + string, num_units + ))); + } + write_u16s(w, &units)?; + w.write_unused(2 * (num_units - units.len())) +} + +impl Ja2Object { + /// Number of inventory slots in a detailed soldier placement + pub const NUM_INV_SLOTS: usize = 19; + + fn read(r: &mut dyn io::Read) -> io::Result { + let item = r.read_u16::()?; + let number = r.read_u8()?; + r.read_unused(1)?; + let data = read_bytes(r)?; + let attach_items = read_u16s(r)?; + let attach_status = read_i8s(r)?; + let flags = r.read_i8()?; + let mission = r.read_u8()?; + let trap = r.read_i8()?; + let imprint_id = r.read_u8()?; + let weight = r.read_u8()?; + let used = r.read_u8()?; + r.read_unused(2)?; + Ok(Self { + item, + number, + data, + attach_items, + attach_status, + flags, + mission, + trap, + imprint_id, + weight, + used, + }) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_u16::(self.item)?; + w.write_u8(self.number)?; + w.write_unused(1)?; + w.write_all(&self.data)?; + write_u16s(w, &self.attach_items)?; + write_i8s(w, &self.attach_status)?; + w.write_i8(self.flags)?; + w.write_u8(self.mission)?; + w.write_i8(self.trap)?; + w.write_u8(self.imprint_id)?; + w.write_u8(self.weight)?; + w.write_u8(self.used)?; + w.write_unused(2) + } +} + +impl Ja2WorldItem { + fn read(r: &mut dyn io::Read) -> io::Result { + let exists = read_bool(r)?; + r.read_unused(1)?; + let grid_no = r.read_i16::()?; + let level = r.read_u8()?; + r.read_unused(3)?; + let object = Ja2Object::read(r)?; + let flags = r.read_u16::()?; + let render_z_height_above_level = r.read_i8()?; + let visible = r.read_i8()?; + let non_exist_chance = r.read_u8()?; + r.read_unused(3)?; + Ok(Self { + exists, + grid_no, + level, + object, + flags, + render_z_height_above_level, + visible, + non_exist_chance, + }) + } + + fn read_all(r: &mut dyn io::Read) -> io::Result> { + let count = r.read_u32::()?; + (0..count).map(|_| Self::read(r)).collect() + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_u8(self.exists.into())?; + w.write_unused(1)?; + w.write_i16::(self.grid_no)?; + w.write_u8(self.level)?; + w.write_unused(3)?; + self.object.write(w)?; + w.write_u16::(self.flags)?; + w.write_i8(self.render_z_height_above_level)?; + w.write_i8(self.visible)?; + w.write_u8(self.non_exist_chance)?; + w.write_unused(3) + } + + fn write_all(items: &[Self], w: &mut dyn io::Write) -> io::Result<()> { + w.write_u32::(count(items.len(), "world items")?)?; + items.iter().try_for_each(|item| item.write(w)) + } +} + +impl Ja2AmbientLight { + fn read(r: &mut dyn io::Read) -> io::Result { + Ok(Self { + basement: read_bool(r)?, + caves: read_bool(r)?, + level: r.read_u8()?, + }) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_u8(self.basement.into())?; + w.write_u8(self.caves.into())?; + w.write_u8(self.level) + } +} + +impl Ja2LightSprite { + fn read(r: &mut dyn io::Read) -> io::Result { + let x = r.read_i16::()?; + let y = r.read_i16::()?; + r.read_unused(12)?; + let flags = r.read_u32::()?; + r.read_unused(4)?; + let name_len = r.read_u8()?; + if name_len == 0 { + return Err(invalid_data("light sprite template name is empty")); + } + let template_name = read_lossy_string(r, name_len.into())?; + Ok(Self { + x, + y, + flags, + template_name, + }) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_i16::(self.x)?; + w.write_i16::(self.y)?; + w.write_unused(12)?; + w.write_u32::(self.flags)?; + w.write_unused(4)?; + // Like the game, the length includes the nul terminator + let name_len = self.template_name.len() + 1; + let name_len: u8 = count(name_len, "bytes in a light template name")?; + w.write_u8(name_len)?; + write_lossy_string(w, name_len.into(), &self.template_name) + } +} + +impl Ja2Lights { + fn read(r: &mut dyn io::Read) -> io::Result { + let num_colors = r.read_u8()?; + let colors = (0..num_colors) + .map(|_| read_bytes(r)) + .collect::>()?; + let num_lights = r.read_u16::()?; + let sprites = (0..num_lights) + .map(|_| Ja2LightSprite::read(r)) + .collect::>()?; + Ok(Self { colors, sprites }) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_u8(count(self.colors.len(), "light colors")?)?; + self.colors + .iter() + .try_for_each(|color| w.write_all(color))?; + w.write_u16::(count(self.sprites.len(), "lights")?)?; + self.sprites.iter().try_for_each(|sprite| sprite.write(w)) + } +} + +impl Ja2MapInfo { + fn read(r: &mut dyn io::Read) -> io::Result { + let info = Self { + north_grid_no: r.read_i16::()?, + east_grid_no: r.read_i16::()?, + south_grid_no: r.read_i16::()?, + west_grid_no: r.read_i16::()?, + num_individuals: r.read_u8()?, + map_version: r.read_u8()?, + restricted_scroll_id: r.read_u8()?, + editor_smoothing_type: r.read_u8()?, + center_grid_no: r.read_i16::()?, + isolated_grid_no: r.read_i16::()?, + }; + // 83 padding bytes + 1 alignment byte, the struct is 100 bytes in total + r.read_unused(84)?; + Ok(info) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_i16::(self.north_grid_no)?; + w.write_i16::(self.east_grid_no)?; + w.write_i16::(self.south_grid_no)?; + w.write_i16::(self.west_grid_no)?; + w.write_u8(self.num_individuals)?; + w.write_u8(self.map_version)?; + w.write_u8(self.restricted_scroll_id)?; + w.write_u8(self.editor_smoothing_type)?; + w.write_i16::(self.center_grid_no)?; + w.write_i16::(self.isolated_grid_no)?; + w.write_unused(84) + } +} + +impl Ja2BasicSoldierPlacement { + fn read(r: &mut dyn io::Read) -> io::Result { + let detailed_placement = read_bool(r)?; + r.read_unused(1)?; + let starting_grid_no = r.read_u16::()?; + let team = r.read_i8()?; + let relative_attribute_level = r.read_i8()?; + let relative_equipment_level = r.read_i8()?; + let direction = r.read_i8()?; + let orders = r.read_i8()?; + let attitude = r.read_i8()?; + let body_type = r.read_i8()?; + r.read_unused(1)?; + let patrol_grid = read_i16s(r)?; + let patrol_cnt = r.read_i8()?; + let on_roof = read_bool(r)?; + let soldier_class = r.read_u8()?; + let civilian_group = r.read_u8()?; + let priority_existance = read_bool(r)?; + let has_keys = read_bool(r)?; + r.read_unused(14)?; + Ok(Self { + detailed_placement, + starting_grid_no, + team, + relative_attribute_level, + relative_equipment_level, + direction, + orders, + attitude, + body_type, + patrol_grid, + patrol_cnt, + on_roof, + soldier_class, + civilian_group, + priority_existance, + has_keys, + }) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_u8(self.detailed_placement.into())?; + w.write_unused(1)?; + w.write_u16::(self.starting_grid_no)?; + w.write_i8(self.team)?; + w.write_i8(self.relative_attribute_level)?; + w.write_i8(self.relative_equipment_level)?; + w.write_i8(self.direction)?; + w.write_i8(self.orders)?; + w.write_i8(self.attitude)?; + w.write_i8(self.body_type)?; + w.write_unused(1)?; + write_i16s(w, &self.patrol_grid)?; + w.write_i8(self.patrol_cnt)?; + w.write_u8(self.on_roof.into())?; + w.write_u8(self.soldier_class)?; + w.write_u8(self.civilian_group)?; + w.write_u8(self.priority_existance.into())?; + w.write_u8(self.has_keys.into())?; + w.write_unused(14) + } +} + +impl Ja2DetailedSoldierPlacement { + const PALETTE_LENGTH: usize = 30; + const NAME_LENGTH: usize = 10; + + fn read(r: &mut dyn io::Read) -> io::Result { + let is_static = read_bool(r)?; + let profile = r.read_u8()?; + r.read_unused(2)?; + let copy_profile_items_over = read_bool(r)?; + r.read_unused(1)?; + let sector_x = r.read_i16::()?; + let sector_y = r.read_i16::()?; + let direction = r.read_i8()?; + r.read_unused(1)?; + let insertion_grid_no = r.read_i16::()?; + let team = r.read_i8()?; + let body_type = r.read_i8()?; + let attitude = r.read_i8()?; + let orders = r.read_i8()?; + let life_max = r.read_i8()?; + let life = r.read_i8()?; + let agility = r.read_i8()?; + let dexterity = r.read_i8()?; + let exp_level = r.read_i8()?; + let marksmanship = r.read_i8()?; + let medical = r.read_i8()?; + let mechanical = r.read_i8()?; + let explosive = r.read_i8()?; + let leadership = r.read_i8()?; + let strength = r.read_i8()?; + let wisdom = r.read_i8()?; + let morale = r.read_i8()?; + let ai_morale = r.read_i8()?; + let inventory = (0..Ja2Object::NUM_INV_SLOTS) + .map(|_| Ja2Object::read(r)) + .collect::>()?; + let head_pal = read_lossy_string(r, Self::PALETTE_LENGTH)?; + let pants_pal = read_lossy_string(r, Self::PALETTE_LENGTH)?; + let vest_pal = read_lossy_string(r, Self::PALETTE_LENGTH)?; + let skin_pal = read_lossy_string(r, Self::PALETTE_LENGTH)?; + r.read_unused(30)?; + let patrol_grid = read_i16s(r)?; + let patrol_cnt = r.read_i8()?; + let visible = read_bool(r)?; + let name = read_utf16_string(r, Self::NAME_LENGTH)?; + let soldier_class = r.read_u8()?; + let on_roof = read_bool(r)?; + let sector_z = r.read_i8()?; + r.read_unused(6)?; + let civilian_group = r.read_u8()?; + r.read_unused(1)?; + let schedule_id = r.read_u8()?; + let use_given_vehicle = read_bool(r)?; + let use_given_vehicle_id = r.read_i8()?; + let has_keys = read_bool(r)?; + r.read_unused(117)?; + Ok(Self { + is_static, + profile, + copy_profile_items_over, + sector_x, + sector_y, + direction, + insertion_grid_no, + team, + body_type, + attitude, + orders, + life_max, + life, + agility, + dexterity, + exp_level, + marksmanship, + medical, + mechanical, + explosive, + leadership, + strength, + wisdom, + morale, + ai_morale, + inventory, + head_pal, + pants_pal, + vest_pal, + skin_pal, + patrol_grid, + patrol_cnt, + visible, + name, + soldier_class, + on_roof, + sector_z, + civilian_group, + schedule_id, + use_given_vehicle, + use_given_vehicle_id, + has_keys, + }) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + if self.inventory.len() != Ja2Object::NUM_INV_SLOTS { + return Err(invalid_input(format!( + "a detailed placement has {} inventory slots instead of {}", + self.inventory.len(), + Ja2Object::NUM_INV_SLOTS + ))); + } + w.write_u8(self.is_static.into())?; + w.write_u8(self.profile)?; + w.write_unused(2)?; + w.write_u8(self.copy_profile_items_over.into())?; + w.write_unused(1)?; + w.write_i16::(self.sector_x)?; + w.write_i16::(self.sector_y)?; + w.write_i8(self.direction)?; + w.write_unused(1)?; + w.write_i16::(self.insertion_grid_no)?; + write_i8s( + w, + &[ + self.team, + self.body_type, + self.attitude, + self.orders, + self.life_max, + self.life, + self.agility, + self.dexterity, + self.exp_level, + self.marksmanship, + self.medical, + self.mechanical, + self.explosive, + self.leadership, + self.strength, + self.wisdom, + self.morale, + self.ai_morale, + ], + )?; + self.inventory + .iter() + .try_for_each(|object| object.write(w))?; + write_lossy_string(w, Self::PALETTE_LENGTH, &self.head_pal)?; + write_lossy_string(w, Self::PALETTE_LENGTH, &self.pants_pal)?; + write_lossy_string(w, Self::PALETTE_LENGTH, &self.vest_pal)?; + write_lossy_string(w, Self::PALETTE_LENGTH, &self.skin_pal)?; + w.write_unused(30)?; + write_i16s(w, &self.patrol_grid)?; + w.write_i8(self.patrol_cnt)?; + w.write_u8(self.visible.into())?; + write_utf16_string(w, Self::NAME_LENGTH, &self.name)?; + w.write_u8(self.soldier_class)?; + w.write_u8(self.on_roof.into())?; + w.write_i8(self.sector_z)?; + w.write_unused(6)?; + w.write_u8(self.civilian_group)?; + w.write_unused(1)?; + w.write_u8(self.schedule_id)?; + w.write_u8(self.use_given_vehicle.into())?; + w.write_i8(self.use_given_vehicle_id)?; + w.write_u8(self.has_keys.into())?; + w.write_unused(117) + } +} + +impl Ja2SoldierPlacement { + fn read(r: &mut dyn io::Read) -> io::Result { + let basic = Ja2BasicSoldierPlacement::read(r)?; + let detailed = if basic.detailed_placement { + Some(Ja2DetailedSoldierPlacement::read(r)?) + } else { + None + }; + Ok(Self { basic, detailed }) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + if self.basic.detailed_placement != self.detailed.is_some() { + return Err(invalid_input( + "the detailed placement flag of a placement does not match its data", + )); + } + self.basic.write(w)?; + match &self.detailed { + Some(detailed) => detailed.write(w), + None => Ok(()), + } + } +} + +impl Ja2ExitGrid { + fn read(r: &mut dyn io::Read) -> io::Result { + Ok(Self { + grid_no: r.read_u16::()?, + dest_grid_no: r.read_u16::()?, + dest_sector: (r.read_u8()?, r.read_u8()?, r.read_u8()?), + }) + } + + fn read_all(r: &mut dyn io::Read) -> io::Result> { + let count = r.read_u16::()?; + (0..count).map(|_| Self::read(r)).collect() + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_u16::(self.grid_no)?; + w.write_u16::(self.dest_grid_no)?; + let (x, y, z) = self.dest_sector; + w.write_all(&[x, y, z]) + } + + fn write_all(exit_grids: &[Self], w: &mut dyn io::Write) -> io::Result<()> { + w.write_u16::(count(exit_grids.len(), "exit grids")?)?; + exit_grids + .iter() + .try_for_each(|exit_grid| exit_grid.write(w)) + } +} + +impl Ja2Door { + fn read(r: &mut dyn io::Read) -> io::Result { + let door = Self { + grid_no: r.read_i16::()?, + locked: read_bool(r)?, + trap_level: r.read_u8()?, + trap_id: r.read_u8()?, + lock_id: r.read_u8()?, + perceived_locked: r.read_i8()?, + perceived_trapped: r.read_i8()?, + lock_damage: r.read_i8()?, + }; + r.read_unused(5)?; + Ok(door) + } + + fn read_all(r: &mut dyn io::Read) -> io::Result> { + let count = r.read_u8()?; + (0..count).map(|_| Self::read(r)).collect() + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_i16::(self.grid_no)?; + w.write_u8(self.locked.into())?; + w.write_u8(self.trap_level)?; + w.write_u8(self.trap_id)?; + w.write_u8(self.lock_id)?; + w.write_i8(self.perceived_locked)?; + w.write_i8(self.perceived_trapped)?; + w.write_i8(self.lock_damage)?; + w.write_unused(5) + } + + fn write_all(doors: &[Self], w: &mut dyn io::Write) -> io::Result<()> { + w.write_u8(count(doors.len(), "doors")?)?; + doors.iter().try_for_each(|door| door.write(w)) + } +} + +impl Ja2EdgePointList { + fn read(r: &mut dyn io::Read) -> io::Result { + let count = r.read_u16::()?; + let middle_index = r.read_u16::()?; + let mut grid_nos = vec![0; count.into()]; + r.read_i16_into::(&mut grid_nos)?; + Ok(Self { + middle_index, + grid_nos, + }) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_u16::(count(self.grid_nos.len(), "edge points")?)?; + w.write_u16::(self.middle_index)?; + write_i16s(w, &self.grid_nos) + } +} + +impl Ja2EdgePointSet { + fn read(r: &mut dyn io::Read) -> io::Result { + Ok(Self { + north: Ja2EdgePointList::read(r)?, + east: Ja2EdgePointList::read(r)?, + south: Ja2EdgePointList::read(r)?, + west: Ja2EdgePointList::read(r)?, + }) + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + self.north.write(w)?; + self.east.write(w)?; + self.south.write(w)?; + self.west.write(w) + } +} + +impl Ja2EdgePoints { + fn read(info: &Ja2MapInfo, r: &mut dyn io::Read) -> io::Result { + let primary = Ja2EdgePointSet::read(r)?; + let secondary = if info.map_version >= 17 { + Some(Ja2EdgePointSet::read(r)?) + } else { + None + }; + Ok(Self { primary, secondary }) + } + + fn write(&self, info: &Ja2MapInfo, w: &mut dyn io::Write) -> io::Result<()> { + self.primary.write(w)?; + match (&self.secondary, info.map_version >= 17) { + (Some(secondary), true) => secondary.write(w), + (None, false) => Ok(()), + _ => Err(invalid_input( + "secondary edge points must exist exactly in maps with map version >= 17", + )), + } + } +} + +impl Ja2Schedule { + fn read(r: &mut dyn io::Read) -> io::Result { + r.read_unused(4)?; + Ok(Self { + time: read_u16s(r)?, + data1: read_u16s(r)?, + data2: read_u16s(r)?, + action: read_bytes(r)?, + schedule_id: r.read_u8()?, + soldier_id: r.read_u8()?, + flags: r.read_u16::()?, + }) + } + + fn read_all(r: &mut dyn io::Read) -> io::Result> { + let count = r.read_u8()?; + (0..count).map(|_| Self::read(r)).collect() + } + + fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + w.write_unused(4)?; + write_u16s(w, &self.time)?; + write_u16s(w, &self.data1)?; + write_u16s(w, &self.data2)?; + w.write_all(&self.action)?; + w.write_u8(self.schedule_id)?; + w.write_u8(self.soldier_id)?; + w.write_u16::(self.flags) + } + + fn write_all(schedules: &[Self], w: &mut dyn io::Write) -> io::Result<()> { + w.write_u8(count(schedules.len(), "schedules")?)?; + schedules.iter().try_for_each(|schedule| schedule.write(w)) + } +} + +/// Indices into the per tile layer counts +const LAND: usize = 0; +const OBJECTS: usize = 1; +const STRUCTS: usize = 2; +const SHADOWS: usize = 3; +const ROOFS: usize = 4; +const ON_ROOFS: usize = 5; + +impl Ja2MapElement { + /// The tile layer with the given index + fn layer(&self, layer: usize) -> &[Ja2TileLayer] { + match layer { + LAND => &self.land, + OBJECTS => &self.objects, + STRUCTS => &self.structs, + SHADOWS => &self.shadows, + ROOFS => &self.roofs, + _ => &self.on_roofs, + } + } +} + +impl Ja2Map { + fn read_heights(map_elements: &mut [Ja2MapElement], r: &mut dyn io::Read) -> io::Result<()> { + for element in map_elements.iter_mut() { + // Second byte is unused filler + let [height, _] = read_bytes(r)?; + element.height = height; + } + Ok(()) + } + + fn read_counts( + map_elements: &mut [Ja2MapElement], + r: &mut dyn io::Read, + ) -> io::Result> { + let mut counts = Vec::with_capacity(map_elements.len()); + for element in map_elements.iter_mut() { + let count: [u8; 4] = read_bytes(r)?; + element.flags = Ja2MapElementFlags::from_bits(count[0] >> 4) + .ok_or_else(|| invalid_data("unknown map element bitflags"))?; + counts.push([ + count[0] & 0x0F, + count[1] & 0x0F, + count[1] >> 4, + count[2] & 0x0F, + count[2] >> 4, + count[3] & 0x0F, + ]); + } + Ok(counts) + } + + /// Reads one tile layer for all map elements. `wide_sub_index` is used for + /// objects in newer maps that store the sub index as u16. + fn read_layer( + map_elements: &mut [Ja2MapElement], + counts: &[[u8; 6]], + layer: usize, + wide_sub_index: bool, + r: &mut dyn io::Read, + ) -> io::Result<()> { + for (element, count) in map_elements.iter_mut().zip(counts) { + let tiles = (0..count[layer]) + .map(|_| { + let kind = r.read_u8()?; + let sub_index = if wide_sub_index { + r.read_u16::()? + } else { + r.read_u8()?.into() + }; + Ok(Ja2TileLayer { kind, sub_index }) + }) + .collect::>>()?; + let target = match layer { + LAND => &mut element.land, + OBJECTS => &mut element.objects, + STRUCTS => &mut element.structs, + SHADOWS => &mut element.shadows, + ROOFS => &mut element.roofs, + _ => &mut element.on_roofs, + }; + *target = tiles; + } + Ok(()) + } + + fn read_rooms(map_elements: &mut [Ja2MapElement], r: &mut dyn io::Read) -> io::Result<()> { + let mut rooms = vec![0; map_elements.len()]; + r.read_exact(&mut rooms)?; + for (element, room) in map_elements.iter_mut().zip(rooms) { + element.room = room; + } + Ok(()) + } + + fn write_heights(map_elements: &[Ja2MapElement], w: &mut dyn io::Write) -> io::Result<()> { + for element in map_elements { + // Second byte is unused filler + w.write_all(&[element.height, 0])?; + } + Ok(()) + } + + fn write_counts(map_elements: &[Ja2MapElement], w: &mut dyn io::Write) -> io::Result<()> { + for element in map_elements { + let mut counts = [0u8; 6]; + for (layer, count) in counts.iter_mut().enumerate() { + let len = element.layer(layer).len(); + *count = u8::try_from(len) + .ok() + .filter(|&count| count <= 0x0F) + .ok_or_else(|| { + invalid_input(format!("a tile has {} tiles in one layer, max is 15", len)) + })?; + } + w.write_all(&[ + counts[LAND] | element.flags.bits() << 4, + counts[OBJECTS] | counts[STRUCTS] << 4, + counts[SHADOWS] | counts[ROOFS] << 4, + counts[ON_ROOFS], + ])?; + } + Ok(()) + } + + /// Writes one tile layer for all map elements, like [`Ja2Map::read_layer`] reads it + fn write_layer( + map_elements: &[Ja2MapElement], + layer: usize, + wide_sub_index: bool, + w: &mut dyn io::Write, + ) -> io::Result<()> { + for element in map_elements { + for tile in element.layer(layer) { + w.write_u8(tile.kind)?; + if wide_sub_index { + w.write_u16::(tile.sub_index)?; + } else { + let sub_index = u8::try_from(tile.sub_index).map_err(|_| { + invalid_input(format!( + "sub index {} does not fit into the map version", + tile.sub_index + )) + })?; + w.write_u8(sub_index)?; + } + } + } + Ok(()) + } +} + +impl Ja2Map { + pub fn parse(mut bytes: &[u8]) -> io::Result { + let r: &mut dyn io::Read = &mut bytes; + + let version = { + let major = r.read_f32::()?; + let minor = if major >= 4.0 { r.read_u8()? } else { 0 }; + Ja2MapVersion { major, minor } + }; + if version.major > 6. || version.minor > 26 { + return Err(invalid_data(format!( + "map version too high, was {}.{}, max is {}.{}", + version.major, version.minor, 6., 26, + ))); + } + + let flags = Ja2MapFlags::from_bits(r.read_u32::()?) + .ok_or_else(|| invalid_data("unknown map bitflags"))?; + let tileset_id = r.read_i32::()?; + let soldier_size = r.read_u32::()?; + + let mut map_elements = vec![Ja2MapElement::default(); JA2_TILE_COUNT]; + Self::read_heights(&mut map_elements, r)?; + let counts = Self::read_counts(&mut map_elements, r)?; + for layer in [LAND, OBJECTS, STRUCTS, SHADOWS, ROOFS, ON_ROOFS] { + let wide_sub_index = layer == OBJECTS && version.minor >= 15; + Self::read_layer(&mut map_elements, &counts, layer, wide_sub_index, r)?; + } + + let russian_unknown = if version.major == 6. && version.minor == 26 { + Some(read_bytes(r)?) + } else { + None + }; + + Self::read_rooms(&mut map_elements, r)?; + + let items = if flags.contains(Ja2MapFlags::MAP_WORLDITEMS_SAVED) { + Ja2WorldItem::read_all(r)? + } else { + vec![] + }; + let ambient_light = if flags.contains(Ja2MapFlags::MAP_AMBIENTLIGHTLEVEL_SAVED) { + Some(Ja2AmbientLight::read(r)?) + } else { + None + }; + let lights = if flags.contains(Ja2MapFlags::MAP_WORLDLIGHTS_SAVED) { + Some(Ja2Lights::read(r)?) + } else { + None + }; + + let info = Ja2MapInfo::read(r)?; + if version.major >= 4. && info.map_version != version.minor { + return Err(invalid_data(format!( + "map info version {} does not match minor version {}", + info.map_version, version.minor + ))); + } + + let placements = if flags.contains(Ja2MapFlags::MAP_FULLSOLDIER_SAVED) { + (0..info.num_individuals) + .map(|_| Ja2SoldierPlacement::read(r)) + .collect::>()? + } else { + vec![] + }; + let exit_grids = if flags.contains(Ja2MapFlags::MAP_EXITGRIDS_SAVED) { + Ja2ExitGrid::read_all(r)? + } else { + vec![] + }; + let doors = if flags.contains(Ja2MapFlags::MAP_DOORTABLE_SAVED) { + Ja2Door::read_all(r)? + } else { + vec![] + }; + let edge_points = if flags.contains(Ja2MapFlags::MAP_EDGEPOINTS_SAVED) { + Some(Ja2EdgePoints::read(&info, r)?) + } else { + None + }; + let schedules = if flags.contains(Ja2MapFlags::MAP_NPCSCHEDULES_SAVED) { + Ja2Schedule::read_all(r)? + } else { + vec![] + }; + // Any remaining data is ignored, like the game does + + Ok(Ja2Map { + version, + flags, + tileset_id, + soldier_size, + map_elements, + russian_unknown, + items, + ambient_light, + lights, + info, + placements, + exit_grids, + doors, + edge_points, + schedules, + }) + } + + /// Writes the map in its version, the inverse of [`Ja2Map::parse`] + pub fn write(&self, w: &mut dyn io::Write) -> io::Result<()> { + let version = &self.version; + w.write_f32::(version.major)?; + if version.major >= 4.0 { + w.write_u8(version.minor)?; + } + w.write_u32::(self.flags.bits())?; + w.write_i32::(self.tileset_id)?; + w.write_u32::(self.soldier_size)?; + + if self.map_elements.len() != JA2_TILE_COUNT { + return Err(invalid_input(format!( + "the map has {} tiles instead of {}", + self.map_elements.len(), + JA2_TILE_COUNT + ))); + } + Self::write_heights(&self.map_elements, w)?; + Self::write_counts(&self.map_elements, w)?; + for layer in [LAND, OBJECTS, STRUCTS, SHADOWS, ROOFS, ON_ROOFS] { + let wide_sub_index = layer == OBJECTS && version.minor >= 15; + Self::write_layer(&self.map_elements, layer, wide_sub_index, w)?; + } + + let is_russian = version.major == 6. && version.minor == 26; + match (&self.russian_unknown, is_russian) { + (Some(russian_unknown), true) => w.write_all(russian_unknown)?, + (None, true) => w.write_unused(148)?, + _ => {} + } + + let rooms: Vec = self + .map_elements + .iter() + .map(|element| element.room) + .collect(); + w.write_all(&rooms)?; + + let flags = self.flags; + if flags.contains(Ja2MapFlags::MAP_WORLDITEMS_SAVED) { + Ja2WorldItem::write_all(&self.items, w)?; + } + if flags.contains(Ja2MapFlags::MAP_AMBIENTLIGHTLEVEL_SAVED) { + saved(&self.ambient_light, "ambient lights")?.write(w)?; + } + if flags.contains(Ja2MapFlags::MAP_WORLDLIGHTS_SAVED) { + saved(&self.lights, "lights")?.write(w)?; + } + + self.info.write(w)?; + + if flags.contains(Ja2MapFlags::MAP_FULLSOLDIER_SAVED) { + if usize::from(self.info.num_individuals) != self.placements.len() { + return Err(invalid_input(format!( + "the map info says there are {} placements, but there are {}", + self.info.num_individuals, + self.placements.len() + ))); + } + self.placements + .iter() + .try_for_each(|placement| placement.write(w))?; + } + if flags.contains(Ja2MapFlags::MAP_EXITGRIDS_SAVED) { + Ja2ExitGrid::write_all(&self.exit_grids, w)?; + } + if flags.contains(Ja2MapFlags::MAP_DOORTABLE_SAVED) { + Ja2Door::write_all(&self.doors, w)?; + } + if flags.contains(Ja2MapFlags::MAP_EDGEPOINTS_SAVED) { + saved(&self.edge_points, "edge points")?.write(&self.info, w)?; + } + if flags.contains(Ja2MapFlags::MAP_NPCSCHEDULES_SAVED) { + Ja2Schedule::write_all(&self.schedules, w)?; + } + Ok(()) + } + + /// The map as written by [`Ja2Map::write`] + pub fn to_bytes(&self) -> io::Result> { + let mut bytes = vec![]; + self.write(&mut bytes)?; + Ok(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Parses every `.dat` file in the directory given by `JA2_MAPS_DIR` + #[test] + fn parse_maps_from_dir() { + let Ok(dir) = std::env::var("JA2_MAPS_DIR") else { + eprintln!("JA2_MAPS_DIR not set, skipping"); + return; + }; + let mut parsed = 0; + let mut failures = vec![]; + for entry in std::fs::read_dir(&dir).expect("JA2_MAPS_DIR should be readable") { + let path = entry.expect("dir entry should be readable").path(); + let is_dat = path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("dat")); + if !is_dat { + continue; + } + let bytes = std::fs::read(&path).expect("map should be readable"); + match Ja2Map::parse(&bytes) { + Ok(map) => { + if map.version.major >= 4. { + assert_eq!(map.info.map_version, map.version.minor, "{:?}", path); + } + parsed += 1; + } + Err(e) => failures.push(format!("{}: {}", path.display(), e)), + } + } + assert!( + failures.is_empty(), + "failed to parse maps:\n{}", + failures.join("\n") + ); + assert!(parsed > 0, "no maps found in {}", dir); + } + + /// A map that uses every part of the map file + fn synthetic_map(major: f32, minor: u8) -> Ja2Map { + let mut map_elements = vec![Ja2MapElement::default(); JA2_TILE_COUNT]; + map_elements[3] = Ja2MapElement { + flags: Ja2MapElementFlags::MAPELEMENT_ENEMY_MINE_PRESENT + | Ja2MapElementFlags::MAPELEMENT_REDUNDANT, + height: 20, + land: vec![ + Ja2TileLayer { + kind: 1, + sub_index: 3, + }; + 15 + ], + objects: vec![Ja2TileLayer { + kind: 40, + sub_index: if minor >= 15 { 300 } else { 200 }, + }], + structs: vec![Ja2TileLayer { + kind: 50, + sub_index: 7, + }], + shadows: vec![Ja2TileLayer { + kind: 60, + sub_index: 1, + }], + roofs: vec![Ja2TileLayer { + kind: 70, + sub_index: 2, + }], + on_roofs: vec![Ja2TileLayer { + kind: 80, + sub_index: 4, + }], + room: 9, + }; + let edge_list = |grid_nos: Vec| Ja2EdgePointList { + middle_index: 1, + grid_nos, + }; + let edge_set = Ja2EdgePointSet { + north: edge_list(vec![1, 2, 3]), + east: edge_list(vec![]), + south: edge_list(vec![-1]), + west: edge_list(vec![5, 6]), + }; + let object = Ja2Object { + item: 12, + number: 2, + data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + attach_items: [1, 0, 3, 0], + attach_status: [100, 0, -5, 0], + flags: -3, + mission: 4, + trap: 1, + imprint_id: 7, + weight: 8, + used: 1, + }; + let placement = Ja2SoldierPlacement { + basic: Ja2BasicSoldierPlacement { + detailed_placement: true, + starting_grid_no: 1234, + team: 1, + direction: 3, + body_type: -1, + patrol_grid: [1, 2, 3, 4, 5, 6, 7, 8, 9, -1], + patrol_cnt: 3, + on_roof: true, + soldier_class: 2, + has_keys: true, + ..default() + }, + detailed: Some(Ja2DetailedSoldierPlacement { + profile: 200, + sector_x: 9, + sector_y: 1, + insertion_grid_no: -1, + life: 50, + ai_morale: -1, + inventory: vec![object.clone(); Ja2Object::NUM_INV_SLOTS], + head_pal: "BROWNHEAD".to_owned(), + skin_pal: "X".repeat(Ja2DetailedSoldierPlacement::PALETTE_LENGTH), + name: "Kingpin".to_owned(), + sector_z: 1, + use_given_vehicle_id: -1, + ..default() + }), + }; + Ja2Map { + version: Ja2MapVersion { major, minor }, + flags: Ja2MapFlags::all(), + tileset_id: 3, + soldier_size: 1040, + map_elements, + russian_unknown: (major == 6. && minor == 26).then(|| { + let mut unknown = [0; 148]; + unknown[0] = 1; + unknown[147] = 2; + unknown + }), + items: vec![Ja2WorldItem { + exists: true, + grid_no: 555, + level: 1, + object, + flags: 0x10, + render_z_height_above_level: -1, + visible: 1, + non_exist_chance: 20, + }], + ambient_light: Some(Ja2AmbientLight { + basement: true, + caves: false, + level: 12, + }), + lights: Some(Ja2Lights { + colors: vec![[10, 20, 30, 0]], + sprites: vec![Ja2LightSprite { + x: 10, + y: 20, + flags: 0x80, + template_name: "L-R01.LHT".to_owned(), + }], + }), + info: Ja2MapInfo { + north_grid_no: -1, + east_grid_no: 100, + num_individuals: 1, + map_version: minor, + editor_smoothing_type: 2, + center_grid_no: 12880, + ..default() + }, + placements: vec![placement], + exit_grids: vec![Ja2ExitGrid { + grid_no: 7, + dest_grid_no: 8, + dest_sector: (9, 1, 2), + }], + doors: vec![Ja2Door { + grid_no: 99, + locked: true, + trap_level: 3, + trap_id: 2, + lock_id: 4, + perceived_locked: -1, + perceived_trapped: 1, + lock_damage: 5, + }], + edge_points: Some(Ja2EdgePoints { + primary: edge_set.clone(), + secondary: (minor >= 17).then_some(edge_set), + }), + schedules: vec![Ja2Schedule { + time: [1, 2, 3, 4], + data1: [5, 6, 7, 8], + data2: [9, 10, 11, 12], + action: [1, 2, 3, 4], + schedule_id: 1, + soldier_id: 2, + flags: 3, + }], + } + } + + #[test] + fn written_maps_parse_the_same() { + for (major, minor) in [(6., 26), (6., 25), (6., 15), (5., 14)] { + let map = synthetic_map(major, minor); + let bytes = map.to_bytes().unwrap(); + let parsed = Ja2Map::parse(&bytes).unwrap(); + assert_eq!(parsed, map, "version {}.{}", major, minor); + assert_eq!(parsed.to_bytes().unwrap(), bytes); + } + } + + #[test] + fn rejects_what_does_not_fit() { + let mut map = synthetic_map(6., 26); + map.map_elements[0].structs = vec![Ja2TileLayer::default(); 16]; + assert!(map.to_bytes().is_err()); + + let mut map = synthetic_map(6., 26); + map.doors = vec![Ja2Door::default(); 256]; + assert!(map.to_bytes().is_err()); + + let mut map = synthetic_map(5., 14); + map.map_elements[0].objects = vec![Ja2TileLayer { + kind: 1, + sub_index: 256, + }]; + assert!(map.to_bytes().is_err()); + + let mut map = synthetic_map(6., 26); + map.info.num_individuals = 2; + assert!(map.to_bytes().is_err()); + } + + /// Writes every `.dat` file in the directory given by `JA2_MAPS_DIR` and parses it again + #[test] + fn write_maps_from_dir() { + let Ok(dir) = std::env::var("JA2_MAPS_DIR") else { + eprintln!("JA2_MAPS_DIR not set, skipping"); + return; + }; + let mut written = 0; + for entry in std::fs::read_dir(&dir).expect("JA2_MAPS_DIR should be readable") { + let path = entry.expect("dir entry should be readable").path(); + let is_dat = path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("dat")); + if !is_dat { + continue; + } + let bytes = std::fs::read(&path).expect("map should be readable"); + let map = Ja2Map::parse(&bytes).expect("map should parse"); + let written_bytes = map + .to_bytes() + .unwrap_or_else(|e| panic!("{}: {}", path.display(), e)); + let reparsed = Ja2Map::parse(&written_bytes) + .unwrap_or_else(|e| panic!("{}: {}", path.display(), e)); + assert!(reparsed == map, "{} changed", path.display()); + written += 1; + } + assert!(written > 0, "no maps found in {}", dir); + } +} diff --git a/src-map-editor/src/file_formats/ja2_stci.rs b/src-map-editor/src/file_formats/ja2_stci.rs new file mode 100644 index 0000000..15421a0 --- /dev/null +++ b/src-map-editor/src/file_formats/ja2_stci.rs @@ -0,0 +1,33 @@ +use crate::file_formats::stci::Stci; + +use super::stci::StciAppData; + +#[derive(Clone, Debug, PartialEq)] +pub struct SubImageInfo { + pub offset: (i16, i16), + /// Width and height + pub dimensions: (u16, u16), + /// Set for all sub images or for none, see `Stci::from_input` + pub app_data: Option, +} + +impl SubImageInfo { + /// Metadata of each sub image of an STCI, RGB STCIs have a single sub image + pub fn from_stci(stci: &Stci) -> Vec { + match stci { + Stci::Indexed { sub_images, .. } => sub_images + .iter() + .map(|sub_image| SubImageInfo { + offset: sub_image.offset, + dimensions: sub_image.dimensions, + app_data: sub_image.app_data.clone(), + }) + .collect(), + Stci::Rgb { width, height, .. } => vec![SubImageInfo { + offset: (0, 0), + dimensions: (*width, *height), + app_data: None, + }], + } + } +} diff --git a/src-map-editor/src/file_formats/ja2_structure.rs b/src-map-editor/src/file_formats/ja2_structure.rs new file mode 100644 index 0000000..c7c5887 --- /dev/null +++ b/src-map-editor/src/file_formats/ja2_structure.rs @@ -0,0 +1,781 @@ +//! Structure data files (`.jsd`, "J2SD") +//! +//! A JSD file lies next to a tile STI and describes the structures of its sub images: which tiles +//! they cover, their line of sight profile, armour, etc. It can also replace the app data of the +//! STI (aux data and relative tile locations). Port of `STRUCTURE_FILE_REF`, +//! `CreateFileStructureArrays` and `AddZStripInfoToVObject` in `Structure.cc` of the game. + +use std::io::{self, ErrorKind::InvalidData, Read}; + +use bevy::{ + asset::{io::Reader, AssetLoader, LoadContext}, + prelude::*, +}; +use byteorder::{ReadBytesExt, LE}; + +use super::{ + ja2_stci::SubImageInfo, ja2_tile_types::Orientation, stci::StciAppData, StracciatellaReadExt, +}; + +/// Width of the map in tiles (`WORLD_COLS`) +const WORLD_COLS: i16 = 160; +/// Width of a tile in pixels (`WORLD_TILE_X`) +const WORLD_TILE_X: i32 = 40; + +const STRUCTURE_FILE_ID: &[u8; 4] = b"J2SD"; +const STRUCTURE_FILE_HEADER_SIZE: usize = 16; +const STRUCTURE_FILE_CONTAINS_AUXIMAGEDATA: u8 = 0x01; +const STRUCTURE_FILE_CONTAINS_STRUCTUREDATA: u8 = 0x02; + +/// Size of `DB_STRUCTURE` in bytes +pub const DB_STRUCTURE_SIZE: usize = 16; +/// Size of `DB_STRUCTURE_TILE` in bytes +pub const DB_STRUCTURE_TILE_SIZE: usize = 32; + +/// Size of the line of sight profile of a structure tile +pub const PROFILE_X_SIZE: usize = 5; +pub const PROFILE_Y_SIZE: usize = 5; +pub const PROFILE_Z_SIZE: usize = 4; + +bitflags::bitflags! { + /// Flags of a structure (`StructureFlags` in `Structure_Internals.h`) + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct StructureFlags: u32 { + const BASE_TILE = 0x0000_0001; + const OPEN = 0x0000_0002; + const OPENABLE = 0x0000_0004; + const GARAGEDOOR = 0x0000_0008; + const MOBILE = 0x0000_0010; + const PASSABLE = 0x0000_0020; + const EXPLOSIVE = 0x0000_0040; + const TRANSPARENT = 0x0000_0080; + const GENERIC = 0x0000_0100; + const TREE = 0x0000_0200; + const FENCE = 0x0000_0400; + const WIREFENCE = 0x0000_0800; + const HASITEMONTOP = 0x0000_1000; + const SPECIAL = 0x0000_2000; + const LIGHTSOURCE = 0x0000_4000; + const VEHICLE = 0x0000_8000; + const WALL = 0x0001_0000; + const WALLNWINDOW = 0x0002_0000; + const SLIDINGDOOR = 0x0004_0000; + const DOOR = 0x0008_0000; + const MULTI = 0x0010_0000; + const CAVEWALL = 0x0020_0000; + const DDOOR_LEFT = 0x0040_0000; + const DDOOR_RIGHT = 0x0080_0000; + const NORMAL_ROOF = 0x0100_0000; + const SLANTED_ROOF = 0x0200_0000; + const TALL_ROOF = 0x0400_0000; + const SWITCH = 0x0800_0000; + const ON_LEFT_WALL = 0x1000_0000; + const ON_RIGHT_WALL = 0x2000_0000; + const CORPSE = 0x4000_0000; + const PERSON = 0x8000_0000; + + const ANYFENCE = 0x0000_0C00; + const ANYDOOR = 0x00CC_0008; + const OBSTACLE = 0x0000_8F00; + const WALLSTUFF = 0x00CF_0008; + const BLOCKSMOVES = 0x0020_8F00; + const TYPE_DEFINED = 0x8FEF_8F08; + const ROOF = 0x0700_0000; + } +} + +bitflags::bitflags! { + /// Flags of a structure tile + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct StructureTileFlags: u8 { + const ON_ROOF = 0x01; + const PASSABLE = 0x02; + } +} + +/// One tile of a structure (`DB_STRUCTURE_TILE`) +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DbStructureTile { + /// Position relative to the base tile as a grid number offset, `x + y * 160` + pub pos_rel_to_base: i16, + pub x_pos_rel_to_base: i8, + pub y_pos_rel_to_base: i8, + /// Line of sight profile, each byte has one bit per height level + pub shape: [[u8; PROFILE_Y_SIZE]; PROFILE_X_SIZE], + pub flags: StructureTileFlags, + pub vehicle_hit_location: u8, +} + +impl DbStructureTile { + fn from_input(input: &mut impl Read) -> io::Result { + let pos_rel_to_base = input.read_i16::()?; + let x_pos_rel_to_base = input.read_i8()?; + let y_pos_rel_to_base = input.read_i8()?; + let mut shape = [[0; PROFILE_Y_SIZE]; PROFILE_X_SIZE]; + for row in &mut shape { + input.read_exact(row)?; + } + let flags = StructureTileFlags::from_bits_retain(input.read_u8()?); + let vehicle_hit_location = input.read_u8()?; + input.read_unused(1)?; + Ok(Self { + pos_rel_to_base, + x_pos_rel_to_base, + y_pos_rel_to_base, + shape, + flags, + vehicle_hit_location, + }) + } + + /// Number of filled cubes in the profile (`FilledTilePositions`) + fn filled_positions(&self) -> u32 { + self.shape + .iter() + .flatten() + .map(|&value| (value & 0x0F).count_ones()) + .sum() + } +} + +/// A structure, the structure data of one sub image (`DB_STRUCTURE`) +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DbStructure { + pub armour: u8, + /// Computed from the profile when loading, like in the game + pub hit_points: u8, + pub density: u8, + pub flags: StructureFlags, + pub structure_number: u16, + pub wall_orientation: Orientation, + /// > 0: debris number (value - 1), < 0: partner graphic + pub destruction_partner: i8, + /// Opened/closed version etc., 0 if unused + pub partner_delta: i8, + pub z_tile_offset_x: i8, + pub z_tile_offset_y: i8, + pub tiles: Vec, +} + +/// Header of a `DB_STRUCTURE`, the tiles follow it +struct DbStructureHeader { + armour: u8, + density: u8, + number_of_tiles: u8, + flags: StructureFlags, + structure_number: u16, + wall_orientation: u8, + destruction_partner: i8, + partner_delta: i8, + z_tile_offset_x: i8, + z_tile_offset_y: i8, +} + +impl DbStructureHeader { + fn from_input(input: &mut impl Read) -> io::Result { + let armour = input.read_u8()?; + let _hit_points = input.read_u8()?; + let density = input.read_u8()?; + let number_of_tiles = input.read_u8()?; + let flags = StructureFlags::from_bits_retain(input.read_u32::()?); + let structure_number = input.read_u16::()?; + let wall_orientation = input.read_u8()?; + let destruction_partner = input.read_i8()?; + let partner_delta = input.read_i8()?; + let z_tile_offset_x = input.read_i8()?; + let z_tile_offset_y = input.read_i8()?; + input.read_unused(1)?; + Ok(Self { + armour, + density, + number_of_tiles, + flags, + structure_number, + wall_orientation, + destruction_partner, + partner_delta, + z_tile_offset_x, + z_tile_offset_y, + }) + } +} + +/// Z-buffer info of a sub image that spans multiple tiles (`ZStripInfo`) +/// +/// The image is split into vertical strips of half a tile width, and the z value changes from +/// strip to strip. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ZStripInfo { + /// Difference in z between the leftmost and the base strip + pub initial_z_change: i8, + /// Width of the leftmost strip in pixels + pub first_z_strip_width: u8, + /// Change of the z value in each strip after the first + pub z_changes: Vec, +} + +/// A structure data file +#[derive(Asset, TypePath, Clone, Debug, Default)] +pub struct Ja2StructureFile { + /// Number of sub images the file describes + pub number_of_structures: u16, + /// Aux data for each sub image, empty if the file has none + pub aux_data: Vec, + /// Relative tile locations, referenced by the aux data + pub tile_locs: Vec<(i8, i8)>, + /// Structure of each sub image, empty if the file has no structure data + pub structures: Vec>, +} + +#[derive(Default, TypePath)] +pub struct Ja2StructureFileLoader; + +impl AssetLoader for Ja2StructureFileLoader { + type Asset = Ja2StructureFile; + type Settings = (); + type Error = io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + _load_context: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + Ja2StructureFile::parse(&bytes) + } + + fn extensions(&self) -> &[&str] { + &["jsd", "JSD"] + } +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(InvalidData, message.into()) +} + +impl Ja2StructureFile { + pub fn parse(bytes: &[u8]) -> io::Result { + let mut input = io::Cursor::new(bytes); + + let mut id = [0; 4]; + input.read_exact(&mut id)?; + let number_of_structures = input.read_u16::()?; + let number_of_structures_stored = input.read_u16::()?; + let data_size = input.read_u16::()?; + let flags = input.read_u8()?; + input.read_unused(3)?; + let number_of_tile_locs = input.read_u16::()?; + debug_assert_eq!(input.position(), STRUCTURE_FILE_HEADER_SIZE as u64); + if &id != STRUCTURE_FILE_ID { + return Err(invalid_data("not a structure file, expected J2SD")); + } + if number_of_structures == 0 { + return Err(invalid_data("structure file has no structures")); + } + + let mut file = Ja2StructureFile { + number_of_structures, + ..Default::default() + }; + if flags & STRUCTURE_FILE_CONTAINS_AUXIMAGEDATA != 0 { + file.aux_data = (0..number_of_structures) + .map(|_| StciAppData::from_input(&mut input)) + .collect::>()?; + file.tile_locs = (0..number_of_tile_locs) + .map(|_| Ok((input.read_i8()?, input.read_i8()?))) + .collect::>()?; + } + if flags & STRUCTURE_FILE_CONTAINS_STRUCTUREDATA != 0 && data_size > 0 { + let mut data = vec![0; usize::from(data_size)]; + input.read_exact(&mut data)?; + file.read_structures(&data, number_of_structures_stored)?; + } + Ok(file) + } + + /// Port of `CreateFileStructureArrays` + fn read_structures(&mut self, data: &[u8], number_stored: u16) -> io::Result<()> { + let too_short = || invalid_data("structure data is too short"); + let mut input = io::Cursor::new(data); + self.structures = vec![None; usize::from(self.number_of_structures)]; + for _ in 0..number_stored { + let header = DbStructureHeader::from_input(&mut input).map_err(|_| too_short())?; + let mut tiles = (0..header.number_of_tiles) + .map(|_| DbStructureTile::from_input(&mut input).map_err(|_| too_short())) + .collect::>>()?; + + let mut hit_points = 0; + for tile in &mut tiles { + tile.pos_rel_to_base = i16::from(tile.x_pos_rel_to_base) + + i16::from(tile.y_pos_rel_to_base) * WORLD_COLS; + hit_points += tile.filled_positions(); + } + normalize_structure_tiles(&mut tiles); + + let index = usize::from(header.structure_number); + if index >= self.structures.len() { + return Err(invalid_data(format!( + "structure number {} is out of range, the file has {} structures", + index, + self.structures.len() + ))); + } + + // Convert contiguous orientation values to flags + let mut wall_orientation = header.wall_orientation; + if !self.aux_data.is_empty() && wall_orientation > 2 { + wall_orientation = match wall_orientation { + 3 => Orientation::OUTSIDE_LEFT.bits(), + 4 => Orientation::OUTSIDE_RIGHT.bits(), + other => other, + }; + self.aux_data[index].wall_orientation = wall_orientation; + } + + self.structures[index] = Some(DbStructure { + armour: header.armour, + // Scale hit points down to something reasonable + hit_points: (hit_points * 100 / 255) as u8, + density: header.density, + flags: header.flags, + structure_number: header.structure_number, + wall_orientation: Orientation::from_bits_retain(wall_orientation), + destruction_partner: header.destruction_partner, + partner_delta: header.partner_delta, + z_tile_offset_x: header.z_tile_offset_x, + z_tile_offset_y: header.z_tile_offset_y, + tiles, + }); + } + Ok(()) + } + + /// Whether the file has structure data, i.e. whether `structures` is filled + pub fn has_structure_data(&self) -> bool { + !self.structures.is_empty() + } + + /// Z-strip info for each sub image of a tile surface, port of `AddZStripInfoToVObject` for + /// tile surfaces (not animations) + /// + /// Returns one entry per sub image, all `None` if no structure spans multiple tiles. + pub fn z_strip_info(&self, sub_images: &[SubImageInfo]) -> Vec> { + let mut result = vec![None; sub_images.len()]; + if !self.has_structure_data() { + return result; + } + + let spans_multiple_tiles = self.structures.iter().flatten().any(|structure| { + structure.tiles.len() > 1 + && structure.tiles[1..] + .iter() + .any(|tile| tile.pos_rel_to_base != 0) + }); + // The game checks the corpse flag of the last structure only + let last_is_corpse = self + .structures + .last() + .and_then(Option::as_ref) + .is_some_and(|structure| structure.flags.contains(StructureFlags::CORPSE)); + if !spans_multiple_tiles && !last_is_corpse { + return result; + } + + for (index, sub_image) in sub_images.iter().enumerate() { + let Some(Some(structure)) = self.structures.get(index) else { + continue; + }; + if structure.tiles.len() <= 1 && !structure.flags.contains(StructureFlags::CORPSE) { + continue; + } + if structure.flags.intersects(StructureFlags::ANYDOOR) + && !structure.flags.contains(StructureFlags::GARAGEDOOR) + { + continue; + } + result[index] = Some(compute_z_strip_info(structure, sub_image)); + } + result + } +} + +/// Makes sure that the tile closest to the base has position 0 (`NormalizeStructureTiles`) +fn normalize_structure_tiles(tiles: &mut [DbStructureTile]) { + let Some(min_dist) = tiles + .iter() + .map(|tile| tile.pos_rel_to_base) + .reduce(|min, pos| if min.abs() > pos.abs() { pos } else { min }) + else { + return; + }; + if min_dist == 0 { + return; + } + let x_dist = (min_dist % WORLD_COLS) as i8; + let y_dist = (min_dist / WORLD_COLS) as i8; + for tile in tiles { + tile.pos_rel_to_base -= min_dist; + tile.x_pos_rel_to_base = tile.x_pos_rel_to_base.wrapping_sub(x_dist); + tile.y_pos_rel_to_base = tile.y_pos_rel_to_base.wrapping_sub(y_dist); + } +} + +fn compute_z_strip_info(structure: &DbStructure, sub_image: &SubImageInfo) -> ZStripInfo { + const HALF_TILE: i32 = WORLD_TILE_X / 2; + + let mut offset_x = i32::from(sub_image.offset.0); + let width = i32::from(sub_image.dimensions.0); + if structure + .flags + .intersects(StructureFlags::MOBILE | StructureFlags::CORPSE) + { + // Adjust for the difference between the animation and structure base tile, and for the + // tile offset. The y offset is irrelevant for the strips. + offset_x += HALF_TILE; + offset_x = offset_x - i32::from(structure.z_tile_offset_x) * HALF_TILE + + i32::from(structure.z_tile_offset_y) * HALF_TILE; + } + + // How much of the image is on each side of the bottom corner of the base tile + let (left_half_width, right_half_width) = if offset_x <= 0 { + let right = width + offset_x - HALF_TILE; + if right >= 0 { + // Negative image offset, image straddles bottom corner + (-offset_x + HALF_TILE, right) + } else { + // Negative image offset, image all on the left side + (width - (right % HALF_TILE), 0) + } + } else if offset_x < HALF_TILE { + let left = HALF_TILE - offset_x; + let right = width - left; + if right <= 0 { + // Positive offset < 20, image all on the left side + (HALF_TILE, 0) + } else { + // Positive offset < 20, image straddles bottom corner + (left, right) + } + } else { + // Positive offset, image all on the right side + (0, width) + }; + + let mut num_increasing: u8 = 0; + let mut num_stable: u8 = 0; + let mut num_decreasing: u8 = 0; + if left_half_width > 0 { + num_increasing = (left_half_width / HALF_TILE) as u8; + } + if right_half_width > 0 { + num_stable = 1; + if right_half_width > HALF_TILE { + num_decreasing = (right_half_width / HALF_TILE) as u8; + } + } + + let mut first_z_strip_width; + if left_half_width > 0 { + first_z_strip_width = (left_half_width % HALF_TILE) as u8; + if first_z_strip_width == 0 { + num_increasing = num_increasing.saturating_sub(1); + first_z_strip_width = HALF_TILE as u8; + } + } else { + // Right side only, the offset is at least half a tile + first_z_strip_width = if offset_x > WORLD_TILE_X { + (HALF_TILE - (offset_x - WORLD_TILE_X) % HALF_TILE) as u8 + } else { + (WORLD_TILE_X - offset_x) as u8 + }; + if first_z_strip_width == 0 { + // The game underflows here for images narrower than a tile half + num_decreasing = num_decreasing.saturating_sub(1); + first_z_strip_width = HALF_TILE as u8; + } + } + + let mut z_changes = Vec::with_capacity( + usize::from(num_increasing) + usize::from(num_stable) + usize::from(num_decreasing), + ); + z_changes.extend(std::iter::repeat_n(1, num_increasing.into())); + z_changes.extend(std::iter::repeat_n(0, num_stable.into())); + z_changes.extend(std::iter::repeat_n(-1, num_decreasing.into())); + + let initial_z_change = if num_increasing > 0 { + -(num_increasing as i8) + } else if num_stable > 0 { + 0 + } else { + -(num_decreasing as i8) + }; + + ZStripInfo { + initial_z_change, + first_z_strip_width, + z_changes, + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::file_formats::stci::{indexed::STCI_APP_DATA_SIZE, StciAppDataFlags}; + + /// Builds the bytes of a `DB_STRUCTURE` and its tiles, tiles are `(x, y, shape byte)` + pub fn structure_bytes( + number: u16, + flags: StructureFlags, + wall_orientation: u8, + tiles: &[(i8, i8, u8)], + ) -> Vec { + structure_bytes_with_tile_flags( + number, + flags, + wall_orientation, + tiles, + StructureTileFlags::PASSABLE, + ) + } + + /// A structure whose tiles all have the given flags + pub fn structure_bytes_with_tile_flags( + number: u16, + flags: StructureFlags, + wall_orientation: u8, + tiles: &[(i8, i8, u8)], + tile_flags: StructureTileFlags, + ) -> Vec { + let mut bytes = vec![7, 0, 3, tiles.len() as u8]; + bytes.extend(flags.bits().to_le_bytes()); + bytes.extend(number.to_le_bytes()); + bytes.extend([wall_orientation, 0xFE_u8, 1, 0, 0, 0]); + for &(x, y, shape) in tiles { + bytes.extend(0x7FFF_i16.to_le_bytes()); + bytes.extend([x as u8, y as u8]); + bytes.extend([shape; PROFILE_X_SIZE * PROFILE_Y_SIZE]); + bytes.extend([tile_flags.bits(), 2, 0]); + } + bytes + } + + pub fn aux_bytes(wall_orientation: u8, number_of_tiles: u8, tile_loc_index: u16) -> Vec { + let mut bytes = vec![wall_orientation, number_of_tiles]; + bytes.extend(tile_loc_index.to_le_bytes()); + bytes.extend([0, 0, 0, 1, 3, StciAppDataFlags::ANIMATED_TILE.bits()]); + bytes.extend([0; 6]); + assert_eq!(bytes.len(), STCI_APP_DATA_SIZE); + bytes + } + + /// Builds a JSD file + pub fn jsd_bytes( + number_of_structures: u16, + aux: Option<(&[Vec], &[(i8, i8)])>, + structures: &[Vec], + ) -> Vec { + let data: Vec = structures.concat(); + let mut flags = 0; + if aux.is_some() { + flags |= STRUCTURE_FILE_CONTAINS_AUXIMAGEDATA; + } + if !structures.is_empty() { + flags |= STRUCTURE_FILE_CONTAINS_STRUCTUREDATA; + } + let tile_locs = aux.map_or(&[][..], |(_, locs)| locs); + let mut bytes = b"J2SD".to_vec(); + bytes.extend(number_of_structures.to_le_bytes()); + bytes.extend((structures.len() as u16).to_le_bytes()); + bytes.extend((data.len() as u16).to_le_bytes()); + bytes.extend([flags, 0, 0, 0]); + bytes.extend((tile_locs.len() as u16).to_le_bytes()); + if let Some((aux, locs)) = aux { + bytes.extend(aux.concat()); + for &(x, y) in locs { + bytes.extend([x as u8, y as u8]); + } + } + bytes.extend(data); + bytes + } + + fn sub_image(offset_x: i16, width: u16) -> SubImageInfo { + SubImageInfo { + offset: (offset_x, 0), + dimensions: (width, 50), + app_data: None, + } + } + + #[test] + fn record_sizes() { + assert_eq!( + structure_bytes(0, StructureFlags::empty(), 0, &[]).len(), + DB_STRUCTURE_SIZE + ); + assert_eq!( + structure_bytes(0, StructureFlags::empty(), 0, &[(0, 0, 0)]).len(), + DB_STRUCTURE_SIZE + DB_STRUCTURE_TILE_SIZE + ); + } + + #[test] + fn rejects_invalid_header() { + let mut bytes = jsd_bytes(1, None, &[]); + bytes[0] = b'X'; + assert!(Ja2StructureFile::parse(&bytes).is_err()); + assert!(Ja2StructureFile::parse(&jsd_bytes(0, None, &[])).is_err()); + assert!(Ja2StructureFile::parse(b"J2SD").is_err()); + } + + #[test] + fn parses_aux_data_and_tile_locs() { + let aux = [aux_bytes(1, 2, 0), aux_bytes(2, 0, 0)]; + let bytes = jsd_bytes(2, Some((&aux, &[(0, 0), (-1, 1)])), &[]); + let file = Ja2StructureFile::parse(&bytes).unwrap(); + assert_eq!(file.number_of_structures, 2); + assert_eq!(file.aux_data.len(), 2); + assert_eq!(file.aux_data[0].wall_orientation, 1); + assert_eq!(file.aux_data[0].number_of_tiles, 2); + assert_eq!(file.aux_data[1].number_of_frames, 3); + assert_eq!(file.tile_locs, vec![(0, 0), (-1, 1)]); + assert!(!file.has_structure_data()); + } + + #[test] + fn parses_structures() { + let structures = [structure_bytes( + 1, + StructureFlags::WALL, + 2, + &[(0, 0, 0x0F), (1, 0, 0x01)], + )]; + let bytes = jsd_bytes(3, None, &structures); + let file = Ja2StructureFile::parse(&bytes).unwrap(); + assert_eq!(file.structures.len(), 3); + assert!(file.structures[0].is_none()); + assert!(file.structures[2].is_none()); + let structure = file.structures[1].as_ref().unwrap(); + assert_eq!(structure.armour, 7); + assert_eq!(structure.density, 3); + assert_eq!(structure.flags, StructureFlags::WALL); + assert_eq!(structure.structure_number, 1); + assert_eq!(structure.wall_orientation, Orientation::INSIDE_RIGHT); + assert_eq!(structure.destruction_partner, -2); + assert_eq!(structure.partner_delta, 1); + // 25 * 4 + 25 * 1 filled cubes + assert_eq!(structure.hit_points, (125 * 100 / 255) as u8); + assert_eq!(structure.tiles.len(), 2); + assert_eq!(structure.tiles[0].pos_rel_to_base, 0); + assert_eq!(structure.tiles[1].pos_rel_to_base, 1); + assert_eq!(structure.tiles[1].shape[4][4], 0x01); + assert_eq!(structure.tiles[1].flags, StructureTileFlags::PASSABLE); + assert_eq!(structure.tiles[1].vehicle_hit_location, 2); + } + + #[test] + fn rejects_short_structure_data() { + let mut structure = structure_bytes(0, StructureFlags::empty(), 0, &[(0, 0, 0)]); + structure.truncate(DB_STRUCTURE_SIZE + 10); + assert!(Ja2StructureFile::parse(&jsd_bytes(1, None, &[structure])).is_err()); + let structure = structure_bytes(5, StructureFlags::empty(), 0, &[]); + assert!(Ja2StructureFile::parse(&jsd_bytes(1, None, &[structure])).is_err()); + } + + #[test] + fn converts_orientation_with_aux_data() { + let aux = [aux_bytes(3, 0, 0), aux_bytes(4, 0, 0)]; + let structures = [ + structure_bytes(0, StructureFlags::WALL, 3, &[(0, 0, 0)]), + structure_bytes(1, StructureFlags::WALL, 4, &[(0, 0, 0)]), + ]; + let file = Ja2StructureFile::parse(&jsd_bytes(2, Some((&aux, &[])), &structures)).unwrap(); + let orientation = |i: usize| file.structures[i].as_ref().unwrap().wall_orientation; + assert_eq!(orientation(0), Orientation::OUTSIDE_LEFT); + assert_eq!(orientation(1), Orientation::OUTSIDE_RIGHT); + assert_eq!(file.aux_data[0].wall_orientation, 0x04); + assert_eq!(file.aux_data[1].wall_orientation, 0x08); + + // Without aux data the values are not converted + let file = Ja2StructureFile::parse(&jsd_bytes(2, None, &structures)).unwrap(); + assert_eq!( + file.structures[0].as_ref().unwrap().wall_orientation.bits(), + 3 + ); + } + + #[test] + fn normalizes_tiles_without_base() { + let structures = [structure_bytes( + 0, + StructureFlags::VEHICLE, + 0, + &[(1, 1, 0), (2, 1, 0), (1, 2, 0)], + )]; + let file = Ja2StructureFile::parse(&jsd_bytes(1, None, &structures)).unwrap(); + let tiles = &file.structures[0].as_ref().unwrap().tiles; + let positions: Vec<_> = tiles + .iter() + .map(|t| (t.pos_rel_to_base, t.x_pos_rel_to_base, t.y_pos_rel_to_base)) + .collect(); + assert_eq!(positions, vec![(0, 0, 0), (1, 1, 0), (160, 0, 1)]); + } + + #[test] + fn z_strip_info() { + let structures = [ + // Multi tile structure + structure_bytes(0, StructureFlags::GENERIC, 0, &[(0, 0, 0), (0, 1, 0)]), + // Single tile structure + structure_bytes(1, StructureFlags::GENERIC, 0, &[(0, 0, 0)]), + // Doors don't get z-strips + structure_bytes(2, StructureFlags::DOOR, 0, &[(0, 0, 0), (1, 0, 0)]), + ]; + let file = Ja2StructureFile::parse(&jsd_bytes(4, None, &structures)).unwrap(); + let sub_images = [ + sub_image(-30, 70), + sub_image(-30, 70), + sub_image(-30, 70), + sub_image(-30, 70), + ]; + let info = file.z_strip_info(&sub_images); + assert_eq!(info.len(), 4); + // 50 px left of the bottom corner (2 increasing, first strip 10 px wide), 20 px right + assert_eq!( + info[0], + Some(ZStripInfo { + initial_z_change: -2, + first_z_strip_width: 10, + z_changes: vec![1, 1, 0], + }) + ); + assert_eq!(info[1], None); + assert_eq!(info[2], None); + assert_eq!(info[3], None); + + // Image only right of the bottom corner + let info = file.z_strip_info(&[sub_image(30, 50)]); + assert_eq!( + info[0], + Some(ZStripInfo { + initial_z_change: 0, + first_z_strip_width: 10, + z_changes: vec![0, -1, -1], + }) + ); + } + + #[test] + fn no_z_strip_info_for_single_tile_files() { + let structures = [structure_bytes( + 0, + StructureFlags::GENERIC, + 0, + &[(0, 0, 0), (0, 0, 0)], + )]; + let file = Ja2StructureFile::parse(&jsd_bytes(1, None, &structures)).unwrap(); + assert_eq!(file.z_strip_info(&[sub_image(-30, 70)]), vec![None]); + } +} diff --git a/src-map-editor/src/file_formats/ja2_tile_database.rs b/src-map-editor/src/file_formats/ja2_tile_database.rs new file mode 100644 index 0000000..10e2dd7 --- /dev/null +++ b/src-map-editor/src/file_formats/ja2_tile_database.rs @@ -0,0 +1,835 @@ +//! The tile database, one entry for every tile that a map can reference +//! +//! Port of `CreateTileDatabase` (`TileDef.cc`), `SetSpecificDatabaseValues` (`TileDat.cc`) and +//! `SetRaisedObjectFlag` (`Tile_Surface.cc`) in the game. It is independent of Bevy handles, the +//! loading code passes in the loaded tile surfaces. The runtime tiles are built from it by +//! [`crate::tileset::Tileset::from_database`]. + +use super::{ + ja2_stci::SubImageInfo, + ja2_structure::{DbStructure, Ja2StructureFile, StructureFlags, ZStripInfo}, + ja2_tile_types::{ + tile_index, tile_type_start_index, Orientation, TerrainType, TileElementFlags, TileType, + ANOTHERDEBRIS, BODYEXPLOSION, BULLETTILE, CLOSED_DOOR_LIST, CLOSED_DOOR_SHADOW_LIST, + CREATUREBLOOD, DEBRIS2MISC, DEBRISWEEDS, DEBRISWOOD, DEEPWATERTEXTURE, FIRSTCLIFFHANG, + FIRSTCLIFFSHADOW, FIRSTDOOR, FIRSTEXPLDEBRIS, FIRSTFLOOR, FIRSTFULLSTRUCT, FIRSTHIGHROOF, + FIRSTMISS, FIRSTROOF, FIRSTWALL, FOOTPRINTS, FOURTHDOOR, FOURTHDOORSHADOW, FOURTHFLOOR, + FOURTHWALL, FULL_BASE_TILE_VALUES, GUNS, HUMANBLOOD, LAST_UI_ELEMENT, MOCKFLOOR, + NUMBER_OF_TILES, NUMBER_OF_TILE_TYPES, NUM_TILES_PER_TYPE, OPEN_DOOR_LIST, + OPEN_DOOR_SHADOW_LIST, P2ITEMS, P3ITEMS, REGWATERTEXTURE, SECONDEXPLDEBRIS, + SECONDFULLSTRUCT, SECONDHIGHROOF, SECONDSLANTROOF, SHADOW_BUDDIES, THIRDPOINTERS, + WALL_AFRAME_END, WALL_AFRAME_START, WINDOWSHATTER, + }, + stci::{StciAppData, StciAppDataFlags}, +}; +use crate::tileset::TilesetId; + +/// Armour value of plywood walls (`MATERIAL_PLYWOOD_WALL`) +const MATERIAL_PLYWOOD_WALL: u8 = 2; + +/// Number of frames of the door, window and miss animations that the database sets up +const SPECIFIC_ANIMATION_FRAMES: u16 = 5; + +/// Debris files whose objects use the object z height (`SetRaisedObjectFlag`) +const RAISED_OBJECT_FILES: [&str; 11] = [ + "bones", "bones2", "grass2", "grass3", "l_weed3", "litter", "miniweed", "sblast", "sweeds", + "twigs", "wing", +]; + +/// A loaded tile surface (STI and optional JSD) of one tile type, the input of the database +#[derive(Clone, Debug)] +pub struct TileSurfaceInfo { + /// Tileset the file was loaded from, differs from the map tileset for default surfaces + pub tileset_id: TilesetId, + pub filename: String, + /// One entry per sub image (region) of the STI + pub sub_images: Vec, + pub structure: Option, + /// Terrain of the tile type in the map tileset + pub terrain: TerrainType, +} + +/// A tile surface in the database +#[derive(Clone, Debug)] +pub struct TileSurface { + pub tileset_id: TilesetId, + pub filename: String, + /// The structure data, including the database fixes + pub structure: Option, +} + +/// One tile of the database (`TILE_ELEMENT`) +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TileElement { + pub tile_type: TileType, + /// Sub image of the surface, 0 for underflow fillers + pub region_index: u16, + pub flags: TileElementFlags, + pub terrain: TerrainType, + pub wall_orientation: Orientation, + /// Whether the tile covers its whole tile (for land tiles) + pub full_tile: bool, + /// Tiles (relative to this one) that the sub image covers + pub tile_locs: Vec<(i8, i8)>, + /// Shadow of a structure or structure of a shadow + pub buddy: Option, + /// Database indices of the animation frames, empty for static tiles + pub anim_frames: Vec, + pub current_frame: u8, + /// Index of the structure in the structure file of the surface + pub structure: Option, + pub z_strip: Option, +} + +/// The tile database of a map, with exactly `NUMBER_OF_TILES` entries +#[derive(Clone, Debug)] +pub struct Ja2TileDatabase { + surfaces: Vec>, + elements: Vec, +} + +impl Ja2TileDatabase { + /// Builds the database from one optional surface per tile type (`CreateTileDatabase`) + pub fn build(surfaces: Vec>) -> Result { + if surfaces.len() != NUMBER_OF_TILE_TYPES { + return Err(format!( + "expected {} tile surfaces, got {}", + NUMBER_OF_TILE_TYPES, + surfaces.len() + )); + } + + let mut elements: Vec = Vec::with_capacity(NUMBER_OF_TILES); + let mut database_surfaces = Vec::with_capacity(NUMBER_OF_TILE_TYPES); + for (tile_type, surface) in surfaces.into_iter().enumerate() { + debug_assert_eq!( + elements.len(), + usize::from(tile_type_start_index(tile_type)) + ); + let num_tiles = usize::from(NUM_TILES_PER_TYPE[tile_type]); + let Some(mut surface) = surface else { + // The game fails to load such a tileset, keep the indices of the other types + elements.extend((0..num_tiles).map(|_| underflow_filler(tile_type))); + database_surfaces.push(None); + continue; + }; + + if let Some(structure) = &surface.structure { + if usize::from(structure.number_of_structures) != surface.sub_images.len() { + return Err(format!( + "structure file of {} (tileset {}) describes {} sub images, the image has {}", + surface.filename, + surface.tileset_id, + structure.number_of_structures, + surface.sub_images.len() + )); + } + } + let z_strips = surface + .structure + .as_ref() + .map(|structure| structure.z_strip_info(&surface.sub_images)) + .unwrap_or_default(); + let raised_object = is_raised_object(tile_type, &surface.filename); + + // Aux data from the JSD, or from the STI if every sub image has app data + let (aux_data, tile_locs) = match &surface.structure { + Some(structure) if !structure.aux_data.is_empty() => { + (structure.aux_data.clone(), structure.tile_locs.clone()) + } + _ => ( + surface + .sub_images + .iter() + .map(|sub_image| sub_image.app_data.clone()) + .collect::>>() + .unwrap_or_default(), + vec![], + ), + }; + + // Overflow is cut off + let num_regions = surface.sub_images.len().min(num_tiles); + for region in 0..num_regions { + let database_index = elements.len() as u16; + let mut element = TileElement { + tile_type, + region_index: region as u16, + terrain: surface.terrain, + ..Default::default() + }; + if let Some(Some(z_strip)) = z_strips.get(region) { + element.flags |= TileElementFlags::MULTI_Z_TILE; + element.z_strip = Some(z_strip.clone()); + } + if let Some(structure) = &surface.structure { + if matches!(structure.structures.get(region), Some(Some(_))) { + element.structure = Some(region as u16); + } + } + if let Some(aux) = aux_data.get(region) { + apply_aux_data(&mut element, aux, &tile_locs, database_index); + } + + set_specific_database_values( + &mut element, + database_index, + raised_object, + &elements, + ); + if region == 0 && (FIRSTDOOR..=FOURTHDOOR).contains(&tile_type) { + if let Some(structure) = &mut surface.structure { + fix_double_door(&mut element, structure); + } + } + elements.push(element); + } + // Underflow is padded with fillers that show the first region + elements.extend((num_regions..num_tiles).map(|_| underflow_filler(tile_type))); + + database_surfaces.push(Some(TileSurface { + tileset_id: surface.tileset_id, + filename: surface.filename, + structure: surface.structure, + })); + } + debug_assert_eq!(elements.len(), NUMBER_OF_TILES); + + Ok(Self { + surfaces: database_surfaces, + elements, + }) + } + + /// Database index of a tile in a map file (`GetTileIndexFromTypeSubIndex`) + /// + /// Like in the game, sub indices that are out of range for their type spill into the next + /// type. Returns `None` for sub index 0, for indices after the last tile and for tiles of + /// types that could not be loaded. + pub fn index(&self, tile_type: TileType, sub_index: u16) -> Option { + if tile_type >= NUMBER_OF_TILE_TYPES || sub_index == 0 { + return None; + } + let index = usize::from(tile_type_start_index(tile_type)) + usize::from(sub_index) - 1; + self.element(u16::try_from(index).ok()?)?; + Some(index as u16) + } + + /// A tile of the database, `None` if its type could not be loaded + pub fn element(&self, index: u16) -> Option<&TileElement> { + let element = self.elements.get(usize::from(index))?; + self.surface(element.tile_type)?; + Some(element) + } + + /// The surface of a tile type, `None` if it could not be loaded + pub fn surface(&self, tile_type: TileType) -> Option<&TileSurface> { + self.surfaces.get(tile_type)?.as_ref() + } + + /// Structure data of a tile, if it has any + pub fn structure(&self, index: u16) -> Option<&DbStructure> { + let element = self.element(index)?; + let structure_file = self.surface(element.tile_type)?.structure.as_ref()?; + structure_file + .structures + .get(usize::from(element.structure?))? + .as_ref() + } + + /// The surfaces (one per tile type, `None` if not loaded) and the tiles of the database + pub fn into_parts(self) -> (Vec>, Vec) { + (self.surfaces, self.elements) + } +} + +fn underflow_filler(tile_type: TileType) -> TileElement { + TileElement { + tile_type, + flags: TileElementFlags::UNDERFLOW_FILLER, + ..Default::default() + } +} + +/// Port of `SetRaisedObjectFlag` +fn is_raised_object(tile_type: TileType, filename: &str) -> bool { + if ![DEBRISWOOD, DEBRISWEEDS, DEBRIS2MISC, ANOTHERDEBRIS].contains(&tile_type) { + return false; + } + let file = filename.rsplit(['/', '\\']).next().unwrap_or(filename); + let stem = file.rsplit_once('.').map_or(file, |(stem, _)| stem); + RAISED_OBJECT_FILES + .iter() + .any(|raised| raised.eq_ignore_ascii_case(stem)) +} + +fn apply_aux_data( + element: &mut TileElement, + aux: &StciAppData, + tile_locs: &[(i8, i8)], + database_index: u16, +) { + if aux.flags.contains(StciAppDataFlags::FULL_TILE) { + element.full_tile = true; + } + if aux.flags.contains(StciAppDataFlags::ANIMATED_TILE) { + element.current_frame = aux.current_frame; + let first = database_index.wrapping_sub(u16::from(aux.current_frame)); + element.anim_frames = (0..u16::from(aux.number_of_frames)) + .map(|frame| first.wrapping_add(frame)) + .collect(); + element.flags |= TileElementFlags::ANIMATED_TILE; + } + element.wall_orientation = Orientation::from_bits_retain(aux.wall_orientation); + if aux.number_of_tiles > 0 { + let start = usize::from(aux.tile_location_index); + let end = start + usize::from(aux.number_of_tiles); + element.tile_locs = tile_locs.get(start..end).unwrap_or_default().to_vec(); + } +} + +/// Animation of 5 frames starting at the tile, forwards or backwards (`SetAnimData`) +fn set_anim_data(element: &mut TileElement, database_index: u16, forwards: bool) { + element.current_frame = 0; + element.anim_frames = (0..SPECIFIC_ANIMATION_FRAMES) + .map(|frame| { + if forwards { + database_index + frame + } else { + database_index - frame + } + }) + .collect(); +} + +/// Port of `SetSpecificDatabaseValues`, `elements` are the tiles before this one +fn set_specific_database_values( + element: &mut TileElement, + database_index: u16, + raised_object: bool, + elements: &[TileElement], +) { + let tile_type = element.tile_type; + let index = database_index; + + // Buddies of structures and shadows + for buddies in &SHADOW_BUDDIES { + let structure_start = tile_type_start_index(buddies.structure); + let shadow_start = tile_type_start_index(buddies.shadow); + if tile_type == buddies.shadow { + let buddy = index - shadow_start + structure_start; + element.buddy = Some(buddy); + // Take the flags of the structure + if let Some(structure) = elements.get(usize::from(buddy)) { + element.flags |= structure.flags; + } + break; + } else if tile_type == buddies.structure { + element.buddy = Some(index - structure_start + shadow_start); + element.flags |= TileElementFlags::HAS_SHADOW_BUDDY; + break; + } + } + + // Door animations + if (tile_index(FIRSTDOOR, 1)..=tile_index(FOURTHDOORSHADOW, 20)).contains(&index) { + if OPEN_DOOR_LIST.contains(&index) || OPEN_DOOR_SHADOW_LIST.contains(&index) { + set_anim_data(element, index, true); + } + if CLOSED_DOOR_LIST.contains(&index) || CLOSED_DOOR_SHADOW_LIST.contains(&index) { + set_anim_data(element, index, false); + } + } + + if index == tile_index(FIRSTMISS, 1) { + set_anim_data(element, index, true); + } + if (tile_index(FIRSTMISS, 1)..=tile_index(FIRSTMISS, 5)).contains(&index) { + element.flags |= TileElementFlags::DYNAMIC_TILE; + } + + if [1, 6, 11, 16] + .map(|n| tile_index(WINDOWSHATTER, n)) + .contains(&index) + { + set_anim_data(element, index, true); + } + if (tile_index(WINDOWSHATTER, 1)..=tile_index(WINDOWSHATTER, 20)).contains(&index) { + element.flags |= TileElementFlags::DYNAMIC_TILE; + } + + if tile_type == BODYEXPLOSION { + element.flags |= TileElementFlags::DYNAMIC_TILE; + } + + // Object pieces which use proper z + if raised_object { + element.flags |= TileElementFlags::OBJECTLAYER_USEZHEIGHT; + } + + // Full 3D tiles, and the dynamic flag for the foliage + if (tile_index(FIRSTFULLSTRUCT, 1)..=tile_index(SECONDFULLSTRUCT, 12)).contains(&index) { + match (index - tile_type_start_index(tile_type)) % 3 { + 0 => element.flags |= TileElementFlags::FULL3D_TILE, + 1 => element.flags |= TileElementFlags::DYNAMIC_TILE, + _ => {} + } + } + + // Cliffs ignore the height, if the land is raised the cliff is not offset + if (tile_index(FIRSTCLIFFHANG, 1)..=tile_index(FIRSTCLIFFSHADOW, 17)).contains(&index) { + if tile_type == FIRSTCLIFFHANG { + element.flags |= TileElementFlags::CLIFFHANG_TILE; + } + element.flags |= TileElementFlags::IGNORE_WORLD_HEIGHT; + } + + if (tile_index(FIRSTWALL, 1)..=tile_index(FOURTHWALL, 65)).contains(&index) { + element.flags |= TileElementFlags::WALL_TILE; + } + + // A-frames are drawn higher (for roof pieces) + if (tile_index(FIRSTWALL, 1)..=tile_index(FOURTHWALL, 47)).contains(&index) { + let start = tile_type_start_index(tile_type); + if (start + WALL_AFRAME_START..=start + WALL_AFRAME_END).contains(&index) { + element.flags |= TileElementFlags::AFRAME_TILE; + } + } + + // UI elements are dynamic + if (tile_index(FOOTPRINTS, 1)..=tile_index(THIRDPOINTERS, 2)).contains(&index) { + element.flags |= TileElementFlags::DYNAMIC_TILE | TileElementFlags::OBJECTLAYER_USEZHEIGHT; + } + + // UI elements, blood, items and explosion debris use the object z level + if (FOOTPRINTS..=LAST_UI_ELEMENT).contains(&tile_type) + || (HUMANBLOOD..=CREATUREBLOOD).contains(&tile_type) + || (GUNS..=P2ITEMS).contains(&tile_type) + || tile_type == P3ITEMS + || (FIRSTEXPLDEBRIS..=SECONDEXPLDEBRIS).contains(&tile_type) + { + element.flags |= TileElementFlags::OBJECTLAYER_USEZHEIGHT; + } + + if index == tile_index(MOCKFLOOR, 1) || tile_type == BULLETTILE { + element.flags |= TileElementFlags::DYNAMIC_TILE; + } + + // Floors are full tiles + if (tile_index(FIRSTFLOOR, 1)..=tile_index(FOURTHFLOOR, 8)).contains(&index) { + element.full_tile = true; + } + + // Fullness of the land tiles + if index <= tile_index(DEEPWATERTEXTURE, 10) { + element.full_tile = FULL_BASE_TILE_VALUES[usize::from(index)] != 0; + } + + // The game compares the tile index to the tile type `REGWATERTEXTURE` here + if (tile_index(REGWATERTEXTURE, 18)..=tile_index(REGWATERTEXTURE, 50)).contains(&index) + || index == REGWATERTEXTURE as u16 + || [12, 14, 16] + .map(|n| tile_index(REGWATERTEXTURE, n)) + .contains(&index) + { + element.terrain = TerrainType::FlatGround; + } + + if (FIRSTROOF..=SECONDSLANTROOF).contains(&tile_type) + || tile_type == FIRSTHIGHROOF + || tile_type == SECONDHIGHROOF + { + element.flags |= TileElementFlags::ROOF_TILE; + } +} + +/// Fixes incorrect double door flags, which only affects DOOR3 in the PALACE! tileset and DOOR1 +/// in QUEEN'S TROPICAL in vanilla. `element` is the first tile of a door type. +fn fix_double_door(element: &mut TileElement, structure_file: &mut Ja2StructureFile) { + let structures = &mut structure_file.structures; + let Some(Some(first)) = structures.first() else { + return; + }; + if !first + .flags + .intersects(StructureFlags::DDOOR_RIGHT | StructureFlags::DDOOR_LEFT) + { + return; + } + // A door in an open state that takes up 1 tile and is flagged as outside oriented + let open_door_tiles = structures + .get(4) + .and_then(Option::as_ref) + .map(|structure| structure.tiles.len()); + if element.wall_orientation != Orientation::OUTSIDE_RIGHT || open_door_tiles != Some(1) { + return; + } + // The game expects all these structures to exist + if [0, 4, 5, 9, 10, 14, 15, 19] + .iter() + .any(|&i| !matches!(structures.get(i), Some(Some(_)))) + { + return; + } + fn structure(structures: &mut [Option], i: usize) -> &mut DbStructure { + structures[i].as_mut().expect("checked above") + } + + element.wall_orientation = Orientation::INSIDE_RIGHT; + structure(structures, 0).wall_orientation = Orientation::INSIDE_RIGHT; + structure(structures, 4).wall_orientation = Orientation::INSIDE_RIGHT; + structure(structures, 4).armour = MATERIAL_PLYWOOD_WALL; + structure(structures, 5).wall_orientation = Orientation::INSIDE_LEFT; + structure(structures, 9).wall_orientation = Orientation::INSIDE_LEFT; + // Between sub indices 0-4 and 10-14 there can only be 1 right part of a double door + for i in [10, 14] { + structure(structures, i) + .flags + .remove(StructureFlags::DDOOR_RIGHT); + structure(structures, i) + .flags + .insert(StructureFlags::DDOOR_LEFT); + } + // Between sub indices 5-9 and 15-19 there can only be 1 left part of a double door + for i in [15, 19] { + structure(structures, i) + .flags + .remove(StructureFlags::DDOOR_LEFT); + structure(structures, i) + .flags + .insert(StructureFlags::DDOOR_RIGHT); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::file_formats::{ + ja2_structure::tests::{aux_bytes, jsd_bytes, structure_bytes}, + ja2_tile_types::{ + FIRSTCLIFF, FIRSTOSTRUCT, FIRSTSHADOW, FIRSTTEXTURE, SECONDTEXTURE, THIRDTEXTURE, + WIREFRAMES, + }, + }; + + fn sub_images(count: usize) -> Vec { + (0..count) + .map(|_| SubImageInfo { + offset: (0, 0), + dimensions: (40, 20), + app_data: None, + }) + .collect() + } + + pub(crate) fn surface(filename: &str, count: usize) -> TileSurfaceInfo { + TileSurfaceInfo { + tileset_id: TilesetId::new(0), + filename: filename.to_owned(), + sub_images: sub_images(count), + structure: None, + terrain: TerrainType::NoTerrain, + } + } + + /// All surfaces loaded, each type with exactly its number of tiles + pub(crate) fn full_surfaces() -> Vec> { + (0..NUMBER_OF_TILE_TYPES) + .map(|t| { + Some(surface( + &format!("type{}.sti", t), + usize::from(NUM_TILES_PER_TYPE[t]), + )) + }) + .collect() + } + + fn build(surfaces: Vec>) -> Ja2TileDatabase { + Ja2TileDatabase::build(surfaces).unwrap() + } + + /// A database with every tile type loaded and every tile a 40×20 image + pub(crate) fn test_database() -> Ja2TileDatabase { + build(full_surfaces()) + } + + #[test] + fn indices_are_fixed() { + let mut surfaces = full_surfaces(); + // Too many and too few regions don't change the start indices + surfaces[FIRSTTEXTURE] = Some(surface("a.sti", 50)); + surfaces[SECONDTEXTURE] = Some(surface("b.sti", 3)); + let db = build(surfaces); + assert_eq!(db.index(FIRSTCLIFFHANG, 1), Some(348)); + assert_eq!(db.index(FIRSTDOOR, 1), Some(907)); + assert_eq!(db.index(WIREFRAMES, 15), Some(NUMBER_OF_TILES as u16 - 1)); + assert_eq!(db.index(WIREFRAMES, 16), None); + assert_eq!(db.index(FIRSTTEXTURE, 0), None); + assert_eq!(db.index(NUMBER_OF_TILE_TYPES, 1), None); + } + + #[test] + fn overflow_and_underflow() { + let mut surfaces = full_surfaces(); + surfaces[FIRSTTEXTURE] = Some(surface("a.sti", 50)); + surfaces[SECONDTEXTURE] = Some(surface("b.sti", 3)); + let db = build(surfaces); + + // Overflow is cut off, the index spills into the next type + let spilled = db.element(db.index(FIRSTTEXTURE, 36).unwrap()).unwrap(); + assert_eq!(spilled.tile_type, SECONDTEXTURE); + assert_eq!(spilled.region_index, 0); + assert!(!spilled.flags.contains(TileElementFlags::UNDERFLOW_FILLER)); + + let last = db.element(db.index(SECONDTEXTURE, 3).unwrap()).unwrap(); + assert_eq!(last.region_index, 2); + assert!(!last.flags.contains(TileElementFlags::UNDERFLOW_FILLER)); + let filler = db.element(db.index(SECONDTEXTURE, 4).unwrap()).unwrap(); + assert_eq!(filler.tile_type, SECONDTEXTURE); + assert_eq!(filler.region_index, 0); + assert_eq!(filler.flags, TileElementFlags::UNDERFLOW_FILLER); + } + + #[test] + fn missing_surfaces() { + let mut surfaces = full_surfaces(); + surfaces[SECONDTEXTURE] = None; + let db = build(surfaces); + assert!(db.surface(SECONDTEXTURE).is_none()); + assert_eq!(db.index(SECONDTEXTURE, 1), None); + assert_eq!(db.index(FIRSTTEXTURE, 36), None); + assert_eq!(db.index(THIRDTEXTURE, 1), Some(70)); + + assert!(Ja2TileDatabase::build(vec![]).is_err()); + } + + #[test] + fn specific_flags() { + let db = build(full_surfaces()); + let flags = |tile_type, sub_index| { + db.element(db.index(tile_type, sub_index).unwrap()) + .unwrap() + .flags + }; + assert!(flags(FIRSTWALL, 1).contains(TileElementFlags::WALL_TILE)); + assert!(flags(FOURTHWALL, 65).contains(TileElementFlags::WALL_TILE)); + assert!(!flags(FIRSTWALL, 31).contains(TileElementFlags::AFRAME_TILE)); + assert!(flags(FIRSTWALL, 32).contains(TileElementFlags::AFRAME_TILE)); + assert!(flags(FIRSTWALL, 35).contains(TileElementFlags::AFRAME_TILE)); + assert!(!flags(FIRSTWALL, 36).contains(TileElementFlags::AFRAME_TILE)); + assert!(flags(FIRSTROOF, 1).contains(TileElementFlags::ROOF_TILE)); + assert!(flags(SECONDHIGHROOF, 1).contains(TileElementFlags::ROOF_TILE)); + assert!(flags(FIRSTFULLSTRUCT, 1).contains(TileElementFlags::FULL3D_TILE)); + assert!(flags(FIRSTFULLSTRUCT, 2).contains(TileElementFlags::DYNAMIC_TILE)); + assert!(!flags(FIRSTFULLSTRUCT, 3) + .intersects(TileElementFlags::FULL3D_TILE | TileElementFlags::DYNAMIC_TILE)); + assert!(flags(FIRSTFULLSTRUCT, 4).contains(TileElementFlags::FULL3D_TILE)); + assert!(flags(FIRSTCLIFFHANG, 1) + .contains(TileElementFlags::CLIFFHANG_TILE | TileElementFlags::IGNORE_WORLD_HEIGHT)); + assert!(flags(FIRSTCLIFF, 1).contains(TileElementFlags::IGNORE_WORLD_HEIGHT)); + assert!(!flags(FIRSTCLIFF, 1).contains(TileElementFlags::CLIFFHANG_TILE)); + assert!(flags(FOOTPRINTS, 1).contains(TileElementFlags::DYNAMIC_TILE)); + assert!(flags(GUNS, 1).contains(TileElementFlags::OBJECTLAYER_USEZHEIGHT)); + + let element = + |tile_type, sub_index| db.element(db.index(tile_type, sub_index).unwrap()).unwrap(); + assert!(element(FIRSTTEXTURE, 1).full_tile); + assert!(!element(FIRSTTEXTURE, 11).full_tile); + assert!(element(FIRSTFLOOR, 3).full_tile); + assert_eq!( + element(REGWATERTEXTURE, 20).terrain, + TerrainType::FlatGround + ); + + let door = element(FIRSTDOOR, 1); + assert_eq!(door.anim_frames, vec![907, 908, 909, 910, 911]); + let closed = element(FIRSTDOOR, 5); + assert_eq!(closed.anim_frames, vec![911, 910, 909, 908, 907]); + } + + #[test] + fn shadow_buddies() { + let mut surfaces = full_surfaces(); + let structures = [structure_bytes( + 0, + StructureFlags::GENERIC, + 0, + &[(0, 0, 0), (1, 0, 0)], + )]; + let mut ostruct = surface("ostruct.sti", 12); + ostruct.sub_images[0].offset = (-30, 0); + ostruct.sub_images[0].dimensions = (70, 50); + ostruct.structure = + Some(Ja2StructureFile::parse(&jsd_bytes(12, None, &structures)).unwrap()); + surfaces[FIRSTOSTRUCT] = Some(ostruct); + let db = build(surfaces); + + let structure_index = db.index(FIRSTOSTRUCT, 1).unwrap(); + let shadow_index = db.index(FIRSTSHADOW, 1).unwrap(); + let structure = db.element(structure_index).unwrap(); + let shadow = db.element(shadow_index).unwrap(); + assert_eq!(structure.buddy, Some(shadow_index)); + assert_eq!(shadow.buddy, Some(structure_index)); + assert!(structure.flags.contains(TileElementFlags::HAS_SHADOW_BUDDY)); + assert!(structure.flags.contains(TileElementFlags::MULTI_Z_TILE)); + assert!(structure.z_strip.is_some()); + // The shadow takes the flags of the structure + assert!(shadow + .flags + .contains(TileElementFlags::HAS_SHADOW_BUDDY | TileElementFlags::MULTI_Z_TILE)); + assert_eq!(structure.structure, Some(0)); + assert_eq!(db.structure(structure_index).unwrap().tiles.len(), 2); + assert_eq!(db.element(structure_index + 1).unwrap().structure, None); + } + + #[test] + fn aux_data() { + let mut surfaces = full_surfaces(); + let aux: Vec<_> = (0..12) + .map(|i| aux_bytes(0, if i == 0 { 2 } else { 0 }, 1)) + .collect(); + let mut ostruct = surface("ostruct.sti", 12); + ostruct.structure = Some( + Ja2StructureFile::parse(&jsd_bytes(12, Some((&aux, &[(0, 0), (1, 0), (0, 1)])), &[])) + .unwrap(), + ); + surfaces[FIRSTOSTRUCT] = Some(ostruct); + let db = build(surfaces); + let index = db.index(FIRSTOSTRUCT, 2).unwrap(); + let element = db.element(index).unwrap(); + assert!(element.flags.contains(TileElementFlags::ANIMATED_TILE)); + // Current frame 1 of 3 + assert_eq!(element.anim_frames, vec![index - 1, index, index + 1]); + let first = db.element(index - 1).unwrap(); + assert_eq!(first.tile_locs, vec![(1, 0), (0, 1)]); + } + + #[test] + fn structure_count_must_match() { + let mut surfaces = full_surfaces(); + let mut ostruct = surface("ostruct.sti", 12); + ostruct.structure = Some(Ja2StructureFile::parse(&jsd_bytes(11, None, &[])).unwrap()); + surfaces[FIRSTOSTRUCT] = Some(ostruct); + assert!(Ja2TileDatabase::build(surfaces).is_err()); + } + + /// Builds the database of every tileset from the extracted game data in `JA2_DATA_DIR` (with + /// lowercase paths: `binarydata/ja2set.dat`, `tilesets//*.sti|jsd`, `maps/*.dat`) and + /// looks up every tile of every map + #[test] + fn build_from_data_dir() { + use crate::file_formats::{ + ja2_map::Ja2Map, ja2_tile_types::FIRSTPOINTERS, ja2_tileset::Ja2Tilesets, stci::Stci, + }; + use std::{collections::HashMap, fs, path::Path}; + + let Ok(dir) = std::env::var("JA2_DATA_DIR") else { + eprintln!("JA2_DATA_DIR not set, skipping"); + return; + }; + let dir = Path::new(&dir); + let tilesets = + Ja2Tilesets::parse(&fs::read(dir.join("binarydata/ja2set.dat")).unwrap()).unwrap(); + + let mut failures = vec![]; + let mut databases = HashMap::new(); + for tileset_id in tilesets.ids() { + let tileset = tilesets.get(tileset_id.index()).unwrap(); + let surfaces = (0..NUMBER_OF_TILE_TYPES) + .map(|tile_type| { + let resolved = tilesets.resolve_tile_surface(tileset_id, tile_type)?; + let file = dir + .join(format!("tilesets/{}", resolved.tileset_id)) + .join(resolved.filename.to_lowercase()); + let bytes = fs::read(&file) + .map_err(|e| failures.push(format!("{}: {}", file.display(), e))) + .ok()?; + let stci = Stci::from_input(&mut std::io::Cursor::new(bytes)) + .map_err(|e| failures.push(format!("{}: {}", file.display(), e))) + .ok()?; + let jsd = file.with_extension("jsd"); + let structure = fs::read(&jsd).ok().and_then(|bytes| { + Ja2StructureFile::parse(&bytes) + .map_err(|e| failures.push(format!("{}: {}", jsd.display(), e))) + .ok() + }); + Some(TileSurfaceInfo { + tileset_id: resolved.tileset_id, + filename: resolved.filename, + sub_images: SubImageInfo::from_stci(&stci), + structure, + terrain: tileset.terrain_id(tile_type), + }) + }) + .collect(); + match Ja2TileDatabase::build(surfaces) { + Ok(database) => { + databases.insert(tileset_id, database); + } + Err(e) => failures.push(format!("tileset {}: {}", tileset_id, e)), + } + } + + let mut missing = vec![]; + let mut maps = 0; + let mut tiles = 0; + let mut fillers = 0; + let mut spilled = 0; + for entry in fs::read_dir(dir.join("maps")).unwrap() { + let path = entry.unwrap().path(); + let Ok(map) = Ja2Map::parse(&fs::read(&path).unwrap()) else { + continue; + }; + let Some(database) = databases.get(&TilesetId::new(map.tileset_id as u8)) else { + failures.push(format!("{}: no database", path.display())); + continue; + }; + maps += 1; + for element in &map.map_elements { + let objects = element + .objects + .iter() + .filter(|o| usize::from(o.kind) < FIRSTPOINTERS); + for layer in element.land.iter().chain(objects).chain(&element.structs) { + tiles += 1; + let Some(index) = database.index(layer.kind.into(), layer.sub_index) else { + missing.push(format!( + "{}: kind {} sub index {}", + path.display(), + layer.kind, + layer.sub_index + )); + continue; + }; + let element = database.element(index).unwrap(); + if element.flags.contains(TileElementFlags::UNDERFLOW_FILLER) { + fillers += 1; + } + if element.tile_type != usize::from(layer.kind) { + spilled += 1; + } + } + } + } + eprintln!( + "built {} databases, looked up {} tiles of {} maps: {} underflow fillers, {} in \ + the next type, {} missing", + databases.len(), + tiles, + maps, + fillers, + spilled, + missing.len() + ); + assert!(failures.is_empty(), "failures:\n{}", failures.join("\n")); + assert!(missing.is_empty(), "missing tiles:\n{}", missing.join("\n")); + } + + #[test] + fn raised_objects() { + assert!(is_raised_object(DEBRISWOOD, "TWIGS.STI")); + assert!(is_raised_object(ANOTHERDEBRIS, "tilesets/0/bones.sti")); + assert!(!is_raised_object(DEBRISWOOD, "twigs2.sti")); + assert!(!is_raised_object(FIRSTOSTRUCT, "twigs.sti")); + } +} diff --git a/src-map-editor/src/file_formats/ja2_tile_types.rs b/src-map-editor/src/file_formats/ja2_tile_types.rs new file mode 100644 index 0000000..ece3d55 --- /dev/null +++ b/src-map-editor/src/file_formats/ja2_tile_types.rs @@ -0,0 +1,568 @@ +//! Tile types and the fixed layout of the tile database +//! +//! Port of `TileDat.h/.cc`, the tile flags of `TileDef.h` and the tileset ids of +//! `World_Tileset_Enums.h` in the game. The tile database has exactly +//! `NUM_TILES_PER_TYPE[type]` entries per tile type, so the index of a tile only depends on its +//! type and sub index. This is the value of the `TileDefines` enum in the game, e.g. `FIRSTDOOR1` +//! is `tile_index(FIRSTDOOR, 1)`. + +use crate::tileset::TilesetId; + +/// Index of a tile type, the `kind` of a tile in a map file +pub type TileType = usize; + +// Generated from `TileTypeDefines` in `TileDat.h` + +pub const FIRSTTEXTURE: TileType = 0; +pub const SECONDTEXTURE: TileType = 1; +pub const THIRDTEXTURE: TileType = 2; +pub const FOURTHTEXTURE: TileType = 3; +pub const FIFTHTEXTURE: TileType = 4; +pub const SIXTHTEXTURE: TileType = 5; +pub const SEVENTHTEXTURE: TileType = 6; +pub const REGWATERTEXTURE: TileType = 7; +pub const DEEPWATERTEXTURE: TileType = 8; +pub const FIRSTCLIFFHANG: TileType = 9; +pub const FIRSTCLIFF: TileType = 10; +pub const FIRSTCLIFFSHADOW: TileType = 11; +pub const FIRSTOSTRUCT: TileType = 12; +pub const SECONDOSTRUCT: TileType = 13; +pub const THIRDOSTRUCT: TileType = 14; +pub const FOURTHOSTRUCT: TileType = 15; +pub const FIFTHOSTRUCT: TileType = 16; +pub const SIXTHOSTRUCT: TileType = 17; +pub const SEVENTHOSTRUCT: TileType = 18; +pub const EIGHTOSTRUCT: TileType = 19; +pub const FIRSTFULLSTRUCT: TileType = 20; +pub const SECONDFULLSTRUCT: TileType = 21; +pub const THIRDFULLSTRUCT: TileType = 22; +pub const FOURTHFULLSTRUCT: TileType = 23; +pub const FIRSTSHADOW: TileType = 24; +pub const SECONDSHADOW: TileType = 25; +pub const THIRDSHADOW: TileType = 26; +pub const FOURTHSHADOW: TileType = 27; +pub const FIFTHSHADOW: TileType = 28; +pub const SIXTHSHADOW: TileType = 29; +pub const SEVENTHSHADOW: TileType = 30; +pub const EIGHTSHADOW: TileType = 31; +pub const FIRSTFULLSHADOW: TileType = 32; +pub const SECONDFULLSHADOW: TileType = 33; +pub const THIRDFULLSHADOW: TileType = 34; +pub const FOURTHFULLSHADOW: TileType = 35; +pub const FIRSTWALL: TileType = 36; +pub const SECONDWALL: TileType = 37; +pub const THIRDWALL: TileType = 38; +pub const FOURTHWALL: TileType = 39; +pub const FIRSTDOOR: TileType = 40; +pub const SECONDDOOR: TileType = 41; +pub const THIRDDOOR: TileType = 42; +pub const FOURTHDOOR: TileType = 43; +pub const FIRSTDOORSHADOW: TileType = 44; +pub const SECONDDOORSHADOW: TileType = 45; +pub const THIRDDOORSHADOW: TileType = 46; +pub const FOURTHDOORSHADOW: TileType = 47; +pub const SLANTROOFCEILING: TileType = 48; +pub const ANOTHERDEBRIS: TileType = 49; +pub const ROADPIECES: TileType = 50; +pub const FOURTHWINDOW: TileType = 51; +pub const FIRSTDECORATIONS: TileType = 52; +pub const SECONDDECORATIONS: TileType = 53; +pub const THIRDDECORATIONS: TileType = 54; +pub const FOURTHDECORATIONS: TileType = 55; +pub const FIRSTWALLDECAL: TileType = 56; +pub const SECONDWALLDECAL: TileType = 57; +pub const THIRDWALLDECAL: TileType = 58; +pub const FOURTHWALLDECAL: TileType = 59; +pub const FIRSTFLOOR: TileType = 60; +pub const SECONDFLOOR: TileType = 61; +pub const THIRDFLOOR: TileType = 62; +pub const FOURTHFLOOR: TileType = 63; +pub const FIRSTROOF: TileType = 64; +pub const SECONDROOF: TileType = 65; +pub const THIRDROOF: TileType = 66; +pub const FOURTHROOF: TileType = 67; +pub const FIRSTSLANTROOF: TileType = 68; +pub const SECONDSLANTROOF: TileType = 69; +pub const FIRSTONROOF: TileType = 70; +pub const SECONDONROOF: TileType = 71; +pub const MOCKFLOOR: TileType = 72; +pub const FIRSTISTRUCT: TileType = 73; +pub const SECONDISTRUCT: TileType = 74; +pub const THRIDISTRUCT: TileType = 75; +pub const FOURTHISTRUCT: TileType = 76; +pub const FIRSTCISTRUCT: TileType = 77; +pub const FIRSTROAD: TileType = 78; +pub const DEBRISROCKS: TileType = 79; +pub const DEBRISWOOD: TileType = 80; +pub const DEBRISWEEDS: TileType = 81; +pub const DEBRISGRASS: TileType = 82; +pub const DEBRISSAND: TileType = 83; +pub const DEBRISMISC: TileType = 84; +pub const ANIOSTRUCT: TileType = 85; +pub const FENCESTRUCT: TileType = 86; +pub const FENCESHADOW: TileType = 87; +pub const FIRSTVEHICLE: TileType = 88; +pub const SECONDVEHICLE: TileType = 89; +pub const FIRSTVEHICLESHADOW: TileType = 90; +pub const SECONDVEHICLESHADOW: TileType = 91; +pub const DEBRIS2MISC: TileType = 92; +pub const FIRSTDEBRISSTRUCT: TileType = 93; +pub const SECONDDEBRISSTRUCT: TileType = 94; +pub const FIRSTDEBRISSTRUCTSHADOW: TileType = 95; +pub const SECONDDEBRISSTRUCTSHADOW: TileType = 96; +pub const NINTHOSTRUCT: TileType = 97; +pub const TENTHOSTRUCT: TileType = 98; +pub const NINTHOSTRUCTSHADOW: TileType = 99; +pub const TENTHOSTRUCTSHADOW: TileType = 100; +pub const FIRSTEXPLDEBRIS: TileType = 101; +pub const SECONDEXPLDEBRIS: TileType = 102; +pub const FIRSTLARGEEXPDEBRIS: TileType = 103; +pub const SECONDLARGEEXPDEBRIS: TileType = 104; +pub const FIRSTLARGEEXPDEBRISSHADOW: TileType = 105; +pub const SECONDLARGEEXPDEBRISSHADOW: TileType = 106; +pub const FIFTHISTRUCT: TileType = 107; +pub const SIXTHISTRUCT: TileType = 108; +pub const SEVENTHISTRUCT: TileType = 109; +pub const EIGHTISTRUCT: TileType = 110; +pub const FIRSTHIGHROOF: TileType = 111; +pub const SECONDHIGHROOF: TileType = 112; +pub const FIFTHWALLDECAL: TileType = 113; +pub const SIXTHWALLDECAL: TileType = 114; +pub const SEVENTHWALLDECAL: TileType = 115; +pub const EIGTHWALLDECAL: TileType = 116; +pub const HUMANBLOOD: TileType = 117; +pub const CREATUREBLOOD: TileType = 118; +pub const FIRSTSWITCHES: TileType = 119; +pub const REVEALEDSLANTROOFS: TileType = 120; +pub const FIRSTREVEALEDHIGHROOFS: TileType = 121; +pub const SECONDREVEALEDHIGHROOFS: TileType = 122; +pub const GUNS: TileType = 123; +pub const P1ITEMS: TileType = 124; +pub const P2ITEMS: TileType = 125; +pub const WINDOWSHATTER: TileType = 126; +pub const P3ITEMS: TileType = 127; +pub const BODYEXPLOSION: TileType = 128; +pub const EXITTEXTURE: TileType = 129; +pub const FOOTPRINTS: TileType = 130; +pub const FIRSTPOINTERS: TileType = 131; +pub const SECONDPOINTERS: TileType = 132; +pub const THIRDPOINTERS: TileType = 133; +pub const GOODRUN: TileType = 134; +pub const GOODWALK: TileType = 135; +pub const GOODSWAT: TileType = 136; +pub const GOODPRONE: TileType = 137; +pub const CONFIRMMOVE: TileType = 138; +pub const VEHICLEMOVE: TileType = 139; +pub const ACTIONTWO: TileType = 140; +pub const XMARKER: TileType = 141; +pub const GOODRING: TileType = 142; +pub const ROTATINGKEY: TileType = 143; +pub const SELRING: TileType = 144; +pub const SPECIALTILES: TileType = 145; +pub const BULLETTILE: TileType = 146; +pub const FIRSTMISS: TileType = 147; +pub const SECONDMISS: TileType = 148; +pub const THIRDMISS: TileType = 149; +pub const WIREFRAMES: TileType = 150; +pub const NUMBER_OF_TILE_TYPES: usize = 151; + +/// Names of the tile types (`gTileSurfaceName`) +pub const TILE_TYPE_NAMES: [&str; NUMBER_OF_TILE_TYPES] = [ + "TEXTURE1", + "TEXTURE2", + "TEXTURE3", + "TEXTURE4", + "TEXTURE5", + "TEXTURE6", + "TEXTURE7", + "WATER1", + "DEEP WATER", + "FIRSTCLIFFHANG", + "FIRSTCLIFF", + "FIRSTCLIFFSHADOW", + "OSTRUCT1", + "OSTRUCT2", + "OSTRUCT3", + "OSTRUCT4", + "OSTRUCT5", + "OSTRUCT6", + "OSTRUCT7", + "OSTRUCT8", + "OFSTRUCT1", + "OFSTRUCT2", + "PLACEHOLDER1", + "PLACEHOLDER2", + "SHADOW1", + "SHADOW2", + "SHADOW3", + "SHADOW4", + "SHADOW5", + "SHADOW6", + "SHADOW7", + "SHADOW8", + "FSHADOW1", + "FSHADOW2", + "PLACEHOLDER3", + "PLACEHOLDER4", + "WALL1", + "WALL2", + "WALL3", + "WALL4", + "DOOR1", + "DOOR2", + "DOOR3", + "DOOR4", + "DOORSH1", + "DOORSH2", + "DOORSH3", + "DOORSH4", + "SLANTFLATPEICE", + "ANOTHERDEBRIS", + "ROADPIECES", + "WINDOW4", + "DECORATIONS1", + "DECORATIONS2", + "DECORATIONS3", + "DECORATIONS4", + "WALLDECAL1", + "WALLDECAL2", + "WALLDECAL3", + "WALLDECAL4", + "FLOOR1", + "FLOOR2", + "FLOOR3", + "FLOOR4", + "ROOF1", + "ROOF2", + "ROOF3", + "ROOF4", + "SROOF1", + "SROOF2", + "ONROOF1", + "ONROOF2", + "MOCKF1", + "ISTRUCT1", + "ISTRUCT2", + "ISTRUCT3", + "ISTRUCT4", + "FIRSTCISTRCUT", + "FIRSTROAD", + "ROCKS", + "WOOD", + "WEEDS", + "GRASS", + "SAND", + "MISC", + "ANIOSTRUCT", + "FENCESTRUCT", + "FENCESHADOW", + "FIRSTVEHICLE", + "SECONDVEHICLE", + "FIRSTVEHICLESHADOW", + "SECONDVEHICLESHADOW", + "MISC2", + "FIRSTDEBRISSTRUCT", + "SECONDDEBRISSTRUCT", + "FIRSTDEBRISSTRUCTSHADOW", + "SECONDDEBRISSTRUCTSHADOW", + "NINTHOSTRUCT", + "TENTHOSTRUCT", + "NINTHOSTRUCTSHADOW", + "TENTHOSTRUCTSHADOW", + "FIRSTEXPLODEDEBRIS", + "SECONDEXPLODEDEBRIS", + "FIRSTLARGEEXPLODEDEBRIS", + "SECONDLARGEEXPLODEDEBRIS", + "FIRSTLARGEEXPLODEDEBRISSHADOW", + "SECONDLARGEEXPLODEDEBRISSHADOW", + "FIFTHISTRUCT", + "SIXTHISTRUCT", + "SEVENTHISTRUCT", + "EIGHTISTRUCT", + "FIRSTHIGHROOF", + "SECONDHIGHROOF", + "WALLDECAL5", + "WALLDECAL6", + "WALLDECAL7", + "WALLDECAL8", + "HUMANBLOOD", + "CREATUREBLOOD", + "FIRSTSWITCHES", + "REVEALEDSLANTROOF", + "1stREVEALEDHIGHROOF", + "2ndREVEALEDHIGHROOF", + "GUNS", + "ITEMS", + "ITEMS2", + "GLASSSHATTER", + "ITEMS3", + "BODYBLOW", + "EXITTEXTURE", + "FOOTPRINTS", + "POINTERS", + "POINTERS2", + "POINTERS3", + "GOODRUN", + "GOODWALK", + "GOODSWAT", + "GOODSCOOT", + "CONFIRMMOVE", + "VEHICLEMOVE", + "ACTIONTWO", + "BADMARKER", + "GRING", + "ROTATINGKEY", + "SELRING", + "SPECIAL", + "BULLET", + "MISS1", + "MISS2", + "MISS3", + "WIREFRAME", +]; + +/// Number of database entries of each tile type (`gNumTilesPerType`) +pub const NUM_TILES_PER_TYPE: [u16; NUMBER_OF_TILE_TYPES] = [ + 35, 35, 35, 35, 35, 37, 49, 50, 37, 17, 17, 17, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 2, 2, + 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 2, 2, 65, 65, 65, 65, 20, 20, 20, 20, 20, 20, 20, 20, + 2, 10, 400, 2, 10, 10, 10, 10, 10, 10, 10, 10, 8, 8, 8, 8, 14, 14, 14, 14, 20, 20, 12, 12, 1, + 24, 24, 24, 24, 24, 35, 10, 10, 10, 10, 10, 10, 20, 22, 22, 12, 12, 12, 12, 10, 10, 10, 10, 10, + 12, 12, 12, 12, 40, 40, 10, 10, 10, 10, 24, 24, 24, 24, 15, 15, 10, 10, 10, 10, 16, 16, 21, 8, + 11, 11, 60, 149, 45, 20, 16, 15, 35, 80, 24, 8, 3, 11, 11, 11, 11, 11, 10, 11, 11, 10, 8, 10, + 6, 2, 5, 5, 14, 15, +]; + +/// `gFullBaseTileValues`, the fullness of the land tiles up to `DEEPWATERTEXTURE10` +pub const FULL_BASE_TILE_VALUES: [u8; 321] = [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, +]; + +/// Types before this one belong to the tilesets, the rest are items, animations and UI elements +pub const LAST_TILESET_ELEMENT: TileType = GUNS - 1; +pub const LAST_UI_ELEMENT: TileType = WIREFRAMES - 1; + +/// Index of the first database entry of a tile type (`gTileTypeStartIndex`) +pub const fn tile_type_start_index(tile_type: TileType) -> u16 { + let mut start = 0; + let mut i = 0; + while i < tile_type { + start += NUM_TILES_PER_TYPE[i]; + i += 1; + } + start +} + +/// Database index of a tile, `sub_index` is 1-based like the names of the `TileDefines` enum +pub const fn tile_index(tile_type: TileType, sub_index: u16) -> u16 { + tile_type_start_index(tile_type) + sub_index - 1 +} + +/// Number of entries in the tile database +pub const NUMBER_OF_TILES: usize = tile_type_start_index(NUMBER_OF_TILE_TYPES) as usize; + +/// Logical height of the land tile types, used for land layer ordering (`gTileTypeLogicalHeight`) +pub const TILE_TYPE_LOGICAL_HEIGHT: [u8; NUMBER_OF_TILE_TYPES] = { + let mut heights = [0; NUMBER_OF_TILE_TYPES]; + let mut i = FIRSTTEXTURE; + while i <= SEVENTHTEXTURE { + heights[i] = 2; + i += 1; + } + heights[REGWATERTEXTURE] = 10; + heights[DEEPWATERTEXTURE] = 10; + heights +}; + +// A-frame wall pieces within a wall type, 0-based (`WALL_AFRAME_START`, `WALL_AFRAME_END`) +pub const WALL_AFRAME_START: u16 = 31; +pub const WALL_AFRAME_END: u16 = 34; + +bitflags::bitflags! { + /// Flags of a tile database entry (`TileElementFlags` in `TileDef.h`) + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct TileElementFlags: u32 { + const WALL_TILE = 1 << 0; + const ANIMATED_TILE = 1 << 1; + const DYNAMIC_TILE = 1 << 2; + const IGNORE_WORLD_HEIGHT = 1 << 3; + const FULL3D_TILE = 1 << 4; + const MULTI_Z_TILE = 1 << 5; + const OBJECTLAYER_USEZHEIGHT = 1 << 6; + const ROOFSHADOW_TILE = 1 << 7; + const ROOF_TILE = 1 << 8; + const HAS_SHADOW_BUDDY = 1 << 9; + const AFRAME_TILE = 1 << 10; + const CLIFFHANG_TILE = 1 << 11; + const UNDERFLOW_FILLER = 1 << 12; + } +} + +bitflags::bitflags! { + /// Wall orientation of a tile or structure (`Orientation` in `TileDef.h`) + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct Orientation: u8 { + const INSIDE_LEFT = 0x01; + const INSIDE_RIGHT = 0x02; + const OUTSIDE_LEFT = 0x04; + const OUTSIDE_RIGHT = 0x08; + + const LEFT = 0x05; + const RIGHT = 0x0A; + const INSIDE = 0x03; + const OUTSIDE = 0x0C; + } +} + +/// Terrain of a tile (`TerrainTypeDefines` in `TileDef.h`) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[repr(u8)] +pub enum TerrainType { + #[default] + NoTerrain, + FlatGround, + FlatFloor, + PavedRoad, + DirtRoad, + LowGrass, + HighGrass, + TrainTracks, + LowWater, + MedWater, + DeepWater, +} + +/// A struct tile type and its shadow tile type, the tiles of both are buddies of each other +pub struct ShadowBuddies { + pub structure: TileType, + pub shadow: TileType, +} + +const fn buddies(structure: TileType, shadow: TileType) -> ShadowBuddies { + ShadowBuddies { structure, shadow } +} + +/// Bidirectional mapping between structs and shadows (`g_shadow_buddies`) +pub const SHADOW_BUDDIES: [ShadowBuddies; 26] = [ + buddies(FIRSTCLIFF, FIRSTCLIFFSHADOW), + buddies(FIRSTOSTRUCT, FIRSTSHADOW), + buddies(SECONDOSTRUCT, SECONDSHADOW), + buddies(THIRDOSTRUCT, THIRDSHADOW), + buddies(FOURTHOSTRUCT, FOURTHSHADOW), + buddies(FIFTHOSTRUCT, FIFTHSHADOW), + buddies(SIXTHOSTRUCT, SIXTHSHADOW), + buddies(SEVENTHOSTRUCT, SEVENTHSHADOW), + buddies(EIGHTOSTRUCT, EIGHTSHADOW), + buddies(FIRSTFULLSTRUCT, FIRSTFULLSHADOW), + buddies(SECONDFULLSTRUCT, SECONDFULLSHADOW), + buddies(THIRDFULLSTRUCT, THIRDFULLSHADOW), + buddies(FOURTHFULLSTRUCT, FOURTHFULLSHADOW), + buddies(FIRSTDOOR, FIRSTDOORSHADOW), + buddies(SECONDDOOR, SECONDDOORSHADOW), + buddies(THIRDDOOR, THIRDDOORSHADOW), + buddies(FOURTHDOOR, FOURTHDOORSHADOW), + buddies(FENCESTRUCT, FENCESHADOW), + buddies(FIRSTVEHICLE, FIRSTVEHICLESHADOW), + buddies(SECONDVEHICLE, SECONDVEHICLESHADOW), + buddies(FIRSTDEBRISSTRUCT, FIRSTDEBRISSTRUCTSHADOW), + buddies(SECONDDEBRISSTRUCT, SECONDDEBRISSTRUCTSHADOW), + buddies(NINTHOSTRUCT, NINTHOSTRUCTSHADOW), + buddies(TENTHOSTRUCT, TENTHOSTRUCTSHADOW), + buddies(FIRSTLARGEEXPDEBRIS, FIRSTLARGEEXPDEBRISSHADOW), + buddies(SECONDLARGEEXPDEBRIS, SECONDLARGEEXPDEBRISSHADOW), +]; + +/// Tiles 1, 6, 11 and 16 of all four types, the first frame of each door opening animation +const fn door_frames(first: TileType, sub_index: u16) -> [u16; 16] { + let mut list = [0; 16]; + let mut i = 0; + while i < 16 { + list[i] = tile_index(first + i % 4, sub_index + (i / 4) as u16 * 5); + i += 1; + } + list +} + +/// Open doors, animated forwards (`gOpenDoorList`) +pub const OPEN_DOOR_LIST: [u16; 16] = door_frames(FIRSTDOOR, 1); +/// Shadows of open doors, animated forwards (`gOpenDoorShadowList`) +pub const OPEN_DOOR_SHADOW_LIST: [u16; 16] = door_frames(FIRSTDOORSHADOW, 1); +/// Closed doors, animated backwards (`gClosedDoorList`) +pub const CLOSED_DOOR_LIST: [u16; 16] = door_frames(FIRSTDOOR, 5); +/// Shadows of closed doors, animated backwards (`gClosedDoorShadowList`) +pub const CLOSED_DOOR_SHADOW_LIST: [u16; 16] = door_frames(FIRSTDOORSHADOW, 5); + +/// Number of tilesets of vanilla JA2 +pub const VANILLA_NUM_TILESETS: usize = 50; +/// Number of tilesets of Unfinished Business +pub const JA25_NUM_TILESETS: usize = 70; + +// Tileset ids (`TileSetID`), only the ones the code refers to +pub const GENERIC_1: TilesetId = TilesetId::new(0); +pub const CAVES_1: TilesetId = TilesetId::new(1); +pub const TROPICAL_1: TilesetId = TilesetId::new(4); +pub const AIRSTRIP: TilesetId = TilesetId::new(17); +pub const DEAD_AIRSTRIP: TilesetId = TilesetId::new(19); +pub const TEMP_14: TilesetId = TilesetId::new(33); +pub const TEMP_18: TilesetId = TilesetId::new(37); +pub const TEMP_19: TilesetId = TilesetId::new(38); +pub const TEMP_20: TilesetId = TilesetId::new(39); +pub const TEMP_26: TilesetId = TilesetId::new(45); +pub const TEMP_27: TilesetId = TilesetId::new(46); +pub const TEMP_28: TilesetId = TilesetId::new(47); +pub const TEMP_29: TilesetId = TilesetId::new(48); +pub const DEFAULT_JA25_TILESET: TilesetId = TilesetId::new(50); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_indices_match_tile_defines() { + // Values of the `TileDefines` enum in the game + assert_eq!(tile_index(FIRSTTEXTURE, 1), 0); + assert_eq!(tile_index(FIRSTCLIFFHANG, 1), 348); + assert_eq!(tile_index(FIRSTCLIFFSHADOW, 17), 398); + assert_eq!(tile_index(FIRSTFULLSTRUCT, 1), 495); + assert_eq!(tile_index(FIRSTWALL, 1), 647); + assert_eq!(tile_index(FOURTHWALL, 65), 906); + assert_eq!(tile_index(FIRSTDOOR, 1), 907); + assert_eq!(tile_index(FOURTHDOORSHADOW, 20), 1066); + assert_eq!(tile_index(FIRSTFLOOR, 1), 1561); + assert_eq!(tile_index(MOCKFLOOR, 1), 1713); + assert_eq!(tile_index(WINDOWSHATTER, 1), 2762); + assert_eq!(tile_index(FOOTPRINTS, 1), 2848); + assert_eq!(tile_index(SPECIALTILES, 1), 3078); + assert_eq!(tile_index(FIRSTMISS, 1), 3086); + assert_eq!(NUMBER_OF_TILES, 3125); + } + + #[test] + fn full_base_tile_values_cover_the_land_types() { + assert_eq!( + FULL_BASE_TILE_VALUES.len(), + usize::from(tile_index(DEEPWATERTEXTURE, 10)) + 1 + ); + } + + #[test] + fn door_lists() { + assert_eq!(OPEN_DOOR_LIST[0], tile_index(FIRSTDOOR, 1)); + assert_eq!(OPEN_DOOR_LIST[3], tile_index(FOURTHDOOR, 1)); + assert_eq!(OPEN_DOOR_LIST[4], tile_index(FIRSTDOOR, 6)); + assert_eq!(CLOSED_DOOR_LIST[15], tile_index(FOURTHDOOR, 20)); + assert_eq!( + CLOSED_DOOR_SHADOW_LIST[15], + tile_index(FOURTHDOORSHADOW, 20) + ); + } +} diff --git a/src-map-editor/src/file_formats/ja2_tileset.rs b/src-map-editor/src/file_formats/ja2_tileset.rs new file mode 100644 index 0000000..0e6f96a --- /dev/null +++ b/src-map-editor/src/file_formats/ja2_tileset.rs @@ -0,0 +1,380 @@ +use std::io; + +use bevy::{ + asset::{io::Reader, AssetLoader, LoadContext}, + prelude::*, +}; +use byteorder::{ReadBytesExt, LE}; + +use super::{ + ja2_tile_types::{ + TerrainType, TileType, AIRSTRIP, CAVES_1, DEAD_AIRSTRIP, DEEPWATERTEXTURE, + DEFAULT_JA25_TILESET, FIFTHTEXTURE, FIRSTFLOOR, FIRSTROAD, FIRSTTEXTURE, FOURTHFLOOR, + FOURTHTEXTURE, GENERIC_1, JA25_NUM_TILESETS, NUMBER_OF_TILE_TYPES, REGWATERTEXTURE, + ROADPIECES, SECONDFLOOR, SECONDTEXTURE, SEVENTHTEXTURE, SIXTHTEXTURE, SPECIALTILES, + TEMP_14, TEMP_18, TEMP_19, TEMP_20, TEMP_26, TEMP_27, TEMP_28, TEMP_29, THIRDFLOOR, + THIRDTEXTURE, TROPICAL_1, VANILLA_NUM_TILESETS, + }, + StracciatellaReadExt, +}; +use crate::tileset::TilesetId; + +#[derive(Clone, Debug)] +pub struct Ja2Tile { + pub filename: String, +} + +/// Which of the `Set*TerrainValues` functions of the game applies to a tileset +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TilesetTerrainValues { + #[default] + One, + /// Floors are flat ground + Two, + /// Roads are paved + Three, + /// The second floor is flat ground + Four, +} + +impl TilesetTerrainValues { + /// Terrain values of a tileset, as assigned in `InitEngineTilesets` + fn for_tileset(tileset_id: TilesetId) -> Self { + match tileset_id { + CAVES_1 => Self::Two, + AIRSTRIP | DEAD_AIRSTRIP | TEMP_14 | TEMP_18 | TEMP_19 | TEMP_26 | TEMP_27 + | TEMP_28 | TEMP_29 => Self::Three, + TROPICAL_1 | TEMP_20 => Self::Four, + _ => Self::One, + } + } + + /// Terrain of a tile type, `NoTerrain` for types that the game does not set + pub fn terrain_id(self, tile_type: TileType) -> TerrainType { + match tile_type { + FIRSTTEXTURE | SECONDTEXTURE | THIRDTEXTURE | FOURTHTEXTURE | SEVENTHTEXTURE => { + TerrainType::FlatGround + } + FIFTHTEXTURE | SIXTHTEXTURE => TerrainType::LowGrass, + REGWATERTEXTURE => TerrainType::LowWater, + DEEPWATERTEXTURE => TerrainType::DeepWater, + FIRSTROAD | ROADPIECES => match self { + Self::Three => TerrainType::PavedRoad, + _ => TerrainType::DirtRoad, + }, + FIRSTFLOOR | THIRDFLOOR | FOURTHFLOOR => match self { + Self::Two => TerrainType::FlatGround, + _ => TerrainType::FlatFloor, + }, + SECONDFLOOR => match self { + Self::Two | Self::Four => TerrainType::FlatGround, + _ => TerrainType::FlatFloor, + }, + _ => TerrainType::NoTerrain, + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct Ja2Tileset { + pub name: String, + pub ambient_id: u8, + /// File name for each tile type, `None` if the tileset uses the file of the default tileset + pub tiles: Vec>, + pub terrain_values: TilesetTerrainValues, +} + +impl Ja2Tileset { + /// Terrain of a tile type in this tileset + pub fn terrain_id(&self, tile_type: TileType) -> TerrainType { + self.terrain_values.terrain_id(tile_type) + } +} + +/// The tileset and file a tile type is loaded from +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedTileSurface { + pub tileset_id: TilesetId, + pub filename: String, +} + +#[derive(Asset, TypePath, Clone, Debug, Default)] +pub struct Ja2Tilesets { + pub(crate) tilesets: Vec, +} + +#[derive(Default, TypePath)] +pub struct Ja2TilesetsLoader; + +impl AssetLoader for Ja2TilesetsLoader { + type Asset = Ja2Tilesets; + type Settings = (); + type Error = io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + _load_context: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + Ja2Tilesets::parse(&bytes) + } +} + +pub const TILESET_NAME_LENGTH: usize = 32; +pub const TILE_SURFACE_FILENAME_LENGTH: usize = 32; + +impl Ja2Tilesets { + /// Parses `JA2SET.DAT` and validates it like `InitEngineTilesets` in the game + pub fn parse(bytes: &[u8]) -> io::Result { + let mut handle = io::Cursor::new(bytes); + let number_of_tilesets = usize::from(handle.read_u8()?); + if number_of_tilesets != VANILLA_NUM_TILESETS && number_of_tilesets != JA25_NUM_TILESETS { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "expected {} or {} tilesets, got {}", + VANILLA_NUM_TILESETS, JA25_NUM_TILESETS, number_of_tilesets + ), + )); + } + let number_of_tiles = handle.read_u32::()?; + if number_of_tiles as usize != NUMBER_OF_TILE_TYPES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "expected {} tile types per tileset, got {}", + NUMBER_OF_TILE_TYPES, number_of_tiles + ), + )); + } + + let mut tilesets = Vec::with_capacity(number_of_tilesets); + for tileset_id in 0..number_of_tilesets { + let name = handle.read_fixed_string(TILESET_NAME_LENGTH)?; + let ambient_id = handle.read_u8()?; + let mut tiles = Vec::with_capacity(NUMBER_OF_TILE_TYPES); + for _ in 0..NUMBER_OF_TILE_TYPES { + let filename = handle.read_fixed_string(TILE_SURFACE_FILENAME_LENGTH)?; + + tiles.push(if filename.is_empty() { + None + } else { + Some(Ja2Tile { filename }) + }); + } + tilesets.push(Ja2Tileset { + name, + ambient_id, + tiles, + terrain_values: TilesetTerrainValues::for_tileset(TilesetId::new(tileset_id as u8)), + }) + } + + Ok(Ja2Tilesets { tilesets }) + } + + pub fn get(&self, tileset_id: usize) -> Option<&Ja2Tileset> { + self.tilesets.get(tileset_id) + } + + /// The tilesets by id, in ascending order + pub fn ids(&self) -> impl Iterator { + (0..self.tilesets.len()).map(|id| TilesetId::new(id as u8)) + } + + /// Tileset that provides the tile types a tileset has no file for (`GetDefaultTileset`) + pub fn default_tileset_id(&self) -> TilesetId { + if self.tilesets.len() == JA25_NUM_TILESETS { + DEFAULT_JA25_TILESET + } else { + GENERIC_1 + } + } + + /// The file a tile type is loaded from for a tileset, falls back to the default tileset + /// (`GetAdjustedTilesetResource`) + /// + /// Returns `None` if the tileset does not exist, or if neither the tileset nor the default + /// tileset has a file for the tile type. + pub fn resolve_tile_surface( + &self, + tileset_id: TilesetId, + tile_type: TileType, + ) -> Option { + let resolve = |tileset_id: TilesetId| { + let tile = self + .get(tileset_id.index())? + .tiles + .get(tile_type)? + .as_ref()?; + Some(ResolvedTileSurface { + tileset_id, + filename: tile.filename.clone(), + }) + }; + self.get(tileset_id.index())?; + resolve(tileset_id).or_else(|| { + let default_id = + if self.tilesets.len() == JA25_NUM_TILESETS && tile_type == SPECIALTILES { + DEFAULT_JA25_TILESET + } else { + self.default_tileset_id() + }; + resolve(default_id) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::file_formats::ja2_tile_types::{FIRSTWALL, WIREFRAMES}; + + fn fixed_string(value: &str, length: usize) -> Vec { + let mut bytes = value.as_bytes().to_vec(); + bytes.resize(length, 0); + bytes + } + + /// Builds a `JA2SET.DAT`, `files` returns the file name of a tileset and tile type + fn ja2set( + number_of_tilesets: u8, + number_of_tiles: u32, + files: impl Fn(usize, usize) -> &'static str, + ) -> Vec { + let mut bytes = vec![number_of_tilesets]; + bytes.extend(number_of_tiles.to_le_bytes()); + for tileset_id in 0..usize::from(number_of_tilesets) { + bytes.extend(fixed_string( + &format!("Tileset {}", tileset_id), + TILESET_NAME_LENGTH, + )); + bytes.push(tileset_id as u8); + for tile_type in 0..number_of_tiles as usize { + bytes.extend(fixed_string( + files(tileset_id, tile_type), + TILE_SURFACE_FILENAME_LENGTH, + )); + } + } + bytes + } + + fn only_default(default_id: usize) -> impl Fn(usize, usize) -> &'static str { + move |tileset_id, tile_type| match (tileset_id, tile_type) { + (id, _) if id == default_id => "default.sti", + (5, FIRSTWALL) => "wall.sti", + _ => "", + } + } + + #[test] + fn parses_tilesets() { + let bytes = ja2set(50, NUMBER_OF_TILE_TYPES as u32, only_default(0)); + let tilesets = Ja2Tilesets::parse(&bytes).unwrap(); + assert_eq!(tilesets.tilesets.len(), 50); + let tileset = tilesets.get(5).unwrap(); + assert_eq!(tileset.name, "Tileset 5"); + assert_eq!(tileset.ambient_id, 5); + assert_eq!(tileset.tiles.len(), NUMBER_OF_TILE_TYPES); + assert_eq!( + tileset.tiles[FIRSTWALL].as_ref().unwrap().filename, + "wall.sti" + ); + assert!(tileset.tiles[FIRSTTEXTURE].is_none()); + } + + #[test] + fn validates_counts() { + for count in [0, 49, 51, 69, 71] { + let bytes = ja2set(count, NUMBER_OF_TILE_TYPES as u32, |_, _| ""); + assert!(Ja2Tilesets::parse(&bytes).is_err(), "{} tilesets", count); + } + let bytes = ja2set(50, NUMBER_OF_TILE_TYPES as u32 - 1, |_, _| ""); + assert!(Ja2Tilesets::parse(&bytes).is_err()); + let bytes = ja2set(70, NUMBER_OF_TILE_TYPES as u32, |_, _| ""); + assert!(Ja2Tilesets::parse(&bytes).is_ok()); + } + + #[test] + fn default_tileset_id() { + let vanilla = Ja2Tilesets::parse(&ja2set(50, NUMBER_OF_TILE_TYPES as u32, |_, _| "")); + assert_eq!(vanilla.unwrap().default_tileset_id(), GENERIC_1); + let ja25 = Ja2Tilesets::parse(&ja2set(70, NUMBER_OF_TILE_TYPES as u32, |_, _| "")); + assert_eq!(ja25.unwrap().default_tileset_id(), DEFAULT_JA25_TILESET); + } + + #[test] + fn resolves_with_fallback() { + let tilesets = + Ja2Tilesets::parse(&ja2set(50, NUMBER_OF_TILE_TYPES as u32, only_default(0))).unwrap(); + assert_eq!( + tilesets.resolve_tile_surface(TilesetId::new(5), FIRSTWALL), + Some(ResolvedTileSurface { + tileset_id: TilesetId::new(5), + filename: "wall.sti".to_owned() + }) + ); + assert_eq!( + tilesets.resolve_tile_surface(TilesetId::new(5), FIRSTTEXTURE), + Some(ResolvedTileSurface { + tileset_id: TilesetId::new(0), + filename: "default.sti".to_owned() + }) + ); + assert_eq!( + tilesets.resolve_tile_surface(TilesetId::new(50), FIRSTTEXTURE), + None + ); + + let tilesets = + Ja2Tilesets::parse(&ja2set(70, NUMBER_OF_TILE_TYPES as u32, only_default(50))).unwrap(); + assert_eq!( + tilesets + .resolve_tile_surface(TilesetId::new(5), WIREFRAMES) + .unwrap() + .tileset_id, + TilesetId::new(50) + ); + assert_eq!( + tilesets + .resolve_tile_surface(TilesetId::new(5), SPECIALTILES) + .unwrap() + .tileset_id, + TilesetId::new(50) + ); + } + + #[test] + fn missing_in_default_tileset() { + let tilesets = + Ja2Tilesets::parse(&ja2set(50, NUMBER_OF_TILE_TYPES as u32, only_default(1))).unwrap(); + assert_eq!( + tilesets.resolve_tile_surface(TilesetId::new(5), FIRSTTEXTURE), + None + ); + } + + #[test] + fn terrain_values() { + let tilesets = + Ja2Tilesets::parse(&ja2set(50, NUMBER_OF_TILE_TYPES as u32, |_, _| "")).unwrap(); + let terrain = |tileset_id: TilesetId, tile_type| { + tilesets + .get(tileset_id.index()) + .unwrap() + .terrain_id(tile_type) + }; + assert_eq!(terrain(GENERIC_1, FIRSTROAD), TerrainType::DirtRoad); + assert_eq!(terrain(AIRSTRIP, FIRSTROAD), TerrainType::PavedRoad); + assert_eq!(terrain(GENERIC_1, FIRSTFLOOR), TerrainType::FlatFloor); + assert_eq!(terrain(CAVES_1, FIRSTFLOOR), TerrainType::FlatGround); + assert_eq!(terrain(TROPICAL_1, SECONDFLOOR), TerrainType::FlatGround); + assert_eq!(terrain(TROPICAL_1, THIRDFLOOR), TerrainType::FlatFloor); + assert_eq!(terrain(GENERIC_1, FIFTHTEXTURE), TerrainType::LowGrass); + assert_eq!(terrain(GENERIC_1, FIRSTWALL), TerrainType::NoTerrain); + } +} diff --git a/src-map-editor/src/file_formats/mod.rs b/src-map-editor/src/file_formats/mod.rs new file mode 100644 index 0000000..648eb06 --- /dev/null +++ b/src-map-editor/src/file_formats/mod.rs @@ -0,0 +1,79 @@ +//! This module contains code to read and write fileformats that are used by Jagged Alliance 2 + +use std::io::ErrorKind::{InvalidData, InvalidInput}; +use std::io::{Error, Read, Result, Write}; + +pub mod ja2_map; +pub mod ja2_stci; +pub mod ja2_structure; +pub mod ja2_tile_database; +pub mod ja2_tile_types; +pub mod ja2_tileset; +pub mod stci; + +/// Registers the JA2 asset types and their loaders +pub struct Ja2AssetsPlugin; + +impl bevy::app::Plugin for Ja2AssetsPlugin { + fn build(&self, app: &mut bevy::app::App) { + use bevy::asset::AssetApp; + + app.init_asset::() + .init_asset::() + .init_asset::() + .init_asset_loader::() + .init_asset_loader::() + .init_asset_loader::(); + } +} + +/// Trait that adds extra functions to Read. +pub trait StracciatellaReadExt: Read { + /// Reads and discards unused bytes. + fn read_unused(&mut self, num_bytes: usize) -> Result<()> { + let mut buffer = vec![0u8; num_bytes]; + self.read_exact(&mut buffer)?; + Ok(()) + } + + /// Reads a nul terminated fixed size string. + fn read_fixed_string(&mut self, num_bytes: usize) -> Result { + let mut buffer = vec![0u8; num_bytes]; + self.read_exact(&mut buffer)?; + // must be nul terminated and valid utf8 + match buffer.iter().position(|&byte| byte == 0) { + Some(position) => match ::std::str::from_utf8(&buffer[..position]) { + Ok(s) => Ok(s.to_string()), + Err(e) => Err(Error::new(InvalidData, e)), + }, + None => Err(Error::new(InvalidData, "string is not nul terminated")), + } + } +} + +/// Trait that adds extra functions to Write. +pub trait StracciatellaWriteExt: Write { + /// Writes zeroed unused bytes. + fn write_unused(&mut self, num_bytes: usize) -> Result<()> { + let buffer = vec![0u8; num_bytes]; + self.write_all(&buffer) + } + + /// Write a nul terminated fixed size string, unused space is zeroed. + fn write_fixed_string(&mut self, num_bytes: usize, string: &str) -> Result<()> { + let mut buffer = vec![0u8; num_bytes]; + let string_bytes = string.as_bytes(); + if string_bytes.len() >= buffer.len() { + return Err(Error::new(InvalidInput, "string is too long")); + } + buffer[..string_bytes.len()].copy_from_slice(string_bytes); + self.write_all(&buffer)?; + Ok(()) + } +} + +/// Everything that implements Read gets Ja2WriteExt for free. +impl StracciatellaReadExt for T {} + +/// Everything that implements Write gets Ja2WriteExt for free. +impl StracciatellaWriteExt for T {} diff --git a/src-map-editor/src/file_formats/stci/color.rs b/src-map-editor/src/file_formats/stci/color.rs new file mode 100644 index 0000000..902e9ac --- /dev/null +++ b/src-map-editor/src/file_formats/stci/color.rs @@ -0,0 +1,104 @@ +//! This module contains color related functionality for the STCI image format + +use byteorder::{ReadBytesExt, WriteBytesExt, LE}; +use std::io::{Read, Result, Write}; + +/// Mask used to get red bits from Rgb565 color +pub const STCI_RGB565_RED_MASK: u32 = 0xF800; +/// Mask used to get green bits from Rgb565 color +pub const STCI_RGB565_GREEN_MASK: u32 = 0x7E0; +/// Mask used to get blue bits from Rgb565 color +pub const STCI_RGB565_BLUE_MASK: u32 = 0x1F; + +/// Rgb color representation with 8 bit per color +/// +/// This is used in indexed STCI images as the palette colors +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct StciRgb888(pub u8, pub u8, pub u8); + +impl StciRgb888 { + /// Read a single Rgb888 pixel from input + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let red = input.read_u8()?; + let green = input.read_u8()?; + let blue = input.read_u8()?; + + Ok(Self(red, green, blue)) + } + + /// Write a single Rgb888 pixel to output + #[allow(dead_code)] + pub fn to_output(self, output: &mut T) -> Result<()> + where + T: Write, + { + output.write_u8(self.0)?; + output.write_u8(self.1)?; + output.write_u8(self.2)?; + Ok(()) + } +} + +const fn max_value_565_shift_right(mask: u32, shift: u32) -> u32 { + mask >> shift +} + +const fn max_value_565_shift_left(mask: u32, shift: u32) -> u32 { + mask << shift +} + +impl From for StciRgb888 { + /// This conversion is slightly different from the one used in the original source code + /// It ensures that the whole 0..255 range of colors is used. + fn from(value16: StciRgb565) -> Self { + let r: u32 = (u32::from(value16.0) & STCI_RGB565_RED_MASK) >> 8; + let g: u32 = (u32::from(value16.0) & STCI_RGB565_GREEN_MASK) >> 3; + let b: u32 = (u32::from(value16.0) & STCI_RGB565_BLUE_MASK) << 3; + let r = (255 * r) / max_value_565_shift_right(STCI_RGB565_RED_MASK, 8); + let g = (255 * g) / max_value_565_shift_right(STCI_RGB565_GREEN_MASK, 3); + let b = (255 * b) / max_value_565_shift_left(STCI_RGB565_BLUE_MASK, 3); + StciRgb888(r as u8, g as u8, b as u8) + } +} + +/// Rgb color representation with 5 bits red, 6 bits green, 5 bits blue +/// +/// This is used in rgb STCI images as pixel data +/// +/// ``` +/// use stracciatella::file_formats::stci::{StciRgb565, StciRgb888}; +/// +/// // White should be white +/// assert_eq!(StciRgb888::from(StciRgb565(std::u16::MAX)), StciRgb888(255, 255, 255)); +/// // Black should be black +/// assert_eq!(StciRgb888::from(StciRgb565(0)), StciRgb888(0, 0, 0)); +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct StciRgb565(pub u16); + +impl StciRgb565 { + /// Read a single Rgb565 pixel from input + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let v = input.read_u16::()?; + + Ok(Self(v)) + } + + /// Write a single Rgb565 pixel to output + #[allow(dead_code)] + pub fn to_output(self, output: &mut T) -> Result<()> + where + T: Write, + { + output.write_u16::(self.0)?; + Ok(()) + } +} diff --git a/src-map-editor/src/file_formats/stci/etrle.rs b/src-map-editor/src/file_formats/stci/etrle.rs new file mode 100644 index 0000000..2f1ed21 --- /dev/null +++ b/src-map-editor/src/file_formats/stci/etrle.rs @@ -0,0 +1,253 @@ +//! This module contains functionality around the ETRLE compression used in indexed STCI images +//! +//! ETRLE compression can compress an arbitrary byte array and is optimized to compress consecutive +//! bytes of the same value. This value is chosen beforehand and called `val` in further description. +//! ETRLE compressed data consists of control bytes and data bytes. +//! +//! A control byte consists of the following bits: +//! - 1 Bit (called `com` in further description) to indicate whether this control byte indicates a compressed (1) +//! sequence of bytes or a non-compressed (0) sequence of bytes +//! - 7 Bit (called `len` in further description) to indicate the length of the current sequence +//! +//! The algorithm works as follows: +//! - Read next control byte +//! - If `com == 1`, decompress it by writing `val` `len` times to the output +//! - If not read the next `len` bytes and copy them to the output +//! - Goto read next control byte + +use byteorder::{ReadBytesExt, WriteBytesExt}; +use std::io::{Error, ErrorKind, Read, Result, Write}; + +/// Predetermined value that is compressed +pub const INDEXED_ALPHA_VALUE: u8 = 0; + +/// Bit mask to determine whether the current control byte indicates a compressed or non-compressed sequence of bytes +#[allow(dead_code)] +const IS_COMPRESSED_BIT_MASK: u8 = 0x80; + +/// Bit mask to determine the length of the current sequence +#[allow(dead_code)] +const COMPRESSED_SEQUENCE_LENGTH_MASK: u8 = 0x7F; + +/// Maximum sequence length +#[allow(dead_code)] +const MAX_SEQUENCE_LENGTH: u8 = 127; + +/// This function reads ETRLE compressed data from input and writes the decompressed data to output +/// +/// This function reads input until the end. +/// +/// ```rust +/// use stracciatella::file_formats::stci::etrle::etrle_decompress; +/// +/// let mut input: &[u8] = &[0x85]; +/// let mut output: Vec = vec![]; +/// +/// etrle_decompress(&mut input, &mut output).unwrap(); +/// assert_eq!(output, vec![0, 0, 0, 0, 0]) +/// ``` +#[allow(dead_code)] +pub fn etrle_decompress(input: &mut R, output: &mut W) -> Result<()> +where + R: Read, + W: Write, +{ + let next_control_byte = input.read_u8(); + match next_control_byte { + Ok(next_control_byte) => { + let is_compressed = ((next_control_byte & IS_COMPRESSED_BIT_MASK) >> 7) == 1; + let length_of_subsequence = next_control_byte & COMPRESSED_SEQUENCE_LENGTH_MASK; + + for _ in 0..length_of_subsequence { + if is_compressed { + output.write_u8(INDEXED_ALPHA_VALUE)?; + } else { + output.write_u8(input.read_u8()?)?; + } + } + + etrle_decompress(input, output) + } + Err(ref e) if e.kind() == ErrorKind::UnexpectedEof => Ok(()), + Err(e) => Err(e), + } +} + +/// Writes a sequence to the output +/// +/// A sequence in this case consists of only zero bytes or only non-zero bytes +/// and is shorter or equal 127 bytes +fn write_sequence(sequence: &[u8], output: &mut W) -> Result<()> +where + W: Write, +{ + let sequence_length = sequence.len(); + if sequence_length == 0 { + return Ok(()); + } + if sequence_length > usize::from(MAX_SEQUENCE_LENGTH) { + return Err(Error::new( + ErrorKind::InvalidInput, + format!("expected sequence length <= 127, got {}", sequence.len()), + )); + } + let sequence_length = sequence_length as u8; + + if sequence[0] == INDEXED_ALPHA_VALUE { + output.write_u8(sequence_length | IS_COMPRESSED_BIT_MASK)?; + } else { + output.write_u8(sequence_length)?; + output.write_all(sequence)?; + } + + Ok(()) +} + +/// This function reads data from input and writes the ETRLE compressed data to output +/// +/// This function reads input until the end. +/// +/// ```rust +/// use stracciatella::file_formats::stci::etrle::etrle_compress; +/// +/// let mut input: &[u8] = &[0, 0, 0, 0, 0]; +/// let mut output: Vec = vec![]; +/// +/// etrle_compress(&mut input, &mut output).unwrap(); +/// assert_eq!(output, vec![0x85]) +/// ``` +#[allow(dead_code)] +pub fn etrle_compress(input: &mut R, output: &mut W) -> Result<()> +where + R: Read, + W: Write, +{ + let mut current_sequence = vec![]; + loop { + let next_byte = input.read_u8(); + + match next_byte { + Ok(next_byte) => { + if current_sequence.len() == usize::from(MAX_SEQUENCE_LENGTH) { + write_sequence(¤t_sequence, output)?; + current_sequence = vec![]; + } + match (current_sequence.first(), next_byte) { + (None, next_byte) => { + current_sequence.push(next_byte); + } + (Some(&INDEXED_ALPHA_VALUE), INDEXED_ALPHA_VALUE) => { + current_sequence.push(INDEXED_ALPHA_VALUE); + } + (Some(_), INDEXED_ALPHA_VALUE) => { + write_sequence(¤t_sequence, output)?; + current_sequence = vec![INDEXED_ALPHA_VALUE]; + } + (Some(&INDEXED_ALPHA_VALUE), v) => { + write_sequence(¤t_sequence, output)?; + current_sequence = vec![v]; + } + (Some(_), v) => { + current_sequence.push(v); + } + } + } + Err(ref e) if e.kind() == ErrorKind::UnexpectedEof => { + write_sequence(¤t_sequence, output)?; + break; + } + Err(e) => { + return Err(e); + } + } + } + + Ok(()) +} + +/// Compresses an image of `width` pixels per row like the game's images: every row is compressed +/// on its own and ends with a 0 byte, which the blitters of the game rely on +/// +/// [`etrle_decompress`] skips the 0 bytes, as they are sequences of length 0. +pub fn etrle_compress_rows(pixels: &[u8], width: usize, output: &mut W) -> Result<()> +where + W: Write, +{ + if width == 0 { + return Ok(()); + } + for mut row in pixels.chunks(width) { + etrle_compress(&mut row, output)?; + output.write_u8(0)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode() { + let mut input: &[u8] = &[]; + let mut output: Vec = vec![]; + etrle_decompress(&mut input, &mut output).unwrap(); + assert_eq!(output.len(), 0); + + let mut input: &[u8] = &[0x02, 2, 3, 0x03, 4, 5, 6]; + let mut output: Vec = vec![]; + etrle_decompress(&mut input, &mut output).unwrap(); + assert_eq!(output, vec![2, 3, 4, 5, 6]); + + let mut input: &[u8] = &[0x85]; + let mut output: Vec = vec![]; + etrle_decompress(&mut input, &mut output).unwrap(); + assert_eq!(output, vec![0, 0, 0, 0, 0]); + + let mut input: &[u8] = &[0x82, 0x02, 2, 3, 0x83, 0x03, 4, 5, 6]; + let mut output: Vec = vec![]; + etrle_decompress(&mut input, &mut output).unwrap(); + assert_eq!(output, vec![0, 0, 2, 3, 0, 0, 0, 4, 5, 6]); + } + + #[test] + fn encode() { + let mut input: &[u8] = &[]; + let mut output: Vec = vec![]; + etrle_compress(&mut input, &mut output).unwrap(); + assert_eq!(output.len(), 0); + + let mut input: &[u8] = &[2, 3, 4, 5, 6]; + let mut output: Vec = vec![]; + etrle_compress(&mut input, &mut output).unwrap(); + assert_eq!(output, vec![0x05, 2, 3, 4, 5, 6]); + + let mut input: &[u8] = &[0, 0, 0, 0, 0]; + let mut output: Vec = vec![]; + etrle_compress(&mut input, &mut output).unwrap(); + assert_eq!(output, vec![0x85]); + + let mut input: &[u8] = &[0, 0, 2, 3, 0, 0, 0, 4, 5, 6]; + let mut output: Vec = vec![]; + etrle_compress(&mut input, &mut output).unwrap(); + assert_eq!(output, vec![0x82, 0x02, 2, 3, 0x83, 0x03, 4, 5, 6]); + + let mut input: &[u8] = &[0; 128]; + let mut output: Vec = vec![]; + etrle_compress(&mut input, &mut output).unwrap(); + assert_eq!(output, vec![0xFF, 0x81]); + } + + #[test] + fn encode_rows() { + let pixels: &[u8] = &[0, 0, 2, 3, 4, 0]; + let mut output: Vec = vec![]; + etrle_compress_rows(pixels, 3, &mut output).unwrap(); + assert_eq!(output, vec![0x82, 0x01, 2, 0x00, 0x02, 3, 4, 0x81, 0x00]); + + let mut input = output.as_slice(); + let mut decompressed: Vec = vec![]; + etrle_decompress(&mut input, &mut decompressed).unwrap(); + assert_eq!(decompressed, pixels); + } +} diff --git a/src-map-editor/src/file_formats/stci/indexed.rs b/src-map-editor/src/file_formats/stci/indexed.rs new file mode 100644 index 0000000..677f47d --- /dev/null +++ b/src-map-editor/src/file_formats/stci/indexed.rs @@ -0,0 +1,465 @@ +//! This module contains functionality around indexed STCI images + +use super::color::StciRgb888; +use crate::file_formats::{StracciatellaReadExt, StracciatellaWriteExt}; +use byteorder::{ReadBytesExt, WriteBytesExt, LE}; +use std::fmt; +use std::io::{ + Error, + ErrorKind::{InvalidData, InvalidInput}, + Read, Result, Write, +}; + +/// Represents the color depth defined within the STCI indexed format specific header +/// +/// We currently only support RGB888 color depth for palettes, all other color depths in STCI +/// images will result in an error. +#[derive(Debug, Clone, PartialEq)] +pub struct StciColorDepthIndexed(pub u8, pub u8, pub u8); + +impl StciColorDepthIndexed { + /// Read the color depth part from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let red = input.read_u8()?; + let green = input.read_u8()?; + let blue = input.read_u8()?; + + if red != 8 || green != 8 || blue != 8 { + return Err(Error::new( + InvalidData, + format!( + "expected RGB888 palette in indexed STCI, go RGB{}{}{} in header", + red, green, blue + ), + )); + } + + Ok(Self(red, green, blue)) + } + + /// Write the color depth part to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + if self.0 != 8 || self.1 != 8 || self.2 != 8 { + return Err(Error::new( + InvalidInput, + format!( + "only supporting RGB888 palettes in indexed STCI, got RGB{}{}{}", + self.0, self.1, self.2 + ), + )); + } + output.write_u8(self.0)?; + output.write_u8(self.1)?; + output.write_u8(self.2)?; + Ok(()) + } +} + +impl Default for StciColorDepthIndexed { + fn default() -> Self { + StciColorDepthIndexed(8, 8, 8) + } +} + +/// Represents the indexed format specific part of the STCI header +/// +/// We currently only support 256 palette colors, all other numbers of colors +/// will result in an error. +#[derive(Debug, Clone, PartialEq)] +pub struct StciHeaderIndexed { + pub number_of_palette_colors: u32, + pub number_of_images: u16, + pub color_depth: StciColorDepthIndexed, +} + +impl StciHeaderIndexed { + /// Read format specific part from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let number_of_palette_colors = input.read_u32::()?; + let number_of_images = input.read_u16::()?; + + if number_of_palette_colors != 256 { + return Err(Error::new( + InvalidData, + format!( + "expected 256 palette colors in indexed STCI, got {} in header", + number_of_palette_colors, + ), + )); + } + if number_of_images == 0 { + return Err(Error::new( + InvalidData, + "expected at least one sub image in indexed STCI", + )); + } + + let color_depth = StciColorDepthIndexed::from_input(input)?; + input.read_unused(11)?; + + Ok(Self { + number_of_palette_colors, + number_of_images, + color_depth, + }) + } + + /// Write format specific part to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + if self.number_of_palette_colors != 256 { + return Err(Error::new( + InvalidInput, + format!( + "expected 256 palette colors in indexed STCI, got {}", + self.number_of_palette_colors, + ), + )); + } + if self.number_of_images == 0 { + return Err(Error::new( + InvalidData, + "expected at least one sub image in indexed STCI", + )); + } + output.write_u32::(self.number_of_palette_colors)?; + output.write_u16::(self.number_of_images)?; + self.color_depth.to_output(output)?; + output.write_unused(11)?; + + Ok(()) + } +} + +impl Default for StciHeaderIndexed { + fn default() -> Self { + Self { + number_of_palette_colors: 256, + number_of_images: 0, + color_depth: StciColorDepthIndexed::default(), + } + } +} + +/// Represents the palette within a STCI image +/// +/// The palette of STCI images consists of 256 RGB888 color values. +#[derive(Clone)] +pub struct StciPalette { + pub colors: [StciRgb888; 256], +} + +impl Default for StciPalette { + fn default() -> Self { + Self { + colors: [StciRgb888(0, 0, 0); 256], + } + } +} + +impl fmt::Debug for StciPalette { + fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + self.colors[..].fmt(formatter) + } +} + +impl PartialEq for StciPalette { + fn eq(&self, other: &StciPalette) -> bool { + self.colors[..].eq(&other.colors[..]) + } +} + +impl StciPalette { + /// Read the palette from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T, number_of_colors: usize) -> Result + where + T: Read, + { + if number_of_colors != 256 { + return Err(Error::new( + InvalidData, + format!("expected 256 colors for palette, got {}", number_of_colors), + )); + } + let mut colors = [StciRgb888(0, 0, 0); 256]; + for color in colors.iter_mut() { + *color = StciRgb888::from_input(input)?; + } + Ok(Self { colors }) + } + + /// Write the palette to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + for color in &self.colors[..] { + color.to_output(output)?; + } + Ok(()) + } +} + +/// Size of a single sub image header in STCI files in bytes +#[allow(dead_code)] +pub const STCI_SUB_IMAGE_HEADER_SIZE: usize = 16; + +/// This represents a sub image header within an indexed STCI image +/// +/// Sub image headers determine where the data of a specific sub image +/// is located and it's dimensions and offset when rendered. +#[derive(Debug, Clone, PartialEq)] +pub struct StciSubImageHeader { + pub data_offset: u32, + pub data_length: u32, + pub offset: (i16, i16), + pub dimensions: (u16, u16), +} + +impl StciSubImageHeader { + /// Read the sub image header from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let data_offset = input.read_u32::()?; + let data_length = input.read_u32::()?; + let offset_x = input.read_i16::()?; + let offset_y = input.read_i16::()?; + let dimensions_y = input.read_u16::()?; + let dimensions_x = input.read_u16::()?; + + Ok(Self { + data_offset, + data_length, + offset: (offset_x, offset_y), + dimensions: (dimensions_x, dimensions_y), + }) + } + + /// Write the sub image header to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + output.write_u32::(self.data_offset)?; + output.write_u32::(self.data_length)?; + output.write_i16::(self.offset.0)?; + output.write_i16::(self.offset.1)?; + output.write_u16::(self.dimensions.1)?; + output.write_u16::(self.dimensions.0)?; + Ok(()) + } +} + +bitflags::bitflags! { + /// Some tile-specific flags within the app data in an indexed STCI image + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct StciAppDataFlags: u8 { + const FULL_TILE = 0x01; + const ANIMATED_TILE = 0x02; + const DYNAMIC_TILE = 0x04; + const INTERACTIVE_TILE = 0x08; + const IGNORES_HEIGHT = 0x10; + const USES_LAND_Z = 0x20; + } +} + +/// Ja2 specific metadata that might exist for each sub image in an indexed STCI image +#[derive(Debug, Clone, PartialEq)] +pub struct StciAppData { + pub wall_orientation: u8, + pub number_of_tiles: u8, + pub tile_location_index: u16, + pub current_frame: u8, + pub number_of_frames: u8, + pub flags: StciAppDataFlags, +} + +/// Size of a single app data stuct in STCI files in bytes +#[allow(dead_code)] +pub const STCI_APP_DATA_SIZE: usize = 16; + +impl StciAppData { + /// Read the app data from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let wall_orientation = input.read_u8()?; + let number_of_tiles = input.read_u8()?; + let tile_location_index = input.read_u16::()?; + input.read_unused(3)?; + let current_frame = input.read_u8()?; + let number_of_frames = input.read_u8()?; + let flags = input.read_u8()?; + // Unknown bits are kept, the game ignores them as well + let flags = StciAppDataFlags::from_bits_retain(flags); + input.read_unused(6)?; + + Ok(Self { + wall_orientation, + number_of_tiles, + tile_location_index, + current_frame, + number_of_frames, + flags, + }) + } + + /// Write the app data to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + output.write_u8(self.wall_orientation)?; + output.write_u8(self.number_of_tiles)?; + output.write_u16::(self.tile_location_index)?; + output.write_unused(3)?; + output.write_u8(self.current_frame)?; + output.write_u8(self.number_of_frames)?; + output.write_u8(self.flags.bits())?; + output.write_unused(6)?; + Ok(()) + } +} + +/// Final representation of a sub image within a indexed STCI image. +/// +/// This already holds decompressed ETRLE data, so no need to further decompress anything. +#[derive(Debug, Clone, PartialEq)] +pub struct StciSubImage { + pub offset: (i16, i16), + pub dimensions: (u16, u16), + pub app_data: Option, + pub data: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_indexed_format_specific_header() { + let format_specific_header = StciHeaderIndexed { + number_of_palette_colors: 256, + number_of_images: 1, + color_depth: StciColorDepthIndexed(8, 8, 8), + }; + let mut v = vec![0u8; 16]; + let mut c = Cursor::new(&mut v); + + format_specific_header + .to_output(&mut c) + .expect("should be possible to write header"); + assert_eq!( + c.position(), + 20, + "format specific header should have 20 bytes in size when writing" + ); + + c.set_position(0); + let read_header = + StciHeaderIndexed::from_input(&mut c).expect("should be possible to read header"); + assert_eq!( + c.position(), + 20, + "format specific header should have 20 bytes in size when reading" + ); + assert_eq!(read_header, format_specific_header) + } + + #[test] + fn test_stci_sub_image_header() { + let header = StciSubImageHeader { + data_offset: 256, + data_length: 300, + offset: (1, -1), + dimensions: (20, 30), + }; + let mut v = vec![0u8; STCI_SUB_IMAGE_HEADER_SIZE]; + let mut c = Cursor::new(&mut v); + + header + .to_output(&mut c) + .expect("should be possible to write header"); + assert_eq!( + c.position(), + STCI_SUB_IMAGE_HEADER_SIZE as u64, + "app data should have size {} when writing, got {}", + STCI_SUB_IMAGE_HEADER_SIZE, + c.position() + ); + + c.set_position(0); + let read_header = + StciSubImageHeader::from_input(&mut c).expect("should be possible to read header"); + assert_eq!( + c.position(), + STCI_SUB_IMAGE_HEADER_SIZE as u64, + "app data should have size {} when reading, got {}", + STCI_SUB_IMAGE_HEADER_SIZE, + c.position() + ); + assert_eq!(read_header, header) + } + + #[test] + fn test_stci_app_data() { + let header = StciAppData { + wall_orientation: 0, + number_of_tiles: 0, + tile_location_index: 0, + current_frame: 0, + number_of_frames: 0, + flags: StciAppDataFlags::empty(), + }; + let mut v = vec![0u8; STCI_APP_DATA_SIZE]; + let mut c = Cursor::new(&mut v); + + header + .to_output(&mut c) + .expect("should be possible to write header"); + assert_eq!( + c.position(), + STCI_APP_DATA_SIZE as u64, + "app data should have size {} when writing, got {}", + STCI_APP_DATA_SIZE, + c.position() + ); + + c.set_position(0); + let read_header = + StciAppData::from_input(&mut c).expect("should be possible to read header"); + assert_eq!( + c.position(), + STCI_APP_DATA_SIZE as u64, + "app data should have size {} when reading, got {}", + STCI_APP_DATA_SIZE, + c.position() + ); + assert_eq!(read_header, header) + } +} diff --git a/src-map-editor/src/file_formats/stci/mod.rs b/src-map-editor/src/file_formats/stci/mod.rs new file mode 100644 index 0000000..6f1a103 --- /dev/null +++ b/src-map-editor/src/file_formats/stci/mod.rs @@ -0,0 +1,917 @@ +//! This module contains code to read and write Sir-Tech's Crazy Image (STCI) file format. +//! +//! STCI is a file format that holds information about one or multiple images in Jagged Alliance 2. +//! +//! There are two variants: Indexed and RGB. +//! +//! Indexed STCI images store their colors in a palette and the data as ETRLE compressed indices into +//! this palette. It also can contain multiple sub images, so it can encode e.g. one or multiple images, +//! animations or tiles. Those sub images can have additional metadata associated such as offsets or +//! animation or tile metadata. +//! +//! RGB STCI images store their colors as RGB565 colors in the data section. They cannot have additional +//! metadata and only a single image can be stored in a RGB STCI image. +//! +//! # File Structure +//! +//! Based on `src/sgp/ImgFmt.h` +//! +//! Each file is composed of: +//! +//! - STCI prefix: 4 bytes of value `b"STCI"` +//! - Header (60 bytes) +//! - Palette (only for indexed images, 768 bytes, stored as RGB triplets) +//! - SubImageHeaders (only for indexed images, 16 bytes each) +//! - Data Section +//! - App Data (only for indexed images, even then it is optional, 16 bytes each) +//! +//! # Header Structure +//! +//! - 4 byte unsigned int with the uncompressed size of the included image(s) pixels +//! - 4 byte unsigned int with the compressed size of the included image(s) pixels +//! - 4 byte unsigned int with the value that is transparent in the image. Is always 0 for Jagged Alliance 2 assets. +//! - 4 byte unsigned into with flags for the stci image. Flags determine whether it is the indexed or RGB variant of STCI +//! - 2 byte unsigned int with the height of the image. Ignored for indexed images, as their size is defined in the sub image headers. +//! - 2 byte unsigned int with the width of the image. Ignored for indexed images, as their size is defined in the sub image headers. +//! - 20 bytes with a format specific header. This header part is different for indexed and RGB images +//! - 1 byte unsigned int with color_depth of the image. This is always 8 for indexed and 16 for RGB images for Jagged Alliance 2 assets. +//! - 3 bytes unused. NOTE: This is different from original source code where no unused bytes are defined after the color depth, but +//! it seems to be required +//! - 4 bytes unsigned int with the size of the app data included in the STCI image. Always 0 for RGB images. +//! +//! # Indexed Format Specific Header Structure +//! +//! - 4 bytes unsigned int with the number of palette colors. Always 256 for Jagged Alliance 2 assets. +//! - 2 bytes unsigned int with the number of sub images in the indexed STCI image +//! - 1 byte unsigned int with the red color depth for the STCI image. Always 8 for Jagged Alliance 2 assets. +//! - 1 byte unsigned int with the green color depth for the STCI image. Always 8 for Jagged Alliance 2 assets. +//! - 1 byte unsigned int with the blue color depth for the STCI image. Always 8 for Jagged Alliance 2 assets. +//! - 11 bytes of unused data +//! +//! # RGB Format Specific Header Structure +//! +//! - 4 bytes unsigned int with the red color mask for the RGB image. Always `0xF800` for Jagged Alliance 2 assets. +//! - 4 bytes unsigned int with the green color mask for the RGB image. Always `0x7E0` for Jagged Alliance 2 assets. +//! - 4 bytes unsigned int with the blue color mask for the RGB image. Always `0x1F` for Jagged Alliance 2 assets. +//! - 4 bytes unsigned int with the alpha color mask for the RGB image. Always `0` for Jagged Alliance 2 assets. +//! - 1 bytes unsigned int with the red color depth for the RGB image. Always `5` for Jagged Alliance 2 assets. +//! - 1 bytes unsigned int with the green color depth for the RGB image. Always `6` for Jagged Alliance 2 assets. +//! - 1 bytes unsigned int with the blue color depth for the RGB image. Always `5` for Jagged Alliance 2 assets. +//! - 1 bytes unsigned int with the alpha color depth for the RGB image. Always `0` for Jagged Alliance 2 assets. +//! +//! # Sub Image Header Stucture +//! +//! - 4 bytes unsigned int with the data offset from data section start +//! - 4 bytes unsigned int with the compressed data length for this sub image +//! - 2 bytes signed int with the x offset for this sub image +//! - 2 bytes signed int with the y offset for this sub image +//! - 2 bytes unsigned int with the height of this sub image +//! - 2 bytes unsigned int with the width of this sub image +//! +//! # Indexed Data Section Structure +//! +//! The data section for indexed images contains all pixel data for each sub image in consecutive order. +//! The data is ETRLE compressed for each sub image. When decompressed it can be used to index into the +//! palette of the STCI image. +//! +//! # RGB Data Section Structure +//! +//! The data section for RGB images contains all pixel data for the RGB image in RGB565 color (which is represented +//! as an 2 byte unsigned int). +//! +//! # App Data Structure +//! +//! App Data is optionally there for indexed STCI images. It contains additional information for the tile and/or +//! animation system. It is only read when the app data size field in the header is > 0. +//! +//! - 1 byte unsigned int for wall orientation +//! - 1 byte unsigned int for number of tiles +//! - 2 byte unsigned int for tile location index +//! - 3 unused bytes +//! - 1 byte unsigned int for current animation keyframe. Seems to be `0` for Jagged Alliance 2 assets. +//! - 1 byte unsigned int for the number of frames for animation +//! - 1 byte unsigned int for flags on the sub image +//! - 6 unused bytes + +use crate::file_formats::{StracciatellaReadExt, StracciatellaWriteExt}; +use bitflags::bitflags; +use byteorder::{ReadBytesExt, WriteBytesExt, LE}; +use std::io::{ + Cursor, Error, + ErrorKind::{InvalidData, InvalidInput, UnexpectedEof}, + Read, Result, Seek, SeekFrom, Write, +}; + +mod color; +pub mod etrle; +pub mod indexed; +pub mod rgb; + +pub use color::*; +use indexed::*; +use rgb::*; + +pub use indexed::{StciAppData, StciAppDataFlags, StciPalette, StciSubImage}; + +/// Representation of the size part of the STCI header. +/// +/// - For indexed images: +/// - `original`: Accumulated uncompressed size of the data of all sub images +/// - `stored`: Accumulated compressed size of the data of all sub images +/// - For rgb images: +/// - `original` == `stored`: Uncompressed size of the data in the STCI image +#[derive(Debug, Default, Clone, PartialEq)] +struct StciSize { + original: u32, + stored: u32, +} + +impl StciSize { + /// Read the sizes from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let original = input.read_u32::()?; + let stored = input.read_u32::()?; + + Ok(Self { original, stored }) + } + + /// Write the sizes to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + output.write_u32::(self.original)?; + output.write_u32::(self.stored)?; + Ok(()) + } +} + +bitflags! { + /// Flags that can be set on an STCI image + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct StciFlags: u32 { + /// Sets the STCI to be ETRLE compressed. Needs to be set for indexed STCI images. + const ETRLE_COMPRESSED = 0x0020; + /// Sets the STCI to be zlib compressed. Not supported. Setting this flag will result in an error. + const ZLIB_COMPRESSED = 0x0010; + /// Sets the STCI to be an indexed STCI. + const INDEXED = 0x0008; + /// Sets the STCI to be a rgb STCI. + const RGB = 0x0004; + /// Seems to be unused. + const ALPHA = 0x0002; + /// Seems to be unused. + const TRANSPARENT = 0x0001; + } +} + +impl StciFlags { + /// Returns proper flags for indexed image + #[allow(dead_code)] + fn indexed() -> Self { + let mut flags = StciFlags::empty(); + flags.insert(StciFlags::INDEXED); + flags.insert(StciFlags::ETRLE_COMPRESSED); + flags + } + + /// Returns proper flags for rgb image + #[allow(dead_code)] + fn rgb() -> Self { + let mut flags = StciFlags::empty(); + flags.insert(StciFlags::RGB); + flags + } +} + +/// Representation of header parts that are common to indexed and rgb STCI. +/// +/// In the original data structure there are rgb/indexed specific header parts +/// between `width` and `color_depth`. +#[derive(Debug, Clone, PartialEq)] +struct StciCommonHeader { + size: StciSize, + transparent_value: u32, + flags: StciFlags, + height: u16, + width: u16, + color_depth: u8, + app_data_size: u32, +} + +impl StciCommonHeader { + /// Returns proper common header for indexed images + /// + /// Unknown values (e.g. sizes are set to 0) + #[allow(dead_code)] + fn indexed() -> Self { + StciCommonHeader { + size: StciSize::default(), + transparent_value: etrle::INDEXED_ALPHA_VALUE.into(), + flags: StciFlags::indexed(), + height: 0, + width: 0, + color_depth: 8, + app_data_size: 0, + } + } + + /// Returns proper common header for rgb images + /// + /// Unknown values (e.g. sizes are set to 0) + #[allow(dead_code)] + fn rgb() -> Self { + StciCommonHeader { + size: StciSize::default(), + transparent_value: 0, + flags: StciFlags::rgb(), + height: 0, + width: 0, + color_depth: 16, + app_data_size: 0, + } + } +} + +/// Complete representation of the STCI header including common and format specific parts +#[derive(Debug, Clone, PartialEq)] +enum StciHeader { + Indexed { + header: StciCommonHeader, + format_specific_header: StciHeaderIndexed, + }, + Rgb { + header: StciCommonHeader, + format_specific_header: StciHeaderRgb, + }, +} + +impl StciHeader { + /// Read the header from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let size = StciSize::from_input(input)?; + let transparent_value = input.read_u32::()?; + let flags = input.read_u32::()?; + let flags = StciFlags::from_bits(flags) + .ok_or_else(|| Error::new(InvalidData, "could not parse StciFlags"))?; + let height = input.read_u16::()?; + let width = input.read_u16::()?; + let is_indexed = flags.intersects(StciFlags::INDEXED); + let is_rgb = flags.intersects(StciFlags::RGB); + + if flags.intersects(StciFlags::ZLIB_COMPRESSED) { + return Err(Error::new( + InvalidData, + "zlib compressed stci not supported", + )); + } + + match (is_indexed, is_rgb) { + (true, false) => { + let format_specific_header = StciHeaderIndexed::from_input(input)?; + let color_depth = input.read_u8()?; + input.read_unused(3)?; + let app_data_size = input.read_u32::()?; + input.read_unused(12)?; + + if color_depth != 8 { + return Err(Error::new( + InvalidData, + "only supporting 8 bit indexed images", + )); + } + if transparent_value != u32::from(etrle::INDEXED_ALPHA_VALUE) { + return Err(Error::new( + InvalidData, + "only supporting 0 as transparent value", + )); + } + if !flags.intersects(StciFlags::ETRLE_COMPRESSED) { + return Err(Error::new( + InvalidData, + "only supporting etrle compressed indexed images", + )); + } + + Ok(StciHeader::Indexed { + header: StciCommonHeader { + size, + transparent_value, + flags, + width, + height, + color_depth, + app_data_size, + }, + format_specific_header, + }) + } + (false, true) => { + let format_specific_header = StciHeaderRgb::from_input(input)?; + let color_depth = input.read_u8()?; + input.read_unused(3)?; + let app_data_size = input.read_u32::()?; + input.read_unused(12)?; + + if color_depth != 16 { + return Err(Error::new(InvalidData, "only supporting 16 bit RGB images")); + } + if flags.intersects(StciFlags::ETRLE_COMPRESSED) { + return Err(Error::new( + InvalidData, + "not supporting etrle encoded rgb images", + )); + } + + Ok(StciHeader::Rgb { + header: StciCommonHeader { + size, + transparent_value, + flags, + width, + height, + color_depth, + app_data_size, + }, + format_specific_header, + }) + } + (true, true) => Err(Error::new( + InvalidInput, + "both INDEXED and RGB flags are set", + )), + (false, false) => Err(Error::new( + InvalidInput, + "neither INDEXED nor RGB flag is set", + )), + } + } + + /// Write the header to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + match self { + StciHeader::Indexed { header, .. } | StciHeader::Rgb { header, .. } => { + header.size.to_output(output)?; + output.write_u32::(header.transparent_value)?; + output.write_u32::(header.flags.bits())?; + output.write_u16::(header.height)?; + output.write_u16::(header.width)?; + } + } + match self { + StciHeader::Indexed { + format_specific_header, + .. + } => format_specific_header.to_output(output)?, + StciHeader::Rgb { + format_specific_header, + .. + } => format_specific_header.to_output(output)?, + } + match self { + StciHeader::Indexed { header, .. } | StciHeader::Rgb { header, .. } => { + output.write_u8(header.color_depth)?; + output.write_unused(3)?; + output.write_u32::(header.app_data_size)?; + output.write_unused(12)?; + } + } + Ok(()) + } +} + +/// Complete representation of an STCI image +#[derive(Clone, Debug, PartialEq)] +pub enum Stci { + /// Indexed variant + Indexed { + palette: Box, + sub_images: Vec, + }, + /// Rgb variant + Rgb { + width: u16, + height: u16, + data: Vec, + }, +} + +fn decode_sub_image_headers( + input: &mut T, + number_of_sub_images: usize, +) -> Result> +where + T: Read, +{ + let mut subimage_headers = Vec::with_capacity(number_of_sub_images); + for _ in 0..number_of_sub_images { + subimage_headers.push(StciSubImageHeader::from_input(input)?); + } + Ok(subimage_headers) +} + +fn decode_sub_images( + subimage_headers: Vec, + decode_app_data: bool, + input: &mut T, +) -> Result> +where + T: Read, +{ + let number_of_subimages = subimage_headers.len(); + let mut sub_images = Vec::with_capacity(number_of_subimages); + let mut current_index: u32 = 0; + for header in subimage_headers.iter() { + // We expect the images to be stored in the same order as the headers. + // This seems to be true for all STCI shipped with Jagged Alliance 2 + if header.data_offset != current_index { + return Err(Error::new( + InvalidInput, + "only supporting continoous offsets", + )); + } + + let data_length = header.data_length as usize; + let expected_decompressed_length = + header.dimensions.0 as usize * header.dimensions.1 as usize; + let mut data = Vec::with_capacity(expected_decompressed_length); + let mut output = Cursor::new(&mut data); + let mut input = input.take(data_length as u64); + etrle::etrle_decompress(&mut input, &mut output)?; + + // Check whether we decompressed exactly the number of bytes we need + if data.len() != expected_decompressed_length { + return Err(Error::new( + UnexpectedEof, + format!( + "expected to read {} bytes for sub image data, got {}", + expected_decompressed_length, + data.len(), + ), + )); + } + sub_images.push(StciSubImage { + offset: header.offset, + dimensions: header.dimensions, + app_data: None, + data, + }); + + current_index += header.data_length; + } + // App data is optional + if decode_app_data { + for sub_image in sub_images.iter_mut() { + let app_data = StciAppData::from_input(input)?; + sub_image.app_data = Some(app_data); + } + } + Ok(sub_images) +} + +impl Stci { + /// Check if an input is actually an STCI image. + /// + /// This is done by reading the first 4 bytes and checking them against `b"STCI"`. + /// After reading it seeks back 4 bytes, so we are at the same position as before. + #[allow(dead_code)] + pub fn peek_is_stci(input: &mut T) -> Result + where + T: Read + Seek, + { + let mut tag = [0u8; 4]; + let e = input.read_exact(&mut tag); + match e { + Ok(_) => {} + Err(ref e) if e.kind() == UnexpectedEof => { + // We cannot read enough bytes for the tag, so its not an STCI file + return Ok(false); + } + Err(e) => { + // Forward any other error + return Err(e); + } + } + input.seek(SeekFrom::Current(-4))?; + Ok(&tag == b"STCI") + } + + /// Read an STCI image from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let mut tag = [0u8; 4]; + input.read_exact(&mut tag)?; + if &tag != b"STCI" { + return Err(Error::new(InvalidInput, "does not seem to be a stci file")); + } + let header = StciHeader::from_input(input)?; + Ok(match header { + StciHeader::Rgb { header, .. } => { + let pixels = header.width as usize * header.height as usize; + let mut data = Vec::with_capacity(pixels); + for _ in 0..pixels { + data.push(StciRgb565::from_input(input)?) + } + Stci::Rgb { + width: header.width, + height: header.height, + data, + } + } + StciHeader::Indexed { + header, + format_specific_header, + } => { + let palette = StciPalette::from_input( + input, + format_specific_header.number_of_palette_colors as usize, + )?; + let sub_image_headers = decode_sub_image_headers( + input, + format_specific_header.number_of_images as usize, + )?; + // Like the game, only use app data that has an entry for every sub image + let has_app_data = header.app_data_size as usize + == sub_image_headers.len() * indexed::STCI_APP_DATA_SIZE; + let sub_images = decode_sub_images(sub_image_headers, has_app_data, input)?; + Stci::Indexed { + palette: Box::new(palette), + sub_images, + } + } + }) + } + + /// Write a STCI image to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + output.write_all(b"STCI")?; + match self { + Stci::Rgb { + width, + height, + data, + } => { + let byte_size: usize = std::mem::size_of::(); + let header = StciHeader::Rgb { + header: StciCommonHeader { + size: StciSize { + original: (data.len() * byte_size) as u32, + stored: (data.len() * byte_size) as u32, + }, + width: *width, + height: *height, + ..StciCommonHeader::rgb() + }, + format_specific_header: StciHeaderRgb::default(), + }; + header.to_output(output)?; + + for color in data { + color.to_output(output)?; + } + } + Stci::Indexed { + palette, + sub_images, + } => { + let number_of_images = sub_images.len(); + let app_data_size: u32 = sub_images + .iter() + .map(|sub_image| { + if sub_image.app_data.is_some() { + STCI_APP_DATA_SIZE as u32 + } else { + 0 + } + }) + .sum(); + let compressed_sub_image_bytes: Result>> = sub_images + .iter() + .map(|sub_image| { + let mut data = Vec::::new(); + let mut output = Cursor::new(&mut data); + + etrle::etrle_compress_rows( + &sub_image.data, + usize::from(sub_image.dimensions.0), + &mut output, + )?; + + Ok(data) + }) + .collect(); + let compressed_sub_image_bytes = compressed_sub_image_bytes?; + let original_size: u32 = sub_images + .iter() + .map(|sub_image| { + u32::from(sub_image.dimensions.0) * u32::from(sub_image.dimensions.1) + }) + .sum(); + let stored_size: u32 = compressed_sub_image_bytes + .iter() + .map(|v| v.len() as u32) + .sum(); + let header = StciHeader::Indexed { + header: StciCommonHeader { + size: StciSize { + original: original_size, + stored: stored_size, + }, + app_data_size, + ..StciCommonHeader::indexed() + }, + format_specific_header: StciHeaderIndexed { + number_of_images: number_of_images as u16, + ..StciHeaderIndexed::default() + }, + }; + header.to_output(output)?; + palette.to_output(output)?; + + let mut current_offset: u32 = 0; + for (index, sub_image) in sub_images.iter().enumerate() { + let compressed_data = &compressed_sub_image_bytes[index]; + let compressed_data_len = compressed_data.len() as u32; + let sub_image_header = StciSubImageHeader { + data_offset: current_offset, + data_length: compressed_data_len, + dimensions: sub_image.dimensions, + offset: sub_image.offset, + }; + + sub_image_header.to_output(output)?; + + current_offset += compressed_data_len; + } + + for sub_image_bytes in compressed_sub_image_bytes { + output.write_all(&sub_image_bytes)?; + } + + if app_data_size > 0 { + for sub_image in sub_images { + if let Some(app_data) = &sub_image.app_data { + app_data.to_output(output)?; + } else { + return Err(Error::new( + InvalidInput, + "either all or no sub images should have app data", + )); + } + } + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn parse_sti_rgb() { + let mut sti_rgb: &[u8] = b"STCI\x06\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x01\x00\x03\x00\x00\xF8\x00\x00\xe0\x07\x00\x00\x1f\x00\x00\x00\x00\x00\x00\x00\x05\x06\x05\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xF8\x00\xF8\x00\xF8"; + Stci::from_input(&mut sti_rgb).expect("rgb sti should parse"); + } + + #[test] + fn test_stci_header_indexed() { + let header = StciHeader::Indexed { + header: StciCommonHeader::indexed(), + format_specific_header: StciHeaderIndexed { + number_of_images: 1, + ..StciHeaderIndexed::default() + }, + }; + let expected_size: usize = 60; + let mut v = vec![0u8; expected_size]; + let mut c = Cursor::new(&mut v); + + // We dont include the STCI tag in our header so it needs to be 60 instead of 64 bytes + header + .to_output(&mut c) + .expect("should be possible to write header"); + assert_eq!( + c.position(), + expected_size as u64, + "header should have {} bytes in size when writing, got {}", + expected_size, + c.position(), + ); + + c.set_position(0); + let read_header = + StciHeader::from_input(&mut c).expect("should be possible to read header"); + assert_eq!( + c.position(), + expected_size as u64, + "header should have {} bytes in size when reading, got {}", + expected_size, + c.position(), + ); + assert_eq!(read_header, header) + } + + #[test] + fn test_stci_header_rgb() { + let header = StciHeader::Rgb { + header: StciCommonHeader { + height: 1, + width: 3, + ..StciCommonHeader::rgb() + }, + format_specific_header: StciHeaderRgb::default(), + }; + let expected_size: usize = 60; + let mut v = vec![0u8; expected_size]; + let mut c = Cursor::new(&mut v); + + // We dont include the STCI tag in our header so it needs to be 60 instead of 64 bytes + header + .to_output(&mut c) + .expect("should be possible to write header"); + assert_eq!( + c.position(), + expected_size as u64, + "header should have {} bytes in size when writing, got {}", + expected_size, + c.position(), + ); + + c.set_position(0); + let read_header = + StciHeader::from_input(&mut c).expect("should be possible to read header"); + assert_eq!( + c.position(), + expected_size as u64, + "header should have {} bytes in size when reading, got {}", + expected_size, + c.position(), + ); + assert_eq!(read_header, header) + } + + #[test] + fn test_stci_rgb() { + let stci = Stci::Rgb { + height: 1, + width: 1, + data: vec![StciRgb565(0)], + }; + // expected size is 4 byte STCI tag + 60 byte header + 2 byte data + let expected_size: usize = 66; + let mut v = vec![0u8; expected_size]; + let mut c = Cursor::new(&mut v); + + stci.to_output(&mut c) + .expect("should be possible to write header"); + assert_eq!( + c.position(), + expected_size as u64, + "stci should have {} bytes in size when writing, got {}", + expected_size, + c.position() + ); + + c.set_position(0); + let read_stci = Stci::from_input(&mut c).expect("should be possible to read header"); + assert_eq!( + c.position(), + expected_size as u64, + "stci should have {} bytes in size when reading, got {}", + expected_size, + c.position(), + ); + assert_eq!(read_stci, stci) + } + + #[test] + fn test_stci_indexed_no_app_data() { + let stci = Stci::Indexed { + palette: Box::new(StciPalette::default()), + sub_images: vec![ + StciSubImage { + offset: (0, 0), + dimensions: (1, 1), + data: vec![0], + app_data: None, + }, + StciSubImage { + offset: (1, -1), + dimensions: (1, 1), + data: vec![0], + app_data: None, + }, + ], + }; + // expected size is 4 byte STCI tag + 60 byte header + 3 * 256 bytes palette + 2 * 16 byte sub image header + 2 * 2 byte data (a row and its end) + let expected_size: usize = 868; + let mut v = vec![0u8; expected_size]; + let mut c = Cursor::new(&mut v); + + stci.to_output(&mut c) + .expect("should be possible to write header"); + assert_eq!( + c.position(), + expected_size as u64, + "stci should have {} bytes in size when writing, got {}", + expected_size, + c.position() + ); + + c.set_position(0); + let read_stci = Stci::from_input(&mut c).expect("should be possible to read header"); + assert_eq!( + c.position(), + expected_size as u64, + "stci should have {} bytes in size when reading, got {}", + expected_size, + c.position(), + ); + assert_eq!(read_stci, stci) + } + + #[test] + fn test_stci_indexed_with_app_data() { + let stci = Stci::Indexed { + palette: Box::new(StciPalette::default()), + sub_images: vec![ + StciSubImage { + offset: (0, 0), + dimensions: (1, 1), + data: vec![0], + app_data: Some(StciAppData { + wall_orientation: 0, + number_of_tiles: 0, + tile_location_index: 0, + current_frame: 0, + number_of_frames: 2, + flags: StciAppDataFlags::empty(), + }), + }, + StciSubImage { + offset: (1, -1), + dimensions: (1, 1), + data: vec![0], + app_data: Some(StciAppData { + wall_orientation: 0, + number_of_tiles: 0, + tile_location_index: 0, + current_frame: 0, + number_of_frames: 0, + flags: StciAppDataFlags::empty(), + }), + }, + ], + }; + // expected size is + // 4 byte STCI tag + 60 byte header + 3 * 256 bytes palette + 2 * 16 byte sub image header + 2 * 2 byte data (a row and its end) + 2 * 16 byte app data + let expected_size: usize = 900; + let mut v = vec![0u8; expected_size]; + let mut c = Cursor::new(&mut v); + + stci.to_output(&mut c) + .expect("should be possible to write header"); + assert_eq!( + c.position(), + expected_size as u64, + "stci should have {} bytes in size when writing, got {}", + expected_size, + c.position() + ); + + c.set_position(0); + let read_stci = Stci::from_input(&mut c).expect("should be possible to read header"); + assert_eq!( + c.position(), + expected_size as u64, + "stci should have {} bytes in size when reading, got {}", + expected_size, + c.position(), + ); + assert_eq!(read_stci, stci) + } +} diff --git a/src-map-editor/src/file_formats/stci/rgb.rs b/src-map-editor/src/file_formats/stci/rgb.rs new file mode 100644 index 0000000..d06117c --- /dev/null +++ b/src-map-editor/src/file_formats/stci/rgb.rs @@ -0,0 +1,168 @@ +//! This module contains functionality around RGB STCI images + +use super::{STCI_RGB565_BLUE_MASK, STCI_RGB565_GREEN_MASK, STCI_RGB565_RED_MASK}; +use std::io::{ + Error, + ErrorKind::{InvalidData, InvalidInput}, + Read, Result, Write, +}; + +use byteorder::{ReadBytesExt, WriteBytesExt, LE}; + +/// Mask part of the rgb STCI header +/// +/// The mask includes masks for all RGBA channels. +/// +/// We only support RGB565 without alpha channel right now. All other masks will result +/// in an error. +#[derive(Debug, Clone, PartialEq)] +pub struct StciMaskRgb(pub u32, pub u32, pub u32, pub u32); + +impl StciMaskRgb { + /// Read the mask part from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let red = input.read_u32::()?; + let green = input.read_u32::()?; + let blue = input.read_u32::()?; + let alpha = input.read_u32::()?; + + if red != STCI_RGB565_RED_MASK + || green != STCI_RGB565_GREEN_MASK + || blue != STCI_RGB565_BLUE_MASK && alpha != 0 + { + return Err(Error::new(InvalidData, "expected RGB565 mask values")); + } + + Ok(Self(red, green, blue, alpha)) + } + + /// Write the mask part to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + if self.0 != STCI_RGB565_RED_MASK + || self.1 != STCI_RGB565_GREEN_MASK + || self.2 != STCI_RGB565_BLUE_MASK && self.3 != 0 + { + return Err(Error::new(InvalidInput, "expected RGB565 mask values")); + } + + output.write_u32::(self.0)?; + output.write_u32::(self.1)?; + output.write_u32::(self.2)?; + output.write_u32::(self.3)?; + Ok(()) + } +} + +impl Default for StciMaskRgb { + fn default() -> Self { + Self( + STCI_RGB565_RED_MASK, + STCI_RGB565_GREEN_MASK, + STCI_RGB565_BLUE_MASK, + 0, + ) + } +} + +/// Color depth part of the rgb STCI header +/// +/// The mask includes color depths for all RGBA channels. +/// +/// We only support RGB565 without alpha channel right now. All other color depths will result +/// in an error. +#[derive(Debug, Clone, PartialEq)] +pub struct StciColorDepthRgb(pub u8, pub u8, pub u8, pub u8); + +impl StciColorDepthRgb { + /// Read the color depth part from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let red = input.read_u8()?; + let green = input.read_u8()?; + let blue = input.read_u8()?; + let alpha = input.read_u8()?; + + if red != 5 || green != 6 || blue != 5 || alpha != 0 { + return Err(Error::new( + InvalidData, + format!( + "only supporting RGB5650 color depth in rgb stci images, got RGB{}{}{}{} in header", + red, green, blue, alpha + ), + )); + } + + Ok(Self(red, green, blue, alpha)) + } + + /// Write the color depth part to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + if self.0 != 5 || self.1 != 6 || self.2 != 5 || self.3 != 0 { + return Err(Error::new( + InvalidInput, + format!( + "only supporting RGB5650 color depth in rgb stci images, got RGB{}{}{}{}", + self.0, self.1, self.2, self.3 + ), + )); + } + + output.write_u8(self.0)?; + output.write_u8(self.1)?; + output.write_u8(self.2)?; + output.write_u8(self.3)?; + Ok(()) + } +} + +impl Default for StciColorDepthRgb { + fn default() -> Self { + Self(5, 6, 5, 0) + } +} + +/// Rgb specific part of the STCI header +#[derive(Default, Debug, Clone, PartialEq)] +pub struct StciHeaderRgb { + pub mask: StciMaskRgb, + pub color_depth: StciColorDepthRgb, +} + +impl StciHeaderRgb { + /// Read the rgb specific part from input. + #[allow(dead_code)] + pub fn from_input(input: &mut T) -> Result + where + T: Read, + { + let mask = StciMaskRgb::from_input(input)?; + let color_depth = StciColorDepthRgb::from_input(input)?; + Ok(Self { mask, color_depth }) + } + + /// Write the rgb specific part to output. + #[allow(dead_code)] + pub fn to_output(&self, output: &mut T) -> Result<()> + where + T: Write, + { + self.mask.to_output(output)?; + self.color_depth.to_output(output)?; + Ok(()) + } +} diff --git a/src-map-editor/src/host.rs b/src-map-editor/src/host.rs new file mode 100644 index 0000000..559fc77 --- /dev/null +++ b/src-map-editor/src/host.rs @@ -0,0 +1,462 @@ +//! Communication with the JS host, i.e. the toolset renderer +//! +//! The host reads files (mod files layered over the vanilla data) and is told about the loading +//! status of maps and about the editor. It also forwards keyboard input from outside the canvas. +//! The JS callbacks are not `Send`, so they are kept in a thread local. This is fine because wasm +//! is single threaded. + +use std::{ + cell::RefCell, + io, + path::{Path, PathBuf}, + sync::Arc, +}; + +use bevy::{ + asset::io::{AssetReader, AssetReaderError, AssetSourceBuilder, PathStream, Reader, VecReader}, + input::{ + keyboard::{Key, KeyboardFocusLost, KeyboardInput, NativeKey}, + ButtonState, InputSystems, + }, + prelude::*, + reflect::{ + enums::{DynamicEnum, DynamicVariant, VariantInfo}, + TypeInfo, Typed, + }, + tasks::futures_lite::stream, + window::PrimaryWindow, +}; +use wasm_bindgen::JsValue; + +/// Name of the asset source that reads from the host, e.g. `ja2://maps/B13.DAT` +pub const JA2_ASSET_SOURCE: &str = "ja2"; + +struct Callbacks { + read_file: js_sys::Function, + files_exist: js_sys::Function, + on_status: js_sys::Function, + on_editor_event: js_sys::Function, +} + +/// The functions settling the promise of a `save_map` call +struct PendingSave { + resolve: js_sys::Function, + reject: js_sys::Function, +} + +/// The functions settling the promise of a `render_radar_map` call +struct PendingRadar { + map: String, + resolve: js_sys::Function, + reject: js_sys::Function, +} + +thread_local! { + static CALLBACKS: RefCell> = const { RefCell::new(None) }; + static PENDING_SAVES: RefCell> = const { RefCell::new(Vec::new()) }; + static PENDING_RADARS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +pub fn set_callbacks( + read_file: js_sys::Function, + files_exist: js_sys::Function, + on_status: js_sys::Function, + on_editor_event: js_sys::Function, +) { + CALLBACKS.with_borrow_mut(|callbacks| { + *callbacks = Some(Callbacks { + read_file, + files_exist, + on_status, + on_editor_event, + }) + }); +} + +/// Loading state of a map, reported to the host +#[derive(Clone, Copy, Debug)] +pub enum MapStatus { + Loading, + Loaded, + Error, +} + +impl MapStatus { + fn as_str(&self) -> &'static str { + match self { + MapStatus::Loading => "loading", + MapStatus::Loaded => "loaded", + MapStatus::Error => "error", + } + } +} + +/// Tells the host about the loading state of a map +pub fn report_status(status: MapStatus, map: &str, message: Option<&str>) { + let value = js_sys::Object::new(); + // Setting properties on a plain object cannot fail + let _ = js_sys::Reflect::set(&value, &"state".into(), &status.as_str().into()); + let _ = js_sys::Reflect::set(&value, &"map".into(), &map.into()); + if let Some(message) = message { + let _ = js_sys::Reflect::set(&value, &"message".into(), &message.into()); + } + CALLBACKS.with_borrow(|callbacks| { + if let Some(callbacks) = callbacks { + if let Err(e) = callbacks.on_status.call1(&JsValue::NULL, &value) { + error!("Error reporting map status: {:?}", e); + } + } + }); +} + +/// Tells the host about the editor, `event` is sent as a JSON object +pub fn report_editor_event(event: &impl serde::Serialize) { + CALLBACKS.with_borrow(|callbacks| { + let Some(callbacks) = callbacks else { + return; + }; + let value = match serde_json::to_string(event) + .map_err(|e| e.to_string()) + .and_then(|json| js_sys::JSON::parse(&json).map_err(|e| format!("{:?}", e))) + { + Ok(value) => value, + Err(e) => { + error!("Error serializing editor event: {}", e); + return; + } + }; + if let Err(e) = callbacks.on_editor_event.call1(&JsValue::NULL, &value) { + error!("Error reporting editor event: {:?}", e); + } + }); +} + +/// A map written by the editor, see [`request_save`] +pub struct SavedMap { + pub map: String, + pub revision: u64, + pub data: Vec, +} + +/// A promise that is settled with the next [`finish_saves`] +pub fn request_save() -> js_sys::Promise { + js_sys::Promise::new(&mut |resolve, reject| { + PENDING_SAVES.with_borrow_mut(|saves| saves.push(PendingSave { resolve, reject })); + }) +} + +/// Whether the host is waiting for the map to be saved +pub fn save_requested() -> bool { + PENDING_SAVES.with_borrow(|saves| !saves.is_empty()) +} + +/// Resolves the promises of [`request_save`] with the saved map, or rejects them with the error +pub fn finish_saves(result: Result) { + let saves = PENDING_SAVES.with_borrow_mut(std::mem::take); + let (settle, value): (fn(&PendingSave) -> &js_sys::Function, JsValue) = match result { + Ok(saved) => { + let value = js_sys::Object::new(); + // Setting properties on a plain object cannot fail + let _ = js_sys::Reflect::set(&value, &"map".into(), &saved.map.into()); + // Revisions stay far below 2^53 + let _ = + js_sys::Reflect::set(&value, &"revision".into(), &(saved.revision as f64).into()); + let data = js_sys::Uint8Array::from(saved.data.as_slice()); + let _ = js_sys::Reflect::set(&value, &"data".into(), &data); + (|save| &save.resolve, value.into()) + } + Err(message) => (|save| &save.reject, js_sys::Error::new(&message).into()), + }; + for save in &saves { + if let Err(e) = settle(save).call1(&JsValue::NULL, &value) { + error!("Error settling the save promise: {:?}", e); + } + } +} + +/// A promise that is settled with the radar map of `map` by [`finish_radars`] +pub fn request_radar(map: String) -> js_sys::Promise { + js_sys::Promise::new(&mut |resolve, reject| { + let map = map.clone(); + PENDING_RADARS.with_borrow_mut(|radars| { + radars.push(PendingRadar { + map, + resolve, + reject, + }) + }); + }) +} + +/// Whether the host is waiting for a radar map +pub fn radar_requested() -> bool { + PENDING_RADARS.with_borrow(|radars| !radars.is_empty()) +} + +/// The maps whose radar maps the host is waiting for, in the order of the requests +pub fn requested_radar_maps() -> Vec { + PENDING_RADARS.with_borrow(|radars| { + let mut maps: Vec = Vec::new(); + for radar in radars { + if !maps.contains(&radar.map) { + maps.push(radar.map.clone()); + } + } + maps + }) +} + +/// Resolves the promises of [`request_radar`] for `map` with the STI file of its radar map, or +/// rejects them with the error +pub fn finish_radars(map: &str, result: Result, String>) { + let radars = PENDING_RADARS.with_borrow_mut(|radars| { + let (finished, waiting): (Vec<_>, Vec<_>) = std::mem::take(radars) + .into_iter() + .partition(|radar| radar.map == map); + *radars = waiting; + finished + }); + let (settle, value): (fn(&PendingRadar) -> &js_sys::Function, JsValue) = match result { + Ok(data) => ( + |radar| &radar.resolve, + js_sys::Uint8Array::from(data.as_slice()).into(), + ), + Err(message) => (|radar| &radar.reject, js_sys::Error::new(&message).into()), + }; + for radar in &radars { + if let Err(e) = settle(radar).call1(&JsValue::NULL, &value) { + error!("Error settling the radar map promise: {:?}", e); + } + } +} + +/// Keyboard input forwarded by the host, see `crate::key_event` +#[derive(Debug)] +pub enum HostKey { + Key { + key_code: KeyCode, + /// The `key` of the web keyboard event + key: String, + pressed: bool, + repeat: bool, + }, + ReleaseAll, +} + +/// The key code of the `code` of a web keyboard event, `None` if it is not known +pub fn key_code(code: String) -> Option { + // Cmd and the Windows key, which winit maps the same way + match code.as_str() { + "MetaLeft" | "OSLeft" => return Some(KeyCode::SuperLeft), + "MetaRight" | "OSRight" => return Some(KeyCode::SuperRight), + _ => {} + } + // The other unit variants of `KeyCode` are named like the `code` values of web keyboard events + let TypeInfo::Enum(info) = KeyCode::type_info() else { + return None; + }; + if !matches!(info.variant(&code)?, VariantInfo::Unit(_)) { + return None; + } + KeyCode::from_reflect(&DynamicEnum::new(code, DynamicVariant::Unit)) +} + +/// Registers the host as the `ja2://` asset source, must be added before `AssetPlugin` +pub struct HostPlugin; + +impl Plugin for HostPlugin { + fn build(&self, app: &mut App) { + app.register_asset_source( + JA2_ASSET_SOURCE, + AssetSourceBuilder::new(|| Box::new(HostAssetReader)), + ) + .add_systems(PreUpdate, forward_host_keys.before(InputSystems)); + } +} + +/// Writes the keys forwarded by the host as if the window received them +fn forward_host_keys( + window: Query>, + mut keyboard_input: MessageWriter, + mut focus_lost: MessageWriter, +) { + let keys = crate::take_host_keys(); + let Ok(window) = window.single() else { + return; + }; + for key in keys { + match key { + HostKey::Key { + key_code, + key, + pressed, + repeat, + } => { + keyboard_input.write(KeyboardInput { + key_code, + logical_key: Key::Unidentified(NativeKey::Web(key.into())), + state: if pressed { + ButtonState::Pressed + } else { + ButtonState::Released + }, + text: None, + repeat, + window, + }); + } + HostKey::ReleaseAll => { + focus_lost.write(KeyboardFocusLost); + } + } + } +} + +/// Reads assets through the `read_file` callback of the host +struct HostAssetReader; + +fn io_error(message: String) -> AssetReaderError { + AssetReaderError::Io(Arc::new(io::Error::other(message))) +} + +fn host_path(path: &Path) -> Result { + let path = path + .to_str() + .ok_or_else(|| io_error(format!("path `{}` is not valid unicode", path.display())))?; + Ok(path.replace('\\', "/")) +} + +#[cfg(target_arch = "wasm32")] +fn js_error_message(value: &JsValue) -> String { + use wasm_bindgen::JsCast; + + value + .dyn_ref::() + .map(|e| String::from(e.message())) + .or_else(|| value.as_string()) + .unwrap_or_else(|| format!("{:?}", value)) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn read_from_host(path: &Path) -> Result, AssetReaderError> { + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let path_str = host_path(path)?; + let read_file = CALLBACKS + .with_borrow(|callbacks| callbacks.as_ref().map(|c| c.read_file.clone())) + .ok_or_else(|| io_error("host callbacks are not set".to_owned()))?; + let promise = read_file + .call1(&JsValue::NULL, &path_str.as_str().into()) + .map_err(|e| io_error(js_error_message(&e)))? + .dyn_into::() + .map_err(|_| io_error("read_file did not return a promise".to_owned()))?; + let value = JsFuture::from(promise).await.map_err(|e| { + io_error(format!( + "Error reading `{}`: {}", + path_str, + js_error_message(&e) + )) + })?; + if value.is_null() { + return Err(AssetReaderError::NotFound(path.to_owned())); + } + let bytes = value + .dyn_into::() + .map_err(|_| io_error("read_file did not resolve to a Uint8Array or null".to_owned()))?; + Ok(bytes.to_vec()) +} + +/// JS futures are not `Send`, which native Bevy requires. Natively there is no host anyways. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn read_from_host(path: &Path) -> Result, AssetReaderError> { + Err(io_error(format!( + "cannot read `{}`, reading from the host is only supported on wasm", + host_path(path)? + ))) +} + +/// Asks the host which of the files exist, relative to the data root +#[cfg(target_arch = "wasm32")] +pub async fn files_exist(paths: Vec) -> Result, String> { + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let files_exist = CALLBACKS + .with_borrow(|callbacks| callbacks.as_ref().map(|c| c.files_exist.clone())) + .ok_or_else(|| "host callbacks are not set".to_owned())?; + let paths_array: js_sys::Array = paths.iter().map(|p| JsValue::from_str(p)).collect(); + let promise = files_exist + .call1(&JsValue::NULL, &paths_array) + .map_err(|e| js_error_message(&e))? + .dyn_into::() + .map_err(|_| "files_exist did not return a promise".to_owned())?; + let value = JsFuture::from(promise) + .await + .map_err(|e| format!("Error checking which files exist: {}", js_error_message(&e)))?; + let exists = value + .dyn_into::() + .map_err(|_| "files_exist did not resolve to an array".to_owned())? + .iter() + .map(|v| { + v.as_bool() + .ok_or_else(|| "files_exist did not resolve to an array of booleans".to_owned()) + }) + .collect::, _>>()?; + if exists.len() != paths.len() { + return Err(format!( + "files_exist returned {} results for {} files", + exists.len(), + paths.len() + )); + } + Ok(exists) +} + +/// See `read_from_host`, natively there is no host +#[cfg(not(target_arch = "wasm32"))] +pub async fn files_exist(_paths: Vec) -> Result, String> { + Err("checking whether files exist is only supported on wasm".to_owned()) +} + +impl AssetReader for HostAssetReader { + async fn read<'a>(&'a self, path: &'a Path) -> Result { + Ok(VecReader::new(read_from_host(path).await?)) + } + + async fn read_meta<'a>(&'a self, path: &'a Path) -> Result { + // JA2 assets have no meta files + Err::(AssetReaderError::NotFound(path.to_owned())) + } + + async fn read_directory<'a>( + &'a self, + _path: &'a Path, + ) -> Result, AssetReaderError> { + // Directories are never listed, all paths are known up front + Ok(Box::new(stream::empty::())) + } + + async fn is_directory<'a>(&'a self, _path: &'a Path) -> Result { + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_code_should_map_web_codes() { + assert_eq!(key_code("KeyW".to_owned()), Some(KeyCode::KeyW)); + assert_eq!(key_code("F3".to_owned()), Some(KeyCode::F3)); + assert_eq!(key_code("Equal".to_owned()), Some(KeyCode::Equal)); + assert_eq!(key_code("NumpadAdd".to_owned()), Some(KeyCode::NumpadAdd)); + assert_eq!(key_code("ArrowUp".to_owned()), Some(KeyCode::ArrowUp)); + assert_eq!(key_code("MetaLeft".to_owned()), Some(KeyCode::SuperLeft)); + assert_eq!(key_code("MetaRight".to_owned()), Some(KeyCode::SuperRight)); + assert_eq!(key_code("NotAKey".to_owned()), None); + assert_eq!(key_code("Unidentified".to_owned()), None); + assert_eq!(key_code(String::new()), None); + } +} diff --git a/src-map-editor/src/item/mod.rs b/src-map-editor/src/item/mod.rs new file mode 100644 index 0000000..73063e3 --- /dev/null +++ b/src-map-editor/src/item/mod.rs @@ -0,0 +1,521 @@ +//! The items of the game, as far as the editor needs them: their graphics on the ground, how +//! soldiers hold them and what new world items get (`ItemModel` of the game) +//! +//! The items are read from the externalized item files (`ITEM_FILES`). + +use std::{collections::HashMap, io}; + +use bevy::{ + asset::{io::Reader, AssetLoader, LoadContext}, + prelude::*, +}; +use serde::Deserialize; + +use crate::file_formats::ja2_tile_types::{TileType, GUNS, P1ITEMS, P2ITEMS, P3ITEMS}; + +/// Registers the item file asset and its loader +pub struct ItemPlugin; + +impl Plugin for ItemPlugin { + fn build(&self, app: &mut App) { + app.init_asset::() + .init_asset_loader::(); + } +} + +/// An item (`usItem`), the item index of the item files +/// +/// Item 0 (`NOTHING`) is not an item, empty inventory slots and attachments are `None`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ItemId(u16); + +impl ItemId { + /// Items that are not moved onto structures (`OWNERSHIP`, `ACTION_ITEM`) + pub const OWNERSHIP: Self = Self(240); + pub const ACTION_ITEM: Self = Self(234); + /// Panic triggers and triggers of action items + pub const SWITCH: Self = Self(233); + pub const ROCKET_LAUNCHER: Self = Self(51); + pub const TANK_CANNON: Self = Self(60); + + /// The item with the given index, `None` for `NOTHING` + pub const fn new(index: u16) -> Option { + if index == 0 { + None + } else { + Some(Self(index)) + } + } + + pub const fn index(self) -> u16 { + self.0 + } +} + +/// Files with the tile graphics of the items, earlier files win (`DefaultContentManager`) +pub const ITEM_FILES: [&str; 5] = [ + "weapons.json", + "magazines.json", + "armours.json", + "explosives.json", + "items.json", +]; + +/// Item classes (`IC_*`) +pub const IC_NONE: u32 = 0x0001; +pub const IC_GUN: u32 = 0x0002; +pub const IC_BLADE: u32 = 0x0004; +pub const IC_THROWING_KNIFE: u32 = 0x0008; +pub const IC_LAUNCHER: u32 = 0x0010; +pub const IC_THROWN: u32 = 0x0040; +pub const IC_PUNCH: u32 = 0x0080; +pub const IC_GRENADE: u32 = 0x0100; +pub const IC_BOMB: u32 = 0x0200; +pub const IC_AMMO: u32 = 0x0400; +pub const IC_ARMOUR: u32 = 0x0800; +pub const IC_MEDKIT: u32 = 0x1000; +pub const IC_KIT: u32 = 0x2000; +pub const IC_FACE: u32 = 0x8000; +pub const IC_KEY: u32 = 0x10000; +pub const IC_MISC: u32 = 0x1000_0000; +pub const IC_MONEY: u32 = 0x2000_0000; + +/// The ammo types of the game by their index (`ammo-types.json`), magazines of other types are +/// treated as regular ammo +const AMMO_TYPES: [&str; 13] = [ + "AMMO_REGULAR", + "AMMO_HP", + "AMMO_AP", + "AMMO_SUPER_AP", + "AMMO_BUCKSHOT", + "AMMO_FLECHETTE", + "AMMO_GRENADE", + "AMMO_MONSTER", + "AMMO_KNIFE", + "AMMO_HE", + "AMMO_HEAT", + "AMMO_SLEEP_DART", + "AMMO_FLAME", +]; + +#[derive(Clone, Debug, Deserialize)] +struct TileGraphic { + #[serde(rename = "type")] + tile_type: String, + #[serde(rename = "subIndex")] + sub_index: u16, +} + +#[derive(Clone, Debug, Deserialize)] +struct ItemEntry { + #[serde(rename = "itemIndex")] + item_index: u16, + #[serde(rename = "internalName")] + internal_name: Option, + #[serde(rename = "usItemClass")] + item_class: Option, + /// Weapon type, only in `weapons.json` + #[serde(rename = "internalType")] + weapon_type: Option, + #[serde(rename = "bTwoHanded", default)] + two_handed: bool, + #[serde(rename = "tileGraphic")] + tile_graphic: Option, + /// Item class of the explosives, `GRENADE` or `BOMB` + #[serde(rename = "itemClass")] + explosive_class: Option, + #[serde(rename = "ubClassIndex", default)] + class_index: u16, + #[serde(rename = "ubPerPocket", default)] + per_pocket: u8, + #[serde(rename = "ubMagSize", default)] + mag_size: u8, + #[serde(default)] + capacity: u8, + calibre: Option, + #[serde(rename = "ammoType")] + ammo_type: Option, + #[serde(rename = "dontUseAsDefaultMagazine", default)] + not_default_magazine: bool, + #[serde(rename = "bNotEditor", default)] + not_editor: bool, + #[serde(rename = "bSinks", default)] + sinks: bool, + #[serde(rename = "bDefaultUndroppable", default)] + default_undroppable: bool, +} + +/// What new world items of an item get and whether the editor offers it +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ItemKind { + /// Item class (`IC_*`) + pub class: u32, + /// `ubClassIndex`, the key id of keys + pub class_index: u16, + pub per_pocket: u8, + /// Rounds of a full gun + pub mag_size: u8, + /// Rounds of a magazine + pub capacity: u8, + /// Calibre of guns and magazines + pub calibre: Option, + /// Index of the ammo type of magazines + pub ammo_type: u8, + /// Magazines that `DefaultMagazine` skips + pub not_default_magazine: bool, + /// Items the editor doesn't offer (`ITEM_NOT_EDITOR`) + pub not_editor: bool, + /// Items that are lost in water (`ITEM_SINKS`) + pub sinks: bool, + /// Items that stay with dead soldiers (`ITEM_DEFAULT_UNDROPPABLE`) + pub default_undroppable: bool, +} + +/// The item class of a weapon type of `weapons.json` (`WeaponModel::deserialize`) +fn weapon_class(weapon_type: &str) -> u32 { + match weapon_type { + "BLADE" => IC_BLADE, + "THROWINGBLADE" => IC_THROWING_KNIFE, + "PUNCH" | "PUNCHWEAPON" => IC_PUNCH, + "THROWN" => IC_THROWN, + "LAUNCHER" => IC_LAUNCHER, + "NOWEAPON" => IC_NONE, + _ => IC_GUN, + } +} + +/// How a soldier holds an item in the hand, for the standing animation +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HandItem { + /// A pistol or another one-handed gun + OneHandedGun, + /// A rifle or another two-handed gun or launcher + TwoHandedGun, +} + +/// An item of an items file +#[derive(Clone, Debug, PartialEq)] +pub struct ItemInfo { + pub index: ItemId, + /// Tile type and sub index + pub graphic: Option<(TileType, u16)>, + /// Set for guns and launchers (`IC_GUN`, `IC_LAUNCHER`) except the rocket launcher + pub hand: Option, + pub kind: ItemKind, +} + +/// The items of one items file +#[derive(Asset, TypePath, Clone, Debug, Default, PartialEq)] +pub struct ItemFile { + pub items: Vec, +} + +impl ItemFile { + /// Parses one of the [`ITEM_FILES`], by its lower case name, whose items get their class + /// from the file + pub fn parse(bytes: &[u8], file: &str) -> io::Result { + let items_json = file == "items.json"; + let entries: Vec = serde_json::from_slice(bytes)?; + let skipped = if items_json { + IC_NONE | IC_GRENADE | IC_BOMB | IC_ARMOUR + } else { + IC_NONE + }; + let items = entries + .into_iter() + .filter(|entry| entry.item_class.is_none_or(|class| class & skipped == 0)) + .filter_map(|entry| { + // `NOTHING` is not an item + let index = ItemId::new(entry.item_index)?; + let graphic = entry.tile_graphic.and_then(|graphic| { + let tile_type = match graphic.tile_type.as_str() { + "GUNS" => GUNS, + "P1ITEMS" => P1ITEMS, + "P2ITEMS" => P2ITEMS, + "P3ITEMS" => P3ITEMS, + other => { + warn!( + "Unsupported tile type {} of item {}", + other, entry.item_index + ); + return None; + } + }; + Some((tile_type, graphic.sub_index)) + }); + let is_gun = matches!( + entry.weapon_type.as_deref(), + Some( + "PISTOL" + | "M_PISTOL" + | "SMG" + | "RIFLE" + | "SN_RIFLE" + | "ASRIFLE" + | "LMG" + | "SHOTGUN" + | "LAUNCHER" + | "LAW" + | "CANNON" + ) + ) && entry.internal_name.as_deref() != Some("ROCKET_LAUNCHER"); + let hand = is_gun.then_some(if entry.two_handed { + HandItem::TwoHandedGun + } else { + HandItem::OneHandedGun + }); + let class = match file { + "weapons.json" => weapon_class(entry.weapon_type.as_deref().unwrap_or("")), + "magazines.json" => IC_AMMO, + "armours.json" => IC_ARMOUR, + "explosives.json" => match entry.explosive_class.as_deref() { + Some("BOMB") => IC_BOMB, + _ => IC_GRENADE, + }, + _ => entry.item_class.unwrap_or(IC_NONE), + }; + let ammo_type = entry.ammo_type.as_deref().map_or(0, |name| { + AMMO_TYPES.iter().position(|&t| t == name).unwrap_or(0) as u8 + }); + let kind = ItemKind { + class, + class_index: entry.class_index, + per_pocket: entry.per_pocket, + mag_size: entry.mag_size, + capacity: entry.capacity, + calibre: entry.calibre, + ammo_type, + not_default_magazine: entry.not_default_magazine, + not_editor: entry.not_editor, + sinks: entry.sinks, + default_undroppable: entry.default_undroppable, + }; + Some(ItemInfo { + index, + graphic, + hand, + kind, + }) + }) + .collect(); + Ok(Self { items }) + } +} + +#[derive(Default, TypePath)] +pub struct ItemFileLoader; + +impl AssetLoader for ItemFileLoader { + type Asset = ItemFile; + type Settings = (); + type Error = io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + load_context: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + let file = load_context + .path() + .path() + .file_name() + .map(|name| name.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + ItemFile::parse(&bytes, &file) + } + + fn extensions(&self) -> &[&str] { + &["json"] + } +} + +/// Tile graphics, hand items and kinds of all items +#[derive(Resource, Clone, Debug, Default)] +pub struct ItemTable { + graphics: HashMap, + hands: HashMap, + kinds: HashMap, + /// The magazines in the order of the files, which `DefaultMagazine` searches + magazines: Vec, +} + +impl ItemTable { + /// Combines the item files, in the order of [`ITEM_FILES`] + pub fn new<'a>(files: impl IntoIterator) -> Self { + let mut table = Self::default(); + for file in files { + for item in &file.items { + if table.graphics.contains_key(&item.index) { + continue; + } + // Items without a graphic show the first gun (`TilesetTileIndexModel`) + table + .graphics + .insert(item.index, item.graphic.unwrap_or((GUNS, 1))); + if let Some(hand) = item.hand { + table.hands.insert(item.index, hand); + } + if item.kind.class == IC_AMMO { + table.magazines.push(item.index); + } + table.kinds.insert(item.index, item.kind.clone()); + } + } + table + } + + /// Tile type and sub index of the graphic of an item on the ground, `None` if the item is + /// not in any items file + pub fn graphic(&self, item: ItemId) -> Option<(TileType, u16)> { + self.graphics.get(&item).copied() + } + + /// How an item is held, `None` if it is not a gun + pub fn hand(&self, item: ItemId) -> Option { + self.hands.get(&item).copied() + } + + /// The class and the values of an item, `None` if it is not in any items file + pub fn info(&self, item: ItemId) -> Option<&ItemKind> { + self.kinds.get(&item) + } + + /// The magazine a new gun is loaded with: the first one of its calibre that holds a full gun + /// (`DefaultMagazine`), `None` if there is none + pub fn default_magazine(&self, gun: ItemId) -> Option { + let gun = self.info(gun)?; + let calibre = gun.calibre.as_ref()?; + self.magazines.iter().copied().find(|&magazine| { + self.info(magazine).is_some_and(|mag| { + !mag.not_default_magazine + && mag.calibre.as_ref() == Some(calibre) + && mag.capacity == gun.mag_size + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(index: u16) -> ItemId { + ItemId::new(index).unwrap() + } + + #[test] + fn parses_item_files() { + let json = br#"[ + { "itemIndex": 0, "internalName": "NOTHING" }, + { "itemIndex": 2, "internalType": "PISTOL", "calibre": "AMMO9", "ubMagSize": 15, + "tileGraphic": { "type": "GUNS", "subIndex": 3 } }, + { "itemIndex": 5, "usItemClass": 256, "tileGraphic": { "type": "P1ITEMS", "subIndex": 1 } }, + { "itemIndex": 6, "usItemClass": 4096, "tileGraphic": { "type": "P3ITEMS", "subIndex": 4 } }, + { "itemIndex": 7, "internalType": "RIFLE", "bTwoHanded": true } + ]"#; + let file = ItemFile::parse(json, "items.json").unwrap(); + let summary: Vec<_> = file + .items + .iter() + .map(|item| (item.index, item.graphic, item.hand)) + .collect(); + assert_eq!( + summary, + vec![ + (id(2), Some((GUNS, 3)), Some(HandItem::OneHandedGun)), + (id(6), Some((P3ITEMS, 4)), None), + (id(7), None, Some(HandItem::TwoHandedGun)), + ] + ); + // Grenades are only skipped in items.json + assert_eq!( + ItemFile::parse(json, "weapons.json").unwrap().items.len(), + 4 + ); + + let other = ItemFile { + items: vec![ + ItemInfo { + index: id(2), + graphic: Some((P1ITEMS, 1)), + hand: None, + kind: ItemKind::default(), + }, + ItemInfo { + index: id(8), + graphic: Some((P2ITEMS, 2)), + hand: None, + kind: ItemKind::default(), + }, + ], + }; + let table = ItemTable::new([&file, &other]); + assert_eq!(table.graphic(id(2)), Some((GUNS, 3))); + assert_eq!(table.graphic(id(7)), Some((GUNS, 1))); + assert_eq!(table.graphic(id(8)), Some((P2ITEMS, 2))); + assert_eq!(table.graphic(id(9)), None); + assert_eq!(table.hand(id(2)), Some(HandItem::OneHandedGun)); + assert_eq!(table.hand(id(8)), None); + // Items without a class in the file are no items + assert_eq!(table.info(id(7)).map(|kind| kind.class), Some(IC_NONE)); + assert_eq!(table.info(id(6)).map(|kind| kind.class), Some(IC_MEDKIT)); + } + + #[test] + fn items_get_their_class_from_the_file() { + let weapons = br#"[ + { "itemIndex": 1, "internalType": "PISTOL", "calibre": "AMMO9", "ubMagSize": 15, + "bSinks": true }, + { "itemIndex": 2, "internalType": "BLADE", "bNotEditor": true }, + { "itemIndex": 3, "internalType": "LAUNCHER" } + ]"#; + let magazines = br#"[ + { "itemIndex": 10, "calibre": "AMMO9", "capacity": 15, "ammoType": "AMMO_AP", + "dontUseAsDefaultMagazine": true }, + { "itemIndex": 11, "calibre": "AMMO9", "capacity": 30, "ammoType": "AMMO_REGULAR" }, + { "itemIndex": 12, "calibre": "AMMO9", "capacity": 15, "ammoType": "AMMO_HP", + "ubPerPocket": 8 } + ]"#; + let explosives = br#"[ + { "itemIndex": 20, "itemClass": "BOMB" }, + { "itemIndex": 21, "itemClass": "GRENADE" } + ]"#; + let items = br#"[ + { "itemIndex": 30, "usItemClass": 65536, "ubClassIndex": 4, + "bDefaultUndroppable": true } + ]"#; + let table = ItemTable::new(&[ + ItemFile::parse(weapons, "weapons.json").unwrap(), + ItemFile::parse(magazines, "magazines.json").unwrap(), + ItemFile::parse(explosives, "explosives.json").unwrap(), + ItemFile::parse(items, "items.json").unwrap(), + ]); + let class = |index| table.info(id(index)).unwrap().class; + assert_eq!( + [1, 2, 3, 10, 20, 21, 30].map(class), + [ + IC_GUN, + IC_BLADE, + IC_LAUNCHER, + IC_AMMO, + IC_BOMB, + IC_GRENADE, + IC_KEY + ] + ); + let pistol = table.info(id(1)).unwrap(); + assert!(pistol.sinks && !pistol.not_editor); + assert!(table.info(id(2)).unwrap().not_editor); + assert_eq!(table.info(id(12)).unwrap().ammo_type, 1); + assert_eq!(table.info(id(12)).unwrap().per_pocket, 8); + let key = table.info(id(30)).unwrap(); + assert_eq!(key.class_index, 4); + assert!(key.default_undroppable); + // The first magazine of the calibre that fills the gun and may be used + assert_eq!(table.default_magazine(id(1)), Some(id(12))); + assert_eq!(table.default_magazine(id(3)), None); + } +} diff --git a/src-map-editor/src/lib.rs b/src-map-editor/src/lib.rs new file mode 100644 index 0000000..7091452 --- /dev/null +++ b/src-map-editor/src/lib.rs @@ -0,0 +1,655 @@ +//! JA2 tactical map viewer, compiled to wasm and embedded in the toolset renderer +//! +//! The toolset starts the Bevy app once per page with [`start`] and keeps it alive. Maps are +//! switched with [`load_map`] or [`new_map`], because winit can only create its event loop once +//! per page. + +// Bevy systems commonly take many parameters and complex queries +#![allow(clippy::too_many_arguments, clippy::type_complexity)] + +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, + }, + time::Duration, +}; + +use bevy::{ + asset::{embedded_asset, AssetMetaCheck}, + diagnostic::FrameTimeDiagnosticsPlugin, + prelude::*, + winit::{UpdateMode, WinitSettings}, +}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; + +use editor::{ + commands::{DoorRequest, EditorCommand}, + item_events::{ItemCommand, ItemEdit}, + light_events::{LightCommand, LightEdit}, + mode::EditorMode, + soldier_events::{SoldierCommand, SoldierEdit}, + traversal_events::TraversalEdit, + EditorPlugin, +}; +use file_formats::{ja2_tileset::Ja2Tilesets, Ja2AssetsPlugin}; +use host::{HostKey, HostPlugin}; +use map::EditorSmoothing; +use render::RenderOptions; +use states::{AppState, DebugOverlay, LoadingPhase, UiFont}; + +pub mod components; +pub mod editor; +pub mod file_formats; +mod host; +pub mod item; +pub mod light; +pub mod map; +pub mod random; +pub mod render; +pub mod soldier; +mod states; +mod systems; +pub mod tileset; + +/// Map that should be loaded next, set by [`load_map`] or [`new_map`] and picked up by the app +static REQUESTED_MAP: Mutex> = Mutex::new(None); +/// Render options that should be applied next, set by [`set_render_options`] +static REQUESTED_RENDER_OPTIONS: Mutex> = Mutex::new(None); +/// Editor mode that should be used next, set by [`set_editor_mode`] +static REQUESTED_EDITOR_MODE: Mutex> = Mutex::new(None); +/// Editor commands in the order they were requested, e.g. by [`undo`] +static EDITOR_COMMANDS: Mutex> = Mutex::new(Vec::new()); +/// Keyboard input forwarded by the host, see [`key_event`] +static HOST_KEYS: Mutex> = Mutex::new(Vec::new()); +/// Whether the viewer is currently shown, set by [`set_active`] +static ACTIVE: AtomicBool = AtomicBool::new(true); + +/// A map that the host asked for +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum MapRequest { + /// The map file at the path, relative to the data root + File(String), + /// A new map that is saved at the path, see [`new_map`] + New { + path: String, + options: NewMapOptions, + }, +} + +impl MapRequest { + /// Path of the map, relative to the data root + pub(crate) fn path(&self) -> &str { + match self { + MapRequest::File(path) | MapRequest::New { path, .. } => path, + } + } +} + +/// What a new map looks like, see [`new_map`] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +pub struct NewMapOptions { + pub tileset: u8, + pub smoothing: EditorSmoothing, +} + +/// How often the app updates while the viewer is not shown +const INACTIVE_UPDATE_INTERVAL: Duration = Duration::from_secs(1); + +/// Background color behind the map +pub(crate) const CLEAR_COLOR: Color = Color::linear_rgb(0.34, 0.36, 0.52); + +/// Starts the viewer on the canvas matching `canvas_selector` +/// +/// `read_file(path: string): Promise` reads a file relative to the data root, +/// `null` if it does not exist. +/// `files_exist(paths: string[]): Promise` checks which files exist. +/// `on_status(status: { state: 'loading' | 'loaded' | 'error', map: string, message?: string })` +/// is called when a map starts loading, finishes loading or fails to load. Maps are loaded with +/// [`load_map`] or created with [`new_map`]. +/// `on_editor_event(event)` is called with the palettes once a map is loaded +/// (`{ type: 'palette', map, tileset, ground, roads, debris, trees, rocks, misc, buildings }`, +/// `buildings` has the palettes of the buildings mode) and whenever the state of the editor +/// changes (`{ type: 'state', map, canUndo, undoDepth, canRedo, redoDepth, background, +/// nextRoomNumber, smoothing, restrictedScroll, revision }`, `smoothing` is `normal`, `basement` or +/// `caves`, `restrictedScroll` whether the game only scrolls over the middle of the map, `revision` counts the changes of the +/// map since it was loaded, see [`save_map`]). Clicking a door with the door locks tool +/// asks to edit it (`{ type: 'door', map, gridNo, door }`, `door` like in [`set_door`] or `null` +/// for a door without lock). The soldiers mode gets its choices once a map is loaded (`{ type: +/// 'soldierPalette', map, maxSoldiers, maxTeamSoldiers, enemyClasses, bodies, civilianGroups, +/// colours }`) and the selected soldier whenever it changes (`{ type: 'soldier', map, soldier, +/// counts, schedule, pick }`, `soldier` is `null` if none is +/// selected, see `SoldierValue`, `schedule` has the 4 actions of its schedule and `pick` the tile +/// of the schedule that the next click picks). The items mode gets the selected tile whenever it, +/// its items or the items of the map change (`{ type: 'item', map, tile, items, counts }`, `tile` +/// is `null` if none is selected, `items` are the items shown on it in the order of the map, see +/// `ItemValue`, `counts` has the number of placed items by item +/// (`items: [{ item, placed, quantity }]`), by trigger slot (`triggers`) and by lock (`keys: +/// [{ lockId, placed }]`)). The lights mode gets the selected light and the lighting of the map +/// whenever they change (`{ type: 'light', map, light, count, color, ambient, radii }`, `light` is +/// `null` if none is selected or `{ id, gridNo, x, y, radius, template, time }`, `radius` is +/// `null` for templates that are not `L-R0N.LHT`, `time` is `always`, `night` or `primeTime`, +/// `color` is `[r, g, b]`, `ambient` is `{ basement, caves, level }` for basements and caves and +/// `null` otherwise, with the light `level` of the original editor (1 to 15, higher is brighter), +/// and `radii` are the radii whose templates exist). The traversal mode gets the exit grids, the +/// entry points, the selection and the edge points whenever they change (`{ type: 'traversal', +/// map, exitGrids, selected, queried, entryPoints, edgePoints }`, `exitGrids` is their number, +/// `selected` is `null`, `{ kind: 'exitGrid', gridNo, destSector, destGridNo }` or `{ kind: +/// 'entry', point, gridNo }`, `queried` is the exit grid that was right clicked last, `null` or `{ gridNo, destSector: { x, y, z }, destGridNo }`, +/// `entryPoints` is `{ north, east, south, west, center, isolated }` with grid numbers or `null`, +/// `edgePoints` is `{ primary, secondary, missing, pending }`, the numbers of edge points of each +/// side (`{ north, east, south, west }`), the sides with an entry point but no edge points and +/// whether they are being generated again). Edits that can't be done are reported (`{ type: +/// 'notice', map, message }`). +/// +/// This must only be called once per page. +#[wasm_bindgen] +pub fn start( + canvas_selector: String, + read_file: js_sys::Function, + files_exist: js_sys::Function, + on_status: js_sys::Function, + on_editor_event: js_sys::Function, +) { + host::set_callbacks(read_file, files_exist, on_status, on_editor_event); + + App::new() + // Asset sources have to be registered before the `AssetPlugin` + .add_plugins(HostPlugin) + .add_plugins( + DefaultPlugins + .set(WindowPlugin { + primary_window: Some(Window { + title: "Ja2 Map Viewer".into(), + canvas: Some(canvas_selector), + fit_canvas_to_parent: true, + // Keep keyboard input working in the rest of the page + prevent_default_event_handling: false, + ..default() + }), + ..default() + }) + .set(AssetPlugin { + // None of the JA2 assets have .meta files, don't look for them + meta_check: AssetMetaCheck::Never, + ..default() + }), + ) + .add_plugins(FrameTimeDiagnosticsPlugin::default()) + .add_plugins(embed_shaders) + .insert_resource(ClearColor(CLEAR_COLOR)) + .insert_resource(WinitSettings::continuous()) + .init_resource::() + .init_state::() + .add_sub_state::() + .add_sub_state::() + .add_plugins(( + Ja2AssetsPlugin, + tileset::TilesetPlugin, + item::ItemPlugin, + light::LightPlugin, + states::loading::LoadingPlugin, + states::map::MapPlugin, + states::tile_debug::TileDebugPlugin, + render::RenderPlugin, + soldier::SoldierPlugin, + systems::camera::CameraPlugin, + systems::pixel_view::PixelViewPlugin, + systems::bake::BakePlugin, + systems::radar::RadarPlugin, + systems::cursor::CursorPlugin, + EditorPlugin, + )) + .add_plugins(systems::configure_map_systems) + .add_systems(Update, apply_active) + // Returns immediately on wasm, the event loop is driven by the browser + .run(); +} + +/// Requests loading the map at `path`, relative to the data root (e.g. `maps/A9.dat`) +#[wasm_bindgen] +pub fn load_map(path: String) { + request_map(MapRequest::File(path)); +} + +/// Requests a new map that is saved at `path`, relative to the data root (e.g. `maps/A9.dat`) +/// +/// `options` is JSON like `{ "tileset": 0, "smoothing": "normal" }`, `smoothing` is `normal`, +/// `basement` or `caves`. Like the original editor, every tile gets a random texture, basements +/// are roofed everywhere and caves are rock everywhere. The map is not read from `path` and not +/// written anywhere until it is saved with [`save_map`]. It is reported like a loaded map, a +/// tileset that does not exist is reported as an error. +#[wasm_bindgen] +pub fn new_map(path: String, options: String) -> Result<(), JsError> { + let options: NewMapOptions = serde_json::from_str(&options)?; + request_map(MapRequest::New { path, options }); + Ok(()) +} + +fn request_map(request: MapRequest) { + *REQUESTED_MAP.lock().unwrap_or_else(|e| e.into_inner()) = Some(request); +} + +/// A tileset of [`TilesetList`] +#[derive(Serialize)] +struct TilesetEntry { + id: u8, + name: String, +} + +/// What [`tilesets`] resolves with +#[derive(Serialize)] +struct TilesetList { + default: u8, + tilesets: Vec, +} + +/// Lists the tilesets of `binarydata/ja2set.dat`, e.g. for choosing the tileset of a new map +/// +/// Resolves with `{ default, tilesets: [{ id, name }] }`, `default` is the id of the tileset that +/// the others fall back to (`GENERIC_1`, or the default tileset of JA2.5 data). Rejects if the +/// file can't be read. Only needs [`start`] to have been called, not a loaded map. +#[wasm_bindgen] +pub fn tilesets() -> js_sys::Promise { + wasm_bindgen_futures::future_to_promise(async { + let path = std::path::Path::new("binarydata/ja2set.dat"); + let bytes = host::read_from_host(path) + .await + .map_err(|e| JsError::new(&format!("Error reading the tilesets: {}", e)))?; + let tilesets = Ja2Tilesets::parse(&bytes) + .map_err(|e| JsError::new(&format!("Error reading the tilesets: {}", e)))?; + let list = TilesetList { + default: tilesets.default_tileset_id().number(), + tilesets: tilesets + .ids() + .filter_map(|id| { + Some(TilesetEntry { + id: id.number(), + name: tilesets.get(id.index())?.name.clone(), + }) + }) + .collect(), + }; + let json = serde_json::to_string(&list).map_err(|e| JsError::new(&e.to_string()))?; + js_sys::JSON::parse(&json) + }) +} + +/// Sets what the viewer shows +/// +/// `options` is JSON like `{ "layers": { "terrain": true, "objects": true, "buildings": true, +/// "items": true, "soldiers": true, "soldierTeams": { "enemy": true, "creature": true, "rebel": +/// true, "civilian": true }, "roomNumbers": false }, "floor": "ground", "lighting": "day" }`, +/// `lighting` is `day` or `night`. `terrain`, `objects` and `buildings` show what the editing mode +/// of that name places, with the shadows (the floors with the `terrain`), `roomNumbers` shows the +/// room numbers of the tiles. The soldiers of teams hidden in `soldierTeams` are neither drawn +/// nor selected or erased by the soldiers mode. +/// `floor` is `ground` or `roof`: the roofs, what is on them and the topmost tiles are only drawn +/// on the roof, and only soldiers and items on the floor can be selected, placed, moved or +/// erased. The terrain, objects, lights and traversal modes only edit on the ground. Page +/// Up and Page Down switch the floor, which is reported (`{ type: 'floor', map, floor }`). +/// Missing values keep their defaults. +#[wasm_bindgen] +pub fn set_render_options(options: String) -> Result<(), JsError> { + let options: RenderOptions = serde_json::from_str(&options)?; + *REQUESTED_RENDER_OPTIONS + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(options); + Ok(()) +} + +/// Sets the editing mode, the latest one wins +/// +/// `mode` is JSON like `{ "mode": "view" }`, `{ "mode": "terrain", "tool": "ground", "erase": +/// false, "brush": { "kind": "square", "size": 3 }, "density": 20, "ground": { "selected": +/// "TEXTURE1", "weights": [{ "typeName": "TEXTURE2", "weight": 1 }] } }`, `{ "mode": "terrain", +/// "tool": "roads", "erase": false, "road": 0 }`, `{ "mode": "terrain", "tool": "cliffs", +/// "erase": false, "cliff": 1 }` (roads and cliffs ignore the brush, `cliff` is the sub index of +/// the `FIRSTCLIFF` piece, the height of the land is worked out again after every cliff edit), +/// `{ "mode": "objects", +/// "erase": false, "brush": { "kind": "square", "size": 3 }, "density": 20, "objects": +/// [{ "tileId": 1234, "count": 1 }] }` or `{ "mode": "buildings", "tool": "room", "erase": false, +/// "brush": { "kind": "area" }, "selections": { "room": [{ "tileId": 1234, "count": 1 }], +/// "tiles": [] }, "smart": { "kind": "tile", "tileId": 1237 } }`. +/// +/// The buildings `tool` is `tiles`, +/// which places single tiles of the `tiles` selection (of the walls, doors, windows, roofs, +/// broken walls, decals and floors palettes) in the layers of their pieces and when erasing +/// clears the layers of the selected kinds, `smart`, which +/// places what the `smart` value says (a tile of the smart palettes, whose piece tells whether it +/// is a wall, door, window, broken wall or roof, or `{ "kind": "hole" }`; roofs replace the +/// roof of the clicked building) and when erasing restores doors, windows and broken walls and +/// removes plain walls, or `room`, which uses the `room` selection (and saws the room out of the +/// buildings when erasing), `select`, which selects the clicked building (Delete removes it, dragging moves +/// it and Ctrl+dragging, or Cmd+dragging on macOS, copies it), or `roomNumber`, which gives the tiles the `roomNumber` of +/// the mode (0 is no room), `caves`, which fills the area with rock and carves caves with the +/// right button, or `doorLocks`, which asks the host to edit the clicked door. +/// +/// `{ "mode": "items", "tool": "place", "erase": false, "brush": { "kind": "square", "size": 3 }, +/// "choice": { "kind": "item", "item": 2 }, "trapLevel": 9, "action": 12 }` +/// places items with left clicks (on flat roofs on the roof floor), or erases the items of the +/// brush with `erase`. The `select` +/// tool selects the clicked tile with its items, dragging moves them, Ctrl+dragging (Cmd+dragging +/// on macOS) copies them and Delete removes them. The `choice` is `{ "kind": "item", "item": 2 }`, +/// `{ "kind": "trigger", "slot": 3 }` (even slots below 26 are triggers, the others actions, 26 is +/// the pressure action) or `{ "kind": "key", "lockId": 4, "item": 275 }` (the key item of the +/// lock), `null` places nothing. New items always exist. `trapLevel` and `action` (an +/// `ACTIONITEM_*` index of the original editor, 0 to 33) are those of new action items. +/// +/// `{ "mode": "soldiers", "tool": "place", "erase": false, "brush": { "kind": "square", "size": +/// 3 }, "team": "enemy", "defaults": { "orders": "stationary", ... } }` places a soldier of the +/// `team` with the `defaults` with left clicks (on flat roofs on the roof floor, see +/// [`set_render_options`]), or with `erase` removes the soldiers of the teams shown by the +/// `soldierTeams` layer on the tiles of the brush. The `tool` `select` selects the clicked soldier (clicking it again selects +/// the next soldier of the tile), dragging moves it and Ctrl+dragging (Cmd+dragging on macOS) +/// copies it, both keep its level. Delete removes it, Esc deselects it and 1 to 9 set its patrol +/// points at the cursor (Shift removes them). +/// +/// `{ "mode": "lights", "tool": "place", "erase": false, "brush": { "kind": "square", "size": 3 }, +/// "radius": 5, "time": "primeTime" }` places lights with left clicks (replacing the lights of +/// the tile), or with `erase` removes the lights of the tiles of the brush. `radius` (1 to 8) +/// picks the template `L-R0N.LHT`, `time` is `always`, `night` or `primeTime`. The `tool` +/// `select` selects the clicked light (clicking it again selects the next light of the tile), +/// dragging moves it and Ctrl+dragging (Cmd+dragging on macOS) copies it, Delete removes it and +/// Esc deselects it. +/// +/// `{ "mode": "traversal", "tool": "place", "place": "exit", "erase": false, "brush": { "kind": +/// "square", "size": 3 }, "destSector": { "x": 11, "y": 10, "z": 1 }, "destGridNo": 12345, +/// "entry": "north" }` paints exit grids to `destSector` (`x` is the column 1 to 16, `y` the row +/// 1 (A) to 16 (P), `z` the level 0 to 3) and `destGridNo` with the brush (`square` sizes or +/// `area`), replacing the exit grids of the tiles, or removes them with `erase`. A right click +/// reports the clicked exit grid (`queried` of the `traversal` event). The `place` `entry` sets +/// the `entry` point (`north`, `east`, `south`, `west`, `center` or `isolated`) with a left +/// click. The `tool` `select` selects the entry point or exit grid of the clicked tile (clicking +/// it again selects the next one of the tile), dragging moves it and Ctrl+dragging (Cmd+dragging +/// on macOS) copies exit grids, Delete removes exit grids and clears entry points and Esc +/// deselects. +/// +/// The brush `kind` is `square` or `circle` (with a `size`), `line` (with a `width`), `area` or +/// `fill` (only for the terrain). The buildings mode restricts the brush like the original +/// editor: most tools use the clicked tile. +#[wasm_bindgen] +pub fn set_editor_mode(mode: String) -> Result<(), JsError> { + let mode: EditorMode = serde_json::from_str(&mode)?; + *REQUESTED_EDITOR_MODE + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(mode); + Ok(()) +} + +/// Undoes the last edit of the loaded map +#[wasm_bindgen] +pub fn undo() { + push_editor_command(EditorCommand::Undo); +} + +/// Redoes the last undone edit of the loaded map, until the map is edited again +#[wasm_bindgen] +pub fn redo() { + push_editor_command(EditorCommand::Redo); +} + +/// Replaces the ground of the whole map by a texture, the name of the tile type of a texture of +/// the ground palette (e.g. `TEXTURE1`). Other tile types are ignored. +#[wasm_bindgen] +pub fn set_map_background(type_name: String) { + push_editor_command(EditorCommand::SetBackground(type_name)); +} + +/// Sets how the editor smooths the loaded map, `normal`, `basement` or `caves`, which decides +/// what the building tools do. Can be undone. +#[wasm_bindgen] +pub fn set_map_smoothing(smoothing: String) -> Result<(), JsError> { + let smoothing: EditorSmoothing = serde_json::from_value(serde_json::Value::String(smoothing))?; + push_editor_command(EditorCommand::SetSmoothing(smoothing)); + Ok(()) +} + +/// Sets whether the game only scrolls over the middle of the loaded map, like basements do +/// (`ubRestrictedScrollID` 1 instead of 0). Can be undone. +#[wasm_bindgen] +pub fn set_restricted_scroll(restricted_scroll: bool) { + push_editor_command(EditorCommand::SetRestrictedScroll(restricted_scroll)); +} + +/// Adds, replaces or removes the lock and trap of a door, which can be undone +/// +/// `door` is JSON like `{ "gridNo": 12345, "door": { "lockId": 3, "trap": { "kind": "siren", +/// "level": 2 }, "locked": true } }`, the trap `kind` is `explosion`, `electric`, `siren`, +/// `silentAlarm`, `brothelSiren` or `superElectric`. The trap and the door can be `null`, a door +/// that is `null` is removed from the door table. The lock is at most 63, the trap level 1 to 20. +#[wasm_bindgen] +pub fn set_door(door: String) -> Result<(), JsError> { + let request: DoorRequest = serde_json::from_str(&door)?; + push_editor_command(EditorCommand::SetDoor(request)); + Ok(()) +} + +/// Gives every tile with a room a flat roof of the roofs selection of the buildings mode, the +/// ceilings of the rooms of basements. Can be undone. +#[wasm_bindgen] +pub fn roof_all_rooms() { + push_editor_command(EditorCommand::RoofAllRooms); +} + +/// Edits the selected soldier, which can be undone +/// +/// `soldier` is JSON like the `soldier` of the `soldier` events: its `id` and the values it +/// edits (`orders`, `attitude`, `direction`, `relativeAttributeLevel`, `relativeEquipmentLevel`, +/// `soldierClass`, `civilianGroup`, `body`, `priorityExistence`, `hasKeys` and `detailed`, see +/// `SoldierFields`). Values that are out of range are clamped, unknown classes and body types +/// are ignored, the `detailed` values only if the soldier has a detailed placement. +#[wasm_bindgen] +pub fn set_soldier(soldier: String) -> Result<(), JsError> { + let edit: SoldierEdit = serde_json::from_str(&soldier)?; + push_editor_command(EditorCommand::UpdateSoldier(Box::new(edit))); + Ok(()) +} + +/// Asks something of the soldiers, see `SoldierCommand` +/// +/// `command` is JSON like `{ "kind": "select", "id": 3 }` (`null` deselects), `{ "kind": +/// "detailed", "detailed": true }` (gives the selected soldier a detailed placement or removes +/// it), `{ "kind": "center", "gridNo": 12345 }` +/// (centers the view on the tile), `{ "kind": "schedule", "rows": [...] }` (sets the 4 actions of +/// the schedule of the selected soldier, like the `schedule` of the `soldier` events), `{ "kind": +/// "clearSchedule" }`, `{ "kind": "pick", "slot": 0, "point": "a", "single": false }` (the next +/// left click picks tile A or B of an action, after A of a door action B is picked unless +/// `single` is set) or `{ "kind": "cancelPick" }`. +#[wasm_bindgen] +pub fn soldier_command(command: String) -> Result<(), JsError> { + let command: SoldierCommand = serde_json::from_str(&command)?; + push_editor_command(EditorCommand::Soldier(command)); + Ok(()) +} + +/// Edits a world item, e.g. one of the selected tile, which can be undone +/// +/// `item` is JSON like the `item` of the `item` events: its `id`, `stack` (the item stack, its +/// item can't be changed), `visibility` (`hiddenItem`, `buried`, `hiddenInObject`, `invisible`, +/// `unseen` or `visible`), `flags` and `nonExistChance`. Values that are out of range are +/// clamped like the original editor does, see `EditContext::change_item`. +#[wasm_bindgen] +pub fn set_item(item: String) -> Result<(), JsError> { + let edit: ItemEdit = serde_json::from_str(&item)?; + push_editor_command(EditorCommand::UpdateItem(Box::new(edit))); + Ok(()) +} + +/// Asks something of the world items, see `ItemCommand` +/// +/// `command` is JSON like `{ "kind": "delete", "id": 3 }` (removes an item, which can be undone) +/// or `{ "kind": "center" }` (centers the view on the selected tile). +#[wasm_bindgen] +pub fn item_command(command: String) -> Result<(), JsError> { + let command: ItemCommand = serde_json::from_str(&command)?; + push_editor_command(EditorCommand::Item(command)); + Ok(()) +} + +/// Edits the selected light, which can be undone +/// +/// `light` is JSON like `{ "id": 3, "radius": 4, "time": "night" }`, a `radius` of `null` keeps +/// the template. A radius whose template doesn't exist is reported with a `notice`. +#[wasm_bindgen] +pub fn set_light(light: String) -> Result<(), JsError> { + let edit: LightEdit = serde_json::from_str(&light)?; + push_editor_command(EditorCommand::UpdateLight(edit)); + Ok(()) +} + +/// Asks something of the lights, see `LightCommand` +/// +/// `command` is JSON like `{ "kind": "deselect" }` or `{ "kind": "settings", "color": [r, g, b], +/// "ambientLevel": 8 }` (sets the light colour and, for basements and caves, the light level of +/// the original editor, 1 to 15, which can be undone). +#[wasm_bindgen] +pub fn light_command(command: String) -> Result<(), JsError> { + let command: LightCommand = serde_json::from_str(&command)?; + push_editor_command(EditorCommand::Light(command)); + Ok(()) +} + +/// Edits the selected entry point or exit grid, which can be undone +/// +/// `edit` is JSON like `{ "kind": "entry", "point": "north", "gridNo": 12345 }` (moved to where +/// the game can show it, like a click does) or `{ "kind": "exitGrid", "gridNo": 12345, +/// "destSector": { "x": 11, "y": 10, "z": 1 }, "destGridNo": 6000 }` (the destination of the exit +/// grid of the tile). +#[wasm_bindgen] +pub fn set_traversal(edit: String) -> Result<(), JsError> { + let edit: TraversalEdit = serde_json::from_str(&edit)?; + push_editor_command(EditorCommand::UpdateTraversal(edit)); + Ok(()) +} + +/// Saves the loaded map with the edits so far, in the version it was loaded in +/// +/// Resolves with `{ map, revision, data }` once the edits requested before are applied: `map` is +/// the path of the map, `revision` the `revision` of the state events that the saved map has and +/// `data` the map file as `Uint8Array`. The map is not written anywhere, that is up to the host. +/// Edge points that edits made out of date are generated again first. Rejects if no map is +/// loaded or the map can't be saved. +#[wasm_bindgen] +pub fn save_map() -> js_sys::Promise { + host::request_save() +} + +/// Renders the radar map of `map` (e.g. `maps/A9.dat`), the small image of the map in the +/// strategic screen of the game, like the game's map utility does +/// +/// Resolves with the radar map as an 88×44 8 bit STI file (`Uint8Array`) once `map` is loaded, +/// which is up to the host to write (to `radarmaps/.sti`). `map` must be the map +/// that was requested last with [`load_map`] or [`new_map`]. The loaded map is rendered with its edits so far. +/// Rejects if `map` can't be loaded or another map is loaded before the radar map is done. +#[wasm_bindgen] +pub fn render_radar_map(map: String) -> js_sys::Promise { + host::request_radar(map) +} + +fn push_editor_command(command: EditorCommand) { + EDITOR_COMMANDS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(command); +} + +/// Forwards a key that was pressed or released outside of the canvas +/// +/// `code` and `key` are the `code` and `key` of the `KeyboardEvent`. The canvas only receives +/// keyboard input while it is focused, so the host forwards keys pressed elsewhere on the page. +/// Codes that are not known are ignored. +#[wasm_bindgen] +pub fn key_event(code: String, key: String, pressed: bool, repeat: bool) { + let Some(key_code) = host::key_code(code) else { + return; + }; + push_host_key(HostKey::Key { + key_code, + key, + pressed, + repeat, + }); +} + +/// Releases all keys, e.g. when the page loses the focus +#[wasm_bindgen] +pub fn release_keys() { + push_host_key(HostKey::ReleaseAll); +} + +fn push_host_key(key: HostKey) { + HOST_KEYS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(key); +} + +/// Throttles updates while the viewer is not shown +#[wasm_bindgen] +pub fn set_active(active: bool) { + ACTIVE.store(active, Ordering::Relaxed); +} + +/// Takes the map requested by [`load_map`] or [`new_map`], if any +pub(crate) fn take_requested_map() -> Option { + REQUESTED_MAP + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() +} + +/// Whether a map was requested by [`load_map`] or [`new_map`] that is not switched to yet +pub(crate) fn map_requested() -> bool { + REQUESTED_MAP + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() +} + +/// Takes the render options requested by [`set_render_options`], if any +pub(crate) fn take_requested_render_options() -> Option { + REQUESTED_RENDER_OPTIONS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() +} + +/// Takes the editor mode requested by [`set_editor_mode`], if any +pub(crate) fn take_requested_editor_mode() -> Option { + REQUESTED_EDITOR_MODE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() +} + +/// Takes the editor commands requested since the last call +pub(crate) fn take_editor_commands() -> Vec { + std::mem::take(&mut *EDITOR_COMMANDS.lock().unwrap_or_else(|e| e.into_inner())) +} + +/// Takes the keyboard input forwarded since the last call +pub(crate) fn take_host_keys() -> Vec { + std::mem::take(&mut *HOST_KEYS.lock().unwrap_or_else(|e| e.into_inner())) +} + +/// Embeds the shaders, see `embedded_asset!` for the resulting paths +fn embed_shaders(app: &mut App) { + embedded_asset!(app, "shaders/ja2_replay.wesl"); +} + +fn apply_active(mut winit_settings: ResMut) { + // Radar maps are also rendered while the viewer is not shown + let mode = if ACTIVE.load(Ordering::Relaxed) || host::radar_requested() { + UpdateMode::Continuous + } else { + UpdateMode::reactive_low_power(INACTIVE_UPDATE_INTERVAL) + }; + // On the web, the window is only focused while the canvas has keyboard focus. Keep updating + // while the viewer is shown, so that it also reacts to the mouse without being clicked first. + if winit_settings.focused_mode != mode { + winit_settings.focused_mode = mode; + winit_settings.unfocused_mode = mode; + } +} diff --git a/src-map-editor/src/light/mod.rs b/src-map-editor/src/light/mod.rs new file mode 100644 index 0000000..4c4854d --- /dev/null +++ b/src-map-editor/src/light/mod.rs @@ -0,0 +1,139 @@ +//! Light templates (`.lht`), the shapes of the light sprites + +use std::{collections::HashMap, io, sync::Arc}; + +use bevy::{ + asset::{io::Reader, AssetLoader, LoadContext}, + prelude::*, +}; +use byteorder::{ReadBytesExt, LE}; + +/// Registers the light template asset and its loader +pub struct LightPlugin; + +impl Plugin for LightPlugin { + fn build(&self, app: &mut App) { + app.init_asset::() + .init_asset_loader::(); + } +} + +/// The radii of the light templates that the editor places (`L-R01.LHT` to `L-R08.LHT`) +pub const LIGHT_RADII: std::ops::RangeInclusive = 1..=8; + +/// The loaded light templates by lower case file name: the ones of the map lights and the ones +/// the editor places, templates that could not be loaded are missing +#[derive(Resource, Clone, Debug, Default)] +pub struct LightTemplates(pub HashMap>); + +impl LightTemplates { + /// The template of a file name, in any case + pub fn get(&self, name: &str) -> Option<&Arc> { + self.0.get(&name.to_lowercase()) + } + + /// The radii of the editor whose templates are loaded + pub fn radii(&self) -> Vec { + LIGHT_RADII + .filter(|&radius| self.get(&crate::map::light_template_name(radius)).is_some()) + .collect() + } +} + +/// Flags of the ray list of a light template +pub const LIGHT_BACKLIGHT: u16 = 0x4000; +pub const LIGHT_NEW_RAY: u16 = 0x8000; + +/// A node of a light template, a tile relative to the light (`LIGHT_NODE`) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LightNode { + pub dx: i16, + pub dy: i16, + pub light: u8, +} + +/// A light template file (`.lht`) +#[derive(Asset, TypePath, Clone, Debug, Default, PartialEq)] +pub struct LightTemplate { + pub lights: Vec, + /// Rays through the nodes: node indices, with `LIGHT_NEW_RAY` marking the start of a ray and + /// `LIGHT_BACKLIGHT` as a flag + pub rays: Vec, +} + +impl LightTemplate { + /// Port of `LightLoad` + pub fn parse(bytes: &[u8]) -> io::Result { + let mut input = io::Cursor::new(bytes); + let num_lights = input.read_u16::()?; + let lights = (0..num_lights) + .map(|_| { + let dx = input.read_i16::()?; + let dy = input.read_i16::()?; + let _flags = input.read_u8()?; + let light = input.read_u8()?; + Ok(LightNode { dx, dy, light }) + }) + .collect::>()?; + let num_rays = input.read_u16::()?; + let rays = (0..num_rays) + .map(|_| input.read_u16::()) + .collect::>()?; + Ok(Self { lights, rays }) + } +} + +#[derive(Default, TypePath)] +pub struct LightTemplateLoader; + +impl AssetLoader for LightTemplateLoader { + type Asset = LightTemplate; + type Settings = (); + type Error = io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + _load_context: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + LightTemplate::parse(&bytes) + } + + fn extensions(&self) -> &[&str] { + &["lht", "LHT"] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_light_templates() { + let mut bytes = vec![]; + bytes.extend(2u16.to_le_bytes()); + for (dx, dy, light) in [(8191i16, 8191i16, 0u8), (1, -1, 3)] { + bytes.extend(dx.to_le_bytes()); + bytes.extend(dy.to_le_bytes()); + bytes.extend([0, light]); + } + bytes.extend(3u16.to_le_bytes()); + for ray in [LIGHT_NEW_RAY, 1, LIGHT_BACKLIGHT | 1] { + bytes.extend(ray.to_le_bytes()); + } + let template = LightTemplate::parse(&bytes).unwrap(); + assert_eq!( + template.lights[1], + LightNode { + dx: 1, + dy: -1, + light: 3 + } + ); + assert_eq!(template.rays, vec![LIGHT_NEW_RAY, 1, LIGHT_BACKLIGHT | 1]); + assert!(LightTemplate::parse(&bytes[..5]).is_err()); + } +} diff --git a/src-map-editor/src/map/convert.rs b/src-map-editor/src/map/convert.rs new file mode 100644 index 0000000..a2c0aed --- /dev/null +++ b/src-map-editor/src/map/convert.rs @@ -0,0 +1,1463 @@ +//! Converts the map file into the runtime map + +use std::sync::Arc; + +use bevy::prelude::*; + +use super::placement::{appearance, Appearance, AppearanceContext, PlacementId}; +use super::save; +use super::{ + AmbientLight, Attitude, DetailedPlacement, Direction, Door, DoorTrap, DoorTrapKind, + EdgePointList, EdgePointSet, EdgePoints, EditorSmoothing, ExitGrid, GridArray, GridNo, + ItemHeight, ItemStack, Level, LightId, LightTime, LockId, MapContext, MapInfo, MapItem, + MapLight, MapTile, Orders, ProfileId, RoomNo, Schedule, ScheduleAction, ScheduleEntry, + ScheduleFlags, ScheduleId, SectorId, SoldierPlacement, SoldierStats, TacticalMap, Visibility, + WorldItemFlags, WorldItemId, MAX_PATROL_POINTS, NUM_INV_SLOTS, +}; +use crate::{ + components::map::{GridPosition, JA2_MAP_TILES}, + file_formats::{ + ja2_map::{ + Ja2DetailedSoldierPlacement, Ja2EdgePointList, Ja2EdgePointSet, Ja2ExitGrid, + Ja2LightSprite, Ja2Map, Ja2MapElementFlags, Ja2MapInfo, Ja2Object, Ja2Schedule, + Ja2SoldierPlacement, Ja2TileLayer, Ja2WorldItem, + }, + ja2_tile_types::{TileType, FIRSTPOINTERS}, + }, + item::{ItemId, ItemTable}, + tileset::{Tile, Tileset, TilesetId}, +}; + +/// Raw grid number of nothing (`NOWHERE`) +pub(super) const NOWHERE: i16 = -1; + +/// Raw body type of placements whose body type is chosen at random (`BODY_RANDOM`) +pub(super) const BODY_RANDOM: i8 = -1; + +/// Raw time, grid number and data of unused schedule actions +pub(super) const NO_SCHEDULE_DATA: u16 = 0xffff; + +/// Ambient light of old caves maps (`ValidateAndUpdateMapVersionIfNecessary`) +const OLD_CAVES_LIGHTLEVEL: u8 = 13; + +impl TacticalMap { + /// Converts a map file, anything that can't be converted is left out with a warning + /// + /// Fails if the map as a whole can't be used. + pub fn from_ja2(map: &Ja2Map, ctx: &MapContext) -> Result { + let tileset = u8::try_from(map.tileset_id) + .map(TilesetId::new) + .map_err(|_| { + format!( + "The map uses tileset {}, which does not exist", + map.tileset_id + ) + })?; + if map.map_elements.len() != JA2_MAP_TILES { + return Err(format!( + "The map has {} tiles instead of {}", + map.map_elements.len(), + JA2_MAP_TILES + )); + } + let info = convert_info(&map.info); + let (light_color, lights, skipped_lights) = convert_lights(map, ctx); + let (soldiers, skipped_placements) = convert_placements(map, ctx); + let (items, skipped_items) = convert_items(map, &info, ctx); + let (exit_grids, skipped_exit_grids) = convert_exit_grids(map); + Ok(TacticalMap { + tileset, + tiles: convert_tiles(map, ctx.tileset), + ambient_light: convert_ambient_light(map), + light_color, + lights, + skipped_lights, + items, + skipped_items, + soldiers, + skipped_placements, + exit_grids, + skipped_exit_grids, + doors: convert_doors(map), + edge_points: map + .edge_points + .as_ref() + .map(|edge_points| EdgePoints { + primary: convert_edge_point_set(&edge_points.primary), + secondary: edge_points.secondary.as_ref().map(convert_edge_point_set), + }) + .unwrap_or_default(), + edge_points_stale: false, + schedules: map.schedules.iter().filter_map(convert_schedule).collect(), + info, + }) + } +} + +/// A grid number of the map file, `None` with a warning if it is not on the map +fn valid_grid_no(raw: i32, what: &str) -> Option { + let grid_no = GridNo::from_i32(raw); + if grid_no.is_none() { + warn!( + "Leaving out {}: grid number {} is not on the map", + what, raw + ); + } + grid_no +} + +fn convert_info(info: &Ja2MapInfo) -> MapInfo { + let point = |raw: i16, what: &str| { + if raw == NOWHERE { + None + } else { + valid_grid_no(raw.into(), what) + } + }; + let editor_smoothing = + EditorSmoothing::from_number(info.editor_smoothing_type).unwrap_or_else(|| { + warn!( + "Unknown editor smoothing type {}, using normal smoothing", + info.editor_smoothing_type + ); + EditorSmoothing::Normal + }); + MapInfo { + north_entry: point(info.north_grid_no, "the north entry point"), + east_entry: point(info.east_grid_no, "the east entry point"), + south_entry: point(info.south_grid_no, "the south entry point"), + west_entry: point(info.west_grid_no, "the west entry point"), + center: point(info.center_grid_no, "the center entry point"), + isolated: point(info.isolated_grid_no, "the isolated entry point"), + restricted_scroll: info.restricted_scroll_id != 0, + editor_smoothing, + } +} + +/// A tile of a map file, `None` if it is not in the tileset +fn resolve_tile(tileset: &Tileset, kind: u8, sub_index: u16) -> Option> { + let index = tileset.index(TileType::from(kind), sub_index)?; + tileset.tile(index).cloned() +} + +/// Resolves a tile layer, counts the tiles that are not in the tileset +fn resolve_layer<'a>( + tileset: &Tileset, + layers: impl IntoIterator, + missing: &mut usize, +) -> Vec> { + layers + .into_iter() + .filter_map(|layer| { + let tile = resolve_tile(tileset, layer.kind, layer.sub_index); + if tile.is_none() { + *missing += 1; + } + tile + }) + .collect() +} + +/// Resolves the tile layers, like the layer loading of `LoadWorldFromSGPFile` +fn convert_tiles(map: &Ja2Map, tileset: &Tileset) -> GridArray { + let mut missing = 0; + let tiles = GridArray::from_fn(|grid_no| { + let element = &map.map_elements[grid_no.index()]; + let mut structs = vec![]; + for layer in &element.structs { + let Some(mut tile) = resolve_tile(tileset, layer.kind, layer.sub_index) else { + missing += 1; + continue; + }; + if map.version.minor <= 25 && tile.image.underflow_filler { + // Workaround of the game for "phantom menace" struct data in old maps: skip + // underflow fillers if there is more than one struct on the tile, otherwise use + // the first tile of the type + if element.structs.len() > 1 { + continue; + } + match resolve_tile(tileset, layer.kind, 1) { + Some(first) => tile = first, + None => continue, + } + } + structs.push(tile); + } + // Objects of the pointer types and later are editor cursors, the game skips them + let objects = element + .objects + .iter() + .filter(|object| TileType::from(object.kind) < FIRSTPOINTERS); + MapTile { + height: element.height, + land: resolve_layer(tileset, &element.land, &mut missing), + objects: resolve_layer(tileset, objects, &mut missing), + structs, + shadows: resolve_layer(tileset, &element.shadows, &mut missing), + roofs: resolve_layer(tileset, &element.roofs, &mut missing), + on_roofs: resolve_layer(tileset, &element.on_roofs, &mut missing), + room: RoomNo::new(element.room), + enemy_mine: element + .flags + .contains(Ja2MapElementFlags::MAPELEMENT_ENEMY_MINE_PRESENT), + player_mine: element + .flags + .contains(Ja2MapElementFlags::MAPELEMENT_PLAYER_MINE_PRESENT), + cave: false, + } + }); + if missing > 0 { + warn!( + "{} tiles of the map are missing in the tile tileset", + missing + ); + } + tiles +} + +/// An item stack, `None` if it has no item (`NOTHING`) +pub(super) fn convert_object(object: &Ja2Object) -> Option { + Some(ItemStack { + item: ItemId::new(object.item)?, + number: object.number, + data: object.data, + attachments: object + .attach_items + .iter() + .zip(object.attach_status) + .filter_map(|(&item, status)| Some((ItemId::new(item)?, status))) + .collect(), + flags: object.flags, + mission: object.mission, + trap: object.trap, + imprint_id: object.imprint_id, + weight: object.weight, + used: object.used, + }) +} + +/// The world items, as `LoadWorldItemsFromMap` loads them in the editor, and the ones of the map +/// file that could not be converted +/// +/// The items are numbered in the order of the map file. +fn convert_items( + map: &Ja2Map, + info: &MapInfo, + ctx: &MapContext, +) -> (Vec, Vec) { + let mut missing_graphics = 0; + let mut items = vec![]; + let mut skipped = vec![]; + for (index, item) in map.items.iter().enumerate() { + let Some(stack) = convert_object(&item.object) else { + warn!("Leaving out world item {}: it has no item", index); + skipped.push(item.clone()); + continue; + }; + // `NOWHERE` and other invalid grid numbers become the center of the map + let on_map = GridNo::from_i32(item.grid_no.into()); + let Some(grid_no) = on_map.or(info.center) else { + warn!( + "Leaving out world item {}: grid number {} is not on the map, and the map has no \ + center", + index, item.grid_no + ); + skipped.push(item.clone()); + continue; + }; + let Some(visibility) = Visibility::from_number(item.visible) else { + warn!( + "Leaving out world item {}: unknown visibility {}", + index, item.visible + ); + skipped.push(item.clone()); + continue; + }; + let graphic = item_graphic(ctx.tileset, ctx.items, stack.item); + if graphic.is_none() { + missing_graphics += 1; + } + let render_height = match item.render_z_height_above_level { + -1 => ItemHeight::ForceGround, + height => ItemHeight::Above(height.max(0) as u8), + }; + let file_object = + (save::convert_object(&stack) != item.object).then(|| item.object.clone()); + items.push(MapItem { + id: WorldItemId(items.len() as u32), + grid_no, + file_grid_no: on_map.is_none().then_some(item.grid_no), + level: Level::from_number(item.level), + stack, + file_object, + graphic, + render_height, + visibility, + flags: WorldItemFlags::from_bits_retain(item.flags), + non_exist_chance: item.non_exist_chance, + }); + } + if missing_graphics > 0 { + warn!("{} items of the map have no graphic", missing_graphics); + } + (items, skipped) +} + +/// The graphic of an item on the ground, `None` if it has none +pub fn item_graphic(tileset: &Tileset, items: &ItemTable, item: ItemId) -> Option> { + items + .graphic(item) + .and_then(|(tile_type, sub_index)| tileset.index(tile_type, sub_index)) + .and_then(|tile_index| tileset.tile(tile_index)) + .cloned() +} + +/// The ambient light of basements and caves, old caves maps get the level the game uses +pub fn convert_ambient_light(map: &Ja2Map) -> Option { + map.ambient_light.as_ref().map(|ambient| AmbientLight { + basement: ambient.basement, + caves: ambient.caves, + level: if ambient.caves && map.info.map_version < 25 { + OLD_CAVES_LIGHTLEVEL + } else { + ambient.level + }, + }) +} + +/// The light colour, the light sprites and the ones that can't be converted (`LoadMapLights`) +/// +/// The lights get ids in the order of the map file. +fn convert_lights(map: &Ja2Map, ctx: &MapContext) -> ([u8; 3], Vec, Vec) { + let Some(lights) = &map.lights else { + return ([0; 3], vec![], vec![]); + }; + let light_color = lights + .colors + .first() + .map_or([0; 3], |color| [color[0], color[1], color[2]]); + let mut missing_templates = 0; + let mut converted = vec![]; + let mut skipped = vec![]; + for (index, sprite) in lights.sprites.iter().enumerate() { + let Some(position) = GridPosition::from_xy(sprite.x.into(), sprite.y.into()) else { + warn!( + "Leaving out light {}: {} x {} is not on the map", + sprite.template_name, sprite.x, sprite.y + ); + skipped.push(sprite.clone()); + continue; + }; + // The game ignores lights whose template can't be loaded + let Some(template) = ctx + .light_templates + .get(&sprite.template_name.to_lowercase()) + else { + missing_templates += 1; + skipped.push(sprite.clone()); + continue; + }; + converted.push(MapLight { + id: LightId(index as u32), + position, + time: LightTime::from_flags(sprite.flags), + template_name: sprite.template_name.clone(), + template: template.clone(), + file_sprite: Some(sprite.clone()), + }); + } + if missing_templates > 0 { + warn!( + "Leaving out {} lights of the map whose template is missing", + missing_templates + ); + } + (light_color, converted, skipped) +} + +/// The valid placements and the invalid ones, which are left out with a warning +fn convert_placements( + map: &Ja2Map, + ctx: &MapContext, +) -> (Vec, Vec) { + let mut valid = vec![]; + let mut skipped = vec![]; + for (index, placement) in map.placements.iter().enumerate() { + match convert_placement(index, placement, ctx) { + Ok(converted) => valid.push(converted), + Err(error) => { + warn!( + "Skipping soldier placement {} at grid number {}: {}", + index, placement.basic.starting_grid_no, error + ); + skipped.push(placement.clone()); + } + } + } + (valid, skipped) +} + +/// Validates a placement and makes the random choices of the game +/// (`CreateDetailedPlacementGivenBasicPlacementInfo`, `TacticalCreateSoldier`) +fn convert_placement( + index: usize, + placement: &Ja2SoldierPlacement, + ctx: &MapContext, +) -> Result { + let definitions = ctx.soldiers; + let basic = &placement.basic; + let team = definitions + .teams + .validate(basic.team) + .ok_or_else(|| format!("unknown team {}", basic.team))?; + let soldier_class = definitions + .classes + .validate(basic.soldier_class) + .ok_or_else(|| format!("unknown soldier class {}", basic.soldier_class))?; + let given_body = match basic.body_type { + BODY_RANDOM => None, + raw => Some( + definitions + .bodies + .validate(raw) + .ok_or_else(|| format!("unknown body type {}", raw))?, + ), + }; + let grid_no = GridNo::new(basic.starting_grid_no.into()) + .ok_or_else(|| "the grid number is not on the map".to_owned())?; + let orders = Orders::from_number(basic.orders) + .ok_or_else(|| format!("unknown orders {}", basic.orders))?; + let attitude = Attitude::from_number(basic.attitude) + .ok_or_else(|| format!("unknown attitude {}", basic.attitude))?; + // Directions >= 100 are the old way to save a fixed direction + let mut direction = basic.direction as u8; + if direction >= 100 { + direction -= 100; + } + let direction = Direction::ALL[usize::from(direction % 8)]; + // The first entry is not a patrol point, the game sets it to where the soldier stands + let patrol_count = usize::try_from(basic.patrol_cnt) + .unwrap_or(0) + .min(MAX_PATROL_POINTS); + let patrol = basic.patrol_grid[1..=patrol_count] + .iter() + .filter_map(|&raw| { + valid_grid_no( + raw.into(), + &format!("a patrol point of placement {}", index), + ) + }) + .collect(); + let detailed = placement.detailed.as_ref().map(convert_detailed); + let Appearance { + body, + surface, + palette, + } = appearance( + index, + grid_no, + &team, + &soldier_class, + given_body.as_ref(), + detailed.as_ref(), + &AppearanceContext { + items: ctx.items, + bodies: definitions.bodies, + palettes: ctx.palettes, + }, + ); + + Ok(SoldierPlacement { + id: PlacementId(index as u32), + index, + grid_no, + direction, + level: if basic.on_roof { + Level::Roof + } else { + Level::Ground + }, + team, + soldier_class, + given_body, + body, + surface, + palette, + relative_attribute_level: basic.relative_attribute_level, + relative_equipment_level: basic.relative_equipment_level, + orders, + attitude, + patrol, + civilian_group: basic.civilian_group, + priority_existence: basic.priority_existance, + has_keys: basic.has_keys, + detailed, + }) +} + +fn convert_detailed(detailed: &Ja2DetailedSoldierPlacement) -> DetailedPlacement { + // -1 means that the game chooses the value + let stat = |value: i8| (value != -1).then_some(value); + let sector = (|| { + Some(SectorId { + x: u8::try_from(detailed.sector_x).ok()?, + y: u8::try_from(detailed.sector_y).ok()?, + z: u8::try_from(detailed.sector_z).ok()?, + }) + })(); + let inventory: [Option; NUM_INV_SLOTS] = + std::array::from_fn(|slot| detailed.inventory.get(slot).and_then(convert_object)); + DetailedPlacement { + is_static: detailed.is_static, + profile: ProfileId::new(detailed.profile), + copy_profile_items_over: detailed.copy_profile_items_over, + sector, + insertion_grid_no: GridNo::from_i32(detailed.insertion_grid_no.into()), + stats: SoldierStats { + life_max: stat(detailed.life_max), + life: stat(detailed.life), + agility: stat(detailed.agility), + dexterity: stat(detailed.dexterity), + exp_level: stat(detailed.exp_level), + marksmanship: stat(detailed.marksmanship), + medical: stat(detailed.medical), + mechanical: stat(detailed.mechanical), + explosive: stat(detailed.explosive), + leadership: stat(detailed.leadership), + strength: stat(detailed.strength), + wisdom: stat(detailed.wisdom), + morale: stat(detailed.morale), + ai_morale: stat(detailed.ai_morale), + }, + inventory, + head_palette: detailed.head_pal.clone(), + pants_palette: detailed.pants_pal.clone(), + vest_palette: detailed.vest_pal.clone(), + skin_palette: detailed.skin_pal.clone(), + visible: detailed.visible, + name: detailed.name.clone(), + schedule: ScheduleId::new(detailed.schedule_id), + vehicle_id: detailed + .use_given_vehicle + .then_some(detailed.use_given_vehicle_id), + } +} + +/// The exit grids of the map file, and the ones that could not be converted +fn convert_exit_grids(map: &Ja2Map) -> (Vec, Vec) { + let mut skipped = vec![]; + let exit_grids = map + .exit_grids + .iter() + .filter_map(|exit_grid| { + let (x, y, z) = exit_grid.dest_sector; + let grid_no = valid_grid_no(exit_grid.grid_no.into(), "an exit grid"); + let dest_grid_no = valid_grid_no( + exit_grid.dest_grid_no.into(), + "the destination of an exit grid", + ); + let (Some(grid_no), Some(dest_grid_no)) = (grid_no, dest_grid_no) else { + skipped.push(exit_grid.clone()); + return None; + }; + Some(ExitGrid { + grid_no, + dest_grid_no, + dest_sector: SectorId { x, y, z }, + }) + }) + .collect(); + (exit_grids, skipped) +} + +fn convert_doors(map: &Ja2Map) -> Vec { + map.doors + .iter() + .filter_map(|door| { + let grid_no = valid_grid_no(door.grid_no.into(), "a door")?; + let trap = match DoorTrapKind::from_number(door.trap_id) { + Ok(kind) => kind.map(|kind| DoorTrap { + kind, + level: door.trap_level, + }), + Err(trap_id) => { + warn!( + "Leaving out the door at grid number {}: unknown trap {}", + grid_no.index(), + trap_id + ); + return None; + } + }; + Some(Door { + grid_no, + locked: door.locked, + trap, + lock: LockId::new(door.lock_id), + perceived_locked: door.perceived_locked, + perceived_trapped: door.perceived_trapped, + lock_damage: door.lock_damage, + }) + }) + .collect() +} + +fn convert_edge_point_list(list: &Ja2EdgePointList) -> EdgePointList { + EdgePointList { + middle_index: list.middle_index, + grid_nos: list + .grid_nos + .iter() + .filter_map(|&raw| valid_grid_no(raw.into(), "an edge point")) + .collect(), + } +} + +fn convert_edge_point_set(set: &Ja2EdgePointSet) -> EdgePointSet { + EdgePointSet { + north: convert_edge_point_list(&set.north), + east: convert_edge_point_list(&set.east), + south: convert_edge_point_list(&set.south), + west: convert_edge_point_list(&set.west), + } +} + +fn convert_schedule(schedule: &Ja2Schedule) -> Option { + let Some(id) = ScheduleId::new(schedule.schedule_id) else { + warn!("Leaving out a schedule without id"); + return None; + }; + let actions = std::array::from_fn(|slot| { + let data = |raw: u16, what: &str| { + valid_grid_no( + raw.into(), + &format!("action {} of schedule {} ({})", slot, id.number(), what), + ) + }; + let door = |door, destination| Some((data(door, "door")?, data(destination, "tile")?)); + let (data1, data2) = (schedule.data1[slot], schedule.data2[slot]); + let action = match schedule.action[slot] { + 0 => return None, + 1 => { + let (door, destination) = door(data1, data2)?; + ScheduleAction::LockDoor { door, destination } + } + 2 => { + let (door, destination) = door(data1, data2)?; + ScheduleAction::UnlockDoor { door, destination } + } + 3 => { + let (door, destination) = door(data1, data2)?; + ScheduleAction::OpenDoor { door, destination } + } + 4 => { + let (door, destination) = door(data1, data2)?; + ScheduleAction::CloseDoor { door, destination } + } + 5 => ScheduleAction::GridNo(data(data1, "tile")?), + 6 => ScheduleAction::LeaveSector, + 7 => ScheduleAction::EnterSector(data(data1, "tile")?), + 8 => ScheduleAction::StayInSector, + 9 => ScheduleAction::Sleep(data(data1, "tile")?), + 10 => ScheduleAction::Wake, + unknown => { + warn!( + "Leaving out action {} of schedule {}: unknown action {}", + slot, + id.number(), + unknown + ); + return None; + } + }; + Some(ScheduleEntry { + time: (schedule.time[slot] != NO_SCHEDULE_DATA).then_some(schedule.time[slot]), + action, + }) + }); + Some(Schedule { + id, + soldier_id: schedule.soldier_id, + flags: ScheduleFlags::from_bits_retain(schedule.flags), + actions, + }) +} + +#[cfg(test)] +pub(crate) mod tests { + use std::collections::HashMap; + + use super::*; + use crate::{ + file_formats::{ + ja2_map::{ + Ja2AmbientLight, Ja2BasicSoldierPlacement, Ja2LightSprite, Ja2Lights, + Ja2MapElement, Ja2MapVersion, Ja2WorldItem, + }, + ja2_tile_database::{ + tests::{full_surfaces, surface}, + Ja2TileDatabase, + }, + ja2_tile_types::{FIRSTOSTRUCT, FIRSTTEXTURE, GUNS}, + }, + item::{ItemFile, ItemInfo, ItemTable}, + light::LightTemplate, + map::{HANDPOS, LIGHT_NIGHTTIME, LIGHT_PRIMETIME}, + soldier::{ + body_type::SoldierBodyTypeId, class::SoldierClassId, palette::ResolvedSoldierPalette, + palette_files::PaletteReplacements, team::TeamId, Registries, + }, + tileset::tests::test_tileset, + }; + + /// Every palette name that the palette choices use + const PALETTE_NAMES: [&str; 26] = [ + "PINKSKIN", + "TANSKIN", + "DARKSKIN", + "BLACKSKIN", + "BROWNHEAD", + "BLACKHEAD", + "WHITEHEAD", + "BLONDHEAD", + "REDHEAD", + "YELLOWVEST", + "GREENPANTS", + "BLACKSHIRT", + "BLACKPANTS", + "REDVEST", + "GREENVEST", + "BEIGEPANTS", + "JEANVEST", + "BLUEVEST", + "greyVEST", + "TANPANTS", + "GYELLOWSHIRT", + "WHITEVEST", + "PURPLESHIRT", + "BROWNVEST", + "JEANPANTS", + "BLUEPANTS", + ]; + + /// What the conversion needs besides the map + pub(crate) struct Fixture { + pub(crate) tileset: Tileset, + items: ItemTable, + registries: Registries, + palettes: PaletteReplacements, + light_templates: HashMap>, + } + + impl Fixture { + pub(in crate::map) fn new() -> Self { + Self::with_tileset(test_tileset()) + } + + pub(crate) fn with_tileset(tileset: Tileset) -> Self { + let item = |index, graphic, hand| ItemInfo { + index: ItemId::new(index).unwrap(), + graphic, + hand, + kind: Default::default(), + }; + let items = ItemTable::new([&ItemFile { + items: vec![ + item( + 1, + Some((GUNS, 2)), + Some(crate::item::HandItem::OneHandedGun), + ), + item(2, None, None), + ], + }]); + Self { + tileset, + items, + registries: Registries::build(), + palettes: PaletteReplacements::with_names(&PALETTE_NAMES), + light_templates: HashMap::from([( + "l-r01.lht".to_owned(), + Arc::new(LightTemplate::default()), + )]), + } + } + + pub(crate) fn context(&self) -> MapContext<'_> { + MapContext { + tileset: &self.tileset, + items: &self.items, + soldiers: self.registries.definitions(), + palettes: Some(&self.palettes), + light_templates: &self.light_templates, + } + } + + pub(crate) fn convert(&self, map: &Ja2Map) -> TacticalMap { + TacticalMap::from_ja2(map, &self.context()).unwrap() + } + } + + pub(in crate::map) fn empty_map() -> Ja2Map { + Ja2Map { + version: Ja2MapVersion { + major: 6., + minor: 26, + }, + map_elements: vec![Ja2MapElement::default(); JA2_MAP_TILES], + ..default() + } + } + + pub(in crate::map) fn grid(index: usize) -> GridNo { + GridNo::new(index).unwrap() + } + + pub(in crate::map) fn layer(kind: TileType, sub_index: u16) -> Ja2TileLayer { + Ja2TileLayer { + kind: kind as u8, + sub_index, + } + } + + #[test] + fn rejects_unusable_maps() { + let fixture = Fixture::new(); + let ctx = fixture.context(); + let map = Ja2Map { + tileset_id: -1, + ..empty_map() + }; + assert!(TacticalMap::from_ja2(&map, &ctx).is_err()); + assert!(TacticalMap::from_ja2(&Ja2Map::default(), &ctx).is_err()); + let map = Ja2Map { + tileset_id: 38, + ..empty_map() + }; + assert_eq!( + TacticalMap::from_ja2(&map, &ctx).unwrap().tileset, + TilesetId::new(38) + ); + } + + #[test] + fn resolves_tile_layers() { + let fixture = Fixture::new(); + let mut map = empty_map(); + map.map_elements[5] = Ja2MapElement { + height: 4, + land: vec![layer(FIRSTTEXTURE, 1), layer(FIRSTTEXTURE, 0)], + objects: vec![layer(FIRSTPOINTERS, 1), layer(FIRSTOSTRUCT, 2)], + room: 3, + flags: Ja2MapElementFlags::MAPELEMENT_ENEMY_MINE_PRESENT + | Ja2MapElementFlags::MAPELEMENT_REDUNDANT, + ..default() + }; + let converted = fixture.convert(&map); + let tile = &converted.tiles[grid(5)]; + assert_eq!(tile.height, 4); + // Sub index 0 is no tile, pointers are left out + let ids = |tiles: &[Arc]| tiles.iter().map(|tile| tile.id).collect::>(); + let index = |tile_type, sub_index| fixture.tileset.index(tile_type, sub_index).unwrap(); + assert_eq!(ids(&tile.land), [index(FIRSTTEXTURE, 1)]); + assert_eq!(ids(&tile.objects), [index(FIRSTOSTRUCT, 2)]); + assert_eq!(tile.room.map(RoomNo::number), Some(3)); + assert!(tile.enemy_mine); + assert!(!tile.player_mine); + assert!(converted.tiles[grid(4)].room.is_none()); + } + + #[test] + fn phantom_menace_workaround() { + let mut surfaces = full_surfaces(); + surfaces[FIRSTOSTRUCT] = Some(surface("ostruct.sti", 3)); + let fixture = Fixture::with_tileset(Tileset::from_database( + Ja2TileDatabase::build(surfaces).unwrap(), + )); + let first = fixture.tileset.index(FIRSTOSTRUCT, 1).unwrap(); + let filler = fixture.tileset.index(FIRSTOSTRUCT, 5).unwrap(); + let mut map = empty_map(); + map.map_elements[0].structs = vec![layer(FIRSTOSTRUCT, 5)]; + map.map_elements[1].structs = vec![layer(FIRSTOSTRUCT, 5), layer(FIRSTOSTRUCT, 2)]; + let structs = |map: &TacticalMap, index| -> Vec<_> { + map.tiles[grid(index)] + .structs + .iter() + .map(|tile| tile.id) + .collect() + }; + + // Old maps use the first tile instead of a single filler, and skip other fillers + map.version.minor = 25; + let old = fixture.convert(&map); + assert_eq!(structs(&old, 0), [first]); + assert_eq!(structs(&old, 1), [first + 1]); + // New maps keep them + map.version.minor = 26; + let new = fixture.convert(&map); + assert_eq!(structs(&new, 0), [filler]); + assert_eq!(structs(&new, 1), [filler, first + 1]); + } + + #[test] + fn entry_points() { + let fixture = Fixture::new(); + let mut map = empty_map(); + map.info.north_grid_no = NOWHERE; + map.info.east_grid_no = 30000; + map.info.center_grid_no = 12880; + map.info.restricted_scroll_id = 1; + map.info.editor_smoothing_type = 2; + let info = fixture.convert(&map).info; + assert_eq!(info.north_entry, None); + assert_eq!(info.east_entry, None); + assert_eq!(info.center, Some(grid(12880))); + assert!(info.restricted_scroll); + assert_eq!(info.editor_smoothing, EditorSmoothing::Caves); + } + + #[test] + fn world_items() { + let fixture = Fixture::new(); + let world_item = |item, grid_no, render_z_height_above_level| Ja2WorldItem { + exists: true, + grid_no, + level: 1, + object: Ja2Object { + item, + attach_items: [0, 2, 0, 0], + attach_status: [0, 50, 0, 0], + ..default() + }, + render_z_height_above_level, + visible: 1, + ..default() + }; + let mut map = empty_map(); + map.info.center_grid_no = 100; + map.items = vec![ + world_item(1, 5, -1), + // No item + world_item(0, 5, 0), + // `NOWHERE` is the center + world_item(2, -1, 12), + // Unknown visibility + Ja2WorldItem { + visible: 9, + ..world_item(1, 5, 0) + }, + ]; + let converted = fixture.convert(&map); + let items = converted.items; + assert_eq!(items.len(), 2); + // Left out items are kept as they are, the others are numbered in their order + assert_eq!( + converted.skipped_items, + [map.items[1].clone(), map.items[3].clone()] + ); + assert_eq!(items.iter().map(|i| i.id.0).collect::>(), [0, 1]); + assert_eq!(items[0].file_grid_no, None); + assert_eq!(items[1].file_grid_no, Some(NOWHERE)); + assert_eq!(items[0].grid_no, grid(5)); + assert_eq!(items[0].level, Level::Roof); + assert_eq!(items[0].render_height, ItemHeight::ForceGround); + assert_eq!(items[0].visibility, Visibility::Visible); + assert_eq!( + items[0].graphic.as_ref().map(|tile| tile.id), + fixture.tileset.index(GUNS, 2) + ); + assert_eq!(items[0].stack.attachments, [(ItemId::new(2).unwrap(), 50)]); + assert_eq!(items[1].grid_no, grid(100)); + assert_eq!(items[1].render_height, ItemHeight::Above(12)); + // Items without a graphic of their own show the first gun + assert_eq!( + items[1].graphic.as_ref().map(|tile| tile.id), + fixture.tileset.index(GUNS, 1) + ); + + // Without a center, items at `NOWHERE` are left out + map.info.center_grid_no = NOWHERE; + let converted = fixture.convert(&map); + assert_eq!(converted.items.len(), 1); + assert_eq!(converted.skipped_items.len(), 3); + } + + #[test] + fn lights() { + let fixture = Fixture::new(); + let sprite = |x, flags, template_name: &str| Ja2LightSprite { + x, + y: 7, + flags, + template_name: template_name.to_owned(), + }; + let mut map = empty_map(); + map.lights = Some(Ja2Lights { + colors: vec![[1, 2, 3, 0]], + sprites: vec![ + sprite(5, 0, "L-R01.LHT"), + sprite(6, LIGHT_NIGHTTIME, "l-r01.lht"), + sprite(7, LIGHT_PRIMETIME | 0x1, "l-r01.lht"), + // Missing template and off the map + sprite(8, 0, "missing.lht"), + sprite(-1, 0, "l-r01.lht"), + ], + }); + let converted = fixture.convert(&map); + assert_eq!(converted.light_color, [1, 2, 3]); + let lights: Vec<_> = converted + .lights + .iter() + .map(|light| (light.id, light.position, light.time)) + .collect(); + assert_eq!( + lights, + [ + (LightId(0), GridPosition::new(5, 7), LightTime::Always), + (LightId(1), GridPosition::new(6, 7), LightTime::Night), + (LightId(2), GridPosition::new(7, 7), LightTime::PrimeTime), + ] + ); + // The lights keep the name and the record of the map file + let first = &converted.lights[0]; + assert_eq!(first.template_name, "L-R01.LHT"); + assert_eq!(first.radius(), Some(1)); + assert_eq!( + first.file_sprite.as_ref(), + map.lights.as_ref().map(|l| &l.sprites[0]) + ); + // The others are kept for saving + let skipped: Vec<_> = converted.skipped_lights.iter().map(|s| s.x).collect(); + assert_eq!(skipped, [8, -1]); + assert_eq!(fixture.convert(&empty_map()).light_color, [0; 3]); + } + + #[test] + fn old_caves_are_darker() { + let fixture = Fixture::new(); + let mut map = empty_map(); + map.ambient_light = Some(Ja2AmbientLight { + basement: false, + caves: true, + level: 5, + }); + map.info.map_version = 24; + assert_eq!( + fixture.convert(&map).ambient_light.map(|a| a.level), + Some(OLD_CAVES_LIGHTLEVEL) + ); + map.info.map_version = 25; + assert_eq!( + fixture.convert(&map).ambient_light.map(|a| a.level), + Some(5) + ); + } + + #[test] + fn schedules_and_doors() { + let fixture = Fixture::new(); + let mut map = empty_map(); + map.schedules = vec![ + Ja2Schedule { + time: [60, 120, 0, 0], + data1: [300, 400, 0, 0], + data2: [301, 0, 0, 0], + action: [1, 5, 0, 42], + schedule_id: 1, + ..default() + }, + Ja2Schedule::default(), + ]; + map.doors = vec![ + crate::file_formats::ja2_map::Ja2Door { + grid_no: 10, + trap_id: 2, + trap_level: 4, + lock_id: 0, + ..default() + }, + crate::file_formats::ja2_map::Ja2Door { + grid_no: 11, + trap_id: 99, + ..default() + }, + ]; + let converted = fixture.convert(&map); + assert_eq!(converted.schedules.len(), 1); + let schedule = &converted.schedules[0]; + assert_eq!(schedule.id, ScheduleId::new(1).unwrap()); + assert_eq!( + schedule.actions, + [ + Some(ScheduleEntry { + time: Some(60), + action: ScheduleAction::LockDoor { + door: grid(300), + destination: grid(301) + } + }), + Some(ScheduleEntry { + time: Some(120), + action: ScheduleAction::GridNo(grid(400)) + }), + None, + None, + ] + ); + assert_eq!(converted.doors.len(), 1); + assert_eq!( + converted.doors[0].trap, + Some(DoorTrap { + kind: DoorTrapKind::Electric, + level: 4 + }) + ); + assert_eq!(converted.doors[0].lock, None); + } + + pub(in crate::map) fn placement( + team: i8, + soldier_class: u8, + body_type: i8, + ) -> Ja2SoldierPlacement { + Ja2SoldierPlacement { + basic: Ja2BasicSoldierPlacement { + team, + soldier_class, + body_type, + ..default() + }, + detailed: None, + } + } + + pub(in crate::map) fn detailed() -> Ja2DetailedSoldierPlacement { + Ja2DetailedSoldierPlacement { + profile: 200, + inventory: vec![Ja2Object::default(); NUM_INV_SLOTS], + ..default() + } + } + + #[test] + fn validates_placements() { + let fixture = Fixture::new(); + let ctx = fixture.context(); + let convert = |placement: &Ja2SoldierPlacement| convert_placement(0, placement, &ctx); + let valid = convert(&placement(4, 8, 27)).unwrap(); + assert_eq!(valid.team.id, TeamId::CIV); + assert_eq!(valid.soldier_class.id, SoldierClassId::MINER); + assert_eq!(valid.body.id, SoldierBodyTypeId::JEEP); + assert!(convert(&placement(1, 0, -1)).is_ok()); + assert!(convert(&placement(1, 0, 28)) + .unwrap_err() + .contains("body type 28")); + assert!(convert(&placement(1, 0, -2)).is_err()); + assert!(convert(&placement(6, 0, 0)).unwrap_err().contains("team 6")); + assert!(convert(&placement(-1, 0, 0)).is_err()); + assert!(convert(&placement(1, 9, 0)) + .unwrap_err() + .contains("soldier class 9")); + let mut off_map = placement(1, 0, 0); + off_map.basic.starting_grid_no = JA2_MAP_TILES as u16; + assert!(convert(&off_map).is_err()); + let mut orders = placement(1, 0, 0); + orders.basic.orders = 8; + assert!(convert(&orders).unwrap_err().contains("orders 8")); + } + + #[test] + fn resolves_placement_values() { + let fixture = Fixture::new(); + let ctx = fixture.context(); + let mut raw = placement(1, 0, 0); + raw.basic.starting_grid_no = 500; + raw.basic.direction = 103; + raw.basic.on_roof = true; + raw.basic.orders = 4; + raw.basic.attitude = 7; + raw.basic.patrol_grid = [500, -1, 700, 800, 0, 0, 0, 0, 0, 0]; + raw.basic.patrol_cnt = 4; + let mut detailed = detailed(); + detailed.profile = 57; + detailed.life = -1; + detailed.agility = 80; + detailed.inventory[HANDPOS].item = 1; + detailed.schedule_id = 2; + raw.detailed = Some(detailed); + let converted = convert_placement(0, &raw, &ctx).unwrap(); + assert_eq!(converted.grid_no, grid(500)); + assert_eq!(converted.direction, Direction::SouthEast); + assert_eq!(converted.level, Level::Roof); + assert_eq!(converted.orders, Orders::PointPatrol); + assert_eq!(converted.attitude, Attitude::AttackSlayOnly); + // The first entry is not a patrol point + assert_eq!(converted.patrol, [grid(700), grid(800), grid(0)]); + // Holding a pistol + assert_eq!( + converted.surface.id, + crate::soldier::standing_surface::StandingSurfaceId::REGMALE_PISTOL + ); + let detailed = converted.detailed.unwrap(); + assert_eq!(detailed.profile.map(ProfileId::number), Some(57)); + assert_eq!(detailed.stats.life, None); + assert_eq!(detailed.stats.agility, Some(80)); + assert_eq!(detailed.schedule, ScheduleId::new(2)); + assert!(detailed.inventory[0].is_none()); + + let mut no_profile = placement(1, 0, 0); + no_profile.detailed = Some(self::detailed()); + let converted = convert_placement(0, &no_profile, &ctx).unwrap(); + assert_eq!(converted.detailed.unwrap().profile, None); + } + + #[test] + fn skips_invalid_placements() { + let fixture = Fixture::new(); + let map = Ja2Map { + placements: vec![placement(1, 2, 0), placement(9, 2, 0), placement(4, 0, -1)], + ..empty_map() + }; + let converted = fixture.convert(&map); + let valid = converted.soldiers; + let indices: Vec<_> = valid.iter().map(|placement| placement.index).collect(); + assert_eq!(indices, [0, 2]); + assert_eq!(valid[1].team.id, TeamId::CIV); + // Saving keeps them + assert_eq!(converted.skipped_placements, [placement(9, 2, 0)]); + } + + /// The body types chosen for `count` placements of `team`, `soldier_class` and `body_type` + fn body_types( + fixture: &Fixture, + team: i8, + soldier_class: u8, + body_type: i8, + count: usize, + ) -> Vec { + let ctx = fixture.context(); + (0..count) + .map(|index| { + let raw = placement(team, soldier_class, body_type); + convert_placement(index, &raw, &ctx).unwrap().body.id + }) + .collect() + } + + #[test] + fn chooses_body_types() { + use SoldierBodyTypeId as B; + let fixture = Fixture::new(); + let body_types = |team, soldier_class, body_type, count| { + body_types(&fixture, team, soldier_class, body_type, count) + }; + assert_eq!(body_types(1, 0, 27, 1), [B::JEEP]); + assert_eq!(body_types(4, 0, 3, 1), [B::REGFEMALE]); + assert_eq!(body_types(1, 2, 3, 1), [B::REGFEMALE]); + // Enemies that are not elite are never female, even with a given body type + assert!(!body_types(1, 0, 3, 50).contains(&B::REGFEMALE)); + assert_eq!(body_types(2, 7, -1, 3), [B::REGMALE; 3]); + let enemies = body_types(1, 3, -1, 200); + assert!(!enemies.contains(&B::REGFEMALE)); + assert!(enemies.contains(&B::STOCKYMALE)); + let elites = body_types(1, 2, -1, 200); + assert!(elites.contains(&B::REGFEMALE)); + let miners = body_types(4, 8, -1, 200); + assert!(miners + .iter() + .all(|body| [B::REGMALE, B::BIGMALE, B::MANCIV].contains(body))); + let civilians = body_types(4, 0, -1, 200); + assert!(civilians + .iter() + .all(|body| (11..=16).contains(&body.index()))); + assert!(civilians.contains(&B::KIDCIV)); + // The same placement always gets the same body type + assert_eq!(body_types(3, 4, -1, 20), body_types(3, 4, -1, 20)); + } + + /// The palettes of `count` placements, generated palettes as their replacement names + fn palettes( + fixture: &Fixture, + team: i8, + soldier_class: u8, + body_type: SoldierBodyTypeId, + detailed: Option, + count: usize, + ) -> Vec { + let ctx = fixture.context(); + (0..count) + .map(|index| { + let raw = Ja2SoldierPlacement { + detailed: detailed.clone(), + ..placement(team, soldier_class, body_type.index() as i8) + }; + convert_placement(index, &raw, &ctx).unwrap().palette + }) + .collect() + } + + fn names<'a>(fixture: &'a Fixture, palette: &ResolvedSoldierPalette) -> Vec<&'a str> { + match palette { + ResolvedSoldierPalette::Generated(ids) => ids + .iter() + .map(|&id| fixture.palettes.name(id).unwrap()) + .collect(), + _ => panic!("expected a generated palette, got {:?}", palette), + } + } + + #[test] + fn palettes_of_body_types() { + use SoldierBodyTypeId as B; + let fixture = Fixture::new(); + assert_eq!( + palettes(&fixture, 2, 7, B::QUEENMONSTER, None, 1), + [ResolvedSoldierPalette::Own] + ); + assert_eq!( + palettes(&fixture, 2, 7, B::AM_MONSTER, None, 1), + [ResolvedSoldierPalette::Substitution( + "anims/monsters/monster.col" + )] + ); + let larvae = palettes(&fixture, 2, 7, B::LARVAE_MONSTER, None, 1); + assert_eq!(names(&fixture, &larvae[0]).len(), 4); + } + + #[test] + fn classes_wear_their_uniform() { + let fixture = Fixture::new(); + for palette in palettes(&fixture, 1, 2, SoldierBodyTypeId::REGMALE, None, 50) { + let [head, vest, pants, skin] = names(&fixture, &palette)[..] else { + panic!("expected 4 replacements"); + }; + assert_eq!((vest, pants), ("BLACKSHIRT", "BLACKPANTS")); + if ["DARKSKIN", "BLACKSKIN"].contains(&skin) { + assert!(["BLACKHEAD", "BROWNHEAD"].contains(&head)); + } + } + } + + #[test] + fn visible_placements_keep_their_clothes() { + let fixture = Fixture::new(); + let detailed = Ja2DetailedSoldierPlacement { + visible: true, + vest_pal: "PURPLESHIRT".to_owned(), + pants_pal: "TANPANTS".to_owned(), + ..self::detailed() + }; + let visible = palettes( + &fixture, + 4, + 0, + SoldierBodyTypeId::MANCIV, + Some(detailed.clone()), + 20, + ); + // The replacements are applied in the order of the game + assert!(visible + .iter() + .all(|palette| names(&fixture, palette)[1..3] == ["PURPLESHIRT", "TANPANTS"])); + let hidden = Ja2DetailedSoldierPlacement { + visible: false, + ..detailed + }; + let hidden = palettes(&fixture, 4, 0, SoldierBodyTypeId::MANCIV, Some(hidden), 20); + assert!(hidden + .iter() + .any(|palette| names(&fixture, palette)[1] != "PURPLESHIRT")); + } + + #[test] + fn palettes_without_replacements_file() { + let fixture = Fixture::new(); + let ctx = MapContext { + palettes: None, + ..fixture.context() + }; + let raw = placement(1, 2, 0); + assert_eq!( + convert_placement(0, &raw, &ctx).unwrap().palette, + ResolvedSoldierPalette::Generated(vec![]) + ); + // Unknown names are left out + let palettes = PaletteReplacements::with_names(&["PINKSKIN", "BLACKSHIRT"]); + let ctx = MapContext { + palettes: Some(&palettes), + ..fixture.context() + }; + let ResolvedSoldierPalette::Generated(ids) = + convert_placement(0, &raw, &ctx).unwrap().palette + else { + panic!("expected a generated palette"); + }; + assert!(ids.contains(&palettes.find("BLACKSHIRT").unwrap())); + assert!(ids.len() < 4); + } + + /// Converts every `.dat` file in the directory given by `JA2_MAPS_DIR` with the test tileset + #[test] + fn convert_maps_from_dir() { + let Ok(dir) = std::env::var("JA2_MAPS_DIR") else { + eprintln!("JA2_MAPS_DIR not set, skipping"); + return; + }; + let fixture = Fixture::new(); + let ctx = fixture.context(); + let mut converted = 0; + let mut failures = vec![]; + for entry in std::fs::read_dir(&dir).expect("JA2_MAPS_DIR should be readable") { + let path = entry.expect("dir entry should be readable").path(); + let is_dat = path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("dat")); + if !is_dat { + continue; + } + let bytes = std::fs::read(&path).expect("map should be readable"); + let Ok(map) = Ja2Map::parse(&bytes) else { + continue; + }; + match TacticalMap::from_ja2(&map, &ctx) { + Ok(converted_map) => { + converted += 1; + // An unchanged map saves the same world items + let mut saved = map.clone(); + converted_map.apply_items(&mut saved); + if saved.items != map.items { + failures.push(format!("{}: the items changed", path.display())); + } + // And the same lights, the skipped ones last + converted_map.apply_lights(&mut saved); + let sprites = |lights: &Option| { + let mut sprites = lights + .as_ref() + .map(|lights| lights.sprites.clone()) + .unwrap_or_default(); + sprites.sort_by_key(|s| (s.x, s.y, s.flags, s.template_name.clone())); + sprites + }; + let colors = |lights: &Option| { + lights + .as_ref() + .and_then(|lights| lights.colors.first().copied()) + }; + if sprites(&saved.lights) != sprites(&map.lights) + || (map.lights.is_some() && colors(&saved.lights) != colors(&map.lights)) + || saved.ambient_light != map.ambient_light + { + failures.push(format!("{}: the lights changed", path.display())); + } + } + Err(e) => failures.push(format!("{}: {}", path.display(), e)), + } + } + assert!( + failures.is_empty(), + "failed to convert maps:\n{}", + failures.join("\n") + ); + assert!(converted > 0, "no maps found in {}", dir); + } +} diff --git a/src-map-editor/src/map/door.rs b/src-map-editor/src/map/door.rs new file mode 100644 index 0000000..f3853ac --- /dev/null +++ b/src-map-editor/src/map/door.rs @@ -0,0 +1,81 @@ +//! The door table of the map (`DOOR`) + +use serde::{Deserialize, Serialize}; + +use super::GridNo; + +/// A lock of the lock table (`ubLockID`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LockId(u8); + +impl LockId { + /// The lock with the given number, `None` for 0, which is no lock + pub fn new(number: u8) -> Option { + (number != 0).then_some(Self(number)) + } + + pub fn number(self) -> u8 { + self.0 + } +} + +/// Trap types of doors (`DoorTrapTypes`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DoorTrapKind { + Explosion, + Electric, + Siren, + SilentAlarm, + BrothelSiren, + SuperElectric, +} + +impl DoorTrapKind { + /// The trap of a raw trap id, `Ok(None)` for `NO_TRAP` and `Err` for unknown ids + pub fn from_number(number: u8) -> Result, u8> { + Ok(Some(match number { + 0 => return Ok(None), + 1 => Self::Explosion, + 2 => Self::Electric, + 3 => Self::Siren, + 4 => Self::SilentAlarm, + 5 => Self::BrothelSiren, + 6 => Self::SuperElectric, + _ => return Err(number), + })) + } + + /// The raw trap id, the inverse of [`DoorTrapKind::from_number`] + pub fn number(self) -> u8 { + match self { + Self::Explosion => 1, + Self::Electric => 2, + Self::Siren => 3, + Self::SilentAlarm => 4, + Self::BrothelSiren => 5, + Self::SuperElectric => 6, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DoorTrap { + pub kind: DoorTrapKind, + /// Difficulty of finding the trap, 0 to 10 + pub level: u8, +} + +/// Lock and trap of a door +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Door { + pub grid_no: GridNo, + pub locked: bool, + pub trap: Option, + pub lock: Option, + /// What the player believes about the lock (`DOOR_PERCEIVED_*`) + pub perceived_locked: i8, + /// What the player believes about the trap (`DOOR_PERCEIVED_*`) + pub perceived_trapped: i8, + pub lock_damage: i8, +} diff --git a/src-map-editor/src/map/edge_points.rs b/src-map-editor/src/map/edge_points.rs new file mode 100644 index 0000000..a9ec704 --- /dev/null +++ b/src-map-editor/src/map/edge_points.rs @@ -0,0 +1,50 @@ +//! The tiles at the edges of the map where soldiers enter the sector +//! +//! The editor generates them again when the map changed in a way that can move them, see +//! `render/edge_points.rs`. + +use super::GridNo; + +/// The entry tiles of one side of the map +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EdgePointList { + /// Index of the first tile of the inner row, the outer row comes first + /// (`gus1stNorthEdgepointMiddleIndex` and so on) + /// + /// The game moves it around when it removes tiles, so it can be past the end of the list. + pub middle_index: u16, + pub grid_nos: Vec, +} + +/// The entry tiles of all sides of the map +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EdgePointSet { + pub north: EdgePointList, + pub east: EdgePointList, + pub south: EdgePointList, + pub west: EdgePointList, +} + +impl EdgePointSet { + /// The lists in the order north, east, south, west + pub fn sides(&self) -> [&EdgePointList; 4] { + [&self.north, &self.east, &self.south, &self.west] + } + + pub fn sides_mut(&mut self) -> [&mut EdgePointList; 4] { + [ + &mut self.north, + &mut self.east, + &mut self.south, + &mut self.west, + ] + } +} + +/// The edge points of the map +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EdgePoints { + pub primary: EdgePointSet, + /// The entry tiles of the part of the map that is isolated from the primary edge points + pub secondary: Option, +} diff --git a/src-map-editor/src/map/exit_grid.rs b/src-map-editor/src/map/exit_grid.rs new file mode 100644 index 0000000..c79dfd4 --- /dev/null +++ b/src-map-editor/src/map/exit_grid.rs @@ -0,0 +1,98 @@ +//! Exit grids, the tiles that lead to another sector (`EXITGRID`) + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::GridNo; + +/// A sector of the strategic map +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SectorId { + /// Column, 1 to 16 + pub x: u8, + /// Row, 1 (A) to 16 (P) + pub y: u8, + /// Underground level, 0 on the surface + pub z: u8, +} + +impl SectorId { + /// The sector of a map file like `maps/a9.dat`, as the editor of the game reads it from the + /// file name (`SetGlobalSectorValues`), `None` if the name doesn't start with a sector + /// + /// Like the game, anything may follow the sector, and only a lower case `_b1` to `_b3` is + /// read as the level, so `H5_A.DAT` is H5 and `J11_B1.DAT` is J11 on the surface. + pub fn from_map_path(path: &str) -> Option { + let name = path.rsplit(['/', '\\']).next()?.as_bytes(); + let at = |index: usize| name.get(index).copied().unwrap_or(0); + let y = match at(0) { + row @ b'A'..=b'P' => row - b'A' + 1, + row @ b'a'..=b'p' => row - b'a' + 1, + _ => return None, + }; + let (x, rest) = match (at(1), at(2)) { + (digit @ b'1'..=b'9', next) if !next.is_ascii_digit() => (digit - b'0', 2), + (b'1', digit @ b'0'..=b'6') => (10 + digit - b'0', 3), + _ => return None, + }; + let z = match (at(rest), at(rest + 1), at(rest + 2)) { + (b'_', b'b', level @ b'1'..=b'3') => level - b'0', + _ => 0, + }; + Some(Self { x, y, z }) + } +} + +/// `J11`, or `J11 B1` underground +impl fmt::Display for SectorId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let row = match self.y { + 1..=26 => char::from(b'A' + self.y - 1), + _ => '?', + }; + write!(f, "{}{}", row, self.x)?; + if self.z > 0 { + write!(f, " B{}", self.z)?; + } + Ok(()) + } +} + +/// A tile that leads to another sector +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExitGrid { + pub grid_no: GridNo, + /// Tile in the destination sector + pub dest_grid_no: GridNo, + pub dest_sector: SectorId, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sectors_are_read_from_map_names() { + let sector = |x, y, z| Some(SectorId { x, y, z }); + assert_eq!(SectorId::from_map_path("maps/a9.dat"), sector(9, 1, 0)); + assert_eq!(SectorId::from_map_path("MAPS\\I14.DAT"), sector(14, 9, 0)); + assert_eq!(SectorId::from_map_path("j11_b1.dat"), sector(11, 10, 1)); + assert_eq!(SectorId::from_map_path("J11_B1.DAT"), sector(11, 10, 0)); + assert_eq!(SectorId::from_map_path("H5_A.DAT"), sector(5, 8, 0)); + assert_eq!( + SectorId::from_map_path("maps/p16_b3.dat"), + sector(16, 16, 3) + ); + for other in [ + "maps/q1.dat", + "maps/a17.dat", + "maps/a0.dat", + "maps/test.dat", + ] { + assert_eq!(SectorId::from_map_path(other), None, "{other}"); + } + assert_eq!(sector(11, 10, 1).unwrap().to_string(), "J11 B1"); + assert_eq!(sector(9, 1, 0).unwrap().to_string(), "A9"); + } +} diff --git a/src-map-editor/src/map/grid.rs b/src-map-editor/src/map/grid.rs new file mode 100644 index 0000000..3a9b346 --- /dev/null +++ b/src-map-editor/src/map/grid.rs @@ -0,0 +1,296 @@ +//! Grid numbers, the tile indices of the game, and per tile data indexed by them + +use std::ops::{Index, IndexMut}; + +use crate::components::map::{GridPosition, JA2_MAP_SIZE, JA2_MAP_TILES}; + +const WORLD_COLS: i32 = JA2_MAP_SIZE as i32; +const WORLD_MAX: i32 = JA2_MAP_TILES as i32; + +/// A tile of the map (`GridNo`), `y * 160 + x` +/// +/// Always on the map. `NOWHERE` and other invalid grid numbers of the map file are `None`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct GridNo(u16); + +impl GridNo { + /// The grid number with the given index, `None` if it is not on the map + pub fn new(index: usize) -> Option { + (index < JA2_MAP_TILES).then_some(Self(index as u16)) + } + + /// The grid number of a raw number of the game, `None` if it is not on the map + pub fn from_i32(grid_no: i32) -> Option { + (0..WORLD_MAX) + .contains(&grid_no) + .then_some(Self(grid_no as u16)) + } + + /// All grid numbers in ascending order + pub fn all() -> impl DoubleEndedIterator + ExactSizeIterator { + (0..JA2_MAP_TILES as u16).map(Self) + } + + pub const fn index(self) -> usize { + self.0 as usize + } + + pub fn position(self) -> GridPosition { + let size = u16::from(JA2_MAP_SIZE); + GridPosition::new((self.0 % size) as u8, (self.0 / size) as u8) + } + + /// The grid number `delta` away, `None` if that is not on the map + /// + /// This is the plain arithmetic of the game: steps past the left or right edge wrap into the + /// neighbouring row. + pub fn offset(self, delta: i32) -> Option { + Self::from_i32(i32::from(self.0) + delta) + } + + /// The tile `dx` columns and `dy` rows away, `None` if that is off the map + /// + /// Unlike [`GridNo::offset`], steps past the left or right edge don't wrap into the + /// neighbouring row. + pub fn neighbour(self, dx: i32, dy: i32) -> Option { + let position = self.position(); + GridPosition::from_xy(i32::from(position.x) + dx, i32::from(position.y) + dy) + .map(GridNo::from) + } + + /// The grid number one step away, or this one if the step leaves the map (`NewGridNo`) + pub fn step(self, direction: Direction) -> GridNo { + let grid_no = i32::from(self.0); + let proposed = grid_no + direction.grid_no_increment(); + if out_of_bounds(grid_no, proposed) { + self + } else { + GridNo(proposed as u16) + } + } +} + +impl From for GridNo { + fn from(position: GridPosition) -> Self { + Self(u16::from(position.y) * u16::from(JA2_MAP_SIZE) + u16::from(position.x)) + } +} + +/// Whether a step from `grid_no` to `proposed` leaves the map (`OutOfBounds`) +pub(crate) fn out_of_bounds(grid_no: i32, proposed: i32) -> bool { + let column = grid_no % WORLD_COLS; + let last_row_start = WORLD_MAX - WORLD_COLS; + if column != 0 && column != WORLD_COLS - 1 && grid_no < last_row_start && grid_no > WORLD_COLS { + return false; + } + if proposed < 0 { + return true; + } + let proposed_column = proposed % WORLD_COLS; + (column == 0 && proposed_column == WORLD_COLS - 1) + || (column == WORLD_COLS - 1 && proposed_column == 0) + || (grid_no >= last_row_start && proposed >= WORLD_MAX) +} + +/// One value for every tile of the map, indexed by [`GridNo`] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GridArray(Box<[T]>); + +impl GridArray { + pub fn from_fn(mut f: impl FnMut(GridNo) -> T) -> Self { + Self(GridNo::all().map(&mut f).collect()) + } + + /// The values with their grid numbers, in ascending order + pub fn iter(&self) -> impl DoubleEndedIterator + ExactSizeIterator { + GridNo::all().zip(self.0.iter()) + } + + pub fn values(&self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.0.iter() + } + + pub fn values_mut(&mut self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.0.iter_mut() + } +} + +impl GridArray { + /// Every tile with the same value + pub fn filled(value: T) -> Self { + Self(vec![value; JA2_MAP_TILES].into_boxed_slice()) + } +} + +impl Default for GridArray { + fn default() -> Self { + Self::from_fn(|_| T::default()) + } +} + +impl Index for GridArray { + type Output = T; + + fn index(&self, grid_no: GridNo) -> &T { + &self.0[grid_no.index()] + } +} + +impl IndexMut for GridArray { + fn index_mut(&mut self, grid_no: GridNo) -> &mut T { + &mut self.0[grid_no.index()] + } +} + +/// Height level of the game (`bLevel`), also the floor of the viewer (`"ground"` or `"roof"`) +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "lowercase")] +#[repr(u8)] +pub enum Level { + #[default] + Ground = 0, + /// On a roof + Roof = 1, +} + +impl Level { + /// The level from the number of the map file, anything but 0 is the roof + pub fn from_number(number: u8) -> Self { + if number == 0 { + Level::Ground + } else { + Level::Roof + } + } + + /// Number of the level, 0 or 1 + pub const fn number(self) -> u8 { + self as u8 + } +} + +/// Directions of the game (`WorldDirections`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +#[repr(u8)] +pub enum Direction { + North, + NorthEast, + East, + SouthEast, + South, + SouthWest, + West, + NorthWest, +} + +impl Direction { + pub const ALL: [Direction; 8] = [ + Direction::North, + Direction::NorthEast, + Direction::East, + Direction::SouthEast, + Direction::South, + Direction::SouthWest, + Direction::West, + Direction::NorthWest, + ]; + + /// Direction with the given number, like `DirectionInc` numbers > 7 become north east + pub fn from_number(number: u8) -> Direction { + Direction::ALL + .get(usize::from(number)) + .copied() + .unwrap_or(Direction::NorthEast) + } + + /// Whether the direction is diagonal on the grid, the odd directions of the game + pub fn is_diagonal(self) -> bool { + (self as u8) & 1 == 1 + } + + /// Grid number change of a step in this direction (`DirIncrementer`) + pub fn grid_no_increment(self) -> i32 { + match self { + Direction::North => -WORLD_COLS, + Direction::NorthEast => 1 - WORLD_COLS, + Direction::East => 1, + Direction::SouthEast => 1 + WORLD_COLS, + Direction::South => WORLD_COLS, + Direction::SouthWest => WORLD_COLS - 1, + Direction::West => -1, + Direction::NorthWest => -WORLD_COLS - 1, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grid_no(index: usize) -> GridNo { + GridNo::new(index).unwrap() + } + + #[test] + fn grid_numbers() { + assert_eq!(GridNo::from(GridPosition::new(3, 2)), grid_no(323)); + assert_eq!(grid_no(323).position(), GridPosition::new(3, 2)); + assert_eq!(GridNo::new(JA2_MAP_TILES), None); + assert_eq!(GridNo::from_i32(-1), None); + assert_eq!(GridNo::from_i32(WORLD_MAX), None); + assert_eq!( + GridNo::from_i32(WORLD_MAX - 1), + Some(grid_no(JA2_MAP_TILES - 1)) + ); + assert_eq!(GridNo::all().len(), JA2_MAP_TILES); + } + + #[test] + fn offsets_wrap_across_rows() { + assert_eq!(grid_no(320).offset(-1), Some(grid_no(319))); + assert_eq!(grid_no(5).offset(-WORLD_COLS), None); + assert_eq!(grid_no(JA2_MAP_TILES - 5).offset(WORLD_COLS), None); + assert_eq!(grid_no(0).offset(0), Some(grid_no(0))); + } + + #[test] + fn neighbours_do_not_wrap() { + assert_eq!(grid_no(321).neighbour(-1, -1), Some(grid_no(160))); + assert_eq!(grid_no(320).neighbour(-1, 0), None); + assert_eq!(grid_no(159).neighbour(1, 0), None); + assert_eq!(grid_no(5).neighbour(0, -1), None); + assert_eq!(grid_no(5).neighbour(0, 2), Some(grid_no(325))); + } + + #[test] + fn steps_stay_on_the_map() { + let step = |index, direction| grid_no(index).step(direction).index(); + // Inside of the map + assert_eq!(step(161 * 2, Direction::North), 161 * 2 - 160); + // Left edge + assert_eq!(step(320, Direction::West), 320); + assert_eq!(step(320, Direction::East), 321); + // Right edge + assert_eq!(step(479, Direction::East), 479); + // Top edge + assert_eq!(step(5, Direction::North), 5); + // Bottom edge + assert_eq!(step(JA2_MAP_TILES - 5, Direction::South), JA2_MAP_TILES - 5); + // The game treats grid number 160 as an edge tile, but it still moves inside + assert_eq!(step(161, Direction::North), 1); + } + + #[test] + fn grid_arrays() { + let mut array = GridArray::filled(0u8); + array[grid_no(7)] = 3; + assert_eq!(array[grid_no(7)], 3); + let set: Vec<_> = array.iter().filter(|(_, &v)| v != 0).collect(); + assert_eq!(set, vec![(grid_no(7), &3)]); + let indices = GridArray::from_fn(|grid_no| grid_no.index()); + assert_eq!(indices[grid_no(JA2_MAP_TILES - 1)], JA2_MAP_TILES - 1); + } +} diff --git a/src-map-editor/src/map/item.rs b/src-map-editor/src/map/item.rs new file mode 100644 index 0000000..16743aa --- /dev/null +++ b/src-map-editor/src/map/item.rs @@ -0,0 +1,146 @@ +//! Items that are placed on the map (`WORLDITEM`) and item stacks (`OBJECTTYPE`) + +use std::sync::Arc; + +use bitflags::bitflags; +use serde::{Deserialize, Serialize}; + +use super::{GridNo, Level}; +use crate::{file_formats::ja2_map::Ja2Object, item::ItemId, tileset::Tile}; + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct WorldItemFlags: u16 { + const DONTRENDER = 0x0001; + const FIND_SWEETSPOT_FROM_GRIDNO = 0x0002; + const ARMED_BOMB = 0x0040; + const SCIFI_ONLY = 0x0080; + const REALISTIC_ONLY = 0x0100; + const REACHABLE = 0x0200; + const GRIDNO_NOT_SET_USE_ENTRY_POINT = 0x0400; + } +} + +/// A stack of items, as items on the map and in inventories are stored (`OBJECTTYPE`) +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ItemStack { + pub item: ItemId, + /// Number of items in the stack + pub number: u8, + /// Item class specific data (gun, ammo, money, bomb, key, owner). The meaning depends on the + /// item class, which is not part of the map file. + pub data: [u8; 12], + /// Attached items and their status + pub attachments: Vec<(ItemId, i8)>, + pub flags: i8, + pub mission: u8, + pub trap: i8, + pub imprint_id: u8, + pub weight: u8, + pub used: u8, +} + +/// How high an item is drawn (`bRenderZHeightAboveLevel`) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ItemHeight { + /// Always on the ground, never moved onto a structure (-1) + ForceGround, + /// Pixels above the level, unless the item is moved onto a structure + Above(u8), +} + +impl ItemHeight { + /// The raw value of the map file + pub fn number(self) -> i8 { + match self { + Self::ForceGround => -1, + Self::Above(height) => height.min(i8::MAX as u8) as i8, + } + } +} + +/// Whether an item can be seen (`Visibility`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Visibility { + HiddenItem, + Buried, + HiddenInObject, + Invisible, + /// `VISIBILITY_0` + Unseen, + Visible, +} + +impl Visibility { + /// The visibility of a raw value of the game, `None` if it has no name + pub fn from_number(number: i8) -> Option { + Some(match number { + -4 => Self::HiddenItem, + -3 => Self::Buried, + -2 => Self::HiddenInObject, + -1 => Self::Invisible, + 0 => Self::Unseen, + 1 => Self::Visible, + _ => return None, + }) + } + + /// The raw value, the inverse of [`Visibility::from_number`] + pub fn number(self) -> i8 { + match self { + Self::HiddenItem => -4, + Self::Buried => -3, + Self::HiddenInObject => -2, + Self::Invisible => -1, + Self::Unseen => 0, + Self::Visible => 1, + } + } +} + +/// Identifies a world item while the map is edited, it stays the same when the item is changed +/// or restored by undo +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct WorldItemId(pub u32); + +/// An item placed on the map (`WORLDITEM`) +#[derive(Clone, Debug)] +pub struct MapItem { + pub id: WorldItemId, + /// Tile of the item, items of the map file without a tile are at the center of the map + pub grid_no: GridNo, + /// The raw grid number of items of the map file that are not on the map (`NOWHERE` and the + /// like), which they are saved with unless they are moved + pub file_grid_no: Option, + pub level: Level, + pub stack: ItemStack, + /// The item stack of the map file if saving `stack` would change it, e.g. because its + /// attachments are not in the first slots. It is saved as long as `stack` is unchanged. + pub file_object: Option, + /// The graphic of the item on the ground, `None` if it has none + pub graphic: Option>, + pub render_height: ItemHeight, + pub visibility: Visibility, + pub flags: WorldItemFlags, + /// Chance of the item not existing in the game, in percent + pub non_exist_chance: u8, +} + +impl PartialEq for MapItem { + /// Graphics are equal if they are the same tile + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.grid_no == other.grid_no + && self.file_grid_no == other.file_grid_no + && self.level == other.level + && self.stack == other.stack + && self.file_object == other.file_object + && self.graphic.as_ref().map(|tile| tile.id) + == other.graphic.as_ref().map(|tile| tile.id) + && self.render_height == other.render_height + && self.visibility == other.visibility + && self.flags == other.flags + && self.non_exist_chance == other.non_exist_chance + } +} diff --git a/src-map-editor/src/map/item_data.rs b/src-map-editor/src/map/item_data.rs new file mode 100644 index 0000000..997f4f0 --- /dev/null +++ b/src-map-editor/src/map/item_data.rs @@ -0,0 +1,226 @@ +//! Typed views of the item class specific data of an item stack (the union in `OBJECTTYPE`) +//! +//! The 12 bytes mean different things for guns, money, bombs, keys and ownership items, the item +//! class decides which view applies. Each view only reads and writes its own bytes, the others +//! are kept. The offsets are the ones of `Item_Types.h`, the host decodes the same layout +//! (`itemFields.ts`). + +/// Raw data of an item stack +pub type ItemData = [u8; 12]; + +fn read_u16(data: &ItemData, offset: usize) -> u16 { + u16::from_le_bytes([data[offset], data[offset + 1]]) +} + +fn write_u16(data: &mut ItemData, offset: usize, value: u16) { + data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); +} + +/// A gun (`bGunStatus`, `ubGunAmmoType`, `ubGunShotsLeft`, `usGunAmmoItem`, `bGunAmmoStatus`) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GunData { + pub status: i8, + pub ammo_type: u8, + pub shots_left: u8, + /// The magazine in the gun, 0 for none + pub ammo_item: u16, + pub ammo_status: i8, +} + +impl GunData { + pub fn read(data: &ItemData) -> Self { + Self { + status: data[0] as i8, + ammo_type: data[1], + shots_left: data[2], + ammo_item: read_u16(data, 4), + ammo_status: data[6] as i8, + } + } + + pub fn write(&self, data: &mut ItemData) { + data[0] = self.status as u8; + data[1] = self.ammo_type; + data[2] = self.shots_left; + write_u16(data, 4, self.ammo_item); + data[6] = self.ammo_status as u8; + } +} + +/// Money (`bMoneyStatus`, `uiMoneyAmount`) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MoneyData { + pub status: i8, + pub amount: u32, +} + +impl MoneyData { + pub fn read(data: &ItemData) -> Self { + Self { + status: data[0] as i8, + amount: u32::from_le_bytes([data[4], data[5], data[6], data[7]]), + } + } + + pub fn write(&self, data: &mut ItemData) { + data[0] = self.status as u8; + data[4..8].copy_from_slice(&self.amount.to_le_bytes()); + } +} + +/// How a placed bomb goes off (`DetonatorType`) +pub const BOMB_TIMED: i8 = 1; +pub const BOMB_REMOTE: i8 = 2; +pub const BOMB_PRESSURE: i8 = 3; + +/// A placed bomb, a switch or an action item (`bBombStatus`, `bDetonatorType`, `usBombItem`, +/// `bFrequency` or `bDelay`, `ubBombOwner`, `bActionValue`, `ubTolerance`) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BombData { + pub status: i8, + pub detonator: i8, + /// The item that explodes, 0 for actions without an explosion + pub bomb_item: u16, + /// The frequency of remote bombs and triggers, the delay of timed bombs + pub frequency: i8, + pub owner: u8, + /// What the action item does (`ItemActionType`) + pub action_value: u8, + /// How likely the AI is to use a panic trigger + pub tolerance: u8, +} + +impl BombData { + pub fn read(data: &ItemData) -> Self { + Self { + status: data[0] as i8, + detonator: data[1] as i8, + bomb_item: read_u16(data, 2), + frequency: data[4] as i8, + owner: data[5], + action_value: data[6], + tolerance: data[7], + } + } + + pub fn write(&self, data: &mut ItemData) { + data[0] = self.status as u8; + data[1] = self.detonator as u8; + write_u16(data, 2, self.bomb_item); + data[4] = self.frequency as u8; + data[5] = self.owner; + data[6] = self.action_value; + data[7] = self.tolerance; + } +} + +/// A key (`bKeyStatus[0]`, `ubKeyID`) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct KeyData { + pub status: i8, + /// The lock the key opens, the index in `Locks.bin` + pub key_id: u8, +} + +impl KeyData { + pub fn read(data: &ItemData) -> Self { + Self { + status: data[0] as i8, + key_id: data[6], + } + } + + pub fn write(&self, data: &mut ItemData) { + data[0] = self.status as u8; + data[6] = self.key_id; + } +} + +/// An ownership item (`ubOwnerProfile`, `ubOwnerCivGroup`) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OwnerData { + pub profile: u8, + pub civ_group: u8, +} + +impl OwnerData { + pub fn read(data: &ItemData) -> Self { + Self { + profile: data[0], + civ_group: data[1], + } + } + + pub fn write(&self, data: &mut ItemData) { + data[0] = self.profile; + data[1] = self.civ_group; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn views_use_the_offsets_of_the_game() { + let mut data = [0xee; 12]; + GunData { + status: 90, + ammo_type: 1, + shots_left: 15, + ammo_item: 0x0147, + ammo_status: 100, + } + .write(&mut data); + assert_eq!( + data, + [90, 1, 15, 0xee, 0x47, 0x01, 100, 0xee, 0xee, 0xee, 0xee, 0xee] + ); + assert_eq!(GunData::read(&data).ammo_item, 0x0147); + + let mut data = [0; 12]; + MoneyData { + status: 100, + amount: 20000, + } + .write(&mut data); + assert_eq!(data[..8], [100, 0, 0, 0, 0x20, 0x4e, 0, 0]); + assert_eq!(MoneyData::read(&data).amount, 20000); + + let mut data = [0; 12]; + let bomb = BombData { + status: 100, + detonator: BOMB_PRESSURE, + bomb_item: 137, + frequency: -1, + owner: 1, + action_value: 3, + tolerance: 50, + }; + bomb.write(&mut data); + assert_eq!(data[..8], [100, 3, 137, 0, 0xff, 1, 3, 50]); + assert_eq!(BombData::read(&data), bomb); + + // Views only change their own bytes + let mut data = [7; 12]; + KeyData { + status: 80, + key_id: 12, + } + .write(&mut data); + assert_eq!(data, [80, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7]); + OwnerData { + profile: 200, + civ_group: 3, + } + .write(&mut data); + assert_eq!(data[..3], [200, 3, 7]); + assert_eq!( + OwnerData::read(&data), + OwnerData { + profile: 200, + civ_group: 3 + } + ); + } +} diff --git a/src-map-editor/src/map/light.rs b/src-map-editor/src/map/light.rs new file mode 100644 index 0000000..571daf0 --- /dev/null +++ b/src-map-editor/src/map/light.rs @@ -0,0 +1,132 @@ +//! The lighting of the map: the ambient light and the light sprites + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::{ + components::map::GridPosition, file_formats::ja2_map::Ja2LightSprite, light::LightTemplate, +}; + +/// Light sprite flags of the map file (`LIGHT_SPR_ACTIVE`, `LIGHT_SPR_ON`, `LIGHT_PRIMETIME`, +/// `LIGHT_NIGHTTIME`) +pub const LIGHT_SPR_ACTIVE: u32 = 0x1; +pub const LIGHT_SPR_ON: u32 = 0x2; +pub const LIGHT_PRIMETIME: u32 = 0x80; +pub const LIGHT_NIGHTTIME: u32 = 0x100; + +/// Ambient light of basements and caves (`LightSetBaseLevel` of the saved level) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AmbientLight { + pub basement: bool, + pub caves: bool, + /// Ambient light level, higher is darker. For caves of old maps this is already the level + /// the game uses instead of the saved one. + pub level: u8, +} + +/// When a light sprite is on (`LIGHT_PRIMETIME`, `LIGHT_NIGHTTIME`) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightTime { + /// Always on + Always, + /// On at night + Night, + /// On in the evening, the default of the original editor + #[default] + PrimeTime, +} + +impl LightTime { + /// The time of the flags of a light sprite of the map file + pub fn from_flags(flags: u32) -> Self { + if flags & LIGHT_PRIMETIME != 0 { + LightTime::PrimeTime + } else if flags & LIGHT_NIGHTTIME != 0 { + LightTime::Night + } else { + LightTime::Always + } + } + + /// The time flag of a light sprite + pub fn flags(self) -> u32 { + match self { + LightTime::Always => 0, + LightTime::Night => LIGHT_NIGHTTIME, + LightTime::PrimeTime => LIGHT_PRIMETIME, + } + } +} + +/// Identifies a light while the map is edited, ids are never used twice +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct LightId(pub u32); + +/// The template file of the lights of a radius that the editor places (`PlaceLight`) +pub fn light_template_name(radius: u8) -> String { + format!("L-R{:02}.LHT", radius) +} + +/// A light sprite of the map +#[derive(Clone, Debug)] +pub struct MapLight { + pub id: LightId, + /// Tile of the light + pub position: GridPosition, + pub time: LightTime, + /// File name of the template as the map file has it, e.g. `L-R05.LHT` + pub template_name: String, + pub template: Arc, + /// The light sprite of the map file, saved as it was while the light is unchanged + pub file_sprite: Option, +} + +impl MapLight { + /// The radius of the editor's templates (`L-R0N.LHT`), `None` for other templates + pub fn radius(&self) -> Option { + let name = self.template_name.to_lowercase(); + let radius = name.strip_prefix("l-r")?.strip_suffix(".lht")?; + radius + .parse() + .ok() + .filter(|radius| (1..=99).contains(radius)) + } +} + +impl PartialEq for MapLight { + /// Templates are equal if they have the same name + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.position == other.position + && self.time == other.time + && self.template_name == other.template_name + && self.file_sprite == other.file_sprite + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn radii_come_from_the_template_names() { + let light = |name: &str| MapLight { + id: LightId(0), + position: GridPosition::new(1, 1), + time: LightTime::Always, + template_name: name.to_owned(), + template: Arc::default(), + file_sprite: None, + }; + assert_eq!(light(&light_template_name(3)).radius(), Some(3)); + assert_eq!(light("l-r08.lht").radius(), Some(8)); + assert_eq!(light("L-R00.LHT").radius(), None); + assert_eq!(light("LAMP.LHT").radius(), None); + assert_eq!(light_template_name(5), "L-R05.LHT"); + for time in [LightTime::Always, LightTime::Night, LightTime::PrimeTime] { + assert_eq!(LightTime::from_flags(time.flags() | LIGHT_SPR_ON), time); + } + } +} diff --git a/src-map-editor/src/map/mod.rs b/src-map-editor/src/map/mod.rs new file mode 100644 index 0000000..3075807 --- /dev/null +++ b/src-map-editor/src/map/mod.rs @@ -0,0 +1,212 @@ +//! The loaded map, the runtime form of the map file +//! +//! [`TacticalMap::from_ja2`] turns the file format map (`Ja2Map`) into a [`TacticalMap`] once the +//! tileset, the item files, the light templates and the soldier palettes are loaded. Raw numbers +//! become typed values: grid numbers are [`GridNo`]s, tiles are the [`Tile`](crate::tileset::Tile)s +//! of the tileset, light templates, items and soldier definitions are resolved, the random choices +//! of the game for soldiers are made, and the version specific fix-ups of the game are applied. +//! Magic values like `NOWHERE` or `NO_PROFILE` become `None`. Anything that can't be converted is +//! left out with a warning, so the renderer never needs to know about the map file. + +use std::{collections::HashMap, sync::Arc}; + +use bevy::prelude::Resource; +use serde::{Deserialize, Serialize}; + +use crate::{ + file_formats::ja2_map::{Ja2ExitGrid, Ja2LightSprite, Ja2SoldierPlacement, Ja2WorldItem}, + item::ItemTable, + light::LightTemplate, + soldier::{palette_files::PaletteReplacements, SoldierDefinitions}, + tileset::{Tileset, TilesetId}, +}; + +mod convert; +pub mod door; +pub mod edge_points; +pub mod exit_grid; +pub mod grid; +pub mod item; +pub mod item_data; +pub mod light; +mod new_map; +pub mod placement; +mod save; +pub mod schedule; +pub mod tile; + +#[cfg(test)] +pub(crate) use convert::tests as convert_tests; +pub use convert::{convert_ambient_light, item_graphic}; +pub use door::*; +pub use edge_points::*; +pub use exit_grid::*; +pub use grid::*; +pub use item::*; +pub use light::*; +pub use new_map::new_ja2_map; +pub use placement::*; +pub use save::{MAX_NUM_SOLDIERS, MAX_SCHEDULES, OBJECT_UNDROPPABLE}; +pub use schedule::*; +pub use tile::*; + +/// How the editor smooths the terrain of the map (`ubEditorSmoothingType`) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EditorSmoothing { + #[default] + Normal, + Basement, + Caves, +} + +impl EditorSmoothing { + pub fn from_number(number: u8) -> Option { + Some(match number { + 0 => Self::Normal, + 1 => Self::Basement, + 2 => Self::Caves, + _ => return None, + }) + } + + /// The raw smoothing type, the inverse of [`EditorSmoothing::from_number`] + pub fn number(self) -> u8 { + match self { + Self::Normal => 0, + Self::Basement => 1, + Self::Caves => 2, + } + } +} + +/// General information of the map (`MAPCREATE_STRUCT`) +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MapInfo { + /// Where soldiers enter the map from the neighbouring sectors, `None` if they can't + pub north_entry: Option, + pub east_entry: Option, + pub south_entry: Option, + pub west_entry: Option, + pub center: Option, + /// Entry point of the part of the map that is isolated from the edges + pub isolated: Option, + /// Scrolling restriction of basements and caves (`ubRestrictedScrollID`) + pub restricted_scroll: bool, + pub editor_smoothing: EditorSmoothing, +} + +/// One of the entry points of [`MapInfo`] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EntryPoint { + #[default] + North, + East, + South, + West, + Center, + Isolated, +} + +impl EntryPoint { + pub const ALL: [EntryPoint; 6] = [ + EntryPoint::North, + EntryPoint::East, + EntryPoint::South, + EntryPoint::West, + EntryPoint::Center, + EntryPoint::Isolated, + ]; +} + +impl MapInfo { + /// The tile of an entry point + pub fn entry(&self, point: EntryPoint) -> Option { + match point { + EntryPoint::North => self.north_entry, + EntryPoint::East => self.east_entry, + EntryPoint::South => self.south_entry, + EntryPoint::West => self.west_entry, + EntryPoint::Center => self.center, + EntryPoint::Isolated => self.isolated, + } + } + + pub fn entry_mut(&mut self, point: EntryPoint) -> &mut Option { + match point { + EntryPoint::North => &mut self.north_entry, + EntryPoint::East => &mut self.east_entry, + EntryPoint::South => &mut self.south_entry, + EntryPoint::West => &mut self.west_entry, + EntryPoint::Center => &mut self.center, + EntryPoint::Isolated => &mut self.isolated, + } + } +} + +/// The loaded map +#[derive(Resource, Clone, Debug)] +pub struct TacticalMap { + pub tileset: TilesetId, + pub tiles: GridArray, + pub info: MapInfo, + /// Saved ambient light, only basements and caves have one + pub ambient_light: Option, + /// Added to all palettes (`g_light_color`), black if the map has no light colour + pub light_color: [u8; 3], + pub lights: Vec, + /// The light sprites of the map file that could not be converted, saved as they are + pub skipped_lights: Vec, + pub items: Vec, + /// The world items of the map file that could not be converted, saved as they are + pub skipped_items: Vec, + pub soldiers: Vec, + /// The placements of the map file that could not be converted, saved as they are + pub skipped_placements: Vec, + pub exit_grids: Vec, + /// The exit grids of the map file that could not be converted, saved as they are + pub skipped_exit_grids: Vec, + pub doors: Vec, + pub edge_points: EdgePoints, + /// Whether the edge points were generated again since the map was loaded, only then they are + /// saved, so the edge points of the map file are kept otherwise + pub edge_points_stale: bool, + pub schedules: Vec, +} + +impl TacticalMap { + /// A map without any tiles, lights, items or soldiers + pub fn empty(tileset: TilesetId) -> Self { + Self { + tileset, + tiles: GridArray::default(), + info: MapInfo::default(), + ambient_light: None, + light_color: [0; 3], + lights: vec![], + skipped_lights: vec![], + items: vec![], + skipped_items: vec![], + soldiers: vec![], + skipped_placements: vec![], + exit_grids: vec![], + skipped_exit_grids: vec![], + doors: vec![], + edge_points: EdgePoints::default(), + edge_points_stale: false, + schedules: vec![], + } + } +} + +/// Everything besides the map file that the conversion needs +pub struct MapContext<'a> { + pub tileset: &'a Tileset, + pub items: &'a ItemTable, + pub soldiers: SoldierDefinitions<'a>, + /// `None` if `ja2pal.dat` could not be loaded, soldiers then keep their animation palette + pub palettes: Option<&'a PaletteReplacements>, + /// Light templates by lower case file name, lights whose template is missing are left out + pub light_templates: &'a HashMap>, +} diff --git a/src-map-editor/src/map/new_map.rs b/src-map-editor/src/map/new_map.rs new file mode 100644 index 0000000..f3e40c7 --- /dev/null +++ b/src-map-editor/src/map/new_map.rs @@ -0,0 +1,196 @@ +//! New maps, like the original editor creates them (`CreateNewMap`, `NewWorld`) + +use crate::{ + components::map::JA2_MAP_TILES, + editor::lights::SURFACE_AMBIENT_LEVEL, + file_formats::{ + ja2_map::{ + Ja2AmbientLight, Ja2Map, Ja2MapElement, Ja2MapFlags, Ja2MapInfo, Ja2MapVersion, + Ja2TileLayer, + }, + ja2_tile_types::{TileType, FIRSTROOF, FIRSTTEXTURE, FIRSTWALL}, + }, + random::Random, + tileset::TilesetId, +}; + +use super::{convert::NOWHERE, EditorSmoothing}; + +/// Minor version of new maps, the one of the vanilla maps (6.00/26 is the russian one) +const NEW_MAP_MINOR_VERSION: u8 = 25; + +fn piece(tile_type: TileType, sub_index: u32) -> Ja2TileLayer { + Ja2TileLayer { + kind: tile_type as u8, + sub_index: sub_index as u16, + } +} + +/// A map file of a new map: every tile has a random texture, basements are roofed and caves are +/// solid rock +/// +/// Nothing else is placed, the entry points are not set. Basements and caves get the ambient +/// light that switching to them gets in the editor. +pub fn new_ja2_map(tileset: TilesetId, smoothing: EditorSmoothing, random: &mut Random) -> Ja2Map { + let map_elements = (0..JA2_MAP_TILES) + .map(|_| { + let mut element = Ja2MapElement { + // `NewWorld`: one of the first 10 textures + land: vec![piece(FIRSTTEXTURE, 1 + random.next(10))], + ..Default::default() + }; + match smoothing { + EditorSmoothing::Normal => {} + EditorSmoothing::Basement => { + element.roofs = vec![piece(FIRSTROOF, 9 + random.next(3))]; + } + // `AddCave`, the editor marks the rock as caves once the map is loaded + EditorSmoothing::Caves => { + element.structs = vec![piece(FIRSTWALL, 60 + random.next(6))]; + } + } + element + }) + .collect(); + let ambient_light = (smoothing != EditorSmoothing::Normal).then(|| Ja2AmbientLight { + basement: smoothing == EditorSmoothing::Basement, + caves: smoothing == EditorSmoothing::Caves, + level: SURFACE_AMBIENT_LEVEL, + }); + Ja2Map { + version: Ja2MapVersion { + major: 6., + minor: NEW_MAP_MINOR_VERSION, + }, + flags: if ambient_light.is_some() { + Ja2MapFlags::MAP_AMBIENTLIGHTLEVEL_SAVED + } else { + Ja2MapFlags::empty() + }, + tileset_id: tileset.index() as i32, + map_elements, + ambient_light, + info: Ja2MapInfo { + north_grid_no: NOWHERE, + east_grid_no: NOWHERE, + south_grid_no: NOWHERE, + west_grid_no: NOWHERE, + center_grid_no: NOWHERE, + isolated_grid_no: NOWHERE, + map_version: NEW_MAP_MINOR_VERSION, + editor_smoothing_type: smoothing.number(), + ..Default::default() + }, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::{ + editor::caves::mark_caves, + file_formats::ja2_tile_types::TILE_TYPE_NAMES, + map::{convert_tests::Fixture, AmbientLight, TacticalMap}, + tileset::Tile, + }; + + fn new_map(smoothing: EditorSmoothing) -> Ja2Map { + new_ja2_map(TilesetId::new(3), smoothing, &mut Random::new(1)) + } + + /// The tile types and sub indexes of the tiles of a layer + fn pieces(tiles: &[Arc]) -> Vec<(&str, u16)> { + tiles + .iter() + .map(|tile| (tile.type_name.as_str(), tile.sub_index)) + .collect() + } + + #[test] + fn new_maps_survive_a_round_trip() { + for smoothing in [ + EditorSmoothing::Normal, + EditorSmoothing::Basement, + EditorSmoothing::Caves, + ] { + let map = new_map(smoothing); + let parsed = Ja2Map::parse(&map.to_bytes().unwrap()).unwrap(); + assert_eq!(parsed, map, "{:?}", smoothing); + assert_eq!(parsed.tileset_id, 3); + assert_eq!((parsed.version.major, parsed.version.minor), (6., 25)); + } + } + + #[test] + fn outdoor_maps_have_textures() { + let fixture = Fixture::new(); + let map = fixture.convert(&new_map(EditorSmoothing::Normal)); + assert_eq!(map.tileset, TilesetId::new(3)); + assert_eq!(map.info.editor_smoothing, EditorSmoothing::Normal); + assert_eq!(map.ambient_light, None); + assert_eq!(map.info.north_entry, None); + assert_eq!(map.info.center, None); + let mut sub_indexes = vec![]; + for tile in map.tiles.values() { + let land = pieces(&tile.land); + assert_eq!(land.len(), 1); + assert_eq!(land[0].0, TILE_TYPE_NAMES[FIRSTTEXTURE]); + sub_indexes.push(land[0].1); + assert!(tile.structs.is_empty() && tile.roofs.is_empty()); + } + assert!(sub_indexes.iter().all(|i| (1..=10).contains(i))); + // The textures are mixed + assert!(sub_indexes.contains(&1) && sub_indexes.contains(&10)); + } + + #[test] + fn basements_are_roofed() { + let fixture = Fixture::new(); + let map = fixture.convert(&new_map(EditorSmoothing::Basement)); + assert_eq!(map.info.editor_smoothing, EditorSmoothing::Basement); + assert_eq!( + map.ambient_light, + Some(AmbientLight { + basement: true, + caves: false, + level: SURFACE_AMBIENT_LEVEL, + }) + ); + for tile in map.tiles.values() { + let roofs = pieces(&tile.roofs); + assert_eq!(roofs.len(), 1); + assert_eq!(roofs[0].0, TILE_TYPE_NAMES[FIRSTROOF]); + assert!((9..=11).contains(&roofs[0].1)); + assert_eq!(pieces(&tile.land)[0].0, TILE_TYPE_NAMES[FIRSTTEXTURE]); + assert!(tile.structs.is_empty()); + } + } + + #[test] + fn caves_are_solid_rock() { + let fixture = Fixture::new(); + let mut map: TacticalMap = fixture.convert(&new_map(EditorSmoothing::Caves)); + assert_eq!(map.info.editor_smoothing, EditorSmoothing::Caves); + assert_eq!( + map.ambient_light, + Some(AmbientLight { + basement: false, + caves: true, + level: SURFACE_AMBIENT_LEVEL, + }) + ); + for tile in map.tiles.values() { + let structs = pieces(&tile.structs); + assert_eq!(structs.len(), 1); + assert_eq!(structs[0].0, TILE_TYPE_NAMES[FIRSTWALL]); + assert!((60..=65).contains(&structs[0].1)); + assert!(tile.roofs.is_empty()); + } + // The editor knows the rock is caves + mark_caves(&mut map); + assert!(map.tiles.values().all(|tile| tile.cave)); + } +} diff --git a/src-map-editor/src/map/placement.rs b/src-map-editor/src/map/placement.rs new file mode 100644 index 0000000..c50698f --- /dev/null +++ b/src-map-editor/src/map/placement.rs @@ -0,0 +1,318 @@ +//! Soldier placements of the map (`BASIC_SOLDIERCREATE_STRUCT`, `SOLDIERCREATE_STRUCT`) +//! +//! The team, class and body type of a placement refer to the soldier definitions, and the random +//! choices that the game makes when it creates the soldier (body type and palette) are already +//! made. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use super::{exit_grid::SectorId, schedule::ScheduleId, Direction, GridNo, ItemStack, Level}; +use crate::{ + item::ItemTable, + soldier::{ + body_type::{choose_body_type, SoldierBodyType}, + class::SoldierClass, + palette::{soldier_palette, ResolvedSoldierPalette}, + palette_files::PaletteReplacements, + random::placement_random, + standing_surface::StandingSurface, + team::Team, + SoldierBodyTypes, + }, +}; + +/// Number of inventory slots of a detailed placement (`NUM_INV_SLOTS`) +pub const NUM_INV_SLOTS: usize = 19; + +/// Hand item slot of the inventory (`HANDPOS`) +pub const HANDPOS: usize = 5; + +/// A profile of a named character (`ubProfile`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ProfileId(u8); + +impl ProfileId { + /// Profile of soldiers without a profile (`NO_PROFILE`) + const NO_PROFILE: u8 = 200; + + /// The profile with the given number, `None` for `NO_PROFILE` + pub fn new(number: u8) -> Option { + (number != Self::NO_PROFILE).then_some(Self(number)) + } + + pub fn number(self) -> u8 { + self.0 + } +} + +/// What a soldier does until it is alerted (`bOrders`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Orders { + /// Moves at most 1 tile + Stationary, + /// Moves at most 2 tiles + OnGuard, + /// Patrols within 5 tiles + ClosePatrol, + /// Patrols within 15 tiles + FarPatrol, + /// Patrols along the patrol points + PointPatrol, + /// Helps others anywhere in the sector + OnCall, + SeekEnemy, + /// Patrols between random patrol points + RandomPointPatrol, +} + +impl Orders { + pub fn from_number(number: i8) -> Option { + Some(match number { + 0 => Self::Stationary, + 1 => Self::OnGuard, + 2 => Self::ClosePatrol, + 3 => Self::FarPatrol, + 4 => Self::PointPatrol, + 5 => Self::OnCall, + 6 => Self::SeekEnemy, + 7 => Self::RandomPointPatrol, + _ => return None, + }) + } + + /// The raw orders, the inverse of [`Orders::from_number`] + pub fn number(self) -> i8 { + match self { + Self::Stationary => 0, + Self::OnGuard => 1, + Self::ClosePatrol => 2, + Self::FarPatrol => 3, + Self::PointPatrol => 4, + Self::OnCall => 5, + Self::SeekEnemy => 6, + Self::RandomPointPatrol => 7, + } + } + + /// Whether the soldier walks along the patrol points, which it then needs + pub fn uses_patrol_points(self) -> bool { + matches!(self, Self::PointPatrol | Self::RandomPointPatrol) + } +} + +/// How a soldier fights (`bAttitude`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Attitude { + Defensive, + BraveSolo, + BraveAid, + CunningSolo, + CunningAid, + Aggressive, + /// Only aggressive against Slay (`ATTACKSLAYONLY`) + AttackSlayOnly, +} + +impl Attitude { + pub fn from_number(number: i8) -> Option { + Some(match number { + 0 => Self::Defensive, + 1 => Self::BraveSolo, + 2 => Self::BraveAid, + 3 => Self::CunningSolo, + 4 => Self::CunningAid, + 5 => Self::Aggressive, + 7 => Self::AttackSlayOnly, + _ => return None, + }) + } + + /// The raw attitude, the inverse of [`Attitude::from_number`] + pub fn number(self) -> i8 { + match self { + Self::Defensive => 0, + Self::BraveSolo => 1, + Self::BraveAid => 2, + Self::CunningSolo => 3, + Self::CunningAid => 4, + Self::Aggressive => 5, + Self::AttackSlayOnly => 7, + } + } +} + +/// Attributes and skills of a detailed placement, `None` if the game chooses them +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SoldierStats { + pub life_max: Option, + pub life: Option, + pub agility: Option, + pub dexterity: Option, + pub exp_level: Option, + pub marksmanship: Option, + pub medical: Option, + pub mechanical: Option, + pub explosive: Option, + pub leadership: Option, + pub strength: Option, + pub wisdom: Option, + pub morale: Option, + pub ai_morale: Option, +} + +/// The part of a detailed placement that the basic placement doesn't have +/// +/// The detailed placement repeats team, class, body type, orders, attitude, direction, patrol +/// points, level, civilian group and keys of the basic placement. The game uses the ones of the +/// basic placement, so those are only in [`SoldierPlacement`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DetailedPlacement { + /// Whether the stats and the inventory are fixed (`fStatic`) + pub is_static: bool, + pub profile: Option, + pub copy_profile_items_over: bool, + /// `None` if the saved sector is not a valid sector + pub sector: Option, + pub insertion_grid_no: Option, + pub stats: SoldierStats, + pub inventory: [Option; NUM_INV_SLOTS], + /// Palette names of the clothes, skin and hair, used if the soldier is `visible` + pub head_palette: String, + pub pants_palette: String, + pub vest_palette: String, + pub skin_palette: String, + /// Whether the soldier keeps the given palettes (`fVisible`) + pub visible: bool, + pub name: String, + pub schedule: Option, + /// Vehicle the soldier uses (`fUseGivenVehicle`, `bUseGivenVehicleID`) + pub vehicle_id: Option, +} + +/// Identifies a placement while the map is edited, it stays the same when the placement is +/// changed, moved or restored by undo +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PlacementId(pub u32); + +/// Most patrol points of a placement (`MAXPATROLGRIDS`, the first entry is not a patrol point) +pub const MAX_PATROL_POINTS: usize = 9; + +/// A soldier placement whose team, class and body type exist +#[derive(Clone, Debug, PartialEq)] +pub struct SoldierPlacement { + pub id: PlacementId, + /// Index in the map file, seeds the random choices + pub index: usize, + pub grid_no: GridNo, + pub direction: Direction, + pub level: Level, + pub team: Arc, + pub soldier_class: Arc, + /// The body type of the placement, `None` if it is chosen at random (`BODY_RANDOM`) + pub given_body: Option>, + /// The body type, `given_body` or the one chosen at random + pub body: Arc, + /// The standing animation for the body and the item in the hand + pub surface: Arc, + pub palette: ResolvedSoldierPalette, + pub relative_attribute_level: i8, + pub relative_equipment_level: i8, + pub orders: Orders, + pub attitude: Attitude, + /// The patrol points in their order, at most [`MAX_PATROL_POINTS`] + pub patrol: Vec, + pub civilian_group: u8, + pub priority_existence: bool, + pub has_keys: bool, + pub detailed: Option, +} + +/// What the appearance of a soldier depends on besides its placement +#[derive(Clone, Copy)] +pub struct AppearanceContext<'a> { + pub items: &'a ItemTable, + pub bodies: &'a SoldierBodyTypes, + /// `None` if `ja2pal.dat` could not be loaded, soldiers then keep their animation palette + pub palettes: Option<&'a PaletteReplacements>, +} + +/// The body type, the standing animation and the palette of a soldier +pub(crate) struct Appearance { + pub body: Arc, + pub surface: Arc, + pub palette: ResolvedSoldierPalette, +} + +/// Makes the random choices of the game for a placement and picks the standing animation for +/// the item in its hand (`CreateDetailedPlacementGivenBasicPlacementInfo`, +/// `TacticalCreateSoldier`) +/// +/// The choices are seeded with the index and the grid number, so they are the same every time. +pub(crate) fn appearance( + index: usize, + grid_no: GridNo, + team: &Team, + class: &SoldierClass, + given_body: Option<&Arc>, + detailed: Option<&DetailedPlacement>, + ctx: &AppearanceContext, +) -> Appearance { + let mut random = placement_random(index, grid_no); + let has_profile = detailed.is_some_and(|detailed| detailed.profile.is_some()); + let body = choose_body_type( + team, + class, + given_body, + has_profile, + ctx.bodies, + &mut random, + ); + let hand = detailed + .and_then(|detailed| detailed.inventory[HANDPOS].as_ref()) + .and_then(|stack| ctx.items.hand(stack.item)); + let surface = body.standing.surface(hand).clone(); + let given_clothes = detailed + .filter(|detailed| detailed.visible) + .filter(|detailed| !detailed.vest_palette.is_empty() && !detailed.pants_palette.is_empty()) + .map(|detailed| { + ( + detailed.vest_palette.as_str(), + detailed.pants_palette.as_str(), + ) + }); + let palette = + soldier_palette(&body, team, class, given_clothes, &mut random).resolve(ctx.palettes); + Appearance { + body, + surface, + palette, + } +} + +impl SoldierPlacement { + /// Chooses the body type, the standing animation and the palette again, after the team, the + /// class, the body type, the item in the hand or the clothes changed + pub fn resolve_appearance(&mut self, ctx: &AppearanceContext) { + let Appearance { + body, + surface, + palette, + } = appearance( + self.index, + self.grid_no, + &self.team, + &self.soldier_class, + self.given_body.as_ref(), + self.detailed.as_ref(), + ctx, + ); + self.body = body; + self.surface = surface; + self.palette = palette; + } +} diff --git a/src-map-editor/src/map/save.rs b/src-map-editor/src/map/save.rs new file mode 100644 index 0000000..63083fe --- /dev/null +++ b/src-map-editor/src/map/save.rs @@ -0,0 +1,1208 @@ +//! Puts the edits of the runtime map back into the map file, the inverse of the parts of +//! `convert.rs` that the editor changes +//! +//! The map file keeps everything else, e.g. the tiles and items that could not be converted, so +//! it is patched instead of written from scratch. + +use std::{collections::HashMap, sync::Arc}; + +use super::{ + convert::{convert_ambient_light, BODY_RANDOM, NOWHERE, NO_SCHEDULE_DATA}, + DetailedPlacement, Door, EdgePointList, EdgePointSet, ExitGrid, GridNo, ItemStack, LightTime, + MapItem, MapLight, MapTile, Schedule, ScheduleAction, ScheduleFlags, ScheduleId, + SoldierPlacement, TacticalMap, LIGHT_NIGHTTIME, LIGHT_PRIMETIME, LIGHT_SPR_ACTIVE, + LIGHT_SPR_ON, MAX_PATROL_POINTS, +}; +use crate::{ + components::map::JA2_MAP_TILES, + file_formats::{ + ja2_map::{ + Ja2AmbientLight, Ja2BasicSoldierPlacement, Ja2DetailedSoldierPlacement, Ja2Door, + Ja2EdgePointList, Ja2EdgePointSet, Ja2EdgePoints, Ja2ExitGrid, Ja2LightSprite, + Ja2Lights, Ja2Map, Ja2MapElement, Ja2MapElementFlags, Ja2MapFlags, Ja2Object, + Ja2Schedule, Ja2SoldierPlacement, Ja2TileLayer, Ja2WorldItem, + }, + ja2_tile_types::TILE_TYPE_NAMES, + }, + tileset::Tile, +}; + +/// Most soldiers of a map (`MAX_NUM_SOLDIERS`) +pub const MAX_NUM_SOLDIERS: usize = 148; +/// Most schedules that are saved (`SaveSchedules`) +pub const MAX_SCHEDULES: usize = 32; +/// Profile of soldiers without a profile (`NO_PROFILE`) +const NO_PROFILE: u8 = 200; +/// Flag of items that are not dropped when the soldier dies (`OBJECT_UNDROPPABLE`) +pub const OBJECT_UNDROPPABLE: i8 = 0x01; + +impl TacticalMap { + /// Overwrites the tiles, the doors, the editor smoothing, the scroll restriction (other ids + /// than 0 and 1 are kept as long as it stays restricted), the entry points, the exit grids, + /// the world items, the lights, the soldiers and the schedules of the map file with the ones + /// of this map, and the edge points if they were generated again + pub fn apply_to_ja2(&self, raw: &mut Ja2Map) -> Result<(), String> { + if raw.map_elements.len() != JA2_MAP_TILES { + return Err(format!( + "The map has {} tiles instead of {}", + raw.map_elements.len(), + JA2_MAP_TILES + )); + } + for ((grid_no, tile), element) in self.tiles.iter().zip(&mut raw.map_elements) { + apply_tile(tile, element) + .map_err(|error| format!("Tile {}: {}", grid_no.index(), error))?; + } + raw.doors = self.doors.iter().map(convert_door).collect(); + if !raw.doors.is_empty() { + raw.flags |= Ja2MapFlags::MAP_DOORTABLE_SAVED; + } + raw.info.editor_smoothing_type = self.info.editor_smoothing.number(); + if self.info.restricted_scroll != (raw.info.restricted_scroll_id != 0) { + raw.info.restricted_scroll_id = u8::from(self.info.restricted_scroll); + } + self.apply_entry_points(raw); + self.apply_exit_grids(raw); + self.apply_edge_points(raw); + self.apply_items(raw); + self.apply_lights(raw); + self.apply_soldiers(raw) + } + + /// Writes the light colour and the light sprites, the converted ones first + /// (`SaveMapLights`), and the ambient light if it changed + /// + /// Extra light colours of the map file are kept. The ambient light is compared with the one + /// the map file has, so the level of old caves maps is only written once it was changed. + /// Maps above ground have none, like in the original. + pub(super) fn apply_lights(&self, raw: &mut Ja2Map) { + let mut colors = raw + .lights + .as_ref() + .map(|lights| lights.colors.clone()) + .unwrap_or_default(); + let [r, g, b] = self.light_color; + match colors.first_mut() { + Some(first) => *first = [r, g, b, first[3]], + None => colors.push([r, g, b, 0]), + } + raw.lights = Some(Ja2Lights { + colors, + sprites: self + .lights + .iter() + .map(convert_light) + .chain(self.skipped_lights.iter().cloned()) + .collect(), + }); + raw.flags |= Ja2MapFlags::MAP_WORLDLIGHTS_SAVED; + + match self.ambient_light { + Some(ambient) => { + if convert_ambient_light(raw) != Some(ambient) { + raw.ambient_light = Some(Ja2AmbientLight { + basement: ambient.basement, + caves: ambient.caves, + level: ambient.level, + }); + raw.flags |= Ja2MapFlags::MAP_AMBIENTLIGHTLEVEL_SAVED; + } + } + None => { + raw.ambient_light = None; + raw.flags.remove(Ja2MapFlags::MAP_AMBIENTLIGHTLEVEL_SAVED); + } + } + } + + /// Writes the entry points, `NOWHERE` for the ones that are not set + pub(super) fn apply_entry_points(&self, raw: &mut Ja2Map) { + let raw_grid_no = |grid_no: Option| grid_no.map_or(NOWHERE, |g| g.index() as i16); + let info = &mut raw.info; + info.north_grid_no = raw_grid_no(self.info.north_entry); + info.east_grid_no = raw_grid_no(self.info.east_entry); + info.south_grid_no = raw_grid_no(self.info.south_entry); + info.west_grid_no = raw_grid_no(self.info.west_entry); + info.center_grid_no = raw_grid_no(self.info.center); + info.isolated_grid_no = raw_grid_no(self.info.isolated); + } + + /// Writes the exit grids and the ones that could not be converted, sorted by their grid + /// numbers like the game keeps them (`SaveExitGrids`) + /// + /// The game always saves them, a map without them keeps not saving them. A skipped exit grid + /// is dropped once its tile got a new one, the game would load the one that comes last. + pub(super) fn apply_exit_grids(&self, raw: &mut Ja2Map) { + let converted: Vec = self.exit_grids.iter().map(convert_exit_grid).collect(); + let skipped = self + .skipped_exit_grids + .iter() + .filter(|skipped| converted.iter().all(|grid| grid.grid_no != skipped.grid_no)) + .cloned(); + let mut exit_grids: Vec = converted.iter().cloned().chain(skipped).collect(); + exit_grids.sort_by_key(|grid| grid.grid_no); + if !exit_grids.is_empty() { + raw.flags |= Ja2MapFlags::MAP_EXITGRIDS_SAVED; + } + raw.exit_grids = exit_grids; + } + + /// Writes the edge points if they were generated again (`SaveMapEdgepoints`), maps before + /// version 17 only have the primary ones + pub(super) fn apply_edge_points(&self, raw: &mut Ja2Map) { + if !self.edge_points_stale { + return; + } + let secondary = (raw.info.map_version >= 17).then(|| { + convert_edge_point_set(&self.edge_points.secondary.clone().unwrap_or_default()) + }); + raw.edge_points = Some(Ja2EdgePoints { + primary: convert_edge_point_set(&self.edge_points.primary), + secondary, + }); + raw.flags |= Ja2MapFlags::MAP_EDGEPOINTS_SAVED; + } + + /// Writes the world items, the converted ones first (`SaveWorldItemsToMap`) + pub(super) fn apply_items(&self, raw: &mut Ja2Map) { + raw.items = self + .items + .iter() + .map(convert_world_item) + .chain(self.skipped_items.iter().cloned()) + .collect(); + raw.flags |= Ja2MapFlags::MAP_WORLDITEMS_SAVED; + } + + /// Writes the placements, the converted ones first, and the schedules they use + /// (`SaveSoldiersToMap`, `OptimizeSchedules`, `SaveSchedules`) + fn apply_soldiers(&self, raw: &mut Ja2Map) -> Result<(), String> { + let count = self.soldiers.len() + self.skipped_placements.len(); + if count > MAX_NUM_SOLDIERS { + return Err(format!( + "The map has {} soldiers, at most {} can be saved", + count, MAX_NUM_SOLDIERS + )); + } + let schedules = optimize_schedules(&self.schedules, &self.soldiers); + raw.placements = self + .soldiers + .iter() + .map(|placement| { + let schedule = placement + .detailed + .as_ref() + .and_then(|detailed| detailed.schedule) + .and_then(|id| schedules.ids.get(&id).copied()); + convert_placement(placement, schedule) + }) + .chain(self.skipped_placements.iter().cloned()) + .collect(); + raw.info.num_individuals = count as u8; + raw.flags |= Ja2MapFlags::MAP_FULLSOLDIER_SAVED; + raw.schedules = schedules.saved; + if !raw.schedules.is_empty() { + raw.flags |= Ja2MapFlags::MAP_NPCSCHEDULES_SAVED; + } + Ok(()) + } +} + +/// The schedules as they are saved, and their new ids by their old ones +struct OptimizedSchedules { + saved: Vec, + ids: HashMap, +} + +/// Leaves out the temporary schedules, numbers the others from 1 in their order and sorts their +/// actions by time (`OptimizeSchedules`, `SortSchedule`, `SaveSchedules`) +/// +/// The schedules know the soldier that uses them by its index in the map file. +fn optimize_schedules(schedules: &[Schedule], soldiers: &[SoldierPlacement]) -> OptimizedSchedules { + let mut users = HashMap::new(); + for (index, placement) in soldiers.iter().enumerate() { + if let Some(id) = placement.detailed.as_ref().and_then(|d| d.schedule) { + users.entry(id).or_insert(index as u8); + } + } + let mut saved = vec![]; + let mut ids = HashMap::new(); + for schedule in schedules { + if schedule.flags.contains(ScheduleFlags::TEMPORARY) || saved.len() == MAX_SCHEDULES { + continue; + } + let id = saved.len() as u8 + 1; + ids.insert(schedule.id, id); + let soldier_id = users + .get(&schedule.id) + .copied() + .unwrap_or(schedule.soldier_id); + saved.push(convert_schedule(&sort_schedule(schedule), id, soldier_id)); + } + OptimizedSchedules { saved, ids } +} + +/// The flags of a slot of a schedule +fn slot_flags(slot: usize) -> ScheduleFlags { + let variance = [ + ScheduleFlags::VARIANCE1, + ScheduleFlags::VARIANCE2, + ScheduleFlags::VARIANCE3, + ScheduleFlags::VARIANCE4, + ]; + let active = [ + ScheduleFlags::ACTIVE1, + ScheduleFlags::ACTIVE2, + ScheduleFlags::ACTIVE3, + ScheduleFlags::ACTIVE4, + ]; + variance[slot] | active[slot] +} + +/// The actions in the order of their times, actions without a time and unused slots last +/// (`SortSchedule`) +/// +/// Unlike the game, the variance of an action moves with it. +pub fn sort_schedule(schedule: &Schedule) -> Schedule { + let mut slots: Vec = (0..schedule.actions.len()).collect(); + slots.sort_by_key(|&slot| { + let entry = schedule.actions[slot]; + ( + entry.is_none(), + entry.and_then(|e| e.time).unwrap_or(NO_SCHEDULE_DATA), + ) + }); + let all_slots = (0..4).fold(ScheduleFlags::empty(), |all, slot| all | slot_flags(slot)); + let mut flags = schedule.flags - all_slots; + for (to, &from) in slots.iter().enumerate() { + if schedule.flags.intersects(slot_flags(from)) { + let moved = (schedule.flags & slot_flags(from)).bits() >> from << to; + flags |= ScheduleFlags::from_bits_retain(moved); + } + } + Schedule { + flags, + actions: std::array::from_fn(|to| schedule.actions[slots[to]]), + ..schedule.clone() + } +} + +fn convert_schedule(schedule: &Schedule, id: u8, soldier_id: u8) -> Ja2Schedule { + let mut raw = Ja2Schedule { + time: [NO_SCHEDULE_DATA; 4], + data1: [NO_SCHEDULE_DATA; 4], + data2: [NO_SCHEDULE_DATA; 4], + action: [0; 4], + schedule_id: id, + soldier_id, + flags: schedule.flags.bits(), + }; + for (slot, entry) in schedule.actions.iter().enumerate() { + let Some(entry) = entry else { + continue; + }; + let raw_grid = |grid_no: super::GridNo| grid_no.index() as u16; + let (action, data1, data2) = match entry.action { + ScheduleAction::LockDoor { door, destination } => (1, Some(door), Some(destination)), + ScheduleAction::UnlockDoor { door, destination } => (2, Some(door), Some(destination)), + ScheduleAction::OpenDoor { door, destination } => (3, Some(door), Some(destination)), + ScheduleAction::CloseDoor { door, destination } => (4, Some(door), Some(destination)), + ScheduleAction::GridNo(grid_no) => (5, Some(grid_no), None), + ScheduleAction::LeaveSector => (6, None, None), + ScheduleAction::EnterSector(grid_no) => (7, Some(grid_no), None), + ScheduleAction::StayInSector => (8, None, None), + ScheduleAction::Sleep(grid_no) => (9, Some(grid_no), None), + ScheduleAction::Wake => (10, None, None), + }; + raw.time[slot] = entry.time.unwrap_or(NO_SCHEDULE_DATA); + raw.action[slot] = action; + raw.data1[slot] = data1.map_or(NO_SCHEDULE_DATA, raw_grid); + raw.data2[slot] = data2.map_or(NO_SCHEDULE_DATA, raw_grid); + } + raw +} + +/// The placement of the map file, the inverse of `convert_placement` +fn convert_placement(placement: &SoldierPlacement, schedule: Option) -> Ja2SoldierPlacement { + let team = placement.team.id.index() as i8; + let body_type = placement + .given_body + .as_ref() + .map_or(BODY_RANDOM, |body| body.id.index() as i8); + let direction = placement.direction as u8 as i8; + let on_roof = placement.level == super::Level::Roof; + // The first entry is where the soldier stands + let mut patrol_grid = [0; 10]; + patrol_grid[0] = placement.grid_no.index() as i16; + let patrol_count = placement.patrol.len().min(MAX_PATROL_POINTS); + for (to, grid_no) in patrol_grid[1..].iter_mut().zip(&placement.patrol) { + *to = grid_no.index() as i16; + } + let basic = Ja2BasicSoldierPlacement { + detailed_placement: placement.detailed.is_some(), + starting_grid_no: placement.grid_no.index() as u16, + team, + relative_attribute_level: placement.relative_attribute_level, + relative_equipment_level: placement.relative_equipment_level, + direction, + orders: placement.orders.number(), + attitude: placement.attitude.number(), + body_type, + patrol_grid, + patrol_cnt: patrol_count as i8, + on_roof, + soldier_class: placement.soldier_class.id.index() as u8, + civilian_group: placement.civilian_group, + priority_existance: placement.priority_existence, + has_keys: placement.has_keys, + }; + let detailed = placement + .detailed + .as_ref() + .map(|detailed| convert_detailed(detailed, &basic, schedule)); + Ja2SoldierPlacement { basic, detailed } +} + +/// The detailed placement, which repeats the values of the basic one +fn convert_detailed( + detailed: &DetailedPlacement, + basic: &Ja2BasicSoldierPlacement, + schedule: Option, +) -> Ja2DetailedSoldierPlacement { + // -1 means that the game chooses the value + let stat = |value: Option| value.unwrap_or(-1); + let stats = &detailed.stats; + let sector = detailed.sector.unwrap_or_default(); + Ja2DetailedSoldierPlacement { + is_static: detailed.is_static, + profile: detailed + .profile + .map_or(NO_PROFILE, |profile| profile.number()), + copy_profile_items_over: detailed.copy_profile_items_over, + sector_x: sector.x.into(), + sector_y: sector.y.into(), + direction: basic.direction, + insertion_grid_no: detailed + .insertion_grid_no + .map_or(NOWHERE, |grid_no| grid_no.index() as i16), + team: basic.team, + body_type: basic.body_type, + attitude: basic.attitude, + orders: basic.orders, + life_max: stat(stats.life_max), + life: stat(stats.life), + agility: stat(stats.agility), + dexterity: stat(stats.dexterity), + exp_level: stat(stats.exp_level), + marksmanship: stat(stats.marksmanship), + medical: stat(stats.medical), + mechanical: stat(stats.mechanical), + explosive: stat(stats.explosive), + leadership: stat(stats.leadership), + strength: stat(stats.strength), + wisdom: stat(stats.wisdom), + morale: stat(stats.morale), + ai_morale: stat(stats.ai_morale), + inventory: detailed + .inventory + .iter() + .map(|slot| slot.as_ref().map_or_else(empty_slot, convert_object)) + .collect(), + head_pal: detailed.head_palette.clone(), + pants_pal: detailed.pants_palette.clone(), + vest_pal: detailed.vest_palette.clone(), + skin_pal: detailed.skin_palette.clone(), + patrol_grid: basic.patrol_grid, + patrol_cnt: basic.patrol_cnt, + visible: detailed.visible, + name: detailed.name.clone(), + soldier_class: basic.soldier_class, + on_roof: basic.on_roof, + sector_z: sector.z as i8, + civilian_group: basic.civilian_group, + schedule_id: schedule.unwrap_or(0), + use_given_vehicle: detailed.vehicle_id.is_some(), + use_given_vehicle_id: detailed.vehicle_id.unwrap_or(0), + has_keys: basic.has_keys, + } +} + +/// An empty inventory slot, like the editor creates them +/// (`CreateStaticDetailedPlacementGivenBasicPlacementInfo`) +fn empty_slot() -> Ja2Object { + Ja2Object { + flags: OBJECT_UNDROPPABLE, + ..Default::default() + } +} + +/// The item stack of the map file, the inverse of `convert_object` +pub(super) fn convert_object(stack: &ItemStack) -> Ja2Object { + let mut attach_items = [0; 4]; + let mut attach_status = [0; 4]; + for (slot, &(item, status)) in stack.attachments.iter().take(4).enumerate() { + attach_items[slot] = item.index(); + attach_status[slot] = status; + } + Ja2Object { + item: stack.item.index(), + number: stack.number, + data: stack.data, + attach_items, + attach_status, + flags: stack.flags, + mission: stack.mission, + trap: stack.trap, + imprint_id: stack.imprint_id, + weight: stack.weight, + used: stack.used, + } +} + +/// The light sprite of the map file: the one it was loaded from while it is unchanged, else +/// the flags of that one with the new time, or those of a new light (`PlaceLight`) +fn convert_light(light: &MapLight) -> Ja2LightSprite { + let x = i16::from(light.position.x); + let y = i16::from(light.position.y); + let time_flags = LIGHT_PRIMETIME | LIGHT_NIGHTTIME; + let flags = match &light.file_sprite { + Some(sprite) + if sprite.x == x + && sprite.y == y + && sprite.template_name == light.template_name + && LightTime::from_flags(sprite.flags) == light.time => + { + return sprite.clone(); + } + Some(sprite) => sprite.flags & !time_flags, + None => LIGHT_SPR_ACTIVE | LIGHT_SPR_ON, + }; + Ja2LightSprite { + x, + y, + flags: flags | light.time.flags(), + template_name: light.template_name.clone(), + } +} + +/// The world item of the map file, the inverse of `convert_items` +fn convert_world_item(item: &MapItem) -> Ja2WorldItem { + Ja2WorldItem { + exists: true, + grid_no: item + .file_grid_no + .unwrap_or_else(|| item.grid_no.index() as i16), + level: item.level.number(), + object: match &item.file_object { + Some(object) + if super::convert::convert_object(object).as_ref() == Some(&item.stack) => + { + object.clone() + } + _ => convert_object(&item.stack), + }, + flags: item.flags.bits(), + render_z_height_above_level: item.render_height.number(), + visible: item.visibility.number(), + non_exist_chance: item.non_exist_chance, + } +} + +fn apply_tile(tile: &MapTile, element: &mut Ja2MapElement) -> Result<(), String> { + element.height = tile.height; + element.land = convert_layer(&tile.land)?; + element.objects = convert_layer(&tile.objects)?; + element.structs = convert_layer(&tile.structs)?; + element.shadows = convert_layer(&tile.shadows)?; + element.roofs = convert_layer(&tile.roofs)?; + element.on_roofs = convert_layer(&tile.on_roofs)?; + element.room = tile.room.map_or(0, |room| room.number()); + element.flags.set( + Ja2MapElementFlags::MAPELEMENT_ENEMY_MINE_PRESENT, + tile.enemy_mine, + ); + element.flags.set( + Ja2MapElementFlags::MAPELEMENT_PLAYER_MINE_PRESENT, + tile.player_mine, + ); + Ok(()) +} + +fn convert_layer(tiles: &[Arc]) -> Result, String> { + tiles + .iter() + .map(|tile| { + let kind = TILE_TYPE_NAMES + .iter() + .position(|&name| name == tile.type_name) + .ok_or_else(|| format!("unknown tile type {}", tile.type_name))?; + let kind = u8::try_from(kind) + .map_err(|_| format!("tile type {} does not fit the map", tile.type_name))?; + Ok(Ja2TileLayer { + kind, + sub_index: tile.sub_index, + }) + }) + .collect() +} + +fn convert_exit_grid(grid: &ExitGrid) -> Ja2ExitGrid { + let sector = grid.dest_sector; + Ja2ExitGrid { + grid_no: grid.grid_no.index() as u16, + dest_grid_no: grid.dest_grid_no.index() as u16, + dest_sector: (sector.x, sector.y, sector.z), + } +} + +fn convert_edge_point_list(list: &EdgePointList) -> Ja2EdgePointList { + Ja2EdgePointList { + middle_index: list.middle_index, + grid_nos: list.grid_nos.iter().map(|g| g.index() as i16).collect(), + } +} + +fn convert_edge_point_set(set: &EdgePointSet) -> Ja2EdgePointSet { + Ja2EdgePointSet { + north: convert_edge_point_list(&set.north), + east: convert_edge_point_list(&set.east), + south: convert_edge_point_list(&set.south), + west: convert_edge_point_list(&set.west), + } +} + +fn convert_door(door: &Door) -> Ja2Door { + Ja2Door { + grid_no: door.grid_no.index() as i16, + locked: door.locked, + trap_level: door.trap.map_or(0, |trap| trap.level), + trap_id: door.trap.map_or(0, |trap| trap.kind.number()), + lock_id: door.lock.map_or(0, |lock| lock.number()), + perceived_locked: door.perceived_locked, + perceived_trapped: door.perceived_trapped, + lock_damage: door.lock_damage, + } +} + +#[cfg(test)] +mod tests { + use bevy::prelude::default; + + use super::*; + use crate::{ + file_formats::ja2_tile_types::{FIRSTOSTRUCT, FIRSTTEXTURE}, + map::{ + convert::tests::{detailed, empty_map, grid, layer, placement, Fixture}, + DoorTrap, DoorTrapKind, EditorSmoothing, LockId, RoomNo, ScheduleEntry, HANDPOS, + }, + }; + + #[test] + fn edits_survive_a_round_trip() { + let fixture = Fixture::new(); + let mut raw = empty_map(); + raw.map_elements[5].land = vec![layer(FIRSTTEXTURE, 1)]; + raw.map_elements[5].flags = Ja2MapElementFlags::MAPELEMENT_REDUNDANT; + let mut map = fixture.convert(&raw); + + let tile = |tile_type, sub_index| { + let index = fixture.tileset.index(tile_type, sub_index).unwrap(); + fixture.tileset.tile(index).unwrap().clone() + }; + let edited = &mut map.tiles[grid(5)]; + edited.height = 7; + edited.land.push(tile(FIRSTTEXTURE, 2)); + edited.structs = vec![tile(FIRSTOSTRUCT, 2), tile(FIRSTOSTRUCT, 1)]; + edited.room = RoomNo::new(4); + edited.player_mine = true; + map.info.editor_smoothing = EditorSmoothing::Basement; + map.info.restricted_scroll = true; + map.doors = vec![Door { + grid_no: grid(5), + locked: true, + trap: Some(DoorTrap { + kind: DoorTrapKind::Siren, + level: 2, + }), + lock: LockId::new(3), + perceived_locked: 1, + perceived_trapped: 0, + lock_damage: 0, + }]; + + map.apply_to_ja2(&mut raw).unwrap(); + let element = &raw.map_elements[5]; + assert_eq!( + element.land, + [layer(FIRSTTEXTURE, 1), layer(FIRSTTEXTURE, 2)] + ); + // Other element flags are kept + assert!(element + .flags + .contains(Ja2MapElementFlags::MAPELEMENT_REDUNDANT)); + assert!(raw.flags.contains(Ja2MapFlags::MAP_DOORTABLE_SAVED)); + assert_eq!(raw.info.editor_smoothing_type, 1); + assert_eq!(raw.info.restricted_scroll_id, 1); + assert_eq!(raw.doors[0].trap_id, 3); + + let reloaded = fixture.convert(&raw); + let ids = |tiles: &[Arc]| tiles.iter().map(|tile| tile.id).collect::>(); + let (before, after) = (&map.tiles[grid(5)], &reloaded.tiles[grid(5)]); + assert_eq!(after.height, 7); + assert_eq!(ids(&after.land), ids(&before.land)); + assert_eq!(ids(&after.structs), ids(&before.structs)); + assert_eq!(after.room, before.room); + assert!(after.player_mine && !after.enemy_mine); + assert_eq!(reloaded.doors, map.doors); + assert_eq!(reloaded.info, map.info); + + // The patched map can be written and read again + raw.info.map_version = raw.version.minor; + raw.russian_unknown = Some([0; 148]); + let bytes = raw.to_bytes().unwrap(); + assert!(Ja2Map::parse(&bytes).unwrap() == raw); + } + + #[test] + fn rejects_tiles_that_the_map_file_cannot_hold() { + let fixture = Fixture::new(); + let mut raw = empty_map(); + let mut map = fixture.convert(&raw); + let mut unknown = (*fixture + .tileset + .tile(fixture.tileset.index(FIRSTTEXTURE, 1).unwrap()) + .unwrap() + .clone()) + .clone(); + unknown.type_name = "NOT A TYPE".to_owned(); + map.tiles[grid(0)].land = vec![Arc::new(unknown)]; + assert!(map.apply_to_ja2(&mut raw).is_err()); + assert!(map.apply_to_ja2(&mut Ja2Map { ..default() }).is_err()); + } + + fn schedule(id: u8, flags: ScheduleFlags) -> Ja2Schedule { + Ja2Schedule { + time: [120, 60, NO_SCHEDULE_DATA, NO_SCHEDULE_DATA], + data1: [300, NO_SCHEDULE_DATA, NO_SCHEDULE_DATA, NO_SCHEDULE_DATA], + data2: [NO_SCHEDULE_DATA; 4], + action: [5, 6, 0, 0], + schedule_id: id, + soldier_id: 99, + flags: flags.bits(), + } + } + + #[test] + fn soldiers_and_schedules_survive_a_round_trip() { + let fixture = Fixture::new(); + let mut raw = empty_map(); + let mut enemy = placement(1, 3, BODY_RANDOM); + enemy.basic.starting_grid_no = 100; + enemy.basic.direction = 3; + enemy.basic.orders = 4; + enemy.basic.patrol_grid = [100, 200, 300, 0, 0, 0, 0, 0, 0, 0]; + enemy.basic.patrol_cnt = 2; + let unknown_team = placement(9, 0, 0); + let mut civilian = placement(4, 0, 12); + civilian.basic.starting_grid_no = 500; + civilian.basic.detailed_placement = true; + let mut civilian_detailed = detailed(); + civilian_detailed.schedule_id = 7; + civilian_detailed.life = 50; + civilian_detailed.agility = -1; + civilian_detailed.insertion_grid_no = 500; + civilian_detailed.inventory[HANDPOS] = Ja2Object { + item: 1, + number: 1, + data: [90, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + attach_items: [2, 0, 0, 0], + attach_status: [40, 0, 0, 0], + flags: -128, + ..Default::default() + }; + civilian.detailed = Some(civilian_detailed); + raw.placements = vec![enemy, unknown_team.clone(), civilian]; + raw.info.num_individuals = 3; + raw.flags |= Ja2MapFlags::MAP_FULLSOLDIER_SAVED | Ja2MapFlags::MAP_NPCSCHEDULES_SAVED; + raw.schedules = vec![ + schedule(3, ScheduleFlags::TEMPORARY), + schedule(7, ScheduleFlags::VARIANCE1), + ]; + let map = fixture.convert(&raw); + assert_eq!(map.soldiers.len(), 2); + assert_eq!(map.schedules.len(), 2); + + map.apply_to_ja2(&mut raw).unwrap(); + // The converted placements first, then the skipped ones + assert_eq!(raw.info.num_individuals, 3); + assert_eq!(raw.placements[2], unknown_team); + let enemy = &raw.placements[0]; + // A random body stays random + assert_eq!(enemy.basic.body_type, BODY_RANDOM); + assert_eq!(enemy.basic.patrol_cnt, 2); + assert_eq!(enemy.basic.patrol_grid[..3], [100, 200, 300]); + assert!(enemy.detailed.is_none()); + let civilian = raw.placements[1].detailed.as_ref().unwrap(); + assert_eq!(civilian.life, 50); + assert_eq!(civilian.agility, -1); + assert_eq!(civilian.body_type, 12); + assert_eq!(civilian.team, 4); + assert_eq!(civilian.inventory[HANDPOS].item, 1); + assert_eq!(civilian.inventory[HANDPOS].attach_status, [40, 0, 0, 0]); + assert_eq!(civilian.inventory[0].item, 0); + assert_eq!(civilian.inventory[0].flags, OBJECT_UNDROPPABLE); + // The temporary schedule is left out, the other one is numbered again and sorted, its + // variance moves with the action + assert_eq!(civilian.schedule_id, 1); + assert_eq!(raw.schedules.len(), 1); + let saved = &raw.schedules[0]; + assert_eq!(saved.schedule_id, 1); + assert_eq!(saved.soldier_id, 1); + assert_eq!(saved.time, [60, 120, NO_SCHEDULE_DATA, NO_SCHEDULE_DATA]); + assert_eq!(saved.action, [6, 5, 0, 0]); + assert_eq!(saved.data1[1], 300); + assert_eq!(saved.flags, ScheduleFlags::VARIANCE2.bits()); + + let reloaded = fixture.convert(&raw); + let (before, after) = (&map.soldiers[0], &reloaded.soldiers[0]); + assert_eq!(after.grid_no, before.grid_no); + assert_eq!(after.patrol, before.patrol); + assert_eq!(after.body.id, before.body.id); + assert_eq!(after.direction, before.direction); + let (before, after) = (&map.soldiers[1], &reloaded.soldiers[1]); + let mut expected = before.detailed.clone().unwrap(); + expected.schedule = ScheduleId::new(1); + expected.inventory[0] = None; + assert_eq!(after.detailed.as_ref(), Some(&expected)); + assert_eq!(reloaded.skipped_placements, [unknown_team]); + assert_eq!( + reloaded.schedules[0].actions[0], + Some(ScheduleEntry { + time: Some(60), + action: ScheduleAction::LeaveSector + }) + ); + + // The patched map can be written and read again + raw.info.map_version = raw.version.minor; + raw.russian_unknown = Some([0; 148]); + let bytes = raw.to_bytes().unwrap(); + assert!(Ja2Map::parse(&bytes).unwrap() == raw); + } + + #[test] + fn items_survive_a_round_trip() { + use crate::{ + file_formats::ja2_map::Ja2WorldItem, + map::{ItemHeight, Level, Visibility, WorldItemFlags}, + }; + let fixture = Fixture::new(); + let mut raw = empty_map(); + raw.info.center_grid_no = 100; + let world_item = |item, grid_no, visible| Ja2WorldItem { + exists: true, + grid_no, + level: 0, + object: Ja2Object { + item, + number: 1, + data: [80, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0], + attach_items: [2, 0, 0, 0], + attach_status: [60, 0, 0, 0], + flags: 8, + trap: 3, + imprint_id: 200, + ..Default::default() + }, + flags: (WorldItemFlags::ARMED_BOMB | WorldItemFlags::SCIFI_ONLY).bits(), + render_z_height_above_level: -1, + visible, + non_exist_chance: 30, + }; + let unknown_visibility = world_item(1, 5, 9); + // The attachment is in the third slot, the stack keeps it in the first + let mut third_slot = world_item(1, 5, 1); + third_slot.object.attach_items = [0, 0, 2, 0]; + third_slot.object.attach_status = [0, 0, 60, 0]; + raw.items = vec![ + third_slot, + Ja2WorldItem { + level: 1, + render_z_height_above_level: 12, + ..world_item(2, 7, -3) + }, + // `NOWHERE` stays `NOWHERE` + world_item(2, NOWHERE, -1), + unknown_visibility.clone(), + ]; + raw.flags |= Ja2MapFlags::MAP_WORLDITEMS_SAVED; + let original = raw.items.clone(); + let map = fixture.convert(&raw); + assert_eq!(map.items.len(), 3); + assert_eq!(map.items[2].grid_no, grid(100)); + + // An unchanged map saves the same items + map.apply_to_ja2(&mut raw).unwrap(); + assert_eq!(raw.items, original); + + // Edited items are saved with their changes, moved ones lose their file grid number + let mut edited = map.clone(); + edited.items[0].visibility = Visibility::Buried; + edited.items[0].render_height = ItemHeight::Above(4); + edited.items[0].stack.number = 3; + edited.items[1].level = Level::Ground; + edited.items[2].grid_no = grid(300); + edited.items[2].file_grid_no = None; + edited.items.remove(1); + edited.apply_to_ja2(&mut raw).unwrap(); + assert_eq!(raw.items.len(), 3); + assert_eq!(raw.items[0].visible, -3); + assert_eq!(raw.items[0].render_z_height_above_level, 4); + assert_eq!(raw.items[0].object.number, 3); + // Changed stacks are saved as they are now + assert_eq!(raw.items[0].object.attach_items, [2, 0, 0, 0]); + assert_eq!(raw.items[1].grid_no, 300); + assert_eq!(raw.items[2], unknown_visibility); + + // Without items the map still says that it saved them + let mut empty = map.clone(); + empty.items.clear(); + empty.skipped_items.clear(); + raw.flags -= Ja2MapFlags::MAP_WORLDITEMS_SAVED; + empty.apply_to_ja2(&mut raw).unwrap(); + assert!(raw.items.is_empty()); + assert!(raw.flags.contains(Ja2MapFlags::MAP_WORLDITEMS_SAVED)); + + // The patched map can be written and read again + edited.apply_to_ja2(&mut raw).unwrap(); + raw.info.map_version = raw.version.minor; + raw.russian_unknown = Some([0; 148]); + let bytes = raw.to_bytes().unwrap(); + let reloaded = Ja2Map::parse(&bytes).unwrap(); + assert!(reloaded == raw); + let reloaded = fixture.convert(&reloaded); + assert_eq!(reloaded.items.len(), 2); + assert_eq!(reloaded.items[0].stack, edited.items[0].stack); + assert_eq!(reloaded.items[0].flags, edited.items[0].flags); + assert_eq!(reloaded.items[0].non_exist_chance, 30); + } + + #[test] + fn lights_survive_a_round_trip() { + use crate::{ + file_formats::ja2_map::{Ja2LightSprite, Ja2Lights}, + map::LightId, + }; + let fixture = Fixture::new(); + let mut raw = empty_map(); + let sprite = |x, flags, name: &str| Ja2LightSprite { + x, + y: 9, + flags, + template_name: name.to_owned(), + }; + let night = sprite(2, LIGHT_SPR_ACTIVE | LIGHT_NIGHTTIME | 0x40, "L-R01.LHT"); + let missing = sprite(4, LIGHT_SPR_ACTIVE, "missing.lht"); + let always = sprite(3, LIGHT_SPR_ACTIVE | LIGHT_SPR_ON, "l-r01.lht"); + raw.lights = Some(Ja2Lights { + colors: vec![[1, 2, 3, 9], [4, 5, 6, 7]], + sprites: vec![night.clone(), missing.clone(), always.clone()], + }); + raw.flags |= Ja2MapFlags::MAP_WORLDLIGHTS_SAVED; + let map = fixture.convert(&raw); + assert_eq!(map.lights.len(), 2); + + // An unchanged map saves the same lights, the converted ones first + map.apply_to_ja2(&mut raw).unwrap(); + let lights = raw.lights.clone().unwrap(); + assert_eq!(lights.colors, [[1, 2, 3, 9], [4, 5, 6, 7]]); + assert_eq!( + lights.sprites, + [night.clone(), always.clone(), missing.clone()] + ); + + // Edited lights keep their other flags, new ones are on + let mut edited = map.clone(); + edited.light_color = [7, 8, 9]; + edited.lights[0].time = LightTime::PrimeTime; + edited.lights.remove(1); + edited.lights.push(MapLight { + id: LightId(5), + position: crate::components::map::GridPosition::new(10, 11), + time: LightTime::Night, + template_name: "L-R01.LHT".to_owned(), + template: map.lights[0].template.clone(), + file_sprite: None, + }); + edited.apply_to_ja2(&mut raw).unwrap(); + let lights = raw.lights.clone().unwrap(); + // Only the first colour is the light colour + assert_eq!(lights.colors, [[7, 8, 9, 9], [4, 5, 6, 7]]); + assert_eq!( + lights.sprites, + [ + sprite(2, LIGHT_SPR_ACTIVE | LIGHT_PRIMETIME | 0x40, "L-R01.LHT"), + Ja2LightSprite { + x: 10, + y: 11, + flags: LIGHT_SPR_ACTIVE | LIGHT_SPR_ON | LIGHT_NIGHTTIME, + template_name: "L-R01.LHT".to_owned(), + }, + missing.clone(), + ] + ); + + // Maps without lights get a light colour + let mut no_lights = empty_map(); + fixture + .convert(&no_lights) + .apply_to_ja2(&mut no_lights) + .unwrap(); + assert_eq!(no_lights.lights.as_ref().unwrap().colors, [[0, 0, 0, 0]]); + assert!(no_lights.lights.as_ref().unwrap().sprites.is_empty()); + assert!(no_lights.flags.contains(Ja2MapFlags::MAP_WORLDLIGHTS_SAVED)); + + // The patched map can be written and read again + raw.info.map_version = raw.version.minor; + raw.russian_unknown = Some([0; 148]); + let bytes = raw.to_bytes().unwrap(); + let reloaded = Ja2Map::parse(&bytes).unwrap(); + assert!(reloaded == raw); + let reloaded = fixture.convert(&reloaded); + assert_eq!(reloaded.light_color, [7, 8, 9]); + let times: Vec<_> = reloaded.lights.iter().map(|l| l.time).collect(); + assert_eq!(times, [LightTime::PrimeTime, LightTime::Night]); + } + + #[test] + fn the_ambient_light_is_only_written_when_it_changed() { + use crate::file_formats::ja2_map::Ja2AmbientLight; + let fixture = Fixture::new(); + let mut raw = empty_map(); + raw.info.map_version = 24; + raw.ambient_light = Some(Ja2AmbientLight { + basement: false, + caves: true, + level: 5, + }); + raw.flags |= Ja2MapFlags::MAP_AMBIENTLIGHTLEVEL_SAVED; + let map = fixture.convert(&raw); + // Old caves maps use another level than the saved one, which stays + assert_ne!(map.ambient_light.unwrap().level, 5); + map.apply_to_ja2(&mut raw).unwrap(); + assert_eq!(raw.ambient_light.as_ref().unwrap().level, 5); + + raw.info.map_version = 25; + let mut edited = fixture.convert(&raw); + edited.ambient_light.as_mut().unwrap().level = 9; + edited.apply_to_ja2(&mut raw).unwrap(); + assert_eq!( + raw.ambient_light, + Some(Ja2AmbientLight { + basement: false, + caves: true, + level: 9 + }) + ); + assert_eq!(fixture.convert(&raw).ambient_light, edited.ambient_light); + + // Maps that are above ground now lose it + edited.ambient_light = None; + edited.apply_to_ja2(&mut raw).unwrap(); + assert!(raw.ambient_light.is_none()); + assert!(!raw.flags.contains(Ja2MapFlags::MAP_AMBIENTLIGHTLEVEL_SAVED)); + + // Maps without an ambient light don't get one + let mut outdoors = empty_map(); + fixture + .convert(&outdoors) + .apply_to_ja2(&mut outdoors) + .unwrap(); + assert!(outdoors.ambient_light.is_none()); + assert!(!outdoors + .flags + .contains(Ja2MapFlags::MAP_AMBIENTLIGHTLEVEL_SAVED)); + } + + #[test] + fn rejects_too_many_soldiers() { + let fixture = Fixture::new(); + let mut raw = empty_map(); + raw.placements = vec![placement(1, 3, BODY_RANDOM); MAX_NUM_SOLDIERS + 1]; + raw.info.num_individuals = raw.placements.len() as u8; + raw.flags |= Ja2MapFlags::MAP_FULLSOLDIER_SAVED; + let map = fixture.convert(&raw); + assert!(map + .apply_to_ja2(&mut raw) + .unwrap_err() + .contains("149 soldiers")); + } + + #[test] + fn only_32_schedules_are_saved() { + let fixture = Fixture::new(); + let mut raw = empty_map(); + raw.schedules = (1..=40) + .map(|id| schedule(id, ScheduleFlags::empty())) + .collect(); + let map = fixture.convert(&raw); + map.apply_to_ja2(&mut raw).unwrap(); + assert_eq!(raw.schedules.len(), MAX_SCHEDULES); + assert_eq!(raw.schedules[31].schedule_id, 32); + assert!(raw.flags.contains(Ja2MapFlags::MAP_NPCSCHEDULES_SAVED)); + } + + #[test] + fn exit_grids_and_entry_points_survive_a_round_trip() { + use crate::{ + file_formats::ja2_map::Ja2ExitGrid, + map::{EntryPoint, ExitGrid, SectorId}, + }; + let fixture = Fixture::new(); + let mut raw = empty_map(); + let exit_grid = |grid_no, dest_grid_no| Ja2ExitGrid { + grid_no, + dest_grid_no, + dest_sector: (9, 1, 1), + }; + // The second leads off the map, the third is off the map + raw.exit_grids = vec![ + exit_grid(300, 50), + exit_grid(200, 30000), + exit_grid(26000, 1), + ]; + raw.flags |= Ja2MapFlags::MAP_EXITGRIDS_SAVED; + raw.info.north_grid_no = 1000; + raw.info.center_grid_no = NOWHERE; + raw.info.isolated_grid_no = 2000; + let map = fixture.convert(&raw); + assert_eq!(map.exit_grids.len(), 1); + assert_eq!(map.skipped_exit_grids.len(), 2); + + // An unchanged map saves the same exit grids, sorted by grid number + map.apply_to_ja2(&mut raw).unwrap(); + assert_eq!( + raw.exit_grids, + [ + exit_grid(200, 30000), + exit_grid(300, 50), + exit_grid(26000, 1) + ] + ); + assert_eq!( + ( + raw.info.north_grid_no, + raw.info.center_grid_no, + raw.info.isolated_grid_no + ), + (1000, NOWHERE, 2000) + ); + + let mut edited = map.clone(); + edited.exit_grids.push(ExitGrid { + grid_no: grid(100), + dest_grid_no: grid(7), + dest_sector: SectorId { x: 11, y: 10, z: 0 }, + }); + // A new exit grid replaces the skipped one of its tile + edited.exit_grids.push(ExitGrid { + grid_no: grid(200), + dest_grid_no: grid(8), + dest_sector: SectorId { x: 1, y: 2, z: 3 }, + }); + *edited.info.entry_mut(EntryPoint::North) = None; + *edited.info.entry_mut(EntryPoint::West) = Some(grid(4000)); + edited.apply_to_ja2(&mut raw).unwrap(); + let saved: Vec = raw.exit_grids.iter().map(|grid| grid.grid_no).collect(); + assert_eq!(saved, [100, 200, 300, 26000]); + assert_eq!(raw.exit_grids[0].dest_sector, (11, 10, 0)); + assert_eq!(raw.exit_grids[1].dest_grid_no, 8); + assert_eq!( + (raw.info.north_grid_no, raw.info.west_grid_no), + (NOWHERE, 4000) + ); + + // The patched map can be written and read again + raw.info.map_version = raw.version.minor; + raw.russian_unknown = Some([0; 148]); + let bytes = raw.to_bytes().unwrap(); + let reloaded = Ja2Map::parse(&bytes).unwrap(); + assert!(reloaded == raw); + let reloaded = fixture.convert(&reloaded); + assert_eq!(reloaded.exit_grids.len(), 3); + assert_eq!(reloaded.info.west_entry, Some(grid(4000))); + assert_eq!(reloaded.info.north_entry, None); + + // Maps without exit grids don't start saving them + let mut no_grids = empty_map(); + fixture + .convert(&no_grids) + .apply_to_ja2(&mut no_grids) + .unwrap(); + assert!(!no_grids.flags.contains(Ja2MapFlags::MAP_EXITGRIDS_SAVED)); + } + + #[test] + fn only_generated_edge_points_are_saved() { + use crate::{ + file_formats::ja2_map::{Ja2EdgePointList, Ja2EdgePointSet, Ja2EdgePoints}, + map::{EdgePointList, EdgePointSet, EdgePoints}, + }; + let fixture = Fixture::new(); + let mut raw = empty_map(); + raw.info.map_version = 25; + // Invalid grid numbers and a middle index past the end are kept as they are + let list = |grid_nos: Vec, middle_index| Ja2EdgePointList { + middle_index, + grid_nos, + }; + let saved = Ja2EdgePoints { + primary: Ja2EdgePointSet { + north: list(vec![1, -5, 3], 7), + ..Default::default() + }, + secondary: Some(Ja2EdgePointSet { + west: list(vec![9], 0), + ..Default::default() + }), + }; + raw.edge_points = Some(saved.clone()); + raw.flags |= Ja2MapFlags::MAP_EDGEPOINTS_SAVED; + let map = fixture.convert(&raw); + assert_eq!(map.edge_points.primary.north.middle_index, 7); + map.apply_to_ja2(&mut raw).unwrap(); + assert_eq!(raw.edge_points.as_ref(), Some(&saved)); + + let mut generated = map.clone(); + generated.edge_points = EdgePoints { + primary: EdgePointSet { + east: EdgePointList { + middle_index: 1, + grid_nos: vec![grid(1043), grid(1203)], + }, + ..Default::default() + }, + secondary: Some(EdgePointSet::default()), + }; + generated.edge_points_stale = true; + generated.apply_to_ja2(&mut raw).unwrap(); + let written = raw.edge_points.clone().unwrap(); + assert_eq!(written.primary.east, list(vec![1043, 1203], 1)); + assert_eq!(written.primary.north, list(vec![], 0)); + assert_eq!(written.secondary, Some(Ja2EdgePointSet::default())); + raw.info.map_version = raw.version.minor; + raw.russian_unknown = Some([0; 148]); + let bytes = raw.to_bytes().unwrap(); + assert!(Ja2Map::parse(&bytes).unwrap() == raw); + + // Maps before version 17 have no secondary edge points + let mut old = empty_map(); + old.version.major = 5.; + old.version.minor = 16; + old.info.map_version = 16; + generated.apply_to_ja2(&mut old).unwrap(); + assert!(old.flags.contains(Ja2MapFlags::MAP_EDGEPOINTS_SAVED)); + assert_eq!(old.edge_points.as_ref().unwrap().secondary, None); + old.to_bytes().unwrap(); + } +} diff --git a/src-map-editor/src/map/schedule.rs b/src-map-editor/src/map/schedule.rs new file mode 100644 index 0000000..dd1db6f --- /dev/null +++ b/src-map-editor/src/map/schedule.rs @@ -0,0 +1,88 @@ +//! Schedules of civilians (`SCHEDULENODE`, `Scheduling.h`) + +use bitflags::bitflags; + +use super::GridNo; + +/// A schedule, referred to by detailed soldier placements (`ubScheduleID`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ScheduleId(u8); + +impl ScheduleId { + /// The schedule with the given id, `None` for 0, which is no schedule + pub fn new(id: u8) -> Option { + (id != 0).then_some(Self(id)) + } + + pub fn number(self) -> u8 { + self.0 + } +} + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct ScheduleFlags: u16 { + /// The time of an action varies + const VARIANCE1 = 0x0001; + const VARIANCE2 = 0x0002; + const VARIANCE3 = 0x0004; + const VARIANCE4 = 0x0008; + const ACTIVE1 = 0x0010; + const ACTIVE2 = 0x0020; + const ACTIVE3 = 0x0040; + const ACTIVE4 = 0x0080; + const TEMPORARY = 0x0100; + const SLEEP_CONVERTED = 0x0200; + const NPC_SLEEPING = 0x0400; + } +} + +/// What a soldier does at a time of the schedule (`SCHEDULE_ACTION_*`) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScheduleAction { + /// Locks the door next to `door`, then goes to `destination` + LockDoor { + door: GridNo, + destination: GridNo, + }, + UnlockDoor { + door: GridNo, + destination: GridNo, + }, + OpenDoor { + door: GridNo, + destination: GridNo, + }, + CloseDoor { + door: GridNo, + destination: GridNo, + }, + /// Goes to a tile + GridNo(GridNo), + LeaveSector, + /// Enters the sector at a tile + EnterSector(GridNo), + StayInSector, + /// Sleeps at a tile + Sleep(GridNo), + /// Wakes up and goes back to the starting tile + Wake, +} + +/// An action at a time of day +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScheduleEntry { + /// Minutes since midnight, `None` if the action has no time + pub time: Option, + pub action: ScheduleAction, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Schedule { + pub id: ScheduleId, + /// Soldier the schedule was saved with, the game assigns the soldiers again + pub soldier_id: u8, + pub flags: ScheduleFlags, + /// The actions by slot, the flags refer to the slots. `None` if the slot is not used. + pub actions: [Option; 4], +} diff --git a/src-map-editor/src/map/tile.rs b/src-map-editor/src/map/tile.rs new file mode 100644 index 0000000..d52489f --- /dev/null +++ b/src-map-editor/src/map/tile.rs @@ -0,0 +1,51 @@ +//! A tile of the map with its tile layers + +use std::{num::NonZeroU8, sync::Arc}; + +use crate::tileset::Tile; + +/// A room of the map (`ubRoomNum`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RoomNo(NonZeroU8); + +impl RoomNo { + /// The room with the given number, `None` for 0, which is no room + pub fn new(number: u8) -> Option { + NonZeroU8::new(number).map(Self) + } + + pub fn number(self) -> u8 { + self.0.get() + } +} + +/// A tile of the map (`MAP_ELEMENT` as saved in the map file) +/// +/// The layers are in the order of the map file, which is the order the game adds them in: land +/// with `AddLandToHead`, the others with `AddObjectToTail`, `AddStructToTail` and so on. So the +/// head of the game's land list (the topmost land tile) is the last element of `land`, while the +/// heads of the other lists are their first elements. The editor keeps this order: +/// `AddLandToHead` is `land.push`, `AddLandToTail` is `land.insert(0, ..)`, `AddStructToHead` is +/// `structs.insert(0, ..)` and `AddObjectToHead` is `objects.insert(0, ..)`. +#[derive(Clone, Debug, Default)] +pub struct MapTile { + /// Terrain height in pixels + pub height: u8, + /// From the tail to the head, the last element is the topmost land tile + pub land: Vec>, + /// From the head to the tail + pub objects: Vec>, + /// From the head to the tail + pub structs: Vec>, + pub shadows: Vec>, + pub roofs: Vec>, + pub on_roofs: Vec>, + pub room: Option, + /// An enemy mine is on the tile (`MAPELEMENT_ENEMY_MINE_PRESENT`) + pub enemy_mine: bool, + /// A mine of the player is on the tile (`MAPELEMENT_PLAYER_MINE_PRESENT`) + pub player_mine: bool, + /// The wall of the tile is a piece of a cave that the editor placed (`LEVELNODE_CAVE`), the + /// map file doesn't have it + pub cave: bool, +} diff --git a/src-map-editor/src/random.rs b/src-map-editor/src/random.rs new file mode 100644 index 0000000..67815a2 --- /dev/null +++ b/src-map-editor/src/random.rs @@ -0,0 +1,44 @@ +//! Random numbers for the choices the game makes at random +//! +//! The game uses `Random`. A seeded generator makes the choices repeatable, e.g. for tests or so +//! that a placement looks the same every time the map is loaded. + +/// A SplitMix64 generator +#[derive(Clone, Debug)] +pub struct Random(u64); + +impl Random { + pub fn new(seed: u64) -> Self { + Self(seed) + } + + /// A number in `0..range`, 0 if `range` is 0 (`Random`) + pub fn next(&mut self, range: u32) -> u32 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^= z >> 31; + (z % u64::from(range.max(1))) as u32 + } + + pub fn pick(&mut self, choices: &[T]) -> T { + choices[self.next(choices.len() as u32) as usize] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_choices_are_repeatable() { + let mut a = Random::new(5); + let mut b = Random::new(5); + let first: Vec<_> = (0..10).map(|_| a.next(100)).collect(); + let second: Vec<_> = (0..10).map(|_| b.next(100)).collect(); + assert_eq!(first, second); + assert!(first.iter().all(|&n| n < 100)); + assert_eq!(a.next(0), 0); + } +} diff --git a/src-map-editor/src/render/atlas.rs b/src-map-editor/src/render/atlas.rs new file mode 100644 index 0000000..5147ed4 --- /dev/null +++ b/src-map-editor/src/render/atlas.rs @@ -0,0 +1,440 @@ +//! The data of the replay shader: sprites as palette indices, palettes, draws and bins +//! +//! Everything is packed into flat `u32` arrays that become storage buffers. Only the sprites and +//! palettes that the draw list uses are included. + +use std::collections::HashMap; + +use bevy::prelude::*; + +use crate::{ + render::{ + bins::Bins, + draw_list::{Draw, DrawSprite, PaletteKey, SpriteGeometry, SpriteKey}, + palette::{pack_rgba, shaded_palettes, shadow_table, SHADE_LEVELS}, + }, + tileset::StciSheet, + tileset::Tileset, +}; + +/// Number of `u32` words of a draw in the draw buffer +pub const DRAW_WORDS: usize = 7; +/// Number of `u32` words of a sprite in the sprite buffer +pub const SPRITE_WORDS: usize = 3; +/// Marks the sprite word of a draw as an animation id +pub const ANIMATED_SPRITE: u32 = 0x8000_0000; + +/// The images and palettes that draws can refer to +pub struct SpriteSheets<'a> { + pub tileset: &'a Tileset, + /// The loaded sheet of each tileset surface, indexed by `SurfaceId` + pub surface_sheets: Vec>, + /// Animation surfaces of soldiers, see `SpriteKey::Soldier` + pub soldier_sheets: Vec>, + /// Palettes of soldiers, see `PaletteKey::Soldier` + pub soldier_palettes: Vec<[[u8; 3]; 256]>, +} + +impl SpriteSheets<'_> { + /// The sheet and sub image index of a sprite + fn sub_image(&self, key: SpriteKey) -> Option<(&StciSheet, usize)> { + match key { + SpriteKey::Tile(index) => { + let image = &self.tileset.tile(index)?.image; + let sheet = (*self.surface_sheets.get(image.surface.id.index())?)?; + Some((sheet, usize::from(image.sub_image))) + } + SpriteKey::Soldier { surface, frame } => { + let sheet = (*self.soldier_sheets.get(usize::from(surface))?)?; + Some((sheet, usize::from(frame))) + } + } + } + + /// Offset and size of a sprite, `None` if it can't be drawn + pub fn geometry(&self, key: SpriteKey) -> Option { + let (sheet, index) = self.sub_image(key)?; + self.pixels(key)?; + let info = sheet.sub_images.get(index)?; + Some(SpriteGeometry { + offset: IVec2::new(info.offset.0.into(), info.offset.1.into()), + size: UVec2::new(info.dimensions.0.into(), info.dimensions.1.into()), + }) + } + + /// The palette indices of a sprite, row by row + pub fn pixels(&self, key: SpriteKey) -> Option<&[u8]> { + let (sheet, index) = self.sub_image(key)?; + let pixels = sheet.indexed.as_ref()?.pixels.get(index)?; + let (width, height) = sheet.sub_images.get(index)?.dimensions; + let size = usize::from(width) * usize::from(height); + (size > 0 && pixels.len() == size).then_some(pixels.as_slice()) + } + + pub fn palette(&self, key: PaletteKey) -> Option<&[[u8; 3]; 256]> { + match key { + PaletteKey::Tile(surface) => { + let sheet = (*self.surface_sheets.get(surface.index())?)?; + Some(&sheet.indexed.as_ref()?.palette) + } + PaletteKey::Soldier(id) | PaletteKey::SoldierUnbiased(id) => { + self.soldier_palettes.get(usize::from(id)) + } + } + } +} + +/// Values of the replay shader that are not in storage buffers +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct SceneParams { + pub bins_origin: IVec2, + pub bins_count: UVec2, + /// Start of the draw indices in the bins buffer + pub bin_indices_offset: u32, + /// Start of the animation table in the sprite buffer + pub animations_offset: u32, +} + +/// The sprites, palettes and animations that the draws of a scene refer to +/// +/// Only grows: draws that were packed once stay valid when more draws are packed later, so the +/// regions of a scene can be packed one at a time. `version` changes whenever something is added. +#[derive(Clone, Debug, Default)] +pub struct SceneAtlas { + /// Added to all palettes (`g_light_color`) + light: [u8; 3], + /// Palette indices of the sprites, 4 per word, little endian + atlas: Vec, + atlas_len: usize, + /// `SPRITE_WORDS` per sprite + sprites: Vec, + sprite_slots: HashMap>, + /// Rows of 256 colours: the shadow table, then the used palettes and shade levels + palettes: Vec, + /// First row of the shade levels of each palette + palette_rows: HashMap, + /// Sprite indices of the frames of each animation + animations: Vec>, + /// Tile database index of each animation + animation_tiles: Vec, + animation_slots: HashMap, + pub version: u64, +} + +/// The buffers of a `SceneAtlas`, shared by all regions +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SharedBuffers { + pub atlas: Vec, + pub palettes: Vec, + /// `SPRITE_WORDS` per sprite, then per animation the start and number of its frames and + /// finally the sprite indices of the frames + pub sprites: Vec, + /// Start of the animation table in `sprites` + pub animations_offset: u32, + /// Tile database index of each animation, to update the current frames + pub animations: Vec, +} + +impl SceneAtlas { + pub fn new(light: [u8; 3]) -> Self { + Self { + light, + palettes: shadow_table().to_vec(), + ..default() + } + } + + /// Packs draws for the draw buffer, adding what they need to the atlas + pub fn pack_draws(&mut self, draws: &[Draw], sheets: &SpriteSheets) -> Vec { + let mut packed = Vec::with_capacity(draws.len() * DRAW_WORDS); + for draw in draws { + let sprite = match draw.sprite { + DrawSprite::Fixed(key) => self.sprite(key, sheets).unwrap_or(0), + DrawSprite::Animated(animation) => { + ANIMATED_SPRITE | self.animation(animation, sheets) + } + }; + let palette = self.palette_row(draw.palette, draw.shade, sheets); + let position = (draw.position.x as i16 as u16 as u32) + | ((draw.position.y as i16 as u16 as u32) << 16); + let z_strip = draw.z_strip.unwrap_or_default(); + packed.extend([ + position, + sprite, + palette, + draw.blitter as u32 | (u32::from(draw.z) << 16), + u32::from(z_strip.first_width) | (u32::from(z_strip.initial_change as u8) << 8), + z_strip.changes, + pack_rgba(draw.outline), + ]); + } + packed + } + + /// Tile database index of each animation + pub fn animations(&self) -> &[u16] { + &self.animation_tiles + } + + /// The buffers of the shader + pub fn shared_buffers(&self) -> SharedBuffers { + // The animation table follows the sprites + let animations_offset = self.sprites.len() as u32; + let mut frames_start = animations_offset + 2 * self.animations.len() as u32; + let mut sprites = self.sprites.clone(); + for frames in &self.animations { + sprites.extend([frames_start, frames.len() as u32]); + frames_start += frames.len() as u32; + } + for frames in &self.animations { + sprites.extend(frames); + } + SharedBuffers { + atlas: self.atlas.clone(), + palettes: self.palettes.clone(), + sprites, + animations_offset, + animations: self.animation_tiles.clone(), + } + } + + /// Number of sprites and bytes of sprite data + pub fn sprite_stats(&self) -> (usize, usize) { + (self.sprites.len() / SPRITE_WORDS, self.atlas_len) + } + + fn changed(&mut self) { + self.version = self.version.wrapping_add(1); + } + + fn push_atlas_bytes(&mut self, bytes: &[u8]) { + for &byte in bytes { + let shift = (self.atlas_len % 4) * 8; + if shift == 0 { + self.atlas.push(0); + } + if let Some(word) = self.atlas.last_mut() { + *word |= u32::from(byte) << shift; + } + self.atlas_len += 1; + } + } + + /// Index of a sprite in the sprite table, `None` if it can't be drawn + fn sprite(&mut self, key: SpriteKey, sheets: &SpriteSheets) -> Option { + if let Some(slot) = self.sprite_slots.get(&key) { + return *slot; + } + let slot = sheets + .geometry(key) + .zip(sheets.pixels(key)) + .map(|(geometry, pixels)| { + let slot = (self.sprites.len() / SPRITE_WORDS) as u32; + let offset = self.atlas_len as u32; + self.push_atlas_bytes(pixels); + self.sprites.extend([ + offset, + geometry.size.x | (geometry.size.y << 16), + (geometry.offset.x as i16 as u16 as u32) + | ((geometry.offset.y as i16 as u16 as u32) << 16), + ]); + slot + }); + self.sprite_slots.insert(key, slot); + if slot.is_some() { + self.changed(); + } + slot + } + + /// Id of the animation of a tile + fn animation(&mut self, animation: u16, sheets: &SpriteSheets) -> u32 { + if let Some(&slot) = self.animation_slots.get(&animation) { + return slot; + } + let frames = sheets + .tileset + .tile(animation) + .and_then(|tile| tile.animation.as_ref()) + .map(|animation| animation.frames.clone()) + .unwrap_or_default(); + // Frames that can't be drawn show the first sprite of the atlas, like a missing image. + // `build_draw_list` only creates animations with at least one drawable frame. + let frames = frames + .into_iter() + .map(|frame| self.sprite(SpriteKey::Tile(frame), sheets).unwrap_or(0)) + .collect::>(); + let slot = self.animations.len() as u32; + self.animations.push(frames); + self.animation_tiles.push(animation); + self.animation_slots.insert(animation, slot); + self.changed(); + slot + } + + /// Row of a palette at a shade level + fn palette_row(&mut self, key: PaletteKey, shade: u8, sheets: &SpriteSheets) -> u32 { + let first = match self.palette_rows.get(&key) { + Some(&first) => first, + None => { + let first = (self.palettes.len() / 256) as u32; + let light = match key { + PaletteKey::SoldierUnbiased(_) => [0; 3], + _ => self.light, + }; + match sheets.palette(key) { + Some(palette) => { + for row in shaded_palettes(palette, light) { + self.palettes.extend(row); + } + } + // Not reachable for drawable sprites, use black + None => self + .palettes + .extend(std::iter::repeat_n(0xff00_0000, 256 * SHADE_LEVELS)), + } + self.palette_rows.insert(key, first); + self.changed(); + first + } + }; + first + u32::from(shade.min(SHADE_LEVELS as u8 - 1)) + } +} + +/// The draws and bins of one region, packed with a `SceneAtlas` +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RegionScene { + /// `DRAW_WORDS` per draw + pub draws: Vec, + /// Bin offsets, then the draw indices of the bins + pub bins: Vec, + pub bins_origin: IVec2, + pub bins_count: UVec2, + /// Start of the draw indices in `bins` + pub bin_indices_offset: u32, + /// Whether any draw is animated + pub animated: bool, +} + +impl RegionScene { + /// Packs the draws that overlap `area` (in draw list order) with bins covering `area` + pub fn build( + draws: &[Draw], + area: IRect, + atlas: &mut SceneAtlas, + sheets: &SpriteSheets, + ) -> Self { + let bins = Bins::build(area, draws.iter().map(|draw| draw.bounds)); + let mut bin_buffer = bins.offsets; + let bin_indices_offset = bin_buffer.len() as u32; + bin_buffer.extend(&bins.indices); + Self { + draws: atlas.pack_draws(draws, sheets), + bins: bin_buffer, + bins_origin: bins.origin, + bins_count: bins.count, + bin_indices_offset, + animated: draws + .iter() + .any(|draw| matches!(draw.sprite, DrawSprite::Animated(_))), + } + } +} + +/// The data of the replay shader for one region, as the material sees it +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GpuScene { + /// `DRAW_WORDS` per draw + pub draws: Vec, + /// Bin offsets, then the draw indices of the bins + pub bins: Vec, + /// Palette indices of the sprites, 4 per word, little endian + pub atlas: Vec, + /// Rows of 256 colours: the shadow table, then the used palettes and shade levels + pub palettes: Vec, + /// `SPRITE_WORDS` per sprite, then per animation the start and number of its frames and + /// finally the sprite indices of the frames + pub sprites: Vec, + /// Current frame of each animation + pub animation_frames: Vec, + /// Tile database index of each animation, to update `animation_frames` + pub animations: Vec, + pub params: SceneParams, +} + +impl GpuScene { + /// The scene of a draw list with its own atlas + pub fn build(draws: &[Draw], bins: &Bins, sheets: &SpriteSheets, light: [u8; 3]) -> Self { + let mut atlas = SceneAtlas::new(light); + let packed = atlas.pack_draws(draws, sheets); + let mut bin_buffer = bins.offsets.clone(); + let bin_indices_offset = bin_buffer.len() as u32; + bin_buffer.extend(&bins.indices); + let region = RegionScene { + draws: packed, + bins: bin_buffer, + bins_origin: bins.origin, + bins_count: bins.count, + bin_indices_offset, + animated: false, + }; + Self::from_parts(&atlas.shared_buffers(), ®ion, sheets.tileset) + } + + /// The scene of one region of a scene with a shared atlas + pub fn from_parts(shared: &SharedBuffers, region: &RegionScene, tileset: &Tileset) -> Self { + GpuScene { + draws: region.draws.clone(), + bins: region.bins.clone(), + atlas: shared.atlas.clone(), + palettes: shared.palettes.clone(), + sprites: shared.sprites.clone(), + animation_frames: current_animation_frames(&shared.animations, tileset), + animations: shared.animations.clone(), + params: SceneParams { + bins_origin: region.bins_origin, + bins_count: region.bins_count, + bin_indices_offset: region.bin_indices_offset, + animations_offset: shared.animations_offset, + }, + } + } +} + +/// Current frame of each animation, the animations are tile ids +pub fn current_animation_frames(animations: &[u16], tileset: &Tileset) -> Vec { + animations + .iter() + .map(|&animation| u32::from(tileset.current_frame_number(animation))) + .collect() +} + +/// Packs bytes into little endian words, the shader reads byte `i` from word `i / 4` +#[cfg(test)] +fn pack_bytes(bytes: &[u8]) -> Vec { + bytes + .chunks(4) + .map(|chunk| { + let mut word = [0; 4]; + word[..chunk.len()].copy_from_slice(chunk); + u32::from_le_bytes(word) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packs_bytes_little_endian() { + assert_eq!(pack_bytes(&[1, 2, 3, 4, 5]), vec![0x0403_0201, 5]); + assert_eq!(pack_bytes(&[]), Vec::::new()); + // The atlas appends bytes the same way + let mut atlas = SceneAtlas::new([0; 3]); + atlas.push_atlas_bytes(&[1, 2, 3]); + atlas.push_atlas_bytes(&[4, 5]); + assert_eq!(atlas.atlas, pack_bytes(&[1, 2, 3, 4, 5])); + } +} diff --git a/src-map-editor/src/render/bins.rs b/src-map-editor/src/render/bins.rs new file mode 100644 index 0000000..2b234f6 --- /dev/null +++ b/src-map-editor/src/render/bins.rs @@ -0,0 +1,129 @@ +//! Spatial bins of the draw list +//! +//! The replay shader computes each pixel from the draws that cover it. To find them quickly, the +//! drawn area is split into square bins, and each bin lists the draws that overlap it, in draw +//! list order. + +use bevy::prelude::*; + +/// Size of a bin in game pixels +pub const BIN_SIZE: i32 = 32; + +/// The draws of each bin +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Bins { + /// Game pixel position of the top left corner of the first bin + pub origin: IVec2, + /// Number of bins in x and y + pub count: UVec2, + /// The draws of bin `i` (row by row) are `indices[offsets[i]..offsets[i + 1]]` + pub offsets: Vec, + /// Indices into the draw list + pub indices: Vec, +} + +impl Bins { + /// Bins that cover `area` (in game pixels) and list the draws with the given bounds + pub fn build(area: IRect, draw_bounds: impl Iterator + Clone) -> Self { + let origin = IVec2::new( + area.min.x.div_euclid(BIN_SIZE) * BIN_SIZE, + area.min.y.div_euclid(BIN_SIZE) * BIN_SIZE, + ); + let extent = (area.max - origin).max(IVec2::ZERO); + let count = UVec2::new( + (extent.x as u32).div_ceil(BIN_SIZE as u32), + (extent.y as u32).div_ceil(BIN_SIZE as u32), + ); + // Range of bins that a draw overlaps, `None` if it is outside of the area + let bin_range = |bounds: IRect| -> Option<(UVec2, UVec2)> { + let bounds = bounds.intersect(area); + if bounds.is_empty() { + return None; + } + let min = ((bounds.min - origin) / BIN_SIZE).as_uvec2(); + let max = ((bounds.max - 1 - origin) / BIN_SIZE).as_uvec2(); + Some((min, max)) + }; + + // Count first, so the indices can be written in place + let bin_count = (count.x * count.y) as usize; + let mut counts = vec![0u32; bin_count + 1]; + for bounds in draw_bounds.clone() { + if let Some((min, max)) = bin_range(bounds) { + for y in min.y..=max.y { + for x in min.x..=max.x { + counts[(y * count.x + x) as usize] += 1; + } + } + } + } + let mut offsets = Vec::with_capacity(bin_count + 1); + let mut total = 0; + for bin_draws in &counts { + offsets.push(total); + total += bin_draws; + } + let mut next = offsets.clone(); + let mut indices = vec![0; total as usize]; + for (draw, bounds) in draw_bounds.enumerate() { + if let Some((min, max)) = bin_range(bounds) { + for y in min.y..=max.y { + for x in min.x..=max.x { + let bin = (y * count.x + x) as usize; + indices[next[bin] as usize] = draw as u32; + next[bin] += 1; + } + } + } + } + Bins { + origin, + count, + offsets, + indices, + } + } + + /// Draws of the bin that contains a game pixel + pub fn draws_at(&self, pixel: IVec2) -> &[u32] { + let bin = (pixel - self.origin).div_euclid(IVec2::splat(BIN_SIZE)); + if bin.x < 0 || bin.y < 0 || bin.x as u32 >= self.count.x || bin.y as u32 >= self.count.y { + return &[]; + } + let bin = (bin.y as u32 * self.count.x + bin.x as u32) as usize; + &self.indices[self.offsets[bin] as usize..self.offsets[bin + 1] as usize] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn draws_keep_their_order_in_every_bin() { + let area = IRect::new(-40, 0, 64, 64); + let draws = [ + // Covers all bins + IRect::new(-40, 0, 64, 64), + // Only the top left bin + IRect::new(-64, -10, -33, 5), + // Ends exactly at a bin border + IRect::new(0, 0, 32, 32), + // Outside of the area + IRect::new(100, 100, 120, 120), + // Empty + IRect::new(10, 10, 10, 20), + ]; + let bins = Bins::build(area, draws.iter().copied()); + assert_eq!(bins.origin, IVec2::new(-64, 0)); + assert_eq!(bins.count, UVec2::new(4, 2)); + assert_eq!(bins.offsets.len(), 9); + assert_eq!(bins.draws_at(IVec2::new(-40, 0)), &[0, 1]); + assert_eq!(bins.draws_at(IVec2::new(-1, 31)), &[0]); + assert_eq!(bins.draws_at(IVec2::new(0, 0)), &[0, 2]); + assert_eq!(bins.draws_at(IVec2::new(31, 31)), &[0, 2]); + assert_eq!(bins.draws_at(IVec2::new(32, 32)), &[0]); + assert_eq!(bins.draws_at(IVec2::new(0, 64)), &[] as &[u32]); + assert_eq!(bins.draws_at(IVec2::new(-65, 0)), &[] as &[u32]); + } +} diff --git a/src-map-editor/src/render/draw_list.rs b/src-map-editor/src/render/draw_list.rs new file mode 100644 index 0000000..1e45cee --- /dev/null +++ b/src-map-editor/src/render/draw_list.rs @@ -0,0 +1,1461 @@ +//! The draws of a map in the order the game renders them +//! +//! Port of the traversal and the per-node setup of `RenderTiles` in `RenderWorld.cc`, as called by +//! `RenderStaticWorld` and `RenderDynamicWorld`: the level lists are drawn row by row, and the +//! layers of one `RenderTiles` call are interleaved within each row. Each draw records the +//! position, sprite, palette, blitter and z value, the GPU then replays the draws per pixel. +//! +//! Not reproduced are things that only happen while playing (redundant tile culling, revealed and +//! hidden nodes, the glow of enemies) and the game drawing tiles of other rows when its view +//! reaches past the map border. + +use bevy::prelude::*; +use serde::Deserialize; + +use crate::{ + components::map::{GridPosition, JA2_MAP_SIZE}, + map::{GridNo, Level}, + render::{ + soldiers::tile_merc_draws, + world::{LevelNode, MapWorld, NodeFlags, WorldTile}, + }, + soldier::team::TeamId, + tileset::{SurfaceId, Tile, TileKind, Tileset, ViewLayer, ZStrip}, +}; + +/// Height of a wall and of the roof level in pixels (`WALL_HEIGHT`) +pub const WALL_HEIGHT: i32 = 50; + +/// Z levels within a row (`ZLevels`) +pub(crate) const Z_SUBLAYERS: i32 = 8; +const LAND_Z_LEVEL: i32 = 0; +const OBJECT_Z_LEVEL: i32 = 1; +const SHADOW_Z_LEVEL: i32 = 2; +pub(crate) const MERC_Z_LEVEL: i32 = 3; +pub(crate) const STRUCT_Z_LEVEL: i32 = 4; +const ROOF_Z_LEVEL: i32 = 5; +pub(crate) const ONROOF_Z_LEVEL: i32 = 6; +const TOPMOST_Z_LEVEL: i32 = 32767; + +/// Outline colour of items on the ground (`gusNormalItemOutlineColor`) +pub const ITEM_OUTLINE_COLOR: [u8; 3] = [255, 255, 255]; +/// Outline colour of items on roofs (`gusYellowItemOutlineColor`) +pub const ON_ROOF_ITEM_OUTLINE_COLOR: [u8; 3] = [255, 255, 0]; + +/// The blitters of the game that static map content uses, with the depth rules of +/// `VObject_Blitters.cc` and `RenderWorld.cc` +/// +/// The numbers are used by the replay shader. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum Blitter { + /// `Transparent`: no z test or write + Transparent = 0, + /// `TransZ`: drawn where the z buffer is <= z, writes z + TransZ = 1, + /// `TransZNB`: drawn where the z buffer is <= z + TransZNB = 2, + /// `TransZInc`: z changes per z strip, drawn where the z buffer is < z, writes z + TransZInc = 3, + /// `TransZIncSameZBurnsThrough`: like `TransZInc`, but drawn where the z buffer is <= z + TransZIncSameZBurnsThrough = 4, + /// `ShadowZ`: darkens where the z buffer is < z, writes z + ShadowZ = 5, + /// `Shadow`: darkens without a z test + Shadow = 6, + /// `OutlineZ`: drawn where the z buffer is <= z, writes z. Index 254 is drawn in the outline + /// colour without writing z. + OutlineZ = 7, + /// `OutlineZPixelateObscured`: where the z buffer is < z drawn and z written, elsewhere drawn + /// in a checker pattern. Index 254 is drawn in the outline colour. + OutlineZPixelateObscured = 8, + /// `TransShadowZNB`: drawn where the z buffer is < z, index 254 darkens + TransShadowZNB = 9, + /// `TransShadowZNBObscured`: index 254 darkens where the z buffer is < z, the others are drawn + /// where it is <= z and elsewhere in a checker pattern + TransShadowZNBObscured = 10, +} + +impl Blitter { + pub fn uses_z_strips(self) -> bool { + matches!( + self, + Blitter::TransZInc | Blitter::TransZIncSameZBurnsThrough + ) + } +} + +/// An image that a draw shows +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SpriteKey { + /// The sub image of a tile of the tile database + Tile(u16), + /// A frame of a soldier animation surface, `surface` is a `StandingSurfaceId` index + Soldier { surface: u16, frame: u16 }, +} + +/// The palette that a draw uses +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum PaletteKey { + /// The palette of a tileset surface + Tile(SurfaceId), + /// The palette of a soldier + Soldier(u16), + /// The palette of a soldier without the light colour of the map + SoldierUnbiased(u16), +} + +/// The sprite of a draw +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DrawSprite { + Fixed(SpriteKey), + /// The current frame of an animated tile (`ANIMATED_TILE`), the tile database index of the + /// animation + Animated(u16), +} + +/// Offset and size of a sprite (`sOffsetX`, `sOffsetY`, `usWidth`, `usHeight`) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpriteGeometry { + pub offset: IVec2, + pub size: UVec2, +} + +/// Z strips of a sprite in the form the shader uses (`ZStripInfo`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct PackedZStrip { + pub first_width: u8, + pub initial_change: i8, + /// Change of each strip after the first as 2 bit two's complement values, the lowest bits + /// are the first strip. The game has room for 16 changes. + pub changes: u32, +} + +impl From<&ZStrip> for PackedZStrip { + fn from(info: &ZStrip) -> Self { + let changes = info + .z_changes + .iter() + .take(16) + .enumerate() + .fold(0, |bits, (i, &change)| { + bits | ((change as u32 & 0b11) << (2 * i)) + }); + Self { + first_width: info.first_z_strip_width, + initial_change: info.initial_z_change, + changes, + } + } +} + +impl PackedZStrip { + /// Z value of a sprite column, `z` is the z value of the draw + pub fn column_z(&self, z: u16, column: u32) -> u16 { + const HALF_TILE: u32 = 20; + let first = u32::from(self.first_width); + let strip = if column < first { + 0 + } else { + 1 + (column - first) / HALF_TILE + }; + let mut level = i32::from(z) + i32::from(self.initial_change) * Z_SUBLAYERS * 10; + for i in 0..strip.min(16) { + let change = ((self.changes >> (2 * i)) & 0b11) as i32; + // Sign extend the 2 bit value + let change = if change >= 2 { change - 4 } else { change }; + level += change * Z_SUBLAYERS * 10; + } + level as u16 + } +} + +/// One blit of the game +#[derive(Clone, Debug, PartialEq)] +pub struct Draw { + /// Position that the sprite offset is relative to, in game pixels + pub position: IVec2, + pub sprite: DrawSprite, + pub palette: PaletteKey, + /// Shade level of the palette, 0 to 15 + pub shade: u8, + pub blitter: Blitter, + pub z: u16, + /// Set for the blitters that use z strips + pub z_strip: Option, + /// Colour of index 254 for the outline blitters + pub outline: [u8; 3], + /// Area the draw can cover, for animations the area of all frames. Min inclusive, max + /// exclusive, in game pixels. + pub bounds: IRect, + /// Tile the draw belongs to + pub grid_no: GridNo, +} + +/// Which layers of the map are drawn +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayerVisibility { + /// What the terrain mode places: land textures, water, roads, cliffs and their shadows, and + /// the floors of buildings + pub terrain: bool, + /// What the objects mode places: debris, trees, rocks, misc junk, furniture and their + /// shadows + pub objects: bool, + /// What the buildings mode places but the floors: walls, doors and their shadows, wall + /// decorations, roofs and what is on them + pub buildings: bool, + pub items: bool, + pub soldiers: bool, + /// Which teams of the soldiers layer are drawn, edited and selected + pub soldier_teams: VisibleTeams, + /// The room numbers of the tiles, which are not part of the draws + pub room_numbers: bool, +} + +impl LayerVisibility { + /// The layers without those that are not drawn with the map, a change of which doesn't + /// change the draws. The soldiers of the shown teams are brought up to date by + /// `sync_soldier_scene` instead of a rebuild. + pub fn drawn(self) -> Self { + Self { + room_numbers: false, + soldier_teams: VisibleTeams::default(), + ..self + } + } + + /// Whether the tiles of a view layer are drawn, tiles without one always are + pub fn shows(self, layer: Option) -> bool { + match layer { + Some(ViewLayer::Terrain) => self.terrain, + Some(ViewLayer::Objects) => self.objects, + Some(ViewLayer::Buildings) => self.buildings, + None => true, + } + } +} + +impl Default for LayerVisibility { + fn default() -> Self { + Self { + terrain: true, + objects: true, + buildings: true, + items: true, + soldiers: true, + soldier_teams: VisibleTeams::default(), + room_numbers: false, + } + } +} + +/// Which teams of the editor have their soldiers shown (`SetMercTeamVisibility`) +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct VisibleTeams { + pub enemy: bool, + pub creature: bool, + pub rebel: bool, + pub civilian: bool, +} + +impl Default for VisibleTeams { + fn default() -> Self { + Self { + enemy: true, + creature: true, + rebel: true, + civilian: true, + } + } +} + +impl VisibleTeams { + /// Whether the soldiers of a team are shown, those of the player's teams always are + pub fn shows(&self, team: TeamId) -> bool { + if team == TeamId::ENEMY { + self.enemy + } else if team == TeamId::CREATURE { + self.creature + } else if team == TeamId::MILITIA { + self.rebel + } else if team == TeamId::CIV { + self.civilian + } else { + true + } + } +} + +/// Options of the draw list +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct DrawListOptions { + pub layers: LayerVisibility, + /// The roofs, what is on them and the topmost tiles are only drawn on the roof floor + pub floor: Level, + /// Whether the static shadows are drawn (`RENDER_FLAG_SHADOWS`, off from dusk on) + pub shadows: bool, +} + +/// Soldier that is drawn on a tile, see `soldiers.rs` +#[derive(Clone, Debug, PartialEq)] +pub struct MercDraw { + /// Position that the frame offset is relative to, in game pixels + pub position: IVec2, + pub sprite: SpriteKey, + pub palette: PaletteKey, + pub shade: u8, + pub blitter: Blitter, + pub z: u16, + /// Whether the soldier is on a roof (`bLevel > 0`), drawn with the high mercs + pub on_roof: bool, +} + +/// The render layers of the game (`RenderLayerID`) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RenderLayer { + StaticLand, + StaticObjects, + StaticShadows, + StaticStructs, + StaticRoof, + StaticOnRoof, + StaticTopmost, + DynamicObjects, + DynamicShadows, + DynamicMercs, + DynamicStructs, + DynamicRoof, + DynamicHighMercs, + DynamicOnRoof, + DynamicTopmost, +} + +impl RenderLayer { + fn is_dynamic(self) -> bool { + !matches!( + self, + RenderLayer::StaticLand + | RenderLayer::StaticObjects + | RenderLayer::StaticShadows + | RenderLayer::StaticStructs + | RenderLayer::StaticRoof + | RenderLayer::StaticOnRoof + | RenderLayer::StaticTopmost + ) + } +} + +/// Whether a `RenderTiles` call draws the nodes that show through structures +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PassKind { + Normal, + /// `TILES_OBSCURED` + Obscured, +} + +/// One `RenderTiles` call of `RenderStaticWorld` or `RenderDynamicWorld` +struct Pass { + kind: PassKind, + layers: &'static [RenderLayer], +} + +/// The `RenderTiles` calls in the order of the game. Only drawn with shadows: the static shadows. +const PASSES: [Pass; 8] = { + use RenderLayer::*; + let normal = PassKind::Normal; + [ + // RenderStaticWorld + Pass { + kind: normal, + layers: &[StaticLand], + }, + Pass { + kind: normal, + layers: &[StaticObjects], + }, + Pass { + kind: normal, + layers: &[StaticShadows], + }, + Pass { + kind: normal, + layers: &[StaticStructs, StaticRoof, StaticOnRoof, StaticTopmost], + }, + Pass { + kind: PassKind::Obscured, + layers: &[StaticStructs, StaticOnRoof], + }, + // RenderDynamicWorld. It starts with another obscured pass of the structs, which draws + // nothing new because nothing was drawn since the one above. + Pass { + kind: normal, + layers: &[DynamicObjects, DynamicShadows, DynamicMercs, DynamicStructs], + }, + Pass { + kind: normal, + layers: &[DynamicRoof, DynamicHighMercs, DynamicOnRoof], + }, + Pass { + kind: normal, + layers: &[DynamicTopmost], + }, + ] +}; +/// Index of the static shadow pass in `PASSES` +const STATIC_SHADOW_PASS: usize = 2; + +/// Position of a draw in the draw list of the whole map +/// +/// `RenderTiles` draws row by row, and within a row the layers of the pass one after the other, +/// from left to right. So the draws of a map are ordered by pass, row, layer, x and the position +/// in the node list of the tile, and the draws of each tile can be computed on their own. +fn draw_key(pass: usize, position: GridPosition, layer: usize, node: usize) -> u64 { + let row = u64::from(position.x) + u64::from(position.y); + ((pass as u64) << 48) + | (row << 36) + | ((layer as u64) << 32) + | (u64::from(position.x) << 24) + | node as u64 +} + +/// The draws of one tile with their positions in the draw list of the map +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TileDraws { + /// In draw list order, with their `draw_key` + pub draws: Vec<(u64, Draw)>, + /// Union of the bounds of the draws, `None` without draws + pub bounds: Option, +} + +/// Port of the per-tile part of `RenderTiles` +pub struct DrawContext<'a, S> { + pub world: &'a MapWorld, + pub tileset: &'a Tileset, + /// Returns the geometry of a sprite, or `None` if it can't be drawn + pub sprites: S, + pub options: DrawListOptions, +} + +impl Option> DrawContext<'_, S> { + /// The draws of a tile in all passes + pub fn tile_draws(&self, grid_no: GridNo) -> TileDraws { + let position = grid_no.position(); + let tile = TileContext { + world: self.world, + tileset: self.tileset, + sprites: &self.sprites, + options: self.options, + top_y: map_top_y(self.world.restricted_scroll), + mercs: tile_merc_draws(self.world, grid_no), + }; + let mut result = TileDraws::default(); + let mut draws = Vec::new(); + for (pass_index, pass) in PASSES.iter().enumerate() { + if pass_index == STATIC_SHADOW_PASS && !self.options.shadows { + continue; + } + for (layer_index, &layer) in pass.layers.iter().enumerate() { + draws.clear(); + tile.render_tile(pass.kind, layer, position, &mut draws); + for (node, draw) in draws.drain(..).enumerate() { + result.bounds = Some(match result.bounds { + Some(bounds) => bounds.union(draw.bounds), + None => draw.bounds, + }); + let key = draw_key(pass_index, position, layer_index, node); + result.draws.push((key, draw)); + } + } + } + result + } +} + +/// The draws of every tile of the map, the source of the draw lists of the whole map and of +/// parts of it +#[derive(Clone, Debug, Default)] +pub struct DrawCache { + tiles: Vec, +} + +impl DrawCache { + /// The draws of all tiles + pub fn build Option>(context: &DrawContext) -> Self { + Self { + tiles: GridNo::all() + .map(|grid_no| context.tile_draws(grid_no)) + .collect(), + } + } + + pub fn tile(&self, grid_no: GridNo) -> &TileDraws { + &self.tiles[grid_no.index()] + } + + /// Replaces the draws of a tile, returns the area whose pixels change, `None` if nothing + /// changed + /// + /// The area is the union of the old and the new bounds, so it also covers draws that moved + /// or were removed. + pub fn replace(&mut self, grid_no: GridNo, draws: TileDraws) -> Option { + let old = &mut self.tiles[grid_no.index()]; + if *old == draws { + return None; + } + let changed = match (old.bounds, draws.bounds) { + (Some(a), Some(b)) => Some(a.union(b)), + (a, b) => a.or(b), + }; + *old = draws; + changed + } + + /// Union of the bounds of all draws, empty without draws + pub fn bounds(&self) -> IRect { + self.tiles + .iter() + .filter_map(|tile| tile.bounds) + .reduce(|a, b| a.union(b)) + .unwrap_or_default() + } + + /// The draws of the map in draw list order + pub fn all(&self) -> Vec { + self.collect(|_| true) + } + + /// The draws that overlap `rect`, in draw list order + /// + /// A pixel only depends on the draws that cover it, in the order of the draw list. The draws + /// that overlap `rect` in the order of the whole draw list therefore give the same pixels + /// in `rect` as the whole draw list. + pub fn ordered(&self, rect: IRect) -> Vec { + self.collect(|bounds| !bounds.intersect(rect).is_empty()) + } + + fn collect(&self, overlaps: impl Fn(IRect) -> bool) -> Vec { + let mut draws: Vec<&(u64, Draw)> = self + .tiles + .iter() + .filter(|tile| tile.bounds.is_some_and(&overlaps)) + .flat_map(|tile| &tile.draws) + .filter(|(_, draw)| overlaps(draw.bounds)) + .collect(); + draws.sort_unstable_by_key(|(key, _)| *key); + draws.into_iter().map(|(_, draw)| draw.clone()).collect() + } +} + +/// Builds the draw list of a map +/// +/// `sprites` returns the geometry of a sprite, or `None` if it can't be drawn. +pub fn build_draw_list( + world: &MapWorld, + tileset: &Tileset, + sprites: impl Fn(SpriteKey) -> Option, + options: DrawListOptions, +) -> Vec { + DrawCache::build(&DrawContext { + world, + tileset, + sprites, + options, + }) + .all() +} + +/// What the per-node part of `RenderTiles` needs for one tile +struct TileContext<'a, S> { + world: &'a MapWorld, + tileset: &'a Tileset, + sprites: &'a S, + options: DrawListOptions, + /// Top edge of the map in screen coordinates (`gsTopY`) + top_y: i32, + /// The soldiers of the tile in the order of the merc list + mercs: Vec, +} + +/// Where a tile of the ground levels (land, objects, shadows and structs) is drawn: its tile box +/// raised by the terrain height, the sprite offset is relative to this +pub(crate) fn ground_draw_position( + position: GridPosition, + world_tile: &WorldTile, + tile: &Tile, +) -> IVec2 { + let mut draw_position = position.tile_box(); + let height = i32::from(world_tile.height); + if tile.ignores_world_height { + draw_position.y -= ((height / 80 - 1) * 80).max(0); + } else { + draw_position.y -= height; + } + draw_position +} + +/// Top edge of the map in screen coordinates (`gsTopY` after `InitRenderParams`) +pub(crate) fn map_top_y(restricted_scroll: bool) -> i32 { + const CELL_SIZE: i32 = 10; + const ROOF_LEVEL_HEIGHT: i32 = 50; + let rows = i32::from(JA2_MAP_SIZE); + let top_left = if restricted_scroll { + // Basement level 1 + (CELL_SIZE * rows * 3 / 10, CELL_SIZE * rows / 2) + } else { + (CELL_SIZE, CELL_SIZE * rows / 2) + }; + // FromCellToScreenCoordinates + top_left.0 + top_left.1 + ROOF_LEVEL_HEIGHT +} + +/// The screen y of a tile that the z values are based on (`GetMapXYWorldY`) +pub(crate) fn map_world_y(x: i32, y: i32, top_y: i32) -> i32 { + const CELL_SIZE: i32 = 10; + const CENTER_WORLD: i32 = 800; + const SCREEN_CENTER_Y: i32 = 1625; + let world_y = (x * CELL_SIZE - CENTER_WORLD) + (y * CELL_SIZE - CENTER_WORLD); + // The game computes this in INT16 + (world_y + SCREEN_CENTER_Y - top_y) as i16 as i32 +} + +/// The z value the game passes to the blitters, an INT16 converted to UINT16 +fn z_value(level: i32) -> u16 { + level as i16 as u16 +} + +impl Option> TileContext<'_, S> { + /// Whether the nodes of a layer can be drawn, `item` for the item nodes of the structs and + /// the tiles on the roofs, which only depend on the items and, on the roofs, the floor + /// + /// The roofs, what is on them and the topmost tiles are only drawn on the roof floor. Which + /// tiles of the other layers are drawn depends on their view layer. + fn layer_visible(&self, layer: RenderLayer, item: bool) -> bool { + let layers = &self.options.layers; + let roof = self.options.floor == Level::Roof; + if item { + return layers.items + && match layer { + RenderLayer::StaticStructs | RenderLayer::DynamicStructs => true, + RenderLayer::StaticOnRoof | RenderLayer::DynamicOnRoof => roof, + _ => false, + }; + } + match layer { + RenderLayer::StaticRoof + | RenderLayer::DynamicRoof + | RenderLayer::StaticOnRoof + | RenderLayer::DynamicOnRoof + | RenderLayer::StaticTopmost + | RenderLayer::DynamicTopmost => roof, + RenderLayer::DynamicMercs | RenderLayer::DynamicHighMercs => layers.soldiers, + _ => true, + } + } + + fn render_tile( + &self, + kind: PassKind, + layer: RenderLayer, + position: GridPosition, + out: &mut Vec, + ) { + // Item nodes can be shown in a hidden layer + if !self.layer_visible(layer, false) && !self.layer_visible(layer, true) { + return; + } + let world = self.world; + let tile = world.tile(position); + match layer { + RenderLayer::DynamicMercs | RenderLayer::DynamicHighMercs => { + self.render_mercs(layer, position, out); + } + // The land list is drawn from its start towards the head + RenderLayer::StaticLand => { + if let Some(start) = tile.land_start { + for node in tile.land[..=start].iter().rev() { + self.render_node(kind, layer, position, tile, node, out); + } + } + } + _ => { + let nodes = match layer { + RenderLayer::StaticObjects | RenderLayer::DynamicObjects => &tile.objects, + RenderLayer::StaticShadows | RenderLayer::DynamicShadows => &tile.shadows, + RenderLayer::StaticStructs | RenderLayer::DynamicStructs => &tile.structs, + RenderLayer::StaticRoof | RenderLayer::DynamicRoof => &tile.roofs, + RenderLayer::StaticOnRoof | RenderLayer::DynamicOnRoof => &tile.on_roofs, + _ => &tile.topmost, + }; + for node in nodes { + self.render_node(kind, layer, position, tile, node, out); + } + } + } + } + + fn render_mercs(&self, layer: RenderLayer, position: GridPosition, out: &mut Vec) { + let grid_no = GridNo::from(position); + for merc in &self.mercs { + // Soldiers on roofs are drawn with the high mercs, after the roofs + if merc.on_roof != (layer == RenderLayer::DynamicHighMercs) { + continue; + } + let Some(geometry) = (self.sprites)(merc.sprite) else { + continue; + }; + out.push(Draw { + position: merc.position, + sprite: DrawSprite::Fixed(merc.sprite), + palette: merc.palette, + shade: merc.shade, + blitter: merc.blitter, + z: merc.z, + z_strip: None, + outline: [0; 3], + bounds: sprite_bounds(merc.position, geometry), + grid_no, + }); + } + } + + /// Port of the per-node part of `RenderTiles` for tiles + fn render_node( + &self, + kind: PassKind, + layer: RenderLayer, + position: GridPosition, + world_tile: &WorldTile, + node: &LevelNode, + out: &mut Vec, + ) { + let tileset = self.tileset; + let dynamic = layer.is_dynamic(); + let is_item = node.flags.contains(NodeFlags::ITEM); + if !self.layer_visible(layer, is_item) { + return; + } + // Tiles are hidden with the editing mode that places them + if !is_item && !self.options.layers.shows(node.tile.view_layer) { + return; + } + // Items are dynamic (glowing items) and show through structures + // (`HandleItemObscuredFlag`) + let node_dynamic = is_item; + let show_through = is_item; + + let mut tile = &node.tile; + // The animation of an animated tile, its current frame is drawn + let mut animation = None; + if dynamic { + if !node_dynamic && !tile.dynamic { + let Some(frame) = tileset.current_frame(tile.id) else { + return; + }; + let Some(frame_tile) = tileset.tile(frame) else { + return; + }; + animation = node.tile.animation.as_ref(); + tile = frame_tile; + } + } else if tile.animation.is_some() + || ((tile.dynamic || node_dynamic) && kind != PassKind::Obscured) + { + return; + } + + // Only the struct and on roof layers look at the obscured flag + let mut obscured_blitter = false; + if matches!( + layer, + RenderLayer::StaticStructs | RenderLayer::StaticOnRoof + ) { + match (kind, show_through) { + (PassKind::Obscured, true) => obscured_blitter = true, + (PassKind::Obscured, false) | (PassKind::Normal, true) => return, + (PassKind::Normal, false) => {} + } + } + + let (x, y) = (i32::from(position.x), i32::from(position.y)); + let world_y = map_world_y(x, y, self.top_y); + let mut draw_position = ground_draw_position(position, world_tile, tile); + if is_item && node.item_height > 0 { + draw_position.y -= i32::from(node.item_height); + } + + let shadow_blitter = matches!( + layer, + RenderLayer::StaticShadows | RenderLayer::DynamicShadows + ); + // `fZWrite` and `fZBlitter` of the layer (`RenderFX`) + let z_write = !dynamic && layer != RenderLayer::StaticLand; + let z_blitter = !matches!(layer, RenderLayer::StaticLand | RenderLayer::DynamicShadows); + let mut multi_z = false; + let mut wall = false; + let level = match layer { + RenderLayer::StaticLand => LAND_Z_LEVEL, + RenderLayer::StaticObjects | RenderLayer::DynamicObjects => { + if tile.kind == (TileKind::Cliff { hang: true }) { + LAND_Z_LEVEL + } else if tile.uses_object_z_height { + world_y * Z_SUBLAYERS + LAND_Z_LEVEL + } else { + OBJECT_Z_LEVEL + } + } + RenderLayer::StaticShadows | RenderLayer::DynamicShadows => { + ((world_y - 80) * Z_SUBLAYERS + SHADOW_Z_LEVEL).max(0) + } + RenderLayer::StaticStructs | RenderLayer::DynamicStructs => { + if layer == RenderLayer::StaticStructs { + multi_z = tile.z_strip.is_some(); + wall = matches!(tile.kind, TileKind::Wall(_)); + } + let level = if is_item { + if node.item_height > 0 { + STRUCT_Z_LEVEL + i32::from(node.item_height) + } else { + OBJECT_Z_LEVEL + } + } else { + STRUCT_Z_LEVEL + }; + // `sZLevel` is an INT16 + i32::from((level + world_y * Z_SUBLAYERS) as i16) + } + RenderLayer::StaticRoof | RenderLayer::DynamicRoof => { + draw_position.y -= WALL_HEIGHT; + (WALL_HEIGHT + world_y) * Z_SUBLAYERS + ROOF_Z_LEVEL + } + RenderLayer::StaticOnRoof | RenderLayer::DynamicOnRoof => { + draw_position.y -= WALL_HEIGHT; + (world_y + WALL_HEIGHT) * Z_SUBLAYERS + ONROOF_Z_LEVEL + } + RenderLayer::StaticTopmost | RenderLayer::DynamicTopmost => TOPMOST_Z_LEVEL, + RenderLayer::DynamicMercs | RenderLayer::DynamicHighMercs => unreachable!(), + }; + + let blitter = if is_item { + if obscured_blitter { + Blitter::OutlineZPixelateObscured + } else { + Blitter::OutlineZ + } + } else if multi_z { + if wall { + Blitter::TransZIncSameZBurnsThrough + } else { + Blitter::TransZInc + } + } else if shadow_blitter { + match (z_blitter, z_write) { + (true, true) => Blitter::ShadowZ, + // `ShadowZNB` is only used for physics objects + (true, false) => return, + (false, _) => Blitter::Shadow, + } + } else if z_blitter { + if z_write { + Blitter::TransZ + } else { + Blitter::TransZNB + } + } else { + Blitter::Transparent + }; + + let z_strip = if blitter.uses_z_strips() { + match &tile.z_strip { + Some(z_strip) => Some(PackedZStrip::from(z_strip)), + // The game warns and draws nothing + None => return, + } + } else { + None + }; + + let (sprite, bounds) = match animation { + Some(animation) => { + let bounds = animation + .frames + .iter() + .filter_map(|&frame| (self.sprites)(SpriteKey::Tile(frame))) + .map(|geometry| sprite_bounds(draw_position, geometry)) + .reduce(|a, b| a.union(b)); + (DrawSprite::Animated(node.tile.id), bounds) + } + None => { + let key = SpriteKey::Tile(tile.id); + let bounds = + (self.sprites)(key).map(|geometry| sprite_bounds(draw_position, geometry)); + (DrawSprite::Fixed(key), bounds) + } + }; + let Some(bounds) = bounds else { + return; + }; + // `RenderTiles` picks the outline colour of items by layer + let outline = if matches!( + layer, + RenderLayer::StaticOnRoof | RenderLayer::DynamicOnRoof + ) { + ON_ROOF_ITEM_OUTLINE_COLOR + } else { + ITEM_OUTLINE_COLOR + }; + out.push(Draw { + position: draw_position, + sprite, + palette: PaletteKey::Tile(tile.image.surface.id), + shade: node.light.shade, + blitter, + z: z_value(level), + z_strip, + outline, + bounds, + grid_no: GridNo::from(position), + }); + } +} + +/// Area of a sprite drawn at `position` +pub fn sprite_bounds(position: IVec2, geometry: SpriteGeometry) -> IRect { + let min = position + geometry.offset; + IRect::from_corners(min, min + geometry.size.as_ivec2()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tileset::tests::{ + test_node, test_tileset, FIRSTFULLSTRUCT, FIRSTOSTRUCT, FIRSTROOF, FIRSTSHADOW, + FIRSTTEXTURE, GUNS, + }; + + fn tile_sprites(_: SpriteKey) -> Option { + Some(SpriteGeometry { + offset: IVec2::ZERO, + size: UVec2::new(40, 20), + }) + } + + fn grid_no(x: u8, y: u8) -> GridNo { + GridNo::from(GridPosition::new(x, y)) + } + + /// The options that draw everything, on the roof floor + fn options(shadows: bool) -> DrawListOptions { + DrawListOptions { + layers: LayerVisibility::default(), + floor: Level::Roof, + shadows, + } + } + + #[test] + fn rows_interleave_the_layers_of_a_pass() { + let tileset = test_tileset(); + let node = |tile_type, sub_index| test_node(&tileset, tile_type, sub_index); + let mut world = MapWorld::new(false); + let land = &mut world.tiles[grid_no(2, 2)]; + land.land = vec![node(FIRSTTEXTURE, 1)]; + land.land_start = Some(0); + land.objects.push(node(FIRSTOSTRUCT, 3)); + world.tiles[grid_no(0, 0)].roofs.push(node(FIRSTROOF, 1)); + world.tiles[grid_no(0, 1)] + .structs + .push(node(FIRSTOSTRUCT, 1)); + world.tiles[grid_no(0, 1)].roofs.push(node(FIRSTROOF, 2)); + world.tiles[grid_no(1, 0)].height = 30; + world.tiles[grid_no(1, 0)] + .structs + .push(node(FIRSTOSTRUCT, 2)); + + let draws = build_draw_list(&world, &tileset, tile_sprites, options(true)); + let order: Vec<_> = draws + .iter() + .map(|draw| (draw.grid_no, draw.blitter)) + .collect(); + assert_eq!( + order, + vec![ + (grid_no(2, 2), Blitter::Transparent), + (grid_no(2, 2), Blitter::TransZ), + // Row 0: the roof of (0, 0) + (grid_no(0, 0), Blitter::TransZ), + // Row 1: first the structs from left to right, then the roofs + (grid_no(0, 1), Blitter::TransZ), + (grid_no(1, 0), Blitter::TransZ), + (grid_no(0, 1), Blitter::TransZ), + ] + ); + + // Roofs are drawn a wall height higher, with their own z level + let roof = &draws[2]; + assert_eq!(roof.position, IVec2::new(0, -WALL_HEIGHT)); + assert_eq!( + roof.z, + z_value((WALL_HEIGHT + 25 - 860) * Z_SUBLAYERS + ROOF_Z_LEVEL) + ); + // The terrain height lifts the struct + let raised = &draws[4]; + assert_eq!(raised.position, IVec2::new(20, 10 - 30)); + assert_eq!( + raised.z, + z_value((10 + 25 - 860) * Z_SUBLAYERS + STRUCT_Z_LEVEL) + ); + assert_eq!(raised.bounds, IRect::new(20, -20, 60, 0)); + } + + #[test] + fn dynamic_tiles_items_and_shadows() { + let tileset = test_tileset(); + let node = |tile_type, sub_index| test_node(&tileset, tile_type, sub_index); + let mut world = MapWorld::new(false); + let tile = &mut world.tiles[grid_no(3, 3)]; + // Foliage is dynamic and drawn after the static layers, without writing z + tile.structs.push(node(FIRSTFULLSTRUCT, 2)); + let mut item = node(GUNS, 1); + item.flags |= NodeFlags::ITEM; + item.item_height = 10; + tile.structs.push(item); + world.tiles[grid_no(4, 4)] + .shadows + .push(node(FIRSTSHADOW, 1)); + + let draws = build_draw_list(&world, &tileset, tile_sprites, options(true)); + let blitters: Vec<_> = draws.iter().map(|draw| draw.blitter).collect(); + assert_eq!( + blitters, + vec![ + Blitter::ShadowZ, + // Items show through structures in the obscured pass + Blitter::OutlineZPixelateObscured, + Blitter::TransZNB, + Blitter::OutlineZ, + ] + ); + let item_z = z_value(STRUCT_Z_LEVEL + 10 + (60 + 25 - 860) * Z_SUBLAYERS); + assert_eq!(draws[1].z, item_z); + assert_eq!(draws[1].position, IVec2::new(0, 60 - 10)); + + // Shadows are off from dusk on, and items can be hidden + let layers = LayerVisibility { + items: false, + ..default() + }; + let draws = build_draw_list( + &world, + &tileset, + tile_sprites, + DrawListOptions { + layers, + floor: Level::Roof, + shadows: false, + }, + ); + assert_eq!(draws.len(), 1); + assert_eq!(draws[0].blitter, Blitter::TransZNB); + + // Items stay without the objects, the tree and its shadow go with them + let layers = LayerVisibility { + objects: false, + ..Default::default() + }; + let options = DrawListOptions { + layers, + ..options(true) + }; + let blitters: Vec<_> = build_draw_list(&world, &tileset, tile_sprites, options) + .iter() + .map(|draw| draw.blitter) + .collect(); + assert_eq!( + blitters, + vec![Blitter::OutlineZPixelateObscured, Blitter::OutlineZ,] + ); + } + + #[test] + fn buildings_can_be_hidden() { + use crate::file_formats::ja2_tile_types::{FIRSTFLOOR, FIRSTWALL}; + let tileset = crate::tileset::tests::building_tileset(); + let mut world = MapWorld::new(false); + let g = grid_no(3, 3); + let wall = test_node(&tileset, FIRSTWALL, 1).tile; + let wall = world.add_node_to_world(g, &wall, Level::Ground).unwrap(); + world.tiles[g].structs.push(wall); + // Also the walls without structure data and the wall shadows + world.tiles[g] + .structs + .push(test_node(&tileset, FIRSTWALL, 30)); + world.tiles[g] + .shadows + .push(test_node(&tileset, FIRSTWALL, 30)); + // An outside structure in the structs and the floors stay + let kept = &mut world.tiles[grid_no(5, 5)]; + kept.structs.push(test_node(&tileset, FIRSTOSTRUCT, 1)); + kept.land = vec![test_node(&tileset, FIRSTFLOOR, 1)]; + kept.land_start = Some(0); + let draws = |buildings| { + let layers = LayerVisibility { + buildings, + ..Default::default() + }; + let options = DrawListOptions { + layers, + floor: Level::Roof, + shadows: true, + }; + build_draw_list(&world, &tileset, tile_sprites, options) + .iter() + .map(|draw| draw.grid_no) + .collect::>() + }; + assert_eq!(draws(true).len(), 5); + assert_eq!(draws(false), vec![grid_no(5, 5), grid_no(5, 5)]); + } + + #[test] + fn terrain_can_be_hidden() { + use crate::file_formats::ja2_tile_types::{DEBRISROCKS, FIRSTROAD}; + let tileset = test_tileset(); + let node = |tile_type, sub_index| test_node(&tileset, tile_type, sub_index); + let mut world = MapWorld::new(false); + let tile = &mut world.tiles[grid_no(2, 2)]; + tile.land = vec![node(FIRSTTEXTURE, 1)]; + tile.land_start = Some(0); + tile.objects.push(node(FIRSTROAD, 1)); + world.tiles[grid_no(4, 4)] + .objects + .push(node(DEBRISROCKS, 1)); + let tiles = |terrain| { + let layers = LayerVisibility { + terrain, + ..Default::default() + }; + let options = DrawListOptions { + layers, + ..options(true) + }; + build_draw_list(&world, &tileset, tile_sprites, options) + .iter() + .map(|draw| draw.grid_no) + .collect::>() + }; + assert_eq!( + tiles(true), + vec![grid_no(2, 2), grid_no(2, 2), grid_no(4, 4)] + ); + // The debris in the objects list stays + assert_eq!(tiles(false), vec![grid_no(4, 4)]); + } + + #[test] + fn the_roof_floor_shows_the_roofs() { + let tileset = test_tileset(); + let node = |tile_type, sub_index| test_node(&tileset, tile_type, sub_index); + let mut world = MapWorld::new(false); + world.tiles[grid_no(2, 2)] + .structs + .push(node(FIRSTOSTRUCT, 1)); + world.tiles[grid_no(2, 2)].roofs.push(node(FIRSTROOF, 1)); + let mut item = node(GUNS, 1); + item.flags |= NodeFlags::ITEM; + world.tiles[grid_no(3, 3)].on_roofs.push(item); + let tiles_with = |floor, layers| { + let options = DrawListOptions { + floor, + layers, + ..options(true) + }; + build_draw_list(&world, &tileset, tile_sprites, options) + .iter() + .map(|draw| draw.grid_no) + .collect::>() + }; + let tiles = |floor| tiles_with(floor, LayerVisibility::default()); + assert_eq!(tiles(Level::Ground), vec![grid_no(2, 2)]); + let roof = tiles(Level::Roof); + assert_eq!(roof.iter().filter(|&&g| g == grid_no(2, 2)).count(), 2); + assert!(roof.contains(&grid_no(3, 3))); + + // The roofs are buildings + let no_buildings = LayerVisibility { + buildings: false, + ..Default::default() + }; + let roof = tiles_with(Level::Roof, no_buildings); + assert_eq!(roof.iter().filter(|&&g| g == grid_no(2, 2)).count(), 1); + assert!(roof.contains(&grid_no(3, 3))); + let no_objects = LayerVisibility { + objects: false, + ..Default::default() + }; + let roof = tiles_with(Level::Roof, no_objects); + assert_eq!(roof.iter().filter(|&&g| g == grid_no(2, 2)).count(), 1); + // Items don't depend on the objects or buildings + assert!(roof.contains(&grid_no(3, 3))); + let no_items = LayerVisibility { + items: false, + ..no_objects + }; + assert!(!tiles_with(Level::Roof, no_items).contains(&grid_no(3, 3))); + // But on the ground they are hidden with the roofs + assert!(!tiles(Level::Ground).contains(&grid_no(3, 3))); + } + + #[test] + fn soldiers_only_depend_on_their_layer() { + use crate::render::{ + soldiers::Soldier, + world::{MercNode, NodeLight}, + }; + use std::sync::Arc; + let tileset = test_tileset(); + let registries = crate::soldier::Registries::build(); + let surface = registries.surfaces.iter().next().unwrap().clone(); + let mut world = MapWorld::new(false); + for (grid_no, level) in [(grid_no(3, 3), Level::Ground), (grid_no(5, 5), Level::Roof)] { + world.tiles[grid_no].mercs.push(MercNode { + soldier: Arc::new(Soldier { + id: 0, + grid_no, + surface: surface.clone(), + frame: 0, + level, + hostile: false, + vehicle: false, + }), + light: NodeLight::default(), + }); + } + let only_soldiers = LayerVisibility { + terrain: false, + objects: false, + buildings: false, + items: false, + soldiers: true, + soldier_teams: VisibleTeams::default(), + room_numbers: false, + }; + let tiles = |layers, floor| { + let options = DrawListOptions { + layers, + floor, + shadows: true, + }; + build_draw_list(&world, &tileset, tile_sprites, options) + .iter() + .map(|draw| draw.grid_no) + .collect::>() + }; + // Both soldiers are drawn on both floors, whatever else is hidden + for floor in [Level::Ground, Level::Roof] { + assert_eq!( + tiles(only_soldiers, floor), + vec![grid_no(3, 3), grid_no(5, 5)] + ); + let hidden = LayerVisibility { + soldiers: false, + ..Default::default() + }; + assert!(tiles(hidden, floor).is_empty()); + } + } + + #[test] + fn items_on_roofs_have_a_yellow_outline() { + let tileset = test_tileset(); + let item = || { + let mut item = test_node(&tileset, GUNS, 1); + item.flags |= NodeFlags::ITEM; + item + }; + let mut world = MapWorld::new(false); + world.tiles[grid_no(3, 3)].structs.push(item()); + world.tiles[grid_no(5, 5)].on_roofs.push(item()); + + let draws = build_draw_list(&world, &tileset, tile_sprites, options(true)); + let outlines: Vec<_> = draws + .iter() + .map(|draw| (draw.grid_no, draw.blitter, draw.outline)) + .collect(); + assert_eq!( + outlines, + vec![ + ( + grid_no(3, 3), + Blitter::OutlineZPixelateObscured, + ITEM_OUTLINE_COLOR + ), + ( + grid_no(5, 5), + Blitter::OutlineZPixelateObscured, + ON_ROOF_ITEM_OUTLINE_COLOR + ), + (grid_no(3, 3), Blitter::OutlineZ, ITEM_OUTLINE_COLOR), + (grid_no(5, 5), Blitter::OutlineZ, ON_ROOF_ITEM_OUTLINE_COLOR), + ] + ); + } + + /// A world with land everywhere near the origin and structs, shadows and roofs in between + fn busy_world(tileset: &Tileset) -> MapWorld { + let node = |tile_type, sub_index| test_node(tileset, tile_type, sub_index); + let mut world = MapWorld::new(false); + for y in 0..12u8 { + for x in 0..12u8 { + let tile = &mut world.tiles[grid_no(x, y)]; + tile.land = vec![node(FIRSTTEXTURE, 1 + u16::from(x % 3))]; + tile.land_start = Some(0); + if (x + y) % 3 == 0 { + tile.structs.push(node(FIRSTOSTRUCT, 1 + u16::from(y % 4))); + tile.shadows.push(node(FIRSTSHADOW, 1)); + } + if x % 4 == 1 { + tile.roofs.push(node(FIRSTROOF, 1)); + tile.height = 10; + } + } + } + world + } + + #[test] + fn draws_of_a_rect_keep_the_order_of_the_draw_list() { + let tileset = test_tileset(); + let world = busy_world(&tileset); + let cache = DrawCache::build(&DrawContext { + world: &world, + tileset: &tileset, + sprites: tile_sprites, + options: options(true), + }); + let all = cache.all(); + assert_eq!( + all, + build_draw_list(&world, &tileset, tile_sprites, options(true)) + ); + for rect in [ + IRect::new(-50, 0, 30, 40), + IRect::new(10, 35, 11, 36), + IRect::new(-500, -500, 500, 500), + IRect::new(1000, 1000, 1010, 1010), + ] { + let expected: Vec<_> = all + .iter() + .filter(|draw| !draw.bounds.intersect(rect).is_empty()) + .cloned() + .collect(); + assert_eq!(cache.ordered(rect), expected); + } + assert_eq!( + cache.bounds(), + all.iter() + .map(|d| d.bounds) + .reduce(|a, b| a.union(b)) + .unwrap() + ); + } + + #[test] + fn replacing_tile_draws_reports_the_changed_area() { + let tileset = test_tileset(); + let mut world = busy_world(&tileset); + let mut cache = DrawCache::build(&DrawContext { + world: &world, + tileset: &tileset, + sprites: tile_sprites, + options: options(true), + }); + let changed = grid_no(3, 3); + let tile_draws = |world: &MapWorld| { + DrawContext { + world, + tileset: &tileset, + sprites: tile_sprites, + options: options(true), + } + .tile_draws(changed) + }; + let same = tile_draws(&world); + assert_eq!(cache.replace(changed, same), None); + + // Raising the tile moves its draws up + world.tiles[changed].height = 30; + let raised = tile_draws(&world); + let old = cache.tile(changed).bounds.unwrap(); + let new = raised.bounds.unwrap(); + assert_eq!(cache.replace(changed, raised), Some(old.union(new))); + assert_eq!( + cache.all(), + build_draw_list(&world, &tileset, tile_sprites, options(true)) + ); + + // Removing everything leaves the old area + world.tiles[changed] = WorldTile::default(); + let empty = tile_draws(&world); + assert_eq!(empty.bounds, None); + assert_eq!(cache.replace(changed, empty), Some(new)); + } + + #[test] + fn top_y() { + assert_eq!(map_top_y(false), 860); + assert_eq!(map_top_y(true), 1330); + } + + #[test] + fn world_y() { + assert_eq!(map_world_y(0, 0, 860), 25 - 860); + assert_eq!(map_world_y(80, 80, 860), 1600 + 25 - 860); + assert_eq!(map_world_y(159, 159, 1330), 3180 + 25 - 1330); + } + + #[test] + fn z_values_wrap_like_int16() { + assert_eq!(z_value(5), 5); + assert_eq!(z_value(-8), 65528); + assert_eq!(z_value(TOPMOST_Z_LEVEL), 32767); + } + + #[test] + fn z_strip_columns() { + // Increasing by two strips, then stable, then decreasing + let info = ZStrip { + initial_z_change: -2, + first_z_strip_width: 7, + z_changes: vec![1, 1, 0, -1], + }; + let packed = PackedZStrip::from(&info); + assert_eq!(packed.changes, 0b11_00_01_01); + let z = 1000; + assert_eq!(packed.column_z(z, 0), 1000 - 160); + assert_eq!(packed.column_z(z, 6), 1000 - 160); + assert_eq!(packed.column_z(z, 7), 1000 - 80); + assert_eq!(packed.column_z(z, 26), 1000 - 80); + assert_eq!(packed.column_z(z, 27), 1000); + assert_eq!(packed.column_z(z, 47), 1000); + assert_eq!(packed.column_z(z, 66), 1000); + assert_eq!(packed.column_z(z, 67), 1000 - 80); + // Past the last change nothing changes any more + assert_eq!(packed.column_z(z, 500), 1000 - 80); + } + + #[test] + fn soldier_teams_are_read_with_the_layers() { + let options: crate::render::RenderOptions = + serde_json::from_str(r#"{"layers":{"soldierTeams":{"enemy":false}}}"#).unwrap(); + let teams = options.layers.soldier_teams; + assert!(!teams.enemy && teams.creature && teams.rebel && teams.civilian); + assert!(!teams.shows(TeamId::ENEMY)); + assert!(teams.shows(TeamId::CIV)); + // The player's soldiers are always shown + assert!(teams.shows(TeamId::OUR)); + // The shown teams don't change the draws + assert_eq!(options.layers.drawn(), LayerVisibility::default().drawn()); + } +} diff --git a/src-map-editor/src/render/edge_points.rs b/src-map-editor/src/render/edge_points.rs new file mode 100644 index 0000000..4b91270 --- /dev/null +++ b/src-map-editor/src/render/edge_points.rs @@ -0,0 +1,699 @@ +//! Generating the edge points of a map, like the editor of the game does before it saves +//! +//! Port of `GenerateMapEdgepoints` and the functions it calls (`Map_Edgepoints.cc`). The quirks +//! are kept, so the result is what the game would save: +//! +//! - The tiles of each side are collected in two diagonal rows, the outer one first. Both rows +//! only have tiles that are reachable from the first entry point (north, east, south, west, +//! center) and, on the surface, not in a room. +//! - The secondary set has the tiles reachable from the isolated entry point, if the first test +//! didn't reach it. +//! - Tiles that can't reach the edge of the map within 5 tiles are removed (`ValidateEdgepoints`), +//! which moves the middle index to the next tile that is kept. +//! - With an isolated entry point, the primary tiles that are not connected to the entry point of +//! their side within 5 tiles (250 in I14) are moved to the secondary set +//! (`ClassifyEdgepoints`). The game counts the moved tiles of the outer row into the middle +//! index of the secondary list, although they are added at its end. + +use crate::{ + map::{ + EdgePointList, EdgePointSet, EdgePoints, EditorSmoothing, GridArray, GridNo, SectorId, + TacticalMap, + }, + render::{ + movement_costs::{grid_no_on_edge_of_map, MovementCosts}, + reachable::{pyth_spaces_away, PathFinder, PathRequest, TRAIL_TREE}, + world::MapWorld, + }, +}; + +/// The corners of the edge rows (`gsTLGridNo`, `gsTRGridNo`, `gsBLGridNo`, `gsBRGridNo`) +const TOP_LEFT: i32 = 13286; +const TOP_RIGHT: i32 = 1043; +const BOTTOM_LEFT: i32 = 24878; +const BOTTOM_RIGHT: i32 = 12635; + +/// How far edge points may be from the edge of the map and from each other +/// (`EDGE_OF_MAP_SEARCH`) +const EDGE_OF_MAP_SEARCH: u8 = 5; +/// How far edge points of I14 may be from each other, the special case of `EdgepointsClose` +const I14_SEARCH: u8 = 250; + +/// A diagonal row of tiles: from `start`, two steps at a time while the grid number hasn't +/// passed `end` +#[derive(Clone, Copy)] +struct Row { + start: i32, + end: i32, + steps: [i32; 2], +} + +impl Row { + fn tiles(self) -> Vec { + let mut tiles = vec![self.start]; + let mut grid_no = self.start; + let descending = self.steps[0] + self.steps[1] < 0; + while if descending { + grid_no > self.end + } else { + grid_no < self.end + } { + for step in self.steps { + grid_no += step; + tiles.push(grid_no); + } + } + tiles + } +} + +/// The outer and the inner row of each side, north, east, south, west +fn side_rows() -> [[Row; 2]; 4] { + let row = |start, end, steps| Row { start, end, steps }; + let pair = |start, end, offset, steps| { + [ + row(start, end, steps), + row(start + offset, end + offset, steps), + ] + }; + [ + pair(TOP_LEFT, TOP_RIGHT, 161, [1, -160]), + pair(TOP_RIGHT, BOTTOM_RIGHT, 159, [160, 1]), + pair(BOTTOM_LEFT, BOTTOM_RIGHT, -161, [1, -160]), + pair(TOP_LEFT, BOTTOM_LEFT, -159, [1, 160]), + ] +} + +/// The edge points of the sides that have an entry point, from the tiles of a reachable test +fn collect_edge_points( + map: &TacticalMap, + reachable: &GridArray, + entries: [Option; 4], +) -> EdgePointSet { + let basement = map.info.editor_smoothing == EditorSmoothing::Basement; + let usable = + |grid_no: GridNo| reachable[grid_no] && (map.tiles[grid_no].room.is_none() || basement); + let mut set = EdgePointSet::default(); + for ((list, rows), entry) in set.sides_mut().into_iter().zip(side_rows()).zip(entries) { + if entry.is_none() { + continue; + } + for (index, row) in rows.into_iter().enumerate() { + if index == 1 { + list.middle_index = list.grid_nos.len() as u16; + } + list.grid_nos.extend( + row.tiles() + .into_iter() + .filter_map(GridNo::from_i32) + .filter(|&grid_no| usable(grid_no)), + ); + } + } + set +} + +/// Generates the edge points of a map (`GenerateMapEdgepoints`) +/// +/// `costs` must be up to date with the world and the exit grids of the map. `sector` is the +/// sector of the map, which only matters for I14. The secondary set is always generated, maps +/// before version 17 don't save it. +pub fn generate_edge_points( + world: &MapWorld, + costs: &MovementCosts, + map: &TacticalMap, + sector: Option, +) -> EdgePoints { + let info = &map.info; + let entries = [ + info.north_entry, + info.east_entry, + info.south_entry, + info.west_entry, + ]; + let mut edge_points = EdgePoints { + primary: EdgePointSet::default(), + secondary: Some(EdgePointSet::default()), + }; + let Some(seed) = entries.into_iter().flatten().chain(info.center).next() else { + return edge_points; + }; + + let mut finder = PathFinder::new(world, costs, map); + let reachable = finder.global_reachable(seed); + let mut primary = collect_edge_points(map, &reachable, entries); + // Only if the isolated entry point is really isolated + let mut secondary = match info.isolated.filter(|&isolated| !reachable[isolated]) { + Some(isolated) => { + let reachable = finder.global_reachable(isolated); + collect_edge_points(map, &reachable, entries) + } + None => EdgePointSet::default(), + }; + + // Eliminates the edge points that have no path to the edge of the world + for list in primary.sides_mut().into_iter().chain(secondary.sides_mut()) { + validate_map_edge(&mut finder, list); + } + // The design flaw of the original: obstacles between normal edge points also move them to + // the secondary list on maps with an isolated entry point + if info.isolated.is_some() { + let range = if sector == Some(SectorId { x: 14, y: 9, z: 0 }) { + I14_SEARCH + } else { + EDGE_OF_MAP_SEARCH + }; + let mut close = |from: GridNo, to: GridNo| { + finder.find_path( + &PathRequest { + origin: from, + destination: Some(to), + distance_limit: range, + trail_tree: TRAIL_TREE, + }, + |_| {}, + ) + }; + for ((first, second), entry) in primary + .sides_mut() + .into_iter() + .zip(secondary.sides_mut()) + .zip(entries) + { + let Some(entry) = entry else { + continue; + }; + if let Some(nearest) = nearest_edge_point(first, entry) { + classify_edge_points(&mut close, nearest, first, second); + } + } + } + edge_points.primary = primary; + edge_points.secondary = Some(secondary); + edge_points +} + +/// Whether a soldier at an edge point can reach the edge of the map within 5 tiles +/// (`VerifyEdgepoint`) +fn verify_edge_point(finder: &mut PathFinder, grid_no: GridNo) -> bool { + let mut on_edge = false; + let costs = finder.costs(); + let mut reached = vec![]; + finder.find_path( + &PathRequest { + origin: grid_no, + destination: None, + distance_limit: EDGE_OF_MAP_SEARCH, + trail_tree: TRAIL_TREE, + }, + |grid_no| reached.push(grid_no), + ); + for grid_no in reached { + on_edge |= grid_no_on_edge_of_map(costs, grid_no); + } + on_edge +} + +/// Removes the edge points that can't reach the edge of the map (`ValidateMapEdge`) +/// +/// The middle index moves to the next edge point that is kept. +fn validate_map_edge(finder: &mut PathFinder, list: &mut EdgePointList) { + let mut middle = usize::from(list.middle_index); + let mut kept = 0; + for index in 0..list.grid_nos.len() { + let grid_no = list.grid_nos[index]; + if verify_edge_point(finder, grid_no) { + list.grid_nos[kept] = grid_no; + if middle == index { + middle = kept; + } + kept += 1; + } else if middle == index { + middle += 1; + } + } + list.middle_index = middle as u16; + list.grid_nos.truncate(kept); +} + +/// The edge point of a list closest to a tile, the first one of equal distance +/// (`FindNearestEdgepointOnSpecifiedEdge`) +fn nearest_edge_point(list: &EdgePointList, grid_no: GridNo) -> Option { + let mut nearest = None; + let mut nearest_distance = i32::from(i16::MAX); + for &edge_point in &list.grid_nos { + let distance = pyth_spaces_away(grid_no, edge_point); + if distance < nearest_distance { + nearest_distance = distance; + nearest = Some(edge_point); + } + } + nearest +} + +/// Moves the edge points of a side that are not connected with `grid_no` to the secondary list +/// (`InternallyClassifyEdgepoints`) +/// +/// From the edge point and the closest one of the other row, the "benchmarks", the connected +/// edge points are followed in both directions of each row. Everything after the first gap is +/// moved. Where the game would read past the end of the list (the middle index can be past it), +/// the rows end at the end of the list instead. +fn classify_edge_points( + close: &mut impl FnMut(GridNo, GridNo) -> bool, + grid_no: GridNo, + primary: &mut EdgePointList, + secondary: &mut EdgePointList, +) { + let mut points: Vec> = primary.grid_nos.iter().copied().map(Some).collect(); + let len = points.len(); + let middle = usize::from(primary.middle_index); + let outer_end = middle.min(len); + let mut connected = |points: &[Option], a: usize, b: usize| match (points[a], points[b]) + { + (Some(a), Some(b)) => close(a, b), + _ => false, + }; + + let (mut first, mut second) = (None, None); + if let Some(index) = points.iter().position(|&point| point == Some(grid_no)) { + if index < middle { + // in the first half of the array, find the second benchmark + first = Some(index); + second = (middle..len).find(|&other| connected(&points, index, other)); + } else { + // in the second half of the array, find the first benchmark + second = Some(index); + first = (0..outer_end).find(|&other| connected(&points, index, other)); + } + } + + let mut reject = |points: &mut [Option], index: usize, count_middle: bool| { + if let Some(point) = points[index].take() { + secondary.grid_nos.push(point); + } + if count_middle { + secondary.middle_index = secondary.middle_index.wrapping_add(1); + } + }; + if let Some(benchmark) = first { + let mut index = benchmark; + while index > 0 { + if !connected(&points, index, index - 1) { + // All edge points from index 0 to index - 1 are rejected + while index > 0 { + index -= 1; + reject(&mut points, index, true); + } + break; + } + index -= 1; + } + let mut index = benchmark; + while index + 1 < outer_end { + if !connected(&points, index, index + 1) { + // All edge points from index + 1 to the middle index are rejected + while index + 1 < outer_end { + index += 1; + reject(&mut points, index, true); + } + break; + } + index += 1; + } + } + if let Some(benchmark) = second { + let mut index = benchmark; + while index > middle { + if !connected(&points, index, index - 1) { + // All edge points from the middle index to index - 1 are rejected + while index > middle { + index -= 1; + reject(&mut points, index, false); + } + break; + } + index -= 1; + } + let mut index = benchmark; + while index + 1 < len { + if !connected(&points, index, index + 1) { + // All edge points after index are rejected + while index + 1 < len { + index += 1; + reject(&mut points, index, false); + } + break; + } + index += 1; + } + } + + // `CompactEdgepointArray`, which compares with the middle index while it moves it + let mut middle = primary.middle_index; + let mut kept = Vec::with_capacity(len); + for (index, point) in points.into_iter().enumerate() { + match point { + Some(point) => kept.push(point), + None if index < usize::from(middle) => middle -= 1, + None => {} + } + } + primary.grid_nos = kept; + primary.middle_index = middle; +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{ + map::RoomNo, + render::reachable::tests::{grid, Open}, + }; + + fn generate(open: &mut Open, sector: Option) -> EdgePoints { + let costs = open.costs(); + generate_edge_points(&open.world, &costs, &open.map, sector) + } + + fn raw(list: &EdgePointList) -> Vec { + list.grid_nos.iter().map(|g| g.index()).collect() + } + + #[test] + fn rows_follow_the_edges_of_the_map() { + let [north, east, south, west] = side_rows(); + let outer = north[0].tiles(); + assert_eq!(outer.first(), Some(&TOP_LEFT)); + assert_eq!(outer.last(), Some(&TOP_RIGHT)); + assert_eq!(outer.len(), 1 + 2 * 77); + assert_eq!(north[1].tiles()[0], TOP_LEFT + 161); + assert_eq!(east[0].tiles().last(), Some(&BOTTOM_RIGHT)); + assert_eq!(south[0].tiles().last(), Some(&BOTTOM_RIGHT)); + assert_eq!(west[0].tiles().last(), Some(&BOTTOM_LEFT)); + assert_eq!(west[1].tiles()[0], TOP_LEFT - 159); + } + + #[test] + fn open_maps_have_full_edges() { + let mut open = Open::new(); + open.map.info.north_entry = Some(grid(50, 50)); + open.map.info.east_entry = Some(grid(110, 50)); + open.map.info.south_entry = Some(grid(110, 110)); + open.map.info.west_entry = Some(grid(50, 110)); + let edge_points = generate(&mut open, None); + let primary = &edge_points.primary; + for list in primary.sides() { + assert!(list.grid_nos.len() > 100, "{}", list.grid_nos.len()); + assert!(usize::from(list.middle_index) < list.grid_nos.len()); + } + // The outer row starts at the corner + assert_eq!(raw(&primary.north)[0], TOP_LEFT as usize); + assert_eq!(edge_points.secondary, Some(EdgePointSet::default())); + + // Without entry points there are none + let mut open = Open::new(); + assert_eq!(generate(&mut open, None).primary, EdgePointSet::default()); + } + + #[test] + fn sides_without_an_entry_point_are_empty() { + let mut open = Open::new(); + open.map.info.center = Some(grid(80, 80)); + open.map.info.north_entry = Some(grid(50, 50)); + let primary = generate(&mut open, None).primary; + assert!(!primary.north.grid_nos.is_empty()); + assert!(primary.east.grid_nos.is_empty() && primary.east.middle_index == 0); + assert!(primary.south.grid_nos.is_empty() && primary.west.grid_nos.is_empty()); + } + + #[test] + fn walls_across_the_map_make_secondary_edge_points() { + let mut open = Open::new(); + // A wall across the map between columns 80 and 81 + open.wall_column(80, 0, 159); + open.map.info.north_entry = Some(grid(50, 50)); + open.map.info.south_entry = Some(grid(60, 110)); + open.map.info.isolated = Some(grid(110, 80)); + let edge_points = generate(&mut open, None); + let primary = &edge_points.primary; + let secondary = edge_points.secondary.unwrap(); + // The primary edge points are left of the wall, the secondary ones right of it + assert!(!primary.north.grid_nos.is_empty() && !secondary.north.grid_nos.is_empty()); + assert!(primary.north.grid_nos.iter().all(|g| g.position().x <= 80)); + assert!(secondary.north.grid_nos.iter().all(|g| g.position().x > 80)); + assert!(secondary.south.grid_nos.iter().all(|g| g.position().x > 80)); + // East has no entry point + assert!(secondary.east.grid_nos.is_empty()); + + // An isolated entry point that is reachable adds nothing + open.map.info.isolated = Some(grid(51, 51)); + let secondary = generate(&mut open, None).secondary.unwrap(); + assert_eq!(secondary.north.grid_nos.len(), 0); + } + + #[test] + fn classifying_moves_points_behind_gaps_to_the_secondary_list() { + let mut open = Open::new(); + // A wall that splits the north side, the east part is still reachable around it + open.wall_column(80, 0, 60); + open.map.info.north_entry = Some(grid(50, 50)); + let unclassified = generate(&mut open, None).primary; + assert!(unclassified + .north + .grid_nos + .iter() + .any(|g| g.position().x > 80)); + + open.map.info.isolated = Some(grid(51, 51)); + let edge_points = generate(&mut open, None); + let north = &edge_points.primary.north; + let secondary = &edge_points.secondary.unwrap().north; + assert!(north.grid_nos.iter().all(|g| g.position().x <= 80)); + assert!(secondary.grid_nos.iter().all(|g| g.position().x > 80)); + assert_eq!( + north.grid_nos.len() + secondary.grid_nos.len(), + unclassified.north.grid_nos.len() + ); + // The rejects of the outer row count into the middle index of the secondary list + assert!(secondary.middle_index > 0); + } + + #[test] + fn rooms_are_excluded_on_the_surface_only() { + let mut open = Open::new(); + open.map.info.north_entry = Some(grid(50, 50)); + let corner = GridNo::from_i32(TOP_LEFT).unwrap(); + open.map.tiles[corner].room = RoomNo::new(1); + let north = generate(&mut open, None).primary.north; + assert!(!north.grid_nos.contains(&corner)); + + open.map.info.editor_smoothing = EditorSmoothing::Basement; + let north = generate(&mut open, None).primary.north; + assert_eq!(north.grid_nos[0], corner); + } + + #[test] + fn validation_moves_the_middle_index() { + let mut open = Open::new(); + let costs = open.costs(); + let mut finder = PathFinder::new(&open.world, &costs, &open.map); + let edge = GridNo::from_i32(TOP_LEFT).unwrap(); + let inner = grid(80, 80); + // The middle edge point is too far from the edge, the next one takes its place + let mut list = EdgePointList { + middle_index: 1, + grid_nos: vec![edge, inner, edge], + }; + validate_map_edge(&mut finder, &mut list); + assert_eq!(list.grid_nos, [edge, edge]); + assert_eq!(list.middle_index, 1); + // When nothing follows, it points past the end + let mut list = EdgePointList { + middle_index: 1, + grid_nos: vec![edge, inner, inner], + }; + validate_map_edge(&mut finder, &mut list); + assert_eq!(list.grid_nos, [edge]); + assert_eq!(list.middle_index, 3); + } + + #[test] + fn i14_connects_edge_points_further_away() { + let mut open = Open::new(); + // A short wall between two parts of the north edge, too long to walk around within 5 + // tiles but not within 250 + open.wall_column(80, 0, 30); + open.map.info.north_entry = Some(grid(50, 50)); + open.map.info.isolated = Some(grid(51, 51)); + let other = generate(&mut open, SectorId::from_map_path("maps/h14.dat")); + let i14 = generate(&mut open, SectorId::from_map_path("maps/i14.dat")); + assert!(!other.secondary.unwrap().north.grid_nos.is_empty()); + assert!(i14.secondary.unwrap().north.grid_nos.is_empty()); + } + + /// The file at a path below `dir`, whatever the case of its components + pub(crate) fn find_file(dir: &std::path::Path, path: &str) -> Option { + let mut found = dir.to_path_buf(); + for component in path.split('/') { + let entry = std::fs::read_dir(&found).ok()?.flatten().find(|entry| { + entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(component) + })?; + found = entry.path(); + } + Some(found) + } + + /// The tileset of the game data in a directory + pub(crate) fn data_tileset( + dir: &std::path::Path, + tilesets: &crate::file_formats::ja2_tileset::Ja2Tilesets, + tileset_id: crate::tileset::TilesetId, + ) -> crate::tileset::Tileset { + use crate::file_formats::{ + ja2_stci::SubImageInfo, + ja2_structure::Ja2StructureFile, + ja2_tile_database::{Ja2TileDatabase, TileSurfaceInfo}, + ja2_tile_types::NUMBER_OF_TILE_TYPES, + stci::Stci, + }; + let tileset = tilesets.get(tileset_id.index()).unwrap(); + let surfaces = (0..NUMBER_OF_TILE_TYPES) + .map(|tile_type| { + let resolved = tilesets.resolve_tile_surface(tileset_id, tile_type)?; + let file = find_file( + dir, + &format!("tilesets/{}/{}", resolved.tileset_id, resolved.filename), + )?; + let bytes = std::fs::read(&file).ok()?; + let stci = Stci::from_input(&mut std::io::Cursor::new(bytes)) + .map_err(|e| eprintln!("{}: {}", file.display(), e)) + .ok()?; + let jsd = resolved.filename.rsplit_once('.').unwrap().0.to_owned() + ".jsd"; + let structure = + find_file(dir, &format!("tilesets/{}/{}", resolved.tileset_id, jsd)) + .and_then(|jsd| Ja2StructureFile::parse(&std::fs::read(jsd).ok()?).ok()); + Some(TileSurfaceInfo { + tileset_id: resolved.tileset_id, + filename: resolved.filename, + sub_images: SubImageInfo::from_stci(&stci), + structure, + terrain: tileset.terrain_id(tile_type), + }) + }) + .collect(); + crate::tileset::Tileset::from_database(Ja2TileDatabase::build(surfaces).unwrap()) + } + + /// Generates the edge points of every map in the game data in `JA2_DATA_DIR` (or the maps + /// whose name starts with `JA2_EDGE_POINT_MAPS`) and compares them with the saved ones + /// + /// The directory has `maps/*.dat`, `tilesets//*.sti|jsd` and `ja2set.dat` in + /// `binarydata/` or `tilesets/`, in any case. Not all maps of the game match: they were saved + /// by the original editor, some were changed afterwards (e.g. trees on saved edge points) and + /// I14 was saved without its special case. With the vanilla maps, 222 of 261 match. + #[test] + fn generated_edge_points_match_the_maps_of_the_game() { + use std::collections::HashMap; + + use crate::{ + file_formats::{ja2_map::Ja2Map, ja2_tileset::Ja2Tilesets}, + map::convert_tests::Fixture, + tileset::TilesetId, + }; + + let Ok(dir) = std::env::var("JA2_DATA_DIR") else { + eprintln!("JA2_DATA_DIR not set, skipping"); + return; + }; + let prefix = std::env::var("JA2_EDGE_POINT_MAPS").unwrap_or_default(); + let dir = std::path::Path::new(&dir); + let ja2set = find_file(dir, "binarydata/ja2set.dat") + .or_else(|| find_file(dir, "tilesets/ja2set.dat")) + .expect("ja2set.dat should exist"); + let tilesets = Ja2Tilesets::parse(&std::fs::read(ja2set).unwrap()).unwrap(); + let mut fixtures: HashMap = HashMap::new(); + let mut paths: Vec<_> = std::fs::read_dir(find_file(dir, "maps").unwrap()) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + let name = path.file_name().unwrap().to_string_lossy().to_lowercase(); + name.ends_with(".dat") && name.starts_with(&prefix.to_lowercase()) + }) + .collect(); + paths.sort(); + let (mut matched, mut failures) = (0, vec![]); + for path in paths { + let Ok(file) = Ja2Map::parse(&std::fs::read(&path).unwrap()) else { + continue; + }; + let Some(saved) = file + .edge_points + .as_ref() + .filter(|_| file.info.map_version >= 22) + else { + continue; + }; + let tileset_id = TilesetId::new(file.tileset_id as u8); + let fixture = fixtures + .entry(tileset_id) + .or_insert_with(|| Fixture::with_tileset(data_tileset(dir, &tilesets, tileset_id))); + let map = fixture.convert(&file); + let mut world = MapWorld::build(&map); + let costs = MovementCosts::compile(&mut world, &map); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let generated = + generate_edge_points(&world, &costs, &map, SectorId::from_map_path(&name)); + let lists = |set: &EdgePointSet| { + set.sides() + .map(|list| (list.middle_index, raw(list))) + .to_vec() + }; + let saved_lists = |set: &crate::file_formats::ja2_map::Ja2EdgePointSet| { + [&set.north, &set.east, &set.south, &set.west] + .map(|list| { + let grid_nos = list.grid_nos.iter().map(|&g| g as usize).collect(); + (list.middle_index, grid_nos) + }) + .to_vec() + }; + let primary_ok = lists(&generated.primary) == saved_lists(&saved.primary); + let secondary_ok = match (&generated.secondary, &saved.secondary) { + (Some(generated), Some(saved)) => lists(generated) == saved_lists(saved), + _ => true, + }; + if primary_ok && secondary_ok { + matched += 1; + } else { + let counts = |lists: Vec<(u16, Vec)>| { + lists + .iter() + .map(|(middle, list)| format!("{}/{}", middle, list.len())) + .collect::>() + .join(" ") + }; + failures.push(format!( + "{name}: primary {} (saved {}), secondary {} (saved {})", + counts(lists(&generated.primary)), + counts(saved_lists(&saved.primary)), + counts(lists(&generated.secondary.clone().unwrap_or_default())), + counts(saved_lists(&saved.secondary.clone().unwrap_or_default())), + )); + } + } + eprintln!( + "{} maps match, {} don't:\n{}", + matched, + failures.len(), + failures.join("\n") + ); + assert!( + matched >= 4 * failures.len(), + "too many maps don't match:\n{}", + failures.join("\n") + ); + } +} diff --git a/src-map-editor/src/render/items.rs b/src-map-editor/src/render/items.rs new file mode 100644 index 0000000..31de59f --- /dev/null +++ b/src-map-editor/src/render/items.rs @@ -0,0 +1,173 @@ +//! World items of the map as level nodes +//! +//! Port of `LoadWorldItemsFromMap` (`World_Items.cc`), `InternalAddItemToPool` and +//! `AddItemGraphicToWorld` (`Handle_Items.cc`) as the editor runs them: every item is shown, and +//! none of the random or game-only replacements happen. Items that sink in water are kept, because +//! the terrain is not known yet when the game loads the items. + +use std::sync::Arc; + +use crate::{ + item::ItemId, + map::{GridNo, ItemHeight, Level, MapItem, TacticalMap}, + render::world::{MapWorld, NodeFlags}, + tileset::{StructureFlags, Tile, PROFILE_Z_SIZE}, +}; + +/// Height of a structure level in pixels (`CONVERT_INDEX_TO_PIXELS`) +fn index_to_pixels(index: i32) -> i8 { + const MAX_STRUCTURE_HEIGHT: i32 = 50; + const HEIGHT_UNITS_PER_INDEX: i32 = 64; + const HEIGHT_UNITS: i32 = 256; + (index * MAX_STRUCTURE_HEIGHT * HEIGHT_UNITS_PER_INDEX / HEIGHT_UNITS) as i8 +} + +/// Adds the items of the map to the struct and on roof lists +pub fn add_items(world: &mut MapWorld, map: &TacticalMap) { + for index in 0..map.items.len() { + add_item(world, map, index); + } +} + +/// Adds an item of the map to the world, like `add_items` does +/// +/// Items without a graphic are left out, the conversion warned about them. +pub fn add_item(world: &mut MapWorld, map: &TacticalMap, index: usize) { + let item = &map.items[index]; + if let Some(graphic) = &item.graphic { + let placement = item_placement(world, item); + add_item_graphic(world, index, graphic, placement); + } +} + +/// Where an item is shown +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ItemPlacement { + /// The tile of the item, or the base tile of the structure it lies on + pub grid_no: GridNo, + pub level: Level, + /// How far the item is drawn above the ground (`bRenderZHeightAboveLevel`) + pub render_height: i8, +} + +/// Port of `InternalAddItemToPool` for the placement of an item +/// +/// Only depends on the roofs and structures of the tile of the item. +pub fn item_placement(world: &mut MapWorld, item: &MapItem) -> ItemPlacement { + let mut grid_no = item.grid_no; + + // Items on roofs without a roof are put on the ground (`FlatRoofAboveGridNo`) + let flat_roof = world.tiles[grid_no] + .roofs + .iter() + .any(|node| node.tile.is_flat_roof()); + let level = if !flat_roof { + Level::Ground + } else { + item.level + }; + + let (force_on_ground, mut render_height) = match item.render_height { + ItemHeight::ForceGround => (true, 0), + ItemHeight::Above(height) => (false, height as i8), + }; + + if item.stack.item != ItemId::OWNERSHIP && item.stack.item != ItemId::ACTION_ITEM { + let desired_level = match level { + Level::Ground => 0, + Level::Roof => PROFILE_Z_SIZE as i16, + }; + let tile = grid_no; + for index in 0..world.tiles[tile].structures.len() { + let structure = &world.tiles[tile].structures[index]; + if !structure.flags.intersects(StructureFlags::BLOCKSMOVES) + || structure + .flags + .intersects(StructureFlags::PERSON | StructureFlags::CORPSE) + || structure.cube_offset != desired_level + { + continue; + } + let Some(shape) = structure.structure_tile().map(|tile| tile.shape) else { + continue; + }; + // Lowest cube level of the structure, 1 to 4 (`StructureBottomLevel`) + let bottom_level = shape + .iter() + .flatten() + .filter_map(|&column| (0..PROFILE_Z_SIZE).find(|&z| column & (1 << z) != 0)) + .min() + .map_or(PROFILE_Z_SIZE + 1, |z| z + 1); + if bottom_level != 1 && force_on_ground { + break; + } + // The item moves to the base tile of the structure + grid_no = structure.base_grid_no; + let flags = structure.flags; + if flags.contains(StructureFlags::OPENABLE) { + let height = world.cache_structure_height(tile, index); + render_height = index_to_pixels(i32::from(height)); + break; + } else if flags.contains(StructureFlags::GENERIC) { + // On top of the most dense level (`StructureDensity`) + let density: [usize; PROFILE_Z_SIZE] = std::array::from_fn(|z| { + shape + .iter() + .flatten() + .filter(|&&column| column & (1 << z) != 0) + .count() + }); + render_height = 0; + let mut max = 0; + for z in (0..PROFILE_Z_SIZE).rev() { + if density[z] > max { + max = density[z]; + render_height = index_to_pixels(z as i32 + 1); + } + } + break; + } + } + } + ItemPlacement { + grid_no, + level, + render_height, + } +} + +/// Adds the graphic of the item with the given index in the map (`AddItemGraphicToWorld`) +fn add_item_graphic( + world: &mut MapWorld, + index: usize, + graphic: &Arc, + placement: ItemPlacement, +) { + let grid_no = placement.grid_no; + let node = if placement.level == Level::Ground { + if world.add_struct_to_tail(grid_no, graphic).is_err() { + return; + } + world.tiles[grid_no].structs.last_mut() + } else { + match world.add_on_roof_to_head(grid_no, graphic) { + Ok(()) => world.tiles[grid_no].on_roofs.first_mut(), + Err(_) => return, + } + }; + if let Some(node) = node { + node.flags |= NodeFlags::ITEM; + node.item = Some(index as u16); + node.item_height = placement.render_height; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn structure_levels_in_pixels() { + assert_eq!([1, 2, 3, 4].map(index_to_pixels), [12, 25, 37, 50]); + } +} diff --git a/src-map-editor/src/render/lighting.rs b/src-map-editor/src/render/lighting.rs new file mode 100644 index 0000000..be8a233 --- /dev/null +++ b/src-map-editor/src/render/lighting.rs @@ -0,0 +1,683 @@ +//! Shade levels of the level nodes: the ambient light and the light sprites of the map +//! +//! Port of `LightSetBaseLevel`, `LightSpriteRenderAll`, `LightDraw`, `IlluminationFilter` and +//! `LightAddTile` (`Lighting.cc`), and of the light switching of `LoadMapLights` and +//! `ExtractLightSprite`. Lights combine by their maximum, so the order of the light sprites does +//! not matter. + +use std::{collections::BTreeSet, ops::RangeInclusive}; + +use bevy::prelude::Resource; +use serde::Deserialize; + +use crate::{ + components::map::GridPosition, + light::{LightTemplate, LIGHT_BACKLIGHT, LIGHT_NEW_RAY}, + map::{Direction, GridArray, GridNo, LightTime, MapLight, TacticalMap}, + render::{ + movement_costs::{ + door_travel_cost, is_travelcost_door, is_travelcost_open_door, MovementCosts, + TRAVELCOST_CAVEWALL, TRAVELCOST_DOOR, TRAVELCOST_OBSTACLE, TRAVELCOST_WALL, + }, + world::{MapWorld, NodeLight, WorldTile}, + }, + tileset::{Orientation, StructureFlags, TileKind, PROFILE_Z_SIZE}, +}; + +/// Ambient light level at day and at night, higher is darker (`NORMAL_LIGHTLEVEL_DAY`, +/// `NORMAL_LIGHTLEVEL_NIGHT`) +const NORMAL_LIGHTLEVEL_DAY: u8 = 3; +const NORMAL_LIGHTLEVEL_NIGHT: u8 = 12; +/// From this ambient level on, the static shadows are not drawn (`LIGHT_DUSK_CUTOFF`) +const LIGHT_DUSK_CUTOFF: u8 = 8; +/// Brightest and darkest shade level that lights produce (`SHADE_MAX`, `SHADE_MIN`) +const SHADE_MAX: i32 = 1; +const SHADE_MIN: i32 = 15; + +/// A structure blocks light if this many columns of its profile are at least +/// [`STANDING_CUBES`] high (`MIN_BOX_COLUMNS`) +const MIN_BOX_COLUMNS: usize = 8; +const STANDING_CUBES: u8 = 3; + +/// Time of day that the map is shown at +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LightingMode { + /// Daylight, only the lights without a time flag are on + #[default] + Day, + /// Evening (21:00 to midnight), the night and prime time lights are on as well + Night, +} + +/// Lighting of the map that the renderer needs besides the node shades +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Lighting { + pub ambient: u8, + /// Whether the static shadows are drawn (`RENDER_FLAG_SHADOWS`) + pub shadows: bool, + /// Added to all palettes (`g_light_color`) + pub light_color: [u8; 3], +} + +/// Ambient light level of a map +pub fn ambient_light_level(map: &TacticalMap, mode: LightingMode) -> u8 { + match &map.ambient_light { + // Only basements and caves have a saved level + Some(ambient) => ambient.level, + None => match mode { + LightingMode::Day => NORMAL_LIGHTLEVEL_DAY, + LightingMode::Night => NORMAL_LIGHTLEVEL_NIGHT, + }, + } +} + +/// Whether a light is on at the given time of day +fn light_on(time: LightTime, mode: LightingMode) -> bool { + match mode { + LightingMode::Day => time == LightTime::Always, + LightingMode::Night => true, + } +} + +/// The lighting of a map at the given time of day, besides the node shades +pub fn map_lighting(map: &TacticalMap, mode: LightingMode) -> Lighting { + let ambient = ambient_light_level(map, mode); + Lighting { + ambient, + shadows: ambient < LIGHT_DUSK_CUTOFF, + light_color: map.light_color, + } +} + +/// The light of every node before the light sprites are drawn (`LightSetBaseLevel`) +fn base_node_light(lighting: &Lighting) -> NodeLight { + let shade = i32::from(lighting.ambient).clamp(SHADE_MAX, SHADE_MIN) as u8; + NodeLight { + shade, + natural_shade: shade, + max_lights: 0, + } +} + +/// Resets the nodes of a tile to the ambient light (`LightSetBaseLevel` for one tile) +pub fn base_light(tile: &mut WorldTile, lighting: &Lighting) { + let base_light = base_node_light(lighting); + let lists = [ + &mut tile.land, + &mut tile.objects, + &mut tile.structs, + &mut tile.roofs, + &mut tile.on_roofs, + &mut tile.topmost, + ]; + for node in lists.into_iter().flatten() { + node.light = base_light; + } + for node in &mut tile.mercs { + node.light = base_light; + } +} + +/// A light that the scene is lit with as if it was placed, without being a light of the map: +/// the light that a click of the lights mode would place +#[derive(Resource, Clone, Debug, Default, PartialEq)] +pub struct PreviewLight(pub Option); + +/// The lights that the scene is lit with: the lights of the map and the preview, which replaces +/// the lights of its tile like placing it does +pub fn scene_lights<'a>( + map: &'a TacticalMap, + preview: Option<&'a MapLight>, +) -> impl Iterator { + let hidden = preview.map(|light| light.position); + map.lights + .iter() + .filter(move |light| Some(light.position) != hidden) + .chain(preview) +} + +/// The scene lights (see [`scene_lights`]) that are on at the given time of day +pub fn active_lights<'a>( + map: &'a TacticalMap, + preview: Option<&'a MapLight>, + mode: LightingMode, +) -> impl Iterator { + // Caves have no lights on + let caves = map.ambient_light.as_ref().is_some_and(|a| a.caves); + scene_lights(map, preview).filter(move |light| !caves && light_on(light.time, mode)) +} + +/// Draws a light sprite onto the nodes it reaches (`LightDraw`) +/// +/// Lights combine by their maximum, so drawing a light again changes nothing. +pub fn draw_light(world: &mut MapWorld, costs: &MovementCosts, light: &MapLight) { + draw_light_filtered(world, costs, light, |_| true); +} + +/// Sets the shade levels of all nodes for the map lighting at the given time of day, with the +/// preview light if there is one +pub fn light_all( + world: &mut MapWorld, + map: &TacticalMap, + preview: Option<&MapLight>, + costs: &MovementCosts, + mode: LightingMode, +) -> Lighting { + let lighting = map_lighting(map, mode); + for tile in world.tiles.values_mut() { + base_light(tile, &lighting); + } + for light in active_lights(map, preview, mode) { + draw_light(world, costs, light); + } + lighting +} + +/// How far a light can reach beyond the tiles of its template: the base tile of an open door is +/// lit instead of the door, and it can be 2 tiles away (`DoorTravelCost`) +const LIGHT_EXTENT_MARGIN: i32 = 2; + +/// The tiles (as x and y ranges) that a light can light or look at, including the tiles its rays +/// pass through +fn light_extent(light: &MapLight) -> Option<(RangeInclusive, RangeInclusive)> { + let lights = &light.template.lights; + let (x, y) = (i32::from(light.position.x), i32::from(light.position.y)); + let min_x = lights.iter().map(|node| i32::from(node.dx)).min()?; + let max_x = lights.iter().map(|node| i32::from(node.dx)).max()?; + let min_y = lights.iter().map(|node| i32::from(node.dy)).min()?; + let max_y = lights.iter().map(|node| i32::from(node.dy)).max()?; + let margin = LIGHT_EXTENT_MARGIN; + Some(( + (x + min_x - margin)..=(x + max_x + margin), + (y + min_y - margin)..=(y + max_y + margin), + )) +} + +/// The tiles that a light can light or look at, see [`relight_area`]: once a light is added, +/// removed or changed, relighting these tiles brings the lighting up to date +pub fn light_tiles(light: &MapLight) -> Vec { + let Some((xs, ys)) = light_extent(light) else { + return vec![]; + }; + ys.flat_map(|y| xs.clone().filter_map(move |x| grid_no(x, y))) + .collect() +} + +fn in_extent(extent: &(RangeInclusive, RangeInclusive), grid_no: GridNo) -> bool { + let position = grid_no.position(); + extent.0.contains(&i32::from(position.x)) && extent.1.contains(&i32::from(position.y)) +} + +/// Lights the nodes again after the tiles in `seed` changed: their nodes, structures, heights or +/// movement costs. The result is the same as `light_all`. +/// +/// The rays of a light only depend on the tiles within its extent, so the lights whose extent +/// contains a seed tile can light different tiles now. Their extents and the seed tiles are +/// reset to the ambient light, and every light that reaches into them is drawn onto them again. +/// Returns the tiles whose lights may have changed. +pub fn relight_area( + world: &mut MapWorld, + map: &TacticalMap, + preview: Option<&MapLight>, + costs: &MovementCosts, + mode: LightingMode, + seed: &BTreeSet, +) -> BTreeSet { + let lighting = map_lighting(map, mode); + let lights: Vec<_> = active_lights(map, preview, mode) + .filter_map(|light| Some((light, light_extent(light)?))) + .collect(); + let mut area = seed.clone(); + for (_, extent) in &lights { + if seed.iter().any(|&grid_no| in_extent(extent, grid_no)) { + for y in extent.1.clone() { + for x in extent.0.clone() { + if let Some(grid_no) = grid_no(x, y) { + area.insert(grid_no); + } + } + } + } + } + + let mut in_area = GridArray::filled(false); + for &grid_no in &area { + in_area[grid_no] = true; + base_light(&mut world.tiles[grid_no], &lighting); + } + for (light, extent) in &lights { + if area.iter().any(|&grid_no| in_extent(extent, grid_no)) { + draw_light_filtered(world, costs, light, |grid_no| in_area[grid_no]); + } + } + area +} + +/// `draw_light` that only lights the tiles for which `lit` is true +fn draw_light_filtered( + world: &mut MapWorld, + costs: &MovementCosts, + light: &MapLight, + lit: impl Fn(GridNo) -> bool, +) { + LightDrawing { world, costs, lit }.draw(&light.template, light.position); +} + +/// Grid number of a tile position, `None` outside of the map (`MAPROWCOLTOPOS`) +fn grid_no(x: i32, y: i32) -> Option { + GridPosition::from_xy(x, y).map(GridNo::from) +} + +/// Direction from one tile to another (`atan8`) +fn atan8(x: i32, y: i32, x2: i32, y2: i32) -> Direction { + use std::f64::consts::PI; + let dx = f64::from(x2 - x); + // Screen y points south, atan2 expects it to point north + let dy = -f64::from(y2 - y); + if dx == 0. && dy == 0. { + return Direction::East; + } + let mut theta = dy.atan2(dx); + if theta < 0. { + theta += 2. * PI; + } + let index = ((8. * theta + PI) / (2. * PI)) as usize % 8; + [ + Direction::East, + Direction::NorthEast, + Direction::North, + Direction::NorthWest, + Direction::West, + Direction::SouthWest, + Direction::South, + Direction::SouthEast, + ][index] +} + +/// Which structures a ray from one tile to the next lights and whether it goes on +/// (`IlluminationFilter`) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct IlluminationFilter { + blocked: bool, + illuminate_nothing: bool, + /// Only walls and doors are lit + illuminate_oriented_blocks_only: bool, + allowed_orients: Orientation, + source_to_destination: Option, + /// Base tile of an open door that is lit instead of the destination + base_grid_no: Option, +} + +struct LightDrawing<'a, L> { + world: &'a mut MapWorld, + costs: &'a MovementCosts, + /// Whether the light is added to a tile, the rays go on either way + lit: L, +} + +impl bool> LightDrawing<'_, L> { + fn filter(&self, src_x: i32, src_y: i32, x: i32, y: i32) -> IlluminationFilter { + let mut filter = IlluminationFilter::default(); + let (Some(dst), Some(src)) = (grid_no(x, y), grid_no(src_x, src_y)) else { + filter.blocked = true; + filter.illuminate_nothing = true; + return filter; + }; + let tiles = &self.world.tiles; + if tiles[dst].height > tiles[src].height { + return filter; + } + + let direction = atan8(src_x, src_y, x, y); + filter.source_to_destination = Some(direction); + let mut cost = self.costs.cost(dst, direction); + + let window_tile = if matches!(direction, Direction::North | Direction::West) { + dst + } else { + src + }; + // A window does not block + if !tiles[window_tile].structs.is_empty() + && cost == TRAVELCOST_WALL + && tiles[window_tile] + .find_structure(StructureFlags::WALLNWINDOW) + .is_some() + { + if window_tile == dst { + match direction { + Direction::North => filter.allowed_orients = Orientation::LEFT, + Direction::West => filter.allowed_orients = Orientation::RIGHT, + _ => {} + } + } + return filter; + } + + if dst == src { + return filter; + } + + // Walls only light the walls on the side of the light + if cost == TRAVELCOST_WALL { + filter.blocked = true; + match direction { + Direction::North => { + filter.illuminate_oriented_blocks_only = true; + filter.allowed_orients = Orientation::LEFT; + } + Direction::West => { + filter.illuminate_oriented_blocks_only = true; + filter.allowed_orients = Orientation::RIGHT; + } + _ => filter.illuminate_nothing = true, + } + return filter; + } + + if is_travelcost_door(cost) { + let (door_cost, base_grid_no) = door_travel_cost(self.world, self.costs, dst, cost); + cost = door_cost; + if is_travelcost_open_door(cost) && base_grid_no != Some(src) { + // The door slab is lit from its base tile + filter.base_grid_no = base_grid_no; + filter.blocked = true; + filter.illuminate_oriented_blocks_only = true; + match direction { + Direction::North => filter.allowed_orients = Orientation::RIGHT, + Direction::West => filter.allowed_orients = Orientation::LEFT, + _ => filter.illuminate_nothing = true, + } + return filter; + } + // Closed doors light only the walls and doors on the side of the light + if cost == TRAVELCOST_DOOR || is_travelcost_door(cost) { + filter.blocked = true; + filter.illuminate_oriented_blocks_only = true; + match direction { + Direction::North => filter.allowed_orients = Orientation::LEFT, + Direction::West => filter.allowed_orients = Orientation::RIGHT, + _ => filter.illuminate_nothing = true, + } + return filter; + } + } + + if cost == TRAVELCOST_OBSTACLE { + for structure in &tiles[dst].structures { + // The height is only known if the game computed it before + if structure.flags.intersects( + StructureFlags::PASSABLE + | StructureFlags::ANYDOOR + | StructureFlags::WALLSTUFF + | StructureFlags::TREE, + ) || structure.structure_height < STANDING_CUBES + || structure.cube_offset == PROFILE_Z_SIZE as i16 + { + continue; + } + let Some(tile) = structure.structure_tile() else { + continue; + }; + // Columns that are at least 3 cubes high + let box_columns = tile + .shape + .iter() + .flatten() + .filter(|&&column| column == 0b0111 || column == 0b1111) + .count(); + if box_columns >= MIN_BOX_COLUMNS { + filter.blocked = true; + return filter; + } + } + } + + if cost == TRAVELCOST_CAVEWALL { + filter.blocked = true; + } + filter + } + + /// Port of `LightDraw` for a light at `position` + fn draw(&mut self, template: &LightTemplate, position: GridPosition) { + const DRAWN_FULLY: u8 = 0x01; + const DRAWN_NONWALL: u8 = 0x02; + + if template.lights.is_empty() { + return; + } + let mut node_flags = vec![0u8; template.lights.len()]; + let (center_x, center_y) = (i32::from(position.x), i32::from(position.y)); + let (mut src_x, mut src_y) = (center_x, center_y); + + let mut i = 0; + while i < template.rays.len() { + let entry = template.rays[i]; + if entry & LIGHT_NEW_RAY != 0 { + src_x = center_x; + src_y = center_y; + i += 1; + continue; + } + let node_index = usize::from(entry & !LIGHT_BACKLIGHT); + let Some(&node) = template.lights.get(node_index) else { + i += 1; + continue; + }; + // The game computes the destination as INT16 + let dst_x = i32::from((center_x + i32::from(node.dx)) as i16); + let dst_y = i32::from((center_y + i32::from(node.dy)) as i16); + + let filter = self.filter(src_x, src_y, dst_x, dst_y); + if filter.blocked { + // Skip the rest of the ray (`LightFindNextRay`), the loop skips its start + // marker as well + while i < template.rays.len() && template.rays[i] & LIGHT_NEW_RAY == 0 { + i += 1; + } + } + + let south_west = node.dx < 0 && node.dy > 0; + let north_east = node.dx > 0 && node.dy < 0; + let north_west = node.dx < 0 && node.dy < 0; + let flags = &mut node_flags[node_index]; + if *flags & DRAWN_FULLY == 0 + && *flags & DRAWN_NONWALL != 0 + && (south_west + || north_east + || (north_west && !filter.illuminate_oriented_blocks_only)) + { + *flags |= DRAWN_FULLY; + } + + if !filter.illuminate_nothing && *flags & DRAWN_FULLY == 0 && node.light != 0 { + let backlight = entry & LIGHT_BACKLIGHT != 0; + if let Some(target) = filter.base_grid_no.or_else(|| grid_no(dst_x, dst_y)) { + if (self.lit)(target) { + self.add_tile(target, node.light, backlight, &filter); + } + } + + let flags = &mut node_flags[node_index]; + if center_x == dst_x || center_y == dst_y || (node.dx > 0 && node.dy > 0) { + *flags |= DRAWN_FULLY; + } else if filter.illuminate_oriented_blocks_only { + } else if south_west || north_east { + if matches!( + filter.source_to_destination, + Some(Direction::West | Direction::North) + ) { + *flags |= DRAWN_FULLY; + } else { + *flags |= DRAWN_NONWALL; + } + } else if north_west { + *flags |= DRAWN_NONWALL; + } + } + + src_x = center_x; + src_y = center_y; + if !filter.blocked { + src_x += i32::from(node.dx); + src_y += i32::from(node.dy); + } + i += 1; + } + } + + /// Port of `LightAddTile` for map lights (not roof only, not fake) + fn add_tile( + &mut self, + grid_no: GridNo, + light: u8, + backlight: bool, + filter: &IlluminationFilter, + ) { + let tile = &mut self.world.tiles[grid_no]; + let structures = &tile.structures; + for node in &mut tile.structs { + // `pStructureData` is the base tile of the structure + let structure = node.structure_id.and_then(|id| { + structures + .iter() + .find(|s| s.id == id && s.structure_tile_index == 0) + }); + let (oriented_block, wall_orientation) = match structure { + Some(structure) => ( + structure + .flags + .intersects(StructureFlags::WALLSTUFF | StructureFlags::ANYDOOR), + structure.wall_orientation, + ), + None if filter.illuminate_oriented_blocks_only + && (matches!(node.tile.kind, TileKind::WallDecoration { .. }) + || node.tile.lit_as_wall) => + { + (true, filter.allowed_orients) + } + None => (false, Orientation::empty()), + }; + if (filter.illuminate_oriented_blocks_only + && (!oriented_block || !wall_orientation.intersects(filter.allowed_orients))) + || (oriented_block && filter.allowed_orients.is_empty()) + || node.tile.kind == (TileKind::Cliff { hang: true }) + { + continue; + } + add_light(&mut node.light, light); + } + + if !filter.illuminate_oriented_blocks_only { + for node in tile.land.iter_mut().chain(&mut tile.objects) { + add_light(&mut node.light, light); + } + let merc_light = if backlight { + (i32::from(light) * 7 / 10) as u8 + } else { + light + }; + for node in &mut tile.mercs { + add_light(&mut node.light, merc_light); + } + } + } +} + +/// Adds a light to a node (`LightAddTileNode`) +fn add_light(node: &mut NodeLight, light: u8) { + node.max_lights = node.max_lights.max(light); + node.shade = (i32::from(node.natural_shade) - i32::from(node.max_lights)) + .clamp(SHADE_MAX, SHADE_MIN) as u8; +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::{components::map::JA2_MAP_SIZE, map::LightId, tileset::TilesetId}; + + fn light(id: u32, x: u8, y: u8, time: LightTime) -> MapLight { + MapLight { + id: LightId(id), + position: GridPosition::new(x, y), + time, + template_name: "L-R01.LHT".to_owned(), + template: Arc::new(LightTemplate::default()), + file_sprite: None, + } + } + + #[test] + fn the_preview_replaces_the_lights_of_its_tile() { + let mut map = TacticalMap::empty(TilesetId::new(0)); + map.lights = vec![ + light(0, 10, 10, LightTime::Always), + light(1, 20, 20, LightTime::Always), + light(2, 10, 10, LightTime::Night), + ]; + let ids = |preview: Option<&MapLight>| -> Vec { + scene_lights(&map, preview) + .map(|light| light.id.0) + .collect() + }; + assert_eq!(ids(None), [0, 1, 2]); + assert_eq!(ids(Some(&light(3, 10, 10, LightTime::Always))), [1, 3]); + assert_eq!( + ids(Some(&light(3, 30, 30, LightTime::Always))), + [0, 1, 2, 3] + ); + } + + #[test] + fn the_preview_is_only_on_when_a_placed_light_would_be() { + let map = TacticalMap::empty(TilesetId::new(0)); + let prime_time = light(0, 10, 10, LightTime::PrimeTime); + let active = |mode| active_lights(&map, Some(&prime_time), mode).count(); + assert_eq!(active(LightingMode::Day), 0); + assert_eq!(active(LightingMode::Night), 1); + } + + #[test] + fn directions() { + assert_eq!(atan8(5, 5, 5, 4), Direction::North); + assert_eq!(atan8(5, 5, 6, 4), Direction::NorthEast); + assert_eq!(atan8(5, 5, 6, 5), Direction::East); + assert_eq!(atan8(5, 5, 6, 6), Direction::SouthEast); + assert_eq!(atan8(5, 5, 5, 6), Direction::South); + assert_eq!(atan8(5, 5, 4, 6), Direction::SouthWest); + assert_eq!(atan8(5, 5, 4, 5), Direction::West); + assert_eq!(atan8(5, 5, 4, 4), Direction::NorthWest); + assert_eq!(atan8(5, 5, 5, 5), Direction::East); + // Each direction covers 45 degrees + assert_eq!(atan8(0, 0, 3, -1), Direction::East); + assert_eq!(atan8(0, 0, 2, -1), Direction::NorthEast); + assert_eq!(atan8(0, 0, -1, 3), Direction::South); + } + + #[test] + fn grid_numbers_outside_of_the_map() { + assert_eq!(grid_no(0, 0), GridNo::new(0)); + assert_eq!(grid_no(3, 2), GridNo::new(323)); + assert_eq!(grid_no(i32::from(JA2_MAP_SIZE), 0), None); + assert_eq!(grid_no(-1, 5), None); + } + + #[test] + fn lights_combine_by_maximum() { + let mut node = NodeLight { + shade: 12, + natural_shade: 12, + max_lights: 0, + }; + add_light(&mut node, 3); + assert_eq!(node.shade, 9); + add_light(&mut node, 2); + assert_eq!(node.shade, 9); + add_light(&mut node, 20); + assert_eq!(node.shade, 1); + } +} diff --git a/src-map-editor/src/render/mod.rs b/src-map-editor/src/render/mod.rs new file mode 100644 index 0000000..064232c --- /dev/null +++ b/src-map-editor/src/render/mod.rs @@ -0,0 +1,506 @@ +//! Renders maps pixel-exact like the game, on the GPU +//! +//! The game renders in software: it blits 8 bit sprites through shaded palettes into a frame +//! buffer with a 16 bit z buffer, using blitters with different depth rules, in a fixed order. +//! The final colour of a pixel only depends on the ordered list of blits that cover it. So the +//! CPU builds the game's draw list (`draw_list.rs`), sorts it into bins (`bins.rs`) and packs it +//! with the sprites and palettes into storage buffers (`atlas.rs`). The replay shader then applies +//! the draws to each pixel independently (`replay_material.rs`, `shaders/ja2_replay.wesl`). The +//! result is baked into region textures (`systems/bake.rs`). +//! +//! The colours are computed with 8 bits per channel instead of the game's 16 bit frame buffer. + +use std::{ + collections::BTreeSet, + sync::atomic::{AtomicU64, Ordering}, + time::Duration, +}; + +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{ + host::report_editor_event, + map::{GridNo, Level, MapLight, TacticalMap}, + render::{ + atlas::{current_animation_frames, RegionScene, SceneAtlas, SharedBuffers, SpriteSheets}, + draw_list::{Draw, DrawCache, DrawContext, DrawListOptions, LayerVisibility}, + lighting::{light_all, relight_area, Lighting, LightingMode, PreviewLight}, + movement_costs::MovementCosts, + regions::RegionLayout, + world::MapWorld, + }, + states::{ + loading::{MapAssets, MapToLoad}, + AppState, + }, + systems::{bake::MapBake, MapSystems}, + take_requested_render_options, + tileset::{StciSheet, Tileset}, +}; + +pub mod atlas; +pub mod bins; +pub mod draw_list; +pub mod edge_points; +pub mod items; +pub mod lighting; +pub mod movement_costs; +pub mod palette; +pub mod quantize; +pub mod radar; +pub mod reachable; +#[cfg(test)] +mod reference; +pub mod regions; +pub mod replay_material; +pub mod soldiers; +#[cfg(test)] +mod tests; +pub mod world; + +/// How often animated tiles advance a frame (`ANIMATETILES` timer) +const ANIMATION_INTERVAL: Duration = Duration::from_millis(200); + +/// What the viewer shows, set by the host with `set_render_options` +#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct RenderOptions { + pub layers: LayerVisibility, + /// The floor that is shown and edited: the roofs and what is on them are only drawn on the + /// roof, and only soldiers and items on the floor can be edited + pub floor: Level, + pub lighting: LightingMode, +} + +/// The palettes of the soldiers of the loaded map, the soldiers of the merc nodes of `MapWorld` +/// refer to them by their index +/// +/// The atlas keeps the palettes it packed by their index, so a palette never changes. Soldiers +/// with the same palette share it, other palettes are added. +#[derive(Resource, Clone, Debug, Default)] +pub struct MapSoldiers { + pub palettes: Vec<[[u8; 3]; 256]>, +} + +impl MapSoldiers { + /// The index of a palette, it is added if it is new + pub fn palette_id(&mut self, palette: &[[u8; 3]; 256]) -> u16 { + let index = match self.palettes.iter().position(|known| known == palette) { + Some(index) => index, + None => { + self.palettes.push(*palette); + self.palettes.len() - 1 + } + }; + index as u16 + } +} + +/// Changes of the map that the scene has not caught up with yet +/// +/// The scene is only updated while the bake is idle. Changes that arrive in the meantime, e.g. +/// while the mouse paints, are collected and handled together. +#[derive(Resource, Clone, Debug, Default)] +pub struct PendingSceneUpdate { + /// Everything has to be rebuilt: a map was loaded or the render options changed + pub full: bool, + /// Tiles whose level nodes changed + pub tiles: BTreeSet, +} + +impl PendingSceneUpdate { + /// A pending rebuild of everything + pub fn full() -> Self { + Self { + full: true, + tiles: BTreeSet::new(), + } + } + + pub fn is_empty(&self) -> bool { + !self.full && self.tiles.is_empty() + } +} + +/// A new version number for the parts of the scene, unique for the whole app, so that the bake +/// never mistakes new data for data it already has +pub fn next_scene_version() -> u64 { + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +/// The data of one region of the scene +#[derive(Clone, Debug, Default)] +pub struct ReplayRegion { + pub scene: RegionScene, + /// Changes with every new `scene`, see `next_scene_version` + pub version: u64, +} + +/// The replay data of the current map, see `RegionLayout` for the regions +#[derive(Resource, Clone, Debug, Default)] +pub struct ReplayScene { + /// The sprites, palettes and animations of all regions + pub shared: SharedBuffers, + /// Changes with every new `shared`, see `next_scene_version` + pub shared_version: u64, + /// One entry per region of `RegionLayout::default()`, empty without a map + pub regions: Vec, + /// Area covered by the draws in game pixels + pub bounds: IRect, +} + +/// What the scene was built from, kept to update it +#[derive(Resource, Clone, Debug, Default)] +pub struct SceneSources { + pub draws: DrawCache, + pub atlas: SceneAtlas, + /// `None` until the scene is built + pub lighting: Option, + pub layout: RegionLayout, +} + +/// Current frames of the animations of the scene, changes without the scene +#[derive(Resource, Clone, Debug, Default)] +pub struct ReplayAnimation { + /// Tile database index of each animation + pub animations: Vec, + pub frames: Vec, +} + +#[derive(Resource)] +struct AnimationTimer(Timer); + +pub struct RenderPlugin; + +impl Plugin for RenderPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(replay_material::ReplayMaterialPlugin) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(AnimationTimer(Timer::new( + ANIMATION_INTERVAL, + TimerMode::Repeating, + ))) + .add_systems( + PreUpdate, + ( + apply_requested_options, + switch_floor.run_if(in_state(AppState::Map)), + ) + .chain(), + ) + .add_systems( + Update, + ( + update_scene.run_if( + resource_exists::.and_then(resource_exists::), + ), + animate_tiles.run_if(resource_exists::), + ) + .chain() + .in_set(MapSystems::Scene) + .run_if(in_state(AppState::Map)), + ); + } +} + +fn apply_requested_options( + mut options: ResMut, + mut pending: ResMut, +) { + if let Some(requested) = take_requested_render_options() { + if *options != requested { + // The overlays are not part of the scene + let redraw = options.lighting != requested.lighting + || options.floor != requested.floor + || options.layers.drawn() != requested.layers.drawn(); + *options = requested; + pending.full |= redraw; + } + } +} + +/// Tells the host that Page Up or Page Down switched the floor +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename = "floor")] +pub struct FloorEvent { + pub map: String, + pub floor: Level, +} + +/// Page Up switches to the roof, Page Down to the ground +fn switch_floor( + keys: Res>, + map_to_load: Res, + mut options: ResMut, + mut pending: ResMut, +) { + let floor = if keys.just_pressed(KeyCode::PageUp) { + Level::Roof + } else if keys.just_pressed(KeyCode::PageDown) { + Level::Ground + } else { + return; + }; + if options.floor == floor { + return; + } + options.floor = floor; + pending.full = true; + report_editor_event(&FloorEvent { + map: map_to_load.0.clone(), + floor, + }); +} + +/// The sheets and palettes that the draws of the loaded map refer to +pub(crate) fn sprite_sheets<'a>( + tileset: &'a Tileset, + map_assets: &MapAssets, + sheets: &'a Assets, + soldiers: &MapSoldiers, +) -> SpriteSheets<'a> { + SpriteSheets { + tileset, + surface_sheets: map_assets + .surface_sheets + .iter() + .map(|sheet| sheets.get(sheet)) + .collect(), + soldier_sheets: map_assets + .soldier_surfaces + .iter() + .map(|sheet| sheets.get(sheet)) + .collect(), + soldier_palettes: soldiers.palettes.clone(), + } +} + +/// Above this many changed tiles, the scene is rebuilt completely instead of updating it +const MAX_INCREMENTAL_TILES: usize = 4000; + +/// Brings the scene up to date with the map, once the bake is idle +fn update_scene( + mut pending: ResMut, + bake: Res, + options: Res, + mut world: ResMut, + tileset: Res, + mut costs: ResMut, + map: Res, + preview: Res, + soldiers: Res, + map_assets: Res, + sheets: Res>, + mut sources: ResMut, + mut scene: ResMut, + mut animation: ResMut, +) { + // Updating the scene while a bake waits for its materials would delay the bake again and + // again, e.g. while painting + if pending.is_empty() || !bake.is_idle() { + return; + } + let update = std::mem::take(&mut *pending); + let sprite_sheets = sprite_sheets(&tileset, &map_assets, &sheets, &soldiers); + let mut builder = SceneBuilder { + world: &mut world, + map: &map, + preview: preview.0.as_ref(), + costs: &mut costs, + options: *options, + tileset: &tileset, + sheets: &sprite_sheets, + sources: &mut sources, + }; + let incremental = !update.full + && builder.sources.lighting.is_some() + && update.tiles.len() <= MAX_INCREMENTAL_TILES; + if incremental { + builder.update(&update.tiles, &mut scene, &mut animation); + } else { + if !update.tiles.is_empty() { + *builder.costs = MovementCosts::compile(builder.world, &map); + } + builder.rebuild(&mut scene, &mut animation); + } +} + +/// Builds and updates the scene of the loaded map +struct SceneBuilder<'a> { + world: &'a mut MapWorld, + map: &'a TacticalMap, + /// The light that the scene is lit with as if it was placed + preview: Option<&'a MapLight>, + costs: &'a mut MovementCosts, + options: RenderOptions, + tileset: &'a Tileset, + sheets: &'a SpriteSheets<'a>, + sources: &'a mut SceneSources, +} + +impl SceneBuilder<'_> { + fn draw_options(&self, lighting: &Lighting) -> DrawListOptions { + DrawListOptions { + layers: self.options.layers, + floor: self.options.floor, + shadows: lighting.shadows, + } + } + + /// Lights the world and builds every region + fn rebuild(&mut self, scene: &mut ReplayScene, animation: &mut ReplayAnimation) { + let lighting = light_all( + self.world, + self.map, + self.preview, + self.costs, + self.options.lighting, + ); + let sheets = self.sheets; + self.sources.draws = DrawCache::build(&DrawContext { + world: self.world, + tileset: self.tileset, + sprites: |key| sheets.geometry(key), + options: self.draw_options(&lighting), + }); + self.sources.atlas = SceneAtlas::new(lighting.light_color); + self.sources.lighting = Some(lighting); + + // Every draw goes to the regions it overlaps, in draw list order + let sources = &mut *self.sources; + let layout = &sources.layout; + let draws = sources.draws.all(); + let mut region_draws: Vec> = vec![Vec::new(); layout.len()]; + for draw in &draws { + for region in layout.overlapping(draw.bounds) { + region_draws[region].push(draw.clone()); + } + } + let regions = region_draws + .iter() + .enumerate() + .map(|(index, draws)| ReplayRegion { + scene: RegionScene::build(draws, layout.rect(index), &mut sources.atlas, sheets), + version: next_scene_version(), + }) + .collect(); + let (sprites, sprite_bytes) = sources.atlas.sprite_stats(); + info!( + "Built {} draws of {} sprites, {} KiB of sprites", + draws.len(), + sprites, + sprite_bytes / 1024 + ); + + let shared = sources.atlas.shared_buffers(); + *animation = ReplayAnimation { + frames: current_animation_frames(&shared.animations, self.tileset), + animations: shared.animations.clone(), + }; + *scene = ReplayScene { + shared, + shared_version: next_scene_version(), + regions, + bounds: sources.draws.bounds(), + }; + } + + /// Updates the scene after the level nodes of `tiles` changed (`MapWorld::sync_tiles`) + /// + /// The movement costs and the lights are updated where they can have changed, then the + /// draws of all tiles whose nodes or lights changed. Only the regions that the old or new + /// draws of these tiles overlap are built again. + fn update( + &mut self, + tiles: &BTreeSet, + scene: &mut ReplayScene, + animation: &mut ReplayAnimation, + ) { + let Some(lighting) = self.sources.lighting else { + return; + }; + let changed_costs = self.costs.recompile_area(self.world, self.map, tiles); + let mut redraw = tiles.clone(); + redraw.extend(changed_costs); + let relit = relight_area( + self.world, + self.map, + self.preview, + self.costs, + self.options.lighting, + &redraw, + ); + redraw.extend(relit); + + let sheets = self.sheets; + let context = DrawContext { + world: &*self.world, + tileset: self.tileset, + sprites: |key| sheets.geometry(key), + options: self.draw_options(&lighting), + }; + let sources = &mut *self.sources; + let mut dirty_regions = BTreeSet::new(); + for &grid_no in &redraw { + if let Some(changed) = sources.draws.replace(grid_no, context.tile_draws(grid_no)) { + dirty_regions.extend(sources.layout.overlapping(changed)); + } + } + if dirty_regions.is_empty() { + return; + } + + let atlas_version = sources.atlas.version; + for &index in &dirty_regions { + let rect = sources.layout.rect(index); + let draws = sources.draws.ordered(rect); + if let Some(region) = scene.regions.get_mut(index) { + *region = ReplayRegion { + scene: RegionScene::build(&draws, rect, &mut sources.atlas, sheets), + version: next_scene_version(), + }; + } + } + if sources.atlas.version != atlas_version { + scene.shared = sources.atlas.shared_buffers(); + scene.shared_version = next_scene_version(); + if scene.shared.animations != animation.animations { + *animation = ReplayAnimation { + frames: current_animation_frames(&scene.shared.animations, self.tileset), + animations: scene.shared.animations.clone(), + }; + } + } + scene.bounds = sources.draws.bounds(); + info!( + "Updated {} tiles and {} of {} regions", + redraw.len(), + dirty_regions.len(), + sources.layout.len() + ); + } +} + +/// Advances the animated tiles, like `RenderWorld` does on the `ANIMATETILES` timer +fn animate_tiles( + time: Res