docs: add dedicated DASHBOARD_DEV.md and PLUGIN_DEV.md, simplify README & CONTRIBUTING

This commit is contained in:
2026-07-25 15:38:24 +08:00
parent a46e5d786d
commit 5e177f4514
4 changed files with 438 additions and 181 deletions
+2 -67
View File
@@ -2,73 +2,8 @@
## 贡献方式 ## 贡献方式
### 提交游戏插件 (`.tsp`) - **提交游戏插件** (.tsp) — 见 [游戏插件兼容开发指南](docs/PLUGIN_DEV.md)
- **提交仪表盘主题** (.tsd) — 见 [仪表盘开发指南](docs/DASHBOARD_DEV.md)
创建一个包含 `manifest.json``parser.py` 的文件夹,压缩为 zip 改后缀:
```
my_game/
├── manifest.json
└── parser.py
```
#### manifest.json
```json
{
"id": "unique_game_id",
"name": "游戏名称",
"parser_type": "forza",
"description": "简短描述",
"author": "你的名字",
"version": "1.0.0",
"default_port": 20777
}
```
#### parser.py 规范
必须实现 `get_parser()` 函数,返回对象包含:
```python
import time
from server.telemetry.data import TelemetryData
def get_parser():
return MyParser()
class MyParser:
def game_id(self) -> str:
return "unique_game_id"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="unique_game_id", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data)}
# 填充遥测字段...
return td
```
安装方式:在 TurboSu 设置页面 → 游戏插件管理 → 导入 `.tsp`
### 提交仪表盘主题 (`.tsd`)
创建独立文件夹:
```
my_dashboard/
├── manifest.json # 元信息 (参考下方格式)
└── index.html # 完整独立页面
```
`index.html` 需包含:
- WebSocket 连接:`ws://<host>/ws`
- 接收 `telemetry` 消息类型的数据
-`data-bind="字段名"` 属性绑定数据
- 比例约束渲染逻辑
安装方式:在 TurboSu 仪表盘页面 → 导入 `.tsd`,或右键卡片导出。
### 报告 Bug ### 报告 Bug
+2 -114
View File
@@ -80,120 +80,8 @@ python app.py
## 开发指南 ## 开发指南
### 自定义仪表盘格式 - [仪表盘开发指南](docs/DASHBOARD_DEV.md) — 创建自定义仪表盘主题
- [游戏插件兼容开发指南](docs/PLUGIN_DEV.md) — 为任意赛车游戏编写遥测解析器
每个仪表盘是一个独立文件夹,放在 `dashboards/``data/dashboards/`
```
dashboards/my_dashboard/
├── manifest.json # 元信息
└── index.html # 完整独立页面 (含 WS 连接 + 数据绑定)
```
#### manifest.json
```json
{
"id": "my_dashboard",
"name": "我的仪表盘",
"category": "custom",
"description": "简短描述",
"author": "你的名字",
"version": "1.0.0",
"config": {
"icon": "🏎️",
"aspect_ratio": "16:9",
"render_mode": "contain"
}
}
```
#### index.html
完全独立的 HTML 页面。需包含:
- WebSocket 连接到 `ws://<host>/ws` 接收遥测数据
- `data-bind` 属性绑定数据字段(如 `data-bind="speed_kmh"`
- 比例约束渲染逻辑(参考内置仪表盘)
打包分发:将整个文件夹压缩为 zip,改后缀为 `.tsd`
### 自定义游戏插件
插件文件夹放在 `games/user/`
```
games/user/my_game/
├── manifest.json # 元信息
└── parser.py # 解析逻辑 (get_parser() + parse())
```
#### manifest.json
```json
{
"id": "my_game",
"name": "我的游戏",
"parser_type": "forza",
"description": "自定义游戏遥测",
"author": "你的名字",
"version": "1.0.0",
"default_port": 20777
}
```
#### parser.py
```python
import time
from server.telemetry.data import TelemetryData
def get_parser():
return MyGameParser()
class MyGameParser:
def game_id(self) -> str:
return "my_game"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="my_game", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data)}
# 解析你的游戏数据...
# td.speed_kmh = ...
# td.rpm = ...
# td.gear = ...
return td
```
打包分发:将文件夹压缩为 zip,改后缀为 `.tsp`,在设置页面导入。
### TelemetryData 可用字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `speed_kmh` | float | 速度 (km/h) |
| `speed_mph` | float | 速度 (mph) |
| `rpm` | float | 发动机转速 |
| `max_rpm` | float | 最大转速 |
| `gear` | int | 档位 (0=N, -1=R) |
| `throttle` | float | 油门 (0~1) |
| `brake` | float | 刹车 (0~1) |
| `clutch` | float | 离合 (0~1) |
| `handbrake` | float | 手刹 (0~1) |
| `steering` | float | 转向 (-1~1) |
| `lap_time` | float | 当前圈速 (秒) |
| `best_lap` | float | 最佳圈速 (秒) |
| `last_lap` | float | 上圈时间 (秒) |
| `lap_number` | int | 圈数 |
| `fuel` | float | 燃油 |
| `boost` | float | 涡轮增压 |
| `horsepower` | float | 马力 |
| `torque` | float | 扭矩 |
| `position_x/y/z` | float | 坐标 |
| `engine_temp` | float | 发动机温度 |
| `oil_temp` | float | 油温 |
| `raw` | dict | 原始数据 |
## 项目结构 ## 项目结构
+212
View File
@@ -0,0 +1,212 @@
# 仪表盘开发指南
每个仪表盘是 `dashboards/` (内置) 或 `data/dashboards/` (用户) 下的独立文件夹,启动时自动扫描。
## 文件夹结构
```
dashboards/my_dashboard/
├── manifest.json # 元信息
├── index.html # 完整独立页面
└── preview.png # 预览图 (可选)
```
## manifest.json 格式
```json
{
"id": "my_dashboard",
"name": "我的仪表盘",
"category": "custom",
"description": "简短描述",
"author": "你的名字",
"version": "1.0.0",
"config": {
"icon": "🏎️",
"aspect_ratio": "16:9",
"render_mode": "contain"
}
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | string | 是 | 唯一标识,= 文件夹名 |
| `name` | string | 是 | 显示名称 |
| `category` | string | 否 | 分类 (speed/rpm/basic/timing/custom) |
| `description` | string | 否 | 描述文字 |
| `author` | string | 否 | 作者 |
| `version` | string | 否 | 版本号 |
| `config.icon` | string | 否 | 预览图标 emoji |
| `config.aspect_ratio` | string | 否 | 渲染比例: `auto` / `16:9` / `4:3` / `1:1` / `21:9` |
| `config.render_mode` | string | 否 | 渲染模式: `contain` / `cover` / `fill` / `center` |
### 渲染模式说明
| 模式 | 行为 |
|------|------|
| `contain` | 保持比例完整显示,不足处留黑边 (推荐) |
| `cover` | 保持比例铺满屏幕,超出部分裁切 |
| `fill` | 拉伸填满视口,忽略比例 |
| `center` | 居中显示,不超过 `max_width` × `max_height` |
## index.html 规范
`index.html` 是完整的独立 HTML 页面,直接通过 `/dashboard/<id>` 访问。
### 必须包含
1. **WebSocket 连接** — 连接到 `ws://<host>/ws` 接收遥测数据
```javascript
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'telemetry') {
// msg.data 包含所有遥测字段
updateUI(msg.data);
}
};
ws.onclose = () => {
// 断线重连
setTimeout(connect, 2000);
};
```
2. **数据绑定** — 使用 `data-bind` 属性自动绑定
```html
<div data-bind="speed_kmh">0</div>
<span data-bind="rpm">0</span>
```
3. **比例约束渲染** — 处理不同设备屏幕比例
```javascript
const CONFIG = { aspect_ratio: "16:9", render_mode: "contain" };
function applyAspectRatio() {
const ratio = parseRatio(CONFIG.aspect_ratio);
const vw = window.innerWidth, vh = window.innerHeight;
if (CONFIG.render_mode === 'contain') {
// 缩放以完整显示,不足处留黑边
const scale = Math.min(vw / refW, vh / refH);
canvas.style.width = (refW * scale) + 'px';
canvas.style.height = (refH * scale) + 'px';
}
// ... 其他模式
}
```
## 可绑定的遥测字段
| data-bind | 字段 | 类型 | 说明 |
|-----------|------|------|------|
| `speed_kmh` | speed_kmh | float | 速度 (km/h) |
| `speed_mph` | speed_mph | float | 速度 (mph) |
| `rpm` | rpm | float | 发动机转速 |
| `max_rpm` | max_rpm | float | 最大转速 |
| `gear` | gear | int | 档位 (0=N, -1=R, 1~8) |
| `throttle` | throttle | float | 油门 (0~1) |
| `brake` | brake | float | 刹车 (0~1) |
| `clutch` | clutch | float | 离合 (0~1) |
| `steering` | steering | float | 转向 (-1~1) |
| `lap_time` | lap_time | float | 当前圈速 (秒) |
| `best_lap` | best_lap | float | 最佳圈速 (秒) |
| `last_lap` | last_lap | float | 上圈时间 (秒) |
| `lap_number` | lap_number | int | 圈数 |
| `fuel` | fuel | float | 燃油量 |
| `boost` | boost | float | 涡轮增压 |
| `horsepower` | horsepower | float | 马力 |
| `torque` | torque | float | 扭矩 |
| `engine_temp` | engine_temp | float | 发动机温度 |
| `oil_temp` | oil_temp | float | 油温 |
特殊自定义属性:
| 属性 | 说明 |
|------|------|
| `data-bind-rpm` | CSS 变量 `--rpm` / `--rpm-pct` / `--rpm-max` |
| `data-bind-speed` | CSS 变量 `--speed` |
| `data-bind-gear` | CSS 变量 `--gear` |
## 快速模板
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
<title>TurboSu - My Dashboard</title>
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#000;color:#fff;
font-family:'SF Pro Text',-apple-system,'PingFang SC',sans-serif}
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
#box{position:relative;overflow:hidden}
#box.contain{max-width:100vw;max-height:100vh}
#box.cover{min-width:100vw;min-height:100vh}
#box.fill{width:100vw;height:100vh}
</style>
</head>
<body>
<div id="root"><div id="box" class="contain"><div id="content">
<div data-bind="speed_kmh" style="font-size:80px;font-weight:900;">0</div>
<div style="font-size:24px;color:rgba(255,255,255,.5);">KM/H</div>
</div></div></div>
<script>
const M={aspect_ratio:"auto",render_mode:"contain"};
(function(){
const box=document.getElementById('box');
const proto=location.protocol==='https:'?'wss:':'ws:';
let ws,rt;
function parseRatio(r){
if(!r||r==='auto')return null;
const p=r.split(':');if(p.length===2){const w=+p[0],h=+p[1];if(w>0&&h>0)return w/h}
return null
}
function apply(){
const ratio=parseRatio(M.aspect_ratio),mode=M.render_mode;
if(!ratio){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
if(mode==='cover'){if(vr>ratio){cw=vw;ch=vw/ratio}else{ch=vh;cw=vh*ratio};box.className='cover'}
else{box.className='contain';if(vr>ratio){ch=vh;cw=vh*ratio}else{cw=vw;ch=vw/ratio}}
box.style.width=cw+'px';box.style.height=ch+'px'
}
function connect(){
ws=new WebSocket(proto+'//'+location.host+'/ws');
ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}};
ws.onmessage=(e)=>{
try{const m=JSON.parse(e.data);if(m.type==='telemetry')
document.querySelectorAll('[data-bind]').forEach(el=>{
const v=m.data[el.getAttribute('data-bind')];
if(v!==undefined)el.textContent=typeof v==='number'?v.toFixed(1):v
})
}catch(e){}
};
ws.onclose=()=>{rt=setTimeout(connect,2000)}
}
apply();window.addEventListener('resize',apply);connect()
})();
</script>
</body>
</html>
```
## 打包分发
将整个文件夹压缩为 zip,改后缀为 `.tsd`,即可在 TurboSu 仪表盘页面导入或在社区分享。
```bash
zip -r my_dashboard.tsd my_dashboard/
```
+222
View File
@@ -0,0 +1,222 @@
# 游戏插件兼容开发指南
TurboSu 通过插件系统支持任意赛车游戏的遥测数据接入。每个插件是 `games/builtin/``games/user/` 下的独立文件夹。
## 文件夹结构
```
games/user/my_game/
├── manifest.json # 元信息
└── parser.py # 解析逻辑
```
## manifest.json 格式
```json
{
"id": "my_game",
"name": "我的游戏",
"parser_type": "forza",
"description": "自定义游戏遥测",
"author": "你的名字",
"version": "1.0.0",
"default_port": 20777
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | string | 是 | 唯一标识,= 文件夹名 |
| `name` | string | 是 | 显示名称 |
| `parser_type` | string | 是 | 解析器类型标签 |
| `description` | string | 否 | 描述文字 |
| `author` | string | 否 | 作者 |
| `version` | string | 否 | 版本号 |
| `default_port` | int | 否 | 默认 UDP 端口 (默认 20777) |
## parser.py 规范
必须导出 `get_parser()` 函数,返回的对象必须实现以下接口:
```python
class MyParser:
def game_id(self) -> str:
"""返回与 manifest.json 一致的 game id"""
return "my_game"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
"""
解析 UDP 数据包
data: 原始字节数据
addr: (host, port) 发送方地址
返回: server.telemetry.data.TelemetryData
"""
```
### 完整示例
```python
import struct
import time
from server.telemetry.data import TelemetryData
def get_parser():
return MyGameParser()
class MyGameParser:
def game_id(self) -> str:
return "my_game"
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(
game_id="my_game",
timestamp=time.time(),
)
td.raw = {
"raw_hex": data.hex(),
"length": len(data),
"addr": f"{addr[0]}:{addr[1]}",
}
# 解析你的游戏数据协议
# 示例: 简单的二进制格式
try:
td.speed_kmh = struct.unpack_from("<f", data, 0)[0]
td.rpm = struct.unpack_from("<f", data, 4)[0]
td.gear = struct.unpack_from("<B", data, 8)[0]
td.throttle = struct.unpack_from("<f", data, 12)[0]
td.brake = struct.unpack_from("<f", data, 16)[0]
td.steering = struct.unpack_from("<f", data, 20)[0]
except struct.error:
pass
return td
```
## TelemetryData 完整字段
```python
@dataclass
class TelemetryData:
game_id: str = "" # 游戏标识
timestamp: float = 0.0 # 时间戳
# 速度和转速
speed_kmh: float = 0.0 # 速度 (km/h)
speed_mph: float = 0.0 # 速度 (mph)
rpm: float = 0.0 # 发动机转速
max_rpm: float = 8000.0 # 最大转速
# 传动
gear: int = 0 # 档位 (0=N, -1=R, 1~8+)
# 踏板 (0~1)
throttle: float = 0.0 # 油门
brake: float = 0.0 # 刹车
clutch: float = 0.0 # 离合
handbrake: float = 0.0 # 手刹
# 转向 (-1 ~ 1)
steering: float = 0.0
# 计时
lap_time: float = 0.0 # 当前圈速 (秒)
best_lap: float = 0.0 # 最佳圈速 (秒)
last_lap: float = 0.0 # 上圈时间 (秒)
lap_number: int = 0 # 圈数
# 位置/加速度
position_x: float = 0.0
position_y: float = 0.0
position_z: float = 0.0
acceleration_x: float = 0.0
acceleration_y: float = 0.0
acceleration_z: float = 0.0
# 车辆状态
engine_temp: float = 0.0 # 发动机温度
oil_temp: float = 0.0 # 油温
fuel: float = 0.0 # 燃油量
# 性能
boost: float = 0.0 # 涡轮增压
horsepower: float = 0.0 # 马力
torque: float = 0.0 # 扭矩
# 车辆信息
car_name: str = "" # 车辆名称
car_class: str = "" # 车辆等级
# 原始数据 (自动转发给前端调试)
raw: dict = {}
```
## 数据协议解析技巧
### 二进制格式 (Forza / ACC / F1 / iRacing)
使用 `struct` 模块解析固定长度的二进制包:
```python
import struct
# 小端浮点
speed = struct.unpack_from("<f", data, offset)[0]
# 小端无符号字节
gear = struct.unpack_from("<B", data, offset)[0]
# 小端无符号短整型
rpm = struct.unpack_from("<H", data, offset)[0]
```
### 文本格式 (Assetto Corsa)
使用字符串分割解析:
```python
text = data.decode("utf-8", errors="replace").rstrip("\r\n")
parts = text.split("\t")
speed = float(parts[0])
rpm = float(parts[1])
gear = int(float(parts[2]))
```
### 未知协议
如果游戏使用未知/自定义协议:
1. 启用 TurboSu 的数据测试页面查看原始 hex 数据
2. 分析数据模式 (浮点数/整数/字符串)
3. 对照游戏文档或社区逆向结果
## 测试方法
1. 将插件文件夹放入 `games/user/`
2. 启动 TurboSu → 侧边栏选择你的游戏
3. 打开数据测试页面查看实时数据
4. 调整解析逻辑直到字段正确
## 打包分发
将整个文件夹压缩为 zip,改后缀为 `.tsp`,即可在 TurboSu 设置页面导入。
```bash
# 进入插件目录
cd games/user/my_game
# 打包 (不含父路径)
zip -r my_game.tsp manifest.json parser.py
```
## 常见游戏数据格式参考
| 游戏 | 格式 | 数据包大小 | 参考来源 |
|------|------|-----------|----------|
| Forza Horizon 4/5 | 二进制 LE | ~324 bytes | Forza Data Out 文档 |
| Forza Motorsport | 二进制 LE | ~324 bytes | Forza Data Out 文档 |
| Assetto Corsa | 文本 (TSV) | 变长 | AC UDP 插件协议 |
| ACC | 二进制 LE | ~200 bytes | ACC Broadcasting SDK |
| F1 系列 | 二进制 LE | ~1289+ bytes | Codemasters F1 UDP Spec |
| iRacing | 二进制 LE | 变长 | iRacing IRSDK |