diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 7b934d8..b9d8b37 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -14,7 +14,7 @@ body:
- type: input
attributes:
label: RouteFilter version / RouteFilter 版本
- placeholder: 0.4.0-beta.1
+ placeholder: 1.0.0
validations:
required: true
- type: input
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0af6d1..12ca86a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,68 @@
All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and Semantic Versioning prerelease conventions.
+## 1.0.0 — 2026-08-19
+
+### Added
+
+- Stable-release branding, public documentation, screenshot integration points, and official Paradox Mods publishing metadata.
+- A viewport-aware panel layout that keeps selection, assets, paging, and application controls on one screen.
+
+### Changed
+
+- Opening or closing the RouteFilter panel now activates or closes the selection tool at the same time; the separate in-panel activation button was removed.
+- Changed the default remappable panel shortcut from `Ctrl+Shift+X` to `Ctrl+Shift+N`.
+- Promoted the verified asset-level V1 save schema and exact-asset enforcement workflow to the first stable release.
+
+### 中文
+
+- 新增正式版品牌素材、公开说明、游戏内截图接入位置及官方 Paradox Mods 发布元数据。
+- 面板根据视口自适应,目标选择、资产列表、分页和应用按钮保持在同一页面。
+- 面板开关与选择工具同步,不再保留面板内单独的工具启用按钮。
+- 默认且可重新绑定的快捷键由 `Ctrl+Shift+X` 改为 `Ctrl+Shift+N`。
+- 将已验证的逐资产 V1 存档结构和准确资产禁行流程发布为首个正式版本。
+
+## 0.5.0-beta.1 — 2026-08-19
+
+### Added
+
+- Circular node outlines and dashed segment hover highlights.
+- Contextual road/rail asset filtering based on the hovered network target.
+- Hover details for maximum speed in km/h, acceleration, and braking.
+- Expandable fixed-trailer and multiple-unit engine/carriage groups.
+- Minimal line icons for the toolbar, road vehicles, rail vehicles, and trailers.
+
+### Fixed
+
+- Replaced per-frame catalog getters with event-driven value bindings.
+- Replaced unsupported grid CSS with a scrollable flex layout compatible with the game UI engine.
+- Added explicit forbidden/allowed wording; applying an empty forbidden list now intentionally allows all compatible assets.
+- Changed editing to an explicit select → configure → apply workflow; right-click now cancels target selection.
+- Fixed segment raycasts that hit owned lanes instead of the parent network edge.
+- Isolated panel pointer input from the world tool and made asset/group controls independently clickable.
+- Added fixed-height scrolling plus pagination, and made catalog-wide allow/forbid actions independent of filtering.
+- Corrected maximum-speed presentation from metres per second to km/h.
+- Loaded each target's saved forbidden list on selection instead of reusing a global-looking pending state.
+- Expanded enforcement to current lanes, navigation lanes, path elements, network-node endpoints, and complete vehicle consists.
+- Scheduled exact-asset enforcement after road/rail navigation and before vehicle movement.
+- Reduced panel height and removed the redundant engine-group instruction line.
+
+### 中文
+
+- 新增节点圆形线框、路段虚线悬浮高亮,以及道路/轨道资产自动筛选。
+- 新增资产参数悬浮信息、牵引车辆与车厢分组和简约线稿图标。
+- 将每帧资产绑定改为事件驱动,并以可滚动 Flex 布局替代游戏 UI 不支持的 Grid。
+- 明确“选中即禁行”的界面语义;明确应用空禁行清单时会放行全部兼容资产。
+- 改为“选择目标 → 配置清单 → 明确应用”的交互,右键只取消目标选择。
+- 修复射线命中路段子车道时无法解析到所属路段的问题。
+- 隔离面板与地图工具的鼠标输入,并将资产圆形控件和分组展开控件拆分为独立操作。
+- 新增固定高度滚动与分页兜底,全部禁行/放行不再受筛选条件影响。
+- 将最高速度从米/秒正确换算为 km/h 显示。
+- 选中目标时载入其独立保存的禁行清单,不再复用看似全图共享的暂存状态。
+- 将执行检测扩展到当前车道、导航车道、路径元素、路段端点节点和完整车辆编组。
+- 将逐资产拦截明确安排在道路/轨道导航之后、车辆移动之前。
+- 缩短面板并删除重复的编组操作说明行。
+
## 0.4.0-beta.1 — 2026-08-19
### Added
diff --git a/Mod.cs b/Mod.cs
index 26a4cb9..b756571 100644
--- a/Mod.cs
+++ b/Mod.cs
@@ -14,7 +14,7 @@ namespace RouteFilter;
public sealed class Mod : IMod
{
public const string Id = "RouteFilter";
- public const string Version = "0.4.0-beta.1";
+ public const string Version = "1.0.0";
public const string ToggleToolAction = "ToggleRestrictionTool";
public const string ApplyAction = "ApplyRestriction";
public const string ClearAction = "ClearRestriction";
@@ -52,9 +52,13 @@ public void OnLoad(UpdateSystem updateSystem)
updateSystem.UpdateAt(SystemUpdatePhase.ToolUpdate);
updateSystem.UpdateAt(SystemUpdatePhase.ToolUpdate);
+ updateSystem.UpdateAfter(SystemUpdatePhase.ToolUpdate);
updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate);
updateSystem.UpdateAfter(SystemUpdatePhase.ModificationEnd);
updateSystem.UpdateAfter(SystemUpdatePhase.GameSimulation);
+ updateSystem.UpdateAfter(SystemUpdatePhase.GameSimulation);
+ updateSystem.UpdateBefore(SystemUpdatePhase.GameSimulation);
+ updateSystem.UpdateBefore(SystemUpdatePhase.GameSimulation);
updateSystem.UpdateAfter(SystemUpdatePhase.GameSimulation);
}
diff --git a/Properties/PublishConfiguration.xml b/Properties/PublishConfiguration.xml
index b9e7c14..055900b 100644
--- a/Properties/PublishConfiguration.xml
+++ b/Properties/PublishConfiguration.xml
@@ -1,10 +1,51 @@
+
-
- RouteFilter lets Cities: Skylines II players restrict individual vehicle assets at a network node or across an entire road or rail segment. Matching vehicles request a different route when the restriction is detected ahead.
+
+ # RouteFilter
+
+RouteFilter gives Cities: Skylines II players precise control over which vehicle assets may use a selected road or rail node or an entire network segment.
+
+## Main features
+
+- Restrict exact road, tram, train, subway, carriage, trailer, and outside-traffic assets.
+- Keep an independent forbidden list on every selected node or segment.
+- Filter the catalog automatically for the selected road or rail target.
+- Search assets and inspect maximum speed, acceleration, and braking.
+- Group recognized engines and trailers while retaining individual control.
+- Request a new route through the native pathfinder when a matching vehicle approaches.
+- Stop matching vehicles when a fixed route has no valid alternative.
+- Save restrictions with the city and switch automatically between English and Simplified Chinese.
+
+## Basic use
+
+Open the RouteFilter panel from the top-left toolbar or press Ctrl+Shift+N. Select Node or Segment, left-click a network target, choose the assets that should be forbidden, then apply the list to the selected target. Right-click cancels target selection.
+
+Bulk allow/forbid actions edit only the pending list. They do not change the map until the explicit apply button is pressed.
+
+## Important notes
+
+Technical prefab names may not have localized display names. Rebuilding a network segment can replace its game entity and remove restrictions attached to the original. RouteFilter cannot redraw a fixed public-transport line; when no valid detour exists, the affected vehicle stops and may retry.
+
+RouteFilter is an independent GPL-3.0-only community project by Daotie. It is not affiliated with or endorsed by Colossal Order or Paradox Interactive.
+
+
+
+
+
+
-
-
- Added searchable, asset-level vehicle selection and a clean V1 save-data schema.
+
+
+ ## RouteFilter 1.0.0
+
+- First stable release.
+- Opens the panel and selection tool together; default shortcut is Ctrl+Shift+N.
+- Keeps the asset list and application controls visible in one viewport-aware panel.
+- Supports exact asset restrictions on road and rail nodes or full segments.
+- Matches complete recognized vehicle consists, fixed-route rail vehicles, and outside traffic.
+- Requests native pathfinding detours and stops matching vehicles when no alternative exists.
+- Preserves the asset-level V1 save schema used by the public beta.
+
diff --git a/Properties/PublishProfiles/PublishNewMod.pubxml b/Properties/PublishProfiles/PublishNewMod.pubxml
new file mode 100644
index 0000000..d2f4036
--- /dev/null
+++ b/Properties/PublishProfiles/PublishNewMod.pubxml
@@ -0,0 +1,12 @@
+
+
+
+ Release
+ Any CPU
+ FileSystem
+ <_TargetId>Folder
+ net48
+ bin\$(Configuration)\$(TargetFramework)\
+ Publish
+
+
diff --git a/Properties/PublishProfiles/PublishNewVersion.pubxml b/Properties/PublishProfiles/PublishNewVersion.pubxml
new file mode 100644
index 0000000..4fb7fb7
--- /dev/null
+++ b/Properties/PublishProfiles/PublishNewVersion.pubxml
@@ -0,0 +1,12 @@
+
+
+
+ Release
+ Any CPU
+ FileSystem
+ <_TargetId>Folder
+ net48
+ bin\$(Configuration)\$(TargetFramework)\
+ NewVersion
+
+
diff --git a/Properties/PublishProfiles/UpdateCurrentVersion.pubxml b/Properties/PublishProfiles/UpdateCurrentVersion.pubxml
new file mode 100644
index 0000000..197e251
--- /dev/null
+++ b/Properties/PublishProfiles/UpdateCurrentVersion.pubxml
@@ -0,0 +1,12 @@
+
+
+
+ Release
+ Any CPU
+ FileSystem
+ <_TargetId>Folder
+ net48
+ bin\$(Configuration)\$(TargetFramework)\
+ Update
+
+
diff --git a/Properties/Screenshots/README.md b/Properties/Screenshots/README.md
new file mode 100644
index 0000000..f9b7a3e
--- /dev/null
+++ b/Properties/Screenshots/README.md
@@ -0,0 +1,18 @@
+# Paradox Mods screenshots
+
+Before the first Paradox Mods submission, add public gameplay screenshots with these filenames:
+
+1. `01-target-selection.png` — highlighted node and segment selection.
+2. `02-asset-catalog.png` — compatible asset filtering, selection, and hover statistics.
+3. `03-apply-restriction.png` — target-specific pending list and apply controls.
+4. `04-rerouting-result.png` — a matching vehicle stopped or taking a valid detour.
+
+Recommended preparation:
+
+- Use the same current game and RouteFilter version for all screenshots.
+- Capture at a readable UI scale without unrelated debug overlays or personal account information.
+- Prefer 16:9 images and keep the RouteFilter UI legible after thumbnail scaling.
+- Do not imply that a fixed public-transport route is automatically redrawn.
+- After adding the files, uncomment the matching `` entries in `PublishConfiguration.xml`.
+
+README presentation copies belong under `assets/screenshots` using the same filenames.
diff --git a/Properties/Thumbnail.png b/Properties/Thumbnail.png
new file mode 100644
index 0000000..bf86dc0
Binary files /dev/null and b/Properties/Thumbnail.png differ
diff --git a/README.md b/README.md
index 6ae5d64..2122742 100644
--- a/README.md
+++ b/README.md
@@ -2,74 +2,123 @@
English · 简体中文
+
+
+
+
+
# RouteFilter
-Asset-level vehicle access restrictions for road and rail nodes or segments in **Cities: Skylines II**.
+Exact vehicle-asset access control for road and rail networks in **Cities: Skylines II**.
-
-
+
+

-Current beta: `0.4.0-beta.1`
+RouteFilter lets you decide which exact vehicle assets may pass through one network node or an entire road, tram, train, or subway segment. A matching vehicle is stopped before crossing the restricted target and is asked to find another route when the network provides one.
+
+## Highlights
+
+- Restrict individual vehicle assets instead of broad traffic categories.
+- Apply independent forbidden lists to specific nodes or complete network segments.
+- Support road vehicles, outside traffic, trams, trains, subway vehicles, engines, carriages, and recognized trailers.
+- Discover available base-game, content-pack, and custom vehicle prefabs automatically.
+- Filter the catalog to assets compatible with the selected road or rail target.
+- Search, paginate, and scroll the asset catalog without hiding the application controls.
+- Inspect an asset's maximum speed in km/h, acceleration, and braking on hover.
+- Group recognized engines or tractors with their carriages and trailers while retaining individual control.
+- Show clear node circles and segment outlines before selection, with a distinct selected-target highlight.
+- Load the saved forbidden list belonging to the selected target; pending changes never silently affect the whole map.
+- Request a detour through the game's pathfinding data and prevent a matching vehicle from simply driving through when no alternative exists.
+- Store restrictions in the savegame and follow the game's English or Simplified Chinese language automatically.
+
+## In-game overview
+
+### 1. Select a precise network target
+
+Choose **Node** or **Segment**, then left-click the highlighted target. Right-click cancels the selection. The panel opens together with the tool—there is no separate activation step.
+
+
+
+
+### 2. Choose exact vehicle assets
+
+Selected entries are the assets that will be **forbidden**. Road targets show road vehicles; rail targets show compatible rail assets. Hover an entry to inspect its base parameters. Expanding a recognized consist enables separate engine, carriage, or trailer choices.
+
+
+
+
+### 3. Review and apply
-RouteFilter adds a focused in-game tool for controlling which exact vehicle assets may use a selected network node or an entire road or rail segment. When a matching vehicle detects the restriction ahead, the mod promptly requests another route.
+**Forbid all assets** and **Allow all assets** only edit the pending list. Nothing is written to the map until **Apply list to selected target** is pressed. Selecting another target loads that target's own saved list. **Clear target restrictions** removes RouteFilter data from the selected target.
-> RouteFilter is beta software. Back up important saves and review the known limitations before testing.
+
+
-## Features
+### 4. Vehicle enforcement and rerouting
-- Restrict individual vehicle assets rather than broad traffic classes.
-- Discover base-game, content-pack, and available custom vehicle prefabs automatically.
-- Search the asset list by prefab name and select any combination.
-- Apply restrictions to one node or a complete road, tram, train, or subway segment.
-- Request an alternate route as soon as a matching vehicle detects the restriction.
-- Use a visible top-left panel; the keyboard shortcut remains optional.
-- Follow the game's English or Simplified Chinese language setting automatically.
-- Store the selected prefab references with each restricted target in the savegame.
-- Configure emergency-vehicle exemptions and route look-ahead distance.
+RouteFilter checks current lanes, upcoming navigation lanes, path elements, node endpoints, and every recognized prefab in a vehicle consist. For a matching vehicle, it briefly marks the target unavailable to the pathfinder and invalidates that vehicle's current path. If a valid alternative exists, the vehicle can reroute; otherwise it is prevented from continuing normally through the restricted target and may retry.
+
+
+
## Installation
-1. Download the latest package from [GitHub Releases](https://github.com/Daotie/CS2-RouteFilter/releases).
-2. Extract the package into the local Cities: Skylines II mods directory.
-3. Enable **RouteFilter** in the active playset and restart the game when prompted.
-4. Test with a backed-up save first.
+### Paradox Mods
+
+Subscribe to **RouteFilter** on Paradox Mods and add it to the active playset once the listing is available. Restart the game if the playset requests it.
+
+### Manual installation
-## Usage
+1. Download the latest archive from [GitHub Releases](https://github.com/Daotie/CS2-RouteFilter/releases).
+2. Extract it into the local Cities: Skylines II mods directory.
+3. Enable **RouteFilter** in the active playset.
+4. Restart the game before replacing an older DLL.
-1. Open **RouteFilter** from the top-left game toolbar.
+Back up important cities before changing any code-mod setup.
+
+## Quick start
+
+1. Click the RouteFilter button in the top-left toolbar, or press `Ctrl+Shift+N`.
2. Choose **Node** or **Segment**.
-3. Search for and select one or more vehicle assets.
-4. Left-click a target to replace its restriction list with the current selection.
-5. Right-click a target to clear its RouteFilter restriction.
+3. Left-click the network target to edit.
+4. Select the vehicle assets that should be forbidden.
+5. Press **Apply list to selected target**.
+6. Re-select the target at any time to review or change its saved list.
-The panel can refresh its asset catalog after content changes. `Ctrl+Shift+X` is available as an optional tool toggle.
+The shortcut is remappable in the game's settings. Closing the panel also closes the selection tool.
## Configuration
| Setting | Default | Purpose |
| --- | --- | --- |
-| Asset-level restrictions | On | Enforces exact prefab matches at nodes and segments. |
-| Emergency vehicle protection | Off | Exempts police, ambulance, and fire vehicles even when selected. |
-| Look-ahead lanes | 3 | Controls how early an approaching vehicle checks for restrictions. |
+| Asset-level restrictions | On | Enables exact prefab matching at restricted nodes and segments. |
+| Emergency vehicle protection | Off | Allows police cars, ambulances, and fire engines even if their assets are selected. |
+| Look-ahead lanes | 3 | Controls how early an approaching vehicle checks for a restriction. |
+| Show/hide panel | `Ctrl+Shift+N` | Opens or closes both the RouteFilter panel and selection tool. |
+
+## Save data and upgrades
-## Rerouting behavior
+RouteFilter `1.0.0` uses the asset-level V1 save schema introduced during the public beta. Each restricted target stores exact prefab entity references. Saves created with `0.4.0-beta.1` or `0.5.0-beta.1` remain on the same schema.
-For a matching vehicle, RouteFilter briefly presents the selected node or segment as unavailable to the pathfinder, invalidates that vehicle's current path, and removes the temporary barrier after the route request finishes. Concurrent requests are reference-counted.
+Rebuilding, replacing, or deleting a road or track segment creates new game entities and may remove restrictions attached to the original target. Review restrictions after substantial network reconstruction.
-This prompts the affected vehicle to seek a detour, but it cannot guarantee that a valid alternate route exists or that the game will complete pathfinding within a fixed time.
+## Important behavior and limitations
-## Save data
+- Asset names are technical prefab names supplied by the game or asset author; a localized display name may not exist.
+- Engine/carriage grouping appears only where the game exposes a fixed-trailer or multiple-unit relationship.
+- A fixed public-transport route cannot always be changed into a valid detour. When no alternative exists, the affected vehicle is stopped and may retry; RouteFilter does not redraw the player's transport line.
+- During the short pathfinding barrier window, another vehicle requesting a route may also avoid the selected target.
+- Compatibility with mods that replace vehicle navigation, pathfinding, or network entities cannot be guaranteed. Report conflicts with a minimal playset and logs.
-Version `0.4.0-beta.1` uses RouteFilter's asset-level V1 save schema. Each restricted target stores prefab entity references for the selected vehicle assets. Start testing this beta on a new city or a clearly identified backup branch of an existing save.
+## Compatibility and support
-## Known limitations
+RouteFilter uses the official Cities: Skylines II code-mod toolchain and does not require a separate framework mod. For problems, first reproduce with the latest release and the smallest practical playset, then include the game version, RouteFilter version, reproduction steps, active traffic/network mods, and `Player.log`.
-- Prefab names are the technical asset names supplied by the game or asset author; localized display names are not always available.
-- A different vehicle requesting a path during the short temporary-barrier window may also avoid the target.
-- If no valid alternative exists, the affected vehicle may retry until access becomes possible or the restriction is removed.
-- Rebuilding or replacing a network segment may remove the restriction associated with the original entity.
-- Runtime behavior, save/reload, performance, and mod compatibility still require broader in-game testing before a stable release.
+- Help and bug reports: [SUPPORT.md](SUPPORT.md)
+- Security disclosures: [SECURITY.md](SECURITY.md)
+- Release history: [CHANGELOG.md](CHANGELOG.md)
+- Contribution guide: [CONTRIBUTING.md](CONTRIBUTING.md)
## Development
@@ -84,15 +133,9 @@ npm run build
npm audit --omit=dev
```
-See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution standards and [RELEASING.md](RELEASING.md) for the release checklist.
-
-## Support and security
-
-- Usage help and bug reports: [SUPPORT.md](SUPPORT.md)
-- Security disclosures: [SECURITY.md](SECURITY.md)
-- Release history: [CHANGELOG.md](CHANGELOG.md)
+The official Paradox Mods metadata and publish profiles are stored under `Properties`. See [RELEASING.md](RELEASING.md) for the complete release checklist.
-## License
+## License and attribution
Copyright © 2026 Daotie. RouteFilter is licensed under the [GNU General Public License v3.0 only](LICENSE).
diff --git a/README.zh-CN.md b/README.zh-CN.md
index b557d1d..bd3aba7 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -2,74 +2,123 @@
English · 简体中文
+
+
+
+
+
# RouteFilter 路线通行筛选
-为 **《都市:天际线 II》** 的道路与轨道节点或整段路段提供逐车辆资产通行限制。
+为 **《都市:天际线 II》** 道路与轨道路网提供精确到车辆资产的通行控制。
-
-
+
+

-当前测试版本:`0.4.0-beta.1`
+RouteFilter 可控制具体车辆资产能否通过某个路网节点或整段道路、有轨电车轨道、铁路、地铁线路。匹配车辆会在穿过受限目标前被拦截;当路网存在可行替代路线时,模组会请求重新寻路。
+
+## 主要功能
-RouteFilter 提供专用的游戏内工具,可控制具体车辆资产是否能够通过选定的路网节点或整段道路、轨道。匹配车辆在前方检测到限制后,模组会立即请求其他路线。
+- 按单个车辆资产限制通行,而非仅按宽泛交通类别处理。
+- 为指定节点或完整路段分别保存独立的禁行清单。
+- 支持道路车辆、过境交通、有轨电车、火车、地铁、车头、车厢及已识别挂车。
+- 自动发现本体、内容包及当前可用的自定义车辆预制资产。
+- 根据所选道路或轨道目标,仅显示兼容的车辆资产。
+- 支持资产搜索、分页和列表滚动,应用按钮始终保留在同一页面。
+- 悬浮资产时查看以 km/h 显示的最高速度、加速度和制动减速度。
+- 对已识别车头或牵引车辆分组显示车厢、挂车,并支持逐项控制。
+- 节点和路段具有清晰的悬浮线框及独立的选中高亮。
+- 切换目标时读取该目标自己的禁行清单,待应用修改不会静默作用于全图。
+- 将匹配车辆的重新寻路请求接入游戏寻路数据;没有替代路线时,车辆也不能直接穿过受限目标。
+- 限制数据随存档保存,并根据游戏语言自动切换英文或简体中文。
-> RouteFilter 仍处于 Beta 阶段。测试前请备份重要存档,并阅读已知限制。
+## 游戏内功能介绍
-## 功能
+### 1. 精确选择路网目标
-- 按单个车辆资产限制通行,而非仅按宽泛交通类别。
-- 自动发现本体、内容包及当前可用的自定义车辆预制资产。
-- 按预制资产名称搜索,并任意组合选择。
-- 对单个节点或整段道路、有轨电车、铁路、地铁路段应用限制。
-- 匹配车辆检测到限制后立即请求替代路线。
-- 提供左上角可见面板;键盘快捷键仅为可选操作。
-- 根据游戏语言自动切换英文或简体中文。
-- 在存档中随每个限制目标保存所选预制资产引用。
-- 可配置应急车辆豁免及路线前瞻距离。
+选择“节点”或“整段路段”,然后左键单击高亮目标;右键可取消选择。打开面板时选择工具会同步启用,不再需要额外点击“打开工具”。
+
+
+
+
+### 2. 选择具体车辆资产
+
+选中的条目就是将被**禁止通行**的资产。道路目标只显示道路车辆,轨道目标只显示兼容轨道资产。悬浮条目可查看基础参数;展开已识别编组后,可分别选择车头、车厢或挂车。
+
+
+
+
+### 3. 检查并应用
+
+“全部资产禁行”和“全部资产放行”只修改待应用清单。只有点击“应用到所选目标”后才会写入地图。切换目标时会加载该目标自己保存的清单;“清除目标限制”会移除所选目标上的 RouteFilter 数据。
+
+
+
+
+### 4. 车辆拦截与绕行
+
+RouteFilter 会检查当前车道、前方导航车道、底层路径元素、节点端点以及车辆编组内每个已识别预制资产。命中限制后,模组会短暂将目标标记为寻路不可用并使该车辆当前路径失效;存在替代路线时车辆可绕行,不存在时车辆会被阻止继续正常穿过,并可能稍后重试。
+
+
+
## 安装
+### Paradox Mods
+
+Paradox Mods 页面上线后,订阅 **RouteFilter** 并将其加入当前播放集;如果播放集提示重启,请重启游戏。
+
+### 手动安装
+
1. 从 [GitHub Releases](https://github.com/Daotie/CS2-RouteFilter/releases) 下载最新压缩包。
2. 解压到《都市:天际线 II》本地模组目录。
-3. 在当前播放集启用 **RouteFilter**,并在提示时重启游戏。
-4. 首次测试请使用已有备份的存档。
+3. 在当前播放集启用 **RouteFilter**。
+4. 替换旧 DLL 后请重启游戏。
+
+更改代码模组组合前,建议备份重要城市存档。
-## 使用方法
+## 快速使用
-1. 从游戏左上角工具栏打开 **RouteFilter**。
-2. 选择 **节点** 或 **整段路段**。
-3. 搜索并选择一个或多个车辆资产。
-4. 左键点击目标,以当前选择替换该目标的限制列表。
-5. 右键点击目标,清除其 RouteFilter 限制。
+1. 点击游戏左上角的 RouteFilter 按钮,或按 `Ctrl+Shift+N`。
+2. 选择“节点”或“整段路段”。
+3. 左键选择要编辑的路网目标。
+4. 选中需要禁止通行的车辆资产。
+5. 点击“应用到所选目标”。
+6. 随时重新选择目标以检查或修改其已保存清单。
-内容发生变化后可在面板内刷新资产目录。`Ctrl+Shift+X` 可作为可选的工具开关快捷键。
+快捷键可在游戏设置中重新绑定。关闭面板时,选择工具也会同步关闭。
## 可配置项
| 设置 | 默认值 | 用途 |
| --- | --- | --- |
-| 逐资产限制 | 开启 | 在节点及路段按准确预制资产匹配执行限制。 |
-| 应急车辆保护 | 关闭 | 即使选中了对应资产,也始终放行警车、救护车和消防车。 |
-| 前瞻车道数 | 3 | 控制车辆提前检测限制的距离。 |
+| 逐资产限制 | 开启 | 在受限节点或路段按准确预制资产匹配执行限制。 |
+| 应急车辆保护 | 关闭 | 即使选中了对应资产,也放行警车、救护车和消防车。 |
+| 前瞻车道数 | 3 | 控制车辆提前多少段导航车道检测限制。 |
+| 显示或隐藏面板 | `Ctrl+Shift+N` | 同时打开或关闭 RouteFilter 面板及选择工具。 |
-## 绕行行为
+## 存档数据与升级
-对于匹配车辆,RouteFilter 会短暂地将目标节点或路段呈现为寻路不可用,使该车辆当前路径失效,并在路线请求结束后移除临时屏障。并发请求采用引用计数处理。
+RouteFilter `1.0.0` 使用公开测试阶段引入的逐资产 V1 存档结构。每个受限目标保存准确的车辆预制实体引用;使用 `0.4.0-beta.1` 或 `0.5.0-beta.1` 创建的限制沿用相同结构。
-该机制会促使受影响车辆寻找绕行路线,但无法保证替代路线一定存在,也无法保证游戏在固定时间内完成寻路。
+重建、替换或删除道路、轨道路段会使游戏创建新实体,与原目标关联的限制可能因此丢失。大规模改造路网后请重新检查限制。
-## 存档数据
+## 重要行为与已知限制
-`0.4.0-beta.1` 使用 RouteFilter 的逐资产 V1 存档结构。每个受限目标会保存所选车辆资产的预制实体引用。建议在新建城市或现有存档明确标记的备份分支上测试本 Beta。
+- 资产名称来自游戏或资产作者提供的技术名称,不一定存在本地化显示名。
+- 仅当游戏明确提供固定挂车或动车组关系时,界面才会显示车头—车厢分组。
+- 固定公共交通线路不一定存在可行绕行路线。没有替代路线时,受影响车辆会停车并可能重试;RouteFilter 不会自动重绘玩家创建的公交或轨道线路。
+- 在寻路屏障生效的短暂窗口内,其他同时请求路径的车辆也可能避开所选目标。
+- 无法保证兼容所有替换车辆导航、寻路或路网实体的模组。报告冲突时请使用最小播放集并附上日志。
-## 已知限制
+## 兼容性与支持
-- 资产名称来自游戏或资产作者提供的技术名称,不一定存在本地化显示名。
-- 其他车辆若恰好在临时屏障生效的短暂窗口内请求路径,也可能绕开该目标。
-- 如果不存在有效替代路线,车辆可能持续重试,直到目标恢复可用或限制被移除。
-- 重建或替换路段后,与原游戏实体关联的限制可能丢失。
-- 在稳定版发布前,仍需更广泛地验证游戏内行为、存读档、性能和模组兼容性。
+RouteFilter 使用《都市:天际线 II》官方代码模组工具链,无需额外框架模组。遇到问题时,请先使用最新版本及尽可能精简的播放集复现,并提供游戏版本、RouteFilter 版本、复现步骤、当前交通/路网模组和 `Player.log`。
+
+- 使用帮助与问题报告:[SUPPORT.md](SUPPORT.md)
+- 安全问题披露:[SECURITY.md](SECURITY.md)
+- 版本历史:[CHANGELOG.md](CHANGELOG.md)
+- 贡献规范:[CONTRIBUTING.md](CONTRIBUTING.md)
## 开发
@@ -84,15 +133,9 @@ npm run build
npm audit --omit=dev
```
-贡献规范见 [CONTRIBUTING.md](CONTRIBUTING.md),发布检查表见 [RELEASING.md](RELEASING.md)。
-
-## 支持与安全
-
-- 使用帮助与问题报告:[SUPPORT.md](SUPPORT.md)
-- 安全问题披露:[SECURITY.md](SECURITY.md)
-- 版本历史:[CHANGELOG.md](CHANGELOG.md)
+官方 Paradox Mods 元数据和发布配置档位于 `Properties`。完整发布检查表见 [RELEASING.md](RELEASING.md)。
-## 许可证
+## 许可证与归属说明
版权所有 © 2026 Daotie。RouteFilter 采用 [GNU General Public License v3.0 only](LICENSE) 许可。
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 8861260..fea84cf 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,17 +1,57 @@
-# RouteFilter 0.4.0-beta.1
+# RouteFilter 1.0.0
## English
-RouteFilter now provides asset-level access control. Its searchable in-game catalog discovers every currently available vehicle prefab and lets players restrict any combination at a node or across an entire road or rail segment.
+RouteFilter 1.0.0 is the first stable release of the exact vehicle-asset access tool for Cities: Skylines II road and rail networks.
-Restrictions use an explicit V1 save schema containing prefab entity references. A vehicle is affected only when its exact `PrefabRef` matches an entry saved on the target. Matching vehicles promptly request another route through RouteFilter's short-lived, reference-counted pathfinding barrier.
+### Player-facing changes
-This is a beta release. Start with a new city or a clearly identified save backup, then verify node restrictions, segment restrictions, asset matching, and save/reload behavior before using it on an important city.
+- Opening the RouteFilter panel now activates the selection tool immediately. The redundant in-panel tool activation button has been removed, and closing the panel closes the tool.
+- The default remappable panel shortcut is now `Ctrl+Shift+N`.
+- The panel uses viewport-aware top and bottom boundaries. The target selector, asset list, paging controls, and application buttons remain on one screen; only the asset list scrolls.
+- Maximum speed is displayed in km/h, with acceleration and braking available on asset hover.
+- Each selected node or segment loads its own saved forbidden list. Bulk selection changes remain pending until the player explicitly applies them to that target.
+- Road and rail catalogs are filtered by target compatibility, and recognized engines, carriages, and trailers can be controlled as a group or individually.
+
+### Restriction enforcement
+
+- Exact prefab matching covers the vehicle controller and all recognized assets in its consist.
+- Detection checks current lanes, upcoming navigation lanes, underlying path elements, and restricted node endpoints.
+- Enforcement runs after road and rail navigation updates but before vehicle movement.
+- Matching vehicles request a new path while the selected target is temporarily presented as unavailable to the native pathfinder.
+- If a fixed transit route or disconnected network offers no valid detour, the matching vehicle is stopped and may retry instead of continuing normally through the restriction.
+- Outside traffic is evaluated through the same exact-asset path.
+
+### Save compatibility
+
+Version 1.0.0 retains the asset-level V1 save schema used by `0.4.0-beta.1` and `0.5.0-beta.1`. Existing restrictions created by those RouteFilter versions remain structurally compatible.
+
+Please report reproducible compatibility issues with the game version, active traffic/network mods, reproduction steps, and `Player.log`.
## 中文
-RouteFilter 现已支持逐资产通行控制。游戏内可搜索目录会发现当前全部可用车辆预制资产,玩家可任意组合选择,并对节点或整段道路、轨道路段设置限制。
+RouteFilter 1.0.0 是首个正式版本,为《都市:天际线 II》道路与轨道路网提供精确到车辆资产的通行控制。
+
+### 玩家可见改动
+
+- 打开 RouteFilter 面板时会立即启用选择工具,不再显示重复的“打开工具”按钮;关闭面板时工具也会关闭。
+- 默认且可重新绑定的面板快捷键改为 `Ctrl+Shift+N`。
+- 面板根据视口上下边界自适应,目标选择、资产列表、分页和应用按钮保持在同一页面,只有资产列表内部滚动。
+- 最高速度以 km/h 显示,悬浮资产时同时显示加速度和制动减速度。
+- 每个节点或路段会加载自己保存的禁行清单;批量选择仅修改待应用清单,明确点击应用后才写入目标。
+- 道路与轨道资产会按目标兼容性筛选,已识别车头、车厢和挂车可分组或逐项控制。
+
+### 禁行执行
+
+- 准确预制资产匹配覆盖车辆控制实体及编组内所有已识别资产。
+- 检测覆盖当前车道、前方导航车道、底层路径元素和受限节点端点。
+- 执行顺序位于道路、轨道导航更新之后以及车辆实际移动之前。
+- 命中车辆请求新路径时,目标会短暂以不可用状态提供给原生寻路系统。
+- 固定公共交通线路或断开路网没有有效绕行路线时,匹配车辆会停车并可能重试,而不是继续正常穿过限制。
+- 过境交通使用相同的逐资产检测链路。
+
+### 存档兼容
-限制数据采用明确的 V1 存档结构并保存预制实体引用。只有车辆的准确 `PrefabRef` 与目标上保存的条目一致时才会受限。匹配车辆会通过 RouteFilter 的短时引用计数寻路屏障立即请求其他路线。
+1.0.0 保留 `0.4.0-beta.1` 与 `0.5.0-beta.1` 使用的逐资产 V1 存档结构,这些 RouteFilter 版本创建的限制在结构上保持兼容。
-本版本仍处于 Beta 阶段。请从新建城市或明确标记的存档备份开始测试,并在重要城市中使用前验证节点限制、路段限制、资产匹配及存读档行为。
+报告可复现的兼容性问题时,请提供游戏版本、当前交通/路网模组、复现步骤和 `Player.log`。
diff --git a/RELEASING.md b/RELEASING.md
index 5d34e65..5f25fe4 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,13 +1,25 @@
# Release Process / 发布流程
-1. Confirm the target version follows Semantic Versioning prerelease conventions.
-2. Update `VERSION`, `Mod.cs`, `RouteFilter.csproj`, `UI/mod.json`, `UI/package.json`, `UI/package-lock.json`, publish metadata, both README badges, changelog, and release notes.
-3. Build C# in Release configuration with zero warnings and errors.
-4. Run `npm ci`, `npm run build`, and `npm audit --omit=dev` in `UI`.
-5. Test tool activation, asset-catalog discovery and search, exact prefab matching, node and segment restrictions, alternate-route requests, save/reload, restriction clearing, language switching, and behavior without an available detour.
-6. Build the distribution archive and record its SHA-256 digest.
-7. Create a signed Conventional Commit and signed version tag.
-8. Publish a GitHub prerelease for beta versions. Keep the repository private until the first stable release passes gameplay and save-compatibility testing.
-9. For the first stable release, confirm documentation and assets contain no beta warnings that are no longer applicable, then change repository visibility to public.
-
-中文发布要求与上述步骤一致:版本统一、C#/UI/依赖检查、游戏内功能和存档测试、发行包哈希、签名提交与标签、Beta 预发行,以及正式版通过验证后再公开仓库。
+## Stable and maintenance releases
+
+1. Choose a Semantic Versioning release number and write player-facing English and Simplified Chinese notes.
+2. Update `VERSION`, `Mod.cs`, `RouteFilter.csproj`, UI package metadata, Paradox Mods metadata, README badges, changelog, and release notes.
+3. Confirm the asset-level save schema remains compatible or document a migration before release.
+4. Build C# in Release configuration with zero warnings and errors.
+5. Run `npm ci`, `npm run build`, and `npm audit --omit=dev` in `UI`.
+6. Verify panel/shortcut synchronization, responsive layout, target selection, target-specific list loading, exact asset matching, road and rail enforcement, outside traffic, fixed-route behavior, restriction clearing, save/reload, and both supported UI languages.
+7. Build the distribution archive and record its SHA-256 digest.
+8. Create a signed Conventional Commit and signed version tag.
+9. Publish the GitHub release with the matching archive and notes, then verify repository visibility and automated checks.
+10. For Paradox Mods, provide the final thumbnail and screenshots, validate `Properties/PublishConfiguration.xml`, and use the appropriate profile under `Properties/PublishProfiles`.
+11. After the first Paradox Mods submission, store the returned `ModId` in the publish configuration. Use `PublishNewVersion` for later releases and `UpdateCurrentVersion` only to correct metadata or files without creating a new public version.
+
+The local official publisher targets .NET 6. If only a newer compatible .NET runtime is installed, set `$env:DOTNET_ROLL_FORWARD = "Major"` for the publishing process. Build the C# deploy output first, build the UI into the same local mod content folder second, then invoke the selected publish profile so both DLL and UI files are present.
+
+## Prereleases
+
+Use one new prerelease version for each externally tested correction. Do not replace an already published prerelease archive under the same version number. Mark GitHub prereleases clearly and keep historical notes in `CHANGELOG.md`.
+
+## 中文要求
+
+正式版与维护版必须统一版本号、双语公开说明、存档兼容策略、C#/UI/依赖检查、游戏内道路与轨道验证、发行包哈希、签名提交和标签。Paradox Mods 首次提交前必须准备最终缩略图与游戏内截图;首次提交取得 `ModId` 后,将其写回发布配置。对外测试修复应一次使用一个新的预发行版本号,不覆盖已经发布的同版本压缩包。
diff --git a/RouteFilter.csproj b/RouteFilter.csproj
index 574086a..93f1350 100644
--- a/RouteFilter.csproj
+++ b/RouteFilter.csproj
@@ -3,9 +3,9 @@
Debug;Release
RouteFilter
RouteFilter
- 0.4.0-beta.1
- 0.4.0.0
- 0.4.0.0
+ 1.0.0
+ 1.0.0.0
+ 1.0.0.0
Daotie
Asset-level vehicle access restrictions for road and rail nodes or segments in Cities: Skylines II.
RouteFilter
@@ -14,6 +14,8 @@
9.0
$(CSII_TOOLPATH)
D:\SteamLibrary\steamapps\common\Cities Skylines II\Cities2_Data\Content\Game\.ModdingToolchain
+ $(ToolchainPath)\ModPublisher\ModPublisher.exe
+ D:\SteamLibrary\steamapps\common\Cities Skylines II\Cities2_Data\Content\Game\.ModdingToolchain\ModPublisher\ModPublisher.exe
D:\SteamLibrary\steamapps\common\Cities Skylines II\Cities2_Data\Managed
$(ManagedPath)\mscorlib.dll
true
diff --git a/SECURITY.md b/SECURITY.md
index dc9bd0c..ad7cb9d 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,12 +2,12 @@
## Supported versions
-Only the latest published beta or stable release receives security fixes. Older prereleases are unsupported.
+Only the latest stable release receives security fixes. Prereleases and superseded versions are unsupported.
## Reporting a vulnerability
-Do not disclose exploitable vulnerabilities in a public issue. While the repository is private, contact the maintainer privately through the `Daotie` GitHub profile. After the repository becomes public and GitHub private vulnerability reporting is enabled, use that channel instead. Include the affected version, impact, reproduction steps, and any suggested mitigation. You should receive an acknowledgement within seven days.
+Do not disclose exploitable vulnerabilities in a public issue. Use GitHub private vulnerability reporting when available, or contact the maintainer privately through the `Daotie` GitHub profile. Include the affected version, impact, reproduction steps, and any suggested mitigation. You should receive an acknowledgement within seven days.
## 安全问题报告
-请勿在公开 Issue 中披露可利用的安全漏洞。仓库处于 Private 期间,请通过 `Daotie` 的 GitHub 主页私下联系维护者;未来仓库公开并启用 GitHub 私密漏洞报告后,请改用该渠道。请提供受影响版本、影响范围、复现步骤和可行的缓解建议。通常会在七天内确认收到。
+请勿在公开 Issue 中披露可利用的安全漏洞。优先使用 GitHub 私密漏洞报告;该功能不可用时,请通过 `Daotie` 的 GitHub 主页私下联系维护者。请提供受影响版本、影响范围、复现步骤和可行的缓解建议。通常会在七天内确认收到。
diff --git a/Setting.cs b/Setting.cs
index 11c00f4..138b5d9 100644
--- a/Setting.cs
+++ b/Setting.cs
@@ -28,7 +28,7 @@ public sealed class Setting : ModSetting
[SettingsUISlider(min = 1, max = 8, step = 1, scalarMultiplier = 1, unit = Game.UI.Unit.kInteger)]
public int LookAheadLanes { get; set; }
- [SettingsUIKeyboardBinding(BindingKeyboard.X, Mod.ToggleToolAction, ctrl: true, shift: true)]
+ [SettingsUIKeyboardBinding(BindingKeyboard.N, Mod.ToggleToolAction, ctrl: true, shift: true)]
[SettingsUISection(kSection, kToolGroup)]
public ProxyBinding ToggleToolBinding { get; set; }
@@ -66,25 +66,37 @@ public IEnumerable> ReadEntries(IList();
+ m_Tool = World.GetOrCreateSystemManaged();
+ m_Overlay = World.GetOrCreateSystemManaged();
+ }
+
+ protected override void OnUpdate()
+ {
+ if (m_ToolSystem.activeTool != m_Tool) return;
+ if (m_Tool.SelectedTarget == m_Tool.HoveredTarget)
+ Draw(m_Tool.SelectedTarget, true);
+ else
+ {
+ Draw(m_Tool.HoveredTarget, false);
+ Draw(m_Tool.SelectedTarget, true);
+ }
+ }
+
+ private void Draw(Unity.Entities.Entity target, bool selected)
+ {
+ if (target == Unity.Entities.Entity.Null) return;
+
+ var isNode = EntityManager.TryGetComponent(target, out Node node);
+ var hasCurve = EntityManager.TryGetComponent(target, out Curve curve);
+ if (!isNode && !hasCurve) return;
+
+ var buffer = m_Overlay.GetBuffer(out var dependencies);
+ var job = new DrawTargetJob
+ {
+ Buffer = buffer,
+ IsNode = isNode,
+ Position = isNode ? node.m_Position + new float3(0f, 0.35f, 0f) : default,
+ Curve = hasCurve ? curve.m_Bezier : default,
+ Selected = selected
+ };
+ Dependency = job.Schedule(JobHandle.CombineDependencies(Dependency, dependencies));
+ m_Overlay.AddBufferWriter(Dependency);
+ }
+}
diff --git a/Systems/RestrictionShortcutSystem.cs b/Systems/RestrictionShortcutSystem.cs
index dd55cb2..6007f5b 100644
--- a/Systems/RestrictionShortcutSystem.cs
+++ b/Systems/RestrictionShortcutSystem.cs
@@ -1,20 +1,14 @@
using Game;
-using Game.Tools;
-using Unity.Entities;
namespace RouteFilter.Systems;
public sealed partial class RestrictionShortcutSystem : GameSystemBase
{
- private ToolSystem m_ToolSystem = null!;
- private DefaultToolSystem m_DefaultToolSystem = null!;
private RestrictionToolSystem m_RestrictionToolSystem = null!;
protected override void OnCreate()
{
base.OnCreate();
- m_ToolSystem = World.GetOrCreateSystemManaged();
- m_DefaultToolSystem = World.GetOrCreateSystemManaged();
m_RestrictionToolSystem = World.GetOrCreateSystemManaged();
}
@@ -23,9 +17,6 @@ protected override void OnUpdate()
if (!Mod.ToggleTool.WasPressedThisFrame())
return;
- m_ToolSystem.selected = Entity.Null;
- m_ToolSystem.activeTool = m_ToolSystem.activeTool == m_RestrictionToolSystem
- ? m_DefaultToolSystem
- : m_RestrictionToolSystem;
+ m_RestrictionToolSystem.Toggle();
}
}
diff --git a/Systems/RestrictionToolSystem.cs b/Systems/RestrictionToolSystem.cs
index 9151705..22c3bd6 100644
--- a/Systems/RestrictionToolSystem.cs
+++ b/Systems/RestrictionToolSystem.cs
@@ -14,6 +14,12 @@ namespace RouteFilter.Systems;
public sealed partial class RestrictionToolSystem : ToolBaseSystem
{
+ public Entity HoveredTarget { get; private set; } = Entity.Null;
+ public int HoveredTransportMode { get; private set; }
+ public Entity SelectedTarget { get; private set; } = Entity.Null;
+ public int SelectedTransportMode { get; private set; }
+ public bool PointerOverUi { get; private set; }
+
public override string toolID => "RouteFilterTool";
public override bool allowUnderground => true;
@@ -24,6 +30,9 @@ public void Toggle()
{
if (m_ToolSystem.activeTool == this)
{
+ HoveredTarget = Entity.Null;
+ HoveredTransportMode = 0;
+ ClearSelection();
m_ToolSystem.selected = Entity.Null;
m_ToolSystem.activeTool = m_DefaultToolSystem;
}
@@ -45,34 +54,48 @@ public override void InitializeRaycast()
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
+ if (SelectedTarget != Entity.Null && !EntityManager.Exists(SelectedTarget)) ClearSelection();
+
+ if (PointerOverUi)
+ {
+ HoveredTarget = Entity.Null;
+ HoveredTransportMode = 0;
+ m_ToolSystem.selected = SelectedTarget;
+ return inputDeps;
+ }
+
if (!GetRaycastResult(out Entity entity, out RaycastHit hit))
{
- m_ToolSystem.selected = Entity.Null;
+ HoveredTarget = Entity.Null;
+ HoveredTransportMode = 0;
+ m_ToolSystem.selected = SelectedTarget;
+ if (Mod.Clear.WasPressedThisFrame()) ClearSelection();
return inputDeps;
}
var target = ResolveTarget(entity, hit.m_HitPosition);
- m_ToolSystem.selected = target;
- if (target == Entity.Null)
+ HoveredTarget = target;
+ HoveredTransportMode = GetTransportMode(target);
+ m_ToolSystem.selected = SelectedTarget != Entity.Null ? SelectedTarget : target;
+ if (Mod.Clear.WasPressedThisFrame())
+ {
+ ClearSelection();
return inputDeps;
-
- if (Mod.Apply.WasPressedThisFrame())
- SetRestriction(target, Mod.SelectedVehicleAssets);
- else if (Mod.Clear.WasPressedThisFrame())
- ClearRestriction(target);
+ }
+ if (target != Entity.Null && Mod.Apply.WasPressedThisFrame()) SelectTarget(target);
return inputDeps;
}
private Entity ResolveTarget(Entity entity, float3 hitPosition)
{
- if (Mod.SelectedTargetMode == RestrictionTargetMode.Segment)
- return EntityManager.HasComponent(entity) ? entity : Entity.Null;
+ var edgeEntity = FindOwningEdge(entity);
+ if (Mod.SelectedTargetMode == RestrictionTargetMode.Segment) return edgeEntity;
if (EntityManager.HasComponent(entity))
return entity;
- if (!EntityManager.TryGetComponent(entity, out Edge edge))
+ if (edgeEntity == Entity.Null || !EntityManager.TryGetComponent(edgeEntity, out Edge edge))
return Entity.Null;
if (!EntityManager.TryGetComponent(edge.m_Start, out Node start) ||
@@ -84,18 +107,78 @@ private Entity ResolveTarget(Entity entity, float3 hitPosition)
: edge.m_End;
}
+ private Entity FindOwningEdge(Entity entity)
+ {
+ var current = entity;
+ for (var depth = 0; depth < 8 && current != Entity.Null; depth++)
+ {
+ if (EntityManager.HasComponent(current)) return current;
+ if (!EntityManager.TryGetComponent(current, out Owner owner) || owner.m_Owner == current) break;
+ current = owner.m_Owner;
+ }
+ return Entity.Null;
+ }
+
+ public void SetPointerOverUi(bool value) => PointerOverUi = value;
+
+ public void SelectTarget(Entity target)
+ {
+ if (target == Entity.Null) return;
+ SelectedTarget = target;
+ SelectedTransportMode = GetTransportMode(target);
+ m_ToolSystem.selected = target;
+ Mod.Log.Info($"Selected {(EntityManager.HasComponent(target) ? "node" : "segment")} {target.Index}:{target.Version}");
+ }
+
+ public void ClearSelection()
+ {
+ SelectedTarget = Entity.Null;
+ SelectedTransportMode = 0;
+ m_ToolSystem.selected = Entity.Null;
+ }
+
+ public void ApplySelection()
+ {
+ if (SelectedTarget == Entity.Null)
+ {
+ Mod.Log.Warn("Apply ignored: no node or segment selected");
+ return;
+ }
+ SetRestriction(SelectedTarget, Mod.SelectedVehicleAssets);
+ }
+
+ public void ClearSelectedRestriction()
+ {
+ if (SelectedTarget == Entity.Null) return;
+ ClearRestriction(SelectedTarget);
+ Mod.Log.Info($"Restrictions cleared from {SelectedTarget.Index}:{SelectedTarget.Version}");
+ }
+
public void SetRestriction(Entity target, IReadOnlyCollection vehicleAssets)
{
var isNode = EntityManager.HasComponent(target);
var isSegment = EntityManager.HasComponent(target);
if (!isNode && !isSegment) return;
- if (vehicleAssets.Count == 0) ClearRestriction(target);
- else if (isNode) SetNodeRestriction(target, vehicleAssets);
- else SetSegmentRestriction(target, vehicleAssets);
+ var transportMode = GetTransportMode(target);
+ var compatibleAssets = new List();
+ foreach (var asset in vehicleAssets)
+ {
+ if ((transportMode & 1) != 0 && EntityManager.HasComponent(asset)) compatibleAssets.Add(asset);
+ else if ((transportMode & 2) != 0 && EntityManager.HasComponent(asset)) compatibleAssets.Add(asset);
+ }
+ if (compatibleAssets.Count == 0)
+ {
+ ClearRestriction(target);
+ Mod.Log.Info($"{(isNode ? "Node" : "Segment")} {target.Index}:{target.Version} set to allow all compatible vehicle assets");
+ return;
+ }
+
+ if (isNode) SetNodeRestriction(target, compatibleAssets);
+ else SetSegmentRestriction(target, compatibleAssets);
MarkTargetLanesUpdated(target);
- Mod.Log.Info($"{(isNode ? "Node" : "Segment")} {target.Index}:{target.Version} restrictions set to {vehicleAssets.Count} vehicle assets");
+ Mod.Log.Info($"{(isNode ? "Node" : "Segment")} {target.Index}:{target.Version} forbidden list set to {compatibleAssets.Count} compatible vehicle assets");
}
public void ClearRestriction(Entity target)
@@ -137,4 +220,25 @@ private void MarkTargetLanesUpdated(Entity target)
if (!EntityManager.HasComponent(subLane.m_SubLane))
EntityManager.AddComponent(subLane.m_SubLane);
}
+
+ public int GetTransportMode(Entity target)
+ {
+ if (target == Entity.Null) return 0;
+ var mode = GetLaneMode(target);
+ if (mode != 0 || !EntityManager.TryGetBuffer(target, true, out DynamicBuffer edges)) return mode;
+ foreach (var edge in edges) mode |= GetLaneMode(edge.m_Edge);
+ return mode;
+ }
+
+ private int GetLaneMode(Entity target)
+ {
+ var mode = 0;
+ if (!EntityManager.TryGetBuffer(target, true, out DynamicBuffer lanes)) return mode;
+ foreach (var subLane in lanes)
+ {
+ if (EntityManager.HasComponent(subLane.m_SubLane)) mode |= 1;
+ if (EntityManager.HasComponent(subLane.m_SubLane)) mode |= 2;
+ }
+ return mode;
+ }
}
diff --git a/Systems/RouteFilterUISystem.cs b/Systems/RouteFilterUISystem.cs
index 96fc9d4..e065863 100644
--- a/Systems/RouteFilterUISystem.cs
+++ b/Systems/RouteFilterUISystem.cs
@@ -1,11 +1,12 @@
using Colossal.UI.Binding;
+using Colossal.Entities;
using Game;
using Game.Prefabs;
using Game.Tools;
using Game.UI;
-using RouteFilter.Components;
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using Unity.Collections;
using Unity.Entities;
@@ -14,13 +15,33 @@ namespace RouteFilter.Systems;
public sealed partial class RouteFilterUISystem : UISystemBase
{
+ private sealed class AssetInfo
+ {
+ public Entity Entity;
+ public string Name = string.Empty;
+ public int Mode;
+ public float MaxSpeed;
+ public float Acceleration;
+ public float Braking;
+ public Entity Parent;
+ public bool IsTrailer;
+ }
+
private ToolSystem m_ToolSystem = null!;
private RestrictionToolSystem m_RestrictionTool = null!;
private PrefabSystem m_PrefabSystem = null!;
private EntityQuery m_VehiclePrefabQuery;
private readonly Dictionary m_AssetsById = new();
private readonly Dictionary m_IdsByAsset = new();
- private string m_AssetCatalog = string.Empty;
+ private readonly Dictionary m_ModeByAsset = new();
+ private readonly Dictionary> m_ChildrenByAsset = new();
+ private ValueBinding m_ToolActiveBinding = null!;
+ private ValueBinding m_TargetModeBinding = null!;
+ private ValueBinding m_TargetTransportBinding = null!;
+ private ValueBinding m_SelectedTargetKindBinding = null!;
+ private ValueBinding m_AssetCatalogBinding = null!;
+ private ValueBinding m_SelectedAssetsBinding = null!;
+ private Entity m_LastSelectedTarget = Entity.Null;
public override GameMode gameMode => GameMode.GameOrEditor;
@@ -31,69 +52,214 @@ protected override void OnCreate()
m_RestrictionTool = World.GetOrCreateSystemManaged();
m_PrefabSystem = World.GetOrCreateSystemManaged();
m_VehiclePrefabQuery = GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly());
- AddUpdateBinding(new GetterValueBinding(Mod.Id, "toolActive", () => m_ToolSystem.activeTool == m_RestrictionTool));
- AddUpdateBinding(new GetterValueBinding(Mod.Id, "targetMode", () => (int)Mod.SelectedTargetMode));
- AddUpdateBinding(new GetterValueBinding(Mod.Id, "assetCatalog", GetAssetCatalog));
- AddUpdateBinding(new GetterValueBinding(Mod.Id, "selectedAssetIds", GetSelectedAssetIds));
- AddBinding(new TriggerBinding(Mod.Id, "toggleTool", m_RestrictionTool.Toggle));
+
+ m_ToolActiveBinding = CreateValue("toolActive", false);
+ m_TargetModeBinding = CreateValue("targetMode", (int)Mod.SelectedTargetMode);
+ m_TargetTransportBinding = CreateValue("targetTransport", 0);
+ m_SelectedTargetKindBinding = CreateValue("selectedTargetKind", 0);
+ m_AssetCatalogBinding = CreateValue("assetCatalog", string.Empty);
+ m_SelectedAssetsBinding = CreateValue("selectedAssetIds", string.Empty);
+
+ AddBinding(new TriggerBinding(Mod.Id, "toggleTool", ToggleTool));
AddBinding(new TriggerBinding(Mod.Id, "toggleAsset", ToggleAsset));
- AddBinding(new TriggerBinding(Mod.Id, "setTargetMode", value => Mod.SelectedTargetMode = value == 1 ? RestrictionTargetMode.Segment : RestrictionTargetMode.Node));
- AddBinding(new TriggerBinding(Mod.Id, "selectAllAssets", SelectAllAssets));
- AddBinding(new TriggerBinding(Mod.Id, "selectNoAssets", Mod.SelectedVehicleAssets.Clear));
+ AddBinding(new TriggerBinding(Mod.Id, "toggleAssetGroup", ToggleAssetGroup));
+ AddBinding(new TriggerBinding(Mod.Id, "setTargetMode", SetTargetMode));
+ AddBinding(new TriggerBinding(Mod.Id, "selectAllAssets", SelectAllAssets));
+ AddBinding(new TriggerBinding(Mod.Id, "selectNoAssets", SelectNoAssets));
AddBinding(new TriggerBinding(Mod.Id, "refreshAssets", RefreshAssetCatalog));
+ AddBinding(new TriggerBinding(Mod.Id, "applySelection", m_RestrictionTool.ApplySelection));
+ AddBinding(new TriggerBinding(Mod.Id, "clearSelectedRestriction", m_RestrictionTool.ClearSelectedRestriction));
+ AddBinding(new TriggerBinding(Mod.Id, "cancelSelection", m_RestrictionTool.ClearSelection));
+ AddBinding(new TriggerBinding(Mod.Id, "setPointerOverUi", m_RestrictionTool.SetPointerOverUi));
+ }
+
+ protected override void OnUpdate()
+ {
+ if (m_AssetsById.Count == 0 && !m_VehiclePrefabQuery.IsEmptyIgnoreFilter) RefreshAssetCatalog();
+ m_ToolActiveBinding.Update(m_ToolSystem.activeTool == m_RestrictionTool);
+ m_TargetModeBinding.Update((int)Mod.SelectedTargetMode);
+ m_TargetTransportBinding.Update(m_RestrictionTool.SelectedTarget != Entity.Null
+ ? m_RestrictionTool.SelectedTransportMode
+ : m_RestrictionTool.HoveredTransportMode);
+ m_SelectedTargetKindBinding.Update(m_RestrictionTool.SelectedTarget == Entity.Null ? 0
+ : EntityManager.HasComponent(m_RestrictionTool.SelectedTarget) ? 1 : 2);
+ if (m_LastSelectedTarget != m_RestrictionTool.SelectedTarget)
+ {
+ m_LastSelectedTarget = m_RestrictionTool.SelectedTarget;
+ LoadSelectedTargetAssets(m_LastSelectedTarget);
+ }
+ base.OnUpdate();
}
- private string GetAssetCatalog()
+ private ValueBinding CreateValue(string key, T value)
{
- if (m_AssetCatalog.Length == 0) RefreshAssetCatalog();
- return m_AssetCatalog;
+ var binding = new ValueBinding(Mod.Id, key, value, null, EqualityComparer.Default);
+ AddBinding(binding);
+ return binding;
+ }
+
+ private void ToggleTool()
+ {
+ if (m_AssetsById.Count == 0) RefreshAssetCatalog();
+ m_RestrictionTool.Toggle();
+ }
+
+ private void SetTargetMode(int value)
+ {
+ Mod.SelectedTargetMode = value == 1 ? Components.RestrictionTargetMode.Segment : Components.RestrictionTargetMode.Node;
+ m_RestrictionTool.ClearSelection();
+ m_TargetModeBinding.Update((int)Mod.SelectedTargetMode);
}
private void RefreshAssetCatalog()
{
m_AssetsById.Clear();
m_IdsByAsset.Clear();
+ m_ModeByAsset.Clear();
+ m_ChildrenByAsset.Clear();
using var prefabEntities = m_VehiclePrefabQuery.ToEntityArray(Allocator.Temp);
- var assets = prefabEntities
- .Select(entity => (Entity: entity, Name: m_PrefabSystem.GetPrefabName(entity)))
- .Where(item => !string.IsNullOrWhiteSpace(item.Name))
- .OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
- .ToArray();
-
- var lines = new List(assets.Length);
- for (var i = 0; i < assets.Length; i++)
+ var assets = new Dictionary();
+ foreach (var entity in prefabEntities)
+ {
+ var name = m_PrefabSystem.GetPrefabName(entity);
+ if (string.IsNullOrWhiteSpace(name)) continue;
+ var info = new AssetInfo { Entity = entity, Name = name };
+ if (EntityManager.TryGetComponent(entity, out CarData car))
+ {
+ info.Mode = 1;
+ info.MaxSpeed = car.m_MaxSpeed / 2f * 3.6f;
+ info.Acceleration = car.m_Acceleration;
+ info.Braking = car.m_Braking;
+ }
+ else if (EntityManager.TryGetComponent(entity, out TrainData train))
+ {
+ info.Mode = 2;
+ info.MaxSpeed = train.m_MaxSpeed / 2f * 3.6f;
+ info.Acceleration = train.m_Acceleration;
+ info.Braking = train.m_Braking;
+ }
+ else continue;
+
+ if (EntityManager.TryGetComponent(entity, out CarTrailerData trailer))
+ {
+ info.IsTrailer = true;
+ info.Parent = trailer.m_FixedTractor;
+ }
+ if (EntityManager.HasComponent(entity)) info.IsTrailer = true;
+ assets[entity] = info;
+ }
+
+ foreach (var info in assets.Values)
+ {
+ if (EntityManager.TryGetComponent(info.Entity, out CarTractorData tractor) && tractor.m_FixedTrailer != Entity.Null)
+ {
+ if (assets.TryGetValue(tractor.m_FixedTrailer, out var fixedTrailer))
+ {
+ fixedTrailer.Parent = info.Entity;
+ fixedTrailer.IsTrailer = true;
+ }
+ }
+
+ var prefab = m_PrefabSystem.GetPrefab(info.Entity);
+ if (prefab is not MultipleUnitTrainFrontPrefab front || front.m_Carriages == null) continue;
+ foreach (var carriage in front.m_Carriages)
+ {
+ if (carriage?.m_Carriage == null) continue;
+ var carriageEntity = m_PrefabSystem.GetEntity(carriage.m_Carriage);
+ if (!assets.TryGetValue(carriageEntity, out var child)) continue;
+ child.Parent = info.Entity;
+ child.IsTrailer = true;
+ }
+ }
+
+ var ordered = assets.Values.OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase).ToArray();
+ for (var i = 0; i < ordered.Length; i++)
{
var id = i + 1;
- m_AssetsById[id] = assets[i].Entity;
- m_IdsByAsset[assets[i].Entity] = id;
- lines.Add($"{id}|{Uri.EscapeDataString(assets[i].Name)}");
+ m_AssetsById[id] = ordered[i].Entity;
+ m_IdsByAsset[ordered[i].Entity] = id;
+ m_ModeByAsset[ordered[i].Entity] = ordered[i].Mode;
+ }
+
+ var lines = new List(ordered.Length);
+ foreach (var info in ordered)
+ {
+ var parentId = info.Parent != Entity.Null && m_IdsByAsset.TryGetValue(info.Parent, out var resolvedParent) ? resolvedParent : 0;
+ if (parentId != 0)
+ {
+ if (!m_ChildrenByAsset.TryGetValue(info.Parent, out var children))
+ m_ChildrenByAsset[info.Parent] = children = new List();
+ children.Add(info.Entity);
+ }
+ lines.Add(string.Join("|",
+ m_IdsByAsset[info.Entity], Uri.EscapeDataString(info.Name), info.Mode,
+ Format(info.MaxSpeed), Format(info.Acceleration), Format(info.Braking),
+ parentId, info.IsTrailer ? 1 : 0));
}
Mod.SelectedVehicleAssets.RemoveWhere(entity => !m_IdsByAsset.ContainsKey(entity));
- m_AssetCatalog = string.Join("\n", lines);
- Mod.Log.Info($"Vehicle asset catalog refreshed: {assets.Length} assets");
+ m_AssetCatalogBinding.Update(string.Join("\n", lines));
+ UpdateSelectedBinding();
+ Mod.Log.Info($"Vehicle asset catalog refreshed once: {ordered.Length} assets, {m_ChildrenByAsset.Sum(pair => pair.Value.Count)} grouped trailers");
}
- private string GetSelectedAssetIds()
+ private static string Format(float value) => value.ToString("0.##", CultureInfo.InvariantCulture);
+
+ private void LoadSelectedTargetAssets(Entity target)
{
- if (m_AssetCatalog.Length == 0) RefreshAssetCatalog();
- return string.Join(",", Mod.SelectedVehicleAssets
- .Where(m_IdsByAsset.ContainsKey)
- .Select(entity => m_IdsByAsset[entity])
- .OrderBy(id => id));
+ Mod.SelectedVehicleAssets.Clear();
+ if (target != Entity.Null && EntityManager.Exists(target) &&
+ EntityManager.TryGetBuffer(target, true, out DynamicBuffer assets))
+ {
+ foreach (var asset in assets)
+ if (asset.m_Prefab != Entity.Null && m_IdsByAsset.ContainsKey(asset.m_Prefab))
+ Mod.SelectedVehicleAssets.Add(asset.m_Prefab);
+ }
+ UpdateSelectedBinding();
}
private void ToggleAsset(int id)
{
- if (!m_AssetsById.TryGetValue(id, out var entity)) return;
+ if (!m_AssetsById.TryGetValue(id, out var entity)) { Mod.Log.Warn($"UI requested unknown asset id {id}"); return; }
if (!Mod.SelectedVehicleAssets.Add(entity)) Mod.SelectedVehicleAssets.Remove(entity);
+ UpdateSelectedBinding();
+ Mod.Log.Debug($"Asset {id} toggled; {Mod.SelectedVehicleAssets.Count} forbidden assets selected");
}
- private void SelectAllAssets()
+ private void ToggleAssetGroup(int id, bool includeChildren)
{
- if (m_AssetCatalog.Length == 0) RefreshAssetCatalog();
- Mod.SelectedVehicleAssets.Clear();
- foreach (var entity in m_AssetsById.Values) Mod.SelectedVehicleAssets.Add(entity);
+ if (!m_AssetsById.TryGetValue(id, out var entity)) return;
+ var group = new List { entity };
+ if (includeChildren && m_ChildrenByAsset.TryGetValue(entity, out var children)) group.AddRange(children);
+ var remove = group.All(Mod.SelectedVehicleAssets.Contains);
+ foreach (var item in group)
+ {
+ if (remove) Mod.SelectedVehicleAssets.Remove(item);
+ else Mod.SelectedVehicleAssets.Add(item);
+ }
+ UpdateSelectedBinding();
+ Mod.Log.Debug($"Asset group {id} toggled (children: {includeChildren}); {Mod.SelectedVehicleAssets.Count} forbidden assets selected");
+ }
+
+ private void SelectAllAssets(int mode)
+ {
+ foreach (var pair in m_AssetsById)
+ if (mode == 0 || m_ModeByAsset[pair.Value] == mode) Mod.SelectedVehicleAssets.Add(pair.Value);
+ UpdateSelectedBinding();
+ Mod.Log.Debug($"All {m_AssetsById.Count} catalog assets selected as forbidden");
+ }
+
+ private void SelectNoAssets(int mode)
+ {
+ Mod.SelectedVehicleAssets.RemoveWhere(entity => mode == 0 || (m_ModeByAsset.TryGetValue(entity, out var assetMode) && assetMode == mode));
+ UpdateSelectedBinding();
+ Mod.Log.Debug("All catalog assets set to allowed");
+ }
+
+ private void UpdateSelectedBinding()
+ {
+ m_SelectedAssetsBinding.Update(string.Join(",", Mod.SelectedVehicleAssets
+ .Where(m_IdsByAsset.ContainsKey).Select(entity => m_IdsByAsset[entity]).OrderBy(id => id)));
}
}
diff --git a/Systems/VehicleAccessSystem.cs b/Systems/VehicleAccessSystem.cs
index c03767a..1ae6ec2 100644
--- a/Systems/VehicleAccessSystem.cs
+++ b/Systems/VehicleAccessSystem.cs
@@ -3,9 +3,11 @@
using Game.Common;
using Game.Net;
using Game.Objects;
+using Game.Pathfind;
using Game.Prefabs;
using Game.Vehicles;
using RouteFilter.Components;
+using System.Collections.Generic;
using Unity.Collections;
using Unity.Entities;
using NetSubLane = Game.Net.SubLane;
@@ -20,6 +22,11 @@ public sealed partial class VehicleAccessSystem : GameSystemBase
{
private EntityQuery m_Cars;
private EntityQuery m_Trains;
+ private EntityQuery m_RestrictedNodes;
+ private EntityQuery m_RestrictedSegments;
+ private readonly List m_PrefabBuffer = new();
+ private int m_DetoursSinceReport;
+ private int m_ReportTicks;
protected override void OnCreate()
{
@@ -30,13 +37,23 @@ protected override void OnCreate()
m_Trains = GetEntityQuery(
ComponentType.ReadWrite(), ComponentType.ReadWrite(),
ComponentType.ReadOnly(), ComponentType.Exclude());
+ m_RestrictedNodes = GetEntityQuery(ComponentType.ReadOnly());
+ m_RestrictedSegments = GetEntityQuery(ComponentType.ReadOnly());
}
protected override void OnUpdate()
{
- if (!Mod.Settings.EnableExactRestrictions) return;
+ if (!Mod.Settings.EnableExactRestrictions ||
+ (m_RestrictedNodes.IsEmptyIgnoreFilter && m_RestrictedSegments.IsEmptyIgnoreFilter)) return;
ProcessCars();
ProcessTrains();
+ if (++m_ReportTicks >= 256)
+ {
+ if (m_DetoursSinceReport != 0)
+ Mod.Log.Info($"Exact-asset enforcement requested {m_DetoursSinceReport} vehicle detours during the last reporting interval");
+ m_DetoursSinceReport = 0;
+ m_ReportTicks = 0;
+ }
}
private void ProcessCars()
@@ -44,8 +61,8 @@ private void ProcessCars()
using var vehicles = m_Cars.ToEntityArray(Allocator.Temp);
foreach (var vehicle in vehicles)
{
- if (IsProtectedEmergency(vehicle) || !TryGetVehiclePrefab(vehicle, out var prefab) ||
- !TryGetBlockedTarget(vehicle, prefab, true, out var target)) continue;
+ if (IsProtectedEmergency(vehicle) || !LoadVehiclePrefabs(vehicle) ||
+ !TryGetBlockedTarget(vehicle, m_PrefabBuffer, true, out var target)) continue;
RequestDetour(vehicle, target);
var navigation = EntityManager.GetComponentData(vehicle);
var moving = EntityManager.GetComponentData(vehicle);
@@ -62,8 +79,8 @@ private void ProcessTrains()
using var vehicles = m_Trains.ToEntityArray(Allocator.Temp);
foreach (var vehicle in vehicles)
{
- if (!TryGetVehiclePrefab(vehicle, out var prefab) ||
- !TryGetBlockedTarget(vehicle, prefab, false, out var target)) continue;
+ if (!LoadVehiclePrefabs(vehicle) ||
+ !TryGetBlockedTarget(vehicle, m_PrefabBuffer, false, out var target)) continue;
RequestDetour(vehicle, target);
var navigation = EntityManager.GetComponentData(vehicle);
var moving = EntityManager.GetComponentData(vehicle);
@@ -75,41 +92,56 @@ private void ProcessTrains()
}
}
- private bool TryGetBlockedTarget(Entity vehicle, Entity prefab, bool car, out Entity blockedTarget)
+ private bool TryGetBlockedTarget(Entity vehicle, List prefabs, bool car, out Entity blockedTarget)
{
blockedTarget = Entity.Null;
- var count = 0;
var limit = System.Math.Max(1, Mod.Settings.LookAheadLanes);
if (car)
{
+ if (EntityManager.TryGetComponent(vehicle, out CarCurrentLane current) &&
+ (TryGetBlockingTarget(current.m_Lane, prefabs, out blockedTarget) ||
+ TryGetBlockingTarget(current.m_ChangeLane, prefabs, out blockedTarget))) return true;
var lanes = EntityManager.GetBuffer(vehicle, true);
- for (var i = 0; i < lanes.Length && count++ < limit; i++)
- if (TryGetBlockingTarget(lanes[i].m_Lane, prefab, out blockedTarget)) return true;
+ for (var i = 0; i < lanes.Length && i < limit; i++)
+ if (TryGetBlockingTarget(lanes[i].m_Lane, prefabs, out blockedTarget)) return true;
}
else
{
+ if (EntityManager.TryGetComponent(vehicle, out TrainCurrentLane current) &&
+ (TryGetBlockingTarget(current.m_Front.m_Lane, prefabs, out blockedTarget) ||
+ TryGetBlockingTarget(current.m_Rear.m_Lane, prefabs, out blockedTarget))) return true;
var lanes = EntityManager.GetBuffer(vehicle, true);
- for (var i = 0; i < lanes.Length && count++ < limit; i++)
- if (TryGetBlockingTarget(lanes[i].m_Lane, prefab, out blockedTarget)) return true;
+ for (var i = 0; i < lanes.Length && i < limit; i++)
+ if (TryGetBlockingTarget(lanes[i].m_Lane, prefabs, out blockedTarget)) return true;
+ }
+
+ if (EntityManager.TryGetComponent(vehicle, out PathOwner pathOwner) &&
+ EntityManager.TryGetBuffer(vehicle, true, out DynamicBuffer path))
+ {
+ var start = System.Math.Max(0, pathOwner.m_ElementIndex);
+ for (var i = start; i < path.Length && i < start + limit; i++)
+ if (TryGetBlockingTarget(path[i].m_Target, prefabs, out blockedTarget)) return true;
}
return false;
}
- private bool TryGetBlockingTarget(Entity lane, Entity prefab, out Entity blockedTarget)
+ private bool TryGetBlockingTarget(Entity lane, IReadOnlyCollection prefabs, out Entity blockedTarget)
{
blockedTarget = Entity.Null;
+ if (lane == Entity.Null) return false;
var entity = lane;
- for (var depth = 0; depth < 3 && entity != Entity.Null; depth++)
+ for (var depth = 0; depth < 8 && entity != Entity.Null; depth++)
{
- if (EntityManager.HasComponent(entity) && IsAssetRestricted(entity, prefab))
+ if (IsRestrictedTarget(entity, prefabs))
{
blockedTarget = entity;
return true;
}
- if (EntityManager.HasComponent(entity) && IsAssetRestricted(entity, prefab))
+
+ if (EntityManager.TryGetComponent(entity, out Game.Net.Edge edge))
{
- blockedTarget = entity;
- return true;
+ if (IsRestrictedTarget(edge.m_Start, prefabs)) { blockedTarget = edge.m_Start; return true; }
+ if (IsRestrictedTarget(edge.m_End, prefabs)) { blockedTarget = edge.m_End; return true; }
}
if (!EntityManager.TryGetComponent(entity, out Owner owner)) break;
entity = owner.m_Owner;
@@ -117,20 +149,43 @@ private bool TryGetBlockingTarget(Entity lane, Entity prefab, out Entity blocked
return false;
}
- private bool IsAssetRestricted(Entity target, Entity prefab)
+ private bool IsRestrictedTarget(Entity target, IReadOnlyCollection prefabs)
{
+ if (target == Entity.Null ||
+ (!EntityManager.HasComponent(target) &&
+ !EntityManager.HasComponent(target))) return false;
if (!EntityManager.TryGetBuffer(target, true, out DynamicBuffer assets)) return false;
foreach (var asset in assets)
- if (asset.m_Prefab == prefab) return true;
+ foreach (var prefab in prefabs)
+ if (prefab == asset.m_Prefab) return true;
return false;
}
- private bool TryGetVehiclePrefab(Entity vehicle, out Entity prefab)
+ private bool LoadVehiclePrefabs(Entity vehicle)
+ {
+ m_PrefabBuffer.Clear();
+ AddPrefab(vehicle, m_PrefabBuffer);
+ AddLayoutPrefabs(vehicle, m_PrefabBuffer);
+ if (EntityManager.TryGetComponent(vehicle, out Controller controller) &&
+ controller.m_Controller != Entity.Null && controller.m_Controller != vehicle)
+ {
+ AddPrefab(controller.m_Controller, m_PrefabBuffer);
+ AddLayoutPrefabs(controller.m_Controller, m_PrefabBuffer);
+ }
+ return m_PrefabBuffer.Count != 0;
+ }
+
+ private void AddLayoutPrefabs(Entity controller, List prefabs)
+ {
+ if (!EntityManager.TryGetBuffer(controller, true, out DynamicBuffer layout)) return;
+ foreach (var element in layout) AddPrefab(element.m_Vehicle, prefabs);
+ }
+
+ private void AddPrefab(Entity vehicle, List prefabs)
{
- prefab = Entity.Null;
- if (!EntityManager.TryGetComponent(vehicle, out PrefabRef prefabRef)) return false;
- prefab = prefabRef.m_Prefab;
- return prefab != Entity.Null;
+ if (vehicle == Entity.Null || !EntityManager.TryGetComponent(vehicle, out PrefabRef prefabRef) ||
+ prefabRef.m_Prefab == Entity.Null || prefabs.Contains(prefabRef.m_Prefab)) return;
+ prefabs.Add(prefabRef.m_Prefab);
}
private void RequestDetour(Entity vehicle, Entity target)
@@ -138,6 +193,7 @@ private void RequestDetour(Entity vehicle, Entity target)
if (EntityManager.HasComponent(vehicle)) return;
EntityManager.AddComponentData(vehicle, new VehicleDetourRequest(target));
+ m_DetoursSinceReport++;
if (EntityManager.TryGetComponent(target, out AccessDetourBlock block))
{
if (block.m_RequestCount < ushort.MaxValue) block.m_RequestCount++;
diff --git a/UI/mod.json b/UI/mod.json
index e343aa7..3e59c58 100644
--- a/UI/mod.json
+++ b/UI/mod.json
@@ -1,6 +1,6 @@
{
"id": "RouteFilter",
"author": "Daotie",
- "version": "0.4.0-beta.1",
+ "version": "1.0.0",
"dependencies": []
}
diff --git a/UI/package-lock.json b/UI/package-lock.json
index 991cb86..7d30b38 100644
--- a/UI/package-lock.json
+++ b/UI/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cs2-routefilter-ui",
- "version": "0.4.0-beta.1",
+ "version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cs2-routefilter-ui",
- "version": "0.4.0-beta.1",
+ "version": "1.0.0",
"dependencies": {
"classnames": "2.3.2",
"react": "18.3.1",
diff --git a/UI/package.json b/UI/package.json
index 37e1555..35f520c 100644
--- a/UI/package.json
+++ b/UI/package.json
@@ -1,7 +1,7 @@
{
"name": "cs2-routefilter-ui",
"private": true,
- "version": "0.4.0-beta.1",
+ "version": "1.0.0",
"scripts": { "build": "webpack" },
"dependencies": { "classnames": "2.3.2", "react": "18.3.1", "react-dom": "18.3.1" },
"devDependencies": {
diff --git a/UI/src/routeFilterUI.module.scss b/UI/src/routeFilterUI.module.scss
index b548739..94abf8d 100644
--- a/UI/src/routeFilterUI.module.scss
+++ b/UI/src/routeFilterUI.module.scss
@@ -1,31 +1,61 @@
-.toggle { background: #2d8b70; }
-.toggle:hover { background: #38a889; }
-.selected { box-shadow: 0 0 0 3rem rgba(99, 236, 192, .6); }
+.toggle { background: #26333b; }
+.toggle:hover { background: #354650; }
+.selected { box-shadow: 0 0 0 3rem rgba(255, 94, 66, .72); }
.panel {
- position: fixed; left: 18rem; top: 88rem; z-index: 1000; width: 430rem; max-height: 76vh;
- overflow: auto; padding: 18rem; border-radius: 12rem; color: var(--textColor);
- background: rgba(30, 38, 46, .96); box-shadow: 0 8rem 28rem rgba(0,0,0,.45);
- header { display: flex; justify-content: space-between; align-items: start; }
- header div { display: flex; gap: 10rem; align-items: baseline; }
- header small { opacity: .65; font-size: 11rem; }
+ position: fixed; left: 18rem; top: 50rem; bottom: 20rem; z-index: 1000; width: 470rem; height: auto; max-height: 700rem;
+ padding: 16rem; border-radius: 12rem; color: var(--textColor); background: rgba(28, 35, 42, .98);
+ box-sizing: border-box; overflow: hidden; box-shadow: 0 8rem 28rem rgba(0,0,0,.48); display: flex; flex-direction: column; pointer-events: auto;
+ header { display: flex; justify-content: space-between; align-items: center; flex: 0 0 auto; }
+ header div { display: flex; align-items: center; }
+ header small { opacity: .65; font-size: 11rem; margin-left: 10rem; }
header button { border: 0; background: transparent; color: inherit; font-size: 24rem; }
- p { opacity: .8; line-height: 1.35; font-size: 13rem; }
}
-.toolButton, .actions button, .modes button, .assetList button {
- border: 0; border-radius: 6rem; color: white; background: rgba(255,255,255,.1); cursor: pointer;
+.warning { flex: 0 0 auto; margin-top: 8rem; padding: 9rem 11rem; border-left: 3rem solid #ff5e42; background: rgba(255,94,66,.12); }
+.warning strong, .warning span { display: block; }
+.warning span { margin-top: 3rem; opacity: .82; font-size: 12rem; }
+.actions button, .modes button, .expand, .pager button, .applyActions button {
+ border: 0; border-radius: 6rem; color: white; background: rgba(255,255,255,.09); cursor: pointer;
}
-.toolButton { width: 100%; padding: 9rem; background: #26785f; }
-.actions { display: flex; gap: 8rem; margin: 10rem 0; }
-.actions button { flex: 1; padding: 7rem; }
-.modes { display: grid; grid-template-columns: 1fr 1fr; gap: 8rem; margin: 10rem 0; }
-.modes button { padding: 8rem; }
-.modes .checked { background: #26785f; }
-.assetHeader { display: flex; justify-content: space-between; align-items: baseline; margin-top: 14rem; }
-.assetHeader small { opacity: .7; }
-.search { box-sizing: border-box; width: 100%; margin-top: 8rem; padding: 8rem 10rem; border: 1rem solid rgba(255,255,255,.18); border-radius: 6rem; color: white; background: rgba(0,0,0,.22); }
-.assetList { display: grid; grid-template-columns: 1fr 1fr; gap: 6rem; max-height: 390rem; overflow: auto; padding-right: 4rem; }
-.assetList button { display: flex; min-width: 0; align-items: center; gap: 7rem; padding: 7rem; text-align: left; }
-.assetList span { flex: 0 0 auto; display: inline-grid; place-items: center; width: 18rem; height: 18rem; border: 1rem solid #8ea09a; border-radius: 3rem; }
-.assetList em { overflow: hidden; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
-.assetList .checked { background: rgba(45, 139, 112, .65); }
-.empty { grid-column: 1 / -1; text-align: center; }
+.targetStatus { flex: 0 0 auto; margin-top: 8rem; padding: 7rem 9rem; border: 1rem solid rgba(255,255,255,.16); border-radius: 6rem; }
+.targetStatus strong, .targetStatus span { display: block; }
+.targetStatus span { margin-top: 2rem; opacity: .7; font-size: 11rem; }
+.targetReady { border-color: #53ccef; background-color: rgba(45,174,214,.13); }
+.modes { display: flex; flex: 0 0 auto; margin-top: 9rem; margin-bottom: 8rem; }
+.modes button { flex: 1 1 50%; padding: 8rem; }
+.modes button + button { margin-left: 8rem; }
+.modes .modeSelected { background: #3b5969; box-shadow: inset 0 -2rem #8ed6f4; }
+.assetHeader { display: flex; flex: 0 0 auto; justify-content: space-between; align-items: center; margin-top: 10rem; }
+.assetHeader small { opacity: .72; }
+.search { width: 100%; margin-top: 7rem; padding: 8rem 10rem; border: 1rem solid rgba(255,255,255,.18); border-radius: 6rem; color: white; background: rgba(0,0,0,.22); }
+.details { flex: 0 0 65rem; margin-top: 7rem; padding: 7rem 9rem; border-radius: 6rem; background: rgba(0,0,0,.2); font-size: 12rem; overflow: hidden; }
+.details div { display: flex; align-items: center; margin-bottom: 5rem; }
+.details span { display: inline-block; margin-right: 12rem; opacity: .82; }
+.details b { color: #fff; }
+.actions { display: flex; flex: 0 0 auto; margin-top: 8rem; }
+.actions button { flex: 1 1 auto; padding: 7rem; }
+.actions button + button { margin-left: 7rem; }
+.actions .refresh { flex: 0 0 38rem; }
+.assetList { flex: 1 1 140rem; min-height: 90rem; margin-top: 7rem; overflow-y: auto; overflow-x: hidden; padding-right: 5rem; }
+.assetRow { display: flex; width: 100%; min-height: 34rem; margin-bottom: 5rem; align-items: center; border-radius: 6rem; background-color: rgba(255,255,255,.07); }
+.assetRow.child { margin-left: 24rem; width: auto; }
+.assetRow.forbidden { background-color: rgba(190, 58, 40, .78); }
+.assetRow.partial { background-color: rgba(202, 125, 35, .62); }
+.expand, .expandSpacer { flex: 0 0 25rem; width: 25rem; margin-right: 5rem; }
+.expand { font-size: 20rem; line-height: 30rem; }
+.check { flex: 0 0 20rem; display: flex; align-items: center; justify-content: center; width: 20rem; height: 20rem; padding: 0; border: 1rem solid #a9b3b9; border-radius: 10rem; color: white; background-color: rgba(0,0,0,.2); }
+.checked { border-color: white; background-color: #a93428; }
+.partialCheck { background-color: #a96a23; }
+.assetMain { display: flex; flex: 1 1 auto; min-width: 0; height: 34rem; align-items: center; padding: 5rem 7rem 5rem 0; }
+.assetMain em { flex: 1 1 auto; min-width: 0; overflow: hidden; margin-left: 7rem; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
+.assetMain small { flex: 0 0 auto; opacity: .65; margin-left: 6rem; }
+.glyph { flex: 0 0 22rem; width: 22rem; height: 22rem; margin-left: 7rem; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+.details .glyph { margin-left: 0; margin-right: 7rem; }
+.empty { text-align: center; opacity: .65; }
+.pager { display: flex; flex: 0 0 30rem; align-items: center; justify-content: center; margin-top: 5rem; }
+.pager button { width: 42rem; height: 28rem; }
+.pager span { min-width: 150rem; text-align: center; font-size: 11rem; opacity: .78; }
+.pager button:disabled, .applyActions button:disabled { opacity: .35; }
+.applyActions { display: flex; flex: 0 0 auto; margin-top: 7rem; }
+.applyActions button { flex: 1 1 auto; padding: 8rem 5rem; }
+.applyActions button + button { margin-left: 6rem; }
+.applyActions .applyButton { background-color: #287a98; }
diff --git a/UI/src/routeFilterUI.tsx b/UI/src/routeFilterUI.tsx
index 1baaece..8bf6eb5 100644
--- a/UI/src/routeFilterUI.tsx
+++ b/UI/src/routeFilterUI.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useRef, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import classNames from "classnames";
import { bindValue, trigger, useValue } from "cs2/api";
import { useLocalization } from "cs2/l10n";
@@ -7,28 +7,50 @@ import mod from "mod.json";
import icon from "./routefilter.svg";
import styles from "./routeFilterUI.module.scss";
-type VehicleAsset = { id: number; name: string };
+type VehicleAsset = {
+ id: number; name: string; mode: number; maxSpeed: number; acceleration: number;
+ braking: number; parentId: number; trailer: boolean;
+};
const toolActive$ = bindValue(mod.id, "toolActive", false);
const targetMode$ = bindValue(mod.id, "targetMode", 0);
+const targetTransport$ = bindValue(mod.id, "targetTransport", 0);
+const selectedTargetKind$ = bindValue(mod.id, "selectedTargetKind", 0);
const assetCatalog$ = bindValue(mod.id, "assetCatalog", "");
const selectedAssetIds$ = bindValue(mod.id, "selectedAssetIds", "");
-const parseCatalog = (raw: string): VehicleAsset[] => raw.split("\n").flatMap(line => {
- const divider = line.indexOf("|");
- if (divider < 1) return [];
- const id = Number(line.slice(0, divider));
- if (!Number.isInteger(id)) return [];
- try { return [{ id, name: decodeURIComponent(line.slice(divider + 1)) }]; }
- catch { return [{ id, name: line.slice(divider + 1) }]; }
-});
+const parseCatalog = (raw: string): VehicleAsset[] => raw.split("\n").reduce((result, line) => {
+ const part = line.split("|");
+ if (part.length !== 8) return result;
+ const id = Number(part[0]);
+ if (!Number.isInteger(id)) return result;
+ let name = part[1];
+ try { name = decodeURIComponent(name); } catch { /* retain the technical name */ }
+ result.push({ id, name, mode: Number(part[2]), maxSpeed: Number(part[3]), acceleration: Number(part[4]), braking: Number(part[5]), parentId: Number(part[6]), trailer: part[7] === "1" });
+ return result;
+}, []);
+
+const AssetGlyph = ({ mode, trailer }: { mode: number; trailer: boolean }) =>
+ {mode === 2 ? <>
+
+
+ > : <>
+
+
+ >}
+ ;
export const RouteFilterUI = () => {
- const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
+ const [expanded, setExpanded] = useState>(() => new Set());
+ const [hovered, setHovered] = useState(null);
+ const [page, setPage] = useState(0);
const anchor = useRef(null);
const active = useValue(toolActive$);
+ const open = active;
const targetMode = useValue(targetMode$);
+ const targetTransport = useValue(targetTransport$);
+ const selectedTargetKind = useValue(selectedTargetKind$);
const catalogRaw = useValue(assetCatalog$);
const selectedRaw = useValue(selectedAssetIds$);
const { translate } = useLocalization();
@@ -36,47 +58,100 @@ export const RouteFilterUI = () => {
const assets = useMemo(() => parseCatalog(catalogRaw), [catalogRaw]);
const selected = useMemo(() => new Set(selectedRaw.split(",").map(Number).filter(Number.isInteger)), [selectedRaw]);
const normalizedSearch = search.trim().toLocaleLowerCase();
- const visible = useMemo(() => normalizedSearch
- ? assets.filter(asset => asset.name.toLocaleLowerCase().includes(normalizedSearch))
- : assets, [assets, normalizedSearch]);
+ const relevant = useMemo(() => assets.filter(asset => targetTransport === 0 || (asset.mode & targetTransport) !== 0), [assets, targetTransport]);
+ const children = useMemo(() => {
+ const map = new Map();
+ relevant.forEach(asset => { if (asset.parentId) map.set(asset.parentId, [...(map.get(asset.parentId) ?? []), asset]); });
+ return map;
+ }, [relevant]);
+ const roots = useMemo(() => relevant.filter(asset => !asset.parentId || !relevant.some(candidate => candidate.id === asset.parentId)).filter(asset => {
+ if (!normalizedSearch) return true;
+ return asset.name.toLocaleLowerCase().includes(normalizedSearch) || (children.get(asset.id) ?? []).some(child => child.name.toLocaleLowerCase().includes(normalizedSearch));
+ }), [relevant, children, normalizedSearch]);
+ const selectedRelevant = relevant.filter(asset => selected.has(asset.id)).length;
+ const pageSize = 30;
+ const pageCount = Math.max(1, Math.ceil(roots.length / pageSize));
+ const pageIndex = Math.min(page, pageCount - 1);
+ const visibleRoots = roots.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
+
+ useEffect(() => setPage(0), [search, targetTransport]);
+ useEffect(() => {
+ if (!open) return;
+ return () => { trigger(mod.id, "setPointerOverUi", false); };
+ }, [open]);
- const togglePanel = () => {
- setOpen(!open);
- if (!open && !active) trigger(mod.id, "toggleTool");
+ const togglePanel = () => trigger(mod.id, "toggleTool");
+ const toggleExpanded = (id: number) => setExpanded(current => {
+ const next = new Set(current);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+
+ const renderAsset = (asset: VehicleAsset, child = false) => {
+ const childAssets = children.get(asset.id) ?? [];
+ const isExpanded = expanded.has(asset.id) || normalizedSearch.length > 0;
+ const groupIds = [asset.id, ...childAssets.map(item => item.id)];
+ const selectedCount = groupIds.filter(id => selected.has(id)).length;
+ const partial = childAssets.length > 0 && selectedCount > 0 && selectedCount < groupIds.length;
+ return
+ setHovered(asset)} onMouseOver={() => setHovered(asset)}>
+ {childAssets.length > 0 ?
toggleExpanded(asset.id)}>{isExpanded ? "⌄" : "›"} :
}
+
childAssets.length ? trigger(mod.id, "toggleAssetGroup", asset.id, !isExpanded) : trigger(mod.id, "toggleAsset", asset.id)}>
+ {partial ? "−" : selected.has(asset.id) ? "×" : ""}
+
+
+
{asset.name}
+ {childAssets.length > 0 &&
{childAssets.length + 1} }
+
+
+ {childAssets.length > 0 && isExpanded && childAssets.filter(item => !normalizedSearch || item.name.toLocaleLowerCase().includes(normalizedSearch)).map(item => renderAsset(item, true))}
+ ;
};
+ const targetLabel = targetTransport === 1 ? tr("RouteFilter.UI.RoadAssets", "Road vehicles") : targetTransport === 2 ? tr("RouteFilter.UI.RailAssets", "Rail vehicles") : targetTransport === 3 ? tr("RouteFilter.UI.MixedAssets", "Road and rail vehicles") : tr("RouteFilter.UI.HoverTarget", "Hover a target to filter assets");
+ const selectedTargetLabel = selectedTargetKind === 1 ? tr("RouteFilter.UI.NodeSelected", "Node selected") : selectedTargetKind === 2 ? tr("RouteFilter.UI.SegmentSelected", "Segment selected") : tr("RouteFilter.UI.SelectTarget", "Click a node or segment on the map first");
+
return
- {open &&
-
- {tr("RouteFilter.UI.Instruction", "Choose a target and vehicle assets; left-click to apply or right-click to clear.")}
+ {open && trigger(mod.id, "setPointerOverUi", true)} onMouseLeave={() => trigger(mod.id, "setPointerOverUi", false)}>
+
+ {tr("RouteFilter.UI.ForbiddenTitle", "Forbidden assets")} {tr("RouteFilter.UI.ForbiddenHint", "Selected assets will be blocked. Unselected assets remain allowed.")}
- trigger(mod.id, "setTargetMode", 0)}>
- {tr("RouteFilter.UI.Node", "Node")}
-
- trigger(mod.id, "setTargetMode", 1)}>
- {tr("RouteFilter.UI.Segment", "Segment")}
-
+ trigger(mod.id, "setTargetMode", 0)}>{tr("RouteFilter.UI.Node", "Node")}
+ trigger(mod.id, "setTargetMode", 1)}>{tr("RouteFilter.UI.Segment", "Segment")}
- trigger(mod.id, "toggleTool")}>{active ? tr("RouteFilter.UI.Active", "Tool active") : tr("RouteFilter.UI.Inactive", "Open tool")}
-
-
{tr("RouteFilter.UI.Assets", "Vehicle assets")}
-
{selected.size} / {assets.length} {tr("RouteFilter.UI.Selected", "selected")}
+
+ {selectedTargetLabel} {tr("RouteFilter.UI.SelectionHint", "Left-click selects; right-click cancels the selection.")}
+
{targetLabel} {selectedRelevant} / {relevant.length} {tr("RouteFilter.UI.ForbiddenCount", "forbidden")}
setSearch(event.target.value)} placeholder={tr("RouteFilter.UI.Search", "Search vehicle assets")} />
+
{hovered ? <>
+
+
{tr("RouteFilter.UI.MaxSpeed", "Maximum speed")}: {hovered.maxSpeed} km/h
+
{tr("RouteFilter.UI.Acceleration", "Acceleration")}: {hovered.acceleration}
+
{tr("RouteFilter.UI.Braking", "Braking")}: {hovered.braking}
+ > :
{tr("RouteFilter.UI.HoverInfo", "Hover an asset to view its base parameters.")} }
- trigger(mod.id, "selectAllAssets")}>{tr("RouteFilter.UI.All", "Select all")}
- trigger(mod.id, "selectNoAssets")}>{tr("RouteFilter.UI.None", "Clear selection")}
- trigger(mod.id, "refreshAssets")}>{tr("RouteFilter.UI.Refresh", "Refresh assets")}
+ trigger(mod.id, "selectAllAssets", 0)}>{tr("RouteFilter.UI.ForbidAll", "Forbid all assets")}
+ trigger(mod.id, "selectNoAssets", 0)}>{tr("RouteFilter.UI.AllowAll", "Allow all assets")}
+ trigger(mod.id, "refreshAssets")}>↻
+
+
{ event.currentTarget.scrollTop += event.deltaY; event.stopPropagation(); }} onMouseLeave={() => setHovered(null)}>
+ {visibleRoots.map(asset => renderAsset(asset))}
+ {roots.length === 0 &&
{tr("RouteFilter.UI.Empty", "No matching vehicle assets")}
}
+
+
+ setPage(Math.max(0, pageIndex - 1))} disabled={pageIndex === 0}>‹
+ {pageIndex + 1} / {pageCount} · {selected.size} / {assets.length}
+ setPage(Math.min(pageCount - 1, pageIndex + 1))} disabled={pageIndex + 1 >= pageCount}>›
-
{visible.map(asset =>
-
trigger(mod.id, "toggleAsset", asset.id)} title={asset.name}>
- {selected.has(asset.id) ? "✓" : ""} {asset.name}
- )}
- {visible.length === 0 &&
{tr("RouteFilter.UI.Empty", "No matching vehicle assets")}
}
+
+ selectedTargetKind !== 0 && trigger(mod.id, "applySelection")}>{tr("RouteFilter.UI.ApplyToTarget", "Apply list to selected target")}
+ selectedTargetKind !== 0 && trigger(mod.id, "clearSelectedRestriction")}>{tr("RouteFilter.UI.ClearTarget", "Clear target restrictions")}
+ selectedTargetKind !== 0 && trigger(mod.id, "cancelSelection")}>{tr("RouteFilter.UI.CancelTarget", "Cancel selection")}
}
;
diff --git a/UI/src/routefilter.svg b/UI/src/routefilter.svg
index a09cc9e..04fcbf6 100644
--- a/UI/src/routefilter.svg
+++ b/UI/src/routefilter.svg
@@ -1,5 +1,6 @@
-
-
-
-
+
+
+
+
+
diff --git a/VERSION b/VERSION
index 616da2f..3eefcb9 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.0-beta.1
+1.0.0
diff --git a/assets/branding/README.md b/assets/branding/README.md
new file mode 100644
index 0000000..bacf4f4
--- /dev/null
+++ b/assets/branding/README.md
@@ -0,0 +1,7 @@
+# RouteFilter branding
+
+`routefilter-logo.png` is the primary RouteFilter release mark and is also copied to `Properties/Thumbnail.png` for Paradox Mods publishing.
+
+Design concept: road and rail routes converge on a circular filter node, then continue along permitted directions. The cyan, coral, and green palette distinguishes filtering, road traffic, and rail traffic without using a gate, barrier, national symbol, or official game branding.
+
+The logo is distributed with RouteFilter under GPL-3.0-only. It must not be presented as an official Colossal Order or Paradox Interactive mark.
diff --git a/assets/branding/routefilter-logo.png b/assets/branding/routefilter-logo.png
new file mode 100644
index 0000000..bf86dc0
Binary files /dev/null and b/assets/branding/routefilter-logo.png differ
diff --git a/assets/screenshots/README.md b/assets/screenshots/README.md
new file mode 100644
index 0000000..dd81704
--- /dev/null
+++ b/assets/screenshots/README.md
@@ -0,0 +1,10 @@
+# README screenshot slots
+
+Replace the commented screenshot placeholders in both public README files with the following gameplay images:
+
+- `01-target-selection.png`
+- `02-asset-catalog.png`
+- `03-apply-restriction.png`
+- `04-rerouting-result.png`
+
+Keep captions and claims consistent with the matching instructions in `Properties/Screenshots/README.md`.