desktop: 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名
界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/ (该目录不进仓库)。 1. 客户端显示对手的血量 快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠 数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己 绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用 `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。 2. 血条改到画面两个上角,平行四边形,一条分 5 格 左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg), 右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。 条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、 拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。 注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条 从外侧开始灭(代码里写了注释钉住这件事)。 3. 受击画面不抖动 删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但 **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的 配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关, 并有单测钉住"旧配置读得进来、默认不抖"。 4. 血量归零算击败,不显示"损毁" 血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在 Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己** (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血), 两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也 没有"这局结束了"的痕迹。 5. 裁判端可以改队伍名 两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到 「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个 两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照, 紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name` (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。 名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置 面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。 测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
This commit is contained in:
@@ -141,7 +141,7 @@ pub fn bind_armor(
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 自己属于哪一边(0/1)。改错队号会让血条显示对手的血量,所以也要密码。
|
||||
/// 自己属于哪一边(0/1)。改错队号会把对手的血条放在左上角(当成自己的),所以也要密码。
|
||||
#[tauri::command]
|
||||
pub fn set_team(
|
||||
team: u8,
|
||||
@@ -232,6 +232,42 @@ pub fn set_referee_slots(
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 改一边在界面上显示的名字(0 = 队伍一,1 = 队伍二)。
|
||||
///
|
||||
/// # 为什么一次只改一边
|
||||
///
|
||||
/// 界面本来可以传一整个 `[String; 2]`,但那意味着每次改名都是"读两条、改一条、写两条",
|
||||
/// 而读来的那两条来自最多 200 ms 前的快照:紧接着改另一边就会把上一次的改动覆盖回去。
|
||||
/// 一次只改一边,这个窗口不存在。
|
||||
///
|
||||
/// # 为什么这个命令也过密码闸门
|
||||
///
|
||||
/// 名字本身不是判罚数据,但它写在**配置文件**里。给一个不设闸门的"改配置"入口,等于在
|
||||
/// 密码闸门上开一条旁路——今天改的是名字,明天有人顺手把绑定也挪进来。裁判端本来就没有
|
||||
/// 密码(`Role::needs_password` 为假),所以对裁判端这个闸门是空的,界面上的入口也在裁判端。
|
||||
///
|
||||
/// 规范化(去空白、截断、留空回落默认名)由 [`cam_core::config::Config::normalized`] 统一
|
||||
/// 做,这里只管队伍号合不合法。
|
||||
#[tauri::command]
|
||||
pub fn set_team_name(
|
||||
team: u8,
|
||||
name: String,
|
||||
password: Option<String>,
|
||||
state: Shared<'_>,
|
||||
) -> Result<ConfigView, String> {
|
||||
state.require_unlocked(password.as_deref())?;
|
||||
|
||||
if team > 1 {
|
||||
return Err("队伍号只能是 0(队伍一)或 1(队伍二)".into());
|
||||
}
|
||||
|
||||
state
|
||||
.update_config(move |config| config.team_names[usize::from(team)] = name)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 录制目录与 ffmpeg 路径。
|
||||
#[tauri::command]
|
||||
pub fn set_recording_options(
|
||||
|
||||
@@ -125,7 +125,7 @@ fn spawn_armor(state: Arc<AppState>) {
|
||||
ticks[slot] += 1;
|
||||
|
||||
if hp[slot] == 0 {
|
||||
// 打空之后停一会儿再重置:让界面上"已击毁"的状态真的能被看到。
|
||||
// 打空之后停一会儿再重置:让界面上的胜负横幅真的能被看到。
|
||||
dead_for[slot] += 1;
|
||||
if dead_for[slot] >= 10 {
|
||||
hp[slot] = 100;
|
||||
|
||||
@@ -235,6 +235,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
||||
commands::set_role,
|
||||
commands::set_mirror,
|
||||
commands::save_hud,
|
||||
commands::set_team_name,
|
||||
commands::set_referee_slots,
|
||||
commands::set_recording_options,
|
||||
commands::screenshot,
|
||||
|
||||
@@ -27,7 +27,8 @@ use cam_core::receiver::{Discovery, LatestFrames, Receiver};
|
||||
use cam_core::ArmorSource;
|
||||
use cam_core::slots::SlotAllocator;
|
||||
use cam_core::snapshot::{
|
||||
ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot, SlotsDto, TeamHealthDto,
|
||||
resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot,
|
||||
SlotsDto, TeamHealthDto,
|
||||
};
|
||||
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
|
||||
use tokio::sync::broadcast;
|
||||
@@ -127,7 +128,7 @@ impl AppState {
|
||||
let discovery = receiver.discovery();
|
||||
let latest = receiver.latest_frames();
|
||||
|
||||
// 装甲板:两端都要收。用户端要显示自己的血条,裁判端要显示双方。
|
||||
// 装甲板:两端都要收。用户端要显示**双方**的血条(左上自己、右上对手),裁判端也是双方。
|
||||
let (armor, armor_socket) = match ArmorHealthStore::start() {
|
||||
Ok((store, socket)) => (store, Some(socket)),
|
||||
Err(err) => {
|
||||
@@ -543,11 +544,14 @@ impl AppState {
|
||||
|
||||
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
|
||||
|
||||
let health = match config.role {
|
||||
// 用户端只显示自己那一边。
|
||||
Role::Team => vec![self.health_for(config.team)],
|
||||
Role::Referee => vec![self.health_for(0), self.health_for(1)],
|
||||
};
|
||||
// 两边都给:用户端的画面右上角要显示对手的血量,裁判端本来就要双方。
|
||||
//
|
||||
// 顺序固定为队伍号 0、1,界面按 `team` 取那一格——**不靠数组下标**判断"哪个是我",
|
||||
// 因为下标在"用户端绑的是队伍二"时就不再等于自己的队伍号了。
|
||||
let mut health = vec![self.health_for(0), self.health_for(1)];
|
||||
|
||||
// 胜负是**两边合起来**才能算出来的东西,所以在这里补一步。
|
||||
resolve_outcomes(&mut health);
|
||||
|
||||
Snapshot {
|
||||
role: config.role,
|
||||
@@ -577,14 +581,21 @@ impl AppState {
|
||||
}
|
||||
|
||||
/// 一边的装甲板主板:手动指定优先,否则按板子自报的 `team` 自动找。
|
||||
///
|
||||
/// 槽位指定只有**裁判端**有:`referee.armor_a/b` 是裁判端界面上的"左格/右格",用户端
|
||||
/// 拿它当自己的板子会让绑定被一份不相干的配置盖掉。
|
||||
fn assigned_armor(&self, config: &Config, team: u8) -> Option<u32> {
|
||||
let explicit = if team == 0 {
|
||||
config.referee.armor_a
|
||||
let explicit = if config.role == Role::Referee {
|
||||
if team == 0 {
|
||||
config.referee.armor_a
|
||||
} else {
|
||||
config.referee.armor_b
|
||||
}
|
||||
} else {
|
||||
config.referee.armor_b
|
||||
None
|
||||
};
|
||||
|
||||
if config.referee.manual_slots {
|
||||
if config.role == Role::Referee && config.referee.manual_slots {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
@@ -601,27 +612,29 @@ impl AppState {
|
||||
///
|
||||
/// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线
|
||||
/// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。
|
||||
///
|
||||
/// # 对手那一块绝不能用 `armor_id`
|
||||
///
|
||||
/// `armor_id` 是**我自己**绑定的主板。拿它去填对手那一格,右上角显示的就是自己的血——
|
||||
/// 比不显示更糟:现场会照着一个假的对手血量做决策。
|
||||
fn health_for(&self, team: u8) -> TeamHealthDto {
|
||||
let config = self.config();
|
||||
let low = config.hud.health.low_threshold;
|
||||
|
||||
let board = match config.role {
|
||||
Role::Team => config
|
||||
.armor_id
|
||||
.or_else(|| self.assigned_armor(&config, team)),
|
||||
Role::Referee => self.assigned_armor(&config, team),
|
||||
};
|
||||
let own = config.role == Role::Team && team == config.team;
|
||||
let bound = if own { config.armor_id } else { None };
|
||||
let board = bound.or_else(|| self.assigned_armor(&config, team));
|
||||
|
||||
if let Some(board) = board {
|
||||
return match self.armor.health_of_board(board) {
|
||||
Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low),
|
||||
None => TeamHealthDto::absent(team, Some(board)),
|
||||
Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low, &config.team_names),
|
||||
None => TeamHealthDto::absent(team, Some(board), &config.team_names),
|
||||
};
|
||||
}
|
||||
|
||||
match self.armor.health(team) {
|
||||
Some(hps) => TeamHealthDto::present(team, None, hps, low),
|
||||
None => TeamHealthDto::absent(team, None),
|
||||
Some(hps) => TeamHealthDto::present(team, None, hps, low, &config.team_names),
|
||||
None => TeamHealthDto::absent(team, None, &config.team_names),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user