mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 12:57:01 +02:00
Board Break: a hard slam can snap the board
A slam can break the local skater's board at the nose, in the middle or at the tail. Half the time the grip tape holds the pieces together and the board folds or an end hangs; otherwise a piece comes off, flies and lies where it lands. The board is whole again in hand, on riding and on a respawn. The pieces are four stretches of the deck in a content mod's copy of the game's one deck model, each on a spare bone of the board's rig. The boards' render pose hook only moves bones, because the replay records where a bone is and not its size: a broken board's deck goes away and its stretches go to where the pieces lie, and a whole board's stretches go away. The deck the player chose loads first and other mods that bring a deck model are set aside for the run, without touching mods.json. Another deck is chosen while the game runs, through a live apply of the mods. contrib/board-break.md says how it works and how a deck pack is made.
This commit is contained in:
1 parent
f4292d6b73
commit
bd628abc76
23 files changed
+1579
-5
No files matched your search
@@ -291,6 +291,14 @@ if(DINGOSDK_BUILD_ROAD_RASH_TESTS)
|
||||
target_include_directories(dingosdk_road_rash_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME road_rash COMMAND dingosdk_road_rash_tests)
|
||||
endif()
|
||||
option(DINGOSDK_BUILD_BOARD_BREAK_TESTS "Build Board Break regression tests" OFF)
|
||||
if(DINGOSDK_BUILD_BOARD_BREAK_TESTS)
|
||||
enable_testing()
|
||||
# What a break leaves of a board, how likely a slam breaks it, and where its parts lie.
|
||||
add_executable(dingosdk_board_break_tests Extension/BoardBreak/Test/board_break_tests.cpp)
|
||||
target_include_directories(dingosdk_board_break_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME board_break COMMAND dingosdk_board_break_tests)
|
||||
endif()
|
||||
option(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF)
|
||||
if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS)
|
||||
enable_testing()
|
||||
|
||||
+180
-2
@@ -12,6 +12,8 @@
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
@@ -81,8 +83,146 @@ std::string sdk_identity() {
|
||||
return error ? std::string{} : std::to_string(size) + "-" + std::to_string(written);
|
||||
}
|
||||
|
||||
// A mod brings a deck model of its own when its bundle lists name the deck's bundle. Deck graphics
|
||||
// and grip tapes are items of their own and do not touch it.
|
||||
bool changes_the_deck(const fs::path& mod) {
|
||||
constexpr std::string_view deck_bundle = "deck_gen_popsicle_cas_main_bundlereftable";
|
||||
std::error_code error;
|
||||
std::vector<fs::path> lists{mod / L"layout.toc"};
|
||||
for (fs::directory_iterator at(mod / L"Win32", error), end; !error && at != end; at.increment(error))
|
||||
if (at->is_regular_file(error) && at->path().extension() == L".toc") lists.push_back(at->path());
|
||||
for (const auto& list : lists) {
|
||||
std::ifstream file(list, std::ios::binary);
|
||||
if (!file) continue;
|
||||
const std::string bytes{std::istreambuf_iterator<char>(file), {}};
|
||||
if (bytes.find(deck_bundle) != std::string::npos) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// The player's choice of deck is kept beside ReSkate's other files for the user, so that it is
|
||||
// there before the profile is: the mods are put together as the game starts.
|
||||
fs::path board_break_deck_file() {
|
||||
std::array<wchar_t, 1024> text{};
|
||||
const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", text.data(), static_cast<DWORD>(text.size()));
|
||||
if (!size || size >= text.size()) return {};
|
||||
return fs::path(std::wstring(text.data(), size)) / L"ReSkate" / L"BoardBreak.deck";
|
||||
}
|
||||
// A deck's name is a folder name's end: letters, digits and underscores.
|
||||
bool deck_name(std::string_view name) {
|
||||
return !name.empty() && name.size() <= maximum_mod_name &&
|
||||
std::all_of(name.begin(), name.end(), [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; });
|
||||
}
|
||||
// Where a deck comes in the menus: the decks the feature was made with in their own order, then
|
||||
// the others by name.
|
||||
bool deck_before(const std::string& a, const std::string& b) {
|
||||
const auto place = [](const std::string& name) {
|
||||
return static_cast<std::size_t>(std::find(board_break_decks.begin(), board_break_decks.end(), name) - board_break_decks.begin());
|
||||
};
|
||||
return place(a) != place(b) ? place(a) < place(b) : a < b;
|
||||
}
|
||||
// The choice made in this run, and what the last live apply gave the game (none since launch: the
|
||||
// launch's catalogue says).
|
||||
struct BoardBreakApplied {
|
||||
std::string deck;
|
||||
std::vector<std::string> set_aside;
|
||||
};
|
||||
std::mutex board_break_mutex;
|
||||
std::string chosen_deck;
|
||||
std::optional<BoardBreakApplied> applied_board_break;
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string board_break_deck_of(std::string_view mod) {
|
||||
// Team-Board_Break_Wide and Board_Break_Wide are both the deck "wide".
|
||||
constexpr std::string_view prefix = "board_break_";
|
||||
auto name = lower(mod);
|
||||
if (const auto team = name.rfind('-'); team != std::string::npos) name.erase(0, team + 1);
|
||||
if (!name.starts_with(prefix) || !deck_name(std::string_view(name).substr(prefix.size()))) return {};
|
||||
return name.substr(prefix.size());
|
||||
}
|
||||
std::string wanted_board_break_deck() noexcept {
|
||||
try {
|
||||
{
|
||||
std::lock_guard lock(board_break_mutex);
|
||||
if (!chosen_deck.empty()) return chosen_deck;
|
||||
}
|
||||
std::string choice;
|
||||
if (const auto path = board_break_deck_file(); !path.empty()) {
|
||||
std::ifstream file(path);
|
||||
std::getline(file, choice);
|
||||
}
|
||||
while (!choice.empty() && (choice.back() == '\r' || choice.back() == ' ')) choice.pop_back();
|
||||
choice = lower(choice);
|
||||
return deck_name(choice) ? choice : std::string(board_break_decks.front());
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
bool save_board_break_deck(std::string_view deck) noexcept {
|
||||
try {
|
||||
if (!deck_name(deck)) return false;
|
||||
const auto path = board_break_deck_file();
|
||||
if (path.empty()) return false;
|
||||
std::error_code error;
|
||||
fs::create_directories(path.parent_path(), error);
|
||||
std::ofstream file(path, std::ios::trunc);
|
||||
file << deck << '\n';
|
||||
if (!file) return false;
|
||||
std::lock_guard lock(board_break_mutex);
|
||||
chosen_deck = deck;
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::vector<std::filesystem::path> board_break_folders() noexcept {
|
||||
std::vector<fs::path> found;
|
||||
try {
|
||||
std::vector<std::pair<std::string, fs::path>> decks;
|
||||
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))
|
||||
if (auto deck = board_break_deck_of(at->path().filename().string()); !deck.empty()) decks.emplace_back(std::move(deck), at->path());
|
||||
std::sort(decks.begin(), decks.end(), [](const auto& a, const auto& b) { return deck_before(a.first, b.first); });
|
||||
for (auto& deck : decks) found.push_back(std::move(deck.second));
|
||||
} catch (...) {}
|
||||
return found;
|
||||
}
|
||||
std::vector<std::string> installed_board_break_decks() noexcept {
|
||||
std::vector<std::string> found;
|
||||
try {
|
||||
for (const auto& folder : board_break_folders()) {
|
||||
auto deck = board_break_deck_of(folder.filename().string());
|
||||
if (found.empty() || found.back() != deck) found.push_back(std::move(deck));
|
||||
}
|
||||
} catch (...) {}
|
||||
return found;
|
||||
}
|
||||
std::string board_break_in_play() noexcept {
|
||||
try {
|
||||
{
|
||||
std::lock_guard lock(board_break_mutex);
|
||||
if (applied_board_break) return applied_board_break->deck;
|
||||
}
|
||||
return catalog().board_break_deck;
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
std::vector<std::string> set_aside_for_board_break() {
|
||||
{
|
||||
std::lock_guard lock(board_break_mutex);
|
||||
if (applied_board_break) return applied_board_break->set_aside;
|
||||
}
|
||||
return catalog().set_aside;
|
||||
}
|
||||
void board_break_applied(const Catalog& catalog) noexcept {
|
||||
try {
|
||||
std::lock_guard lock(board_break_mutex);
|
||||
applied_board_break = BoardBreakApplied{catalog.board_break_deck, catalog.set_aside};
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
std::filesystem::path engine_data_root() noexcept {
|
||||
try {
|
||||
std::array<wchar_t, 32768> module_path{};
|
||||
@@ -135,10 +275,42 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver
|
||||
try {
|
||||
for (const auto& name : list.missing)
|
||||
result.notes.push_back("mods.json names a folder that is not present: " + name);
|
||||
// Board Break (Extension/BoardBreak/board_break.h). The deck that can break is a mod's own
|
||||
// copy of the game's one deck model, and any mod that reshapes the deck replaces that same
|
||||
// model: the one higher in the load order has it. So the deck the player chose goes first
|
||||
// for this run, whatever mods.json says of the order, and what would fight it is set aside:
|
||||
// Board Break's other decks, and other mods that bring a deck model. Those are left out
|
||||
// whole: with the breakable deck in place of its own, such a mod would have its trucks and
|
||||
// wheels on a deck they were not made for. mods.json is not changed, and their archives are
|
||||
// placed like a disabled mod's, so another deck can be chosen while the game runs. A choice
|
||||
// that is not installed falls back to the first deck, then to any that is.
|
||||
{
|
||||
const auto enabled_deck = [&](const std::string& deck) {
|
||||
return std::find_if(list.entries.begin(), list.entries.end(), [&](const auto& entry) {
|
||||
const auto found = board_break_deck_of(entry.mod.name);
|
||||
return entry.enabled && !found.empty() && (deck.empty() || found == deck);
|
||||
});
|
||||
};
|
||||
auto first = list.entries.end();
|
||||
for (const auto& deck : {wanted_board_break_deck(), std::string(board_break_decks.front()), std::string()})
|
||||
if (first == list.entries.end()) first = enabled_deck(deck);
|
||||
if (first != list.entries.end()) {
|
||||
std::rotate(list.entries.begin(), first, first + 1);
|
||||
result.notes.push_back("Board Break: " + list.entries.front().mod.name + " loads first, so its deck is the one in the game");
|
||||
for (auto entry = list.entries.begin() + 1; entry != list.entries.end(); ++entry) {
|
||||
if (!entry->enabled || (board_break_deck_of(entry->mod.name).empty() && !changes_the_deck(entry->mod.directory))) continue;
|
||||
entry->enabled = false;
|
||||
result.set_aside.push_back(entry->mod.name);
|
||||
result.notes.push_back("Board Break: " + entry->mod.name + " brings a deck model too and is set aside for this run");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (auto& entry : list.entries) {
|
||||
if (!entry.enabled) {
|
||||
result.notes.push_back("Mod disabled in mods.json: " + entry.mod.name);
|
||||
result.disabled.push_back(entry.mod.name);
|
||||
if (std::find(result.set_aside.begin(), result.set_aside.end(), entry.mod.name) == result.set_aside.end()) {
|
||||
result.notes.push_back("Mod disabled in mods.json: " + entry.mod.name);
|
||||
result.disabled.push_back(entry.mod.name);
|
||||
}
|
||||
if (entry.mod.provides_layout) result.inactive.push_back(std::move(entry.mod));
|
||||
continue;
|
||||
}
|
||||
@@ -217,6 +389,12 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver
|
||||
build + ", the installed game is " + game_build);
|
||||
result.inactive.erase(result.inactive.begin() + static_cast<std::ptrdiff_t>(i));
|
||||
}
|
||||
// Board Break's deck is the one in the game when its mod is among those that load and no mod
|
||||
// above it brings a deck model of its own.
|
||||
for (const auto& mod : result.mods) {
|
||||
if (!(result.board_break_deck = board_break_deck_of(mod.name)).empty()) break;
|
||||
if (changes_the_deck(mod.directory)) break;
|
||||
}
|
||||
// Any Mods folder gets a patch, even an empty one or one of disabled
|
||||
// mods only: a mod enabled or dropped in while the game runs needs it
|
||||
// mounted from launch.
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#include "mod_list.h"
|
||||
#include "mod_merge.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::mods {
|
||||
@@ -38,8 +40,39 @@ struct Catalog {
|
||||
// Mods left out because they were built for another game version (Mod::outdated), enabled or
|
||||
// not: they need updating. Also in `excluded` when they were enabled.
|
||||
std::vector<Mod> outdated;
|
||||
// Board Break: the name of its deck that is the one in the game ("standard", "wide" and so on),
|
||||
// empty when none is and a board cannot break; and the enabled mods set aside for it, its
|
||||
// other decks and other mods that bring a deck model. Those are not loaded and nothing about
|
||||
// them waits to be applied.
|
||||
std::string board_break_deck;
|
||||
std::vector<std::string> set_aside;
|
||||
};
|
||||
|
||||
// Board Break's decks (Extension/BoardBreak/board_break.h). The game has one deck model for every
|
||||
// board, so each deck that can break is a content mod of its own that replaces it: any mod whose
|
||||
// folder is named Board_Break_<Name> (also behind a team name, as a mod manager installs it:
|
||||
// Team-Board_Break_<Name>). The name, in small letters, is what the deck goes by; the deck the
|
||||
// player chose is the one that loads. 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"};
|
||||
// The name of the deck a mod folder holds, or nothing for any other mod.
|
||||
std::string board_break_deck_of(std::string_view mod_folder);
|
||||
// The deck the player chose ("standard" when nothing was chosen): read whenever the mods are put
|
||||
// together, at launch and at every live apply. Saving a choice makes it this run's at once.
|
||||
std::string wanted_board_break_deck() noexcept;
|
||||
bool save_board_break_deck(std::string_view deck) noexcept;
|
||||
// The decks whose mod's folder is in Mods/, whatever mods.json says of them, in the menus' order,
|
||||
// and those folders in the same order.
|
||||
std::vector<std::string> installed_board_break_decks() noexcept;
|
||||
std::vector<std::filesystem::path> board_break_folders() 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;
|
||||
std::vector<std::string> set_aside_for_board_break();
|
||||
void board_break_applied(const Catalog& catalog) noexcept;
|
||||
|
||||
// The engine's own data root: the -dataPath argument when present, otherwise
|
||||
// the executable's directory. Mirrors the game so mod paths resolve the same.
|
||||
std::filesystem::path engine_data_root() noexcept;
|
||||
|
||||
@@ -436,6 +436,7 @@ void apply_now() {
|
||||
std::lock_guard lock(state_mutex);
|
||||
// Set with the manifests, so the client tick sees both in one update.
|
||||
if (needed) reload_ready = true;
|
||||
mods::board_break_applied(catalog);
|
||||
pending_levels = mods::level_manifest_paths(catalog);
|
||||
applied.emplace();
|
||||
for (const auto &mod : catalog.mods) applied->push_back(mod.name);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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 <cmath>
|
||||
#include <initializer_list>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
using namespace dingosdk::board_break;
|
||||
namespace {
|
||||
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
|
||||
bool near(float a, float b, float within = 1e-4f) { return std::abs(a - b) < within; }
|
||||
bool near(Vec a, Vec b, float within = 1e-4f) { return length(a - b) < within; }
|
||||
// Where a point of a part ends up after the part's motion.
|
||||
Vec moved(const Rigid& motion, Vec point) { return turn(motion.facing, point) + motion.at; }
|
||||
constexpr Quat level{};
|
||||
// The rig's mount: a quarter turn about the upright axis.
|
||||
const Quat mount = about({0, 1, 0}, 1.5707963f);
|
||||
|
||||
void a_break_leaves_two_pieces_or_one_that_hangs_together() {
|
||||
for (const auto place : {Place::nose, Place::mid, Place::tail}) {
|
||||
const auto clean = broken_at(place, false);
|
||||
check(clean.hinge == Hinge::none && clean.loose, "A clean break has a piece that comes off");
|
||||
check((clean.kept | clean.loose) == whole_board && !(clean.kept & clean.loose), "and the two are the whole board, each part once");
|
||||
const auto joined = broken_at(place, true);
|
||||
check(joined.kept == whole_board && !joined.loose && joined.hinge != Hinge::none, "Held by the grip tape, nothing comes off");
|
||||
}
|
||||
check(broken_at(Place::nose, false).loose == nose && broken_at(Place::tail, false).loose == tail, "An end comes off by itself");
|
||||
const auto halves = broken_at(Place::mid, false);
|
||||
check(halves.kept == (nose | front | front_truck) && halves.loose == (back | tail | back_truck),
|
||||
"In the middle the board keeps its front half, and each half its truck");
|
||||
check(broken_at(Place::nose, true).hinge == Hinge::nose && broken_at(Place::mid, true).hinge == Hinge::fold &&
|
||||
broken_at(Place::tail, true).hinge == Hinge::tail,
|
||||
"and each place hangs together its own way");
|
||||
}
|
||||
|
||||
void a_harder_slam_breaks_a_board_more_often() {
|
||||
check(break_chance(10.0f, 10.0f, 0.25f) == 1.0f && break_chance(30.0f, 10.0f, 0.25f) == 1.0f, "A heavy slam always breaks it");
|
||||
check(break_chance(0.0f, 10.0f, 0.25f) == 0.25f && break_chance(6.0f, 10.0f, 0.25f) == 0.25f, "A light one now and then");
|
||||
check(near(break_chance(8.0f, 10.0f, 0.25f), 0.625f), "and in between it rises evenly");
|
||||
float before{};
|
||||
for (float force = 0; force <= 12.0f; force += 0.5f) {
|
||||
const auto chance = break_chance(force, 10.0f, 0.25f);
|
||||
check(chance >= before && chance <= 1.0f, "It never falls with more force");
|
||||
before = chance;
|
||||
}
|
||||
}
|
||||
|
||||
void the_size_of_a_break_picks_its_sound() {
|
||||
check(break_size(Place::mid, false, 0.0f) == Size::heavy, "A board that comes apart in the middle is the biggest break");
|
||||
check(break_size(Place::mid, true, 0.9f) == Size::heavy && break_size(Place::mid, true, 0.4f) == Size::medium,
|
||||
"A fold right down is as big, a shallow one in between");
|
||||
check(break_size(Place::nose, false, 0.0f) == Size::medium && break_size(Place::tail, false, 0.0f) == Size::medium, "An end that comes off is in between");
|
||||
check(break_size(Place::nose, true, 0.0f) == Size::light && break_size(Place::tail, true, 0.0f) == Size::light, "One that hangs on is a small one");
|
||||
}
|
||||
|
||||
void a_whole_board_keeps_its_stretches_away() {
|
||||
const auto bones = bones_of({});
|
||||
for (std::size_t i = 0; i < 3; ++i) check(near(bones[i].at, away), "A stretch that does not show goes far away");
|
||||
check(near(bones[3].at, Vec{}) && near(bones[3].facing.w, 1.0f), "and the tail's, which hangs on the front's, goes with that one");
|
||||
}
|
||||
|
||||
void the_tail_is_placed_from_the_front_stretch() {
|
||||
// The tail's bone hangs on the front stretch's: wherever the two lie, the front's motion
|
||||
// followed by what goes into the tail's bone is the tail's own motion.
|
||||
const Rigid front_motion{about({1, 0, 0}, 0.3f), {0.01f, 0.02f, -0.03f}};
|
||||
const Rigid tail_motion{unit(about({0, 1, 0}, 1.1f) * about({1, 0, 0}, -0.4f)), {0.5f, -0.2f, 0.7f}};
|
||||
for (const bool front_shown : {true, false}) {
|
||||
std::array<Stretch, 4> stretch{};
|
||||
stretch[1] = {front_shown, front_motion};
|
||||
stretch[3] = {true, tail_motion};
|
||||
const auto bones = bones_of(stretch);
|
||||
const Rigid parent = front_shown ? front_motion : Rigid{{}, away};
|
||||
for (const Vec point : {Vec{0, 0.09f, -0.3f}, Vec{0.1f, 0.1f, -0.4f}})
|
||||
check(near(moved(parent, moved(bones[3], point)), moved(tail_motion, point), 2e-3f), "The tail lies where its piece does");
|
||||
}
|
||||
}
|
||||
|
||||
void a_piece_shows_only_its_own_stretches() {
|
||||
const auto clean = broken_at(Place::mid, false);
|
||||
const auto layout = lay_out(clean.kept, clean.hinge, 0.0f, 1.0f, mount);
|
||||
check(layout.stretch[0].shown && layout.stretch[1].shown && !layout.stretch[2].shown && !layout.stretch[3].shown,
|
||||
"The front half is the nose and the front stretch");
|
||||
check(layout.front_truck && !layout.back_truck, "on its front truck");
|
||||
const auto bones = bones_of(layout.stretch);
|
||||
check(near(bones[2].at, away), "and the back stretch is nowhere to be seen");
|
||||
// Half a board on one truck tips onto its broken end while it is the right way up, and not on its grip.
|
||||
const auto broken_end = moved(layout.stretch[1].motion, turn(mount, Vec{0, 0.09f, mid_row}));
|
||||
check(broken_end.y < 0.09f - 0.03f, "The half tips onto its broken end");
|
||||
const auto upside_down = lay_out(clean.kept, clean.hinge, 0.0f, -1.0f, mount);
|
||||
check(near(upside_down.stretch[1].motion.at, Vec{}), "Lying on its grip it stays as it is");
|
||||
}
|
||||
|
||||
void a_folded_board_meets_at_the_break() {
|
||||
for (const float depth : {0.2f, 0.6f, 1.0f}) {
|
||||
Rigid front_half, back_half;
|
||||
folded(depth, front_half, back_half);
|
||||
// The grip of both halves at the break: the same height, a crack's width apart.
|
||||
const Vec grip{0, 0.095f, mid_row};
|
||||
const auto ahead = moved(front_half, grip), behind = moved(back_half, grip);
|
||||
check(near(ahead.y, behind.y, 1e-3f), "Both halves' grip is at one height at the break");
|
||||
check(near(ahead.z - behind.z, crack, 1e-3f), "with the crack between them");
|
||||
check(ahead.y < grip.y, "and the break has sagged");
|
||||
// Each half turns about its own axle: the wheels stay where they were, but for the slide along the board.
|
||||
check(near(moved(front_half, {0, axle_height, front_axle}).y, axle_height, 1e-4f) &&
|
||||
near(moved(back_half, {0, axle_height, back_axle}).y, axle_height, 1e-4f),
|
||||
"The axles keep their height");
|
||||
}
|
||||
const auto whole = lay_out(whole_board, Hinge::fold, 0.8f, 1.0f, mount);
|
||||
for (const auto& one : whole.stretch) check(one.shown, "A folded board shows all four stretches");
|
||||
check(!near(whole.stretch[1].motion.at, whole.stretch[2].motion.at), "its two halves moved apart");
|
||||
check(near(whole.stretch[0].motion.at, whole.stretch[1].motion.at) && near(whole.stretch[2].motion.at, whole.stretch[3].motion.at),
|
||||
"and each end with its half");
|
||||
}
|
||||
|
||||
void a_hanging_end_turns_about_the_break() {
|
||||
const auto layout = lay_out(whole_board, Hinge::nose, 0.5f, 1.0f, mount);
|
||||
check(near(layout.stretch[1].motion.at, Vec{}) && near(layout.stretch[2].motion.at, Vec{}) && near(layout.stretch[3].motion.at, Vec{}),
|
||||
"The rest of the board stays where it is");
|
||||
const auto tip = moved(layout.stretch[0].motion, turn(mount, Vec{0, 0.14f, 0.41f}));
|
||||
check(tip.y < 0.14f - 0.05f, "and the nose droops");
|
||||
const auto hinge = moved(hung_on_grip(nose_row, 1.0f, 0.5f), {0, grip_height - 0.004f, nose_row});
|
||||
check(near(hinge, Vec{0, grip_height - 0.004f, nose_row + crack * 0.5f}), "It turns about the break line, a hair away from the board");
|
||||
}
|
||||
|
||||
void a_truck_goes_with_its_piece() {
|
||||
// The deck's bone is moved away to hide the real deck, and the truck hangs on it: what goes into
|
||||
// the truck's bone takes that move back and adds the piece's motion.
|
||||
const Vec mount_at{0, 0.0856f, 0};
|
||||
const auto still = truck_bone({}, mount, mount_at, level, front_truck_place);
|
||||
check(near(still.at + away, front_truck_place) && near(still.facing.w, 1.0f), "A piece that has not moved leaves its truck where it was");
|
||||
const Rigid lifted{{}, {0, 0.25f, 0}};
|
||||
const auto up = truck_bone(lifted, mount, mount_at, level, front_truck_place);
|
||||
check(near(up.at + away, front_truck_place + Vec{0, 0.25f, 0}), "A piece that is lifted takes its truck up with it");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
a_break_leaves_two_pieces_or_one_that_hangs_together();
|
||||
a_harder_slam_breaks_a_board_more_often();
|
||||
the_size_of_a_break_picks_its_sound();
|
||||
a_whole_board_keeps_its_stretches_away();
|
||||
the_tail_is_placed_from_the_front_stretch();
|
||||
a_piece_shows_only_its_own_stretches();
|
||||
a_folded_board_meets_at_the_break();
|
||||
a_hanging_end_turns_about_the_break();
|
||||
a_truck_goes_with_its_piece();
|
||||
std::cout << "board break tests passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "board break test failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
#include "board_break.h"
|
||||
#include "board_break_model.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Game/Build/20260929/no_bail.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Extension/Assets/live_mods.h"
|
||||
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
#include "Extension/Profile/local_profile_runtime.h"
|
||||
#include "Extension/Skater/no_bail.h"
|
||||
#include <Windows.h>
|
||||
#include <mmsystem.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <cwctype>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#pragma comment(lib, "winmm.lib")
|
||||
|
||||
namespace dingosdk::board_break {
|
||||
namespace {
|
||||
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;
|
||||
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.
|
||||
constexpr float hand_reach = 1.3f, hand_upright = 0.8f;
|
||||
// The board rig's pose, in the order the game evaluates it: 1 the rig's place in the world, 2 the
|
||||
// deck's mount, 3 the deck, 4 and 8 the trucks, and the spare bones that carry the four stretches
|
||||
// (nose, front, back, tail; the tail's hangs on the front's).
|
||||
constexpr int root_bone = 1, mount_bone = 2, deck_bone = 3, back_truck_bone = 4, front_truck_bone = 8;
|
||||
constexpr std::array<int, 4> carrier{12, 13, 15, 14};
|
||||
constexpr std::uint32_t rig_bones = 16;
|
||||
|
||||
bool readable(std::uintptr_t p, void* out, std::size_t size) { return size ? memory::peek_bytes(p, out, size) : p >= 0x10000; }
|
||||
|
||||
template <class... Args> void say(logging::Level level, std::format_string<Args...> text, Args&&... args) {
|
||||
logging::log(level, logging::Channel::skater, "Board Break: {}", std::format(text, std::forward<Args>(args)...));
|
||||
}
|
||||
|
||||
// Written by the physics step, read by the client tick.
|
||||
struct Hit {
|
||||
std::atomic<std::uint64_t> at{};
|
||||
std::atomic<std::int32_t> reason{-1};
|
||||
std::atomic<float> force{};
|
||||
};
|
||||
Hit& last_cause() { static Hit value; return value; }
|
||||
Hit& last_wipeout() { static Hit value; return value; }
|
||||
|
||||
struct Broken {
|
||||
std::uintptr_t board{};
|
||||
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
|
||||
};
|
||||
struct State {
|
||||
// Any thread.
|
||||
std::atomic<bool> ready{}, on{true}, wanted_mend{};
|
||||
std::atomic<std::uintptr_t> base{};
|
||||
std::atomic<bool> has_deck{}; // one of Board Break's decks is the one in the game
|
||||
std::atomic<int> wanted_snap{-1}; // asked for from another thread, done at the next tick
|
||||
std::mutex names;
|
||||
std::string deck, wanted_deck; // under `names`: the deck in the game, and one asked for (done at the next tick)
|
||||
// Client thread from here on.
|
||||
std::optional<Broken> broken;
|
||||
Break parts; // what the broken board keeps, what flew off, how they hang together
|
||||
float angle{}; // how far a hanging end droops (radians), or how deep a fold sags (0 to 1)
|
||||
std::uint32_t flights{}; // counts the breaks, so each starts a flight of its own
|
||||
bool counted{}, hooked{};
|
||||
std::uint64_t wipeouts{}, back_since{}, last_error{}, rode_at{}, board_seen{};
|
||||
// The ground under the board: its own height while it rolls on it, and what of that was fresh
|
||||
// when the board broke (else not a number).
|
||||
float ride_height{};
|
||||
std::uint64_t ride_when{};
|
||||
float break_ground{std::numeric_limits<float>::quiet_NaN()};
|
||||
std::mt19937 random{std::random_device{}()};
|
||||
};
|
||||
State& state() {
|
||||
static auto* value = new State;
|
||||
return *value;
|
||||
}
|
||||
|
||||
// What the render pose hook does to the boards, published by the client tick.
|
||||
struct RenderEdit {
|
||||
std::atomic<std::uintptr_t> holder{}; // the local board's animation holder
|
||||
std::atomic<std::uint32_t> kept{}; // the piece it shows (Piece bits), 0 while it is whole
|
||||
std::atomic<int> hinge{};
|
||||
std::atomic<float> angle{};
|
||||
std::atomic<bool> park{}; // a breakable deck is in the game: whole boards hide their stretches
|
||||
};
|
||||
RenderEdit& render_edit() { static RenderEdit value; return value; }
|
||||
|
||||
// ---- the board's pose --------------------------------------------------------------------------
|
||||
// One bone: scale, rotation and position, four floats each.
|
||||
struct PoseBone {
|
||||
float scale[4], rotation[4], position[4];
|
||||
};
|
||||
bool load_bone(std::uintptr_t address, PoseBone& out) noexcept {
|
||||
__try {
|
||||
std::memcpy(&out, reinterpret_cast<const void*>(address), sizeof(out));
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
// Rotation and position of one bone, at its own size; the fourth lane of each is left as it is.
|
||||
bool store_bone(std::uintptr_t address, Quat rotation, Vec position) noexcept {
|
||||
__try {
|
||||
auto* lane = reinterpret_cast<float*>(address);
|
||||
lane[0] = lane[1] = lane[2] = 1.0f;
|
||||
lane[4] = rotation.x; lane[5] = rotation.y; lane[6] = rotation.z; lane[7] = rotation.w;
|
||||
lane[8] = position.x; lane[9] = position.y; lane[10] = position.z;
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
Quat facing_of(const PoseBone& bone) noexcept { return unit({bone.rotation[0], bone.rotation[1], bone.rotation[2], bone.rotation[3]}); }
|
||||
Vec place_of(const PoseBone& bone) noexcept { return {bone.position[0], bone.position[1], bone.position[2]}; }
|
||||
// The pose buffer of a board, or 0 when it is not the rig this knows.
|
||||
std::uintptr_t board_pose(std::uintptr_t base, std::uintptr_t holder) {
|
||||
const auto pose = multiplayer::read_native_pose_layout(readable, base, holder, 64);
|
||||
return pose.buffer && pose.count == rig_bones ? pose.buffer : 0;
|
||||
}
|
||||
std::uintptr_t bone_at(std::uintptr_t buffer, int index) noexcept { return buffer + static_cast<std::uint32_t>(index) * 0x30ULL; }
|
||||
// The board's place in the world as its pose has it.
|
||||
bool board_place(std::uintptr_t base, std::uintptr_t holder, Vec& at, Quat& facing) {
|
||||
const auto buffer = board_pose(base, holder);
|
||||
PoseBone root{};
|
||||
if (!buffer || !load_bone(bone_at(buffer, root_bone), root)) return false;
|
||||
at = place_of(root);
|
||||
facing = facing_of(root);
|
||||
return std::isfinite(at.x) && std::isfinite(at.y) && std::isfinite(at.z);
|
||||
}
|
||||
// Moves a truck with a piece (truck_bone).
|
||||
void carry_truck(std::uintptr_t buffer, const Rigid& motion, int index, Vec place) noexcept {
|
||||
PoseBone mount{}, truck{};
|
||||
if (!load_bone(bone_at(buffer, mount_bone), mount) || !load_bone(bone_at(buffer, index), truck)) return;
|
||||
const auto bone = truck_bone(motion, facing_of(mount), place_of(mount), facing_of(truck), place);
|
||||
(void)store_bone(bone_at(buffer, index), bone.facing, bone.at);
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
// wherever the board itself is in that frame, so the two part company although both are the one board.
|
||||
struct Flight {
|
||||
std::mutex mutex;
|
||||
// From the tick.
|
||||
std::uint32_t id{}; // changes with every break; 0 = no piece in flight
|
||||
std::uint32_t mask{}; // the piece's stretches of deck and its trucks
|
||||
float ground{}; // world height of the ground under the skater
|
||||
float rest{}; // how far above the ground the piece's origin lies once it rests
|
||||
Vec kick{}, spin{}; // start speed (m/s) and turning (axis x rad/s), in the deck's own axes
|
||||
float ridden{std::numeric_limits<float>::quiet_NaN()}; // the ground the board rolled on as it broke
|
||||
// The hook's.
|
||||
std::uint32_t running{};
|
||||
std::int64_t last{};
|
||||
Vec at{}, speed{}, turning{};
|
||||
Quat facing{};
|
||||
bool landed{}; // lying on what is taken for the ground right now
|
||||
bool settled{}; // has landed once: flat from then on, whichever side came up
|
||||
bool upside_down{};
|
||||
Vec from{}; // where the board was when it broke
|
||||
float limit{}; // the tick's first guess of the ground: never taken higher than this
|
||||
float tipped{}; // 0..1: how far a half on one truck has tipped onto its broken end
|
||||
};
|
||||
Flight& flight() { static Flight value; return value; }
|
||||
// The stretches of deck that are in flight (0 for none) and, in `piece`, where they are from the
|
||||
// board's own place; the piece's trucks are moved here.
|
||||
std::uint32_t fly_piece(std::uintptr_t buffer, Rigid& piece) {
|
||||
auto& f = flight();
|
||||
std::unique_lock lock(f.mutex, std::try_to_lock);
|
||||
if (!lock.owns_lock() || !f.id) return 0;
|
||||
PoseBone root{}, mount{};
|
||||
if (!load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount)) return 0;
|
||||
const Vec board_at = place_of(root);
|
||||
const Quat board_facing = facing_of(root), deck_turn = facing_of(mount);
|
||||
LARGE_INTEGER now{};
|
||||
QueryPerformanceCounter(&now);
|
||||
static const double frequency = [] { LARGE_INTEGER value{}; QueryPerformanceFrequency(&value); return static_cast<double>(value.QuadPart); }();
|
||||
if (f.running != f.id) {
|
||||
f.running = f.id;
|
||||
f.last = now.QuadPart;
|
||||
f.at = f.from = board_at;
|
||||
f.facing = board_facing;
|
||||
f.speed = turn(board_facing, turn(deck_turn, f.kick));
|
||||
f.turning = turn(board_facing, turn(deck_turn, f.spin));
|
||||
f.landed = f.settled = f.upside_down = false;
|
||||
f.limit = f.ground;
|
||||
f.tipped = 0;
|
||||
}
|
||||
const auto dt = static_cast<float>(std::clamp((now.QuadPart - f.last) / frequency, 0.0, 0.05));
|
||||
f.last = now.QuadPart;
|
||||
// The ground is where the board itself lies: its lowest point right now, which physics brings
|
||||
// to rest on the ground. The skater's height at the slam (the hips, and the skater may be in the
|
||||
// air) is only an upper limit. It is the board's height NOW, not the lowest ever seen: one odd
|
||||
// pose (a teleport, a frame before the board is placed) must not sink the piece for good, and a
|
||||
// pose that is nowhere near the break is passed over altogether.
|
||||
const bool plausible = std::isfinite(board_at.x) && std::isfinite(board_at.y) && std::isfinite(board_at.z) && length(board_at - f.from) < 30.0f;
|
||||
float lowest = board_at.y + 1.0f;
|
||||
if (plausible) {
|
||||
for (const Vec corner : {Vec{0, 0, 0}, Vec{0, 0.093f, 0}, Vec{0, 0.141f, 0.41f}, Vec{0, 0.136f, -0.40f}, Vec{0.105f, 0.10f, 0},
|
||||
Vec{-0.105f, 0.10f, 0}, Vec{0.0876f, 0, front_axle}, Vec{-0.0876f, 0, front_axle}, Vec{0.0876f, 0, back_axle},
|
||||
Vec{-0.0876f, 0, back_axle}})
|
||||
lowest = (std::min)(lowest, (board_at + turn(board_facing, turn(deck_turn, corner))).y);
|
||||
// Only a board that is still near the piece says anything about the ground under the piece.
|
||||
if (std::hypot(board_at.x - f.at.x, board_at.z - f.at.z) < 4.0f) {
|
||||
// The ground the board rolled on as it broke is exact, where its lowest point depends on
|
||||
// how it has come to lie and on what: that height stays, unless the board has plainly
|
||||
// dropped below it since (off a ledge, down a stair).
|
||||
if (std::isfinite(f.ridden)) f.ground = lowest < f.ridden - 0.15f ? lowest : f.ridden;
|
||||
else f.ground = (std::min)(f.limit, lowest);
|
||||
}
|
||||
}
|
||||
// Where the piece's origin is once it lies on the ground: on its grip when it is upside down,
|
||||
// else on its underside or its wheels. A little above it: real ground is rough, and a deck is a
|
||||
// centimetre thick. A gap under a piece goes unseen, a piece cut off by the ground does not.
|
||||
const float lift = f.mask & (front_truck | back_truck) ? 0.004f : 0.012f;
|
||||
const auto lying = [&] {
|
||||
const bool flipped = f.settled ? f.upside_down : turn(f.facing, {0, 1, 0}).y < 0;
|
||||
return (flipped ? f.ground + 0.141f : f.ground + f.rest) + lift; // upside down it rests on the raised end of its grip side
|
||||
};
|
||||
if (f.landed && f.at.y > lying() + 0.01f) {
|
||||
f.landed = false; // the ground turned out to be lower: it drops the rest of the way
|
||||
f.speed = {};
|
||||
f.turning = {};
|
||||
}
|
||||
if (!f.landed) {
|
||||
f.speed.y -= 9.81f * dt;
|
||||
f.at = f.at + f.speed * dt;
|
||||
if (const auto rate = length(f.turning); rate > 1e-4f) f.facing = unit(about(f.turning * (1.0f / rate), rate * dt) * f.facing);
|
||||
if (f.speed.y <= 0 && f.at.y <= lying()) { // on its way down: it starts on the ground it will land on
|
||||
if (!f.settled) {
|
||||
// Down: lying flat, pointing the way it was, the same side up.
|
||||
const auto ahead = turn(f.facing, {0, 0, 1});
|
||||
f.upside_down = turn(f.facing, {0, 1, 0}).y < 0;
|
||||
f.facing = about({0, 1, 0}, std::atan2(ahead.x, ahead.z));
|
||||
if (f.upside_down) f.facing = unit(f.facing * about(turn(deck_turn, {0, 0, 1}), 3.14159265f)); // rolled over, about its length
|
||||
f.speed = {f.speed.x * 0.3f, 0, f.speed.z * 0.3f};
|
||||
f.settled = true;
|
||||
} else f.speed = {};
|
||||
f.at.y = lying();
|
||||
f.landed = true;
|
||||
}
|
||||
} else {
|
||||
f.at = f.at + f.speed * dt; // a short slide
|
||||
f.speed = f.speed * (std::max)(0.0f, 1.0f - 7.0f * dt);
|
||||
f.at.y = lying(); // and it stays on the ground if that comes up again
|
||||
}
|
||||
// A half board that came down on its wheels tips onto its broken end.
|
||||
const bool on_one_truck = (f.mask & (back_truck | front_truck)) && f.settled && !f.upside_down;
|
||||
f.tipped = on_one_truck && f.landed ? (std::min)(1.0f, f.tipped + dt / 0.18f) : on_one_truck ? f.tipped : 0.0f;
|
||||
const auto tip = on_root(deck_turn, f.mask & back_truck ? tipped_on_axle(back_axle, 1.0f, back_tip * f.tipped)
|
||||
: tipped_on_axle(front_axle, -1.0f, front_tip * f.tipped));
|
||||
// Where that is from the board's own place.
|
||||
const auto back_to_board = conj(board_facing);
|
||||
const auto lies_facing = unit(back_to_board * f.facing);
|
||||
const auto lies_at = turn(back_to_board, f.at - board_at);
|
||||
piece = {unit(lies_facing * tip.facing), lies_at + turn(lies_facing, tip.at)};
|
||||
if (f.mask & back_truck) carry_truck(buffer, piece, back_truck_bone, back_truck_place);
|
||||
if (f.mask & front_truck) carry_truck(buffer, piece, front_truck_bone, front_truck_place);
|
||||
return f.mask & stretches;
|
||||
}
|
||||
|
||||
// ---- what the hook writes ------------------------------------------------------------------------
|
||||
// The local board, broken: the real deck goes away, the trucks come back, each stretch goes to
|
||||
// where its piece lies and one that belongs to no piece goes away too.
|
||||
void draw_broken(std::uintptr_t base, std::uintptr_t holder, std::uint32_t kept, Hinge hinge, float angle) {
|
||||
const auto buffer = board_pose(base, holder);
|
||||
PoseBone root{}, mount{}, deck{};
|
||||
if (!buffer || !load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount) ||
|
||||
!load_bone(bone_at(buffer, deck_bone), deck))
|
||||
return;
|
||||
// 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));
|
||||
if (layout.back_truck) carry_truck(buffer, layout.back_motion, back_truck_bone, back_truck_place);
|
||||
if (layout.front_truck) carry_truck(buffer, layout.front_motion, front_truck_bone, front_truck_place);
|
||||
if (hinge == Hinge::none) {
|
||||
Rigid piece{};
|
||||
const auto flying = fly_piece(buffer, piece);
|
||||
for (std::size_t i = 0; i < layout.stretch.size(); ++i)
|
||||
if (flying & (1u << i)) layout.stretch[i] = {true, piece};
|
||||
}
|
||||
const auto bones = bones_of(layout.stretch);
|
||||
for (std::size_t i = 0; i < bones.size(); ++i) (void)store_bone(bone_at(buffer, carrier[i]), bones[i].facing, bones[i].at);
|
||||
}
|
||||
// A whole board, any player's: its four stretches go far away from it.
|
||||
void park_pieces(std::uintptr_t base, std::uintptr_t holder) {
|
||||
const auto buffer = board_pose(base, holder);
|
||||
if (!buffer) return;
|
||||
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""};
|
||||
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());
|
||||
}
|
||||
break;
|
||||
}
|
||||
return found;
|
||||
}();
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// `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 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.back_since = 0;
|
||||
crack_sound(s, 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;
|
||||
}
|
||||
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);
|
||||
s.broken.reset();
|
||||
s.parts = {};
|
||||
}
|
||||
// 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 {
|
||||
bool known{};
|
||||
float distance{}, upright{};
|
||||
bool in_hand() const noexcept { return known && distance < hand_reach && upright < hand_upright; }
|
||||
};
|
||||
Hold board_hold(std::uintptr_t base, std::uintptr_t client) {
|
||||
Hold out;
|
||||
const auto frame = multiplayer::capture_local(base, client, true);
|
||||
if (!frame.ready || frame.pose.board.empty()) return out;
|
||||
const auto& skater = frame.pose.root.position;
|
||||
const auto& board = frame.pose.board.front();
|
||||
out.distance = std::hypot(board.position[0] - skater[0], board.position[1] - skater[1], board.position[2] - skater[2]);
|
||||
const auto& q = board.rotation; // x, y, z, w: the board's up axis, turned into the world
|
||||
out.upright = std::abs(1.0f - 2.0f * (q[0] * q[0] + q[2] * q[2]));
|
||||
out.known = std::isfinite(out.distance) && std::isfinite(out.upright);
|
||||
return out;
|
||||
}
|
||||
bool riding(std::uint32_t physics) noexcept {
|
||||
// Ground/air and grind families; 300 is the wipeout, 5xx is on foot and getting on.
|
||||
return (physics >= 100 && physics < 300) || (physics >= 400 && physics < 500);
|
||||
}
|
||||
// The deck `wanted` means, of those installed: a name, a number in the list (from 1), or "next"
|
||||
// for the one after the deck in the game. Empty when there is no such deck.
|
||||
std::string deck_meant(std::string wanted, const std::string& in_game, const std::vector<std::string>& installed) {
|
||||
for (auto& c : wanted) c = c == ' ' ? '_' : static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (installed.empty()) return {};
|
||||
if (wanted.empty() || wanted == "next") {
|
||||
const auto now = std::find(installed.begin(), installed.end(), in_game);
|
||||
return now == installed.end() || now + 1 == installed.end() ? installed.front() : *(now + 1);
|
||||
}
|
||||
if (std::all_of(wanted.begin(), wanted.end(), [](char c) { return c >= '0' && c <= '9'; }) && wanted.size() <= 3) {
|
||||
// The decks the feature was made with keep their numbers whether they are installed or not;
|
||||
// the others follow, in the order they are listed.
|
||||
const auto number = static_cast<std::size_t>(std::stoi(wanted));
|
||||
const auto& known = mods::board_break_decks;
|
||||
if (number >= 1 && number <= known.size()) return std::string(known[number - 1]);
|
||||
std::size_t next = known.size();
|
||||
for (const auto& deck : installed)
|
||||
if (std::find(known.begin(), known.end(), deck) == known.end() && ++next == number) return deck;
|
||||
return {};
|
||||
}
|
||||
return wanted;
|
||||
}
|
||||
// What the hook is told this tick; `holder` 0 leaves the local board to be treated as any other.
|
||||
void publish(State& s, std::uintptr_t base, std::uintptr_t client, std::uintptr_t holder) {
|
||||
auto& edit = render_edit();
|
||||
const bool broken = s.broken && holder;
|
||||
edit.park.store(s.has_deck.load(std::memory_order_acquire), std::memory_order_release);
|
||||
edit.angle.store(s.angle, std::memory_order_release);
|
||||
edit.hinge.store(static_cast<int>(broken ? s.parts.hinge : Hinge::none), std::memory_order_release);
|
||||
edit.kept.store(broken ? s.parts.kept : 0, std::memory_order_release);
|
||||
edit.holder.store(holder, std::memory_order_release);
|
||||
// The broken-off piece: one flight per break, gone when the board is whole again.
|
||||
auto& f = flight();
|
||||
std::lock_guard lock(f.mutex);
|
||||
if (!broken || !s.parts.loose) f.id = 0;
|
||||
else if (f.id != s.flights) {
|
||||
const auto frame = multiplayer::capture_local(base, client, true);
|
||||
const auto roll = [&](float low, float high) { return std::uniform_real_distribution<float>(low, high)(s.random); };
|
||||
const bool loose_end = !(s.parts.loose & (front_truck | back_truck)); // a nose or tail, no truck on it
|
||||
const float along = s.parts.loose & nose ? 1.0f : -1.0f; // the way it leaves, along the board
|
||||
const float push = loose_end ? 1.0f : 0.55f;
|
||||
f.mask = s.parts.loose;
|
||||
f.ground = frame.ready ? frame.pose.root.position[1] : 0.0f;
|
||||
f.rest = loose_end ? -0.083f : 0.0f; // a bare piece lies on its underside, one with a truck stands on its wheels
|
||||
f.ridden = s.break_ground;
|
||||
f.kick = {roll(-0.9f, 0.9f) * push, (1.4f + roll(0.0f, 1.2f)) * push, along * (1.2f + roll(0.0f, 1.6f)) * push};
|
||||
f.spin = {along * (5.0f + roll(0.0f, 6.0f)) * push, roll(-3.0f, 3.0f) * push, roll(-2.0f, 2.0f) * push};
|
||||
f.id = s.flights;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool start(std::uintptr_t base) noexcept {
|
||||
auto& s = state();
|
||||
if (s.ready.load(std::memory_order_acquire)) return true;
|
||||
if (!no_bail_available()) {
|
||||
logging::write(logging::Level::warning, logging::Channel::skater,
|
||||
"Board Break cannot follow the skater: its slams come through No Bail's hooks, which did not start.");
|
||||
return false;
|
||||
}
|
||||
s.base.store(base, std::memory_order_release);
|
||||
s.on.store(profile_runtime::local_preference(enabled_preference).value_or(true), std::memory_order_release);
|
||||
const auto in_game = mods::board_break_in_play();
|
||||
{
|
||||
std::lock_guard lock(s.names);
|
||||
s.deck = in_game;
|
||||
}
|
||||
s.has_deck.store(!in_game.empty(), std::memory_order_release);
|
||||
s.ready.store(true, std::memory_order_release);
|
||||
logging::log(logging::Level::info, logging::Channel::skater, "Board Break ready ({}, deck: {}).", s.on.load() ? "on" : "off",
|
||||
deck_label(in_game));
|
||||
return true;
|
||||
}
|
||||
|
||||
void note_cause(std::int32_t reason, float magnitude) noexcept {
|
||||
auto& hit = last_cause();
|
||||
const auto now = GetTickCount64();
|
||||
// Keep the strongest of a burst: one slam records several causes.
|
||||
if (now - hit.at.load(std::memory_order_relaxed) < 250 && magnitude <= hit.force.load(std::memory_order_relaxed)) return;
|
||||
hit.force.store(magnitude, std::memory_order_relaxed);
|
||||
hit.reason.store(reason, std::memory_order_relaxed);
|
||||
hit.at.store(now, std::memory_order_release);
|
||||
}
|
||||
void note_wipeout(std::uintptr_t causes) noexcept {
|
||||
std::array<std::uint8_t, cause_count> set{};
|
||||
std::array<float, cause_count> force{};
|
||||
auto& hit = last_wipeout();
|
||||
std::int32_t reason{-1};
|
||||
float strongest{};
|
||||
if (causes && memory::peek_bytes(causes + 0x28, set.data(), set.size()) && memory::peek_bytes(causes + 0x4c, force.data(), sizeof(force)))
|
||||
for (std::size_t i = 0; i < cause_count; ++i)
|
||||
if (set[i] && (reason < 0 || force[i] > strongest)) {
|
||||
reason = static_cast<std::int32_t>(i);
|
||||
strongest = force[i];
|
||||
}
|
||||
hit.force.store(strongest, std::memory_order_relaxed);
|
||||
hit.reason.store(reason, std::memory_order_relaxed);
|
||||
hit.at.store(GetTickCount64(), std::memory_order_release);
|
||||
}
|
||||
|
||||
void on_render_pose(std::uintptr_t animation_interface) noexcept {
|
||||
auto& edit = render_edit();
|
||||
// Without a breakable deck in the game the boards have no stretches: nothing to do.
|
||||
if (!edit.park.load(std::memory_order_acquire) || animation_interface <= 0xc0) return;
|
||||
const auto base = state().base.load(std::memory_order_acquire);
|
||||
const auto holder = edit.holder.load(std::memory_order_acquire);
|
||||
const auto kept = edit.kept.load(std::memory_order_acquire);
|
||||
const auto error = GetLastError();
|
||||
try {
|
||||
if (kept && holder && animation_interface == holder + 0xc0)
|
||||
draw_broken(base, holder, kept, static_cast<Hinge>(edit.hinge.load(std::memory_order_acquire)), edit.angle.load(std::memory_order_acquire));
|
||||
else park_pieces(base, animation_interface - 0xc0);
|
||||
} catch (...) { /* A board that is being built or torn down: the next frame. */ }
|
||||
SetLastError(error);
|
||||
}
|
||||
|
||||
void on_client_tick(std::uintptr_t client) noexcept {
|
||||
auto& s = state();
|
||||
if (!s.ready.load(std::memory_order_acquire)) return;
|
||||
const auto base = s.base.load(std::memory_order_acquire);
|
||||
try {
|
||||
const auto now = GetTickCount64();
|
||||
// The deck in the game: the launch's, or the one the last live apply of the mods put there.
|
||||
const auto deck_in_game = mods::board_break_in_play();
|
||||
const bool in_game = !deck_in_game.empty();
|
||||
std::string wanted;
|
||||
bool changed{};
|
||||
{
|
||||
std::lock_guard lock(s.names);
|
||||
changed = s.deck != deck_in_game;
|
||||
s.deck = deck_in_game;
|
||||
wanted.swap(s.wanted_deck);
|
||||
}
|
||||
s.has_deck.store(in_game, std::memory_order_release);
|
||||
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 (in_game && !s.hooked) {
|
||||
// The boards' render pose hook is shared with multiplayer and first person, and only
|
||||
// installed when one of them asks.
|
||||
std::string detail;
|
||||
s.hooked = multiplayer::install_entity_hooks(base, detail);
|
||||
if (!s.hooked && now - s.last_error > 10000) {
|
||||
s.last_error = now;
|
||||
say(logging::Level::warning, "the boards' render pose hook is unavailable ({}).", detail);
|
||||
}
|
||||
}
|
||||
NoBailSkater skater;
|
||||
std::uintptr_t board{}, holder{};
|
||||
if (in_game && no_bail_skater(skater)) {
|
||||
const auto found = multiplayer::read_native_board(readable, base, skater.entity);
|
||||
board = found.entity;
|
||||
holder = found.holder;
|
||||
}
|
||||
if (!board) {
|
||||
// No local skater or no board (a respawn, a level change): the next board is whole. A
|
||||
// broken one that is only out of sight for a moment stays as it is.
|
||||
if (s.broken && in_game && now - s.board_seen < 500) return;
|
||||
mend(s, "the board is gone");
|
||||
s.counted = false;
|
||||
s.wanted_snap.store(-1, std::memory_order_release);
|
||||
s.wanted_mend.store(false, std::memory_order_release);
|
||||
publish(s, base, client, 0);
|
||||
return;
|
||||
}
|
||||
s.board_seen = now;
|
||||
if (s.broken && s.broken->board != board) mend(s, "the skater has another board");
|
||||
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");
|
||||
const bool carried = watch.valid && watch.state >= 500 && watch.state < 600; // on foot and getting on
|
||||
if (const int place = s.wanted_snap.exchange(-1, std::memory_order_acq_rel); place >= 0)
|
||||
(void)snap(s, board, place < 3 ? std::optional(static_cast<Place>(place)) : std::nullopt, false, carried, "asked for", true);
|
||||
if (watch.valid) {
|
||||
if (watch.state == 100) {
|
||||
// Rolling on the ground, the right way up: the board's own height is the ground's.
|
||||
Vec at{};
|
||||
Quat facing{};
|
||||
if (board_place(base, holder, at, facing) && turn(facing, {0, 1, 0}).y > 0.9f) {
|
||||
s.ride_height = at.y;
|
||||
s.ride_when = now;
|
||||
}
|
||||
}
|
||||
const bool on = s.on.load(std::memory_order_acquire);
|
||||
if (!s.counted) {
|
||||
s.wipeouts = watch.wipeouts;
|
||||
s.counted = true;
|
||||
}
|
||||
if (watch.wipeouts != s.wipeouts) {
|
||||
s.wipeouts = watch.wipeouts;
|
||||
// The cause that made the selector choose Wipeout, or else the last one recorded.
|
||||
auto* hit = &last_wipeout();
|
||||
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));
|
||||
}
|
||||
// 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)
|
||||
(void)snap(s, board, Place::nose, true, false, "it gave way while riding");
|
||||
}
|
||||
s.rode_at = now;
|
||||
if (s.broken) {
|
||||
// Whole again once the skater rides it, or has picked it up after the fall. A board that
|
||||
// gave way under the rider stays as it is while it is ridden; after a fall it is a
|
||||
// broken board like any other.
|
||||
const auto hold = board_hold(base, client);
|
||||
const bool on_it = riding(watch.state);
|
||||
if (s.broken->gave_way && watch.state == addr::no_bail::wipeout_physics_state) s.broken->gave_way = false;
|
||||
const bool back = s.broken->gave_way ? !on_it && hold.in_hand()
|
||||
: on_it || (watch.state != addr::no_bail::wipeout_physics_state && hold.in_hand());
|
||||
// A respawn gives the skater a new physics selector, which the watch reports as state 0
|
||||
// until it has chosen once: the board is whole from that moment.
|
||||
if (watch.state == 0 && now - s.broken->since > 300) mend(s, "a respawn");
|
||||
else if (s.broken->asked) s.back_since = 0;
|
||||
else if (!back) s.back_since = 0;
|
||||
else if (!s.back_since) s.back_since = now;
|
||||
else if (now - s.back_since > 300 && now - s.broken->since > 1200) mend(s, on_it ? "riding again" : "back in hand");
|
||||
}
|
||||
}
|
||||
publish(s, base, client, holder);
|
||||
} catch (const std::exception& error) {
|
||||
// The skater or its board is being rebuilt: tried again at a later tick.
|
||||
const auto now = GetTickCount64();
|
||||
if (now - s.last_error > 10000 && logging::enabled(logging::Level::debug)) {
|
||||
s.last_error = now;
|
||||
say(logging::Level::debug, "waiting ({}).", error.what());
|
||||
}
|
||||
} catch (...) { /* A broken board is never worth the client tick. */ }
|
||||
}
|
||||
|
||||
bool available() noexcept { return state().ready.load(std::memory_order_acquire); }
|
||||
bool enabled() noexcept { return available() && state().on.load(std::memory_order_acquire); }
|
||||
void set_enabled(bool enabled) noexcept {
|
||||
state().on.store(enabled, std::memory_order_release);
|
||||
profile_runtime::set_local_preference(enabled_preference, enabled);
|
||||
}
|
||||
std::string deck() {
|
||||
auto& s = state();
|
||||
std::lock_guard lock(s.names);
|
||||
return s.deck;
|
||||
}
|
||||
std::string deck_label(std::string_view deck) {
|
||||
if (deck.empty()) return "None";
|
||||
std::string name(deck);
|
||||
bool start = true;
|
||||
for (auto& c : name) {
|
||||
if (c == '_') c = ' ';
|
||||
else if (start) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
start = c == ' ';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
std::string choose_deck(std::string wanted) {
|
||||
auto& s = state();
|
||||
if (!available()) return "Board Break is unavailable for this game build.";
|
||||
const auto installed = mods::installed_board_break_decks();
|
||||
if (installed.empty()) return "None of Board Break's decks is installed.";
|
||||
const auto in_game = deck();
|
||||
const auto chosen = deck_meant(wanted, in_game, installed);
|
||||
if (chosen.empty()) return "There is no deck " + wanted + ".";
|
||||
if (std::find(installed.begin(), installed.end(), chosen) == installed.end())
|
||||
return "The " + deck_label(chosen) + " deck is not installed: its mod (Board_Break_" + chosen + ") is not in the Mods folder.";
|
||||
if (chosen == in_game) return "The " + deck_label(chosen) + " deck is the one in the game.";
|
||||
{
|
||||
std::lock_guard lock(s.names);
|
||||
s.wanted_deck = chosen;
|
||||
}
|
||||
return "The " + deck_label(chosen) + " deck: the mods are applied and the level loads again with it.";
|
||||
}
|
||||
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); }
|
||||
} // namespace dingosdk::board_break
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
// Board Break: a hard slam can break the local skater's board. It snaps at the nose, in the middle
|
||||
// or at the tail; half the time the grip tape holds the pieces together and the board folds or an
|
||||
// end hangs, otherwise a piece comes off, flies and lies where it lands. The board is whole again
|
||||
// once the skater has picked it up, rides again or respawns. How hard the slam was (the force of
|
||||
// the bail cause that led to the wipeout) decides whether the board breaks: a heavy one always, a
|
||||
// lighter one now and then.
|
||||
//
|
||||
// The pieces are not drawn by ReSkate: they are in the deck model of a content mod, one mod for
|
||||
// each deck shape, any folder named Board_Break_<Name> (Engine/Vfs/mod_catalog.h). The game has one
|
||||
// deck model for every board; such a mod replaces it with the same deck plus four stretches of it
|
||||
// (nose, front, back, tail) cut along the break rows, each on a spare bone of the board's rig
|
||||
// (contrib/board-break.md says how such a model is made). This only moves bones,
|
||||
// in the boards' render pose hook (Extension/Multiplayer/Remote/native_skater.h):
|
||||
// - a broken board has its real deck moved far away, the trucks moved back to where they were,
|
||||
// and each stretch moved to where its piece lies;
|
||||
// - a whole board has its stretches moved far away, every frame, on every board in the game: the
|
||||
// game draws a deck's wood and grip as layers that do not hide what lies just under them, so
|
||||
// stretches left inside the deck show as cracks.
|
||||
// Nothing is scaled: the game's replay records where a bone is and how it is turned, not its size,
|
||||
// so a break made of moves plays back in the replay editor as it happened.
|
||||
//
|
||||
// Without such a mod installed and loading first nothing ever breaks. With one, mods.json is not
|
||||
// changed: the deck the player chose goes first for the run, and the other decks and any mod that
|
||||
// reshapes the deck are set aside (mod_catalog.cpp). Choosing another deck applies the mods afresh
|
||||
// and loads the level again (Extension/Assets/live_mods.h); the game reads the model then.
|
||||
namespace dingosdk::board_break {
|
||||
// Requires the validated build, No Bail started and the local profile loaded: the switch starts
|
||||
// from the saved choice (on until a player changes it).
|
||||
bool start(std::uintptr_t image_base) noexcept;
|
||||
// Client thread, every tick, with the local client.
|
||||
void on_client_tick(std::uintptr_t client) noexcept;
|
||||
// From No Bail's hooks, on the physics thread: a bail cause recorded for the watched skater, and
|
||||
// the moment its selector chose Wipeout (`causes` is its cause collector, read before it resets).
|
||||
void note_cause(std::int32_t reason, float magnitude) noexcept;
|
||||
void note_wipeout(std::uintptr_t causes) noexcept;
|
||||
// From the boards' render pose hook, after a board's pose has been handed to the renderer and
|
||||
// after another player's pose has been written into it (`animation_interface` is the board's
|
||||
// animation holder + 0xc0): what is written to the pose here is what this frame draws.
|
||||
void on_render_pose(std::uintptr_t animation_interface) noexcept;
|
||||
|
||||
// Any thread.
|
||||
bool available() noexcept;
|
||||
bool enabled() noexcept; // off: boards hold; one that is broken stays so until it is picked up
|
||||
void set_enabled(bool enabled) noexcept;
|
||||
// The name of the deck in the game ("standard", "wide" and so on), empty when none of Board Break's
|
||||
// decks is: boards cannot break then. And a deck's name as the menus show it ("Powell"; "None").
|
||||
std::string deck();
|
||||
std::string deck_label(std::string_view deck);
|
||||
// Asks for another deck: its name, its number in the list `boardbreak deck` prints, or "next" for
|
||||
// the one after the deck in the game. Says what happens, for the console; the mods are applied and
|
||||
// the level loaded again from the next tick.
|
||||
std::string choose_deck(std::string wanted);
|
||||
// Done at the next tick: breaks the board at `place` (0 nose, 1 middle, 2 tail; anything else is
|
||||
// left to chance), to be looked at: that break stays until the board is mended here or the skater
|
||||
// respawns. Or makes a broken board whole.
|
||||
void snap(int place) noexcept;
|
||||
void mend() noexcept;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "board_break.h"
|
||||
#include "board_break_model.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Extension/Console/commands.h"
|
||||
#include <algorithm>
|
||||
|
||||
// Board Break's console switches; the menu's (Mod Options > Player) send them too.
|
||||
namespace dingosdk::console {
|
||||
void register_board_break_commands(Commands ®istry) {
|
||||
const auto unavailable = "Board Break is unavailable for this game build.";
|
||||
auto on = variable("boardbreak", "A hard slam can snap your board at the nose, in the middle or at the tail "
|
||||
"(needs one of the Board Break deck packs)",
|
||||
Group::gameplay, argument("0|1", Type::boolean));
|
||||
on.inspect = [unavailable](const Model &) {
|
||||
const auto deck = board_break::deck();
|
||||
return boolean_state(board_break::available(), board_break::enabled(), unavailable,
|
||||
deck.empty() ? std::string("no Board Break deck is in the game") : "the " + board_break::deck_label(deck) + " deck");
|
||||
};
|
||||
on.run = [](const Model &, const Values &args, const Output &) { board_break::set_enabled(std::get<bool>(args[0])); };
|
||||
registry.add(std::move(on));
|
||||
|
||||
auto which = argument("deck", Type::text, true);
|
||||
which.complete = [](const Model &, auto) {
|
||||
auto names = mods::installed_board_break_decks();
|
||||
names.insert(names.begin(), "next");
|
||||
return names;
|
||||
};
|
||||
auto deck = action("boardbreak deck", "Which deck every board has, by its name or its number, or next; the level loads again with it",
|
||||
Group::gameplay, {std::move(which)});
|
||||
deck.run = [](const Model &, const Values &args, const Output &out) {
|
||||
if (!args.empty() && !std::get<std::string>(args[0]).empty()) {
|
||||
out(board_break::choose_deck(std::get<std::string>(args[0])));
|
||||
return;
|
||||
}
|
||||
// No deck named: which one is in the game, and which there are. The decks the feature was
|
||||
// made with keep their numbers; the others follow.
|
||||
const auto installed = mods::installed_board_break_decks();
|
||||
const auto &known = mods::board_break_decks;
|
||||
std::string list;
|
||||
std::size_t number{};
|
||||
const auto add = [&](std::string_view name, bool there) {
|
||||
const auto place = ++number;
|
||||
list += (place > 1 ? ", " : "") + std::to_string(place) + " " + board_break::deck_label(name) + (there ? "" : " (not installed)");
|
||||
};
|
||||
for (const auto name : known) add(name, std::find(installed.begin(), installed.end(), name) != installed.end());
|
||||
for (const auto &name : installed)
|
||||
if (std::find(known.begin(), known.end(), name) == known.end()) add(name, true);
|
||||
out("In the game: " + board_break::deck_label(board_break::deck()) + ". Decks: " + list + ".");
|
||||
};
|
||||
registry.add(std::move(deck));
|
||||
|
||||
auto where = argument("nose|mid|tail", Type::text, true);
|
||||
where.complete = [](const Model &, auto) {
|
||||
return std::vector<std::string>(board_break::place_names.begin(), board_break::place_names.end());
|
||||
};
|
||||
auto snap = action("boardbreak snap", "Break your board now, at the nose, in the middle, at the tail or wherever, to look at a break: "
|
||||
"it stays broken until `boardbreak mend` or a respawn. A piece only comes off while you ride",
|
||||
Group::gameplay, {std::move(where)});
|
||||
snap.run = [](const Model &, const Values &args, const Output &out) {
|
||||
const auto wanted = args.empty() ? std::string{} : std::get<std::string>(args[0]);
|
||||
const auto &names = board_break::place_names;
|
||||
const auto named = std::find(names.begin(), names.end(), wanted);
|
||||
board_break::snap(named == names.end() ? -1 : static_cast<int>(named - names.begin()));
|
||||
out(board_break::deck().empty() ? "No Board Break deck is in the game: the board holds." : "Snapped.");
|
||||
};
|
||||
registry.add(std::move(snap));
|
||||
|
||||
auto mend = action("boardbreak mend", "Make a broken board whole again", Group::gameplay);
|
||||
mend.run = [](const Model &, const Values &, const Output &out) {
|
||||
board_break::mend();
|
||||
out("Mended.");
|
||||
};
|
||||
registry.add(std::move(mend));
|
||||
}
|
||||
} // namespace dingosdk::console
|
||||
@@ -0,0 +1,206 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
// Board Break's rules and geometry, apart from the game: where a board breaks, what is left of it,
|
||||
// and where each part of a broken board lies. Everything is measured on the deck, in the deck
|
||||
// model's own axes: metres, y up, z along the board with the nose at +z.
|
||||
namespace dingosdk::board_break {
|
||||
// ---- rigid motions ----------------------------------------------------------------------------
|
||||
// Quaternions are x, y, z, w (Hamilton), as the game's poses store them.
|
||||
struct Vec {
|
||||
float x{}, y{}, z{};
|
||||
};
|
||||
struct Quat {
|
||||
float x{}, y{}, z{}, w{1.0f};
|
||||
};
|
||||
constexpr Vec operator+(Vec a, Vec b) noexcept { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
|
||||
constexpr Vec operator-(Vec a, Vec b) noexcept { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
|
||||
constexpr Vec operator*(Vec a, float s) noexcept { return {a.x * s, a.y * s, a.z * s}; }
|
||||
inline float length(Vec a) noexcept { return std::sqrt(a.x * a.x + a.y * a.y + a.z * a.z); }
|
||||
constexpr Quat operator*(Quat a, Quat b) noexcept { // b first, then a
|
||||
return {a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
||||
a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z};
|
||||
}
|
||||
constexpr Quat conj(Quat q) noexcept { return {-q.x, -q.y, -q.z, q.w}; }
|
||||
inline Quat unit(Quat q) noexcept {
|
||||
const auto n = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
|
||||
return n > 1e-6f ? Quat{q.x / n, q.y / n, q.z / n, q.w / n} : Quat{};
|
||||
}
|
||||
inline Quat about(Vec axis, float radians) noexcept {
|
||||
const auto s = std::sin(radians / 2);
|
||||
return {axis.x * s, axis.y * s, axis.z * s, std::cos(radians / 2)};
|
||||
}
|
||||
constexpr Vec turn(Quat q, Vec v) noexcept {
|
||||
const Vec u{q.x, q.y, q.z};
|
||||
const Vec c{u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z, u.x * v.y - u.y * v.x};
|
||||
const Vec d{u.y * c.z - u.z * c.y, u.z * c.x - u.x * c.z, u.x * c.y - u.y * c.x};
|
||||
return v + c * (2 * q.w) + d * 2;
|
||||
}
|
||||
// Turned, then shifted.
|
||||
struct Rigid {
|
||||
Quat facing{};
|
||||
Vec at{};
|
||||
};
|
||||
|
||||
// ---- breaks -----------------------------------------------------------------------------------
|
||||
// A deck breaks across one of three rows: just ahead of the front bolts, in the middle, just behind
|
||||
// the back bolts. That cuts it into four stretches; each truck rides on the stretch its bolts are in.
|
||||
enum Piece : std::uint32_t { nose = 1, front = 2, back = 4, tail = 8, front_truck = 16, back_truck = 32 };
|
||||
constexpr std::uint32_t whole_board = nose | front | back | tail | front_truck | back_truck;
|
||||
constexpr std::uint32_t stretches = nose | front | back | tail;
|
||||
enum class Place { nose, mid, tail };
|
||||
constexpr std::array<std::string_view, 3> place_names{"nose", "mid", "tail"};
|
||||
constexpr float nose_row = 0.260f, mid_row = 0.009f, tail_row = -0.260f;
|
||||
// How the grip tape holds a break together: not at all, or with the nose hanging, the board folded
|
||||
// in the middle, the tail hanging.
|
||||
enum class Hinge { none, nose, fold, tail };
|
||||
// What a break leaves: the piece the board keeps, the piece that comes off and flies (none when
|
||||
// the grip tape holds), and how the two hang together.
|
||||
struct Break {
|
||||
std::uint32_t kept{}, loose{};
|
||||
Hinge hinge{};
|
||||
};
|
||||
constexpr Break broken_at(Place place, bool joined) noexcept {
|
||||
if (joined) return {whole_board, 0, place == Place::nose ? Hinge::nose : place == Place::mid ? Hinge::fold : Hinge::tail};
|
||||
// A clean break: the board keeps the larger piece, and of two halves the front one.
|
||||
switch (place) {
|
||||
case Place::nose: return {whole_board & ~static_cast<std::uint32_t>(nose), nose, Hinge::none};
|
||||
case Place::mid: return {nose | front | front_truck, back | tail | back_truck, Hinge::none};
|
||||
default: return {whole_board & ~static_cast<std::uint32_t>(tail), tail, Hinge::none};
|
||||
}
|
||||
}
|
||||
// How big a break is, for its sound. An end that hangs on by the grip tape is a small one; an end
|
||||
// that comes off, or a board that folds a little, is in between; a board that folds right down or
|
||||
// comes apart in the middle is the biggest. `fold_depth` is 0 to 1, how far a fold sags.
|
||||
enum class Size { light, medium, heavy };
|
||||
constexpr std::array<std::string_view, 3> size_names{"light", "medium", "heavy"};
|
||||
constexpr Size break_size(Place place, bool joined, float fold_depth) noexcept {
|
||||
if (place == Place::mid) return !joined || fold_depth >= 0.7f ? Size::heavy : Size::medium;
|
||||
return joined ? Size::light : Size::medium;
|
||||
}
|
||||
// How likely a slam of `force` breaks the board: always from `heavy` up, with `light` up to 60% of
|
||||
// it, rising evenly in between.
|
||||
constexpr float break_chance(float force, float heavy, float light) noexcept {
|
||||
if (heavy <= 0 || force >= heavy) return 1.0f;
|
||||
const auto low = 0.6f * heavy;
|
||||
return force <= low ? light : light + (1.0f - light) * (force - low) / (heavy - low);
|
||||
}
|
||||
|
||||
// ---- where the parts of a broken board lie ------------------------------------------------------
|
||||
constexpr float axle_height = 0.028f, front_axle = 0.2226f, back_axle = -0.2225f;
|
||||
// How far each half of a board tips: a touch short of where its first splinter meets the ground
|
||||
// (0.351 and 0.325 rad, measured on the mesh), so the end rests on the ground instead of digging in.
|
||||
constexpr float front_tip = 0.340f, back_tip = 0.315f;
|
||||
// Half a board on one truck does not stand level: it tips about its axle until the broken end lies
|
||||
// on the ground. `towards` is +1 when the broken end is on the nose side of that axle.
|
||||
inline Rigid tipped_on_axle(float axle_z, float towards, float angle) noexcept {
|
||||
const auto turned = about({1, 0, 0}, towards * angle);
|
||||
const Vec axle{0, axle_height, axle_z};
|
||||
return {turned, axle - turn(turned, axle)};
|
||||
}
|
||||
// A piece that hangs on by the grip tape: turned about the break line just under the deck's top,
|
||||
// and drawn a hair away from the board, so the tape opens into a ragged crack with the wood of the
|
||||
// broken end in it. `towards` is +1 for the nose side of the break.
|
||||
constexpr float crack = 0.0025f; // how far the tape gapes along the board (m)
|
||||
constexpr float grip_height = 0.0935f;
|
||||
inline Rigid hung_on_grip(float break_z, float towards, float angle) noexcept {
|
||||
const auto turned = about({1, 0, 0}, towards * angle);
|
||||
const Vec hinge{0, grip_height - 0.004f, break_z};
|
||||
return {turned, hinge - turn(turned, hinge) + Vec{0, 0, towards * crack * 0.5f}};
|
||||
}
|
||||
// A board folded in the middle: each half turns about its own axle, the front by `depth` of the
|
||||
// way to the ground and the back as far as brings its grip to the same height, and the two move
|
||||
// along the board until the grip of both all but meets again at the break, where the tape holds.
|
||||
inline void folded(float depth, Rigid& front_half, Rigid& back_half) noexcept {
|
||||
constexpr float grip_y = 0.095f;
|
||||
constexpr float rise = grip_y - axle_height, ahead = front_axle - mid_row, behind = mid_row - back_axle;
|
||||
const float a = front_tip * std::clamp(depth, 0.0f, 1.0f);
|
||||
const float height = rise * std::cos(a) - ahead * std::sin(a); // of the grip at the break, above the axles
|
||||
const float b = std::acos(std::clamp(height / std::hypot(rise, behind), -1.0f, 1.0f)) - std::atan2(behind, rise);
|
||||
front_half = tipped_on_axle(front_axle, -1.0f, a);
|
||||
back_half = tipped_on_axle(back_axle, 1.0f, b);
|
||||
const float front_end = front_axle - (rise * std::sin(a) + ahead * std::cos(a));
|
||||
const float back_end = back_axle + (rise * std::sin(b) + behind * std::cos(b));
|
||||
front_half.at.z += (back_end - front_end + crack) * 0.5f; // and a crack's width more: the tape gapes
|
||||
back_half.at.z -= (back_end - front_end + crack) * 0.5f;
|
||||
}
|
||||
// The deck sits on the board's rig a quarter turn about the upright axis (the rig's mount carries
|
||||
// it), and the stretches hang on the rig's root: a motion worked out on the deck, carried over to
|
||||
// the root's axes.
|
||||
inline Rigid on_root(Quat mount, const Rigid& motion) noexcept {
|
||||
return {unit(mount * motion.facing * conj(mount)), turn(mount, motion.at)};
|
||||
}
|
||||
|
||||
// Where something goes that is not to be seen: far below the board. The game's replay keeps where
|
||||
// a bone is and how it is turned, and nothing of its scale, so a part is never hidden by shrinking.
|
||||
constexpr Vec away{0.0f, -2000.0f, 0.0f};
|
||||
// One of the four stretches of deck (nose, front, back, tail, in that order everywhere): whether
|
||||
// it shows, and its motion from the place it has in a whole board.
|
||||
struct Stretch {
|
||||
bool shown{};
|
||||
Rigid motion{};
|
||||
};
|
||||
// A broken board as it lies: its stretches and its trucks, in the rig root's axes.
|
||||
struct Layout {
|
||||
std::array<Stretch, 4> stretch{};
|
||||
bool front_truck{}, back_truck{};
|
||||
Rigid front_motion{}, back_motion{};
|
||||
};
|
||||
// The piece a board keeps (`kept`, Piece bits), hanging together as `hinge` says. `angle` is how far
|
||||
// an end droops (radians) or how deep a fold sags (0 to 1); `up` is how far the board is the right
|
||||
// way up (1 on its wheels, -1 on its grip); `mount` is the rig's mount turn.
|
||||
inline Layout lay_out(std::uint32_t kept, Hinge hinge, float angle, float up, Quat mount) noexcept {
|
||||
// `ahead` moves the front of the board (and all of it unless told otherwise), `rear` the back half.
|
||||
Rigid ahead{}, rear{}, hung{};
|
||||
if (hinge == Hinge::fold) {
|
||||
// The break sags towards the ground under the board. On its grip the fold would push nose
|
||||
// and tail into the ground instead, so there it stays shallow.
|
||||
const auto level = std::clamp((up + 0.2f) / 0.8f, 0.0f, 1.0f);
|
||||
folded(angle * (0.3f + 0.7f * level), ahead, rear);
|
||||
} else if (hinge == Hinge::nose) hung = hung_on_grip(nose_row, 1.0f, angle);
|
||||
else if (hinge == Hinge::tail) hung = hung_on_grip(tail_row, -1.0f, angle);
|
||||
else if (static_cast<bool>(kept & front_truck) != static_cast<bool>(kept & back_truck)) {
|
||||
// Half a deck on one truck: while the board is the right way up it tips onto its broken end
|
||||
// instead of standing level in the air.
|
||||
const auto level = std::clamp((up - 0.3f) / 0.5f, 0.0f, 1.0f);
|
||||
const auto amount = level * level * (3.0f - 2.0f * level);
|
||||
ahead = kept & front_truck ? tipped_on_axle(front_axle, -1.0f, front_tip * amount) : tipped_on_axle(back_axle, 1.0f, back_tip * amount);
|
||||
}
|
||||
ahead = on_root(mount, ahead);
|
||||
hung = on_root(mount, hung);
|
||||
rear = hinge == Hinge::fold ? on_root(mount, rear) : ahead;
|
||||
Layout out;
|
||||
out.stretch = {{{(kept & nose) != 0, hinge == Hinge::nose ? hung : ahead},
|
||||
{(kept & front) != 0, ahead},
|
||||
{(kept & back) != 0, rear},
|
||||
{(kept & tail) != 0, hinge == Hinge::fold ? rear : hinge == Hinge::tail ? hung : ahead}}};
|
||||
out.front_truck = (kept & front_truck) != 0;
|
||||
out.back_truck = (kept & back_truck) != 0;
|
||||
out.front_motion = ahead;
|
||||
out.back_motion = rear;
|
||||
return out;
|
||||
}
|
||||
// What goes into the stretches' bones. The first three hang on the rig's root; the tail's hangs on
|
||||
// the front stretch's, so its place is given from there. A stretch that does not show goes away.
|
||||
inline std::array<Rigid, 4> bones_of(std::array<Stretch, 4> stretch) noexcept {
|
||||
for (auto& one : stretch)
|
||||
if (!one.shown) one.motion = {{}, away};
|
||||
const auto& from = stretch[1].motion;
|
||||
const auto& to = stretch[3].motion;
|
||||
return {{stretch[0].motion, stretch[1].motion, stretch[2].motion,
|
||||
{unit(conj(from.facing) * to.facing), turn(conj(from.facing), to.at - from.at)}}};
|
||||
}
|
||||
// A truck hangs on the deck's bone, which is moved `away` to hide the real deck, and that bone sits
|
||||
// on the mount. To move the truck with a piece: the piece's motion seen from the deck, the deck's
|
||||
// move taken back. `steer` is the truck's own turn, which it keeps; `place` where it sits on the deck.
|
||||
constexpr Vec back_truck_place{0.0005f, -0.0183f, -0.2345f}, front_truck_place{0.0005f, -0.0183f, 0.2347f};
|
||||
inline Rigid truck_bone(const Rigid& motion, Quat mount, Vec mount_at, Quat steer, Vec place) noexcept {
|
||||
const auto facing = unit(conj(mount) * motion.facing * mount);
|
||||
const auto at = turn(conj(mount), turn(motion.facing, mount_at) + motion.at - mount_at);
|
||||
return {unit(facing * steer), turn(facing, place) + at - away};
|
||||
}
|
||||
} // namespace dingosdk::board_break
|
||||
@@ -227,6 +227,7 @@ const Commands &game_commands() {
|
||||
register_trainer_commands(*result);
|
||||
register_hall_of_meat_commands(*result);
|
||||
register_road_rash_commands(*result);
|
||||
register_board_break_commands(*result);
|
||||
return result;
|
||||
}();
|
||||
return *registry;
|
||||
|
||||
@@ -28,6 +28,7 @@ void register_item_commands(Commands &);
|
||||
void register_trainer_commands(Commands &);
|
||||
void register_hall_of_meat_commands(Commands &);
|
||||
void register_road_rash_commands(Commands &);
|
||||
void register_board_break_commands(Commands &);
|
||||
// Runtime adapters. Invoked only by the verified game-thread dispatcher.
|
||||
void request_debug(overlay::DebugAction, bool enabled = false, float value = 0);
|
||||
void request_feature(overlay::OfflineFeatureGroup, bool enabled);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "puppet_cost.h"
|
||||
#include "Extension/Multiplayer/Session/monotonic_clock.h"
|
||||
#include "Extension/Multiplayer/Session/peer_slots.h"
|
||||
#include "Extension/BoardBreak/board_break.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Game/Build/20260929/engine.h"
|
||||
@@ -344,6 +345,8 @@ bool render_pose_hook(std::uintptr_t animation_interface, std::uintptr_t render_
|
||||
const PeerScope scope(slot);
|
||||
apply_render_pose(animation_interface, render_data);
|
||||
}
|
||||
// Last, so that what it does to a board's pose (another player's included) is what is drawn.
|
||||
if (result) board_break::on_render_pose(animation_interface);
|
||||
return result;
|
||||
}
|
||||
void animation_hook(std::uintptr_t component, std::uintptr_t update) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "no_bail.h"
|
||||
#include "Extension/BoardBreak/board_break.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
@@ -63,7 +64,7 @@ struct Protection {
|
||||
};
|
||||
Protection& protection() { static auto* value = new Protection; return *value; }
|
||||
struct StateWatch {
|
||||
std::atomic<std::uintptr_t> selector{};
|
||||
std::atomic<std::uintptr_t> selector{}, causes{}; // the watched skater's selector and cause collector
|
||||
std::atomic<std::uint64_t> until{}, changes{}, wipeouts{};
|
||||
std::atomic<std::uint32_t> state{}, previous{};
|
||||
std::atomic<std::int64_t> since{}, previous_ticks{}; // performance-counter ticks
|
||||
@@ -180,6 +181,7 @@ bool suppress_cause(std::uintptr_t causes, std::int32_t reason, std::uintptr_t c
|
||||
}
|
||||
void record_cause(std::uintptr_t causes, std::int32_t reason, float magnitude) {
|
||||
const auto caller = reinterpret_cast<std::uintptr_t>(_ReturnAddress());
|
||||
if (causes == state_watch().causes.load(std::memory_order_acquire)) board_break::note_cause(reason, magnitude);
|
||||
const bool protect = suppress_cause(causes, reason, caller);
|
||||
// Do not force the native recovery/stumble predicate (recovery_predicate). Its
|
||||
// result is also exported to animation at +0x9e, even without a collision.
|
||||
@@ -219,7 +221,11 @@ std::uint32_t choose_state(std::uintptr_t selector, std::uint32_t current) {
|
||||
w.previous.store(before, std::memory_order_relaxed);
|
||||
w.previous_ticks.store(now.QuadPart - w.since.exchange(now.QuadPart, std::memory_order_relaxed), std::memory_order_relaxed);
|
||||
w.changes.fetch_add(1, std::memory_order_relaxed);
|
||||
if (chosen == wipeout_physics_state) w.wipeouts.fetch_add(1, std::memory_order_relaxed);
|
||||
if (chosen == wipeout_physics_state) {
|
||||
// Before the count: the tick that sees the new wipeout finds its cause there.
|
||||
board_break::note_wipeout(w.causes.load(std::memory_order_acquire));
|
||||
w.wipeouts.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
@@ -420,6 +426,7 @@ void watch_physics_state(std::uintptr_t client, std::uintptr_t entity) noexcept
|
||||
if (!protection().ready.load(std::memory_order_acquire) || !resolve(client, entity, owner)) return;
|
||||
if (w.selector.exchange(owner.selector, std::memory_order_acq_rel) != owner.selector)
|
||||
w.state.store(0, std::memory_order_release);
|
||||
w.causes.store(owner.causes, std::memory_order_release);
|
||||
w.until.store(GetTickCount64() + 500, std::memory_order_release);
|
||||
}
|
||||
PhysicsStateWatch watched_physics_state() noexcept {
|
||||
|
||||
@@ -140,6 +140,18 @@ int main() {
|
||||
check(capture.command == "roadrashblood 0", "The blood switches off");
|
||||
activate(state, model, cb, "road-rash-heal", "");
|
||||
check(capture.command == "roadrash heal", "and the skater heals");
|
||||
// Board Break's switch sits there too, with the deck that is in the game; another deck waits for a deck pack.
|
||||
check(rash_row("board-break").title == "Board Break: Off" && rash_row("board-break").command.empty(), "Board Break waits until it started");
|
||||
model.board_break = {true, true, false, "None"};
|
||||
check(rash_row("board-break").title == "Board Break: On" && rash_row("board-break-deck").command.empty(),
|
||||
"Without a deck pack there is no deck to change");
|
||||
activate(state, model, cb, "board-break", "");
|
||||
check(capture.command == "boardbreak 0", "Board Break turns off");
|
||||
model.board_break = {true, true, true, "Wide"};
|
||||
check(rash_row("board-break-deck").title == "Board Break deck: Wide" && rash_row("board-break-deck").command == "board-break-deck",
|
||||
"The deck in the game shows");
|
||||
activate(state, model, cb, "board-break-deck", "");
|
||||
check(capture.command == "boardbreak deck next", "and steps to the next one");
|
||||
model.multiplayer.local_name = "steam_name";
|
||||
model.player_card = {true, "Card Name", {}};
|
||||
const auto card_page = render(state, model, cb, player_section);
|
||||
|
||||
@@ -208,6 +208,10 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks
|
||||
console(m.road_rash.blood ? "roadrashblood 0" : "roadrashblood 1", m.road_rash.available && m.road_rash.enabled);
|
||||
} else if (command == "road-rash-heal") {
|
||||
console("roadrash heal", m.road_rash.available && m.road_rash.enabled);
|
||||
} else if (command == "board-break") {
|
||||
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-wear-reset") {
|
||||
console("boardwear reset", m.offline.board_wear.available && m.offline.board_wear.effective);
|
||||
} else if (command == "challenges") {
|
||||
@@ -308,6 +312,9 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3
|
||||
m.road_rash.available && m.road_rash.enabled && cb.queue_console_command);
|
||||
button(p.main, "road-rash-heal", "Heal Road Rash", "road-rash-heal",
|
||||
m.road_rash.available && m.road_rash.enabled && cb.queue_console_command);
|
||||
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-wear", "Board wear", m.offline.board_wear.effective,
|
||||
m.offline.board_wear.available && cb.queue_offline_feature);
|
||||
button(p.main, "board-wear-reset", "Reset board wear", "board-wear-reset",
|
||||
|
||||
@@ -1098,6 +1098,9 @@ void tools_page(Ui &ui, ImVec2 a, ImVec2 z) {
|
||||
if (list.setting("Road Rash", on_off(m.road_rash.enabled), m.road_rash.available && console)) act("road-rash");
|
||||
if (list.setting("Road Rash blood", on_off(m.road_rash.blood), m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-blood");
|
||||
if (list.setting("Heal Road Rash", {}, m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-heal");
|
||||
if (list.setting("Board Break", on_off(m.board_break.enabled), m.board_break.available && console)) act("board-break");
|
||||
if (list.setting("Board Break deck", m.board_break.deck, m.board_break.available && m.board_break.has_deck && console))
|
||||
act("board-break-deck");
|
||||
const auto &wear = m.offline.board_wear;
|
||||
if (list.setting("Board wear", on_off(wear.effective), wear.available && cb.queue_offline_feature)) act("board-wear");
|
||||
if (list.setting("Reset board wear", {}, wear.available && wear.effective && console)) act("board-wear-reset");
|
||||
|
||||
@@ -50,11 +50,18 @@ void draw_installed_mods(SkateMenu& menu) {
|
||||
const auto excluded = [&](const std::string& name) {
|
||||
return std::ranges::any_of(launch.excluded, [&](const mods::Mod& mod) { return _stricmp(mod.name.c_str(), name.c_str()) == 0; });
|
||||
};
|
||||
// Board Break's other decks and other mods that bring a deck model: enabled, not loaded, and
|
||||
// nothing about them waits to be applied.
|
||||
const auto aside = mods::set_aside_for_board_break();
|
||||
const auto set_aside = [&](const mods::ModEntry& entry) {
|
||||
return entry.enabled && !in_effect(entry.mod.name) &&
|
||||
std::ranges::any_of(aside, [&](const std::string& other) { return _stricmp(other.c_str(), entry.mod.name.c_str()) == 0; });
|
||||
};
|
||||
std::size_t loaded{}, pending{};
|
||||
for (const auto& entry : list.entries) {
|
||||
const bool effect = in_effect(entry.mod.name);
|
||||
if (effect) ++loaded;
|
||||
if (entry.enabled != effect && !excluded(entry.mod.name)) ++pending;
|
||||
if (entry.enabled != effect && !excluded(entry.mod.name) && !set_aside(entry)) ++pending;
|
||||
}
|
||||
// Mods still in effect whose folder has been deleted: applying unloads them.
|
||||
std::vector<std::string> removed;
|
||||
@@ -155,6 +162,10 @@ void draw_installed_mods(SkateMenu& menu) {
|
||||
colour = skate_theme::danger;
|
||||
hover = "Not loaded: it could not be merged cleanly, so none of it is used.\n\n"
|
||||
"Reinstall the whole mod folder, or rebuild it with a current ReSkate Studio.";
|
||||
} else if (set_aside(entry)) {
|
||||
status = "SET ASIDE";
|
||||
hover = "Set aside while a Board Break deck is the one in the game: the game has one deck model, so one "
|
||||
"mod's deck loads at a time. Pick another deck with Board Break deck, or disable Board Break's decks.";
|
||||
} else if (entry.enabled && effect) {
|
||||
status = "LOADED";
|
||||
colour = skate_theme::good;
|
||||
|
||||
@@ -216,6 +216,13 @@ struct RoadRashModel {
|
||||
bool available = false, enabled = false, blood = false;
|
||||
};
|
||||
|
||||
// Board Break's switch (Extension/BoardBreak/board_break.h): started, switched on, and which of
|
||||
// its decks is the one in the game (none: no deck pack loads, and no board can break).
|
||||
struct BoardBreakModel {
|
||||
bool available = false, enabled = false, has_deck = false;
|
||||
std::string deck = "None";
|
||||
};
|
||||
|
||||
struct Model {
|
||||
std::string state = "Waiting for native state";
|
||||
std::string detail;
|
||||
@@ -236,6 +243,7 @@ struct Model {
|
||||
PlayerCardModel player_card;
|
||||
HallOfMeatModel hall_of_meat;
|
||||
RoadRashModel road_rash;
|
||||
BoardBreakModel board_break;
|
||||
ObjectPersistenceModel object_persistence;
|
||||
ParkEditorModel editor;
|
||||
float menu_scale = default_menu_scale;
|
||||
|
||||
@@ -178,6 +178,12 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c
|
||||
if (toggle_row(menu, "Road Rash blood", "Fresh blood on the worst wounds. Off, they are dry and scabbed.", rash_blood,
|
||||
rash_console && model.road_rash.enabled))
|
||||
send_console(menu, callbacks, rash_blood ? "roadrashblood 1" : "roadrashblood 0");
|
||||
bool board_break = model.board_break.enabled;
|
||||
if (toggle_row(menu, "Board Break", model.board_break.has_deck
|
||||
? "A hard slam can snap your board: at the nose, in the middle or at the tail. Whole again once you pick it up."
|
||||
: "A hard slam can snap your board. Needs one of the Board Break deck packs.",
|
||||
board_break, model.board_break.available && callbacks.queue_console_command))
|
||||
send_console(menu, callbacks, board_break ? "boardbreak 1" : "boardbreak 0");
|
||||
end_card();
|
||||
|
||||
begin_card(menu, "boosts", "BOOSTS", "Buttons are set in Settings > Controls");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "Engine/Game/World/client_state.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat.h"
|
||||
#include "Extension/RoadRash/road_rash.h"
|
||||
#include "Extension/BoardBreak/board_break.h"
|
||||
#include "Extension/Skater/camera_observer.h"
|
||||
#include "Extension/UI/NativeMenu/native_menu.h"
|
||||
#include "Extension/Multiplayer/Hud/native_party.h"
|
||||
@@ -791,6 +792,7 @@ void update_model(std::uintptr_t client, TickState& frame) {
|
||||
if (r.observer_failed) return; // Keep the bounded restore/telemetry path available after catalog failure.
|
||||
dingosdk::hall_of_meat::on_client_tick();
|
||||
dingosdk::road_rash::on_client_tick(client);
|
||||
dingosdk::board_break::on_client_tick(client);
|
||||
if (!has_request && now < r.next_model && state == r.previous_state) return;
|
||||
r.next_model = now + 500;
|
||||
DINGO_PROFILE_ZONE("tick/update_model/world model (500 ms)");
|
||||
@@ -930,6 +932,9 @@ void update_model(std::uintptr_t client, TickState& frame) {
|
||||
model.player_card = dingosdk::local_profile_player_card();
|
||||
model.hall_of_meat = {dingosdk::hall_of_meat::available(), dingosdk::hall_of_meat::enabled()};
|
||||
model.road_rash = {dingosdk::road_rash::available(), dingosdk::road_rash::enabled(), dingosdk::road_rash::blood()};
|
||||
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)};
|
||||
model.bindings = dingosdk::local_profile_controller_bindings();
|
||||
model.parks = dingosdk::local_profile_parks();
|
||||
model.world = dingosdk::local_profile_world_layers();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "Extension/Boot/offline_boot.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat.h"
|
||||
#include "Extension/RoadRash/road_rash.h"
|
||||
#include "Extension/BoardBreak/board_break.h"
|
||||
#include "Extension/Skater/camera_observer.h"
|
||||
#include "Extension/Progression/entitlement_request_hook.h"
|
||||
#include "Extension/Skater/skater_observer.h"
|
||||
@@ -413,6 +414,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() {
|
||||
record("{\"event\":\"hall_of_meat_initialized\",\"active\":" + std::string(meat ? "true" : "false") + "}");
|
||||
const bool rash = dingosdk::road_rash::start(r.base);
|
||||
record("{\"event\":\"road_rash_initialized\",\"active\":" + std::string(rash ? "true" : "false") + "}");
|
||||
const bool board_break = dingosdk::board_break::start(r.base);
|
||||
record("{\"event\":\"board_break_initialized\",\"active\":" + std::string(board_break ? "true" : "false") + "}");
|
||||
const bool noclip_velocity = dingosdk::start_client_noclip_velocity(r.base);
|
||||
record("{\"event\":\"noclip_velocity_initialized\",\"active\":" + std::string(noclip_velocity ? "true" : "false") + "}");
|
||||
const bool loading_screens = dingosdk::loading_screen::start(r.base);
|
||||
|
||||
@@ -132,6 +132,8 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/HallOfMeat/hall_of_meat_slow_motion.cpp
|
||||
Extension/RoadRash/road_rash.cpp
|
||||
Extension/RoadRash/road_rash_commands.cpp
|
||||
Extension/BoardBreak/board_break.cpp
|
||||
Extension/BoardBreak/board_break_commands.cpp
|
||||
Extension/Trainer/trainer.cpp
|
||||
Extension/Trainer/trainer_presets.cpp
|
||||
Extension/Trainer/trainer_jump.cpp
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Board Break
|
||||
|
||||
A hard slam can break the local skater's board: at the nose, in the middle or at the tail. Half the
|
||||
time the grip tape holds the pieces together and the board folds or an end hangs; otherwise a piece
|
||||
comes off, flies and lies where it lands, truck and wheels with it. The board is whole again once
|
||||
the skater has picked it up, rides again or respawns. Built into ReSkate; the broken pieces
|
||||
themselves come from content mods, one for each deck shape.
|
||||
|
||||
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)),
|
||||
[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).
|
||||
|
||||
## What the player gets
|
||||
|
||||
- A slam of force 10 or more always breaks the board, a lighter one a quarter of the time, rising
|
||||
in between (the force is the bail cause's that led to the wipeout). Now and then a board gives way
|
||||
while it is ridden: the nose goes.
|
||||
- `boardbreak 0|1`, `boardbreak deck [name|number|next]`, `boardbreak snap [nose|mid|tail]` to look
|
||||
at a break (that one stays until `boardbreak mend` or a respawn). The switch and the deck are in
|
||||
the menus beside Road Rash (Mod Options > Player, and the overlay's Skater page). The switch is
|
||||
saved with the profile.
|
||||
- A break plays back in the replay editor as it happened.
|
||||
- Without a deck pack installed nothing breaks and nothing is changed.
|
||||
|
||||
## How it works
|
||||
|
||||
- **One deck model.** Every board in the game draws the same deck mesh
|
||||
(`characters/skateboard/unlicensed/deck/generic/popsicle/2022/deck_gen_popsicle_mesh`); deck
|
||||
graphics and grip tapes are items on top of it, and an item cannot bring a mesh of its own. So a
|
||||
breakable deck is a content mod that replaces that mesh: the same deck, plus four stretches of it
|
||||
(nose, front, back, tail) cut along three rows of vertices the deck already has, each rigidly
|
||||
skinned to a bone of the board's rig that the game does not use (Trajectory, Connect, Ground, and
|
||||
Connect2 for the tail, which hangs on Connect).
|
||||
- **Only bones move.** `board_render_pose` (the hook multiplayer already has for other players'
|
||||
boards) runs after a board's pose has been handed to the renderer, which is the one moment a
|
||||
change to the pose is still drawn and not yet rewritten: the board's animation writes every bone
|
||||
again every frame. A broken board gets its Deck bone moved 2 km down, the trucks (which hang on
|
||||
it) moved back by as much, and each stretch moved to where its piece lies. The piece that came
|
||||
off is flown by the hook itself, from the board's place at the break: a kick, gravity, the
|
||||
ground, and written relative to wherever the board is in each frame.
|
||||
- **Nothing is scaled.** The first version shrank the deck and scaled the stretches up. The game's
|
||||
replay records where a bone is and how it is turned, and not its size: a shrunk deck played back
|
||||
as a small whole board, and stretches that were scaled up not at all. Moves play back exactly.
|
||||
- **Whole boards hide their stretches too.** The deck is a hollow shell (the wood has no top face,
|
||||
the grip is a sheet over it), and the game draws wood and grip as layers that do not hide what
|
||||
lies just under them: stretches left inside a whole deck showed as cracks through the grip,
|
||||
fainter the deeper they lay and never gone (tried down to 4 mm). So every frame, on every board
|
||||
that passes the hook (the local one, other players'), the stretches of a whole board are moved
|
||||
away like a broken board's deck. The call into Board Break is the last thing the hook does, after
|
||||
another player's pose has been written, so that pose's bones do not bring them back.
|
||||
- **Which deck is in the game** is a question for the mod list, because any mod that reshapes the
|
||||
deck replaces the same mesh and the one higher in the order has it. When the mods are put
|
||||
together, the Board Break deck the player chose goes first, and what would fight it is set
|
||||
aside for the run: the other Board Break decks, and other mods whose bundle lists name the
|
||||
deck's bundle. Those are left out whole (with the breakable deck in place of its own, a wide-deck
|
||||
mod has its trucks and wheels on a deck they were not made for). mods.json is not changed; the
|
||||
mod list shows them as SET ASIDE.
|
||||
- **Another deck without a restart.** The deck mesh is in the items superbundle, which a live apply
|
||||
of the mods already refreshes, and the game reads the mesh when a level loads. Choosing a deck
|
||||
saves the choice, applies the mods and loads the level again (live_mods.h).
|
||||
- **The choice** is a line in `%LOCALAPPDATA%\ReSkate\BoardBreak.deck`, not a profile preference:
|
||||
the mods are put together before the profile is there.
|
||||
- **The sound** is a wave file from the `sounds` folder of an installed deck pack (subfolders
|
||||
`light`, `medium`, `heavy`, by the size of the break), played straight to the speakers.
|
||||
- **The slam's force** comes through No Bail's existing hooks: the cause it records for the watched
|
||||
skater, and the cause collector at the moment the selector chooses Wipeout. Nothing new is hooked.
|
||||
- **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.
|
||||
- Off, the board holds. Without a deck pack in the game the hook returns at once.
|
||||
|
||||
## The deck packs
|
||||
|
||||
A content mod (no code) in a folder named `Board_Break_<Name>`; 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 pack 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
|
||||
triangles are the donor's own, so a shape is made by moving vertices, never by adding any. Keep
|
||||
every vertex where it is along the board: the break rows (z = 0.260, 0.009 and -0.260 m) and the
|
||||
truck bolts stay put.
|
||||
- For each of the four stretches, append a copy of every part's vertices and triangles, skinned to
|
||||
that stretch's bone, with every vertex on the far side of its break rows folded onto the row's
|
||||
cross-section (top skin onto its top line, bottom skin onto its bottom line). That leaves the
|
||||
deck's own edge faces standing in the gap as the broken end. Push the row itself along the board
|
||||
by a jagged line, the same line for both sides of a break, so the pieces fit together.
|
||||
- Build the stretches at their own size, in the place they have in the whole deck.
|
||||
- The standard deck's pack also carries `sounds/light|medium|heavy/*.wav`.
|
||||
|
||||
## Seen working in game
|
||||
|
||||
Offline, on main at f4292d6, with eight deck packs installed: breaks from real slams at the nose,
|
||||
in the middle and at the tail, clean and held by the grip, the board whole again in hand and on
|
||||
respawn; breaks from the console on foot (folded, nose and tail hanging) that stay until mended.
|
||||
The grip side of a whole board, close up in the board menu, shows no cracks. A deck chosen while
|
||||
the game runs is the one in the board menu and in the world after the level has loaded again
|
||||
(about 20 s), and the one the next launch starts with. With a wide-deck mod switched on and first
|
||||
in the order, the breakable deck is still the one in the game and the mod is set aside; so is an
|
||||
older deck mod of Board Break's own. The mod list shows the decks that are set aside as such, with
|
||||
nothing waiting to be applied. The switch on the overlay's Skater page turns it off and on. On the
|
||||
older build the feature was developed on, a middle break played back in the replay editor.
|
||||
|
||||
## Not tried yet
|
||||
|
||||
- Multiplayer: other players' boards are only handled by the code path (their stretches are moved
|
||||
away), not seen; what peers see of a broken board, with and without the packs.
|
||||
- The online route: all of the above ran on the offline profile.
|
||||
- The replay editor on this branch (the replay was checked on the older build the feature was
|
||||
developed on).
|
||||
- The rows in Mod Options > Player in the game itself (their view has a test; the overlay's switch
|
||||
was used).
|
||||
- A level change in the middle of a break.
|
||||
Reference in new issue
Block a user