mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 11:57:01 +02:00
Board Break: wear and tear, rail snaps, break and body sounds, settings
Boards wear: hard landings, hits onto rails and ledges, grinding and slams wear the board of the deck in the game, kept per deck. From a fifth worn it steers lazier and its trucks go soft (the game's own physics tuning, outside multiplayer sessions only); a worn board breaks from less and, worn out, can give way under the rider. A board that broke is a new one once it is whole again, and the game's own board wear is cleared with it. A boardslide, lipslide or darkslide that comes down too hard onto a rail can snap the board, and the rider goes down with it: No Bail can now be asked for a wipeout, which goes in as a cause before the selector picks. Sounds play through XAudio2: a crack by the size of the break, metal when the board broke on metal (the material comes from the game's contact effects; which are metal is marked in play or listed by a pack), and the body's hits in a bail by how hard they land. The settings are a card on the overlay's Skater page, two rows in Mod Options and console verbs, kept in BoardBreak.settings. A deck may name itself and set its wheels wider apart in a deck.ini; Old School and Pig join the decks the feature knows.
This commit is contained in:
1 parent
6df4f9a7bd
commit
b999f37e7b
20 files changed
+1410
-54
No files matched your search
@@ -220,6 +220,20 @@ std::vector<std::filesystem::path> board_break_folders() noexcept {
|
||||
} catch (...) {}
|
||||
return found;
|
||||
}
|
||||
std::filesystem::path board_break_deck_folder(std::string_view deck) noexcept {
|
||||
try {
|
||||
const auto wanted = lower(deck);
|
||||
std::error_code error;
|
||||
for (fs::directory_iterator at(engine_data_root() / L"Mods", error), end; !error && at != end; at.increment(error)) {
|
||||
if (!at->is_directory(error)) continue;
|
||||
const auto inside = decks_inside(at->path());
|
||||
for (const auto& one : inside)
|
||||
if (one.first == wanted) return one.second;
|
||||
if (inside.empty() && board_break_deck_of(at->path().filename().string()) == wanted) return at->path();
|
||||
}
|
||||
} catch (...) {}
|
||||
return {};
|
||||
}
|
||||
std::vector<std::string> installed_board_break_decks() noexcept {
|
||||
std::vector<std::string> found;
|
||||
try {
|
||||
|
||||
@@ -63,8 +63,8 @@ struct Catalog {
|
||||
// A deck goes by its name in small letters. These are the decks the feature was made with, in the
|
||||
// order the menus list them, and the console also knows them by their place here (deck 1 is the
|
||||
// first). Any other deck comes after them, by its name: a new shape needs no change to ReSkate.
|
||||
inline constexpr std::array<std::string_view, 8> board_break_decks{"standard", "wide", "concave", "powell",
|
||||
"slim", "egg", "square", "shaped"};
|
||||
inline constexpr std::array<std::string_view, 10> board_break_decks{"standard", "wide", "concave", "powell", "slim",
|
||||
"egg", "square", "shaped", "oldschool", "pig"};
|
||||
// The name of the deck a mod holds going by the mod's name (a folder's, or a pack's deck's), or
|
||||
// nothing; and the folder name of the pack that one of a pack's decks is listed under, or nothing.
|
||||
std::string board_break_deck_of(std::string_view mod);
|
||||
@@ -78,6 +78,9 @@ bool save_board_break_deck(std::string_view deck) noexcept;
|
||||
// beside its decks.
|
||||
std::vector<std::string> installed_board_break_decks() noexcept;
|
||||
std::vector<std::filesystem::path> board_break_folders() noexcept;
|
||||
// The folder with a deck's own files (a pack's decks/<name>, or the Board_Break_<Name> folder): what
|
||||
// it says of itself (deck.ini) is there. Empty when that deck is not installed.
|
||||
std::filesystem::path board_break_deck_folder(std::string_view deck) noexcept;
|
||||
// What the mods in effect give the game: the launch's catalogue, or the one the last live apply
|
||||
// read (live_mods tells with board_break_applied).
|
||||
std::string board_break_in_play() noexcept;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Board Break's rules, apart from the game: what a break leaves of a board, how likely a slam
|
||||
// breaks it, and where the parts of a broken board lie.
|
||||
#include "Extension/BoardBreak/board_break_model.h"
|
||||
#include "Extension/BoardBreak/board_break_feel.h"
|
||||
#include <cmath>
|
||||
#include <initializer_list>
|
||||
#include <iostream>
|
||||
@@ -148,6 +149,60 @@ void a_baseplate_stays_on_the_deck_while_its_truck_steers() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void the_settings_keep_to_their_ranges() {
|
||||
Settings s;
|
||||
check(set_setting_value(s, "sensitivity", 9999) && s.sensitivity == sensitivity_max, "A percent is kept within its range");
|
||||
check(set_setting_value(s, "always", 5) && s.always && setting_value(s, "always") == 1, "a switch is on or off");
|
||||
check(set_setting_value(s, "joined", -3) && s.joined == 0, "and nothing goes below its least");
|
||||
check(!set_setting_value(s, "nonsense", 1) && setting_value(s, "nonsense") == -1, "An unknown name is no setting");
|
||||
for (const auto& info : setting_info) check(setting_value(s, info.name) >= 0, "Every setting has a value");
|
||||
}
|
||||
|
||||
void a_worn_or_touchy_board_breaks_sooner() {
|
||||
check(slam_chance(heavy_slam, 100, 0.0f) == 1.0f, "A heavy slam always breaks a new board");
|
||||
check(slam_chance(6.0f, 100, 0.0f) < slam_chance(6.0f, 100, 1.0f), "A worn board breaks from less");
|
||||
check(slam_chance(heavy_slam * 0.5f, 200, 0.0f) == 1.0f, "At 200% half the force always breaks it");
|
||||
check(slam_chance(8.0f, 50, 0.0f) < slam_chance(8.0f, 100, 0.0f), "at 50% it takes more");
|
||||
check(slam_chance(0.0f, 400, 1.0f) <= 0.95f, "and a stumble never breaks it for certain");
|
||||
}
|
||||
|
||||
void every_hit_wears_a_board() {
|
||||
check(landing_wear(3.0f) == 0.0f && landing_wear(5.0f) > 0.0f && landing_wear(9.0f) > landing_wear(5.0f), "Harder landings wear more, light ones none");
|
||||
check(rail_wear(3.0f, true) > rail_wear(3.0f, false), "A slide wears the deck more than a grind on the trucks");
|
||||
check(grind_wear_per_second(true) > grind_wear_per_second(false), "and sliding more than grinding");
|
||||
check(slam_wear(2.0f) == 0.0f && slam_wear(12.0f) > slam_wear(7.0f), "A stumble costs nothing, a hard slam a lot");
|
||||
check(add_wear(0.5f, 0.1f, 0) == 0.5f && near(add_wear(0.5f, 0.1f, 200), 0.7f) && add_wear(0.95f, 1.0f, 100) == 1.0f,
|
||||
"Wear goes at the player's rate, and never past worn out");
|
||||
}
|
||||
|
||||
void a_worn_board_handles_worse() {
|
||||
check(handling(0.0f) == Handling{} && handling(0.2f) == Handling{}, "A board under a fifth worn handles as new");
|
||||
const auto half = handling(0.6f), done = handling(1.0f);
|
||||
check(half.steering < 1.0f && done.steering < half.steering && done.spring < half.spring && done.twist > half.twist,
|
||||
"The more worn, the lazier the turn and the softer the trucks");
|
||||
check(done.steering > 0.5f && done.spring > 0.2f, "but it can still be ridden");
|
||||
check(gives_way_per_second(1.0f) > 50 * gives_way_per_second(0.0f), "A worn-out board gives way under the rider far more often");
|
||||
}
|
||||
|
||||
void a_board_across_its_way_slides() {
|
||||
const Vec level_up{0, 1, 0}, ahead{0, 0, 1}, across{1, 0, 0}, upside_down{0, -1, 0}, going{0, -1, 5};
|
||||
check(contact_of(ahead, level_up, going) == Contact::grind, "Along the rail it grinds on its trucks");
|
||||
check(contact_of(across, level_up, going) == Contact::slide, "Across it, it slides on the deck");
|
||||
check(contact_of(across, upside_down, going) == Contact::darkslide, "On its grip it is a darkslide");
|
||||
check(contact_of(across, level_up, {0, -2, 0.1f}) == Contact::grind, "A board that hardly moves along counts as grinding");
|
||||
check(rail_chance(9.0f, Contact::grind, 100, 0.0f) == 0.0f, "A grind on the trucks never snaps the board");
|
||||
check(rail_chance(2.0f, Contact::slide, 100, 0.0f) == 0.0f && rail_chance(6.0f, Contact::slide, 100, 0.0f) == 1.0f,
|
||||
"A hop onto a rail is safe, a drop onto it snaps the board");
|
||||
check(rail_chance(3.5f, Contact::darkslide, 100, 0.0f) > rail_chance(3.5f, Contact::slide, 100, 0.0f), "a darkslide sooner");
|
||||
}
|
||||
|
||||
void a_board_breaks_where_it_hit() {
|
||||
check(place_of_break(0.8f, false, 0.1f) == Place::nose && place_of_break(-0.8f, false, 0.1f) == Place::tail, "Nose first at the nose, tail first at the tail");
|
||||
check(place_of_break(0.8f, true, 0.1f) == Place::mid, "Across a rail in the middle");
|
||||
check(place_of_break(0.0f, false, 0.2f) == Place::mid && place_of_break(0.0f, false, 0.9f) == Place::tail, "Flat mostly in the middle");
|
||||
check(body_loudness(2.0f) == 0.0f && body_loudness(body_full) == 1.0f && body_loudness(5.0f) < body_loudness(9.0f), "Harder hits sound louder");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
@@ -162,6 +217,12 @@ int main() {
|
||||
a_hanging_end_turns_about_the_break();
|
||||
a_truck_goes_with_its_piece();
|
||||
a_baseplate_stays_on_the_deck_while_its_truck_steers();
|
||||
the_settings_keep_to_their_ranges();
|
||||
a_worn_or_touchy_board_breaks_sooner();
|
||||
every_hit_wears_a_board();
|
||||
a_worn_board_handles_worse();
|
||||
a_board_across_its_way_slides();
|
||||
a_board_breaks_where_it_hit();
|
||||
std::cout << "board break tests passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
|
||||
@@ -9,25 +9,39 @@
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
#include "Extension/Profile/local_profile_runtime.h"
|
||||
#include "Extension/Skater/no_bail.h"
|
||||
#include "board_break_audio.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/skater_state.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat_model.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat_skater.h"
|
||||
#include "Extension/Multiplayer/Remote/native_vfx.h"
|
||||
#include "Extension/Multiplayer/Session/session.h"
|
||||
#include "Extension/Progression/script_natives.h"
|
||||
#include "Extension/Skater/physics_tuning.h"
|
||||
#include <Windows.h>
|
||||
#include <mmsystem.h>
|
||||
#include <shlobj.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <cwctype>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#pragma comment(lib, "winmm.lib")
|
||||
#pragma comment(lib, "ole32.lib")
|
||||
#pragma comment(lib, "shell32.lib")
|
||||
|
||||
namespace dingosdk::board_break {
|
||||
namespace {
|
||||
@@ -35,8 +49,8 @@ constexpr const char* enabled_preference = "BoardBreak";
|
||||
// A slam of `heavy` force or more always breaks the board; a lighter one with `light`, rising to
|
||||
// certain between 60% of `heavy` and `heavy` (break_chance). Half of all breaks stay joined by the
|
||||
// grip tape. Now and then a board gives way under the rider: per second of riding, the nose goes.
|
||||
constexpr float heavy = 10.0f, light = 0.25f, joined_share = 0.5f;
|
||||
constexpr double gives_way = 0.0001;
|
||||
// (board_break_feel.h holds the forces, the chances and what wear does.)
|
||||
namespace sstate = addr::skater_state;
|
||||
constexpr std::size_t cause_count = 35; // the collector's cause bytes (+0x28) and forces (+0x4c)
|
||||
constexpr std::uint64_t recent_ms = 1500; // a cause belongs to a wipeout this soon before it
|
||||
// The board is back in the skater's hand: close to the skater and not lying flat.
|
||||
@@ -50,6 +64,8 @@ constexpr int root_bone = 1, mount_bone = 2, deck_bone = 3, back_truck_bone = 4,
|
||||
constexpr int back_plate_bone = 5, front_plate_bone = 9;
|
||||
constexpr std::array<int, 4> carrier{12, 13, 15, 14};
|
||||
constexpr std::uint32_t rig_bones = 16;
|
||||
// The wheels, each on its truck's bone: back right and left, front right and left.
|
||||
constexpr std::array<int, 4> wheel_bones{6, 7, 10, 11};
|
||||
|
||||
bool readable(std::uintptr_t p, void* out, std::size_t size) { return size ? memory::peek_bytes(p, out, size) : p >= 0x10000; }
|
||||
|
||||
@@ -71,6 +87,7 @@ struct Broken {
|
||||
std::uint64_t since{};
|
||||
bool gave_way{}; // it went under the rider: it stays broken while it is ridden
|
||||
bool asked{}; // it was broken from the console, to be looked at: it stays until it is mended there
|
||||
std::string deck; // the deck it was: the one that gets a new board
|
||||
};
|
||||
struct State {
|
||||
// Any thread.
|
||||
@@ -93,6 +110,23 @@ struct State {
|
||||
std::uint64_t ride_when{};
|
||||
float break_ground{std::numeric_limits<float>::quiet_NaN()};
|
||||
std::mt19937 random{std::random_device{}()};
|
||||
// The player's settings as the physics thread reads them (feel() holds the real ones).
|
||||
std::atomic<bool> always{}, rails{true}, wear_on{true}, body_sounds{true};
|
||||
std::atomic<int> sensitivity{100};
|
||||
// Wear: the board in the game's (for the physics thread), and what the physics thread has
|
||||
// added since the tick took it.
|
||||
std::atomic<float> wear_now{}, wear_added{};
|
||||
// A board that came down too hard onto a rail, seen by the physics thread: the tick breaks it.
|
||||
std::atomic<bool> rail_snap{};
|
||||
std::atomic<int> rail_contact{};
|
||||
std::atomic<float> rail_speed{};
|
||||
// The board's long axis (towards the nose) and its up axis in the world, from the tick.
|
||||
std::mutex axes_mutex;
|
||||
Vec along{}, up{};
|
||||
std::uint64_t axes_at{};
|
||||
// Client thread: the tuning set for wear, and when; when the settings were last saved.
|
||||
std::uint64_t handled_at{}, saved_at{}, session_checked{};
|
||||
bool in_session{}, setup_read{}, effects_hooked{};
|
||||
};
|
||||
State& state() {
|
||||
static auto* value = new State;
|
||||
@@ -106,6 +140,8 @@ struct RenderEdit {
|
||||
std::atomic<int> hinge{};
|
||||
std::atomic<float> angle{};
|
||||
std::atomic<bool> park{}; // a breakable deck is in the game: whole boards hide their stretches
|
||||
// The deck's own trucks (deck.ini: axle): how far apart the wheels sit against a standard board's.
|
||||
std::atomic<float> axle{1.0f};
|
||||
};
|
||||
RenderEdit& render_edit() { static RenderEdit value; return value; }
|
||||
|
||||
@@ -170,6 +206,28 @@ void hold_baseplates(std::uintptr_t buffer) noexcept {
|
||||
hold(front_truck_bone, front_plate_bone, front_truck_rest);
|
||||
}
|
||||
|
||||
// A deck's own trucks (deck.ini: axle): the wheels further apart on a wider deck, closer together on
|
||||
// a narrower one. The game puts each wheel on its truck's bone along the axle, about 0.0876 m out;
|
||||
// that place is set here rather than scaled, so a pose that comes by twice is not moved twice. Only
|
||||
// what is drawn changes: the board rides as it always does.
|
||||
constexpr float wheel_offset = 0.0876f;
|
||||
void spread_wheels(std::uintptr_t buffer) noexcept {
|
||||
const float axle = render_edit().axle.load(std::memory_order_relaxed);
|
||||
if (axle == 1.0f) return;
|
||||
for (const int index : wheel_bones) {
|
||||
PoseBone wheel{};
|
||||
if (!load_bone(bone_at(buffer, index), wheel)) continue;
|
||||
int way = 0; // the axle, in the truck's axes: the largest part of where the wheel sits
|
||||
for (int i = 1; i < 3; ++i)
|
||||
if (std::abs(wheel.position[i]) > std::abs(wheel.position[way])) way = i;
|
||||
const float out = std::abs(wheel.position[way]);
|
||||
if (!(out > 0.05f && out < 0.16f)) continue; // not where the game puts a wheel
|
||||
__try {
|
||||
reinterpret_cast<float*>(bone_at(buffer, index))[8 + way] = std::copysign(wheel_offset * axle, wheel.position[way]);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the piece that comes off --------------------------------------------------------------------
|
||||
// The client tick starts a flight (a new id) when the board breaks; the render pose hook moves the
|
||||
// piece through the world from there (a kick, gravity, the ground) and writes it relative to
|
||||
@@ -303,6 +361,7 @@ void draw_broken(std::uintptr_t base, std::uintptr_t holder, std::uint32_t kept,
|
||||
!load_bone(bone_at(buffer, deck_bone), deck))
|
||||
return;
|
||||
hold_baseplates(buffer);
|
||||
spread_wheels(buffer);
|
||||
// From where its animation put it this frame; a pose nobody has written since the last move stays as it is.
|
||||
if (const Vec at = place_of(deck); length(at) < 100.0f) (void)store_bone(bone_at(buffer, deck_bone), facing_of(deck), at + away);
|
||||
auto layout = lay_out(kept, hinge, angle, turn(facing_of(root), {0, 1, 0}).y, facing_of(mount));
|
||||
@@ -323,61 +382,447 @@ void park_pieces(std::uintptr_t base, std::uintptr_t holder) {
|
||||
const auto buffer = board_pose(base, holder);
|
||||
if (!buffer) return;
|
||||
hold_baseplates(buffer);
|
||||
spread_wheels(buffer);
|
||||
const auto bones = bones_of({});
|
||||
for (std::size_t i = 0; i < bones.size(); ++i) (void)store_bone(bone_at(buffer, carrier[i]), bones[i].facing, bones[i].at);
|
||||
}
|
||||
|
||||
// ---- breaking and mending --------------------------------------------------------------------------
|
||||
// The crack of the deck, straight to the speakers (not through the game's mixer): a wave file out
|
||||
// of the `sounds` folder of one of Board Break's deck mods. Its subfolders light, medium and heavy
|
||||
// hold the sounds for a break of that size, as many as one likes under any names; a size with none
|
||||
// of its own takes the nearest size that has some, and wave files in the folder itself serve when
|
||||
// no subfolder does.
|
||||
void crack_sound(State& s, Size size) {
|
||||
static const std::array<std::vector<std::filesystem::path>, 4> sounds = [] {
|
||||
std::array<std::vector<std::filesystem::path>, 4> found;
|
||||
constexpr std::array<const wchar_t*, 4> names{L"light", L"medium", L"heavy", L""};
|
||||
// ---- the player's settings, the deck's setup, wear -------------------------------------------------
|
||||
// Kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings, a line each: the settings by name (as the
|
||||
// console knows them), each deck's wear (wear.<deck>=0.37), the materials marked as metal.
|
||||
struct Feel {
|
||||
std::mutex mutex;
|
||||
Settings settings;
|
||||
std::map<std::string, float> wear; // by deck
|
||||
std::set<std::uint16_t> metal; // marked by the player
|
||||
std::set<std::uint16_t> pack_metal; // a pack's list (sounds/metal/materials.txt)
|
||||
bool loaded{}, dirty{};
|
||||
};
|
||||
Feel& feel() {
|
||||
static auto* value = new Feel;
|
||||
return *value;
|
||||
}
|
||||
std::filesystem::path settings_path() {
|
||||
PWSTR raw{};
|
||||
std::filesystem::path result;
|
||||
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &raw)) && raw)
|
||||
result = std::filesystem::path(raw) / L"ReSkate" / L"BoardBreak.settings";
|
||||
if (raw) CoTaskMemFree(raw);
|
||||
return result;
|
||||
}
|
||||
std::string trimmed(std::string_view text) {
|
||||
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.front()))) text.remove_prefix(1);
|
||||
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.back()))) text.remove_suffix(1);
|
||||
return std::string(text);
|
||||
}
|
||||
template <class T> bool number(std::string_view text, T& out) {
|
||||
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), out);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size();
|
||||
}
|
||||
// Numbers out of a line of them ("12 40, 77").
|
||||
std::set<std::uint16_t> numbers_in(std::string_view text) {
|
||||
std::set<std::uint16_t> out;
|
||||
std::string one;
|
||||
const auto take = [&] {
|
||||
unsigned value{};
|
||||
if (!one.empty() && number(std::string_view(one), value) && value <= 0xffff) out.insert(static_cast<std::uint16_t>(value));
|
||||
one.clear();
|
||||
};
|
||||
for (const char c : text) {
|
||||
if (c >= '0' && c <= '9') one += c;
|
||||
else take();
|
||||
}
|
||||
take();
|
||||
return out;
|
||||
}
|
||||
// Under the lock.
|
||||
void load_feel(Feel& f) {
|
||||
if (f.loaded) return;
|
||||
f.loaded = true;
|
||||
try {
|
||||
std::ifstream in(settings_path());
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
const auto eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
const auto key = trimmed(std::string_view(line).substr(0, eq));
|
||||
const auto value = trimmed(std::string_view(line).substr(eq + 1));
|
||||
if (key.starts_with("wear.")) {
|
||||
float worn{};
|
||||
if (number(std::string_view(value), worn) && std::isfinite(worn)) f.wear[key.substr(5)] = std::clamp(worn, 0.0f, 1.0f);
|
||||
} else if (key == "metal") f.metal = numbers_in(value);
|
||||
else if (int setting{}; number(std::string_view(value), setting)) (void)set_setting_value(f.settings, key, setting);
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
void save_feel(Feel& f) {
|
||||
try {
|
||||
const auto path = settings_path();
|
||||
if (path.empty()) return;
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(path.parent_path(), error);
|
||||
std::ofstream out(path, std::ios::trunc);
|
||||
for (const auto& info : setting_info) out << info.name << "=" << setting_value(f.settings, info.name) << "\n";
|
||||
for (const auto& [deck, worn] : f.wear) out << "wear." << deck << "=" << std::format("{:.4f}", worn) << "\n";
|
||||
if (!f.metal.empty()) {
|
||||
out << "metal=";
|
||||
for (const auto m : f.metal) out << m << " ";
|
||||
out << "\n";
|
||||
}
|
||||
f.dirty = false;
|
||||
} catch (...) {}
|
||||
}
|
||||
// The settings into the atomics the physics thread reads.
|
||||
void mirror(State& s, const Settings& st) {
|
||||
s.always.store(st.always, std::memory_order_relaxed);
|
||||
s.rails.store(st.rails, std::memory_order_relaxed);
|
||||
s.wear_on.store(st.wear, std::memory_order_relaxed);
|
||||
s.body_sounds.store(st.body_sounds, std::memory_order_relaxed);
|
||||
s.sensitivity.store(st.sensitivity, std::memory_order_relaxed);
|
||||
}
|
||||
Settings settings_now() {
|
||||
auto& f = feel();
|
||||
std::lock_guard lock(f.mutex);
|
||||
load_feel(f);
|
||||
return f.settings;
|
||||
}
|
||||
std::string deck_now(State& s) {
|
||||
std::lock_guard lock(s.names);
|
||||
return s.deck;
|
||||
}
|
||||
|
||||
// What a deck says of itself, in its folder's deck.ini: `axle` for its trucks (1 = a standard board's;
|
||||
// more on a wider deck) and `label`, its name in the menus.
|
||||
struct DeckSetup {
|
||||
float axle = 1.0f;
|
||||
std::string label;
|
||||
};
|
||||
DeckSetup read_deck_setup(std::string_view deck) {
|
||||
DeckSetup out;
|
||||
if (deck.empty()) return out;
|
||||
try {
|
||||
const auto folder = mods::board_break_deck_folder(deck);
|
||||
if (folder.empty()) return out;
|
||||
std::ifstream in(folder / L"deck.ini");
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (const auto hash = line.find_first_of("#;"); hash != std::string::npos) line.erase(hash);
|
||||
const auto eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
auto key = trimmed(std::string_view(line).substr(0, eq));
|
||||
for (auto& c : key) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
const auto value = trimmed(std::string_view(line).substr(eq + 1));
|
||||
float amount{};
|
||||
if (key == "axle" && number(std::string_view(value), amount) && std::isfinite(amount)) out.axle = std::clamp(amount, 0.8f, 1.6f);
|
||||
else if (key == "label") out.label = value;
|
||||
}
|
||||
} catch (...) {}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The same, kept a few seconds per deck: the menus ask every frame.
|
||||
DeckSetup setup_of(std::string_view deck) {
|
||||
struct Kept {
|
||||
DeckSetup setup;
|
||||
std::uint64_t at{};
|
||||
};
|
||||
static std::mutex mutex;
|
||||
static std::map<std::string, Kept, std::less<>> kept;
|
||||
const auto now = GetTickCount64();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (const auto at = kept.find(deck); at != kept.end() && now - at->second.at < 3000) return at->second.setup;
|
||||
}
|
||||
auto setup = read_deck_setup(deck);
|
||||
std::lock_guard lock(mutex);
|
||||
kept[std::string(deck)] = {setup, now};
|
||||
return setup;
|
||||
}
|
||||
|
||||
// ---- sounds ---------------------------------------------------------------------------------------
|
||||
// Out of the `sounds` folder of one of Board Break's deck mods: light, medium and heavy for the
|
||||
// crack of a break that size, metal for a board breaking on metal, body for the body's hits in a
|
||||
// bail; as many wave files in each as one likes, under any names. A crack size with none of its own
|
||||
// takes the nearest that has some, and wave files in the folder itself serve when no subfolder does.
|
||||
// sounds/metal/materials.txt may list the level's materials that are metal.
|
||||
void load_sounds() {
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [] {
|
||||
const auto wavs = [](const std::filesystem::path& folder) {
|
||||
std::vector<std::filesystem::path> found;
|
||||
std::error_code failed;
|
||||
for (std::filesystem::directory_iterator at(folder, failed), end; !failed && at != end; at.increment(failed)) {
|
||||
auto extension = at->path().extension().wstring();
|
||||
for (auto& c : extension) c = static_cast<wchar_t>(towlower(c));
|
||||
if (at->is_regular_file(failed) && extension == L".wav") found.push_back(at->path());
|
||||
}
|
||||
std::sort(found.begin(), found.end());
|
||||
return found;
|
||||
};
|
||||
for (const auto& mod : mods::board_break_folders()) {
|
||||
std::error_code failed;
|
||||
if (!std::filesystem::is_directory(mod / L"sounds", failed)) continue;
|
||||
for (std::size_t i = 0; i < names.size(); ++i) {
|
||||
for (std::filesystem::directory_iterator at(mod / L"sounds" / names[i], failed), end; !failed && at != end; at.increment(failed)) {
|
||||
auto extension = at->path().extension().wstring();
|
||||
for (auto& c : extension) c = static_cast<wchar_t>(towlower(c));
|
||||
if (at->is_regular_file(failed) && extension == L".wav") found[i].push_back(at->path());
|
||||
}
|
||||
std::sort(found[i].begin(), found[i].end());
|
||||
const auto folder = mod / L"sounds";
|
||||
if (!std::filesystem::is_directory(folder, failed)) continue;
|
||||
const auto loose = wavs(folder);
|
||||
constexpr std::array<std::pair<audio::Kind, const wchar_t*>, 5> kinds{{{audio::Kind::light, L"light"}, {audio::Kind::medium, L"medium"},
|
||||
{audio::Kind::heavy, L"heavy"}, {audio::Kind::metal, L"metal"}, {audio::Kind::body, L"body"}}};
|
||||
for (const auto& [kind, name] : kinds) {
|
||||
auto files = wavs(folder / name);
|
||||
if (files.empty() && kind <= audio::Kind::heavy) files = loose;
|
||||
audio::load(kind, files);
|
||||
}
|
||||
try {
|
||||
std::ifstream in(folder / L"metal" / L"materials.txt");
|
||||
std::string line, all;
|
||||
while (std::getline(in, line))
|
||||
if (const auto hash = line.find('#'); hash != 0) all += " " + line.substr(0, hash);
|
||||
auto& f = feel();
|
||||
std::lock_guard lock(f.mutex);
|
||||
f.pack_metal = numbers_in(all);
|
||||
} catch (...) {}
|
||||
break;
|
||||
}
|
||||
return found;
|
||||
}();
|
||||
});
|
||||
}
|
||||
void crack_sound(Size size) {
|
||||
if (!settings_now().crack_sounds) return;
|
||||
// Nearest first: a medium break without sounds of its own takes a heavy one before a light one.
|
||||
static constexpr std::array<std::array<std::size_t, 4>, 3> order{{{0, 1, 2, 3}, {1, 2, 0, 3}, {2, 1, 0, 3}}};
|
||||
for (const auto from : order[static_cast<std::size_t>(size)]) {
|
||||
const auto& files = sounds[from];
|
||||
if (files.empty()) continue;
|
||||
const auto& file = files[std::uniform_int_distribution<std::size_t>(0, files.size() - 1)(s.random)];
|
||||
(void)PlaySoundW(file.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT);
|
||||
return;
|
||||
static constexpr std::array<std::array<audio::Kind, 3>, 3> order{{{audio::Kind::light, audio::Kind::medium, audio::Kind::heavy},
|
||||
{audio::Kind::medium, audio::Kind::heavy, audio::Kind::light},
|
||||
{audio::Kind::heavy, audio::Kind::medium, audio::Kind::light}}};
|
||||
for (const auto kind : order[static_cast<std::size_t>(size)])
|
||||
if (audio::has(kind)) return audio::play(kind, 1.0f);
|
||||
}
|
||||
|
||||
// ---- what a board broke on --------------------------------------------------------------------------
|
||||
// The material of the level the board touched most in the last half second, near `at`: the game's
|
||||
// own contact effects say (Extension/Multiplayer/Remote/native_vfx.h).
|
||||
std::optional<std::uint16_t> material_near(Vec at, std::uint64_t within_ms, float reach) {
|
||||
std::map<std::uint16_t, int> count;
|
||||
for (const auto& one : multiplayer::recent_local_impacts(within_ms)) {
|
||||
const auto& p = one.impact.position;
|
||||
if (std::hypot(p[0] - at.x, p[1] - at.y, p[2] - at.z) <= reach) ++count[one.impact.material];
|
||||
}
|
||||
std::optional<std::uint16_t> best;
|
||||
int most{};
|
||||
for (const auto& [material, times] : count)
|
||||
if (times > most) best = material, most = times;
|
||||
return best;
|
||||
}
|
||||
bool is_metal(std::uint16_t material) {
|
||||
auto& f = feel();
|
||||
std::lock_guard lock(f.mutex);
|
||||
return f.metal.contains(material) || f.pack_metal.contains(material);
|
||||
}
|
||||
|
||||
// ---- wear ----------------------------------------------------------------------------------------
|
||||
// Client thread: what the physics thread added goes onto the deck in the game's board.
|
||||
void take_wear(State& s, const std::string& deck, float extra = 0.0f) {
|
||||
const float added = s.wear_added.exchange(0.0f, std::memory_order_acq_rel) + extra;
|
||||
if (deck.empty() || added <= 0.0f) return;
|
||||
auto& f = feel();
|
||||
std::lock_guard lock(f.mutex);
|
||||
load_feel(f);
|
||||
auto& worn = f.wear[deck];
|
||||
const auto before = worn;
|
||||
worn = add_wear(worn, added, f.settings.wear_rate);
|
||||
s.wear_now.store(worn, std::memory_order_relaxed);
|
||||
if (worn - before >= 0.01f) say(logging::Level::debug, "a hard hit: the board is {:.1f}% worn now (+{:.1f}).", worn * 100.0f, (worn - before) * 100.0f);
|
||||
if (worn != before) f.dirty = true;
|
||||
if (condition_name(before) != condition_name(worn))
|
||||
say(logging::Level::info, "the {} board is {} now ({:.0f}% worn).", deck_label(deck), condition_name(worn), worn * 100.0f);
|
||||
}
|
||||
// A board that broke is a new one once it is whole again: no wear, no scratches.
|
||||
void new_board(State& s, const std::string& deck) {
|
||||
if (deck.empty()) return;
|
||||
auto& f = feel();
|
||||
{
|
||||
std::lock_guard lock(f.mutex);
|
||||
load_feel(f);
|
||||
f.wear[deck] = 0.0f;
|
||||
f.dirty = true;
|
||||
}
|
||||
if (deck == deck_now(s)) {
|
||||
s.wear_now.store(0.0f, std::memory_order_relaxed);
|
||||
s.wear_added.store(0.0f, std::memory_order_relaxed);
|
||||
profile_runtime::reset_board_wear();
|
||||
}
|
||||
}
|
||||
void add_physics_wear(State& s, float amount) {
|
||||
if (!(amount > 0.0f)) return;
|
||||
float current = s.wear_added.load(std::memory_order_relaxed);
|
||||
while (!s.wear_added.compare_exchange_weak(current, current + amount, std::memory_order_acq_rel)) {}
|
||||
}
|
||||
// What wear does to the board's handling: four values of the game's physics tuning, the game's own
|
||||
// value times handling()'s factor. A value the player changed (the trainer) is left alone; outside
|
||||
// a multiplayer session only, which shares tuning between its players.
|
||||
struct Tuned {
|
||||
bool tried{}, known{}, wrote{};
|
||||
std::array<std::uint16_t, 4> offset{};
|
||||
std::array<float, 4> game{}, written{};
|
||||
};
|
||||
Tuned& tuned() {
|
||||
static Tuned value;
|
||||
return value;
|
||||
}
|
||||
void apply_handling(std::uintptr_t base, std::uintptr_t entity, const Handling& want) {
|
||||
auto& t = tuned();
|
||||
if (!t.known) {
|
||||
physics_tuning::prepare();
|
||||
const auto model = physics_tuning::game_tuning();
|
||||
if (!model) return;
|
||||
constexpr std::array<std::string_view, 4> names{"PhysicsSteering.GeneralScalar", "PhysicsTrucks.StaticTruckSpringStrength",
|
||||
"PhysicsTrucks.StaticTruckDamping", "PhysicsTrucks.TruckTwistLimit"};
|
||||
std::size_t found{};
|
||||
for (std::size_t i = 0; i < model->fields.size() && i < model->field_names.size(); ++i)
|
||||
for (std::size_t k = 0; k < names.size(); ++k)
|
||||
if (model->field_names[i] == names[k] && model->fields[i].real && model->fields[i].offset + 4u <= model->image.size()) {
|
||||
t.offset[k] = model->fields[i].offset;
|
||||
std::memcpy(&t.game[k], model->image.data() + t.offset[k], 4);
|
||||
++found;
|
||||
}
|
||||
if (!t.tried) {
|
||||
t.tried = true;
|
||||
if (found != names.size()) say(logging::Level::warning, "wear cannot change the handling: {} of the 4 tuning values found.", found);
|
||||
}
|
||||
if (found != names.size()) return;
|
||||
t.known = true;
|
||||
}
|
||||
physics_tuning::Values live;
|
||||
if (!physics_tuning::read_live(base, live)) return;
|
||||
const std::array<float, 4> factor{want.steering, want.spring, want.damping, want.twist};
|
||||
bool change{};
|
||||
for (std::size_t k = 0; k < factor.size(); ++k) {
|
||||
if (t.offset[k] + 4u > live.image.size()) return;
|
||||
float now{};
|
||||
std::memcpy(&now, live.image.data() + t.offset[k], 4);
|
||||
const auto same = [](float a, float b) { return std::abs(a - b) <= 1e-4f * (std::max)(1.0f, std::abs(b)); };
|
||||
if (!same(now, t.game[k]) && !(t.wrote && same(now, t.written[k]))) continue;
|
||||
const float target = t.game[k] * factor[k];
|
||||
t.written[k] = target;
|
||||
if (!same(now, target)) {
|
||||
std::memcpy(live.image.data() + t.offset[k], &target, 4);
|
||||
change = true;
|
||||
}
|
||||
}
|
||||
t.wrote = true;
|
||||
if (change) (void)physics_tuning::write_live(base, entity, live);
|
||||
}
|
||||
|
||||
// ---- the board's axes, for the physics thread -------------------------------------------------------
|
||||
void note_axes(State& s, std::uintptr_t base, std::uintptr_t holder) {
|
||||
const auto buffer = board_pose(base, holder);
|
||||
PoseBone root{}, mount{};
|
||||
if (!buffer || !load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount)) return;
|
||||
const auto facing = unit(facing_of(root) * facing_of(mount));
|
||||
std::lock_guard lock(s.axes_mutex);
|
||||
s.along = turn(facing, {0, 0, 1});
|
||||
s.up = turn(facing, {0, 1, 0});
|
||||
s.axes_at = GetTickCount64();
|
||||
}
|
||||
bool board_axes(State& s, Vec& along, Vec& up) {
|
||||
std::lock_guard lock(s.axes_mutex);
|
||||
if (!s.axes_at || GetTickCount64() - s.axes_at > 250) return false;
|
||||
along = s.along;
|
||||
up = s.up;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- every physics step of the local skater (No Bail's step listeners) ------------------------------
|
||||
// The board's root body velocity (as the grind exit control reads it).
|
||||
bool board_velocity(const NoBailSkater& skater, std::array<float, 3>& velocity) noexcept {
|
||||
std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{};
|
||||
std::uint32_t count{};
|
||||
if (!memory::peek(skater.core + sstate::board_holder_offset, holder) ||
|
||||
!memory::peek(holder + sstate::board_physics_offset, physics) || !memory::peek(physics, type) ||
|
||||
type != skater.base + sstate::board_physics_vtable || !memory::peek(physics + sstate::physics_bodies_offset, bodies) ||
|
||||
!memory::peek(bodies, count) || count != sstate::board_body_count)
|
||||
return false;
|
||||
const auto body = bodies + sstate::board_root_body * sstate::physics_body_size;
|
||||
if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || !memory::peek(body + sstate::body_velocity_offset, velocity))
|
||||
return false;
|
||||
return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; });
|
||||
}
|
||||
struct StepWatch {
|
||||
bool was_air{};
|
||||
float air_down{}; // how fast the board was coming down, in its last step in the air
|
||||
std::array<std::uint64_t, skater_body::count> body_at{};
|
||||
std::uint64_t any_at{};
|
||||
};
|
||||
void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept {
|
||||
auto& s = state();
|
||||
if (!s.ready.load(std::memory_order_acquire) || !s.on.load(std::memory_order_relaxed)) return;
|
||||
static StepWatch w; // the physics thread's own
|
||||
thread_local std::mt19937 roll{std::random_device{}()};
|
||||
const auto error = GetLastError();
|
||||
std::uint32_t physics{};
|
||||
if (memory::peek(skater.context + sstate::physics_state_offset, physics)) {
|
||||
std::array<float, 3> v{};
|
||||
const bool known = board_velocity(skater, v);
|
||||
const bool air = physics == 200 || physics == 201;
|
||||
const bool grinding = physics >= 400 && physics < 500;
|
||||
const bool rolling = physics >= 100 && physics < 200;
|
||||
const bool wear_on = s.wear_on.load(std::memory_order_relaxed);
|
||||
Vec along{}, up{};
|
||||
const auto contact = known && board_axes(s, along, up) ? contact_of(along, up, {v[0], v[1], v[2]}) : Contact::grind;
|
||||
if (air && known) w.air_down = -v[1];
|
||||
if (w.was_air && (rolling || grinding)) {
|
||||
const float down = w.air_down;
|
||||
if (wear_on) add_physics_wear(s, rolling ? landing_wear(down) : rail_wear(down, contact != Contact::grind));
|
||||
const bool can_break = s.has_deck.load(std::memory_order_relaxed) && !s.rail_snap.load(std::memory_order_acquire);
|
||||
if (can_break && grinding && contact != Contact::grind && s.rails.load(std::memory_order_relaxed)) {
|
||||
const float chance = s.always.load(std::memory_order_relaxed) ? (down > 1.5f ? 1.0f : 0.0f)
|
||||
: rail_chance(down, contact, s.sensitivity.load(std::memory_order_relaxed),
|
||||
s.wear_now.load(std::memory_order_relaxed));
|
||||
if (std::uniform_real_distribution<float>(0.0f, 1.0f)(roll) < chance) {
|
||||
s.rail_contact.store(static_cast<int>(contact), std::memory_order_relaxed);
|
||||
s.rail_speed.store(down, std::memory_order_relaxed);
|
||||
s.rail_snap.store(true, std::memory_order_release);
|
||||
// The board snaps under the rider: the rider goes down with it. Gently: the game reads the
|
||||
// cause's force as how hard it was hit, and a slam's force (10 and more) with the board
|
||||
// flat on the ground threw the rider high into the air.
|
||||
no_bail_request_wipeout(2.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grinding && wear_on) add_physics_wear(s, grind_wear_per_second(contact != Contact::grind) * seconds);
|
||||
w.was_air = air;
|
||||
}
|
||||
// The body's hits in a bail, each where and as hard as it hit.
|
||||
if (s.body_sounds.load(std::memory_order_relaxed) && hall_of_meat::skater_available()) {
|
||||
const auto step = hall_of_meat::read_step(skater, seconds, wipeout);
|
||||
if (step.ragdoll.value_or(false)) {
|
||||
const auto now = GetTickCount64();
|
||||
for (std::size_t i = 1; i < skater_body::count; ++i) {
|
||||
const auto& c = step.body.bodies[i];
|
||||
if (skater_body::foot(static_cast<skater_body::Bone>(i)) || !c.touching || c.impact < body_hit) continue;
|
||||
if (now - w.body_at[i] < 220 || now - w.any_at < 70) continue;
|
||||
w.body_at[i] = w.any_at = now;
|
||||
audio::play(audio::Kind::body, body_loudness(c.impact));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
SetLastError(error);
|
||||
}
|
||||
|
||||
// ---- breaking and mending --------------------------------------------------------------------------
|
||||
// `carried`: the skater is on foot with the board. The grip tape always holds then: a piece that
|
||||
// came off would have to fall to the ground under the skater's feet, and where that is the board
|
||||
// in a hand does not tell (a loose piece takes the ground from where the board rolled or lies).
|
||||
bool snap(State& s, std::uintptr_t board, std::optional<Place> at, bool gave_way, bool carried, std::string_view why, bool asked = false) {
|
||||
if (!s.has_deck.load(std::memory_order_acquire) || s.broken || !board) return false;
|
||||
const auto place = at ? *at : static_cast<Place>(std::uniform_int_distribution<int>(0, 2)(s.random));
|
||||
const bool joined = carried || std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random) < joined_share;
|
||||
const bool joined = carried || std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random) < settings_now().joined / 100.0f;
|
||||
const auto now = GetTickCount64();
|
||||
s.parts = broken_at(place, joined);
|
||||
s.angle = s.parts.hinge == Hinge::fold ? std::uniform_real_distribution<float>(0.35f, 1.0f)(s.random)
|
||||
: std::uniform_real_distribution<float>(0.30f, 0.65f)(s.random);
|
||||
s.break_ground = s.ride_when && now - s.ride_when < 400 ? s.ride_height : std::numeric_limits<float>::quiet_NaN();
|
||||
++s.flights;
|
||||
s.broken = Broken{board, now, gave_way, asked};
|
||||
s.broken = Broken{board, now, gave_way, asked, deck_now(s)};
|
||||
s.back_since = 0;
|
||||
crack_sound(s, break_size(place, joined, s.angle));
|
||||
crack_sound(break_size(place, joined, s.angle));
|
||||
say(logging::Level::debug, "the board broke at the {}{} ({}).", place_names[static_cast<std::size_t>(place)],
|
||||
joined ? ", held together by the grip tape" : "", why);
|
||||
return true;
|
||||
@@ -385,9 +830,21 @@ bool snap(State& s, std::uintptr_t board, std::optional<Place> at, bool gave_way
|
||||
void mend(State& s, std::string_view why) {
|
||||
if (!s.broken) return;
|
||||
say(logging::Level::debug, "the board is whole again after {:.1f} s ({}).", (GetTickCount64() - s.broken->since) / 1000.0, why);
|
||||
// A board that really broke is a new one now; one broken from the console was only looked at.
|
||||
if (!s.broken->asked) new_board(s, s.broken->deck);
|
||||
s.broken.reset();
|
||||
s.parts = {};
|
||||
}
|
||||
// A board that broke on metal (a rail, a bench's edge, a car) sounds like it.
|
||||
void metal_sound(std::uintptr_t base, std::uintptr_t holder) {
|
||||
Vec at{};
|
||||
Quat facing{};
|
||||
if (!board_place(base, holder, at, facing)) return;
|
||||
const auto material = material_near(at, 600, 3.0f);
|
||||
const bool metal = material && is_metal(*material);
|
||||
if (metal && settings_now().crack_sounds) audio::play(audio::Kind::metal, 1.0f);
|
||||
say(logging::Level::debug, "it broke on material {}{}.", material ? std::to_string(*material) : std::string("unknown"), metal ? " (metal)" : "");
|
||||
}
|
||||
// Where the board is relative to the skater: how far, and how flat it lies (1 = flat on the ground
|
||||
// either way up, 0 = on its edge, as when it is carried).
|
||||
struct Hold {
|
||||
@@ -473,6 +930,16 @@ bool start(std::uintptr_t base) noexcept {
|
||||
}
|
||||
s.base.store(base, std::memory_order_release);
|
||||
s.on.store(profile_runtime::local_preference(enabled_preference).value_or(false), std::memory_order_release);
|
||||
{
|
||||
auto& f = feel();
|
||||
std::lock_guard lock(f.mutex);
|
||||
load_feel(f);
|
||||
mirror(s, f.settings);
|
||||
}
|
||||
// Rail snaps, wear and the body's hits come from each physics step of the local skater.
|
||||
if (!add_no_bail_step_listener(&on_step))
|
||||
logging::write(logging::Level::warning, logging::Channel::skater,
|
||||
"Board Break: No Bail has no room for its physics step listener: no rail snaps, wear or body sounds.");
|
||||
const auto in_game = mods::board_break_in_play();
|
||||
{
|
||||
std::lock_guard lock(s.names);
|
||||
@@ -545,11 +1012,32 @@ void on_client_tick(std::uintptr_t client) noexcept {
|
||||
wanted.swap(s.wanted_deck);
|
||||
}
|
||||
s.has_deck.store(in_game, std::memory_order_release);
|
||||
if (changed || !s.setup_read) {
|
||||
s.setup_read = true;
|
||||
const auto setup = read_deck_setup(deck_in_game);
|
||||
render_edit().axle.store(setup.axle, std::memory_order_release);
|
||||
{
|
||||
auto& f = feel();
|
||||
std::lock_guard lock(f.mutex);
|
||||
load_feel(f);
|
||||
const auto worn = f.wear.find(deck_in_game);
|
||||
s.wear_now.store(worn == f.wear.end() ? 0.0f : worn->second, std::memory_order_relaxed);
|
||||
}
|
||||
if (in_game) load_sounds();
|
||||
if (in_game && setup.axle != 1.0f)
|
||||
say(logging::Level::info, "the {} deck's wheels sit {:.2f} as far apart as a standard board's.", deck_label(deck_in_game), setup.axle);
|
||||
}
|
||||
if (changed) say(logging::Level::info, "the deck in the game is now: {}{}.", deck_label(deck_in_game), in_game ? "" : " (boards cannot break)");
|
||||
if (!wanted.empty() && wanted != deck_in_game && mods::save_board_break_deck(wanted)) {
|
||||
mend(s, "another deck");
|
||||
say(logging::Level::info, "deck {} chosen. {}", deck_label(wanted), live_mods::apply(true));
|
||||
}
|
||||
if (NoBailSkater probe; in_game && !s.effects_hooked && no_bail_skater(probe)) {
|
||||
// What a board broke on comes from the game's contact effects (native_vfx.h). The hook is
|
||||
// installed once, in a world (multiplayer would do it there too).
|
||||
s.effects_hooked = true;
|
||||
multiplayer::prepare_effects(base);
|
||||
}
|
||||
if (in_game && !s.hooked) {
|
||||
// The boards' render pose hook is shared with multiplayer and first person, and only
|
||||
// installed when one of them asks.
|
||||
@@ -579,7 +1067,33 @@ void on_client_tick(std::uintptr_t client) noexcept {
|
||||
return;
|
||||
}
|
||||
s.board_seen = now;
|
||||
note_axes(s, base, holder);
|
||||
if (s.broken && s.broken->board != board) mend(s, "the skater has another board");
|
||||
// What wear has done, onto the board; and what that does to its handling.
|
||||
take_wear(s, deck_in_game);
|
||||
if (now - s.handled_at > 500) {
|
||||
s.handled_at = now;
|
||||
if (now - s.session_checked > 2000) {
|
||||
s.session_checked = now;
|
||||
const auto session = multiplayer::model();
|
||||
s.in_session = session.active || session.connected;
|
||||
}
|
||||
const auto st = settings_now();
|
||||
const bool worn = s.on.load(std::memory_order_acquire) && st.wear && !s.in_session;
|
||||
apply_handling(base, skater.entity, worn ? handling(s.wear_now.load(std::memory_order_relaxed)) : Handling{});
|
||||
}
|
||||
if (now - s.saved_at > 5000) {
|
||||
s.saved_at = now;
|
||||
auto& f = feel();
|
||||
std::lock_guard lock(f.mutex);
|
||||
if (f.dirty) save_feel(f);
|
||||
}
|
||||
// A board that came down too hard onto a rail in a slide (the physics thread saw it).
|
||||
if (s.rail_snap.exchange(false, std::memory_order_acq_rel) && s.on.load(std::memory_order_acquire) && !s.broken) {
|
||||
const auto contact = static_cast<std::size_t>(s.rail_contact.load(std::memory_order_relaxed));
|
||||
const auto what = std::format("a {} onto a rail at {:.1f} m/s", contact_names[(std::min)(contact, contact_names.size() - 1)], s.rail_speed.load());
|
||||
if (snap(s, board, Place::mid, false, false, what)) metal_sound(base, holder);
|
||||
}
|
||||
watch_physics_state(client, skater.entity);
|
||||
const auto watch = watched_physics_state();
|
||||
if (s.wanted_mend.exchange(false, std::memory_order_acq_rel)) mend(s, "asked for");
|
||||
@@ -608,13 +1122,24 @@ void on_client_tick(std::uintptr_t client) noexcept {
|
||||
if (hit->reason.load() < 0 || now - hit->at.load() > recent_ms) hit = &last_cause();
|
||||
const bool known = now - hit->at.load() <= recent_ms && hit->reason.load() >= 0;
|
||||
const auto force = known ? hit->force.load() : 0.0f;
|
||||
if (on && !s.broken && std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random) < break_chance(force, heavy, light))
|
||||
(void)snap(s, board, std::nullopt, false, watch.previous >= 500 && watch.previous < 600, std::format("a slam of force {:.1f}", force));
|
||||
const auto st = settings_now();
|
||||
if (on && st.wear) take_wear(s, deck_in_game, slam_wear(force));
|
||||
const float chance = st.always ? 1.0f : slam_chance(force, st.sensitivity, s.wear_now.load(std::memory_order_relaxed));
|
||||
if (on && !s.broken && std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random) < chance) {
|
||||
// Nose first it goes at the nose, tail first at the tail, flat mostly in the middle.
|
||||
Vec along{}, up{};
|
||||
const float slope = board_axes(s, along, up) ? -along.y : 0.0f;
|
||||
const auto place = place_of_break(slope, false, std::uniform_real_distribution<float>(0.0f, 1.0f)(s.random));
|
||||
if (snap(s, board, place, false, watch.previous >= 500 && watch.previous < 600,
|
||||
std::format("a slam of force {:.1f}{}", force, st.always ? ", set to always break" : "")))
|
||||
metal_sound(base, holder);
|
||||
}
|
||||
}
|
||||
// Now and then a board simply gives way under the rider: the nose goes.
|
||||
if (on && !s.broken && s.rode_at && riding(watch.state)) {
|
||||
const auto seconds = (std::min<std::uint64_t>)(now - s.rode_at, 250) / 1000.0;
|
||||
if (std::uniform_real_distribution<double>(0.0, 1.0)(s.random) < gives_way * seconds)
|
||||
const float worn = s.wear_on.load(std::memory_order_relaxed) ? s.wear_now.load(std::memory_order_relaxed) : 0.0f;
|
||||
if (std::uniform_real_distribution<double>(0.0, 1.0)(s.random) < gives_way_per_second(worn) * seconds)
|
||||
(void)snap(s, board, Place::nose, true, false, "it gave way while riding");
|
||||
}
|
||||
s.rode_at = now;
|
||||
@@ -660,6 +1185,7 @@ std::string deck() {
|
||||
}
|
||||
std::string deck_label(std::string_view deck) {
|
||||
if (deck.empty()) return "None";
|
||||
if (auto title = setup_of(deck).label; !title.empty()) return title;
|
||||
std::string name(deck);
|
||||
bool start = true;
|
||||
for (auto& c : name) {
|
||||
@@ -688,4 +1214,84 @@ std::string choose_deck(std::string wanted) {
|
||||
}
|
||||
void snap(int place) noexcept { state().wanted_snap.store(place >= 0 && place < 3 ? place : 3, std::memory_order_release); }
|
||||
void mend() noexcept { state().wanted_mend.store(true, std::memory_order_release); }
|
||||
|
||||
Settings settings() noexcept {
|
||||
try {
|
||||
return settings_now();
|
||||
} catch (...) { return {}; }
|
||||
}
|
||||
bool set_setting(std::string_view name, int value) noexcept {
|
||||
try {
|
||||
auto& f = feel();
|
||||
std::lock_guard lock(f.mutex);
|
||||
load_feel(f);
|
||||
if (!set_setting_value(f.settings, name, value)) return false;
|
||||
mirror(state(), f.settings);
|
||||
save_feel(f);
|
||||
return true;
|
||||
} catch (...) { return false; }
|
||||
}
|
||||
float wear() noexcept { return state().wear_now.load(std::memory_order_relaxed); }
|
||||
void set_wear(float value) noexcept {
|
||||
try {
|
||||
auto& s = state();
|
||||
const auto deck = deck_now(s);
|
||||
if (deck.empty()) return;
|
||||
value = std::clamp(value, 0.0f, 1.0f);
|
||||
{
|
||||
auto& f = feel();
|
||||
std::lock_guard lock(f.mutex);
|
||||
load_feel(f);
|
||||
f.wear[deck] = value;
|
||||
save_feel(f);
|
||||
}
|
||||
s.wear_now.store(value, std::memory_order_relaxed);
|
||||
if (value == 0.0f) profile_runtime::reset_board_wear();
|
||||
} catch (...) {}
|
||||
}
|
||||
std::string metal_command(std::string_view action) {
|
||||
auto& f = feel();
|
||||
if (action == "mark" || action == "unmark") {
|
||||
const auto material = material_near({}, 1500, std::numeric_limits<float>::infinity());
|
||||
if (!material) return "No contacts in the last second and a half: grind the rail first, then mark it.";
|
||||
std::lock_guard lock(f.mutex);
|
||||
load_feel(f);
|
||||
if (action == "mark") f.metal.insert(*material);
|
||||
else f.metal.erase(*material);
|
||||
save_feel(f);
|
||||
return std::format("Material {} is {} metal.", *material, action == "mark" ? "now" : "no longer");
|
||||
}
|
||||
std::lock_guard lock(f.mutex);
|
||||
load_feel(f);
|
||||
if (action == "clear") {
|
||||
f.metal.clear();
|
||||
save_feel(f);
|
||||
return "No material is marked as metal now (a pack's own list still counts).";
|
||||
}
|
||||
std::string list;
|
||||
for (const auto m : f.metal) list += (list.empty() ? "" : ", ") + std::to_string(m);
|
||||
std::string pack;
|
||||
for (const auto m : f.pack_metal) pack += (pack.empty() ? "" : ", ") + std::to_string(m);
|
||||
return "Metal: " + (list.empty() ? std::string("none marked") : list) + "; from the deck pack: " + (pack.empty() ? std::string("none") : pack) + ".";
|
||||
}
|
||||
std::string materials_report() {
|
||||
std::map<std::uint16_t, int> count;
|
||||
for (const auto& one : multiplayer::recent_local_impacts(3000)) ++count[one.impact.material];
|
||||
if (count.empty()) return "No contacts in the last 3 seconds.";
|
||||
std::string out = "Materials touched in the last 3 s:";
|
||||
for (const auto& [material, times] : count) out += std::format(" {} ({}x{})", material, times, is_metal(material) ? ", metal" : "");
|
||||
return out + ".";
|
||||
}
|
||||
std::string status() {
|
||||
auto& s = state();
|
||||
if (!available()) return "Board Break is unavailable for this game build.";
|
||||
const auto st = settings_now();
|
||||
const auto deck = deck_now(s);
|
||||
const auto worn = s.wear_now.load(std::memory_order_relaxed);
|
||||
return std::format("Board Break {}; deck {}; the board is {} ({:.0f}% worn); sensitivity {}%, always break {}, rail snaps {}, wear {} at {}%, "
|
||||
"grip holds {}% of breaks, crack sounds {}, body sounds {}.",
|
||||
s.on.load() ? "on" : "off", deck_label(deck), condition_name(worn), worn * 100.0f,
|
||||
st.sensitivity, st.always ? "on" : "off", st.rails ? "on" : "off", st.wear ? "on" : "off", st.wear_rate, st.joined,
|
||||
st.crack_sounds ? "on" : "off", st.body_sounds ? "on" : "off");
|
||||
}
|
||||
} // namespace dingosdk::board_break
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include "board_break_feel.h"
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
@@ -60,4 +61,23 @@ std::string choose_deck(std::string wanted);
|
||||
// respawns. Or makes a broken board whole.
|
||||
void snap(int place) noexcept;
|
||||
void mend() noexcept;
|
||||
|
||||
// ---- the feel of it -------------------------------------------------------------------------------
|
||||
// The player's settings (board_break_feel.h), kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings.
|
||||
// Any thread; set_setting saves at once and is false for no such setting.
|
||||
Settings settings() noexcept;
|
||||
bool set_setting(std::string_view name, int value) noexcept;
|
||||
// Wear and tear: how worn the board of the deck in the game is (0 new, 1 worn out), kept per deck
|
||||
// with the settings. A board that broke is a new one once it is whole again. A worn board handles
|
||||
// worse (softer trucks, a lazier turn: the game's own physics tuning, only outside multiplayer
|
||||
// sessions, which share tuning), breaks more easily and, worn out, can give way under the rider.
|
||||
// Its scratches are the game's own board wear (Mod Options > Board wear), reset with it.
|
||||
float wear() noexcept;
|
||||
void set_wear(float wear) noexcept;
|
||||
// Metal: the level's materials a board breaking on them sounds like metal on. They are learned
|
||||
// from the contacts while grinding (`boardbreak metal mark` marks what the board is on now as
|
||||
// metal), and a pack can bring a list (sounds/metal/materials.txt). For the console.
|
||||
std::string metal_command(std::string_view action);
|
||||
std::string materials_report();
|
||||
std::string status();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#include "board_break_audio.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include <Windows.h>
|
||||
#include <mmsystem.h>
|
||||
#include <xaudio2.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#pragma comment(lib, "xaudio2.lib")
|
||||
#pragma comment(lib, "winmm.lib")
|
||||
#pragma comment(lib, "ole32.lib")
|
||||
|
||||
namespace dingosdk::board_break::audio {
|
||||
namespace {
|
||||
// One wave file, read whole: its format as the file gives it and its samples.
|
||||
struct Sound {
|
||||
std::filesystem::path file;
|
||||
std::vector<std::uint8_t> format; // WAVEFORMATEX, or the larger WAVEFORMATEXTENSIBLE
|
||||
std::vector<std::uint8_t> samples;
|
||||
};
|
||||
// A RIFF WAVE file's "fmt " and "data" chunks; false for anything else.
|
||||
bool read_wave(const std::filesystem::path& file, Sound& out) {
|
||||
std::ifstream in(file, std::ios::binary);
|
||||
std::vector<std::uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
if (bytes.size() < 12 || std::memcmp(bytes.data(), "RIFF", 4) || std::memcmp(bytes.data() + 8, "WAVE", 4)) return false;
|
||||
std::size_t at = 12;
|
||||
while (at + 8 <= bytes.size()) {
|
||||
std::uint32_t size{};
|
||||
std::memcpy(&size, bytes.data() + at + 4, 4);
|
||||
const auto body = at + 8;
|
||||
if (body + size > bytes.size()) size = static_cast<std::uint32_t>(bytes.size() - body);
|
||||
if (!std::memcmp(bytes.data() + at, "fmt ", 4) && size >= 16) out.format.assign(bytes.begin() + body, bytes.begin() + body + size);
|
||||
else if (!std::memcmp(bytes.data() + at, "data", 4)) out.samples.assign(bytes.begin() + body, bytes.begin() + body + size);
|
||||
at = body + size + (size & 1);
|
||||
}
|
||||
if (out.format.size() < 16 || out.samples.empty()) return false;
|
||||
std::uint16_t tag{};
|
||||
std::memcpy(&tag, out.format.data(), 2);
|
||||
if (tag != WAVE_FORMAT_PCM && tag != WAVE_FORMAT_IEEE_FLOAT && tag != WAVE_FORMAT_EXTENSIBLE) return false;
|
||||
// XAudio2 reads a whole WAVEFORMATEX (cbSize included).
|
||||
if (out.format.size() < sizeof(WAVEFORMATEX)) out.format.resize(sizeof(WAVEFORMATEX), 0);
|
||||
out.file = file;
|
||||
return true;
|
||||
}
|
||||
|
||||
struct Request {
|
||||
Kind kind{};
|
||||
float volume{};
|
||||
};
|
||||
struct Engine {
|
||||
std::mutex mutex;
|
||||
std::condition_variable wake;
|
||||
std::deque<Request> queue;
|
||||
std::array<std::vector<Sound>, kinds> sounds;
|
||||
std::array<bool, kinds> loaded{};
|
||||
std::thread worker;
|
||||
std::atomic<bool> started{}, failed{};
|
||||
std::mt19937 random{std::random_device{}()};
|
||||
};
|
||||
Engine& engine() {
|
||||
static auto* value = new Engine;
|
||||
return *value;
|
||||
}
|
||||
|
||||
void run(Engine& e) {
|
||||
const auto com = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
IXAudio2* xaudio{};
|
||||
IXAudio2MasteringVoice* master{};
|
||||
if (FAILED(XAudio2Create(&xaudio, 0, XAUDIO2_DEFAULT_PROCESSOR)) || FAILED(xaudio->CreateMasteringVoice(&master))) {
|
||||
if (xaudio) xaudio->Release();
|
||||
xaudio = nullptr;
|
||||
e.failed.store(true, std::memory_order_release);
|
||||
logging::log(logging::Level::warning, logging::Channel::skater,
|
||||
"Board Break: no sound device for its own sounds; the cracks play one at a time.");
|
||||
}
|
||||
std::vector<IXAudio2SourceVoice*> voices;
|
||||
for (;;) {
|
||||
Request request;
|
||||
const Sound* sound{};
|
||||
{
|
||||
std::unique_lock lock(e.mutex);
|
||||
e.wake.wait(lock, [&] { return !e.queue.empty(); });
|
||||
request = e.queue.front();
|
||||
e.queue.pop_front();
|
||||
auto& list = e.sounds[static_cast<std::size_t>(request.kind)];
|
||||
if (list.empty()) continue;
|
||||
sound = &list[std::uniform_int_distribution<std::size_t>(0, list.size() - 1)(e.random)];
|
||||
}
|
||||
if (!xaudio) {
|
||||
// Without XAudio2: the cracks still sound, one at a time.
|
||||
if (request.kind <= Kind::heavy) (void)PlaySoundW(sound->file.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT);
|
||||
continue;
|
||||
}
|
||||
// Voices whose sound has played out go; at most a couple of dozen at once.
|
||||
std::erase_if(voices, [](IXAudio2SourceVoice* voice) {
|
||||
XAUDIO2_VOICE_STATE state{};
|
||||
voice->GetState(&state, XAUDIO2_VOICE_NOSAMPLESPLAYED);
|
||||
if (state.BuffersQueued) return false;
|
||||
voice->DestroyVoice();
|
||||
return true;
|
||||
});
|
||||
if (voices.size() >= 24) {
|
||||
voices.front()->DestroyVoice();
|
||||
voices.erase(voices.begin());
|
||||
}
|
||||
IXAudio2SourceVoice* voice{};
|
||||
if (FAILED(xaudio->CreateSourceVoice(&voice, reinterpret_cast<const WAVEFORMATEX*>(sound->format.data())))) continue;
|
||||
XAUDIO2_BUFFER buffer{};
|
||||
buffer.AudioBytes = static_cast<UINT32>(sound->samples.size());
|
||||
buffer.pAudioData = sound->samples.data();
|
||||
buffer.Flags = XAUDIO2_END_OF_STREAM;
|
||||
voice->SetVolume(std::clamp(request.volume, 0.0f, 1.0f));
|
||||
if (FAILED(voice->SubmitSourceBuffer(&buffer)) || FAILED(voice->Start())) {
|
||||
voice->DestroyVoice();
|
||||
continue;
|
||||
}
|
||||
voices.push_back(voice);
|
||||
}
|
||||
(void)com;
|
||||
}
|
||||
}
|
||||
|
||||
void load(Kind kind, const std::vector<std::filesystem::path>& files) noexcept {
|
||||
auto& e = engine();
|
||||
std::vector<Sound> read;
|
||||
for (const auto& file : files) {
|
||||
try {
|
||||
Sound sound;
|
||||
if (read_wave(file, sound)) read.push_back(std::move(sound));
|
||||
else logging::log(logging::Level::warning, logging::Channel::skater, "Board Break: {} is not a wave file it can play.",
|
||||
file.filename().string());
|
||||
} catch (...) {}
|
||||
}
|
||||
std::lock_guard lock(e.mutex);
|
||||
const auto at = static_cast<std::size_t>(kind);
|
||||
if (e.loaded[at]) return;
|
||||
e.loaded[at] = true;
|
||||
e.sounds[at] = std::move(read);
|
||||
}
|
||||
bool has(Kind kind) noexcept {
|
||||
auto& e = engine();
|
||||
std::lock_guard lock(e.mutex);
|
||||
return !e.sounds[static_cast<std::size_t>(kind)].empty();
|
||||
}
|
||||
void play(Kind kind, float volume) noexcept {
|
||||
auto& e = engine();
|
||||
try {
|
||||
if (!e.started.exchange(true)) {
|
||||
e.worker = std::thread([&e] { run(e); });
|
||||
e.worker.detach();
|
||||
}
|
||||
std::lock_guard lock(e.mutex);
|
||||
if (e.queue.size() < 32) e.queue.push_back({kind, volume});
|
||||
} catch (...) { return; }
|
||||
e.wake.notify_one();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
// Board Break's sounds: wave files out of a deck pack's `sounds` folder, played straight to the
|
||||
// speakers (not through the game's mixer), over each other and at the loudness asked for, so a
|
||||
// crack, the board's metal and the body's hits of one bail can all be heard. XAudio2 on a thread
|
||||
// of its own; without it (no sound device) the cracks fall back to PlaySound, one at a time.
|
||||
namespace dingosdk::board_break::audio {
|
||||
enum class Kind { light, medium, heavy, metal, body };
|
||||
inline constexpr std::size_t kinds = 5;
|
||||
// The wave files of a kind (16-bit or float PCM, any rate and channels). Any thread; a kind is
|
||||
// given its files once, later calls for it change nothing.
|
||||
void load(Kind kind, const std::vector<std::filesystem::path>& files) noexcept;
|
||||
bool has(Kind kind) noexcept;
|
||||
// One of them, at random, at `volume` (0 to 1). Any thread; returns at once.
|
||||
void play(Kind kind, float volume) noexcept;
|
||||
}
|
||||
@@ -71,5 +71,78 @@ void register_board_break_commands(Commands ®istry) {
|
||||
out("Mended.");
|
||||
};
|
||||
registry.add(std::move(mend));
|
||||
// The player's settings (board_break_feel.h), kept: with no value each says what it is.
|
||||
struct Setting {
|
||||
const char *name, *description;
|
||||
};
|
||||
const Setting settings[]{
|
||||
{"always", "Board Break: every slam breaks the board, and every slide that comes down onto a rail (for replay clips)"},
|
||||
{"sensitivity", "Board Break: how easily a board breaks, percent (100; 200 takes half the force, 50 twice as much)"},
|
||||
{"joined", "Board Break: percent of breaks the grip tape holds together (50)"},
|
||||
{"rails", "Board Break: a boardslide or darkslide that comes down too hard onto a rail can snap the board"},
|
||||
{"wear", "Board Break: boards wear out with every hit, handle worse and break more easily as they do"},
|
||||
{"wearrate", "Board Break: how fast boards wear, percent (100)"},
|
||||
{"cracks", "Board Break: the crack (and metal) sound of a break"},
|
||||
{"bodysounds", "Board Break: the body's hits in a bail, where and as hard as it hits"},
|
||||
};
|
||||
for (const auto &setting : settings) {
|
||||
auto entry = action(std::string("boardbreak ") + setting.name, setting.description, Group::gameplay,
|
||||
{argument("value", Type::text, true)});
|
||||
entry.run = [name = std::string(setting.name)](const Model &, const Values &values, const Output &out) {
|
||||
const auto *text = values.empty() ? nullptr : std::get_if<std::string>(&values[0]);
|
||||
if (!text || text->empty()) {
|
||||
out(name + " is " + std::to_string(board_break::setting_value(board_break::settings(), name)) + ".");
|
||||
return;
|
||||
}
|
||||
int value{};
|
||||
if (*text == "on" || *text == "yes") value = 1;
|
||||
else if (*text == "off" || *text == "no") value = 0;
|
||||
else {
|
||||
try {
|
||||
value = std::stoi(*text);
|
||||
} catch (...) {
|
||||
out("Give a number (or on/off).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
out(board_break::set_setting(name, value) ? name + " is now " + std::to_string(board_break::setting_value(board_break::settings(), name)) + "."
|
||||
: "No such setting.");
|
||||
};
|
||||
registry.add(std::move(entry));
|
||||
}
|
||||
|
||||
auto worn = action("boardbreak worn", "How worn the board of the deck in the game is, percent; 0 is a new board", Group::gameplay,
|
||||
{argument("percent", Type::text, true)});
|
||||
worn.run = [](const Model &, const Values &values, const Output &out) {
|
||||
const auto *text = values.empty() ? nullptr : std::get_if<std::string>(&values[0]);
|
||||
if (text && !text->empty()) {
|
||||
try {
|
||||
board_break::set_wear(std::stof(*text) / 100.0f);
|
||||
} catch (...) {
|
||||
out("Give a percent, 0 to 100.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const auto wear = board_break::wear();
|
||||
out("The board is " + std::string(board_break::condition_name(wear)) + ", " + std::to_string(static_cast<int>(wear * 100.0f + 0.5f)) + "% worn.");
|
||||
};
|
||||
registry.add(std::move(worn));
|
||||
|
||||
auto status = action("boardbreak status", "Everything Board Break is set to, and how worn the board is", Group::gameplay);
|
||||
status.run = [](const Model &, const Values &, const Output &out) { out(board_break::status()); };
|
||||
registry.add(std::move(status));
|
||||
|
||||
auto metal = action("boardbreak metal", "Which materials a board breaking on them sounds like metal on: mark (what you grind now), unmark, clear, list",
|
||||
Group::gameplay, {argument("mark|unmark|clear|list", Type::text, true)});
|
||||
metal.run = [](const Model &, const Values &values, const Output &out) {
|
||||
const auto *text = values.empty() ? nullptr : std::get_if<std::string>(&values[0]);
|
||||
out(board_break::metal_command(text ? *text : std::string("list")));
|
||||
};
|
||||
registry.add(std::move(metal));
|
||||
|
||||
auto materials = action("boardbreak materials", "The level's materials your skater touched in the last 3 seconds, by number (to find a rail's)",
|
||||
Group::gameplay);
|
||||
materials.run = [](const Model &, const Values &, const Output &out) { out(board_break::materials_report()); };
|
||||
registry.add(std::move(materials));
|
||||
}
|
||||
} // namespace dingosdk::console
|
||||
@@ -0,0 +1,171 @@
|
||||
#pragma once
|
||||
#include "board_break_model.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <string_view>
|
||||
|
||||
// How a board wears and how hard it has to be hit to break, apart from the game: the player's
|
||||
// settings, the damage each kind of hit does, what a worn board does to its handling, and what kind
|
||||
// of contact a board makes with a rail. Plain numbers, so the tests can hold them.
|
||||
namespace dingosdk::board_break {
|
||||
// ---- the player's settings ----------------------------------------------------------------------
|
||||
// Each is kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings and set from the console as
|
||||
// `boardbreak <name> <value>` (the menus send the same).
|
||||
struct Settings {
|
||||
bool always = false; // every slam breaks the board, for replay clips
|
||||
int sensitivity = 100; // percent: at 200 half the force breaks a board, at 50 twice as much
|
||||
int joined = 50; // percent of breaks the grip tape holds together
|
||||
bool rails = true; // a hard boardslide or darkslide onto a rail or ledge can break it
|
||||
bool wear = true; // boards wear out, handle worse and break more easily as they do
|
||||
int wear_rate = 100; // percent: how fast they wear
|
||||
bool crack_sounds = true;
|
||||
bool body_sounds = true; // the body's hits during a bail
|
||||
};
|
||||
inline constexpr int sensitivity_min = 10, sensitivity_max = 400, rate_min = 0, rate_max = 500;
|
||||
struct SettingInfo {
|
||||
std::string_view name;
|
||||
bool on_off; // else a percent
|
||||
int low, high;
|
||||
};
|
||||
inline constexpr std::array<SettingInfo, 8> setting_info{{
|
||||
{"always", true, 0, 1}, {"sensitivity", false, sensitivity_min, sensitivity_max}, {"joined", false, 0, 100},
|
||||
{"rails", true, 0, 1}, {"wear", true, 0, 1}, {"wearrate", false, rate_min, rate_max},
|
||||
{"cracks", true, 0, 1}, {"bodysounds", true, 0, 1},
|
||||
}};
|
||||
// The setting `name` as a number (on/off as 1/0), or -1 for no such setting.
|
||||
inline int setting_value(const Settings& s, std::string_view name) noexcept {
|
||||
if (name == "always") return s.always;
|
||||
if (name == "sensitivity") return s.sensitivity;
|
||||
if (name == "joined") return s.joined;
|
||||
if (name == "rails") return s.rails;
|
||||
if (name == "wear") return s.wear;
|
||||
if (name == "wearrate") return s.wear_rate;
|
||||
if (name == "cracks") return s.crack_sounds;
|
||||
if (name == "bodysounds") return s.body_sounds;
|
||||
return -1;
|
||||
}
|
||||
// Sets it, kept within its range; false for no such setting.
|
||||
inline bool set_setting_value(Settings& s, std::string_view name, int value) noexcept {
|
||||
for (const auto& info : setting_info) {
|
||||
if (info.name != name) continue;
|
||||
value = std::clamp(value, info.low, info.high);
|
||||
if (name == "always") s.always = value != 0;
|
||||
else if (name == "sensitivity") s.sensitivity = value;
|
||||
else if (name == "joined") s.joined = value;
|
||||
else if (name == "rails") s.rails = value != 0;
|
||||
else if (name == "wear") s.wear = value != 0;
|
||||
else if (name == "wearrate") s.wear_rate = value;
|
||||
else if (name == "cracks") s.crack_sounds = value != 0;
|
||||
else s.body_sounds = value != 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- how hard a slam has to be ------------------------------------------------------------------
|
||||
// The slam forces measured in play run from about 2 (a stumble) to 10 and more (a hard slam).
|
||||
inline constexpr float heavy_slam = 10.0f, light_chance = 0.25f;
|
||||
// A worn board takes less: at the end of its life about half the force it took new.
|
||||
inline constexpr float worn_strength = 0.5f;
|
||||
// How likely a slam of `force` breaks the board, with the player's sensitivity (percent) and the
|
||||
// board's wear (0 new, 1 worn out).
|
||||
inline float slam_chance(float force, int sensitivity, float wear) noexcept {
|
||||
const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f;
|
||||
const float strength = 1.0f - (1.0f - worn_strength) * std::clamp(wear, 0.0f, 1.0f);
|
||||
const float light = std::clamp(light_chance * s * (1.0f + wear), 0.0f, 0.95f);
|
||||
return break_chance(force, heavy_slam * strength / s, light);
|
||||
}
|
||||
|
||||
// ---- wear and tear ------------------------------------------------------------------------------
|
||||
// A board's wear runs from 0 (new) to 1 (worn out). Every hit adds some, at the player's rate
|
||||
// (percent of these). A fresh board lasts a good while: a few hundred hard landings, or some
|
||||
// dozens of big drops, or half an hour of boardslides.
|
||||
// A landing, by how fast the board was coming down as it touched (m/s): nothing up to an ollie off
|
||||
// a curb's speed, then more and more.
|
||||
inline constexpr float landing_free = 3.5f;
|
||||
inline float landing_wear(float down_speed) noexcept {
|
||||
const float over = (std::max)(0.0f, down_speed - landing_free);
|
||||
return (std::min)(0.0008f * over * over, 0.04f);
|
||||
}
|
||||
// Onto a rail or ledge: the board's own hit, harder on a slide (the deck takes it) than on the trucks.
|
||||
inline float rail_wear(float down_speed, bool slide) noexcept {
|
||||
const float hit = landing_wear(down_speed + (slide ? 1.0f : 0.0f));
|
||||
return hit + (slide ? 0.001f : 0.0003f);
|
||||
}
|
||||
// Every second of grinding or sliding.
|
||||
inline float grind_wear_per_second(bool slide) noexcept { return slide ? 0.0005f : 0.00015f; }
|
||||
// A slam, by its force.
|
||||
inline float slam_wear(float force) noexcept { return std::clamp((force - 4.0f) / 6.0f, 0.0f, 1.0f) * 0.025f; }
|
||||
inline float add_wear(float wear, float amount, int rate) noexcept {
|
||||
return std::clamp(wear + amount * std::clamp(rate, rate_min, rate_max) / 100.0f, 0.0f, 1.0f);
|
||||
}
|
||||
// What wear does to the board's handling, as factors on the game's own tuning: the steering answers
|
||||
// less, the trucks' springs and their damping go soft (the board wobbles and leans further), and
|
||||
// they twist further. Nothing shows until a board is a fifth worn.
|
||||
struct Handling {
|
||||
float steering = 1, spring = 1, damping = 1, twist = 1;
|
||||
bool operator==(const Handling&) const = default;
|
||||
};
|
||||
inline Handling handling(float wear) noexcept {
|
||||
const float w = std::clamp((wear - 0.2f) / 0.8f, 0.0f, 1.0f);
|
||||
const float eased = w * w * (3.0f - 2.0f * w);
|
||||
return {1.0f - 0.25f * eased, 1.0f - 0.6f * eased, 1.0f - 0.5f * eased, 1.0f + 0.4f * eased};
|
||||
}
|
||||
// A worn board can give way under the rider: per second of riding.
|
||||
inline double gives_way_per_second(float wear) noexcept {
|
||||
const double w = std::clamp((wear - 0.8f) / 0.2f, 0.0f, 1.0f);
|
||||
return 0.0001 + w * w * 0.01;
|
||||
}
|
||||
// What a board's state is called on the menus.
|
||||
inline std::string_view condition_name(float wear) noexcept {
|
||||
return wear < 0.15f ? "new" : wear < 0.4f ? "used" : wear < 0.65f ? "worn" : wear < 0.85f ? "beat" : "done";
|
||||
}
|
||||
|
||||
// ---- the board on a rail ------------------------------------------------------------------------
|
||||
enum class Contact { grind, slide, darkslide };
|
||||
constexpr std::array<std::string_view, 3> contact_names{"grind", "slide", "darkslide"};
|
||||
// From the board's long axis and its up axis in the world, and how it moves: a board lying across
|
||||
// its way (a boardslide, a lipslide) slides, one on its grip is in a darkslide, the rest grind on
|
||||
// their trucks. A board that hardly moves grinds.
|
||||
inline Contact contact_of(Vec along, Vec up, Vec velocity) noexcept {
|
||||
if (up.y < -0.35f) return Contact::darkslide;
|
||||
const float a = std::hypot(along.x, along.z), v = std::hypot(velocity.x, velocity.z);
|
||||
if (a < 0.2f || v < 0.5f) return Contact::grind;
|
||||
const float cosine = std::abs((along.x * velocity.x + along.z * velocity.z) / (a * v));
|
||||
return cosine < 0.5f ? Contact::slide : Contact::grind;
|
||||
}
|
||||
// How likely a board snaps as it comes down onto a rail in a slide, by how fast it was coming down
|
||||
// (m/s): from 3 m/s up (a hop onto a rail is about 2), certain 3 m/s above that; a darkslide on the
|
||||
// grip goes sooner. Grinds on the trucks do not snap a board. Sensitivity and wear as for a slam.
|
||||
inline float rail_chance(float down_speed, Contact contact, int sensitivity, float wear) noexcept {
|
||||
if (contact == Contact::grind) return 0.0f;
|
||||
const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f;
|
||||
const float strength = 1.0f - (1.0f - worn_strength) * std::clamp(wear, 0.0f, 1.0f);
|
||||
const float start = (contact == Contact::darkslide ? 2.2f : 3.0f) * strength / s;
|
||||
const float span = 3.0f * strength / s;
|
||||
return std::clamp((down_speed - start) / span, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
// ---- where a board breaks -----------------------------------------------------------------------
|
||||
// From how the board came down: nose first it breaks at the nose, tail first at the tail, flat (or
|
||||
// across a rail) in the middle. `slope` is how far the nose points down (the board's long axis'
|
||||
// downward part, -1 to 1); `roll` is 0 to 1, chance. Flat landings still break at an end now and
|
||||
// then, as real boards do.
|
||||
inline Place place_of_break(float slope, bool across_rail, float roll) noexcept {
|
||||
if (across_rail) return Place::mid;
|
||||
if (slope > 0.35f) return roll < 0.75f ? Place::nose : Place::mid;
|
||||
if (slope < -0.35f) return roll < 0.75f ? Place::tail : Place::mid;
|
||||
return roll < 0.5f ? Place::mid : roll < 0.75f ? Place::nose : Place::tail;
|
||||
}
|
||||
|
||||
// ---- a body's hit, for its sound -----------------------------------------------------------------
|
||||
// The ragdoll's hits along their normal (Extension/HallOfMeat/hall_of_meat_model.h): a graze is 2 to
|
||||
// 3 m/s, a fall on flat ground 3 to 9, a drop from height 13 and more. Below `body_hit` nothing is
|
||||
// heard; the loudness rises to full at `body_full`.
|
||||
inline constexpr float body_hit = 3.0f, body_full = 11.0f;
|
||||
inline float body_loudness(float speed) noexcept {
|
||||
if (speed < body_hit) return 0.0f;
|
||||
return std::clamp(0.3f + 0.7f * (speed - body_hit) / (body_full - body_hit), 0.3f, 1.0f);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <bit>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
|
||||
// Other players' skaters and skateboards are the game's own entities, and the game gives each
|
||||
@@ -76,6 +77,7 @@ struct State {
|
||||
BuildEffects build_effects{};
|
||||
std::mutex mutex;
|
||||
std::vector<Impact> captured; // the local game's, since the last drain
|
||||
std::deque<LocalImpact> recent; // the local game's, the last few seconds' (recent_local_impacts)
|
||||
std::vector<Registered> registered; // every effects component seen registering, newest last
|
||||
std::vector<Pair> pairs;
|
||||
std::array<bool, max_impact_material + 1> seen{}; // materials this game's own contacts named
|
||||
@@ -132,6 +134,8 @@ Address impact_hook(Address manager, Address key, const void *native) {
|
||||
s.seen[impact.material] = true;
|
||||
if (s.captured.size() >= 16) s.captured.erase(s.captured.begin());
|
||||
s.captured.push_back(impact);
|
||||
if (s.recent.size() >= 96) s.recent.pop_front();
|
||||
s.recent.push_back({impact, GetTickCount64()});
|
||||
++s.captured_count;
|
||||
}
|
||||
}
|
||||
@@ -359,6 +363,15 @@ std::vector<Impact> drain_impacts() {
|
||||
if (out.size() > max_impacts) out.erase(out.begin(), out.end() - max_impacts);
|
||||
return out;
|
||||
}
|
||||
std::vector<LocalImpact> recent_local_impacts(std::uint64_t within_ms) {
|
||||
auto &s = state();
|
||||
const auto now = GetTickCount64();
|
||||
std::lock_guard lock(s.mutex);
|
||||
std::vector<LocalImpact> out;
|
||||
for (const auto &one : s.recent)
|
||||
if (now - one.at <= within_ms) out.push_back(one);
|
||||
return out;
|
||||
}
|
||||
void play_impact(const Impact &impact) noexcept {
|
||||
auto &s = state();
|
||||
if (!s.installed || GetCurrentThreadId() != s.client_thread || !valid_impact(impact)) return;
|
||||
|
||||
@@ -9,6 +9,14 @@ namespace dingosdk::multiplayer {
|
||||
void prepare_effects(std::uintptr_t base) noexcept;
|
||||
// The local game's contacts since the last call, oldest first: the newest max_impacts.
|
||||
std::vector<Impact> drain_impacts();
|
||||
// The same contacts as the game played their effects (sparks, dust, landings), newest last, from
|
||||
// the last `within_ms` milliseconds, kept apart from the drain: what a board broke on (Board Break).
|
||||
// `at` is GetTickCount64's. Any thread.
|
||||
struct LocalImpact {
|
||||
Impact impact;
|
||||
std::uint64_t at{};
|
||||
};
|
||||
std::vector<LocalImpact> recent_local_impacts(std::uint64_t within_ms);
|
||||
// For the current PeerScope slot's skater: the game's effect for one of that player's contacts.
|
||||
void play_impact(const Impact &impact) noexcept;
|
||||
// For the current PeerScope slot's skater, after its outfit changed: the effects its costume
|
||||
|
||||
@@ -21,6 +21,9 @@ using SkeletonResponse = void (*)(std::uintptr_t, float, bool);
|
||||
using PublishAnimation = void (*)(std::uintptr_t);
|
||||
using ResetCauses = void (*)(std::uintptr_t);
|
||||
constexpr std::uintptr_t highest = memory::highest_user_address;
|
||||
// The cause most real slams record (Board Break's logs: cause 6 at forces of 9.6 to 10.7): a wipeout
|
||||
// asked for goes in as one of those.
|
||||
constexpr std::int32_t wanted_wipeout_reason = 6;
|
||||
// The hooks re-resolve the local skater's ownership (some 35 fields) on every
|
||||
// protected physics step: guarded same-process copies, not a system call each.
|
||||
template<class T> bool read(std::uintptr_t address, T& value) noexcept { return memory::peek(address, value); }
|
||||
@@ -72,6 +75,12 @@ struct StateWatch {
|
||||
std::atomic<std::int64_t> since{}, previous_ticks{}; // performance-counter ticks
|
||||
};
|
||||
StateWatch& state_watch() { static auto* value = new StateWatch; return *value; }
|
||||
// A wipeout asked for (no_bail_request_wipeout): the cause goes in at the watched selector's next pick.
|
||||
struct WantedWipeout {
|
||||
std::atomic<float> magnitude{};
|
||||
std::atomic<std::uint64_t> until{};
|
||||
};
|
||||
WantedWipeout& wanted_wipeout() { static auto* value = new WantedWipeout; return *value; }
|
||||
|
||||
// Recheck live local ownership at use time. A retained physics address alone
|
||||
// must never protect another skater after a respawn or level change.
|
||||
@@ -208,12 +217,28 @@ std::uint32_t choose_state(std::uintptr_t selector, std::uint32_t current) {
|
||||
LastError error;
|
||||
hold_off_board(selector, current);
|
||||
}
|
||||
// A wipeout asked for (no_bail_request_wipeout): its cause goes in as the game's contact tests
|
||||
// record theirs, and the pick is Wipeout, as some of those tests return it directly.
|
||||
bool force_wipeout{};
|
||||
if (auto& wanted = wanted_wipeout(); wanted.until.load(std::memory_order_acquire) &&
|
||||
selector == state_watch().selector.load(std::memory_order_acquire)) {
|
||||
LastError error;
|
||||
if (const auto until = wanted.until.exchange(0, std::memory_order_acq_rel); GetTickCount64() < until)
|
||||
if (const auto causes = state_watch().causes.load(std::memory_order_acquire); causes && !protected_owner(selector, &Owner::selector)) {
|
||||
record_cause(causes, wanted_wipeout_reason, wanted.magnitude.load(std::memory_order_relaxed));
|
||||
force_wipeout = true;
|
||||
}
|
||||
}
|
||||
// Clear shared requests before selection so native ground/air/walking
|
||||
// transitions can still run. Some contact tests return Wipeout directly;
|
||||
// retain the current state only for that result, never ordinary Offboard.
|
||||
const bool filtered = filter_requests(selector, &Owner::selector);
|
||||
const auto next = protection().choose_original(selector, current);
|
||||
auto next = protection().choose_original(selector, current);
|
||||
LastError error;
|
||||
if (force_wipeout && next != wipeout_physics_state) {
|
||||
logging::log(logging::Level::debug, logging::Channel::skater, "No Bail: a wipeout was asked for (state {} would have been {}).", current, next);
|
||||
next = wipeout_physics_state;
|
||||
}
|
||||
const auto chosen = filtered && next == wipeout_physics_state && protected_owner(selector, &Owner::selector) ? current : next;
|
||||
auto& w = state_watch();
|
||||
if (selector == w.selector.load(std::memory_order_acquire) && GetTickCount64() < w.until.load(std::memory_order_acquire)) {
|
||||
@@ -433,6 +458,11 @@ bool no_bail_skater(NoBailSkater& skater) noexcept { return published_skater(0,
|
||||
void set_no_bail_step_observer(NoBailStepObserver observer) noexcept {
|
||||
protection().step_observer.store(observer, std::memory_order_release);
|
||||
}
|
||||
void no_bail_request_wipeout(float magnitude) noexcept {
|
||||
auto& wanted = wanted_wipeout();
|
||||
wanted.magnitude.store(magnitude, std::memory_order_relaxed);
|
||||
wanted.until.store(GetTickCount64() + 250, std::memory_order_release);
|
||||
}
|
||||
bool add_no_bail_step_listener(NoBailStepObserver listener) noexcept {
|
||||
if (!listener) return false;
|
||||
auto& slots = protection().listeners;
|
||||
|
||||
@@ -45,5 +45,11 @@ void set_no_bail_step_observer(NoBailStepObserver observer) noexcept;
|
||||
// false when all slots are taken.
|
||||
inline constexpr std::size_t no_bail_step_listener_slots = 4;
|
||||
bool add_no_bail_step_listener(NoBailStepObserver listener) noexcept;
|
||||
// Any thread: the watched local skater bails at its next physics step, as from a slam of
|
||||
// `magnitude` (Board Break: a board that snaps on a rail throws the rider). The cause goes in just
|
||||
// before the game's selector picks the next state, the way the game's own contact tests record
|
||||
// theirs, so the game makes the wipeout itself; while No Bail protects the skater it is dropped like
|
||||
// any other. Expires unused after a quarter of a second.
|
||||
void no_bail_request_wipeout(float magnitude) noexcept;
|
||||
void remove_no_bail_step_listener(NoBailStepObserver listener) noexcept;
|
||||
}
|
||||
@@ -212,6 +212,10 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks
|
||||
console(m.board_break.enabled ? "boardbreak 0" : "boardbreak 1", m.board_break.available);
|
||||
} else if (command == "board-break-deck") {
|
||||
console("boardbreak deck next", m.board_break.available && m.board_break.has_deck);
|
||||
} else if (command == "board-break-always") {
|
||||
console(m.board_break.always ? "boardbreak always 0" : "boardbreak always 1", m.board_break.available);
|
||||
} else if (command == "board-break-wear") {
|
||||
console(m.board_break.wear ? "boardbreak wear 0" : "boardbreak wear 1", m.board_break.available);
|
||||
} else if (command == "grind-exits") {
|
||||
console(m.grind_exit.enabled ? "grindexit exits off" : "grindexit exits on", m.grind_exit.available);
|
||||
} else if (command == "grind-exit-sides") {
|
||||
@@ -327,6 +331,10 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3
|
||||
toggle(p.main, "board-break", "Board Break", m.board_break.enabled, m.board_break.available && cb.queue_console_command);
|
||||
button(p.main, "board-break-deck", "Board Break deck: " + m.board_break.deck, "board-break-deck",
|
||||
m.board_break.available && m.board_break.has_deck && cb.queue_console_command);
|
||||
toggle(p.main, "board-break-always", "Board Break: always break", m.board_break.always,
|
||||
m.board_break.available && m.board_break.enabled && cb.queue_console_command);
|
||||
toggle(p.main, "board-break-wear", "Board Break: wear and tear", m.board_break.wear,
|
||||
m.board_break.available && m.board_break.enabled && cb.queue_console_command);
|
||||
const auto& grind = m.grind_exit;
|
||||
const bool grind_console = grind.available && cb.queue_console_command;
|
||||
toggle(p.main, "grind-exits", "Grind exit triggers", grind.enabled, grind_console);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "Engine/Vfs/initfs.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Extension/Assets/live_mods.h"
|
||||
#include "Extension/BoardBreak/board_break.h"
|
||||
#include "Extension/UI/skate_theme.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
|
||||
@@ -21,14 +22,17 @@ namespace menu {
|
||||
// deck is a mod's copy of it, so picking one applies the mods with that deck first and loads the
|
||||
// level again. A button for each deck the feature was made with, greyed out while it is not
|
||||
// installed, and for any other deck that is; the Mods folder is read again every 1.5 s while the
|
||||
// card shows.
|
||||
// card shows. A deck goes by the name its deck.ini gives it (label), else by its folder's.
|
||||
void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
||||
static std::vector<std::string> installed;
|
||||
static std::vector<std::string> titles; // each installed deck's name for people
|
||||
static std::string chosen;
|
||||
static ULONGLONG next_scan{};
|
||||
if (const auto now = GetTickCount64(); now >= next_scan) {
|
||||
installed = mods::installed_board_break_decks();
|
||||
chosen = mods::wanted_board_break_deck();
|
||||
titles.clear();
|
||||
for (const auto& deck : installed) titles.push_back(board_break::deck_label(deck));
|
||||
next_scan = now + 1500;
|
||||
}
|
||||
if (!model.board_break.available || installed.empty()) return;
|
||||
@@ -39,12 +43,18 @@ void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV
|
||||
for (const auto& deck : installed)
|
||||
if (std::find(decks.begin(), decks.end(), deck) == decks.end()) decks.push_back(deck);
|
||||
const auto label = [&](const std::string& deck) {
|
||||
auto name = deck;
|
||||
bool start = true;
|
||||
for (auto& c : name) {
|
||||
if (c == '_') c = ' ';
|
||||
else if (start && c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
|
||||
start = c == ' ';
|
||||
std::string name;
|
||||
if (const auto at = std::find(installed.begin(), installed.end(), deck);
|
||||
at != installed.end() && static_cast<std::size_t>(at - installed.begin()) < titles.size())
|
||||
name = titles[static_cast<std::size_t>(at - installed.begin())];
|
||||
else {
|
||||
name = deck;
|
||||
bool start = true;
|
||||
for (auto& c : name) {
|
||||
if (c == '_') c = ' ';
|
||||
else if (start && c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
|
||||
start = c == ' ';
|
||||
}
|
||||
}
|
||||
const auto place = std::find(decks.begin(), decks.end(), deck) - decks.begin() + 1;
|
||||
return std::to_string(place) + " " + name;
|
||||
|
||||
@@ -231,6 +231,12 @@ struct RoadRashModel {
|
||||
struct BoardBreakModel {
|
||||
bool available = false, enabled = false, has_deck = false;
|
||||
std::string deck = "None";
|
||||
// Its settings (Extension/BoardBreak/board_break_feel.h) and how worn the board is (0 to 1, and
|
||||
// its word).
|
||||
bool always = false, rails = true, wear = true, crack_sounds = true, body_sounds = true;
|
||||
int sensitivity = 100, joined = 50, wear_rate = 100;
|
||||
float worn = 0;
|
||||
std::string condition = "new";
|
||||
};
|
||||
|
||||
// Grind exit control's switches (Extension/Skater/grind_exit_control.h): started, switched on,
|
||||
|
||||
@@ -143,6 +143,56 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal
|
||||
end_card();
|
||||
}
|
||||
|
||||
// Board Break's settings (Extension/BoardBreak/board_break_feel.h): how a board breaks, wears and
|
||||
// sounds. Each is a console verb, kept with the profile.
|
||||
void board_break_settings_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
||||
const auto& bb = model.board_break;
|
||||
if (!bb.available) return;
|
||||
const bool console = bb.enabled && callbacks.queue_console_command;
|
||||
std::string subtitle = !bb.has_deck ? "No Board Break deck is in the game"
|
||||
: "Your board: " + std::to_string(static_cast<int>(bb.worn * 100.0f + 0.5f)) + "% worn (" + bb.condition + ")";
|
||||
begin_card(menu, "board-break-settings", "BOARD BREAK SETTINGS", subtitle.c_str());
|
||||
if (!bb.enabled) note("Switch Board Break on to change these.");
|
||||
const auto flag = [&](const char* label, const char* hint, bool on, const char* verb) {
|
||||
bool value = on;
|
||||
if (toggle_row(menu, label, hint, value, console)) send_console(menu, callbacks, std::string("boardbreak ") + verb + (value ? " 1" : " 0"));
|
||||
};
|
||||
flag("Always break", "Every slam breaks the board, and every slide that comes down onto a rail: for replay clips.", bb.always, "always");
|
||||
flag("Rail snaps", "A boardslide or darkslide that comes down too hard onto a rail or ledge can snap the board, and you go down with it.",
|
||||
bb.rails, "rails");
|
||||
flag("Wear and tear", "Every hit wears your board. A worn board turns lazier, wobbles and breaks more easily; worn out, it can give way. "
|
||||
"Turn on Board wear (Mod Options) to see the scratches.", bb.wear, "wear");
|
||||
flag("Break sounds", "The crack of a break, and metal when it breaks on metal.", bb.crack_sounds, "cracks");
|
||||
flag("Body hit sounds", "Each hit of your body in a bail, as hard as it hits.", bb.body_sounds, "bodysounds");
|
||||
// Sent once a slider is let go; the dragged value shows until the setting comes back.
|
||||
const auto percent = [&](const char* id, const char* label, const char* hint, int value, int low, int high, int step, const char* verb) {
|
||||
static std::array<int, 3> edited{-1, -1, -1};
|
||||
static std::array<double, 3> edited_at{};
|
||||
const auto slot = std::string_view(id) == "bb-sensitivity" ? 0u : std::string_view(id) == "bb-joined" ? 1u : 2u;
|
||||
int shown = edited[slot] >= 0 ? edited[slot] : value;
|
||||
field(menu, label, hint);
|
||||
ImGui::BeginDisabled(!console);
|
||||
if (ImGui::SliderInt((std::string("##") + id).c_str(), &shown, low, high, "%d%%", ImGuiSliderFlags_AlwaysClamp)) {
|
||||
edited[slot] = std::clamp((shown + step / 2) / step * step, low, high);
|
||||
edited_at[slot] = ImGui::GetTime();
|
||||
}
|
||||
if (ImGui::IsItemDeactivatedAfterEdit() && edited[slot] >= 0) {
|
||||
send_console(menu, callbacks, std::string("boardbreak ") + verb + " " + std::to_string(edited[slot]));
|
||||
edited_at[slot] = ImGui::GetTime();
|
||||
}
|
||||
if (edited[slot] >= 0 && !ImGui::IsItemActive() && (value == edited[slot] || ImGui::GetTime() - edited_at[slot] > 2.0)) edited[slot] = -1;
|
||||
ImGui::EndDisabled();
|
||||
};
|
||||
percent("bb-sensitivity", "Break sensitivity", "How easily a board breaks. 200% takes half the force, 50% twice as much.", bb.sensitivity, 10, 400, 10,
|
||||
"sensitivity");
|
||||
percent("bb-joined", "Grip tape holds", "How many breaks the grip tape holds together instead of a piece coming off.", bb.joined, 0, 100, 5, "joined");
|
||||
percent("bb-wear-rate", "Wear speed", "How fast boards wear. 0% keeps a board as it is.", bb.wear_rate, 0, 500, 10, "wearrate");
|
||||
ImGui::BeginDisabled(!console || !bb.has_deck);
|
||||
if (ImGui::Button("New board", ImVec2(-FLT_MIN, 0))) send_console(menu, callbacks, "boardbreak worn 0");
|
||||
ImGui::EndDisabled();
|
||||
end_card();
|
||||
}
|
||||
|
||||
void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
||||
const auto& debug = model.debug;
|
||||
begin_card(menu, "movement", "FLIGHT & BAILS");
|
||||
@@ -187,6 +237,7 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c
|
||||
send_console(menu, callbacks, board_break ? "boardbreak 1" : "boardbreak 0");
|
||||
end_card();
|
||||
board_break_deck_card(menu, model, callbacks);
|
||||
board_break_settings_card(menu, model, callbacks);
|
||||
|
||||
// Grind exit control (Extension/Skater/grind_exit_control.h).
|
||||
const auto& grind = model.grind_exit;
|
||||
|
||||
@@ -970,6 +970,20 @@ void update_model(std::uintptr_t client, TickState& frame) {
|
||||
const auto breakable_deck = dingosdk::board_break::deck();
|
||||
model.board_break = {dingosdk::board_break::available(), dingosdk::board_break::enabled(), !breakable_deck.empty(),
|
||||
dingosdk::board_break::deck_label(breakable_deck)};
|
||||
{
|
||||
const auto feel = dingosdk::board_break::settings();
|
||||
auto& bb = model.board_break;
|
||||
bb.always = feel.always;
|
||||
bb.rails = feel.rails;
|
||||
bb.wear = feel.wear;
|
||||
bb.crack_sounds = feel.crack_sounds;
|
||||
bb.body_sounds = feel.body_sounds;
|
||||
bb.sensitivity = feel.sensitivity;
|
||||
bb.joined = feel.joined;
|
||||
bb.wear_rate = feel.wear_rate;
|
||||
bb.worn = dingosdk::board_break::wear();
|
||||
bb.condition = std::string(dingosdk::board_break::condition_name(bb.worn));
|
||||
}
|
||||
model.grind_exit = {dingosdk::grind_exit_control::available(), dingosdk::grind_exit_control::enabled(),
|
||||
dingosdk::grind_exit_control::toe_heel(), dingosdk::grind_exit_control::stance(),
|
||||
dingosdk::grind_exit_control::strength()};
|
||||
|
||||
@@ -155,6 +155,7 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/RoadRash/road_rash_commands.cpp
|
||||
Extension/BoardBreak/board_break.cpp
|
||||
Extension/BoardBreak/board_break_commands.cpp
|
||||
Extension/BoardBreak/board_break_audio.cpp
|
||||
Extension/Trainer/trainer.cpp
|
||||
Extension/Trainer/trainer_presets.cpp
|
||||
Extension/Trainer/trainer_jump.cpp
|
||||
|
||||
+84
-7
@@ -6,9 +6,18 @@ comes off, flies and lies where it lands, truck and wheels with it. The board is
|
||||
the skater has picked it up, rides again or respawns. Built into ReSkate; the broken pieces
|
||||
themselves come from a content mod with the deck shapes in it.
|
||||
|
||||
Code: [board_break.cpp](../Extension/BoardBreak/board_break.cpp) (client tick and the boards'
|
||||
render pose hook), [board_break_model.h](../Extension/BoardBreak/board_break_model.h) (the rules and
|
||||
the geometry on their own, with [tests](../Extension/BoardBreak/Test/board_break_tests.cpp)),
|
||||
Boards also wear: every hard landing, rail hit, grind and slam takes something out of the board of
|
||||
the deck in the game, and a worn board steers lazier, wobbles, breaks from less and in the end can
|
||||
give way under the rider. A boardslide or darkslide that comes down too hard onto a rail can snap
|
||||
the board and throw the rider. A break cracks (and sounds like metal when it happened on metal),
|
||||
and the body's hits in a bail are heard where and as hard as they land. The player sets all of it.
|
||||
|
||||
Code: [board_break.cpp](../Extension/BoardBreak/board_break.cpp) (client tick, the boards'
|
||||
render pose hook, wear, rail snaps), [board_break_model.h](../Extension/BoardBreak/board_break_model.h)
|
||||
(the rules and the geometry on their own) and
|
||||
[board_break_feel.h](../Extension/BoardBreak/board_break_feel.h) (the settings, what each hit wears,
|
||||
what wear does, rail contacts), both with [tests](../Extension/BoardBreak/Test/board_break_tests.cpp),
|
||||
[board_break_audio.cpp](../Extension/BoardBreak/board_break_audio.cpp) (the sounds),
|
||||
[board_break_commands.cpp](../Extension/BoardBreak/board_break_commands.cpp) (console), and the
|
||||
deck's place in the mod list in [mod_catalog.cpp](../Engine/Vfs/mod_catalog.cpp).
|
||||
|
||||
@@ -24,6 +33,27 @@ deck's place in the mod list in [mod_catalog.cpp](../Engine/Vfs/mod_catalog.cpp)
|
||||
press asks for that deck, the mods are applied and the level loads again with it. The switch is
|
||||
saved with the profile.
|
||||
- A break plays back in the replay editor as it happened.
|
||||
- **Wear and tear.** Each deck's board wears from 0% (new) to 100% (worn out): a landing coming down
|
||||
faster than 3.5 m/s, a hit onto a rail or ledge (harder on a slide than on the trucks), every second
|
||||
of grinding or sliding, and a slam of force 4 or more. The menus call it new, used, worn, beat or
|
||||
done. From a fifth worn the board answers the steering less and its trucks go soft and twist
|
||||
further; worn out, it takes half the force to break and can give way under the rider. A board
|
||||
that broke is a new one once it is whole again; a worn one is renewed with the button New board
|
||||
or `boardbreak worn 0`. The scratches are the game's own board wear (Mod Options > Board wear),
|
||||
cleared with every new board.
|
||||
- **Rail snaps.** A boardslide, lipslide or darkslide that comes down onto a rail or ledge at 3 m/s
|
||||
or more (2.2 on the grip) can snap the board in the middle, certainly 3 m/s above that, and the
|
||||
rider goes down with it. Grinds on the trucks never do.
|
||||
- **Sounds.** A crack by the size of the break, a metal one on top when the board broke on metal,
|
||||
and the body's hits in a bail, louder the harder they land.
|
||||
- **Settings**, on the overlay's Skater page in a card BOARD BREAK SETTINGS (with how worn the board
|
||||
is): always break (for replay clips), rail snaps, wear and tear, break sounds, body hit sounds,
|
||||
break sensitivity (10% to 400%), how many breaks the grip tape holds (0% to 100%), wear speed (0%
|
||||
to 500%), and New board. Always break and wear and tear are also rows in the game's Mod Options.
|
||||
The console has them all: `boardbreak always|sensitivity|joined|rails|wear|wearrate|cracks|bodysounds
|
||||
[value]`, `boardbreak worn [percent]`, `boardbreak status`, and for metal `boardbreak metal
|
||||
mark|unmark|clear|list` and `boardbreak materials`. They are kept, with each deck's wear and the
|
||||
materials marked as metal, in `%LOCALAPPDATA%\ReSkate\BoardBreak.settings`.
|
||||
- Without a deck pack installed nothing breaks and nothing is changed.
|
||||
|
||||
## How it works
|
||||
@@ -83,6 +113,35 @@ deck's place in the mod list in [mod_catalog.cpp](../Engine/Vfs/mod_catalog.cpp)
|
||||
- **On foot the grip tape always holds.** A loose piece takes the ground from where the board
|
||||
rolled or lies; a board in the skater's hand does not tell where the ground is, and a piece that
|
||||
came off there hung in the air at hand height.
|
||||
- **Every physics step of the local skater** (a step listener of No Bail's) is where wear, rail
|
||||
snaps and the body's hits are seen. How fast the board came down is its root body's speed in its
|
||||
last step in the air; a landing is that step followed by rolling (physics states 1xx), a hit onto
|
||||
a rail by grinding (4xx). What kind of contact it is comes from the board's axes, from the client
|
||||
tick, against how it moves (`contact_of`): across its way it slides, on its grip it is a darkslide,
|
||||
else it grinds on its trucks.
|
||||
- **What wear does to the handling** is four values of the game's own physics tuning
|
||||
(`PhysicsSteering.GeneralScalar`, `PhysicsTrucks.StaticTruckSpringStrength`, `StaticTruckDamping`
|
||||
and `TruckTwistLimit`), each the game's value times a factor of the wear. A value the player
|
||||
changed (the trainer) is left alone, and nothing is changed in a multiplayer session, whose
|
||||
players share their tuning.
|
||||
- **A rail snap throws the rider** through No Bail (`no_bail_request_wipeout`): the cause goes in
|
||||
just before the game's selector picks the next state, the way the game's own contact tests
|
||||
record theirs, and the pick becomes Wipeout. The force is kept low (2.5): the game reads a cause's
|
||||
force as how hard the rider was hit, and a slam's (10 and more) with the board flat on a rail
|
||||
threw the rider high into the air.
|
||||
- **Metal.** Each contact effect the game plays (sparks, dust, landings) names a material of the
|
||||
level (Extension/Multiplayer/Remote/native_vfx.h, `recent_local_impacts`); Board Break installs
|
||||
that hook once in a world, as multiplayer would. The level's materials have no names that can be
|
||||
read, so which are metal is a list: marked in play with `boardbreak metal mark` while grinding a
|
||||
metal rail, and brought by a pack in `sounds/metal/materials.txt`.
|
||||
- **The body's hits** are the ragdoll's contacts that Hall of Meat reads each step, one sound at a
|
||||
time, none below a graze.
|
||||
- **The sounds** play through XAudio2 on a thread of their own, several at once and each at its own
|
||||
loudness; 16-bit and 32-bit float wave files both play.
|
||||
- **Wider decks, wider trucks.** The game puts each wheel on its truck's bone along the axle, about
|
||||
0.0876 m out. A deck that says so (deck.ini `axle`) has its wheels set further apart, or closer,
|
||||
by the render pose hook: set, not scaled, so a pose that comes by twice is not moved twice, and
|
||||
only what is drawn changes. The deck's own truck model is made to match.
|
||||
- Off, the board holds. Without a deck pack in the game the hook returns at once.
|
||||
|
||||
## The deck packs
|
||||
@@ -106,9 +165,9 @@ A deck is a content mod's copy of the deck model (no code), and comes in one of
|
||||
bones would draw the baseplates turning with the hangers, and such a version reads the pack's
|
||||
own layout, never `decks/`.
|
||||
|
||||
The name is what the deck goes by. The eight the feature was made with are standard, wide,
|
||||
concave, powell, slim, egg, square and shaped, and the console also knows those by number. Any
|
||||
other deck that follows the recipe works, with no change to ReSkate:
|
||||
The name is what the deck goes by. The ten the feature was made with are standard, wide,
|
||||
concave, powell, slim, egg, square, shaped, oldschool and pig, and the console also knows those by
|
||||
number. Any other deck that follows the recipe works, with no change to ReSkate:
|
||||
|
||||
- Start from the deck mesh as ReSkate Studio exports it (three parts on the bone Deck: the grip
|
||||
sheet, the wood, and a plain copy of the whole deck). Studio only takes back a mesh whose
|
||||
@@ -124,7 +183,13 @@ other deck that follows the recipe works, with no change to ReSkate:
|
||||
- The truck model for a deck inside a pack: the game's own, with every vertex that is on the bone
|
||||
Deck moved to `eff_truckBack` (behind the middle of the board) or `eff_truckFront` (ahead of
|
||||
it). Nothing else changes; Studio takes it back as it is.
|
||||
- The sounds are `sounds/light|medium|heavy/*.wav` in the folder of any mod that brings a deck.
|
||||
- The sounds are `sounds/light|medium|heavy|metal|body/*.wav` in the folder of any mod that brings
|
||||
a deck (a crack size without sounds of its own takes the nearest that has some), and
|
||||
`sounds/metal/materials.txt` may list the level's materials that are metal, by number.
|
||||
- A deck may say something of itself in a `deck.ini` in its folder (a pack's `decks/<name>/`, or the
|
||||
`Board_Break_<Name>` folder): `label = Old School`, its name in the menus, and `axle = 1.17`, how
|
||||
far apart its wheels sit against a standard board's (0.8 to 1.6), for a deck wider or narrower
|
||||
than the standard one.
|
||||
|
||||
## Seen working in game
|
||||
|
||||
@@ -155,6 +220,11 @@ overlay's deck card: the mods applied in 3.8 s, the level loaded again in 15 s w
|
||||
which then broke. In the game's Mod Options > Player page, on 2.0.6: one press on Board Break
|
||||
deck is one deck further and one reload.
|
||||
|
||||
Wear, rail snaps, sounds and settings, in the player's own game offline, on the first build of
|
||||
them (on main at 6c45e96): boardslides that came down onto rails at 4.6 to 7.0 m/s snapped the
|
||||
board in the middle; the settings card switched always break on; boards wore, at that time far
|
||||
too fast (the rates are a third of that now).
|
||||
|
||||
## Not tried yet
|
||||
|
||||
- Multiplayer: other players' boards are only handled by the code path (their stretches are moved
|
||||
@@ -168,3 +238,10 @@ deck is one deck further and one reload.
|
||||
baseplates would follow the hangers.
|
||||
- A mod that brings a truck model of its own beside a deck pack: the pack's is the one that loads.
|
||||
- A level change in the middle of a break.
|
||||
- The rider going down with a board that snapped on a rail, and the material a board broke on: both
|
||||
changed after the build that was played. No material is marked as metal yet, so the metal sound
|
||||
waits for `boardbreak metal mark` or a pack's list.
|
||||
- The body's hit sounds in a real bail.
|
||||
- Wear's effect on the handling, felt in play, and a worn-out board giving way.
|
||||
- The Old School and Pig decks, and wheels set further apart by `axle`, in play.
|
||||
- This branch as it is, on main at 6df4f9a: built without warnings and its tests run, not played.
|
||||
Reference in new issue
Block a user