mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 09:00:28 +02:00
Add item throwing mechanics to VR configuration and input handling
- Introduced `vrCockpitItemThrow` configuration option to enable throwing items in VR. - Implemented `ThrowDetector` and `ThrowSequence` classes to manage item throwing mechanics based on hand movements. - Updated `OpenXRInput` to handle item throwing based on hand swings, integrating with existing VR input systems. - Enhanced settings overlay to allow users to toggle item throwing functionality. - Added tests for item throwing mechanics, ensuring correct behavior for various hand movements and conditions.
This commit is contained in:
1 parent
db30945f4c
commit
ebc97ef9ce
12 files changed
+701
-6
No files matched your search
@@ -415,10 +415,10 @@ add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests)
|
||||
|
||||
# The first-person cockpit (seated eye, wheel and handlebar geometry, level seat,
|
||||
# native wheel vertices) and hand steering (grab, turn, hand-off to the game),
|
||||
# ported from heurazy's mario-kart-wii-VR-port, and tracked hands (grasp, bare-hand
|
||||
# buttons, flick). All header-only.
|
||||
# ported from heurazy's mario-kart-wii-VR-port, tracked hands (grasp, bare-hand
|
||||
# buttons, flick) and throwing the held item. All header-only.
|
||||
foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_bullet_bill_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests
|
||||
mkw_vr_hand_tracking_tests mkw_vr_culling_tests)
|
||||
mkw_vr_hand_tracking_tests mkw_vr_culling_tests mkw_vr_item_throw_tests)
|
||||
string(REGEX REPLACE "^mkw_" "" test_source "${test_name}")
|
||||
add_executable(${test_name} "${CMAKE_CURRENT_LIST_DIR}/tests/${test_source}.cpp")
|
||||
target_include_directories(${test_name} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
|
||||
@@ -79,6 +79,7 @@ struct RuntimeUserConfig {
|
||||
std::optional<bool> vrObjectCulling;
|
||||
std::optional<bool> vrHandSteering;
|
||||
std::optional<std::string> vrCockpitItemHand;
|
||||
std::optional<bool> vrCockpitItemThrow;
|
||||
std::optional<bool> vrHandTracking;
|
||||
std::optional<float> vrWheelKartDegrees;
|
||||
std::optional<float> vrWheelBikeDegrees;
|
||||
@@ -239,6 +240,8 @@ inline constexpr bool kVrSteeringWheelDefault = true;
|
||||
inline constexpr bool kVrNativeSteeringWheelDefault = true;
|
||||
inline constexpr bool kVrHandSteeringDefault = true;
|
||||
inline constexpr const char* kVrCockpitItemHandDefault = "left";
|
||||
// A quick swing of the item hand forward or back throws the item that way.
|
||||
inline constexpr bool kVrCockpitItemThrowDefault = true;
|
||||
// The game hides karts and objects its own camera cannot see, which a head
|
||||
// turn in VR reveals. object_culling false draws them anyway (see
|
||||
// vr/mkw_vr_culling.h); it only takes effect while VR is enabled. The PC
|
||||
@@ -609,6 +612,8 @@ inline void EnsureConfigFile() {
|
||||
"hand_steering = true\n"
|
||||
"# Show the settled inventory item in one cockpit hand: left, right, or off.\n"
|
||||
"cockpit_item_hand = \"left\"\n"
|
||||
"# Swing that hand forward or back to throw the item that way.\n"
|
||||
"cockpit_item_throw = true\n"
|
||||
"wheel_kart_degrees = 90.0\n"
|
||||
"wheel_bike_degrees = 45.0\n"
|
||||
"wheel_grab_distance = 0.35\n"
|
||||
@@ -885,6 +890,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
||||
config.vrObjectCulling = FindConfigValue<bool>(document, "vr", "object_culling");
|
||||
config.vrHandSteering = FindConfigValue<bool>(document, "vr", "hand_steering");
|
||||
config.vrCockpitItemHand = FindConfigValue<std::string>(document, "vr", "cockpit_item_hand");
|
||||
config.vrCockpitItemThrow = FindConfigValue<bool>(document, "vr", "cockpit_item_throw");
|
||||
config.vrHandTracking = FindConfigValue<bool>(document, "vr", "hand_tracking");
|
||||
config.vrWheelKartDegrees = readRangedFloat("wheel_kart_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax);
|
||||
config.vrWheelBikeDegrees = readRangedFloat("wheel_bike_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax);
|
||||
@@ -1293,6 +1299,11 @@ inline bool SetVrCockpitItemHand(const std::string& value) {
|
||||
return WriteSetting("vr", "cockpit_item_hand", "\"" + value + "\"");
|
||||
}
|
||||
|
||||
inline bool SetVrCockpitItemThrow(bool value) {
|
||||
Mutable().vrCockpitItemThrow = value;
|
||||
return WriteSetting("vr", "cockpit_item_throw", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrHandTracking(bool value) {
|
||||
Mutable().vrHandTracking = value;
|
||||
return WriteSetting("vr", "hand_tracking", value ? "true" : "false");
|
||||
@@ -1789,6 +1800,10 @@ inline std::string VrCockpitItemHand() {
|
||||
return value == "left" || value == "right" || value == "off" ? value : kVrCockpitItemHandDefault;
|
||||
}
|
||||
|
||||
inline bool VrCockpitItemThrow(bool fallback = kVrCockpitItemThrowDefault) {
|
||||
return Get().vrCockpitItemThrow.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool VrHandTracking(bool fallback = kVrHandTrackingDefault) {
|
||||
return Get().vrHandTracking.value_or(fallback);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "vr/camera_toggle.h"
|
||||
#include "vr/openxr_driving.h"
|
||||
#include "vr/openxr_hand_tracking.h"
|
||||
#include "vr/openxr_item_throw.h"
|
||||
#include "vr/openxr_runtime.h"
|
||||
#include "vr/openxr_settings_panel.h"
|
||||
#include "vr/openxr_wii_remote.h"
|
||||
@@ -72,6 +73,11 @@ struct OpenXRPointerScreen {
|
||||
// the game (a shoulder on the gamepad; as a Wii Remote the grips are unbound,
|
||||
// so a hand on the wheel cannot hold down a button).
|
||||
//
|
||||
// Throwing the held item ([vr] cockpit_item_throw, openxr_item_throw.h): in the
|
||||
// cockpit, a quick swing forward or back of the hand showing the item pushes the
|
||||
// left stick that way while the left trigger (Z, or the GameCube's L) is pressed
|
||||
// and released, which the game reads as an aimed throw.
|
||||
//
|
||||
// Tracked hands ([vr] hand_tracking, with hand steering): two hand trackers
|
||||
// (XR_EXT_hand_tracking) located every frame give the cockpit the hands' own
|
||||
// joints, from the controllers' touch sensors while they are held and from the
|
||||
@@ -160,6 +166,9 @@ private:
|
||||
void UpdateDriving(XrTime display_time, const driving::SeatFrame& seat,
|
||||
std::array<wii_remote::HandInputs, kHands>& hands, bool withheld);
|
||||
void ResetDriving();
|
||||
// After the wheel and the bare hands: the item hand's swing, and the throw it
|
||||
// plays on the left hand's stick and trigger.
|
||||
void UpdateItemThrow(float dt_seconds, std::array<wii_remote::HandInputs, kHands>& hands, bool withheld);
|
||||
// Tracked hands: the extension's functions at Create, the trackers as the
|
||||
// settings ask for them, and both hands located for `time`, their joints
|
||||
// in `seat` when it is valid.
|
||||
@@ -255,6 +264,10 @@ private:
|
||||
hand_tracking::FlickDetector m_flick;
|
||||
XrTime m_flick_start = 0;
|
||||
bool m_injected_flick_held = false;
|
||||
// Throwing the held item.
|
||||
item_throw::ThrowDetector m_throw;
|
||||
item_throw::ThrowSequence m_throw_sequence;
|
||||
bool m_injected_throw_held = false;
|
||||
uint64_t m_profile_serial = 0;
|
||||
|
||||
bool m_created = false;
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
// Throwing the held item ([vr] cockpit_item_throw). In the first-person cockpit,
|
||||
// a quick swing forward or backward of the hand that shows the item plays what
|
||||
// Mario Kart Wii reads as an aimed throw: the stick pushed forward or back while
|
||||
// the item button is pressed and released. It goes through the left hand's
|
||||
// controls, which both controller modes read as the stick and the item button
|
||||
// (the Nunchuk's stick and Z, the GameCube's stick and L), so it works whichever
|
||||
// hand shows the item and with bare hands too. Nothing here depends on OpenXR,
|
||||
// so the rules are tested headlessly (tests/vr_item_throw_tests.cpp).
|
||||
|
||||
#include "vr/openxr_wii_remote.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace mkw::vr::item_throw {
|
||||
|
||||
enum class Direction : uint8_t { None, Forward, Backward };
|
||||
|
||||
inline const char* DirectionLabel(Direction direction) noexcept {
|
||||
switch (direction) {
|
||||
case Direction::Forward: return "forward";
|
||||
case Direction::Backward: return "backward";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
inline bool IsFinite(float value) noexcept {
|
||||
// Bit test: the runtime may be built with -ffast-math.
|
||||
uint32_t bits = 0;
|
||||
std::memcpy(&bits, &value, sizeof(bits));
|
||||
return (bits & 0x7F800000u) != 0x7F800000u;
|
||||
}
|
||||
|
||||
// The item hand this frame, in the seated frame (+X right, +Y up, -Z forward),
|
||||
// in metres: the grip, or the palm joint when the hand is drawn from joints.
|
||||
struct HandSample {
|
||||
bool tracked = false;
|
||||
bool held = false; // holding the wheel or handlebar
|
||||
bool bare = false; // tracked by the headset's cameras, no controller in it
|
||||
std::array<float, 3> position{};
|
||||
};
|
||||
|
||||
// What counts as a throw for one kind of hand: the travel along the seated
|
||||
// frame's forward axis within the window, how much of the window's displacement
|
||||
// must be along that axis, the speed along it that means the swing goes on, and
|
||||
// how long a loss of tracking is bridged by the positions either side of it.
|
||||
struct Tuning {
|
||||
float window_seconds;
|
||||
float forward_travel; // m
|
||||
float backward_travel; // m: throwing back is harder
|
||||
float axis_share;
|
||||
float moving_speed; // m/s
|
||||
float max_gap_seconds;
|
||||
size_t min_samples;
|
||||
};
|
||||
// A controller is located smoothly every frame: 1.5 m/s forward, 1.25 back.
|
||||
inline constexpr Tuning kControllerTuning{0.12f, 0.18f, 0.15f, 0.7f, 0.5f, 0.1f, 3};
|
||||
// The cameras lag and smooth a bare hand and often lose it for a few frames in
|
||||
// the middle of a fast swing, so its swing reads slower and shorter: a longer
|
||||
// window, less travel (1 m/s forward, 0.87 back), and the gap bridged.
|
||||
inline constexpr Tuning kBareHandTuning{0.15f, 0.15f, 0.13f, 0.65f, 0.3f, 0.2f, 2};
|
||||
|
||||
// A throw is a swing along the seated frame's forward axis: within the last
|
||||
// window the hand has covered at least the direction's travel along that axis,
|
||||
// mostly along it (so a sideways sweep or the upward flick that does a trick
|
||||
// never counts), and it is still moving that way. Reaching for the wheel or
|
||||
// bringing a hand back to rest is too slow to cover the travel. The window is
|
||||
// counted in tracked time, so a bridged gap does not use it up; across a gap
|
||||
// the swing must still average most of the window's speed, so a slow drift the
|
||||
// cameras briefly lost never counts. A hand fires once per swing and then waits
|
||||
// out the cooldown; a hand that just let go of the wheel must be free a moment
|
||||
// first; a tracking jump, faster than any hand, restarts the track.
|
||||
class ThrowDetector {
|
||||
public:
|
||||
static constexpr float kJumpSpeed = 8.0f; // m/s: faster than a hand, a tracking jump
|
||||
static constexpr float kGapSpeedShare = 0.7f;
|
||||
static constexpr float kFreeSeconds = 0.25f;
|
||||
static constexpr float kCooldownSeconds = 0.6f;
|
||||
|
||||
// What the last throw measured, for the log.
|
||||
struct Measure {
|
||||
float travel = 0.0f; // m along the forward axis, + forward
|
||||
float seconds = 0.0f; // the window it covered, gaps included
|
||||
bool bare = false;
|
||||
bool bridged = false; // the window spans a loss of tracking
|
||||
};
|
||||
|
||||
Direction Update(const HandSample& hand, float dt) noexcept {
|
||||
if (!IsFinite(dt) || dt <= 0.0f) {
|
||||
return Direction::None;
|
||||
}
|
||||
dt = std::min(dt, 0.1f);
|
||||
m_cooldown = std::max(m_cooldown - dt, 0.0f);
|
||||
m_free = std::min(m_free + dt, 1.0f);
|
||||
m_time += dt;
|
||||
const Tuning& tuning = hand.bare ? kBareHandTuning : kControllerTuning;
|
||||
const auto& p = hand.position;
|
||||
if (!hand.tracked || !IsFinite(p[0]) || !IsFinite(p[1]) || !IsFinite(p[2])) {
|
||||
if (m_count > 0 && m_time - At(0).time > tuning.max_gap_seconds) {
|
||||
ClearTrack();
|
||||
}
|
||||
return Direction::None;
|
||||
}
|
||||
if (hand.held) {
|
||||
ClearTrack();
|
||||
m_free = 0.0f;
|
||||
return Direction::None;
|
||||
}
|
||||
bool bridged_now = false;
|
||||
if (m_count > 0) {
|
||||
const Sample& last = At(0);
|
||||
const float elapsed = m_time - last.time;
|
||||
if (elapsed > tuning.max_gap_seconds + dt ||
|
||||
Distance(p, last.position) / std::max(elapsed, 1.0e-4f) > kJumpSpeed) {
|
||||
ClearTrack();
|
||||
} else {
|
||||
bridged_now = elapsed > dt + 1.0e-4f;
|
||||
}
|
||||
}
|
||||
m_tracked_time += dt;
|
||||
Push({m_time, m_tracked_time, bridged_now, p});
|
||||
if (m_count < 2) {
|
||||
return Direction::None;
|
||||
}
|
||||
// The swing's speed along the axis now (+ forward), from the last step.
|
||||
const Sample& previous = At(1);
|
||||
const float forward_speed = -(p[2] - previous.position[2]) / std::max(m_time - previous.time, 1.0e-4f);
|
||||
if (m_swing != Direction::None && Along(m_swing, forward_speed) < tuning.moving_speed) {
|
||||
m_swing = Direction::None; // that swing is over
|
||||
}
|
||||
// The oldest sample still inside the window, in tracked time.
|
||||
size_t oldest = 0;
|
||||
bool bridged = false;
|
||||
for (size_t i = 1; i < m_count && m_tracked_time - At(i).tracked <= tuning.window_seconds + 1.0e-4f; ++i) {
|
||||
bridged = bridged || At(i - 1).bridged;
|
||||
oldest = i;
|
||||
}
|
||||
if (oldest + 1 < tuning.min_samples) {
|
||||
return Direction::None;
|
||||
}
|
||||
const Sample& start = At(oldest);
|
||||
const std::array<float, 3> moved{p[0] - start.position[0], p[1] - start.position[1], p[2] - start.position[2]};
|
||||
const float length = std::sqrt(moved[0] * moved[0] + moved[1] * moved[1] + moved[2] * moved[2]);
|
||||
const float travel = -moved[2];
|
||||
const float seconds = m_time - start.time;
|
||||
const Direction direction = travel >= tuning.forward_travel ? Direction::Forward
|
||||
: -travel >= tuning.backward_travel ? Direction::Backward
|
||||
: Direction::None;
|
||||
if (direction == Direction::None) {
|
||||
return Direction::None;
|
||||
}
|
||||
const float needed = direction == Direction::Forward ? tuning.forward_travel : tuning.backward_travel;
|
||||
if (std::fabs(travel) < tuning.axis_share * length || Along(direction, forward_speed) < tuning.moving_speed ||
|
||||
std::fabs(travel) / std::max(seconds, 1.0e-4f) < kGapSpeedShare * needed / tuning.window_seconds) {
|
||||
return Direction::None;
|
||||
}
|
||||
if (m_free < kFreeSeconds || m_cooldown > 0.0f || m_swing == direction) {
|
||||
// Too soon: this whole swing is spent, however long it goes on.
|
||||
m_swing = direction;
|
||||
return Direction::None;
|
||||
}
|
||||
m_cooldown = kCooldownSeconds;
|
||||
m_swing = direction;
|
||||
m_last = {travel, seconds, hand.bare, bridged};
|
||||
return direction;
|
||||
}
|
||||
|
||||
const Measure& Last() const noexcept { return m_last; }
|
||||
|
||||
void Reset() noexcept { *this = ThrowDetector{}; }
|
||||
|
||||
private:
|
||||
struct Sample {
|
||||
float time = 0.0f; // since the detector started, gaps included
|
||||
float tracked = 0.0f; // tracked time only
|
||||
bool bridged = false; // follows a loss of tracking
|
||||
std::array<float, 3> position{};
|
||||
};
|
||||
static constexpr size_t kCapacity = 32; // over either window at any headset's rate
|
||||
|
||||
static float Along(Direction direction, float forward_speed) noexcept {
|
||||
return direction == Direction::Forward ? forward_speed : -forward_speed;
|
||||
}
|
||||
static float Distance(const std::array<float, 3>& a, const std::array<float, 3>& b) noexcept {
|
||||
const float x = a[0] - b[0], y = a[1] - b[1], z = a[2] - b[2];
|
||||
return std::sqrt(x * x + y * y + z * z);
|
||||
}
|
||||
// Newest first: At(0) is the latest sample.
|
||||
const Sample& At(size_t age) const noexcept { return m_samples[(m_next + kCapacity - 1 - age) % kCapacity]; }
|
||||
void Push(const Sample& sample) noexcept {
|
||||
m_samples[m_next] = sample;
|
||||
m_next = (m_next + 1) % kCapacity;
|
||||
m_count = std::min(m_count + 1, kCapacity);
|
||||
}
|
||||
void ClearTrack() noexcept {
|
||||
m_count = 0;
|
||||
m_swing = Direction::None;
|
||||
}
|
||||
|
||||
std::array<Sample, kCapacity> m_samples{};
|
||||
size_t m_next = 0;
|
||||
size_t m_count = 0;
|
||||
float m_time = 0.0f;
|
||||
float m_tracked_time = 0.0f;
|
||||
float m_free = 1.0f; // seconds since the hand last held the wheel, capped
|
||||
float m_cooldown = 0.0f;
|
||||
Direction m_swing = Direction::None; // the swing that last fired, while it lasts
|
||||
Measure m_last{};
|
||||
};
|
||||
|
||||
// The input a throw plays on the left hand's controls. The stick goes first and
|
||||
// stays until the item button has been pressed and released again, so an item
|
||||
// used on the press (a Mushroom) and one thrown on the release (a trailed shell
|
||||
// or banana) both see the aim. A button the player was already holding, to
|
||||
// trail an item, is released by the throw and then ignored until the player
|
||||
// lets go of it, so it does not use the next item of a triple.
|
||||
class ThrowSequence {
|
||||
public:
|
||||
static constexpr float kAimSeconds = 0.05f; // the stick alone, a few game frames
|
||||
static constexpr float kPressSeconds = 0.10f;
|
||||
static constexpr float kReleaseSeconds = 0.10f; // the stick still aimed
|
||||
|
||||
void Start(Direction direction) noexcept {
|
||||
if (direction == Direction::None) {
|
||||
return;
|
||||
}
|
||||
m_direction = direction;
|
||||
m_elapsed = 0.0f;
|
||||
}
|
||||
|
||||
bool Active() const noexcept { return m_direction != Direction::None; }
|
||||
|
||||
void Apply(wii_remote::HandInputs& left, float dt) noexcept {
|
||||
const bool held = left.trigger > wii_remote::kPressThreshold;
|
||||
if (m_ignore_held) {
|
||||
if (held) {
|
||||
left.trigger = 0.0f;
|
||||
} else {
|
||||
m_ignore_held = false;
|
||||
}
|
||||
}
|
||||
if (!Active()) {
|
||||
return;
|
||||
}
|
||||
left.stick_y = m_direction == Direction::Forward ? 1.0f : -1.0f;
|
||||
if (m_elapsed >= kAimSeconds + kPressSeconds) {
|
||||
m_ignore_held = m_ignore_held || held;
|
||||
left.trigger = 0.0f;
|
||||
} else if (m_elapsed >= kAimSeconds) {
|
||||
left.trigger = 1.0f;
|
||||
}
|
||||
m_elapsed += IsFinite(dt) ? std::clamp(dt, 0.0f, 0.1f) : 0.0f;
|
||||
if (m_elapsed >= kAimSeconds + kPressSeconds + kReleaseSeconds) {
|
||||
m_direction = Direction::None;
|
||||
}
|
||||
}
|
||||
|
||||
void Reset() noexcept { *this = ThrowSequence{}; }
|
||||
|
||||
private:
|
||||
Direction m_direction = Direction::None;
|
||||
float m_elapsed = 0.0f;
|
||||
bool m_ignore_held = false;
|
||||
};
|
||||
|
||||
} // namespace mkw::vr::item_throw
|
||||
@@ -171,6 +171,7 @@ bool g_vrNativeSteeringWheel = RuntimeConfigFile::VrNativeSteeringWheel();
|
||||
bool g_vrObjectCulling = RuntimeConfigFile::VrObjectCulling();
|
||||
bool g_vrHandSteering = RuntimeConfigFile::VrHandSteering();
|
||||
constexpr std::array<const char*, 3> kVrCockpitItemHands{"Left", "Right", "Off"};
|
||||
bool g_vrCockpitItemThrow = RuntimeConfigFile::VrCockpitItemThrow();
|
||||
int g_vrCockpitItemHand = RuntimeConfigFile::VrCockpitItemHand() == "right" ? 1 :
|
||||
RuntimeConfigFile::VrCockpitItemHand() == "off" ? 2 : 0;
|
||||
mkw::vr::WheelTuning g_vrWheelTuning = RuntimeConfigFile::VrWheelTuning();
|
||||
@@ -1235,6 +1236,17 @@ void DrawVrSteeringWheelSettings() {
|
||||
ImGui::SetTooltip("Show Player 1's settled inventory item above this palm. "
|
||||
"Triple items show their remaining count. Using or losing the item hides it.");
|
||||
}
|
||||
ImGui::BeginDisabled(g_vrCockpitItemHand == 2);
|
||||
if (ImGui::Checkbox("Throw the item by swinging that hand", &g_vrCockpitItemThrow)) {
|
||||
RuntimeConfigFile::SetVrCockpitItemThrow(g_vrCockpitItemThrow);
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("With the hand off the wheel, swing it quickly forward to throw the item ahead "
|
||||
"of the kart, or back to throw it behind: the stick and item button the game "
|
||||
"reads for an aimed throw. Swinging while holding the item button throws the "
|
||||
"trailed item.");
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
#if defined(__ANDROID__)
|
||||
ImGui::BeginDisabled(!g_vrHandSteering && g_vrCockpitItemHand == 2);
|
||||
if (ImGui::Checkbox("Tracked hands", &g_vrHandTracking)) {
|
||||
@@ -1759,12 +1771,14 @@ void DrawVrCameraSettings() {
|
||||
g_vrNativeSteeringWheel = RuntimeConfigFile::kVrNativeSteeringWheelDefault;
|
||||
g_vrHandSteering = RuntimeConfigFile::kVrHandSteeringDefault;
|
||||
g_vrCockpitItemHand = 0;
|
||||
g_vrCockpitItemThrow = RuntimeConfigFile::kVrCockpitItemThrowDefault;
|
||||
RuntimeConfigFile::SetVrFirstPersonSeat(RuntimeConfigFile::kVrFirstPersonSeatDefault);
|
||||
RuntimeConfigFile::SetVrCockpitUnitsPerMeter(g_vrCockpitUnitsPerMeter);
|
||||
RuntimeConfigFile::SetVrSteeringWheel(g_vrSteeringWheel);
|
||||
RuntimeConfigFile::SetVrNativeSteeringWheel(g_vrNativeSteeringWheel);
|
||||
RuntimeConfigFile::SetVrHandSteering(g_vrHandSteering);
|
||||
RuntimeConfigFile::SetVrCockpitItemHand(RuntimeConfigFile::kVrCockpitItemHandDefault);
|
||||
RuntimeConfigFile::SetVrCockpitItemThrow(g_vrCockpitItemThrow);
|
||||
#if defined(__ANDROID__)
|
||||
g_vrHandTracking = RuntimeConfigFile::kVrHandTrackingDefault;
|
||||
RuntimeConfigFile::SetVrHandTracking(g_vrHandTracking);
|
||||
|
||||
@@ -215,8 +215,9 @@ VirtualGamepadRelay& Relay() {
|
||||
// directions push the Nunchuk stick). `panel` presses the settings panel's
|
||||
// button (left Y, or both thumbsticks for a gamepad), opening or closing it,
|
||||
// where `a` then selects. `flick` plays the bare hands' flick, one shake of the
|
||||
// remote. The property is unset in normal use, so this costs one property read
|
||||
// every few frames.
|
||||
// remote; `throw_forward` and `throw_backward` play a throw of the held item.
|
||||
// The property is unset in normal use, so this costs one property read every
|
||||
// few frames.
|
||||
constexpr uint32_t kInjectHoldFrames = 12;
|
||||
constexpr uint32_t kInjectPollFrames = 4;
|
||||
|
||||
@@ -857,6 +858,7 @@ void OpenXRInput::Destroy() {
|
||||
m_sources_logged_at = 0;
|
||||
m_squeeze_active = m_hand_driven = m_pinch = {};
|
||||
m_injected_flick_held = false;
|
||||
m_injected_throw_held = false;
|
||||
m_profile_serial = 0;
|
||||
DestroyPoseSpaces();
|
||||
if (m_action_set != XR_NULL_HANDLE) {
|
||||
@@ -1133,6 +1135,10 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen&
|
||||
m_injected_flick_held = injected_flick;
|
||||
#endif
|
||||
|
||||
// Throwing the held item: a quick swing of the item hand forward or back
|
||||
// plays an aimed throw on the stick and the item button.
|
||||
UpdateItemThrow(dt_seconds, hands, withheld);
|
||||
|
||||
// What the game sees of the controllers: nothing held while they are withheld.
|
||||
static const std::array<wii_remote::HandInputs, kHands> kIdleHands{};
|
||||
const auto& game_hands = withheld ? kIdleHands : hands;
|
||||
@@ -1319,9 +1325,76 @@ void OpenXRInput::ResetDriving() {
|
||||
}
|
||||
m_flick.Reset();
|
||||
m_flick_start = 0;
|
||||
m_throw.Reset();
|
||||
m_throw_sequence.Reset();
|
||||
OpenXRPublishDriving(m_driving);
|
||||
}
|
||||
|
||||
void OpenXRInput::UpdateItemThrow(float dt_seconds, std::array<wii_remote::HandInputs, kHands>& hands,
|
||||
bool withheld) {
|
||||
const std::string item_hand = RuntimeConfigFile::VrCockpitItemHand();
|
||||
const uint32_t hand = item_hand == "right" ? 1u : 0u;
|
||||
item_throw::Direction direction = item_throw::Direction::None;
|
||||
if (!withheld && m_driving.cockpit_active && item_hand != "off" && RuntimeConfigFile::VrCockpitItemThrow()) {
|
||||
// The hand as the cockpit draws the item on it: the palm joint when the
|
||||
// hand is drawn from joints, else the grip. The detector follows it with
|
||||
// or without an item, so it always knows when the hand left the wheel.
|
||||
const DrivingHand& located = m_driving.hands[hand];
|
||||
const std::array<float, 12>& pose =
|
||||
located.joints_valid ? m_joint_frame.seat_from_joint[hand][hand_tracking::kPalm] : located.seat_from_grip;
|
||||
item_throw::HandSample sample{};
|
||||
sample.tracked = located.tracked;
|
||||
sample.held = located.held;
|
||||
sample.bare = located.bare;
|
||||
sample.position = {pose[3], pose[7], pose[11]};
|
||||
direction = m_throw.Update(sample, dt_seconds);
|
||||
// A swing with nothing in the hand throws nothing.
|
||||
if (direction != item_throw::Direction::None) {
|
||||
const HeldItem item = MkwVRFirstPersonGetHeldItem();
|
||||
if (!item.valid || item.count == 0) {
|
||||
direction = item_throw::Direction::None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m_throw.Reset();
|
||||
}
|
||||
// `debug.wiicompiled.inject <n>:throw_forward` or `throw_backward` plays the
|
||||
// same throw without the swing, to try it unattended.
|
||||
const bool injected_forward = Injected("throw_forward");
|
||||
const bool injected_backward = Injected("throw_backward");
|
||||
bool injected = false;
|
||||
if ((injected_forward || injected_backward) && !m_injected_throw_held && !withheld) {
|
||||
direction = injected_forward ? item_throw::Direction::Forward : item_throw::Direction::Backward;
|
||||
injected = true;
|
||||
}
|
||||
m_injected_throw_held = injected_forward || injected_backward;
|
||||
if (direction != item_throw::Direction::None) {
|
||||
m_throw_sequence.Start(direction);
|
||||
if (RuntimeConfigFile::VrWheelTuning().haptics) {
|
||||
constexpr XrDuration kThrowPulseNs = 40'000'000;
|
||||
ApplyHaptic(hand, 0.5f, kThrowPulseNs);
|
||||
}
|
||||
// What the swing measured, to tune the thresholds from a run log.
|
||||
std::ostringstream message;
|
||||
message << "Held item thrown " << item_throw::DirectionLabel(direction);
|
||||
if (injected) {
|
||||
message << " (injected)";
|
||||
} else {
|
||||
const auto& measure = m_throw.Last();
|
||||
message << " (" << (measure.bare ? "bare hand" : "controller") << ", "
|
||||
<< std::lround(std::fabs(measure.travel) * 100.0f) << " cm in "
|
||||
<< std::lround(measure.seconds * 1000.0f) << " ms"
|
||||
<< (measure.bridged ? ", across a tracking gap" : "") << ')';
|
||||
}
|
||||
Log(OpenXRLogLevel::Info, message.str());
|
||||
}
|
||||
if (withheld) {
|
||||
m_throw_sequence.Reset();
|
||||
return;
|
||||
}
|
||||
m_throw_sequence.Apply(hands[0], dt_seconds);
|
||||
}
|
||||
|
||||
void OpenXRInput::UpdateDriving(XrTime display_time, const driving::SeatFrame& seat,
|
||||
std::array<wii_remote::HandInputs, kHands>& hands, bool withheld) {
|
||||
const FirstPersonAnchor anchor = MkwVRFirstPersonGetAnchor();
|
||||
|
||||
@@ -22,6 +22,10 @@ int main() {
|
||||
Require(std::string_view(RuntimeConfigFile::kVrCockpitItemHandDefault) == "left");
|
||||
Require(!Parse("[vr]\n").vrCockpitItemHand.has_value());
|
||||
Require(!Parse("[vr]\ncockpit_item_hand = 1\n").vrCockpitItemHand.has_value());
|
||||
Require(RuntimeConfigFile::kVrCockpitItemThrowDefault);
|
||||
Require(Parse("[vr]\ncockpit_item_throw = false\n").vrCockpitItemThrow == false);
|
||||
Require(Parse("[vr]\ncockpit_item_throw = true\n").vrCockpitItemThrow == true);
|
||||
Require(!Parse("[vr]\n").vrCockpitItemThrow.has_value());
|
||||
// [vr] foveation: the Quest's foveated rendering level, index-matched to
|
||||
// aurora_set_stereo_foveation.
|
||||
for (std::string_view level : RuntimeConfigFile::kVrFoveationLevels) {
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Throwing the held item: the swing detector and the stick and item button it
|
||||
// plays, tested without a headset (vr/openxr_item_throw.h).
|
||||
|
||||
#include "vr/openxr_item_throw.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using namespace mkw::vr;
|
||||
using namespace mkw::vr::item_throw;
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
void Check(bool condition, const char* what) {
|
||||
if (!condition) {
|
||||
++g_failures;
|
||||
std::cerr << "FAILED: " << what << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
struct Fire {
|
||||
Direction direction;
|
||||
float time;
|
||||
};
|
||||
|
||||
// Plays `duration` seconds of a hand moving with `velocity(t)` (m/s in the seated
|
||||
// frame) from `start`, at `rate` samples a second, and returns every throw.
|
||||
std::vector<Fire> Play(ThrowDetector& detector, const std::function<std::array<float, 3>(float)>& velocity,
|
||||
float duration, float rate = 90.0f, std::array<float, 3> start = {-0.2f, -0.3f, -0.35f},
|
||||
const std::function<bool(float)>& held = nullptr, bool bare = false,
|
||||
const std::function<bool(float)>& tracked = nullptr) {
|
||||
std::vector<Fire> fires;
|
||||
const float dt = 1.0f / rate;
|
||||
std::array<float, 3> position = start;
|
||||
for (float t = 0.0f; t < duration; t += dt) {
|
||||
const auto v = velocity(t);
|
||||
for (int axis = 0; axis < 3; ++axis) {
|
||||
position[axis] += v[axis] * dt;
|
||||
}
|
||||
HandSample sample{};
|
||||
sample.tracked = tracked ? tracked(t) : true;
|
||||
sample.held = held ? held(t) : false;
|
||||
sample.bare = bare;
|
||||
sample.position = position;
|
||||
const Direction direction = detector.Update(sample, dt);
|
||||
if (direction != Direction::None) {
|
||||
fires.push_back({direction, t});
|
||||
}
|
||||
}
|
||||
return fires;
|
||||
}
|
||||
|
||||
// A flick: speeds up at 30 m/s^2 to `peak`, holds it briefly and stops.
|
||||
std::function<std::array<float, 3>(float)> Flick(std::array<float, 3> direction, float peak, float start = 0.1f) {
|
||||
return [=](float t) {
|
||||
const float local = t - start;
|
||||
float speed = 0.0f;
|
||||
if (local >= 0.0f && local < 0.25f) {
|
||||
speed = std::min(30.0f * local, peak);
|
||||
}
|
||||
return std::array<float, 3>{direction[0] * speed, direction[1] * speed, direction[2] * speed};
|
||||
};
|
||||
}
|
||||
|
||||
void TestThrows() {
|
||||
for (float rate : {72.0f, 90.0f, 120.0f}) {
|
||||
ThrowDetector forward;
|
||||
const auto fires = Play(forward, Flick({0.0f, 0.0f, -1.0f}, 3.0f), 0.8f, rate);
|
||||
Check(fires.size() == 1 && fires[0].direction == Direction::Forward, "a forward flick throws forward");
|
||||
Check(!fires.empty() && fires[0].time < 0.35f, "the throw fires during the swing");
|
||||
ThrowDetector backward;
|
||||
const auto back = Play(backward, Flick({0.0f, 0.0f, 1.0f}, 2.5f), 0.8f, rate);
|
||||
Check(back.size() == 1 && back[0].direction == Direction::Backward, "a backward flick throws backward");
|
||||
}
|
||||
// An overhand throw ends forward and down; an underhand toss forward and up.
|
||||
ThrowDetector overhand;
|
||||
Check(Play(overhand, Flick({0.0f, -0.45f, -0.89f}, 3.0f), 0.8f).size() == 1, "an overhand throw counts");
|
||||
ThrowDetector underhand;
|
||||
Check(Play(underhand, Flick({0.0f, 0.45f, -0.89f}, 3.0f), 0.8f).size() == 1, "an underhand toss counts");
|
||||
}
|
||||
|
||||
void TestNotThrows() {
|
||||
ThrowDetector reach;
|
||||
// Reaching for the wheel, and bringing a hand back to rest.
|
||||
Check(Play(reach, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t < 0.4f ? -0.9f : 0.0f}; }, 1.0f).empty(),
|
||||
"reaching for the wheel is no throw");
|
||||
ThrowDetector rest;
|
||||
Check(Play(rest, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t < 0.35f ? 0.9f : 0.0f}; }, 1.0f).empty(),
|
||||
"bringing a hand back is no throw");
|
||||
ThrowDetector sideways;
|
||||
Check(Play(sideways, Flick({1.0f, 0.0f, 0.0f}, 3.0f), 0.8f).empty(), "a sideways sweep is no throw");
|
||||
ThrowDetector upward;
|
||||
Check(Play(upward, Flick({0.0f, 1.0f, 0.0f}, 3.0f), 0.8f).empty(), "an upward flick (a trick) is no throw");
|
||||
ThrowDetector steep;
|
||||
Check(Play(steep, Flick({0.0f, 0.87f, -0.5f}, 3.0f), 0.8f).empty(), "a mostly upward swing is no throw");
|
||||
ThrowDetector still;
|
||||
Check(Play(still, [](float) { return std::array<float, 3>{}; }, 1.0f).empty(), "a still hand is no throw");
|
||||
ThrowDetector untracked;
|
||||
HandSample lost{};
|
||||
lost.position = {0.0f, 0.0f, -5.0f};
|
||||
const bool first = untracked.Update(lost, 0.011f) == Direction::None;
|
||||
lost.position = {0.0f, 0.0f, 5.0f};
|
||||
Check(first && untracked.Update(lost, 0.011f) == Direction::None, "an untracked hand is no throw");
|
||||
}
|
||||
|
||||
void TestWheel() {
|
||||
// A swing while holding the wheel, then right after letting go of it.
|
||||
ThrowDetector on_wheel;
|
||||
Check(Play(on_wheel, Flick({0.0f, 0.0f, -1.0f}, 3.0f), 0.8f, 90.0f, {-0.2f, -0.3f, -0.35f},
|
||||
[](float) { return true; })
|
||||
.empty(),
|
||||
"a hand on the wheel does not throw");
|
||||
ThrowDetector just_free;
|
||||
Check(Play(just_free, Flick({0.0f, 0.0f, -1.0f}, 3.0f, 0.1f), 0.8f, 90.0f, {-0.2f, -0.3f, -0.35f},
|
||||
[](float t) { return t < 0.05f; })
|
||||
.empty(),
|
||||
"a hand that just let go of the wheel does not throw");
|
||||
ThrowDetector free;
|
||||
Check(Play(free, Flick({0.0f, 0.0f, -1.0f}, 3.0f, 0.4f), 1.0f, 90.0f, {-0.2f, -0.3f, -0.35f},
|
||||
[](float t) { return t < 0.05f; })
|
||||
.size() == 1,
|
||||
"a hand free for a moment throws");
|
||||
}
|
||||
|
||||
void TestJumpAndRepeats() {
|
||||
ThrowDetector jump;
|
||||
HandSample sample{};
|
||||
sample.tracked = true;
|
||||
sample.position = {-0.2f, -0.3f, -0.35f};
|
||||
bool fired = false;
|
||||
for (int frame = 0; frame < 60; ++frame) {
|
||||
if (frame == 30) {
|
||||
sample.position[2] -= 0.5f; // the pose snaps half a metre ahead
|
||||
}
|
||||
fired = fired || jump.Update(sample, 1.0f / 90.0f) != Direction::None;
|
||||
}
|
||||
Check(!fired, "a tracking jump is no throw");
|
||||
|
||||
ThrowDetector long_swing;
|
||||
Check(Play(long_swing, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t > 0.1f ? -2.0f : 0.0f}; }, 1.2f)
|
||||
.size() == 1,
|
||||
"one long swing throws once");
|
||||
const auto two = [](float gap) {
|
||||
return [gap](float t) {
|
||||
const bool first = t >= 0.1f && t < 0.3f, second = t >= 0.3f + gap && t < 0.5f + gap;
|
||||
return std::array<float, 3>{0.0f, 0.0f, first || second ? -2.5f : 0.0f};
|
||||
};
|
||||
};
|
||||
ThrowDetector close;
|
||||
Check(Play(close, two(0.2f), 1.5f).size() == 1, "a second swing inside the cooldown does not throw");
|
||||
ThrowDetector apart;
|
||||
Check(Play(apart, two(0.6f), 2.0f).size() == 2, "a second swing after the cooldown throws again");
|
||||
}
|
||||
|
||||
// Bare hands: the cameras lag, smooth and drop a hand in a fast swing.
|
||||
void TestBareHands() {
|
||||
const auto none = [](float) { return false; };
|
||||
// A slower push than a controller needs: 1.4 m/s at its peak.
|
||||
ThrowDetector controller;
|
||||
Check(Play(controller, Flick({0.0f, 0.0f, -1.0f}, 1.4f), 0.8f).empty(), "a controller needs a faster swing");
|
||||
ThrowDetector bare;
|
||||
const auto push = Play(bare, Flick({0.0f, 0.0f, -1.0f}, 1.4f), 0.8f, 60.0f, {-0.2f, -0.3f, -0.35f}, none, true);
|
||||
Check(push.size() == 1 && push[0].direction == Direction::Forward, "a bare hand's slower push throws");
|
||||
Check(bare.Last().bare && bare.Last().travel >= kBareHandTuning.forward_travel, "the throw's measure");
|
||||
ThrowDetector bare_back;
|
||||
const auto back = Play(bare_back, Flick({0.0f, 0.0f, 1.0f}, 1.2f), 0.8f, 60.0f, {-0.2f, -0.3f, -0.35f}, none, true);
|
||||
Check(back.size() == 1 && back[0].direction == Direction::Backward, "a bare hand throws backward");
|
||||
|
||||
// Tracking is lost for the fast part of the swing and comes back as the
|
||||
// hand stops, so only the positions either side of the gap show the throw:
|
||||
// 0.18 s for a bare hand, 0.09 s for a controller.
|
||||
for (bool is_bare : {false, true}) {
|
||||
const float speed = is_bare ? 2.0f : 3.0f, stop = is_bare ? 0.3f : 0.22f, found = is_bare ? 0.3f : 0.21f;
|
||||
ThrowDetector gap;
|
||||
const auto fires = Play(
|
||||
gap, [=](float t) { return std::array<float, 3>{0.0f, 0.0f, t >= 0.1f && t < stop ? -speed : 0.0f}; },
|
||||
0.8f, 72.0f, {-0.2f, -0.3f, -0.35f}, none, is_bare, [=](float t) { return t <= 0.12f || t > found; });
|
||||
Check(fires.size() == 1 && fires[0].direction == Direction::Forward, "a swing across a short gap throws");
|
||||
Check(gap.Last().bridged, "the throw's measure notes the gap");
|
||||
}
|
||||
// A hand the cameras lose for 0.3 s reappears 30 cm ahead: no throw.
|
||||
ThrowDetector long_gap;
|
||||
Check(Play(long_gap, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t > 0.3f && t < 0.6f ? -1.0f : 0.0f}; },
|
||||
1.2f, 72.0f, {-0.2f, -0.3f, -0.35f}, none, true, [](float t) { return t < 0.3f || t > 0.6f; })
|
||||
.empty(),
|
||||
"a hand lost for long is no throw");
|
||||
// A slow drift the cameras lose briefly: 0.6 m/s across a 0.18 s gap.
|
||||
ThrowDetector drift;
|
||||
Check(Play(drift, [](float) { return std::array<float, 3>{0.0f, 0.0f, -0.6f}; }, 1.2f, 72.0f,
|
||||
{-0.2f, -0.3f, 0.0f}, none, true, [](float t) { return t < 0.5f || t > 0.68f; })
|
||||
.empty(),
|
||||
"a slow drift across a gap is no throw");
|
||||
// Reaching for the wheel with a bare hand.
|
||||
ThrowDetector reach;
|
||||
Check(Play(reach, [](float t) { return std::array<float, 3>{0.0f, 0.0f, t < 0.4f ? -0.8f : 0.0f}; }, 1.0f, 60.0f,
|
||||
{-0.2f, -0.3f, -0.35f}, none, true)
|
||||
.empty(),
|
||||
"a bare hand reaching for the wheel is no throw");
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
float stick_y;
|
||||
float trigger;
|
||||
};
|
||||
|
||||
// Runs the sequence at 90 Hz with the player's own trigger from `player(t)`.
|
||||
std::vector<Frame> Sequence(Direction direction, const std::function<float(float)>& player, float duration,
|
||||
ThrowSequence& sequence) {
|
||||
std::vector<Frame> frames;
|
||||
sequence.Start(direction);
|
||||
const float dt = 1.0f / 90.0f;
|
||||
for (float t = 0.0f; t < duration; t += dt) {
|
||||
wii_remote::HandInputs left{};
|
||||
left.stick_x = 0.6f;
|
||||
left.trigger = player(t);
|
||||
sequence.Apply(left, dt);
|
||||
Check(left.stick_x == 0.6f, "the throw leaves the steering alone");
|
||||
frames.push_back({left.stick_y, left.trigger});
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
void TestSequence() {
|
||||
ThrowSequence sequence;
|
||||
const auto frames = Sequence(Direction::Forward, [](float) { return 0.0f; }, 0.4f, sequence);
|
||||
// 90 Hz: aim for frames 0-4 (0.05 s), press 5-13, release 14-22, then done.
|
||||
Check(frames[0].stick_y == 1.0f && frames[0].trigger == 0.0f, "the stick aims before the button");
|
||||
Check(frames[4].trigger == 0.0f && frames[5].trigger == 1.0f, "then the button is pressed");
|
||||
Check(frames[13].trigger == 1.0f && frames[14].trigger == 0.0f, "then released");
|
||||
Check(frames[14].stick_y == 1.0f && frames[21].stick_y == 1.0f, "with the stick still aimed");
|
||||
Check(frames[24].stick_y == 0.0f && !sequence.Active(), "then the stick is the player's again");
|
||||
ThrowSequence back;
|
||||
Check(Sequence(Direction::Backward, [](float) { return 0.0f; }, 0.1f, back)[0].stick_y == -1.0f,
|
||||
"a backward throw pulls the stick back");
|
||||
|
||||
// The player was trailing the item with the button held: the throw lets it
|
||||
// go, and the button stays released until the player lets go of it.
|
||||
ThrowSequence trailed;
|
||||
const auto held = Sequence(Direction::Forward, [](float t) { return t < 0.5f ? 1.0f : (t < 0.6f ? 0.0f : 1.0f); },
|
||||
0.8f, trailed);
|
||||
Check(held[2].trigger == 1.0f && held[10].trigger == 1.0f, "a held button stays pressed until the release");
|
||||
Check(held[14].trigger == 0.0f && held[30].trigger == 0.0f, "the throw releases a held button");
|
||||
Check(held[44].trigger == 0.0f, "a button still held after the throw is ignored");
|
||||
Check(held[60].trigger == 1.0f, "a new press after letting go uses the next item");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
TestThrows();
|
||||
TestNotThrows();
|
||||
TestWheel();
|
||||
TestJumpAndRepeats();
|
||||
TestBareHands();
|
||||
TestSequence();
|
||||
if (g_failures != 0) {
|
||||
std::cerr << g_failures << " check(s) failed\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "mkw_vr_item_throw_tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user