Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions frametap.toml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frametap の設定。実行ファイルと同じディレクトリに frametap.toml として置く。
# 置かなくても既定値で動く。書いた値が受け付けられなければ、画面の上に警告が出る。

# F 換算の基準。対象のゲームのフレームレートに合わせる。
fps = 60

# 試行を区切る無入力時間 (ms)。全部離してこの時間が過ぎたら、次の入力は別の試行になる。
trial_gap_ms = 300

# 左スティックを方向入力と見なす閾値。中心からの正規化距離で、0 から 1 の間。
stick_deadzone = 0.5

# 画面に積む試行の数。
trials_shown = 5
119 changes: 100 additions & 19 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@ use std::time::Duration;

use eframe::egui;

use crate::config::{Config, Loaded};
use crate::hid_source;
use crate::history::{History, Trial, DEFAULT_RETAIN_US, DEFAULT_TRIAL_GAP_US};
use crate::history::{History, Trial, DEFAULT_RETAIN_US};
use crate::report_decode::decode;
use crate::timeline::{EventKind, InputEvent, Target, Timeline};
use crate::ui;

/// 左スティックを方向入力と見なす閾値。設計 spec の既定値。
const STICK_DEADZONE: f32 = 0.5;

/// 方向を切り替える角度の閾値。セクタの半分にすると、隣のセクタに入った時点で切り替わる。
const STICK_HYSTERESIS_DEG: f32 = 22.5;

Expand All @@ -29,6 +27,19 @@ const RECONNECT_INTERVAL: Duration = Duration::from_secs(1);
/// UI を描き直す間隔。押しっぱなしの長さは入力が無くても伸びるので、待たずに描き直す。
const REPAINT_INTERVAL: Duration = Duration::from_millis(16);

/// 設定の ms を µs に直す。桁溢れは上限で止める。設定ファイルの値を検算していないため。
fn trial_gap_us(config: &Config) -> u64 {
config.trial_gap_ms.saturating_mul(1_000)
}

/// 設定から表示の設定を取り出す。[`ui`] に [`Config`] を知らせないため、変換をここに置く。
fn settings_from(config: &Config) -> ui::Settings {
ui::Settings {
fps: config.fps,
trials_shown: config.trials_shown,
}
}

/// 入力スレッドと UI スレッドが共有する状態。
#[derive(Debug)]
pub struct Shared {
Expand All @@ -37,9 +48,9 @@ pub struct Shared {
}

impl Shared {
pub fn new() -> Self {
pub fn new(config: &Config) -> Self {
Self {
history: Mutex::new(History::new(DEFAULT_RETAIN_US, DEFAULT_TRIAL_GAP_US)),
history: Mutex::new(History::new(DEFAULT_RETAIN_US, trial_gap_us(config))),
status: Mutex::new(ui::Status::default()),
}
}
Expand Down Expand Up @@ -75,23 +86,30 @@ impl Shared {

impl Default for Shared {
fn default() -> Self {
Self::new()
Self::new(&Config::default())
}
}

/// eframe に渡すアプリ。生成した時点で入力スレッドが走り出す。
pub struct FrametapApp {
shared: Arc<Shared>,
settings: ui::Settings,
/// 設定ファイルを読んだときの警告。画面に出したままにする。
notices: Vec<String>,
}

impl FrametapApp {
pub fn new(settings: ui::Settings) -> Self {
let shared = Arc::new(Shared::new());
pub fn new(loaded: Loaded) -> Self {
let Loaded { config, warnings } = loaded;
let shared = Arc::new(Shared::new(&config));
let input = Arc::clone(&shared);
thread::spawn(move || run_input(&input));
thread::spawn(move || run_input(&input, &config));

Self { shared, settings }
Self {
shared,
settings: settings_from(&config),
notices: warnings,
}
}
}

Expand All @@ -101,7 +119,7 @@ impl eframe::App for FrametapApp {
let status = self.shared.status();

// egui の慣習に合わせて引数を ui と呼ぶため、モジュールは絶対パスで書く。
crate::ui::show(ui, &trials, &status, &self.settings);
crate::ui::show(ui, &trials, &status, &self.settings, &self.notices);

// 押しっぱなしの持続 F は入力が無くても伸びる。イベント待ちで止めない。
ui.ctx().request_repaint_after(REPAINT_INTERVAL);
Expand All @@ -112,12 +130,12 @@ impl eframe::App for FrametapApp {
///
/// 履歴は接続をまたいで残す。抜き差しの前に読んでいた試行が消えると、
/// 何が起きたかを確かめる手段がなくなる。
fn run_input(shared: &Shared) {
fn run_input(shared: &Shared, config: &Config) {
loop {
let reason = match hid_source::open() {
Ok(connection) => {
*shared.lock_status() = ui::Status::connected(connection.name().to_owned());
pump(shared, &connection)
pump(shared, &connection, config)
}
Err(reason) => reason,
};
Expand All @@ -130,14 +148,14 @@ fn run_input(shared: &Shared) {
}

/// 1 接続分のレポートを読み続ける。戻り値は読みが止まった理由。
fn pump(shared: &Shared, connection: &hid_source::Connection) -> String {
fn pump(shared: &Shared, connection: &hid_source::Connection, config: &Config) -> String {
let device = connection.device();
let mut timeline = Timeline::new(device, STICK_DEADZONE, STICK_HYSTERESIS_DEG);
let mut timeline = Timeline::new(device, config.stick_deadzone, STICK_HYSTERESIS_DEG);

// [`Timeline`] が出す時刻は接続ごとに 0 から始まる。履歴の時刻は戻せないので、
// 前の接続の末尾から試行の区切り以上空けた位置に載せ直す。空けないと抜き差しの
// 前後が 1 試行に繋がる。
let base_us = shared.now_us() + DEFAULT_TRIAL_GAP_US;
let base_us = shared.now_us() + trial_gap_us(config);
let mut held: Vec<Target> = Vec::new();

loop {
Expand Down Expand Up @@ -246,11 +264,74 @@ mod tests {
assert!(releases(&[], 0).is_empty());
}

fn release(target: Target, at_us: u64) -> InputEvent {
InputEvent {
kind: EventKind::Release,
at_us,
..press(target)
}
}

/// 押して離し、`gap_us` 空けてもう一度押す。区切りが効いたかを試行数で見る。
fn trials_after_a_gap(config: &Config, gap_us: u64) -> usize {
let shared = Shared::new(config);
let first_release_us = 1_000;

shared.push(&[press(Target::Button(Buttons::L1))], 0);
shared.push(
&[release(Target::Button(Buttons::L1), first_release_us)],
first_release_us,
);

let second_press_us = first_release_us + gap_us;
shared.push(
&[InputEvent {
at_us: second_press_us,
..press(Target::Button(Buttons::R1))
}],
second_press_us,
);

shared.snapshot(&ui::Settings::default()).len()
}

/// 区切りの間隔が設定から届く。
#[test]
fn a_shorter_configured_trial_gap_splits_the_trial() {
let config = Config {
trial_gap_ms: 50,
..Config::default()
};

assert_eq!(trials_after_a_gap(&config, 60_000), 2);
}

/// 同じ間隔でも既定の区切り (300ms) では割れない。設定が効いていることの対になる。
#[test]
fn the_same_gap_does_not_split_under_the_default_configuration() {
assert_eq!(trials_after_a_gap(&Config::default(), 60_000), 1);
}

/// 表示の設定が設定ファイルから届く。
#[test]
fn the_display_settings_come_from_the_configuration() {
let config = Config {
fps: 30.0,
trials_shown: 12,
..Config::default()
};

let settings = settings_from(&config);

assert_eq!(settings.fps, 30.0);
assert_eq!(settings.trials_shown, 12);
}

/// 履歴は接続をまたいで残り、次の接続の入力は別の試行になる。
/// 実機を繋がずに、入力スレッドが履歴へ書く手順だけを再現して確かめる。
#[test]
fn a_reconnect_starts_a_new_trial_and_keeps_the_old_one() {
let shared = Shared::new();
let shared = Shared::new(&Config::default());
let first_us = 1_000;
shared.push(&[press(Target::Button(Buttons::L1))], 0);
shared.push(
Expand All @@ -263,7 +344,7 @@ mod tests {
);

// 再接続。デバイス時刻は 0 に戻るので、履歴の末尾から区切り分だけ空けて載せ直す。
let base_us = shared.now_us() + DEFAULT_TRIAL_GAP_US;
let base_us = shared.now_us() + trial_gap_us(&Config::default());
shared.push(
&[InputEvent {
at_us: base_us,
Expand Down
Loading
Loading