mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 04:35:54 +02:00
Plugins: Hall of Meat and grind exits are plugin mods now; their C++ is gone
Hall of Meat (contrib/plugins/hall-of-meat) and grind exit triggers (contrib/plugins/grind-exits) are Lua plugins: the bail model, its Meat card, each map's best, the trigger rules, settings, console commands and menu panels are theirs, with their own checks that dingosdk_plugin_tests runs. What has to run in the game stays in core as plugin services: - every physics step of the skater (skater_physics, the physics_step event, permission events.physics); - the skater's x-ray (xray, xray_render, xray_skeleton; reskate.skater.xray and the draw handler's d:skeleton); - skate.'s bottom left HUD corner (hud_corner; reskate.hud.corner); - the game's slow motion, never in a session (game_speed; reskate.time.slow and speed, permission game.speed); - the side of the next pop out of a grind and pad controls kept from the game (grind_exit; reskate.skater.grind_exit, the grind_exit event, reskate.input.hide); - skate.'s own pictures by name (game_images; d:game_image), d:gradient and a font for drawn text. Removed: Extension/HallOfMeat, grind_exit_control and its commands, the HALL OF MEAT and GRIND EXITS add-on pages, home tile, hub and native menu rows, their menu models, and the hallofmeat and grindexit built-in commands.
This commit is contained in:
1 parent
2d3b77701d
commit
db52508bdb
86 files changed
+3041
-3201
No files matched your search
+6
-12
@@ -377,20 +377,14 @@ if(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS)
|
||||
target_include_directories(dingosdk_replay_keyframes_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME replay_keyframes COMMAND dingosdk_replay_keyframes_tests)
|
||||
endif()
|
||||
option(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF)
|
||||
if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS)
|
||||
option(DINGOSDK_BUILD_XRAY_TESTS "Build the skater x-ray's skeleton tests" OFF)
|
||||
if(DINGOSDK_BUILD_XRAY_TESTS)
|
||||
enable_testing()
|
||||
# The bail: its phases, what each hit did, the Meat it scores and the card that shows it.
|
||||
add_executable(dingosdk_hall_of_meat_tests Extension/HallOfMeat/Test/hall_of_meat_tests.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_model.cpp Extension/HallOfMeat/hall_of_meat_card.cpp)
|
||||
target_include_directories(dingosdk_hall_of_meat_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME hall_of_meat COMMAND dingosdk_hall_of_meat_tests)
|
||||
# skate.'s own skeleton mesh, read from the game's data and posed (with a Skate folder).
|
||||
add_executable(dingosdk_hall_of_meat_skeleton_tests Extension/HallOfMeat/Test/hall_of_meat_skeleton_tests.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_skeleton.cpp)
|
||||
target_include_directories(dingosdk_hall_of_meat_skeleton_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_hall_of_meat_skeleton_tests PRIVATE dingosdk_game_archives dingosdk_logging)
|
||||
add_test(NAME hall_of_meat_skeleton COMMAND dingosdk_hall_of_meat_skeleton_tests "${DINGOSDK_TEST_GAME_ROOT}")
|
||||
add_executable(dingosdk_xray_skeleton_tests Extension/Skater/Test/xray_skeleton_tests.cpp Extension/Skater/xray_skeleton.cpp)
|
||||
target_include_directories(dingosdk_xray_skeleton_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_xray_skeleton_tests PRIVATE dingosdk_game_archives dingosdk_logging)
|
||||
add_test(NAME xray_skeleton COMMAND dingosdk_xray_skeleton_tests "${DINGOSDK_TEST_GAME_ROOT}")
|
||||
endif()
|
||||
option(DINGOSDK_BUILD_PLUGIN_TESTS "Build plugin host regression tests" OFF)
|
||||
if(DINGOSDK_BUILD_PLUGIN_TESTS AND WIN32)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
namespace dingosdk::game::build::v20260929::skater_render {
|
||||
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
|
||||
// The local skater as the renderer draws it: the skinning matrices of its mesh and the camera of
|
||||
// the same picture (Extension/HallOfMeat/hall_of_meat_render.h). Read 2026-10-06; the draw packet,
|
||||
// the same picture (Extension/Skater/xray_render.h). Read 2026-10-06; the draw packet,
|
||||
// the render view and the handle chain were first used by gBGYo's ReSkate fork
|
||||
// (github.com/gBGYo/ReSkate).
|
||||
//
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
namespace dingosdk::game::build::v20260929::skater_skeleton {
|
||||
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
|
||||
// The skater's skeleton as skate. itself has it, read from the installed game's data
|
||||
// (Extension/HallOfMeat/hall_of_meat_skeleton.h): the player's render skeleton, and the Dem Bones
|
||||
// (Extension/Skater/xray_skeleton.h): the player's render skeleton, and the Dem Bones
|
||||
// costume, a full-body skeleton outfit skinned to it. Both were first used by gBGYo's ReSkate
|
||||
// fork (github.com/gBGYo/ReSkate); read on 2026-10-06.
|
||||
//
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
namespace dingosdk::game::build::v20260929::skater_state {
|
||||
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
|
||||
// The skater's live state, read-only (Extension/HallOfMeat/hall_of_meat_skater.h).
|
||||
// The skater's live state, read-only (Extension/Skater/skater_physics.h).
|
||||
|
||||
// The physics state the core's selector chose this step (no_bail.h: choose_physics_state; its
|
||||
// offboard_physics_state is the one on foot and through a whole bail).
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
// A skinned mesh's draw packet as the renderer hands it out (Engine/Game/Build/20260929/
|
||||
// skater_render.h): its bones' skinning matrices, packed, and how to place them in the world.
|
||||
// Plain data and math; Extension/HallOfMeat/hall_of_meat_render.h reads it.
|
||||
// Plain data and math; Extension/Skater/xray_render.h reads it.
|
||||
namespace dingosdk::draw_packet {
|
||||
using game::LinearTransform;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
// The skater's body as the physics simulates it (Engine/Game/Build/20260929/skater_body.h):
|
||||
// 24 rigid bodies, the ragdoll, and the contacts of each physics step. Plain data;
|
||||
// Extension/HallOfMeat/hall_of_meat_skater.h reads it.
|
||||
// Extension/Skater/skater_physics.h reads it.
|
||||
//
|
||||
// The bodies by the physics bone id the contact processing reports. The ids follow the
|
||||
// order of the game's physics bone name map (Engine/Game/Build/20260929/skater_body.h). The
|
||||
|
||||
@@ -228,9 +228,7 @@ const Commands &game_commands() {
|
||||
register_perf_commands(*result);
|
||||
register_item_commands(*result);
|
||||
register_trainer_commands(*result);
|
||||
register_hall_of_meat_commands(*result);
|
||||
register_breakable_deck_commands(*result);
|
||||
register_grind_exit_commands(*result);
|
||||
register_replay_keyframes_commands(*result);
|
||||
register_plugin_commands(*result);
|
||||
return result;
|
||||
|
||||
@@ -26,9 +26,7 @@ void register_perf_commands(Commands &);
|
||||
void start_memory_trace_from_environment() noexcept;
|
||||
void register_item_commands(Commands &);
|
||||
void register_trainer_commands(Commands &);
|
||||
void register_hall_of_meat_commands(Commands &);
|
||||
void register_breakable_deck_commands(Commands &);
|
||||
void register_grind_exit_commands(Commands &);
|
||||
void register_replay_keyframes_commands(Commands &);
|
||||
void register_plugin_commands(Commands &);
|
||||
// Runtime adapters. Invoked only by the verified game-thread dispatcher.
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Hall of Meat
|
||||
|
||||
skate. 3's Hall of Meat for skate.: when you bail, the bones you hurt show through your skater,
|
||||
yellow where a hit bruised one and red where one broke it, with the bail's Meat card in the bottom
|
||||
left corner. It ships no game data: the skeleton (the Dem Bones costume's mesh), the card's icons, the
|
||||
THRASHER wordmark and the brush strokes are read from the installed game.
|
||||
|
||||
## What it does
|
||||
|
||||
- **The skeleton.** From the first hurt bone until you are back on your feet, then it fades out. A fall
|
||||
that hurts nothing shows nothing. A fresh hit flashes the bone it hit.
|
||||
- **The card.** It takes the place of skate.'s own HUD in that corner (the d-pad and the score HUD come
|
||||
back after). Time, hits (head hits count double, hits from vehicles half again), broken bones, road
|
||||
rash, airtime, fall, top speed and rotations, each with its points, then the Meat. Small stats join
|
||||
the card once they are worth showing. Each map keeps its best Meat (**NEW BEST** / **BEST 12,345**).
|
||||
- **A break.** The screen's edges pulse red and, in single player only, the game slows down for a
|
||||
moment. The slow motion never runs in a multiplayer session. It is the same at every break, but
|
||||
never faster than the trainer's game speed (PRACTICE), which it gives back after.
|
||||
|
||||
Off until you switch it on: skate.'s menu, **Mod Options > Player > Hall of Meat**, or the console (`~`)
|
||||
with `hallofmeat 0|1`. The choice and each map's best are saved with the profile.
|
||||
|
||||
With No Bail on, a wipeout No Bail stops is not a bail, so nothing shows.
|
||||
|
||||
## How it works
|
||||
|
||||
| Part | Files |
|
||||
|---|---|
|
||||
| Start, client tick, the switch and each map's best | `hall_of_meat.h`, `hall_of_meat.cpp` |
|
||||
| Each physics step of the local skater, handed on by No Bail's skeleton response hook (`Extension/Skater/no_bail.h`) while the feature is on: what every ragdoll body hit and how hard, and whether it is a ragdoll | `hall_of_meat_skater.*` |
|
||||
| The bail's phases (riding, bailing, down, getting up) and its Meat, on the game's clock; no game access | `hall_of_meat_model.*` |
|
||||
| The card's rows | `hall_of_meat_card.*` |
|
||||
| skate.'s skeleton mesh, read from the game's data and posed with the renderer's skinning matrices | `hall_of_meat_skeleton.*`, `hall_of_meat_render.*` |
|
||||
| Hiding skate.'s bottom left HUD while the card shows | `hall_of_meat_hud.*` |
|
||||
| The slow motion: SimulationTime's rate, frame cap and time scale as a session override, only where slower than the speed it found and writing that back after, the local skater's step along with them | `hall_of_meat_slow_motion.*` |
|
||||
| Drawing, on the overlay's background draw list | `hall_of_meat_overlay.*` |
|
||||
| The `hallofmeat` console switch | `hall_of_meat_commands.cpp` |
|
||||
|
||||
Every address, offset and fingerprint is in `Engine/Game/Build/20260929/` (`skater_body.h`,
|
||||
`skater_state.h`, `skater_render.h`, `skater_skeleton.h`, `hud_corner.h`, `ui_model.h`,
|
||||
`ui_textures.h`), and each is checked before use. Without a matching game build the feature stays
|
||||
unavailable and the menu's switch is greyed out.
|
||||
|
||||
Switched off, it costs the game nothing while it plays: the renderer's two hooks and the UI model's
|
||||
write hook are prepared at startup but only switched on with the feature (and off again once the HUD
|
||||
corner and the game's speed are given back), and No Bail hands no physics step on. The skeleton mesh
|
||||
is read the first time it is switched on. Only the images are read at startup, in the background:
|
||||
the overlay builds its atlas once, when its graphics start.
|
||||
|
||||
A finished bail logs what it scored at `--log-level=debug`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cmake --preset vs2022-x64 -DDINGOSDK_BUILD_HALL_OF_MEAT_TESTS=ON -DDINGOSDK_TEST_GAME_ROOT=<Skate folder>
|
||||
cmake --build --preset release --parallel 4
|
||||
ctest --test-dir build/vs2022-x64 -C Release -R hall_of_meat
|
||||
```
|
||||
|
||||
`hall_of_meat` covers the bail's phases, the Meat and the card. `hall_of_meat_skeleton` reads and poses
|
||||
the skeleton mesh from a real Skate folder.
|
||||
|
||||
The skater's render hooks, its skeleton and its body maps were first found by
|
||||
[gBGYo's ReSkate fork](https://github.com/gBGYo/ReSkate).
|
||||
@@ -1,663 +0,0 @@
|
||||
// The Hall of Meat model, with made-up physics steps, and its score card.
|
||||
#include "Extension/HallOfMeat/hall_of_meat_card.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat_model.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat_slow_motion.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace dingosdk;
|
||||
using namespace dingosdk::hall_of_meat;
|
||||
using skater_body::Bone;
|
||||
namespace {
|
||||
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
|
||||
bool near(float a, float b) { return std::abs(a - b) < 1e-4f; }
|
||||
constexpr std::uint64_t t0 = 100000; // GetTickCount64() is never near 0
|
||||
|
||||
// The game stepping its physics at `speed` (1 full speed, break_game_speed in slow motion): each
|
||||
// step as long in the game's time as the real time since the one before, times the speed.
|
||||
struct Game : Tracker {
|
||||
float speed = 1.0f;
|
||||
std::uint64_t last{};
|
||||
bool step(std::uint64_t now, Step step, Summary* ended = nullptr) noexcept {
|
||||
step.seconds = last && now > last ? static_cast<float>(now - last) / 1000.0f * speed : 0.0f;
|
||||
last = now;
|
||||
return Tracker::step(now, step, ended);
|
||||
}
|
||||
};
|
||||
|
||||
// A step of a skater in a ragdoll that touches nothing: a bail lying still.
|
||||
Step lying() {
|
||||
Step step;
|
||||
step.ragdoll = true;
|
||||
return step;
|
||||
}
|
||||
// A step of a skater standing, or riding: no ragdoll.
|
||||
Step standing_up() {
|
||||
Step step;
|
||||
step.ragdoll = false;
|
||||
return step;
|
||||
}
|
||||
// A ragdoll step in which `bone` hits the ground at `speed`.
|
||||
Step hit(Bone bone, float speed, bool wipeout = false) {
|
||||
auto step = lying();
|
||||
step.wipeout = wipeout;
|
||||
auto& contact = step.body.bodies[skater_body::index(bone)];
|
||||
contact.touching = true;
|
||||
contact.impact = speed;
|
||||
contact.hit.world = true;
|
||||
return step;
|
||||
}
|
||||
// The same while riding: no ragdoll yet.
|
||||
Step riding_hit(Bone bone, float speed) {
|
||||
auto step = hit(bone, speed);
|
||||
step.ragdoll = false;
|
||||
return step;
|
||||
}
|
||||
Step wipeout() {
|
||||
auto step = lying();
|
||||
step.wipeout = true;
|
||||
return step;
|
||||
}
|
||||
// A step in the air, hitting nothing: in a ragdoll, or on the board.
|
||||
Step flying(bool ragdoll = true) {
|
||||
Step step;
|
||||
step.airborne = true;
|
||||
step.ragdoll = ragdoll;
|
||||
return step;
|
||||
}
|
||||
// A ragdoll step in which `bone` slides along the ground at `speed`, hitting nothing hard.
|
||||
Step slide(Bone bone, float speed, bool wipeout = false) {
|
||||
auto step = lying();
|
||||
step.wipeout = wipeout;
|
||||
auto& contact = step.body.bodies[skater_body::index(bone)];
|
||||
contact.touching = true;
|
||||
contact.slide = {speed, 0, 0};
|
||||
contact.hit.world = true;
|
||||
return step;
|
||||
}
|
||||
// A ragdoll step at `speed`, touching nothing.
|
||||
Step moving(float speed) {
|
||||
auto step = lying();
|
||||
step.velocity = game::Vec3{speed, 0, 0};
|
||||
return step;
|
||||
}
|
||||
float seconds(std::uint64_t ms) { return static_cast<float>(ms) / 1000.0f; }
|
||||
// Steps `step` from `now` on, every 16 ms, for `ms`; returns the time after the last.
|
||||
std::uint64_t keep(Game& tracker, std::uint64_t now, std::uint64_t ms, const Step& step) {
|
||||
for (const auto until = now + ms; now < until; now += 16) tracker.step(now, step);
|
||||
return now;
|
||||
}
|
||||
|
||||
void impacts_count_only_during_a_bail() {
|
||||
Game tracker;
|
||||
check(!tracker.step(t0, riding_hit(Bone::neck1, 9.0f)), "a step without a bail ends nothing");
|
||||
check(tracker.view(t0).phase == Phase::riding, "nothing shows before a bail");
|
||||
const auto later = t0 + wipeout_after_impact_ms + 16;
|
||||
tracker.step(later, wipeout());
|
||||
check(tracker.phase(later) == Phase::bailing, "a wipeout starts a bail");
|
||||
check(tracker.view(later).injuries[skater_body::index(Bone::neck1)] == Injury::none,
|
||||
"an impact long before the bail is not counted");
|
||||
}
|
||||
|
||||
void the_hit_that_causes_the_wipeout_counts() {
|
||||
// A drop from height: the thigh hits, the wipeout comes a step later.
|
||||
Game tracker;
|
||||
tracker.step(t0, riding_hit(Bone::right_upleg, 35.0f));
|
||||
tracker.step(t0 + 16, riding_hit(Bone::right_upleg, 12.0f));
|
||||
tracker.step(t0 + 32, wipeout());
|
||||
const auto view = tracker.view(t0 + 32);
|
||||
check(view.phase == Phase::bailing && view.injuries[skater_body::index(Bone::right_upleg)] == Injury::broken,
|
||||
"the hit before the wipeout breaks");
|
||||
check(view.tally.impacts == 1 && view.tally.hit_points == hit_points(35.0f), "at its hardest, once");
|
||||
}
|
||||
|
||||
void nothing_shows_until_a_bone_is_hurt() {
|
||||
Game tracker;
|
||||
tracker.step(t0, wipeout());
|
||||
tracker.step(t0 + 16, hit(Bone::spine, hit_speed - 0.5f));
|
||||
check(tracker.phase(t0 + 16) == Phase::bailing && tracker.view(t0 + 16).phase == Phase::riding,
|
||||
"a bail that hurts nothing shows nothing");
|
||||
tracker.step(t0 + 32, hit(Bone::spine, hit_speed));
|
||||
const auto view = tracker.view(t0 + 32);
|
||||
check(view.phase == Phase::bailing && near(view.alpha, 1.0f) && view.tally.impacts == 1,
|
||||
"the first bruise brings the skeleton and the card");
|
||||
Game unhurt;
|
||||
unhurt.step(t0, hit(Bone::spine, hit_speed - 0.5f, true));
|
||||
Summary summary;
|
||||
check(unhurt.step(t0 + 16, standing_up(), &summary) && !summary.shown, "it ends unshown");
|
||||
check(unhurt.view(t0 + 32).phase == Phase::riding, "nor does it get a card");
|
||||
}
|
||||
|
||||
void hard_hits_break_and_light_ones_bruise() {
|
||||
Game tracker;
|
||||
tracker.step(t0, hit(Bone::left_forearm, hit_speed + 0.1f, true));
|
||||
tracker.step(t0 + 16, hit(Bone::neck1, broken_speed + 0.1f));
|
||||
tracker.step(t0 + 32, hit(Bone::spine, hit_speed - 0.1f));
|
||||
tracker.step(t0 + 48, hit(Bone::left_forearm, 1.0f)); // a later, softer hit keeps the worst
|
||||
const auto view = tracker.view(t0 + 48);
|
||||
check(view.injuries[skater_body::index(Bone::left_forearm)] == Injury::hit, "a light hit bruises");
|
||||
check(view.injuries[skater_body::index(Bone::neck1)] == Injury::broken, "a hard hit breaks");
|
||||
check(view.injuries[skater_body::index(Bone::spine)] == Injury::none, "a graze below the threshold leaves no mark");
|
||||
check(view.flashes[skater_body::index(Bone::neck1)] > 0.9f, "a fresh hit flashes");
|
||||
check(tracker.view(t0 + 16 + flash_ms).flashes[skater_body::index(Bone::neck1)] == 0, "the flash fades");
|
||||
}
|
||||
|
||||
void a_flump_starts_no_bail() {
|
||||
// Rolling along the ground and jumping off a height while rolling: a ragdoll slamming the ground,
|
||||
// which the game never wipes out.
|
||||
Game tracker;
|
||||
auto now = keep(tracker, t0, 1500, flying());
|
||||
for (int i = 0; i < 60; ++i, now += 16) {
|
||||
auto step = hit(i % 2 ? Bone::left_arm : Bone::spine, broken_speed + 5.0f);
|
||||
step.velocity = game::Vec3{8, -6, 0};
|
||||
tracker.step(now, step);
|
||||
}
|
||||
check(tracker.view(now).phase == Phase::riding && tracker.view(now).tally.score == 0, "nothing shows, nothing counts");
|
||||
check(!tracker.step(now, standing_up()), "and no bail ends");
|
||||
}
|
||||
|
||||
void a_break_pulses_the_screen_edge() {
|
||||
Game tracker;
|
||||
tracker.step(t0, hit(Bone::left_hand, hit_speed + 1.0f, true));
|
||||
check(tracker.view(t0 + 16).break_pulse == 0.0f, "a bruise does not pulse");
|
||||
tracker.step(t0 + 16, hit(Bone::left_forearm, broken_speed + 1.0f));
|
||||
const auto broke = t0 + 16;
|
||||
check(near(tracker.view(broke + break_rise_ms / 2).break_pulse, 0.5f), "a break pulses up");
|
||||
check(near(tracker.view(broke + break_rise_ms).break_pulse, 1.0f), "to its full strength");
|
||||
check(near(tracker.view(broke + break_rise_ms + break_hold_ms - 16).break_pulse, 1.0f), "holds it");
|
||||
check(near(tracker.view(broke + break_rise_ms + break_hold_ms + break_fall_ms / 2).break_pulse, 0.5f), "and fades");
|
||||
check(tracker.view(broke + break_effect_ms).break_pulse == 0.0f, "then it is gone");
|
||||
tracker.step(broke + break_effect_ms, hit(Bone::left_forearm, broken_speed + 5.0f));
|
||||
check(tracker.view(broke + break_effect_ms + break_rise_ms).break_pulse == 0.0f, "a broken bone hit again does not pulse");
|
||||
tracker.step(broke + break_effect_ms + 16, hit(Bone::right_leg, broken_speed + 1.0f));
|
||||
check(near(tracker.view(broke + break_effect_ms + 16 + break_rise_ms).break_pulse, 1.0f), "the next break pulses again");
|
||||
}
|
||||
|
||||
void a_break_slows_the_game_with_its_red_edge() {
|
||||
Game tracker;
|
||||
check(tracker.game_speed(t0) == 1.0f, "full speed without a bail");
|
||||
tracker.step(t0, hit(Bone::left_hand, hit_speed + 1.0f, true));
|
||||
check(tracker.game_speed(t0 + 16) == 1.0f, "a bruise keeps the speed");
|
||||
tracker.step(t0 + 16, hit(Bone::left_forearm, broken_speed + 1.0f));
|
||||
const auto broke = t0 + 16;
|
||||
check(tracker.game_speed(broke) == 1.0f && near(tracker.game_speed(broke + break_rise_ms), break_game_speed),
|
||||
"a break slows the game down");
|
||||
check(near(tracker.game_speed(broke + break_rise_ms + break_hold_ms - 16), break_game_speed), "holds it slow");
|
||||
check(near(tracker.game_speed(broke + break_rise_ms + break_hold_ms + break_fall_ms / 2), (1.0f + break_game_speed) / 2),
|
||||
"then lets it go");
|
||||
check(tracker.game_speed(broke + break_effect_ms) == 1.0f, "back to full speed as the red edge is gone");
|
||||
for (auto at = broke; at < broke + break_effect_ms; at += 10)
|
||||
check(near(tracker.game_speed(at), 1.0f - (1.0f - break_game_speed) * tracker.view(at).break_pulse),
|
||||
"the slow motion and the red edge are one");
|
||||
// A further break while it lets go slows the game again, with no jump on the way.
|
||||
const auto again = broke + break_rise_ms + break_hold_ms + break_fall_ms / 2;
|
||||
tracker.step(again, hit(Bone::right_leg, broken_speed + 1.0f));
|
||||
float previous = tracker.game_speed(again - 1), steepest = 0;
|
||||
for (auto at = again; at < again + break_rise_ms + 16; ++at) {
|
||||
const float speed = tracker.game_speed(at);
|
||||
steepest = std::max(steepest, std::abs(speed - previous));
|
||||
previous = speed;
|
||||
}
|
||||
check(steepest < 0.02f, "the speed never jumps");
|
||||
check(near(tracker.game_speed(again + break_rise_ms), break_game_speed), "the further break slows it down again");
|
||||
tracker.lose(again + 16);
|
||||
check(tracker.game_speed(again + 32) == 1.0f, "a skater gone takes the slow motion with them");
|
||||
}
|
||||
|
||||
void a_break_just_before_the_bail_counts_from_the_bail() {
|
||||
// The wipeout comes a few steps after the impact that broke the bone: the slow motion and the
|
||||
// red edge run from the step that saw the break, not from the impact.
|
||||
Game tracker;
|
||||
tracker.step(t0, riding_hit(Bone::right_upleg, broken_speed + 5.0f));
|
||||
const auto wiped_out = t0 + wipeout_after_impact_ms - 16;
|
||||
tracker.step(wiped_out, wipeout());
|
||||
check(near(tracker.game_speed(wiped_out + break_rise_ms), break_game_speed), "the slow motion at its full strength");
|
||||
check(near(tracker.view(wiped_out + break_rise_ms).break_pulse, 1.0f), "the red edge is at its full strength");
|
||||
}
|
||||
|
||||
void the_board_and_bad_values_are_ignored() {
|
||||
Game tracker;
|
||||
auto step = wipeout();
|
||||
step.body.bodies[skater_body::index(Bone::board_root)].impact = 20.0f;
|
||||
step.body.bodies[skater_body::index(Bone::hips)].impact = std::nanf("");
|
||||
step.body.bodies[skater_body::index(Bone::spine1)].impact = -8.0f;
|
||||
tracker.step(t0, step);
|
||||
for (const auto injury : tracker.view(t0).injuries) check(injury == Injury::none, "board, NaN and negative peaks hurt nothing");
|
||||
}
|
||||
|
||||
void a_bail_lasts_until_the_skater_gets_up() {
|
||||
Game tracker;
|
||||
tracker.step(t0, hit(Bone::hips, hit_speed + 1.0f, true));
|
||||
// Rolling, then lying however long: the bail goes on, its card with it.
|
||||
auto now = keep(tracker, t0 + 16, 3000, hit(Bone::spine2, 2.0f));
|
||||
now = keep(tracker, now, 6000, lying());
|
||||
tracker.step(now, hit(Bone::neck1, hit_speed + 1.0f)); // a car finds the body on the ground
|
||||
now = keep(tracker, now + 16, 1000, lying());
|
||||
const auto down = tracker.view(now);
|
||||
check(down.phase == Phase::bailing && near(down.alpha, 1.0f) && down.tally.impacts == 2, "the bail and its card go on");
|
||||
Summary summary;
|
||||
check(tracker.step(now, standing_up(), &summary), "standing up ends the bail");
|
||||
check(near(summary.tally.seconds, seconds(now - t0)) && near(summary.peaks[skater_body::index(Bone::hips)], hit_speed + 1.0f) && summary.shown,
|
||||
"the summary holds the duration and the worst hits");
|
||||
const auto lingering = tracker.view(now + linger_ms - 16);
|
||||
check(lingering.phase == Phase::getting_up && near(lingering.alpha, 1.0f), "the skeleton and the card stay a while");
|
||||
const auto fading = tracker.view(now + linger_ms + fade_ms / 2);
|
||||
check(fading.phase == Phase::getting_up && near(fading.alpha, 0.5f), "then fade out together");
|
||||
check(fading.tally.impacts == 2, "with the bail's tally");
|
||||
check(tracker.view(now + linger_ms + fade_ms).phase == Phase::riding, "then they are gone");
|
||||
// The next wipeout starts afresh.
|
||||
const auto later = now + linger_ms + fade_ms + 16;
|
||||
tracker.step(later, wipeout());
|
||||
check(tracker.view(later).injuries[skater_body::index(Bone::hips)] == Injury::none, "a new bail starts clean");
|
||||
}
|
||||
|
||||
void a_rest_ends_the_counting() {
|
||||
Game tracker;
|
||||
tracker.step(t0, hit(Bone::hips, hit_speed + 1.0f, true));
|
||||
auto now = keep(tracker, t0 + 16, 1000, moving(4.0f));
|
||||
const auto went_still = now;
|
||||
now = keep(tracker, now, rest_ms - 16, moving(0.2f));
|
||||
check(tracker.phase(now - 16) == Phase::bailing, "not resting yet");
|
||||
now = keep(tracker, now, 32, moving(0.1f));
|
||||
check(tracker.phase(now) == Phase::down, "a still body rests");
|
||||
check(near(tracker.view(now).tally.seconds, seconds(went_still - t0)), "its time stopped when it went still");
|
||||
auto twitch = hit(Bone::left_hand, hit_speed + 1.0f);
|
||||
twitch.velocity = game::Vec3{0.3f, 0, 0}; // a bone twitches, the body barely moves
|
||||
tracker.step(now, twitch);
|
||||
check(tracker.phase(now) == Phase::down && tracker.view(now).tally.impacts == 1, "a twitch at rest scores nothing");
|
||||
const auto rested = tracker.view(now).tally;
|
||||
const auto later = now + 3000;
|
||||
check(near(tracker.view(later).tally.seconds, seconds(went_still - t0)), "however long it lies");
|
||||
// Getting up starts inside the ragdoll and moves the body fast again: nothing more counts.
|
||||
auto getting_up = hit(Bone::left_foot, hit_speed + 1.0f);
|
||||
getting_up.velocity = game::Vec3{0, 3.0f, 2.0f};
|
||||
now = keep(tracker, later, 600, getting_up);
|
||||
const auto final = tracker.view(now).tally;
|
||||
check(tracker.phase(now) == Phase::down && final.score == rested.score && final.impacts == rested.impacts &&
|
||||
near(final.seconds, rested.seconds) && near(final.top_speed, rested.top_speed),
|
||||
"getting up adds no time and no points");
|
||||
Summary summary;
|
||||
tracker.step(now, standing_up(), &summary);
|
||||
check(summary.tally.score == rested.score && near(summary.tally.seconds, seconds(went_still - t0)),
|
||||
"the bail ends as it was when the body came to rest");
|
||||
check(tracker.view(now + linger_ms + fade_ms / 2).tally.score == rested.score, "and fades out so");
|
||||
Game unknown;
|
||||
unknown.step(t0, hit(Bone::hips, hit_speed + 1.0f, true));
|
||||
check(unknown.phase(keep(unknown, t0 + 16, 2000, lying())) == Phase::bailing, "an unknown speed never rests");
|
||||
}
|
||||
|
||||
void a_fall_from_height_bails_at_its_wipeout() {
|
||||
// Thrown off the board from height: the ragdoll begins in the air, the wipeout comes with the impact.
|
||||
Game tracker;
|
||||
auto now = keep(tracker, t0, 500, flying(false));
|
||||
now = keep(tracker, now, 2000, flying());
|
||||
check(tracker.phase(now) == Phase::riding, "a ragdoll in the air is no bail yet");
|
||||
tracker.step(now, hit(Bone::neck1, 20.0f, true));
|
||||
check(tracker.view(now).phase == Phase::bailing && tracker.view(now).tally.impacts == 1, "the wipeout starts it with the impact");
|
||||
check(std::abs(tracker.view(now).tally.airtime - 2.5f) < 0.02f, "and the whole flight's airtime");
|
||||
now = keep(tracker, now + 16, 1000, lying());
|
||||
Summary summary;
|
||||
check(tracker.step(now, standing_up(), &summary) && summary.tally.impacts == 1, "and ends when the skater stands up");
|
||||
}
|
||||
|
||||
void a_stumble_without_a_ragdoll_ends() {
|
||||
Game tracker;
|
||||
auto stumble = wipeout();
|
||||
stumble.ragdoll = false;
|
||||
tracker.step(t0, stumble);
|
||||
const auto now = keep(tracker, t0 + 16, ragdoll_wait_ms - 32, standing_up());
|
||||
check(tracker.phase(now) == Phase::bailing, "the ragdoll is given a moment to begin");
|
||||
check(tracker.step(t0 + ragdoll_wait_ms, standing_up()), "then the bail ends");
|
||||
}
|
||||
|
||||
void an_unknown_skater_state_ends_nothing() {
|
||||
Game tracker;
|
||||
tracker.step(t0, hit(Bone::hips, hit_speed + 1.0f, true));
|
||||
const auto now = keep(tracker, t0 + 16, 2000, Step{}); // the skater state could not be read
|
||||
check(tracker.phase(now) == Phase::bailing, "the bail goes on");
|
||||
check(tracker.step(now, standing_up()), "until the skater is seen standing");
|
||||
Game riding;
|
||||
riding.step(t0, Step{});
|
||||
check(riding.phase(t0) == Phase::riding, "nor does it start one");
|
||||
}
|
||||
|
||||
void a_lost_skater_ends_the_bail() {
|
||||
Game tracker;
|
||||
check(!tracker.lose(t0), "no bail, nothing to end");
|
||||
tracker.step(t0, hit(Bone::hips, hit_speed + 1.0f, true));
|
||||
tracker.step(keep(tracker, t0 + 16, 480, lying()) + 4, lying()); // the last step at t0 + 500
|
||||
Summary summary;
|
||||
check(tracker.lose(t0 + 500, &summary) && near(summary.tally.seconds, 0.5f) && summary.shown, "a respawn ends the bail");
|
||||
check(tracker.view(t0 + 500).phase == Phase::getting_up, "and it fades out");
|
||||
check(!tracker.lose(t0 + 516), "once");
|
||||
// A teleport in mid-air: the flight before it is not the next bail's.
|
||||
Game teleported;
|
||||
const auto now = keep(teleported, t0, 1000, flying(false));
|
||||
teleported.lose(now);
|
||||
teleported.step(now + 16, hit(Bone::hips, hit_speed + 1.0f, true));
|
||||
check(teleported.view(now + 16).tally.airtime == 0, "a flight before a teleport is forgotten");
|
||||
}
|
||||
|
||||
void a_reading_clock_behind_the_physics_one_is_harmless() {
|
||||
Game tracker;
|
||||
tracker.step(t0, hit(Bone::neck, hit_speed + 0.5f, true));
|
||||
const auto view = tracker.view(t0 - 5);
|
||||
check(view.phase == Phase::bailing && near(view.alpha, 1.0f) && near(view.flashes[skater_body::index(Bone::neck)], 1.0f),
|
||||
"a view a moment before the step still shows it");
|
||||
}
|
||||
|
||||
void one_contact_is_one_impact() {
|
||||
Game tracker;
|
||||
constexpr float light = hit_speed + 0.5f, harder = broken_speed - 1.0f;
|
||||
tracker.step(t0, hit(Bone::spine, light, true));
|
||||
tracker.step(t0 + 16, hit(Bone::spine, harder)); // the same contact, harder
|
||||
tracker.step(t0 + 32, hit(Bone::spine, 2.0f)); // easing off: no hit
|
||||
auto tally = tracker.view(t0 + 32).tally;
|
||||
check(tally.impacts == 1 && tally.damage == hit_points(harder), "a contact counts once, as its hardest step");
|
||||
tracker.step(t0 + 32 + impact_gap_ms + 16, hit(Bone::spine, light)); // hitting the ground again
|
||||
tally = tracker.view(t0 + 32 + impact_gap_ms + 16).tally;
|
||||
check(tally.impacts == 2 && tally.damage == hit_points(harder) + hit_points(light), "a new contact is a new impact");
|
||||
check(hit_points(hit_speed - 0.1f) == 0 && hit_points(hit_speed) == 100, "only hits score, the lightest 100");
|
||||
check(hit_points(30.0f) == 887, "a slam scores more than its speed, less than its energy");
|
||||
}
|
||||
|
||||
void the_meat_adds_up() {
|
||||
Game tracker;
|
||||
constexpr float breaks = broken_speed + 1.0f, bruises = hit_speed + 1.0f;
|
||||
tracker.step(t0, hit(Bone::neck1, breaks, true)); // the head breaks
|
||||
tracker.step(t0 + 16, hit(Bone::left_hand, bruises)); // a hand is bruised
|
||||
const auto tally = tracker.view(t0 + 16).tally;
|
||||
check(tally.broken == 1 && tally.impacts == 2, "one bone broken, two hits");
|
||||
check(tally.hit_points == hit_points(breaks) + hit_points(bruises) && tally.head_bonus == hit_points(breaks),
|
||||
"a hit to the head counts double");
|
||||
check(tally.score == 2 * hit_points(breaks) + hit_points(bruises) + points_per_break + tally.time_points,
|
||||
"the Meat is the hits, the breaks and the time");
|
||||
}
|
||||
|
||||
void a_vehicle_adds_half_again() {
|
||||
Game tracker;
|
||||
auto car = hit(Bone::spine, 10.0f, true);
|
||||
car.body.bodies[skater_body::index(Bone::spine)].hit.vehicle = true;
|
||||
tracker.step(t0, car);
|
||||
tracker.step(t0 + 16, hit(Bone::spine, 12.0f)); // the same contact, harder: still the car's
|
||||
const auto tally = tracker.view(t0 + 16).tally;
|
||||
check(tally.impacts == 1 && tally.vehicle_bonus == static_cast<int>(hit_points(12.0f) * 0.5f + 0.5f), "half again");
|
||||
check(impact_points({skater_body::index(Bone::neck1), 10.0f, true}) ==
|
||||
hit_points(10.0f) * 2 + static_cast<int>(hit_points(10.0f) * 0.5f + 0.5f),
|
||||
"the head and a vehicle together");
|
||||
}
|
||||
|
||||
void sliding_along_the_ground_is_road_rash() {
|
||||
Game tracker;
|
||||
tracker.step(t0, slide(Bone::hips, 5.0f, true));
|
||||
auto now = t0;
|
||||
for (int i = 0; i < 50; ++i) tracker.step(now += 20, slide(Bone::hips, 5.0f)); // a second at 5 m/s
|
||||
const auto view = tracker.view(now);
|
||||
check(view.phase == Phase::bailing, "a sliding bail goes on");
|
||||
check(std::abs(view.tally.scraped - 5.0f) < 1e-3f && view.tally.scrape_points == static_cast<int>(5.0f * points_per_scraped_metre + 0.5f), "5 m of road rash");
|
||||
check(view.injuries[skater_body::index(Bone::hips)] == Injury::hit, "it bruises and shows");
|
||||
check(view.tally.score == view.tally.scrape_points + view.tally.time_points && view.tally.impacts == 0, "it scores without a hit");
|
||||
|
||||
// The whole body slides as one: the road rash is how far it went, not the bodies' sum.
|
||||
Game body;
|
||||
auto all = slide(Bone::hips, 4.0f, true);
|
||||
for (const auto bone : {Bone::spine, Bone::spine1, Bone::left_upleg})
|
||||
all.body.bodies[skater_body::index(bone)] = all.body.bodies[skater_body::index(Bone::hips)];
|
||||
all.body.bodies[skater_body::index(Bone::left_hand)] = slide(Bone::left_hand, 8.0f).body.bodies[skater_body::index(Bone::left_hand)];
|
||||
body.step(t0, all);
|
||||
all.wipeout = false;
|
||||
for (now = t0; now < t0 + 1000;) body.step(now += 20, all);
|
||||
const auto tally = body.view(now).tally;
|
||||
check(std::abs(tally.scraped - 4.8f) < 1e-3f, "the average of the scraping bodies' slides, over a second");
|
||||
|
||||
Game board;
|
||||
auto on_board = slide(Bone::spine, 5.0f, true);
|
||||
auto& contact = on_board.body.bodies[skater_body::index(Bone::spine)];
|
||||
contact.hit = {};
|
||||
contact.hit.board = true;
|
||||
board.step(t0, on_board);
|
||||
board.step(t0 + 1000, on_board);
|
||||
board.step(t0 + 1020, slide(Bone::left_foot, 5.0f));
|
||||
board.step(t0 + 1040, slide(Bone::spine, scrape_speed - 0.1f));
|
||||
check(board.view(t0 + 1040).tally.scraped == 0.0f, "the board, the feet and a slow slip do not scrape");
|
||||
}
|
||||
|
||||
void the_view_follows_the_bail() {
|
||||
Game tracker;
|
||||
check(tracker.phase(t0) == Phase::riding, "riding");
|
||||
tracker.step(t0, hit(Bone::left_hand, hit_speed + 1.0f, true));
|
||||
tracker.step(keep(tracker, t0 + 16, 480, lying()) + 4, lying()); // the last step at t0 + 500
|
||||
auto view = tracker.view(t0 + 500);
|
||||
check(view.phase == Phase::bailing && near(view.tally.seconds, 0.5f), "bailing");
|
||||
check(view.injuries[skater_body::index(Bone::left_hand)] == Injury::hit && view.tally.impacts == 1, "the hand's hit");
|
||||
const auto now = keep(tracker, t0 + 516, 1000, lying());
|
||||
tracker.step(now, standing_up());
|
||||
view = tracker.view(now + 100);
|
||||
check(view.phase == Phase::getting_up && near(view.tally.seconds, seconds(now - t0)), "getting up, with how long the bail lasted");
|
||||
check(tracker.phase(now + linger_ms + fade_ms) == Phase::riding, "then riding again");
|
||||
}
|
||||
|
||||
void airtime_is_the_bails_time_in_the_air() {
|
||||
// A fall from height on the board: 4 s in the air, the wipeout a step after the impact.
|
||||
Game tracker;
|
||||
std::uint64_t now = t0;
|
||||
for (; now <= t0 + 4000; now += 16) tracker.step(now, flying(false));
|
||||
tracker.step(now, standing_up()); // the touch-down
|
||||
tracker.step(now + 16, hit(Bone::hips, hit_speed, true)); // the impact's wipeout
|
||||
tracker.step(now + 32, flying()); // the ragdoll bounces: +16 ms
|
||||
tracker.step(now + 48, lying()); // and lies
|
||||
check(std::abs(tracker.view(now + 48).tally.airtime - (4000 + 16) / 1000.0f) < 1e-4f,
|
||||
"the flight before the wipeout and the bail's own flights count");
|
||||
const auto air = tracker.view(now + 48).tally;
|
||||
check(air.airtime_points == static_cast<int>(air.airtime * points_per_air_second + 0.5f), "airtime scores by the second");
|
||||
|
||||
// A clean landing, then a bail later on the ground: that flight is not the bail's.
|
||||
Game later;
|
||||
for (now = t0; now <= t0 + 1000; now += 16) later.step(now, flying(false));
|
||||
later.step(now, standing_up());
|
||||
later.step(now + wipeout_after_impact_ms + 16, hit(Bone::hips, hit_speed, true));
|
||||
check(later.view(now + wipeout_after_impact_ms + 16).tally.airtime == 0, "a bail on the ground has no airtime");
|
||||
}
|
||||
|
||||
void a_bail_lasts_through_its_flights() {
|
||||
// The first impact throws the body off a ledge: seconds in the air, then the second one.
|
||||
Game tracker;
|
||||
auto now = keep(tracker, t0, 1000, flying(false));
|
||||
tracker.step(now, hit(Bone::hips, broken_speed + 1.0f, true));
|
||||
const auto thrown = now;
|
||||
now = keep(tracker, now + 16, 3000, flying());
|
||||
check(tracker.view(now).phase == Phase::bailing, "a body in the air is still bailing");
|
||||
tracker.step(now, hit(Bone::neck1, broken_speed + 1.0f, true)); // the second impact, wiping out again
|
||||
const auto tally = tracker.view(now).tally;
|
||||
check(tracker.view(now).phase == Phase::bailing && tally.impacts == 2 && tally.broken == 2, "the same bail, both impacts");
|
||||
check(std::abs(tally.airtime - static_cast<float>(1000 + now - thrown - 16) / 1000.0f) < 0.02f,
|
||||
"the flight before the bail and the one inside it add up");
|
||||
Summary summary;
|
||||
tracker.step(now + 16, standing_up(), &summary);
|
||||
check(summary.tally.impacts == 2 && std::abs(summary.tally.airtime - tally.airtime) < 1e-4f,
|
||||
"the airtime holds until the bail is over");
|
||||
}
|
||||
|
||||
void the_slow_motion_keeps_the_steps() {
|
||||
check(slow_motion_rate(60, 1.0f) == 60, "full speed keeps the rate");
|
||||
check(slow_motion_rate(60, break_game_speed) == 200, "a break's slow motion steps more often: 200 a second of game time is 60 a real one");
|
||||
check(slow_motion_rate(60, 0.5f) == 120, "half speed doubles it");
|
||||
check(slow_motion_rate(60, 2.0f) == 60, "never faster than the game's own");
|
||||
check(slow_motion_rate(60, 0.0f) == highest_simulation_rate && slow_motion_rate(60, -1.0f) == highest_simulation_rate,
|
||||
"a stop is the slowest it can follow");
|
||||
check(slow_motion_rate(60, std::numeric_limits<float>::quiet_NaN()) == 60, "a broken speed is full speed");
|
||||
// Every break runs the same speeds, but never faster than the game ran (the trainer's game speed).
|
||||
check(slow_motion_time_scale(1.0f, break_game_speed) == break_game_speed, "at the game's own speed, the break's");
|
||||
check(slow_motion_time_scale(0.5f, break_game_speed) == break_game_speed, "with the trainer at half speed, the same");
|
||||
check(!slow_motion_time_scale(0.5f, 0.7f), "but not where the curve is faster than the trainer");
|
||||
check(!slow_motion_time_scale(0.1f, break_game_speed), "a trainer slower than the break is never sped up");
|
||||
check(!slow_motion_time_scale(0.0f, break_game_speed), "a paused game stays paused");
|
||||
check(!slow_motion_time_scale(1.0f, 1.0f) && !slow_motion_time_scale(1.0f, 2.0f), "full speed is none");
|
||||
check(slow_motion_time_scale(1.0f, 0.0f) == slowest_speed, "no slower than the steps can follow");
|
||||
check(!slow_motion_time_scale(1.0f, std::numeric_limits<float>::quiet_NaN()), "a broken speed is none");
|
||||
}
|
||||
|
||||
void the_card_shows_the_bail() {
|
||||
check(grouped(0) == "0" && grouped(999) == "999" && grouped(1000) == "1,000" && grouped(1234567) == "1,234,567",
|
||||
"thousands grouped");
|
||||
check(grouped(-1234) == "-1,234", "a negative number too");
|
||||
Game tracker;
|
||||
check(card(tracker.view(t0), {}).opacity == 0 && card(tracker.view(t0), {}).rows.empty(), "no card while riding");
|
||||
tracker.step(t0, hit(Bone::neck1, broken_speed + 1.0f, true)); // the head breaks
|
||||
tracker.step(t0 + 16, hit(Bone::left_hand, hit_speed + 1.0f));
|
||||
tracker.step(keep(tracker, t0 + 32, 1456, lying()) + 12, lying()); // the last step at t0 + 1500
|
||||
const auto view = tracker.view(t0 + 1500);
|
||||
auto shown = card(view, standing(0, view.tally.score));
|
||||
check(near(shown.opacity, 1.0f) && shown.total == view.tally.score, "the Meat, every stat counted");
|
||||
check(shown.rows.size() == 1 && shown.rows[0].stat == Stat::time && shown.rows[0].value == "1.5 s" &&
|
||||
shown.rows[0].points == static_cast<int>(1.5f * points_per_second + 0.5f),
|
||||
"a small bail shows its time alone");
|
||||
check(shown.badge == "NEW BEST" && shown.highlight, "the map's first bail sets its best");
|
||||
shown = card(view, standing(99999, view.tally.score));
|
||||
check(shown.badge == "BEST 99,999" && !shown.highlight, "a smaller one shows the best");
|
||||
check(card(view, {}).badge.empty(), "an unknown best shows none");
|
||||
tracker.step(t0 + 1500, standing_up());
|
||||
check(near(card(tracker.view(t0 + 1500 + linger_ms / 2), {}).opacity, 1.0f), "the card stays once the skater gets up");
|
||||
check(near(card(tracker.view(t0 + 1500 + linger_ms + fade_ms / 2), {}).opacity, 0.5f), "then fades");
|
||||
|
||||
// A big one: off a roof for 3.5 s, five bones broken at 20 m/s, a slide of 5 m.
|
||||
Game big;
|
||||
auto falling = flying(false);
|
||||
falling.velocity = game::Vec3{0, -10, 0};
|
||||
auto now = keep(big, t0, 3500, falling);
|
||||
for (const auto bone : {Bone::hips, Bone::spine, Bone::left_leg, Bone::right_leg, Bone::left_arm}) {
|
||||
auto impact = hit(bone, broken_speed + 1.0f, true);
|
||||
impact.velocity = game::Vec3{20, 0, 0};
|
||||
big.step(now += 16, impact);
|
||||
}
|
||||
auto sliding = slide(Bone::spine1, 5.0f);
|
||||
sliding.velocity = game::Vec3{5, 0, 0};
|
||||
sliding.spin = 3.0f * radians_per_rotation; // tumbling over three times a second
|
||||
now = keep(big, now + 16, 1000, sliding);
|
||||
shown = card(big.view(now), {});
|
||||
std::vector<Stat> stats;
|
||||
for (const auto& row : shown.rows) stats.push_back(row.stat);
|
||||
check(stats == std::vector<Stat>{Stat::time, Stat::hits, Stat::broken, Stat::road_rash, Stat::airtime, Stat::fall, Stat::speed,
|
||||
Stat::rotations},
|
||||
"a big bail shows every stat, in its order");
|
||||
check(shown.rows[2].value == "5 broken" && shown.rows[2].points == 5 * points_per_break, "the broken bones");
|
||||
check(shown.rows[6].value == "44.7 MPH" && shown.rows[6].points == static_cast<int>(20.0f * points_per_speed + 0.5f),
|
||||
"the top speed, in miles per hour");
|
||||
check(shown.rows[7].value == "3.0 rotations", "the rotations");
|
||||
}
|
||||
|
||||
void the_fall_and_the_top_speed_score() {
|
||||
// Off a roof on the board: falling at 10 m/s for 2 s, then the impact's wipeout at 20 m/s.
|
||||
Game tracker;
|
||||
auto falling = flying(false);
|
||||
falling.velocity = game::Vec3{3, -10, 0};
|
||||
auto now = keep(tracker, t0, 2000, falling);
|
||||
auto impact = hit(Bone::hips, hit_speed + 2.0f, true);
|
||||
impact.velocity = game::Vec3{12, -16, 0};
|
||||
tracker.step(now, impact);
|
||||
auto rolling = lying();
|
||||
rolling.velocity = game::Vec3{0, 2, 4}; // rolling on, bouncing up: it goes no further down
|
||||
now = keep(tracker, now + 16, 500, rolling);
|
||||
const auto t = tracker.view(now).tally;
|
||||
check(std::abs(t.fallen - 19.84f) < 0.01f, "the flight's fall is the bail's");
|
||||
check(t.fall_points == static_cast<int>(t.fallen * points_per_metre_fallen + 0.5f), "a metre fallen scores");
|
||||
check(near(t.top_speed, 20.0f) && t.speed_points == static_cast<int>(20.0f * points_per_speed + 0.5f), "the top speed scores");
|
||||
check(t.score == t.damage + t.broken * points_per_break + t.time_points + t.airtime_points + t.fall_points +
|
||||
t.speed_points + t.rotation_points,
|
||||
"the Meat is every stat's points");
|
||||
}
|
||||
|
||||
void the_body_turning_over_scores() {
|
||||
Game tracker;
|
||||
auto spinning = hit(Bone::hips, hit_speed + 1.0f, true);
|
||||
spinning.spin = -1.0f; // not a speed: nothing
|
||||
tracker.step(t0, spinning);
|
||||
auto tumbling = moving(4.0f);
|
||||
tumbling.spin = 2.0f * radians_per_rotation; // twice a second, any way round
|
||||
auto now = keep(tracker, t0 + 16, 1504, tumbling);
|
||||
auto t = tracker.view(now).tally;
|
||||
check(std::abs(t.rotations - 3.0f) < 0.01f, "three rotations in a second and a half");
|
||||
check(t.rotation_points == static_cast<int>(t.rotations * points_per_rotation + 0.5f), "a rotation scores");
|
||||
auto lying_turning = moving(0.1f);
|
||||
lying_turning.spin = radians_per_rotation; // lying still: the body comes to rest, whatever turns
|
||||
now = keep(tracker, now, rest_ms + 32, lying_turning);
|
||||
check(tracker.phase(now) == Phase::down, "the body rests");
|
||||
const auto rested = tracker.view(now).tally.rotations;
|
||||
now = keep(tracker, now, 1000, tumbling); // getting up
|
||||
check(near(tracker.view(now).tally.rotations, rested), "after the rest nothing turns any more");
|
||||
}
|
||||
|
||||
void a_bail_runs_on_the_games_clock() {
|
||||
// A break slows the game down: its physics steps as often each real second as before, each the
|
||||
// shorter. In play on 2026-10-08 the ragdoll came later than ragdoll_wait_ms in real time, so the
|
||||
// bail ended in mid-air and the next impact began another: the bones hurt before were gone.
|
||||
Game slow;
|
||||
slow.speed = break_game_speed;
|
||||
auto thrown = hit(Bone::left_leg, hit_speed + 1.0f, true);
|
||||
thrown.ragdoll = false; // the bail's first part: off the board, the ragdoll still to come
|
||||
slow.step(t0, thrown);
|
||||
auto falling = flying(false);
|
||||
falling.velocity = game::Vec3{0, -10, 0};
|
||||
auto now = keep(slow, t0 + 16, 1000, falling); // a real second, under a third of the game's
|
||||
check(slow.phase(now) == Phase::bailing, "the ragdoll still has the game's moment to begin");
|
||||
falling.ragdoll = true;
|
||||
now = keep(slow, now, 1000, falling);
|
||||
slow.step(now, hit(Bone::neck1, broken_speed + 1.0f, true)); // a further impact, wiping out again
|
||||
const auto view = slow.view(now);
|
||||
check(view.phase == Phase::bailing && near(view.alpha, 1.0f) && view.tally.impacts == 2 &&
|
||||
view.injuries[skater_body::index(Bone::left_leg)] == Injury::hit,
|
||||
"one bail, the skeleton whole: the leg bruised before is still bruised");
|
||||
check(std::abs(view.tally.airtime - 0.6f) < 0.01f && std::abs(view.tally.fallen - 6.0f) < 0.1f,
|
||||
"the airtime and the fall are the game's: two real seconds at 0.3");
|
||||
}
|
||||
|
||||
void a_bail_sets_a_best_only_by_beating_it() {
|
||||
check(standing(0, 300).new_best && standing(0, 300).best == 300, "the first bail on a map sets its best");
|
||||
check(!standing(500, 300).new_best && standing(500, 300).best == 500, "a smaller bail keeps it");
|
||||
check(!standing(500, 500).new_best, "matching it does not beat it");
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
impacts_count_only_during_a_bail();
|
||||
the_hit_that_causes_the_wipeout_counts();
|
||||
nothing_shows_until_a_bone_is_hurt();
|
||||
hard_hits_break_and_light_ones_bruise();
|
||||
a_break_pulses_the_screen_edge();
|
||||
a_break_slows_the_game_with_its_red_edge();
|
||||
a_break_just_before_the_bail_counts_from_the_bail();
|
||||
a_flump_starts_no_bail();
|
||||
the_board_and_bad_values_are_ignored();
|
||||
a_bail_lasts_until_the_skater_gets_up();
|
||||
a_rest_ends_the_counting();
|
||||
a_fall_from_height_bails_at_its_wipeout();
|
||||
a_stumble_without_a_ragdoll_ends();
|
||||
an_unknown_skater_state_ends_nothing();
|
||||
a_lost_skater_ends_the_bail();
|
||||
a_reading_clock_behind_the_physics_one_is_harmless();
|
||||
one_contact_is_one_impact();
|
||||
the_meat_adds_up();
|
||||
a_vehicle_adds_half_again();
|
||||
sliding_along_the_ground_is_road_rash();
|
||||
the_view_follows_the_bail();
|
||||
airtime_is_the_bails_time_in_the_air();
|
||||
a_bail_lasts_through_its_flights();
|
||||
the_fall_and_the_top_speed_score();
|
||||
the_body_turning_over_scores();
|
||||
a_bail_runs_on_the_games_clock();
|
||||
a_bail_sets_a_best_only_by_beating_it();
|
||||
the_card_shows_the_bail();
|
||||
the_slow_motion_keeps_the_steps();
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "FAILED: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
std::cout << "Hall of Meat tests passed.\n";
|
||||
return 0;
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
#include "hall_of_meat.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "hall_of_meat_hud.h"
|
||||
#include "hall_of_meat_overlay.h"
|
||||
#include "hall_of_meat_render.h"
|
||||
#include "hall_of_meat_skater.h"
|
||||
#include "hall_of_meat_skeleton.h"
|
||||
#include "hall_of_meat_slow_motion.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Extension/Profile/local_profile_runtime.h"
|
||||
#include "Extension/Skater/no_bail.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace {
|
||||
constexpr const char* preference = "HallOfMeat";
|
||||
constexpr std::string_view best_prefix = "HallOfMeat.Best."; // + the level, lower case
|
||||
|
||||
struct State {
|
||||
std::atomic<bool> ready{}, on{}; // the switch: started, and switched on
|
||||
SRWLOCK lock = SRWLOCK_INIT; // guards the rest; never held across game reads
|
||||
Tracker tracker;
|
||||
bool summary_pending{};
|
||||
Summary summary;
|
||||
// The current map's best Meat (known once the client tick named the map), where the last
|
||||
// finished bail that showed stands against it, and whether a new best is still to be saved.
|
||||
bool best_known{}, best_unsaved{};
|
||||
int best{};
|
||||
Standing standing;
|
||||
std::string level; // client thread only
|
||||
SlowMotion slow_motion; // client thread only: after a break
|
||||
bool active{}; // client thread only: the hooks on, as the switch is or until what it changed is given back
|
||||
};
|
||||
State& state() { static auto* value = new State; return *value; }
|
||||
|
||||
// A bail is over (under the lock): it is logged on the client tick, and stands against the map's best.
|
||||
void finished(State& s, const Summary& ended) noexcept {
|
||||
s.summary = ended;
|
||||
s.summary_pending = true;
|
||||
s.standing = ended.shown && s.best_known ? standing(s.best, ended.tally.score) : Standing{};
|
||||
if (s.standing.new_best) {
|
||||
s.best = s.standing.best;
|
||||
s.best_unsaved = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Physics thread, every step of the local skater (no_bail.h): what each body touched in the step
|
||||
// and whether it is a ragdoll, and so where the bail is and what it did.
|
||||
void observe_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept {
|
||||
if (!enabled()) return;
|
||||
auto& s = state();
|
||||
const auto now = GetTickCount64();
|
||||
const auto step = read_step(skater, seconds, wipeout);
|
||||
Summary ended;
|
||||
AcquireSRWLockExclusive(&s.lock);
|
||||
if (s.tracker.step(now, step, &ended)) finished(s, ended);
|
||||
ReleaseSRWLockExclusive(&s.lock);
|
||||
}
|
||||
|
||||
// One debug line per bail: what it scored and why.
|
||||
void log_bail(const Summary& summary) {
|
||||
std::vector<std::size_t> order;
|
||||
for (std::size_t index = 1; index < skater_body::count; ++index)
|
||||
if (summary.peaks[index] > 0) order.push_back(index);
|
||||
std::sort(order.begin(), order.end(), [&](auto a, auto b) { return summary.peaks[a] > summary.peaks[b]; });
|
||||
std::string hits;
|
||||
for (const auto index : order) {
|
||||
const float peak = summary.peaks[index];
|
||||
if (peak < hit_speed && !hits.empty()) break;
|
||||
hits += std::format("{}{} {:.1f} m/s{}", hits.empty() ? "" : ", ", skater_body::names[index], peak,
|
||||
peak >= broken_speed ? " (broken)" : peak >= hit_speed ? " (hit)" : " (hardest, not hurt)");
|
||||
}
|
||||
std::string scrapes;
|
||||
for (std::size_t index = 1; index < skater_body::count; ++index)
|
||||
if (summary.scraped[index] >= 0.05f)
|
||||
scrapes += std::format("{}{} {:.1f} m", scrapes.empty() ? "" : ", ", skater_body::names[index], summary.scraped[index]);
|
||||
const auto& tally = summary.tally;
|
||||
logging::log(logging::Level::debug, logging::Channel::skater,
|
||||
"Hall of Meat: bail over, {} Meat ({} damage: {} from {} impacts, head +{}, vehicle +{}, road rash {:.1f} m +{}; "
|
||||
"{} broken +{}; {:.1f} s +{}; {:.1f} s airtime +{}; fell {:.1f} m +{}; top speed {:.1f} m/s +{}; {:.1f} rotations +{}). "
|
||||
"Hits: {}. Road rash: {}",
|
||||
tally.score, tally.damage, tally.hit_points, tally.impacts, tally.head_bonus, tally.vehicle_bonus,
|
||||
static_cast<double>(tally.scraped), tally.scrape_points, tally.broken, tally.broken * points_per_break,
|
||||
static_cast<double>(tally.seconds), tally.time_points, static_cast<double>(tally.airtime), tally.airtime_points,
|
||||
static_cast<double>(tally.fallen), tally.fall_points, static_cast<double>(tally.top_speed), tally.speed_points,
|
||||
static_cast<double>(tally.rotations), tally.rotation_points,
|
||||
hits.empty() ? std::string("no body contact") : hits, scrapes.empty() ? std::string("none") : scrapes);
|
||||
}
|
||||
|
||||
std::string best_key(std::string_view level) {
|
||||
std::string key(best_prefix);
|
||||
for (const char c : level) key += c >= 'A' && c <= 'Z' ? static_cast<char>(c - 'A' + 'a') : c;
|
||||
return key;
|
||||
}
|
||||
int saved_best(std::string_view level) noexcept {
|
||||
const auto value = profile_runtime::local_value(best_key(level));
|
||||
if (!value || !value->is_number()) return 0;
|
||||
const auto best = value->get<double>();
|
||||
return std::isfinite(best) && best > 0 && best < 1e9 ? static_cast<int>(best) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool start(std::uintptr_t base) noexcept {
|
||||
auto& s = state();
|
||||
if (s.ready.load(std::memory_order_acquire)) return true;
|
||||
if (!start_skater(base)) return false; // logged
|
||||
// The hooks are prepared here and switched on and off with the feature (on_client_tick).
|
||||
(void)start_render(base); // without it no skeleton shows: logged
|
||||
start_hud(base);
|
||||
const bool on = profile_runtime::local_preference(preference).value_or(false);
|
||||
// The overlay builds its atlas once, when its graphics start, so the images are read now.
|
||||
overlay::prepare_hall_of_meat_images();
|
||||
s.on.store(on, std::memory_order_release);
|
||||
if (on) set_no_bail_step_observer(&observe_step);
|
||||
s.ready.store(true, std::memory_order_release);
|
||||
logging::log(logging::Level::info, logging::Channel::skater, "Hall of Meat ready ({}).", on ? "on" : "off");
|
||||
return true;
|
||||
}
|
||||
|
||||
void on_client_tick() noexcept {
|
||||
auto& s = state();
|
||||
if (!s.ready.load(std::memory_order_acquire)) return;
|
||||
const bool on = enabled();
|
||||
if (!on && !s.active) return; // switched off: nothing hooked, nothing to give back
|
||||
if (on && !s.active) {
|
||||
prepare_skeleton(); // once, in the background
|
||||
{
|
||||
HookBatch batch; // (one pause of the game's threads for both)
|
||||
(void)hook_render(true);
|
||||
(void)hook_hud(true);
|
||||
}
|
||||
s.active = true;
|
||||
}
|
||||
try {
|
||||
NoBailSkater skater;
|
||||
const bool gone = !no_bail_skater(skater);
|
||||
const auto now = GetTickCount64();
|
||||
Summary summary, ended;
|
||||
bool pending{}, unsaved{}, card{};
|
||||
int best{};
|
||||
float speed{};
|
||||
AcquireSRWLockExclusive(&s.lock);
|
||||
if (gone && s.tracker.lose(now, &ended)) finished(s, ended);
|
||||
card = s.tracker.view(now).phase != Phase::riding;
|
||||
speed = s.tracker.game_speed(now);
|
||||
std::swap(pending, s.summary_pending);
|
||||
if (pending) summary = s.summary;
|
||||
std::swap(unsaved, s.best_unsaved);
|
||||
best = s.best;
|
||||
ReleaseSRWLockExclusive(&s.lock);
|
||||
// The renderer's picture of the skater is only read while a bail shows.
|
||||
follow_render(card);
|
||||
// The card takes the place of skate.'s bottom left HUD while it shows: the bail is what counts.
|
||||
const bool corner_back = hide_hud(card);
|
||||
// A break slows the game down (in single player only: hall_of_meat_slow_motion.h).
|
||||
(void)s.slow_motion.set(speed);
|
||||
if (pending && logging::enabled(logging::Level::debug)) log_bail(summary);
|
||||
if (unsaved && !s.level.empty()) profile_runtime::set_local_values({{best_key(s.level), static_cast<double>(best)}});
|
||||
// Switched off (set_enabled ended the bail): the hooks go once the corner and the game's speed are given back.
|
||||
if (!on && corner_back && s.slow_motion.idle() && hook_hud(false) && hook_render(false)) s.active = false;
|
||||
} catch (...) { /* A lost log line or best is never worth the client tick. */ }
|
||||
}
|
||||
|
||||
void set_level(std::string_view level) noexcept {
|
||||
auto& s = state();
|
||||
if (!s.ready.load(std::memory_order_acquire) || level == s.level) return;
|
||||
try {
|
||||
s.level = level;
|
||||
const int best = level.empty() ? 0 : saved_best(level);
|
||||
AcquireSRWLockExclusive(&s.lock);
|
||||
s.best_known = !level.empty();
|
||||
s.best = best;
|
||||
s.best_unsaved = false;
|
||||
s.standing = {};
|
||||
ReleaseSRWLockExclusive(&s.lock);
|
||||
} catch (...) { /* The map's best stays unknown: no card shows one. */ }
|
||||
}
|
||||
|
||||
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 {
|
||||
auto& s = state();
|
||||
s.on.store(enabled, std::memory_order_release);
|
||||
// Switched off, No Bail's hook hands no step on: the physics step pays nothing for it.
|
||||
if (s.ready.load(std::memory_order_acquire)) set_no_bail_step_observer(enabled ? &observe_step : nullptr);
|
||||
if (!enabled) {
|
||||
AcquireSRWLockExclusive(&s.lock);
|
||||
s.tracker.reset();
|
||||
s.summary_pending = false;
|
||||
ReleaseSRWLockExclusive(&s.lock);
|
||||
}
|
||||
profile_runtime::set_local_preference(preference, enabled);
|
||||
}
|
||||
|
||||
Frame frame() {
|
||||
auto& s = state();
|
||||
if (!enabled()) return {};
|
||||
const auto now = GetTickCount64();
|
||||
View view;
|
||||
Standing against; // the card's: the bail against the map's best before it
|
||||
AcquireSRWLockExclusive(&s.lock);
|
||||
view = s.tracker.view(now);
|
||||
if (view.phase == Phase::bailing || view.phase == Phase::down)
|
||||
against = s.best_known ? standing(s.best, view.tally.score) : Standing{};
|
||||
else if (view.phase == Phase::getting_up) against = s.standing; // the best already counts this bail
|
||||
ReleaseSRWLockExclusive(&s.lock);
|
||||
if (view.phase == Phase::riding) return {};
|
||||
|
||||
Frame result;
|
||||
result.card = card(view, against);
|
||||
result.break_pulse = view.break_pulse;
|
||||
// The skeleton as the renderer drew the skater in the latest picture, seen by its camera.
|
||||
auto mesh = skeleton_mesh();
|
||||
Picture picture;
|
||||
PosedSkeleton posed;
|
||||
if (!mesh || !latest_picture(picture) || !pose_skeleton(*mesh, picture.skin, posed)) return result;
|
||||
auto& skeleton = result.skeleton;
|
||||
skeleton.camera = picture.camera;
|
||||
skeleton.vertical_fov = picture.vertical_fov;
|
||||
skeleton.positions = std::move(posed.positions);
|
||||
skeleton.normals = std::move(posed.normals);
|
||||
skeleton.mesh = std::move(mesh);
|
||||
skeleton.alpha = view.alpha;
|
||||
skeleton.injuries = view.injuries;
|
||||
skeleton.flashes = view.flashes;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
#pragma once
|
||||
#include "hall_of_meat_card.h"
|
||||
#include "hall_of_meat_model.h"
|
||||
#include "hall_of_meat_skeleton.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// Hall of Meat, as in skate. 3: when the local skater bails, the bones it hurt show over the
|
||||
// world, yellow where a hit bruised one and red where one broke it, with the bail's card, its
|
||||
// time and Meat counting until the body comes to rest; both stay a second after the skater gets
|
||||
// up, then fade out. A bone breaking flashes the screen's edges red and, in single player, slows
|
||||
// the game down a moment (hall_of_meat_slow_motion.h).
|
||||
//
|
||||
// Each physics step of the local skater (hall_of_meat_skater.h) feeds the bail
|
||||
// (hall_of_meat_model.h): what each body hit, and whether it is a ragdoll. The skeleton is skate.'s
|
||||
// own skeleton mesh (hall_of_meat_skeleton.h) posed as the renderer draws the skater
|
||||
// (hall_of_meat_render.h); the card (hall_of_meat_card.h) takes the place of skate.'s bottom left HUD
|
||||
// while it shows (hall_of_meat_hud.h). The overlay draws both (hall_of_meat_overlay.h). Each map's best
|
||||
// Meat is saved with the profile.
|
||||
namespace dingosdk::hall_of_meat {
|
||||
// Requires the validated build, No Bail started, and the local profile loaded: the switch starts
|
||||
// from the saved choice, off until a player switches it on.
|
||||
bool start(std::uintptr_t image_base) noexcept;
|
||||
// Client thread, every tick: switches the renderer's and the UI model's hooks with the feature (off,
|
||||
// none of them is called); a bail ends when the local skater is gone (a respawn, a teleport);
|
||||
// logs each finished bail, saves a new best, hides skate.'s HUD corner while the card shows and
|
||||
// runs the slow motion.
|
||||
void on_client_tick() noexcept;
|
||||
// Client thread, with the level being played (empty without one): loads that map's best when
|
||||
// the map changes.
|
||||
void set_level(std::string_view level) noexcept;
|
||||
// Applies at once and saves the choice with the profile.
|
||||
void set_enabled(bool enabled) noexcept;
|
||||
// The switch, any thread: whether it started, and whether it is on. The menu reads it through the
|
||||
// overlay's model (overlay::HallOfMeatModel), the console directly.
|
||||
bool available() noexcept;
|
||||
bool enabled() noexcept;
|
||||
|
||||
// skate.'s skeleton mesh posed as the renderer drew the skater, in world space, with that picture's
|
||||
// camera, and how each body was hurt.
|
||||
struct Skeleton {
|
||||
std::array<float, 16> camera{}; // world matrix: right, up, back, position rows
|
||||
float vertical_fov{}; // degrees
|
||||
std::vector<game::Vec3> positions, normals; // one per vertex
|
||||
std::shared_ptr<const SkeletonMesh> mesh; // its triangles, and each vertex's body
|
||||
float alpha{}; // fades the whole skeleton out
|
||||
std::array<Injury, skater_body::count> injuries{};
|
||||
std::array<float, skater_body::count> flashes{}; // 1 the moment a body is hit, falling to 0
|
||||
};
|
||||
// What the overlay draws now; nothing (no skeleton, no card, no pulse) while riding.
|
||||
struct Frame {
|
||||
Skeleton skeleton; // no positions while the renderer's picture is not there
|
||||
Card card;
|
||||
float break_pulse{}; // the screen's red edge when a bone breaks, 0 to 1
|
||||
};
|
||||
// Render thread, every presented frame.
|
||||
Frame frame();
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
#include "hall_of_meat_card.h"
|
||||
#include <format>
|
||||
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace {
|
||||
std::string hits(int value) { return grouped(value) + (value == 1 ? " hit" : " hits"); }
|
||||
}
|
||||
|
||||
Card card(const View& view, const Standing& against) {
|
||||
if (view.phase == Phase::riding) return {};
|
||||
const auto& t = view.tally;
|
||||
Card result;
|
||||
result.opacity = view.alpha;
|
||||
const float road_rash = t.scraped * feet_per_metre, fallen = t.fallen * feet_per_metre,
|
||||
speed = t.top_speed * mph_per_metre_per_second;
|
||||
const auto row = [&](bool shown, Stat stat, std::string value, int points) {
|
||||
if (shown) result.rows.push_back({stat, std::move(value), points});
|
||||
};
|
||||
row(true, Stat::time, std::format("{:.1f} s", t.seconds), t.time_points);
|
||||
row(t.impacts >= hits_shown, Stat::hits, hits(t.impacts), t.hit_points + t.head_bonus + t.vehicle_bonus);
|
||||
row(t.broken >= broken_shown, Stat::broken, grouped(t.broken) + " broken", t.broken * points_per_break);
|
||||
row(road_rash >= road_rash_shown_feet, Stat::road_rash, std::format("{:.1f} ft", road_rash), t.scrape_points);
|
||||
row(t.airtime >= airtime_shown_seconds, Stat::airtime, std::format("{:.1f} s", t.airtime), t.airtime_points);
|
||||
row(fallen >= fall_shown_feet, Stat::fall, std::format("{:.1f} ft", fallen), t.fall_points);
|
||||
row(speed >= speed_shown_mph, Stat::speed, std::format("{:.1f} MPH", speed), t.speed_points);
|
||||
row(t.rotations >= rotations_shown, Stat::rotations, std::format("{:.1f} rotations", t.rotations), t.rotation_points);
|
||||
result.total = t.score;
|
||||
if (against.new_best) {
|
||||
result.badge = "NEW BEST";
|
||||
result.highlight = true;
|
||||
} else if (against.best > 0) {
|
||||
result.badge = "BEST " + grouped(against.best);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string grouped(long long value) {
|
||||
const bool negative = value < 0;
|
||||
const auto magnitude = negative ? 0ull - static_cast<unsigned long long>(value) : static_cast<unsigned long long>(value);
|
||||
auto digits = std::to_string(magnitude);
|
||||
for (auto at = static_cast<long long>(digits.size()) - 3; at > 0; at -= 3) digits.insert(static_cast<std::size_t>(at), ",");
|
||||
return negative ? "-" + digits : digits;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
#pragma once
|
||||
#include "hall_of_meat_model.h"
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Hall of Meat's score card, as in skate. 3: a row per stat, with skate.'s own icon for it
|
||||
// (hall_of_meat_overlay.cpp draws it), then the Meat. No game access.
|
||||
namespace dingosdk::hall_of_meat {
|
||||
// When a stat comes onto the card: once the bail has done something worth showing, so a small
|
||||
// bail's card stays short and a big one grows as it goes. The time shows from the start; the
|
||||
// Meat counts every stat whether shown or not.
|
||||
inline constexpr int hits_shown = 5, broken_shown = 5;
|
||||
inline constexpr float road_rash_shown_feet = 15.0f, airtime_shown_seconds = 3.0f, fall_shown_feet = 100.0f,
|
||||
speed_shown_mph = 20.0f, rotations_shown = 2.0f;
|
||||
// skate. shows lengths in feet and speeds in miles per hour.
|
||||
inline constexpr float feet_per_metre = 3.28084f;
|
||||
inline constexpr float mph_per_metre_per_second = 2.23694f;
|
||||
|
||||
// Each stat's row, by the icon it shows, in the card's order.
|
||||
enum class Stat : std::uint8_t { time, hits, broken, road_rash, airtime, fall, speed, rotations };
|
||||
struct CardRow {
|
||||
Stat stat{};
|
||||
std::string value; // what was measured, as shown: "6.2 s"
|
||||
int points{};
|
||||
};
|
||||
struct Card {
|
||||
float opacity{}; // 0: no card
|
||||
std::vector<CardRow> rows;
|
||||
int total{}; // the Meat
|
||||
std::string badge; // beside the title: "NEW BEST" or "BEST 12,345"; empty for none
|
||||
bool highlight{}; // the badge marks a record
|
||||
};
|
||||
// The card for what the overlay draws now, against the map's best before the bail. No card while
|
||||
// riding.
|
||||
Card card(const View& view, const Standing& against);
|
||||
// 12,345 and -1,234, as skate. shows its numbers.
|
||||
std::string grouped(long long value);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
#include "hall_of_meat.h"
|
||||
#include "Extension/Console/commands.h"
|
||||
|
||||
// The `hallofmeat` console switch; the menu's (Mod Options > Player) sends it too.
|
||||
namespace dingosdk::console {
|
||||
void register_hall_of_meat_commands(Commands ®istry) {
|
||||
auto meat = variable("hallofmeat", "Show the bones a bail hurt, bruised yellow and broken red, and its Meat score",
|
||||
Group::gameplay, argument("0|1", Type::boolean));
|
||||
meat.inspect = [](const Model &) {
|
||||
return boolean_state(hall_of_meat::available(), hall_of_meat::enabled(), "Hall of Meat is unavailable for this game build.");
|
||||
};
|
||||
meat.run = [](const Model &, const Values &args, const Output &) { hall_of_meat::set_enabled(std::get<bool>(args[0])); };
|
||||
registry.add(std::move(meat));
|
||||
}
|
||||
} // namespace dingosdk::console
|
||||
@@ -1,253 +0,0 @@
|
||||
#include "hall_of_meat_model.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace {
|
||||
// The physics thread steps and the render thread draws, each reading the real clock itself:
|
||||
// a moment ago may be a moment ahead.
|
||||
std::uint64_t elapsed(std::uint64_t now, std::uint64_t since) noexcept { return now > since ? now - since : 0; }
|
||||
// A step's length on the game's clock; nothing for one the game did not report sensibly.
|
||||
double length_ms(float seconds) noexcept { return std::isfinite(seconds) && seconds > 0 ? seconds * 1000.0 : 0.0; }
|
||||
float speed_of(float value) noexcept { return std::isfinite(value) && value > 0 ? value : 0.0f; }
|
||||
bool is_foot(std::size_t bone) noexcept { return skater_body::foot(static_cast<Bone>(bone)); }
|
||||
// Sliding along anything but the board scrapes: the feet ride on it all the time.
|
||||
bool scrapes(const skater_body::HitKinds& hit) noexcept {
|
||||
return hit.world || hit.vehicle || hit.kind_5 || hit.kind_11;
|
||||
}
|
||||
int points(float amount, float per_unit) noexcept { return static_cast<int>(amount * per_unit + 0.5f); }
|
||||
}
|
||||
|
||||
bool Tracker::step(std::uint64_t now, const Step& step, Summary* ended) noexcept {
|
||||
const double step_ms = length_ms(step.seconds);
|
||||
played_ += step_ms;
|
||||
const double flown = step.airborne ? step_ms : 0.0;
|
||||
if (step.airborne) landed_.reset();
|
||||
else if (!landed_) landed_ = played_;
|
||||
const bool ragdoll = step.ragdoll.value_or(false);
|
||||
// How far the skater went down in the step.
|
||||
const float descent = step.velocity ? std::max(0.0f, -(*step.velocity)[1]) * static_cast<float>(step_ms / 1000.0) : 0.0f;
|
||||
bool began{};
|
||||
if (phase_ == Phase::riding) {
|
||||
if (step.airborne) {
|
||||
flight_ms_ += flown;
|
||||
flight_fallen_ += descent;
|
||||
} else if (played_ - *landed_ > wipeout_after_impact_ms) { // landed, and stayed up
|
||||
flight_ms_ = 0;
|
||||
flight_fallen_ = 0;
|
||||
}
|
||||
// Each body's hardest recent hit, for a bail that starts a few steps after it.
|
||||
for (std::size_t index = 1; index < skater_body::count; ++index) { // 0 is the board
|
||||
const auto& contact = step.body.bodies[index];
|
||||
const float speed = speed_of(contact.impact);
|
||||
auto& lead = bones_[index].lead;
|
||||
if (speed <= 0 || (lead.at && played_ - *lead.at <= wipeout_after_impact_ms && speed <= lead.speed)) continue;
|
||||
lead = {speed, contact.hit.vehicle, played_};
|
||||
}
|
||||
if (!step.wipeout) return false;
|
||||
begin(now); // the flight it came from holds this step's airtime and fall
|
||||
began = true;
|
||||
}
|
||||
ragdolled_ = ragdolled_ || ragdoll;
|
||||
const auto speed = step.velocity ? std::optional<float>(game::length(*step.velocity)) : std::nullopt;
|
||||
rest(speed);
|
||||
if (phase_ == Phase::bailing) {
|
||||
if (!began) {
|
||||
airtime_ms_ += flown;
|
||||
fallen_ += descent;
|
||||
}
|
||||
if (speed) top_speed_ = std::max(top_speed_, *speed);
|
||||
if (step.spin) turned_ += speed_of(*step.spin) * static_cast<float>(step_ms / 1000.0);
|
||||
count(now, step, step_ms);
|
||||
}
|
||||
// The skater stood up, or never went down.
|
||||
const bool stood_up = ragdolled_ ? step.ragdoll.has_value() && !*step.ragdoll : played_ - started_ >= ragdoll_wait_ms;
|
||||
if (breaks_ != seen_breaks_) { // a break this step: its effect runs from now
|
||||
seen_breaks_ = breaks_;
|
||||
if (breaks_seen_ < breaks_seen_at_.size()) breaks_seen_at_[breaks_seen_++] = now;
|
||||
}
|
||||
return stood_up && end(now, ended);
|
||||
}
|
||||
|
||||
// Whether the body has come to rest, by how fast it moves: then the bail's time and points are final.
|
||||
void Tracker::rest(std::optional<float> speed) noexcept {
|
||||
if (phase_ != Phase::bailing || !speed) return;
|
||||
if (*speed >= still_speed) {
|
||||
still_since_.reset();
|
||||
} else if (!still_since_) {
|
||||
still_since_ = played_;
|
||||
} else if (played_ - *still_since_ >= rest_ms) {
|
||||
phase_ = Phase::down;
|
||||
stopped_at_ = still_since_; // it has rested since it went still
|
||||
}
|
||||
}
|
||||
|
||||
// What each body hit and how far it slid in one step of the bail.
|
||||
void Tracker::count(std::uint64_t now, const Step& step, double step_ms) noexcept {
|
||||
const float step_seconds = static_cast<float>(step_ms / 1000.0);
|
||||
float sliding{}; // the scraping bodies' slides, summed
|
||||
int scraping{};
|
||||
for (std::size_t index = 1; index < skater_body::count; ++index) {
|
||||
const auto& contact = step.body.bodies[index];
|
||||
const float speed = speed_of(contact.impact);
|
||||
if (speed > 0) hit(index, speed, contact.hit.vehicle, played_, now);
|
||||
const float slide = game::length(contact.slide);
|
||||
if (!contact.touching || is_foot(index) || !scrapes(contact.hit) || !std::isfinite(slide) || slide < scrape_speed)
|
||||
continue;
|
||||
auto& bone = bones_[index];
|
||||
bone.scraped += slide * step_seconds;
|
||||
if (bone.scraped >= bruising_scrape) hurt_ = true;
|
||||
sliding += slide;
|
||||
++scraping;
|
||||
}
|
||||
if (scraping) scraped_ += sliding / static_cast<float>(scraping) * step_seconds;
|
||||
}
|
||||
|
||||
bool Tracker::lose(std::uint64_t now, Summary* ended) noexcept {
|
||||
// Nothing from before the skater went carries over to the one that comes back.
|
||||
flight_ms_ = 0;
|
||||
landed_.reset();
|
||||
breaks_ = seen_breaks_ = 0;
|
||||
breaks_seen_ = 0;
|
||||
flight_fallen_ = 0;
|
||||
for (auto& bone : bones_) bone.lead = {};
|
||||
return phase_ != Phase::riding && end(now, ended);
|
||||
}
|
||||
|
||||
// A bail from this step (`now` in real time), with the flight it came from and the hits just before it.
|
||||
void Tracker::begin(std::uint64_t now) noexcept {
|
||||
const auto played = played_;
|
||||
const auto flight = flight_ms_;
|
||||
const auto flight_fallen = flight_fallen_;
|
||||
const auto before = bones_;
|
||||
*this = {};
|
||||
played_ = played;
|
||||
phase_ = Phase::bailing;
|
||||
started_ = played_;
|
||||
airtime_ms_ = flight; // this step's share is in it
|
||||
fallen_ = flight_fallen;
|
||||
for (std::size_t index = 1; index < skater_body::count; ++index) {
|
||||
const auto& lead = before[index].lead;
|
||||
if (lead.at && played_ - *lead.at <= wipeout_after_impact_ms) hit(index, lead.speed, lead.vehicle, *lead.at, now);
|
||||
}
|
||||
}
|
||||
|
||||
// A hit of one bone at `at` on the game's clock, shown from `now`: a new impact, or the harder step
|
||||
// of the contact it belongs to.
|
||||
void Tracker::hit(std::size_t index, float speed, bool vehicle, double at, std::uint64_t now) noexcept {
|
||||
auto& bone = bones_[index];
|
||||
if (bone.peak < broken_speed && speed >= broken_speed) ++breaks_;
|
||||
bone.peak = std::max(bone.peak, speed);
|
||||
if (speed < hit_speed) return;
|
||||
hurt_ = true;
|
||||
if (bone.hit_at && at - *bone.hit_at <= impact_gap_ms) {
|
||||
auto& impact = impacts_[bone.impact];
|
||||
impact.speed = std::max(impact.speed, speed);
|
||||
impact.vehicle = impact.vehicle || vehicle;
|
||||
} else if (impact_count_ < max_impacts) {
|
||||
bone.impact = impact_count_;
|
||||
impacts_[impact_count_++] = {index, speed, vehicle};
|
||||
}
|
||||
bone.hit_at = at;
|
||||
bone.flashed_at = now;
|
||||
}
|
||||
|
||||
bool Tracker::end(std::uint64_t now, Summary* ended) noexcept {
|
||||
phase_ = Phase::riding;
|
||||
ended_ = now;
|
||||
if (!stopped_at_) stopped_at_ = played_;
|
||||
if (ended) {
|
||||
ended->shown = hurt_;
|
||||
for (std::size_t index = 0; index < skater_body::count; ++index) {
|
||||
ended->peaks[index] = bones_[index].peak;
|
||||
ended->scraped[index] = bones_[index].scraped;
|
||||
}
|
||||
ended->tally = tally();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The bail's time, on the game's clock: until the body came to rest, or the bail ended, or the latest step.
|
||||
double Tracker::bail_ms() const noexcept {
|
||||
return std::max(0.0, (stopped_at_ ? *stopped_at_ : played_) - started_);
|
||||
}
|
||||
|
||||
Phase Tracker::phase(std::uint64_t now) const noexcept {
|
||||
if (phase_ != Phase::riding) return phase_;
|
||||
return ended_ && elapsed(now, ended_) < linger_ms + fade_ms ? Phase::getting_up : Phase::riding;
|
||||
}
|
||||
|
||||
Injury Tracker::injury_of(const BoneState& bone) const noexcept {
|
||||
const auto by_hits = injury(bone.peak);
|
||||
return by_hits == Injury::none && bone.scraped >= bruising_scrape ? Injury::hit : by_hits;
|
||||
}
|
||||
|
||||
Tally Tracker::tally() const noexcept {
|
||||
Tally result;
|
||||
for (std::size_t index = 0; index < impact_count_; ++index) {
|
||||
const auto& impact = impacts_[index];
|
||||
result.hit_points += hit_points(impact.speed);
|
||||
result.head_bonus += head_bonus(impact);
|
||||
result.vehicle_bonus += vehicle_bonus(impact);
|
||||
}
|
||||
result.impacts = static_cast<int>(impact_count_);
|
||||
for (std::size_t index = 1; index < skater_body::count; ++index) // 0 is the board
|
||||
if (injury(bones_[index].peak) == Injury::broken) ++result.broken;
|
||||
result.scraped = scraped_;
|
||||
result.scrape_points = points(result.scraped, points_per_scraped_metre);
|
||||
result.damage = result.hit_points + result.head_bonus + result.vehicle_bonus + result.scrape_points;
|
||||
result.seconds = static_cast<float>(bail_ms() / 1000.0);
|
||||
result.time_points = points(result.seconds, points_per_second);
|
||||
result.airtime = static_cast<float>(airtime_ms_ / 1000.0);
|
||||
result.airtime_points = points(result.airtime, points_per_air_second);
|
||||
result.fallen = fallen_;
|
||||
result.fall_points = points(result.fallen, points_per_metre_fallen);
|
||||
result.top_speed = top_speed_;
|
||||
result.speed_points = points(result.top_speed, points_per_speed);
|
||||
result.rotations = turned_ / radians_per_rotation;
|
||||
result.rotation_points = points(result.rotations, points_per_rotation);
|
||||
result.score = result.damage + result.broken * points_per_break + result.time_points + result.airtime_points +
|
||||
result.fall_points + result.speed_points + result.rotation_points;
|
||||
return result;
|
||||
}
|
||||
|
||||
View Tracker::view(std::uint64_t now) const noexcept {
|
||||
View view;
|
||||
const auto phase = this->phase(now);
|
||||
if (phase == Phase::riding || !hurt_) return view;
|
||||
view.phase = phase;
|
||||
const auto since_end = phase == Phase::getting_up ? elapsed(now, ended_) : 0;
|
||||
view.alpha = since_end > linger_ms ? 1.0f - static_cast<float>(since_end - linger_ms) / static_cast<float>(fade_ms) : 1.0f;
|
||||
view.tally = tally();
|
||||
for (std::size_t index = 0; index < skater_body::count; ++index) {
|
||||
const auto& bone = bones_[index];
|
||||
view.injuries[index] = injury_of(bone);
|
||||
const auto since_hit = elapsed(now, bone.flashed_at);
|
||||
if (bone.flashed_at && since_hit < flash_ms)
|
||||
view.flashes[index] = 1.0f - static_cast<float>(since_hit) / static_cast<float>(flash_ms);
|
||||
}
|
||||
view.break_pulse = break_effect(now);
|
||||
return view;
|
||||
}
|
||||
|
||||
float Tracker::break_effect(std::uint64_t now) const noexcept {
|
||||
const auto smooth = [](std::uint64_t part, std::uint64_t whole) {
|
||||
const float t = static_cast<float>(part) / static_cast<float>(whole);
|
||||
return t * t * (3.0f - 2.0f * t);
|
||||
};
|
||||
float strongest = 0.0f;
|
||||
for (std::size_t index = 0; index < breaks_seen_; ++index) {
|
||||
const auto since = elapsed(now, breaks_seen_at_[index]);
|
||||
if (since >= break_effect_ms) continue;
|
||||
const float effect = since < break_rise_ms ? smooth(since, break_rise_ms)
|
||||
: since < break_rise_ms + break_hold_ms ? 1.0f
|
||||
: 1.0f - smooth(since - break_rise_ms - break_hold_ms, break_fall_ms);
|
||||
strongest = std::max(strongest, effect);
|
||||
}
|
||||
return strongest;
|
||||
}
|
||||
|
||||
float Tracker::game_speed(std::uint64_t now) const noexcept {
|
||||
return 1.0f - (1.0f - break_game_speed) * break_effect(now);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Skater/skater_body.h"
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
// Hall of Meat, the model: what a bail did to the skater, and what it scores. Fed one
|
||||
// physics step at a time (hall_of_meat_skater.h) with what each body touched
|
||||
// (Engine/Game/Skater/skater_body.h), how the skater moves and whether the game simulates the
|
||||
// body as a ragdoll; read by the overlay. No game access, no locking: the caller owns both.
|
||||
//
|
||||
// A bail follows the skater, not a clock:
|
||||
// riding → bailing the game's wipeout, and nothing else: a fall from height ragdolls in the
|
||||
// air before the wipeout of its impact (its flight and its hits of just
|
||||
// before carry into the bail), and skate.'s flumps (a dive, a roll along
|
||||
// the ground, from any height) are ragdolls the game never wipes out
|
||||
// bailing → down the body has come to rest: the bail's time and points are final
|
||||
// bailing, down → getting up the ragdoll ends (the skater stands up), or the skater is gone
|
||||
// (a respawn, a teleport); the bail is over, stays and fades out
|
||||
// Measured in play: the ragdoll begins with the wipeout or in the flight before it, and
|
||||
// ends exactly once, when the skater stands up on foot; skate. may put them back on the board
|
||||
// a moment later. The bail shows only once it has hurt a bone: a fall that leaves the skater
|
||||
// unbruised shows nothing.
|
||||
//
|
||||
// Two clocks. The bail runs on the game's: its steps' own lengths added up, so in slow motion
|
||||
// (a break: hall_of_meat.h) the moment the ragdoll has to begin, the rest, the impacts and every
|
||||
// stat still measure what the body did. What the player sees runs on the real one: a hit's
|
||||
// flash, a break's effect, the linger and the fade after the bail.
|
||||
namespace dingosdk::hall_of_meat {
|
||||
using skater_body::Bone;
|
||||
|
||||
// One physics step of the local skater.
|
||||
struct Step {
|
||||
float seconds{}; // how long the step simulated, in the game's time
|
||||
bool wipeout{}; // the step asks for a wipeout
|
||||
bool airborne{}; // the skater is in the air, on the board or off it
|
||||
// The body is a ragdoll; empty when the skater state could not be read: such a step neither
|
||||
// starts nor ends a bail by it.
|
||||
std::optional<bool> ragdoll;
|
||||
// How the skater moves (with the board while on it, else as its pelvis), metres per second;
|
||||
// empty when unknown: such a step neither rests nor stirs the body, and adds nothing to its
|
||||
// fall or its speed.
|
||||
std::optional<game::Vec3> velocity;
|
||||
// How fast the pelvis turns, any way round, radians per second; empty when unknown: such a
|
||||
// step adds nothing to its rotations.
|
||||
std::optional<float> spin;
|
||||
skater_body::Contacts body; // what each body touched in the step
|
||||
};
|
||||
|
||||
enum class Injury : std::uint8_t { none, hit, broken };
|
||||
|
||||
// Impact speeds along the contact normal, as measured in play: a fall on flat ground hits the
|
||||
// bones it lands on at 3 to 9 m/s and grazes the rest at 2 to 3, a hard landing hits the legs at
|
||||
// 8 to 10, a drop from height everything at 13 to 35. So a hard fall on flat ground bruises, and
|
||||
// only a drop from height breaks bones.
|
||||
inline constexpr float hit_speed = 7.0f;
|
||||
inline constexpr float broken_speed = 12.0f;
|
||||
// A wipeout whose ragdoll does not begin within this long was a stumble: its bail ends.
|
||||
inline constexpr std::uint64_t ragdoll_wait_ms = 500;
|
||||
// The body has come to rest once it moves slower than still_speed for rest_ms: from then on
|
||||
// nothing counts. Measured in play: a body lying moves at 0.0 to 0.3 m/s (a skater standing
|
||||
// 0.0 to 0.1), a tumbling or sliding one at 2 and more. A rest is final: getting up starts inside
|
||||
// the ragdoll (its animated part), moving the body fast again before the ragdoll ends.
|
||||
inline constexpr float still_speed = 0.5f;
|
||||
inline constexpr std::uint64_t rest_ms = 750;
|
||||
// Once the skater gets up, the skeleton and the card stay linger_ms, then fade out over fade_ms.
|
||||
inline constexpr std::uint64_t linger_ms = 1000;
|
||||
inline constexpr std::uint64_t fade_ms = 800;
|
||||
inline constexpr std::uint64_t flash_ms = 350; // a fresh hit flashes this long
|
||||
// A bone breaking hits the whole screen, as in skate. 3's Hall of Meat: its edges pulse red and the
|
||||
// game slows down to break_game_speed (single player only: hall_of_meat.h), together, by one
|
||||
// effect: easing in over break_rise_ms, held break_hold_ms, easing out over break_fall_ms; in real
|
||||
// time, from the step that saw the break (a bail takes over the breaks of just before it, which
|
||||
// happened earlier). Each break has its own and the strongest counts, so a further break hits
|
||||
// again without the screen or the game's speed jumping.
|
||||
inline constexpr std::uint64_t break_rise_ms = 150;
|
||||
inline constexpr std::uint64_t break_hold_ms = 1000;
|
||||
inline constexpr std::uint64_t break_fall_ms = 1000;
|
||||
inline constexpr std::uint64_t break_effect_ms = break_rise_ms + break_hold_ms + break_fall_ms;
|
||||
inline constexpr float break_game_speed = 0.3f;
|
||||
// A bone's contact lasts several physics steps: hits of one bone closer together than
|
||||
// this are one impact, as hard as its hardest step.
|
||||
inline constexpr std::uint64_t impact_gap_ms = 200;
|
||||
inline constexpr std::size_t max_impacts = 64;
|
||||
// The game reports the wipeout of an impact a few physics steps after it (0 to 2 steps in
|
||||
// play; a drop from height hit the right thigh at 35 m/s a step before its wipeout): a bail takes the flight and the hits of this long before it starts.
|
||||
inline constexpr std::uint64_t wipeout_after_impact_ms = 250;
|
||||
|
||||
// The Meat a bail scores: every hit by how hard it was, and each bone broken a bonus once.
|
||||
// A hit's points grow faster than its speed but slower than its energy: the lightest hit
|
||||
// (hit_speed) scores 100, a 30 m/s slam about 900 (not the 18 bruises its energy would make it).
|
||||
// A break scores what ten of the lightest hits do, so a heavy bail's hits and breaks do not
|
||||
// outweigh the rest of the card.
|
||||
inline constexpr float hit_reference_speed = hit_speed;
|
||||
inline constexpr float points_per_reference_hit = 100.0f;
|
||||
inline constexpr float hit_points_exponent = 1.5f;
|
||||
inline constexpr int points_per_break = 1000;
|
||||
// A hit to the head (the upper neck it rides on, Bone::neck1) counts double; one from a
|
||||
// vehicle half again. Both together count 2.5 times.
|
||||
inline constexpr float head_multiplier = 2.0f;
|
||||
inline constexpr float vehicle_multiplier = 1.5f;
|
||||
// Road rash: a body sliding along the ground or a wall, not the board, at least this fast
|
||||
// (metres per second along the surface) scrapes. A long slide moves the body at 3 to 10
|
||||
// m/s for seconds without a single hit. The skater's road rash is how far it slid: in
|
||||
// each step, how fast its scraping bodies slid on average, not their sum (a dozen bodies on the
|
||||
// ground at once would make one metre a dozen). A metre scores what four of the lightest hits do.
|
||||
inline constexpr float scrape_speed = 1.5f;
|
||||
inline constexpr float points_per_scraped_metre = 400.0f;
|
||||
inline constexpr float bruising_scrape = 1.0f; // metres one bone slid: it shows as bruised
|
||||
// The rest of the bail scores as skate. 3's Hall of Meat did, each stat its own points: its time
|
||||
// (falling and sliding, not resting), its airtime, how far the body fell (every metre it went down,
|
||||
// from the flight the bail came from on), its top speed and how often it turned over. Its example card
|
||||
// scored a 6.2 s bail 5,416, 3.4 s of air 10,816, a 50 m fall 13,251 and 25 MPH 1,590. A fall
|
||||
// scores half of that here: at the card's rate it outweighed everything else in play.
|
||||
inline constexpr float points_per_second = 600.0f;
|
||||
inline constexpr float points_per_air_second = 2000.0f;
|
||||
inline constexpr float points_per_metre_fallen = 125.0f;
|
||||
inline constexpr float points_per_speed = 130.0f; // per metre per second
|
||||
// Rotations: how far the body turned, any way round, while it fell and slid. Measured in
|
||||
// play: a skater turning on the spot turns the pelvis at 30 to 300 degrees a second; a body
|
||||
// tumbling down stairs turns over several times a second. A rotation scores what a second's bail does.
|
||||
inline constexpr float points_per_rotation = 600.0f;
|
||||
inline constexpr float radians_per_rotation = 6.2831853f;
|
||||
|
||||
constexpr Injury injury(float peak) noexcept {
|
||||
return peak >= broken_speed ? Injury::broken : peak >= hit_speed ? Injury::hit : Injury::none;
|
||||
}
|
||||
// A hit's points by its speed alone.
|
||||
inline int hit_points(float speed) noexcept {
|
||||
if (!(speed >= hit_speed)) return 0;
|
||||
return static_cast<int>(
|
||||
points_per_reference_hit * std::pow(speed / hit_reference_speed, hit_points_exponent) + 0.5f);
|
||||
}
|
||||
|
||||
struct Impact {
|
||||
std::size_t bone{};
|
||||
float speed{}; // the hardest step of the contact
|
||||
bool vehicle{}; // a vehicle took part in it
|
||||
};
|
||||
constexpr bool head(std::size_t bone) noexcept { return bone == skater_body::index(Bone::neck1); }
|
||||
// What an impact adds on top of its hit points, for the head and for a vehicle.
|
||||
inline int head_bonus(const Impact& impact) noexcept {
|
||||
return head(impact.bone) ? static_cast<int>(hit_points(impact.speed) * (head_multiplier - 1.0f) + 0.5f) : 0;
|
||||
}
|
||||
inline int vehicle_bonus(const Impact& impact) noexcept {
|
||||
return impact.vehicle ? static_cast<int>(hit_points(impact.speed) * (vehicle_multiplier - 1.0f) + 0.5f) : 0;
|
||||
}
|
||||
inline int impact_points(const Impact& impact) noexcept {
|
||||
return hit_points(impact.speed) + head_bonus(impact) + vehicle_bonus(impact);
|
||||
}
|
||||
|
||||
// A bail's numbers, so far or in the end.
|
||||
struct Tally {
|
||||
int impacts{}; // hits of at least hit_speed
|
||||
int broken{}; // bones
|
||||
int hit_points{}; // the hits by their speed
|
||||
int head_bonus{}; // what hits to the head added
|
||||
int vehicle_bonus{}; // what hits from vehicles added
|
||||
float scraped{}; // metres the skater slid along surfaces: the road rash
|
||||
int scrape_points{};
|
||||
int damage{}; // the hits, their bonuses and the road rash
|
||||
float seconds{}; // falling and sliding, not resting
|
||||
int time_points{};
|
||||
float airtime{}; // seconds in the air
|
||||
int airtime_points{};
|
||||
float fallen{}; // metres the body went down
|
||||
int fall_points{};
|
||||
float top_speed{}; // metres per second
|
||||
int speed_points{};
|
||||
float rotations{}; // turns of the body, any way round
|
||||
int rotation_points{};
|
||||
int score{}; // the damage, the breaks, the time, the airtime, the fall, the speed and the rotations: the Meat
|
||||
};
|
||||
// A bail's score against the best on the same map before it.
|
||||
struct Standing {
|
||||
int best{}; // the best, this bail included
|
||||
bool new_best{}; // this bail set it
|
||||
};
|
||||
constexpr Standing standing(int best, int score) noexcept {
|
||||
return score > best ? Standing{score, true} : Standing{best, false};
|
||||
}
|
||||
|
||||
enum class Phase : std::uint8_t { riding, bailing, down, getting_up };
|
||||
// What the overlay draws at one moment: riding (nothing) until the bail hurts a bone.
|
||||
struct View {
|
||||
Phase phase{};
|
||||
float alpha{}; // the skeleton's and the card's: 1 through the bail, fading to 0 as the skater gets up
|
||||
std::array<Injury, skater_body::count> injuries{};
|
||||
std::array<float, skater_body::count> flashes{}; // 1 at a fresh hit, falling to 0
|
||||
float break_pulse{}; // the red edge after a bone breaks: rising to 1, falling to 0
|
||||
Tally tally;
|
||||
};
|
||||
// A finished bail, for the log and the map's best.
|
||||
struct Summary {
|
||||
bool shown{}; // it hurt a bone, so it showed (and counts for a best)
|
||||
std::array<float, skater_body::count> peaks{}; // each body's hardest hit
|
||||
std::array<float, skater_body::count> scraped{}; // and how far it slid, each on its own
|
||||
Tally tally;
|
||||
};
|
||||
|
||||
class Tracker {
|
||||
public:
|
||||
// One physics step, at `now` in real time (milliseconds). True when this step ended a bail;
|
||||
// `ended` then receives it.
|
||||
bool step(std::uint64_t now, const Step& step, Summary* ended = nullptr) noexcept;
|
||||
// The skater is gone (a respawn, a teleport, a map change): a bail ends now. True when one did.
|
||||
bool lose(std::uint64_t now, Summary* ended = nullptr) noexcept;
|
||||
View view(std::uint64_t now) const noexcept;
|
||||
// Where the bail is at `now`, whether it hurt a bone (and so shows) or not.
|
||||
Phase phase(std::uint64_t now) const noexcept;
|
||||
// How fast the game is to run now: down to break_game_speed as a break hits, else 1.
|
||||
float game_speed(std::uint64_t now) const noexcept;
|
||||
void reset() noexcept { *this = {}; }
|
||||
|
||||
private:
|
||||
// Times on the game's clock (played_) are milliseconds of its time, as doubles; real times are
|
||||
// GetTickCount64()'s milliseconds, never 0.
|
||||
//
|
||||
// A bone's hardest hit of the last wipeout_after_impact_ms while riding, which a bail takes.
|
||||
struct Lead {
|
||||
float speed{};
|
||||
bool vehicle{};
|
||||
std::optional<double> at; // the game's clock
|
||||
};
|
||||
struct BoneState {
|
||||
float peak{}; // the hardest hit this bail
|
||||
std::optional<double> hit_at; // its last step with a hit, on the game's clock
|
||||
std::uint64_t flashed_at{}; // and when that hit showed, in real time; 0 never
|
||||
std::size_t impact{}; // which impact that step belonged to
|
||||
float scraped{}; // metres it slid
|
||||
Lead lead;
|
||||
};
|
||||
void begin(std::uint64_t now) noexcept;
|
||||
void rest(std::optional<float> speed) noexcept;
|
||||
void count(std::uint64_t now, const Step& step, double step_ms) noexcept;
|
||||
void hit(std::size_t bone, float speed, bool vehicle, double at, std::uint64_t now) noexcept;
|
||||
bool end(std::uint64_t now, Summary* ended) noexcept;
|
||||
double bail_ms() const noexcept;
|
||||
Tally tally() const noexcept;
|
||||
Injury injury_of(const BoneState& bone) const noexcept;
|
||||
|
||||
Phase phase_{}; // riding, bailing or down; getting_up is the linger and the fade after a bail's end
|
||||
bool ragdolled_{}; // the bail's ragdoll has begun
|
||||
bool hurt_{}; // a bone was hit at least at hit_speed, or scraped bruised, this bail
|
||||
unsigned breaks_{}, seen_breaks_{}; // bones broken this bail, and how many of them the steps saw
|
||||
std::array<std::uint64_t, skater_body::count> breaks_seen_at_{}; // the steps that saw breaks, oldest first
|
||||
std::size_t breaks_seen_{}; // how many of them
|
||||
float break_effect(std::uint64_t now) const noexcept; // 0 to 1
|
||||
double played_{}; // the game's clock: every step's length added up
|
||||
double started_{}; // the game's clock at the bail's wipeout
|
||||
std::uint64_t ended_{}; // real time: when the bail ended, for its linger and fade; 0 none yet
|
||||
std::optional<double> still_since_; // bailing: when the body last went slower than still_speed
|
||||
std::optional<double> stopped_at_; // when the bail's time stopped: the body came to rest, or the bail ended
|
||||
double flight_ms_{}; // while riding: the last flight, which a bail takes over
|
||||
float flight_fallen_{}; // and how far it went down
|
||||
std::optional<double> landed_; // when it touched down; empty while in the air
|
||||
double airtime_ms_{};
|
||||
float fallen_{}; // metres the body went down
|
||||
float top_speed_{}; // metres per second
|
||||
float turned_{}; // radians the body turned, any way round
|
||||
float scraped_{}; // metres the skater slid: the road rash
|
||||
std::array<BoneState, skater_body::count> bones_{};
|
||||
std::array<Impact, max_impacts> impacts_{};
|
||||
std::size_t impact_count_{};
|
||||
};
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
#include "hall_of_meat_overlay.h"
|
||||
#include "hall_of_meat.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/ui_textures.h"
|
||||
#include "Engine/Vfs/game_textures.h"
|
||||
#include "Extension/UI/Overlay/overlay_internal.h"
|
||||
#include "Extension/UI/skate_theme.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <future>
|
||||
#include <optional>
|
||||
|
||||
namespace dingosdk::overlay {
|
||||
namespace {
|
||||
namespace ui = addr::ui_textures;
|
||||
namespace theme = dingosdk::skate_theme;
|
||||
using hall_of_meat::Stat;
|
||||
|
||||
// The images: an icon per stat (in Stat's order), then the logo and the card's shapes.
|
||||
enum Picture : std::size_t { stat_icons, logo = stat_icons + 8, row_bar, panel, scratches, underline, picture_count };
|
||||
struct Source {
|
||||
const ui::Texture* texture;
|
||||
std::uint32_t side; // fitted into a side x side square, its aspect kept
|
||||
bool brightness{}; // white with the texture's brightness as alpha (light marks on black), else its own alpha
|
||||
};
|
||||
constexpr std::array<Source, picture_count> sources{{
|
||||
{&ui::stopwatch, 64}, {&ui::wipeout, 64}, {&ui::wipeout_broken, 64}, {&ui::spread_eagle, 64},
|
||||
{&ui::airtime, 64}, {&ui::gap_height, 64}, {&ui::flaming_wheel, 64}, {&ui::roll, 64},
|
||||
{&ui::thrasher_wordmark, 512}, {&ui::brush_bar, 512}, {&ui::rough_tile, 256}, {&ui::scratches, 512, true},
|
||||
{&ui::streak, 512},
|
||||
}};
|
||||
using Images = std::array<std::optional<frostbite::Image>, picture_count>;
|
||||
|
||||
struct Reads {
|
||||
std::mutex mutex;
|
||||
bool started{};
|
||||
std::future<Images> pending;
|
||||
Images decoded; // kept: every atlas the overlay builds (one per swapchain) needs them
|
||||
};
|
||||
Reads& reads() {
|
||||
static auto* state = new Reads;
|
||||
return *state;
|
||||
}
|
||||
|
||||
// The picture's part of the texture, white: with its own alpha, or its brightness as alpha.
|
||||
frostbite::Image cut(const frostbite::Image& image, const ui::Region& region, bool brightness) {
|
||||
const auto at = [](float fraction, std::uint32_t size) { return static_cast<std::uint32_t>(fraction * static_cast<float>(size) + 0.5f); };
|
||||
if (!image.width || !image.height || image.rgba.size() != std::size_t{image.width} * image.height * 4)
|
||||
throw std::runtime_error("the texture is empty");
|
||||
const auto x0 = std::min(at(region.left, image.width), image.width - 1), y0 = std::min(at(region.top, image.height), image.height - 1);
|
||||
const auto x1 = std::clamp(at(region.right, image.width), x0 + 1, image.width);
|
||||
const auto y1 = std::clamp(at(region.bottom, image.height), y0 + 1, image.height);
|
||||
frostbite::Image result{x1 - x0, y1 - y0, std::vector<std::uint8_t>(std::size_t{x1 - x0} * (y1 - y0) * 4)};
|
||||
for (std::uint32_t y = y0; y < y1; ++y)
|
||||
std::memcpy(result.rgba.data() + std::size_t{y - y0} * result.width * 4,
|
||||
image.rgba.data() + (std::size_t{y} * image.width + x0) * 4, std::size_t{result.width} * 4);
|
||||
for (std::size_t i = 0; i < result.rgba.size(); i += 4) {
|
||||
auto* p = result.rgba.data() + i;
|
||||
if (brightness) p[3] = std::max({p[0], p[1], p[2]});
|
||||
p[0] = p[1] = p[2] = 255;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Images decode() {
|
||||
Images result;
|
||||
try {
|
||||
vfs::GameTextures textures(hall_of_meat::game_folder());
|
||||
for (std::size_t index = 0; index < picture_count; ++index) {
|
||||
const auto& source = sources[index];
|
||||
try {
|
||||
result[index] = cut(textures.read(source.texture->toc, source.texture->bundle, source.texture->name, source.side),
|
||||
source.texture->region, source.brightness);
|
||||
} catch (const std::exception& error) {
|
||||
logging::log(logging::Level::warning, logging::Channel::graphics, "Hall of Meat image {} is unavailable: {}.",
|
||||
source.texture->name, error.what());
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
logging::log(logging::Level::warning, logging::Channel::graphics, "Hall of Meat's images are unavailable: {}.", error.what());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// The images in the atlas being built or drawn with: render thread only.
|
||||
struct Loaded {
|
||||
ImFontAtlas* atlas{};
|
||||
bool filled{};
|
||||
std::array<int, picture_count> rects{}; // each one's custom rect in the atlas; -1 none
|
||||
};
|
||||
Loaded& loaded() {
|
||||
static Loaded state;
|
||||
return state;
|
||||
}
|
||||
// A picture in the atlas the overlay draws with: where, and its texture coordinates.
|
||||
struct Found {
|
||||
const ImFontAtlasCustomRect* rect{};
|
||||
ImVec2 uv0, uv1;
|
||||
};
|
||||
bool find(Picture picture, Found& found) {
|
||||
const auto& l = loaded();
|
||||
if (!l.filled || !l.atlas || ImGui::GetIO().Fonts != l.atlas || !l.atlas->TexID || l.rects[picture] < 0) return false;
|
||||
found.rect = l.atlas->GetCustomRectByIndex(l.rects[picture]);
|
||||
if (!found.rect || !found.rect->IsPacked() || !found.rect->Width || !found.rect->Height) return false;
|
||||
l.atlas->CalcCustomRectUV(found.rect, &found.uv0, &found.uv1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fitted into the box with its aspect kept, centred: an icon, a logo.
|
||||
bool draw_image(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint) {
|
||||
Found found;
|
||||
if (!find(picture, found)) return false;
|
||||
const auto* rect = found.rect;
|
||||
const float scale = std::min((max.x - min.x) / rect->Width, (max.y - min.y) / rect->Height);
|
||||
const ImVec2 size(rect->Width * scale, rect->Height * scale);
|
||||
const ImVec2 at(min.x + (max.x - min.x - size.x) * 0.5f, min.y + (max.y - min.y - size.y) * 0.5f);
|
||||
draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + size.x, at.y + size.y), found.uv0, found.uv1, tint);
|
||||
return true;
|
||||
}
|
||||
// Its body (ui_textures.h Texture::body) stretched onto the box, the rest of it around: a brush
|
||||
// stroke's bar on the box, its splatter beyond.
|
||||
bool draw_shape(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint) {
|
||||
Found found;
|
||||
if (!find(picture, found)) return false;
|
||||
const auto& body = sources[picture].texture->body;
|
||||
const float width = (max.x - min.x) / (body.right - body.left), height = (max.y - min.y) / (body.bottom - body.top);
|
||||
const ImVec2 at(min.x - body.left * width, min.y - body.top * height);
|
||||
draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + width, at.y + height), found.uv0, found.uv1, tint);
|
||||
return true;
|
||||
}
|
||||
// Nine-sliced onto the box: its corners (Texture::slice of the picture) kept `edge` pixels square,
|
||||
// its edges stretched along them and its middle across the rest. A box smaller than two edges
|
||||
// shrinks them to half of it.
|
||||
bool draw_panel(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, float edge, ImU32 tint) {
|
||||
Found found;
|
||||
if (!find(picture, found)) return false;
|
||||
const float slice = sources[picture].texture->slice;
|
||||
const float edge_x = std::clamp(edge, 0.0f, (max.x - min.x) * 0.5f), edge_y = std::clamp(edge, 0.0f, (max.y - min.y) * 0.5f);
|
||||
const float uv_x = slice * (found.uv1.x - found.uv0.x), uv_y = slice * (found.uv1.y - found.uv0.y);
|
||||
const std::array<float, 4> xs{min.x, min.x + edge_x, max.x - edge_x, max.x}, ys{min.y, min.y + edge_y, max.y - edge_y, max.y};
|
||||
const std::array<float, 4> us{found.uv0.x, found.uv0.x + uv_x, found.uv1.x - uv_x, found.uv1.x};
|
||||
const std::array<float, 4> vs{found.uv0.y, found.uv0.y + uv_y, found.uv1.y - uv_y, found.uv1.y};
|
||||
for (std::size_t row = 0; row < 3; ++row)
|
||||
for (std::size_t column = 0; column < 3; ++column)
|
||||
draw->AddImage(loaded().atlas->TexID, ImVec2(xs[column], ys[row]), ImVec2(xs[column + 1], ys[row + 1]),
|
||||
ImVec2(us[column], vs[row]), ImVec2(us[column + 1], vs[row + 1]), tint);
|
||||
return true;
|
||||
}
|
||||
|
||||
// `colour` at `opacity` (0 to 1) of its own alpha.
|
||||
ImU32 faded(ImU32 colour, float opacity) {
|
||||
const auto alpha = static_cast<ImU32>(((colour >> IM_COL32_A_SHIFT) & 0xff) * std::clamp(opacity, 0.0f, 1.0f));
|
||||
return (colour & ~IM_COL32_A_MASK) | (alpha << IM_COL32_A_SHIFT);
|
||||
}
|
||||
// Text over a soft drop shadow, the shadow as see-through as the text.
|
||||
void shadowed_text(ImDrawList* draw, ImFont* font, float size, ImVec2 at, ImU32 colour, const char* text) {
|
||||
const float offset = std::max(1.0f, size / 16.0f);
|
||||
const float opacity = static_cast<float>((colour >> IM_COL32_A_SHIFT) & 0xff) / 255.0f;
|
||||
draw->AddText(font, size, ImVec2(at.x + offset, at.y + offset), faded(IM_COL32_BLACK, opacity * 0.7f), text);
|
||||
draw->AddText(font, size, at, colour, text);
|
||||
}
|
||||
float text_width(ImFont* font, float size, const std::string& text) { return font->CalcTextSizeA(size, FLT_MAX, 0.0f, text.c_str()).x; }
|
||||
|
||||
constexpr ImU32 bruised = IM_COL32(255, 196, 36, 255), broken = IM_COL32(232, 32, 32, 255),
|
||||
dark_red = IM_COL32(150, 12, 12, 255), hot = IM_COL32(255, 255, 255, 255);
|
||||
ImU32 mix(ImU32 from, ImU32 to, float amount) {
|
||||
const auto channel = [&](int shift) {
|
||||
const float a = static_cast<float>((from >> shift) & 0xff), b = static_cast<float>((to >> shift) & 0xff);
|
||||
return static_cast<int>(a + (b - a) * std::clamp(amount, 0.0f, 1.0f));
|
||||
};
|
||||
return IM_COL32(channel(IM_COL32_R_SHIFT), channel(IM_COL32_G_SHIFT), channel(IM_COL32_B_SHIFT), channel(IM_COL32_A_SHIFT));
|
||||
}
|
||||
// `colour` darkened to `light` (0 black, 1 itself), at `alpha`.
|
||||
ImU32 lit(ImU32 colour, float light, float alpha) {
|
||||
const auto channel = [&](int shift) {
|
||||
return static_cast<ImU32>(static_cast<float>((colour >> shift) & 0xff) * std::clamp(light, 0.0f, 1.0f)) << shift;
|
||||
};
|
||||
const auto a = static_cast<ImU32>(255.0f * std::clamp(alpha, 0.0f, 1.0f));
|
||||
return channel(IM_COL32_R_SHIFT) | channel(IM_COL32_G_SHIFT) | channel(IM_COL32_B_SHIFT) | a << IM_COL32_A_SHIFT;
|
||||
}
|
||||
|
||||
// The screen at `strength` (0 to 1), as a hard hit shows it: darkened a little, the broken bones' red
|
||||
// washing in from all four edges, top and bottom deeper than the sides.
|
||||
void draw_break_pulse(float strength) {
|
||||
constexpr float darkening = 0.3f, edge_opacity = 0.7f, top_bottom_reach = 0.3f, sides_reach = 0.18f;
|
||||
strength = std::clamp(strength, 0.0f, 1.0f);
|
||||
const auto display = ImGui::GetIO().DisplaySize;
|
||||
if (strength <= 0 || display.x <= 0 || display.y <= 0) return;
|
||||
auto* draw = ImGui::GetBackgroundDrawList();
|
||||
const ImVec2 min(0, 0), max(display.x, display.y);
|
||||
draw->AddRectFilled(min, max, faded(IM_COL32_BLACK, darkening * strength));
|
||||
const auto edge = faded(broken, edge_opacity * strength), clear = faded(broken, 0);
|
||||
const float vertical = display.y * top_bottom_reach * strength, horizontal = display.x * sides_reach * strength;
|
||||
// Each band from its edge (the colour) inwards (clear); corners: top left, top right, bottom right, bottom left.
|
||||
draw->AddRectFilledMultiColor(min, ImVec2(max.x, vertical), edge, edge, clear, clear);
|
||||
draw->AddRectFilledMultiColor(ImVec2(0, max.y - vertical), max, clear, clear, edge, edge);
|
||||
draw->AddRectFilledMultiColor(min, ImVec2(horizontal, max.y), edge, clear, clear, edge);
|
||||
draw->AddRectFilledMultiColor(ImVec2(max.x - horizontal, 0), max, clear, edge, edge, clear);
|
||||
}
|
||||
|
||||
// The hurt bodies of skate.'s skeleton mesh as an x-ray over the world, projected like the nametags:
|
||||
// bruised ones yellow, broken ones a slowly pulsing red, a fresh hit flashing white-hot; the rest are
|
||||
// not drawn. Every triangle of a hurt body is drawn back to front, each vertex lit by how it faces
|
||||
// the camera: an outline near solid and a middle fainter, so the bones read through each other and
|
||||
// against the world. A triangle reaching into an unhurt body fades out.
|
||||
void draw_skeleton(const hall_of_meat::Skeleton& skeleton) {
|
||||
using Vec3 = std::array<float, 3>;
|
||||
const auto dot = [](const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; };
|
||||
const auto& positions = skeleton.positions;
|
||||
const auto count = positions.size();
|
||||
const auto* mesh = skeleton.mesh.get();
|
||||
if (!count || count > 0xffff || !mesh || mesh->parts.size() != count || skeleton.normals.size() != count ||
|
||||
skeleton.alpha <= 0 || !(skeleton.vertical_fov > 1 && skeleton.vertical_fov < 175))
|
||||
return;
|
||||
std::array<ImU32, skater_body::count> paint{}; // 0: not drawn
|
||||
const double time = ImGui::GetTime();
|
||||
for (std::size_t body = 0; body < skater_body::count; ++body) {
|
||||
const auto injury = skeleton.injuries[body];
|
||||
if (injury == hall_of_meat::Injury::none) continue;
|
||||
const ImU32 colour = injury == hall_of_meat::Injury::broken
|
||||
? mix(dark_red, broken, 0.5f + 0.5f * static_cast<float>(std::sin(time * 6.0))) : bruised;
|
||||
paint[body] = mix(colour, hot, skeleton.flashes[body] * 0.8f);
|
||||
}
|
||||
constexpr float solid = 0.6f;
|
||||
const auto display = ImGui::GetIO().DisplaySize;
|
||||
const auto& m = skeleton.camera;
|
||||
const Vec3 right{m[0], m[1], m[2]}, up{m[4], m[5], m[6]}, back{m[8], m[9], m[10]}, origin{m[12], m[13], m[14]};
|
||||
const float focal = display.y / (2.0f * std::tan(skeleton.vertical_fov * 3.14159265f / 360.0f));
|
||||
const ImVec2 centre(display.x * 0.5f, display.y * 0.5f);
|
||||
// Render thread only: reused frame to frame.
|
||||
static std::vector<ImVec2> at;
|
||||
static std::vector<float> depth;
|
||||
static std::vector<ImU32> colour; // 0 for an unhurt body's vertex
|
||||
static std::vector<std::pair<float, std::uint32_t>> order; // a triangle's depth, its first index
|
||||
at.resize(count);
|
||||
depth.resize(count);
|
||||
colour.resize(count);
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
const auto& p = positions[i];
|
||||
const Vec3 delta{p[0] - origin[0], p[1] - origin[1], p[2] - origin[2]};
|
||||
depth[i] = -dot(delta, back);
|
||||
const float z = std::max(depth[i], 0.1f);
|
||||
at[i] = ImVec2(centre.x + dot(delta, right) * focal / z, centre.y - dot(delta, up) * focal / z);
|
||||
const float distance = std::sqrt(dot(delta, delta));
|
||||
const float facing = distance > 1e-4f ? std::abs(dot(skeleton.normals[i], delta)) / distance : 1.0f;
|
||||
const auto painted = paint[std::min<std::size_t>(mesh->parts[i], skater_body::count - 1)];
|
||||
const float faint = 0.25f + 0.6f * (1.0f - facing);
|
||||
colour[i] = painted ? lit(painted, 0.55f + 0.45f * facing, skeleton.alpha * (solid + (1.0f - solid) * faint)) : 0;
|
||||
}
|
||||
const auto& triangles = mesh->triangles;
|
||||
order.clear();
|
||||
for (std::uint32_t t = 0; t + 2 < triangles.size(); t += 3) {
|
||||
const auto a = triangles[t], b = triangles[t + 1], c = triangles[t + 2];
|
||||
if (a >= count || b >= count || c >= count || depth[a] <= 0.1f || depth[b] <= 0.1f || depth[c] <= 0.1f ||
|
||||
(!colour[a] && !colour[b] && !colour[c]))
|
||||
continue;
|
||||
order.emplace_back(depth[a] + depth[b] + depth[c], t);
|
||||
}
|
||||
if (order.empty()) return;
|
||||
std::sort(order.begin(), order.end(), [](const auto& x, const auto& y) { return x.first > y.first; }); // far first
|
||||
auto* draw = ImGui::GetBackgroundDrawList();
|
||||
const auto uv = ImGui::GetFontTexUvWhitePixel();
|
||||
draw->PrimReserve(static_cast<int>(order.size() * 3), static_cast<int>(count));
|
||||
const auto first = draw->_VtxCurrentIdx;
|
||||
for (std::size_t i = 0; i < count; ++i) draw->PrimWriteVtx(at[i], uv, colour[i]);
|
||||
for (const auto& [unused, t] : order)
|
||||
for (std::uint32_t corner = 0; corner < 3; ++corner) draw->PrimWriteIdx(static_cast<ImDrawIdx>(first + triangles[t + corner]));
|
||||
}
|
||||
|
||||
// The card from frame to frame: the total counts up from what it last showed, and a row fades in
|
||||
// from when it came.
|
||||
struct Motion {
|
||||
float total{};
|
||||
double at{}; // ImGui time of the last frame drawn
|
||||
std::array<double, 8> since{-1, -1, -1, -1, -1, -1, -1, -1}; // each stat's row: when it came onto the card
|
||||
};
|
||||
|
||||
// The score card as skate. 3's Hall of Meat shows it, in the bottom left corner where skate.'s own HUD
|
||||
// shows (hidden meanwhile: hall_of_meat_hud.h), growing upwards as rows come: a brush bar per stat
|
||||
// with its icon, value and points, then a scratched panel with the THRASHER logo, the title in its
|
||||
// arch, the total on a blue stroke and the badge. skate.'s colours: dark bars and panel, its blue for
|
||||
// the icons, the logo and the stroke, white numbers. A shape not loaded draws a plain tile instead.
|
||||
void draw_card(const hall_of_meat::Card& card, Motion& motion) {
|
||||
// From the screen's bottom left corner, in 1080p pixels; as wide as the logo it carries.
|
||||
constexpr float corner_left = 64.0f, corner_bottom = 96.0f, card_width = 250.0f;
|
||||
constexpr double row_fade_seconds = 0.3; // a new row opens and fades in this long
|
||||
const auto display = ImGui::GetIO().DisplaySize;
|
||||
if (card.opacity <= 0 || display.x <= 0 || display.y <= 0) {
|
||||
motion = {};
|
||||
return;
|
||||
}
|
||||
// The total catches up with its value in about a quarter of a second.
|
||||
const double now = ImGui::GetTime();
|
||||
const float step = static_cast<float>(std::clamp(now - motion.at, 0.0, 0.1));
|
||||
motion.at = now;
|
||||
motion.total += (static_cast<float>(card.total) - motion.total) * (1.0f - std::exp(-step * 12.0f));
|
||||
if (std::abs(static_cast<float>(card.total) - motion.total) < 1.0f) motion.total = static_cast<float>(card.total);
|
||||
|
||||
auto& s = detail::state();
|
||||
auto* title_font = s.menu.title ? s.menu.title : ImGui::GetFont();
|
||||
auto* heading = s.menu.heading ? s.menu.heading : ImGui::GetFont();
|
||||
auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
|
||||
auto* draw = ImGui::GetBackgroundDrawList();
|
||||
const float k = display.y / 1080.0f, o = card.opacity;
|
||||
const auto fade = [o](ImU32 colour) { return faded(colour, o); };
|
||||
const float left = corner_left * k, right = left + card_width * k, pad = 14.0f * k;
|
||||
unsigned seed = 211u;
|
||||
|
||||
// The panel's height, under the rows: the logo, the title (in the logo's arch), the total and the badge.
|
||||
const float logo_height = 72.0f * k, title_size = 22.0f * k, total_size = 56.0f * k, badge_size = 18.0f * k;
|
||||
const float edge = 16.0f * k;
|
||||
const float logo_step = logo_height * ui::thrasher_wordmark_arch + 4.0f * k;
|
||||
const float height = pad * 2.0f + logo_step + title_size + total_size + (card.badge.empty() ? 0.0f : badge_size + 4.0f * k);
|
||||
|
||||
// A bar per stat: its icon, what was measured and what it scored. A new one opens and fades in,
|
||||
// and the card grows upwards by it. Every bar is drawn before any row's content, so no bar's
|
||||
// splatter covers another's numbers.
|
||||
struct Line {
|
||||
const hall_of_meat::CardRow* row{};
|
||||
float top{}, shown{};
|
||||
};
|
||||
const float row_height = 30.0f * k, row_gap = 10.0f * k, icon = 22.0f * k, text = 18.0f * k;
|
||||
std::vector<Line> lines;
|
||||
float rows_height{};
|
||||
for (const auto& row : card.rows) {
|
||||
auto& came = motion.since[static_cast<std::size_t>(row.stat)];
|
||||
if (came < 0) came = now;
|
||||
const float shown = static_cast<float>(std::clamp((now - came) / row_fade_seconds, 0.0, 1.0));
|
||||
lines.push_back({&row, rows_height, shown});
|
||||
rows_height += (row_height + row_gap) * shown * (2.0f - shown); // eases out
|
||||
}
|
||||
float y = display.y - corner_bottom * k - height - rows_height;
|
||||
for (auto& line : lines) line.top += y;
|
||||
y += rows_height;
|
||||
for (const auto& line : lines) {
|
||||
const ImVec2 min(left, line.top), max(right, line.top + row_height);
|
||||
const auto colour = faded(theme::tile, o * line.shown * 0.92f);
|
||||
if (!draw_shape(draw, row_bar, min, max, colour)) theme::rough_rect(draw, min, max, colour, ++seed, k);
|
||||
}
|
||||
for (const auto& line : lines) {
|
||||
const auto& row = *line.row;
|
||||
const float middle = line.top + row_height * 0.5f;
|
||||
const auto row_fade = [&](ImU32 colour) { return faded(colour, o * line.shown); };
|
||||
float x = left + pad;
|
||||
if (draw_image(draw, static_cast<Picture>(stat_icons + static_cast<std::size_t>(row.stat)), ImVec2(x, middle - icon * 0.5f),
|
||||
ImVec2(x + icon, middle + icon * 0.5f), row_fade(theme::blue)))
|
||||
x += icon + 10.0f * k;
|
||||
shadowed_text(draw, bold, text, ImVec2(x, middle - text * 0.5f), row_fade(theme::white), row.value.c_str());
|
||||
const auto points = hall_of_meat::grouped(row.points);
|
||||
shadowed_text(draw, bold, text, ImVec2(right - pad - text_width(bold, text, points), middle - text * 0.5f),
|
||||
row_fade(theme::white), points.c_str());
|
||||
}
|
||||
|
||||
const ImVec2 min(left, y), max(right, y + height);
|
||||
const auto tile = fade(faded(theme::tile, 0.92f));
|
||||
if (draw_panel(draw, panel, min, max, edge, tile))
|
||||
draw_shape(draw, scratches, ImVec2(min.x + edge, min.y + edge), ImVec2(max.x - edge, max.y - edge), fade(faded(theme::white, 0.5f)));
|
||||
else
|
||||
theme::rough_rect(draw, min, max, tile, ++seed, k);
|
||||
const float centre = (left + right) * 0.5f;
|
||||
const auto centred = [&](ImFont* font, float size, ImU32 colour, const std::string& line) {
|
||||
shadowed_text(draw, font, size, ImVec2(centre - text_width(font, size, line) * 0.5f, y), colour, line.c_str());
|
||||
y += size;
|
||||
};
|
||||
y += pad;
|
||||
draw_image(draw, logo, ImVec2(left + pad, y), ImVec2(right - pad, y + logo_height), fade(theme::blue));
|
||||
y += logo_step;
|
||||
centred(heading, title_size, fade(theme::white), "Hall of Meat");
|
||||
const auto total = hall_of_meat::grouped(static_cast<long long>(motion.total + 0.5f));
|
||||
const float stroke = text_width(title_font, total_size, total) * 0.5f + 20.0f * k;
|
||||
draw_shape(draw, underline, ImVec2(centre - stroke, y + total_size * 0.55f), ImVec2(centre + stroke, y + total_size * 0.95f),
|
||||
fade(theme::blue));
|
||||
centred(title_font, total_size, fade(theme::white), total);
|
||||
if (!card.badge.empty()) {
|
||||
y += 4.0f * k;
|
||||
centred(bold, badge_size, fade(card.highlight ? theme::good : theme::grey_text), card.badge);
|
||||
}
|
||||
}
|
||||
|
||||
// Render thread only: this frame's, and the card's motion.
|
||||
struct Shown {
|
||||
hall_of_meat::Frame frame;
|
||||
Motion motion;
|
||||
};
|
||||
Shown& shown() {
|
||||
static Shown value;
|
||||
return value;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void prepare_hall_of_meat_images() noexcept {
|
||||
try {
|
||||
auto& state = reads();
|
||||
std::lock_guard lock(state.mutex);
|
||||
if (std::exchange(state.started, true)) return;
|
||||
state.pending = std::async(std::launch::async, &decode);
|
||||
} catch (...) {
|
||||
logging::write(logging::Level::warning, logging::Channel::graphics, "Hall of Meat's images are unavailable: their reading could not start.");
|
||||
}
|
||||
}
|
||||
|
||||
void reserve_hall_of_meat_images(ImFontAtlas& atlas, std::chrono::milliseconds wait) noexcept {
|
||||
auto& l = loaded();
|
||||
l = {};
|
||||
l.rects.fill(-1);
|
||||
try {
|
||||
auto& state = reads();
|
||||
std::lock_guard lock(state.mutex);
|
||||
// A read still running stays pending for the next atlas.
|
||||
if (state.pending.valid() && state.pending.wait_for(wait) == std::future_status::ready) state.decoded = state.pending.get();
|
||||
for (std::size_t index = 0; index < picture_count; ++index) {
|
||||
const auto& image = state.decoded[index];
|
||||
if (!image) continue;
|
||||
if (!l.atlas) {
|
||||
atlas.TexDesiredWidth = std::max(atlas.TexDesiredWidth, 2048);
|
||||
atlas.Flags |= ImFontAtlasFlags_NoPowerOfTwoHeight;
|
||||
l.atlas = &atlas;
|
||||
}
|
||||
l.rects[index] = atlas.AddCustomRectRegular(static_cast<int>(image->width), static_cast<int>(image->height));
|
||||
}
|
||||
} catch (...) {
|
||||
l = {};
|
||||
}
|
||||
}
|
||||
|
||||
void fill_hall_of_meat_images(ImFontAtlas& atlas) noexcept {
|
||||
auto& l = loaded();
|
||||
if (l.atlas != &atlas) return;
|
||||
try {
|
||||
unsigned char* pixels{};
|
||||
int width{}, height{};
|
||||
atlas.GetTexDataAsRGBA32(&pixels, &width, &height);
|
||||
if (!pixels || width <= 0 || height <= 0) {
|
||||
l = {};
|
||||
return;
|
||||
}
|
||||
auto& state = reads();
|
||||
std::lock_guard lock(state.mutex);
|
||||
for (std::size_t index = 0; index < picture_count; ++index) {
|
||||
if (l.rects[index] < 0) continue;
|
||||
const auto& image = *state.decoded[index];
|
||||
const auto* rect = atlas.GetCustomRectByIndex(l.rects[index]);
|
||||
if (!rect || !rect->IsPacked() || rect->X + image.width > static_cast<unsigned>(width) ||
|
||||
rect->Y + image.height > static_cast<unsigned>(height)) {
|
||||
l = {};
|
||||
return;
|
||||
}
|
||||
for (std::uint32_t y = 0; y < image.height; ++y)
|
||||
std::memcpy(pixels + ((static_cast<std::size_t>(rect->Y) + y) * width + rect->X) * 4,
|
||||
image.rgba.data() + static_cast<std::size_t>(y) * image.width * 4, static_cast<std::size_t>(image.width) * 4);
|
||||
}
|
||||
atlas.TexPixelsUseColors = true;
|
||||
l.filled = true;
|
||||
} catch (...) {
|
||||
l = {};
|
||||
}
|
||||
}
|
||||
|
||||
bool hall_of_meat_pending() {
|
||||
auto& s = shown();
|
||||
try {
|
||||
s.frame = hall_of_meat::frame();
|
||||
} catch (...) {
|
||||
s.frame = {};
|
||||
}
|
||||
return !s.frame.skeleton.positions.empty() || s.frame.card.opacity > 0 || s.frame.break_pulse > 0;
|
||||
}
|
||||
|
||||
void draw_hall_of_meat() {
|
||||
auto& s = shown();
|
||||
draw_break_pulse(s.frame.break_pulse); // under the skeleton and the card
|
||||
draw_skeleton(s.frame.skeleton);
|
||||
draw_card(s.frame.card, s.motion);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
#pragma once
|
||||
// The overlay's side of Hall of Meat (hall_of_meat.h): from the local skater's bail until they get
|
||||
// up, the bones it hurt over the world, the bail's score card in the bottom left corner, and the
|
||||
// screen's red edge when a bone breaks. Drawn with skate.'s own icons, logo and UI shapes, read from
|
||||
// the installed game's data (Engine/Game/Build/20260929/ui_textures.h) and placed in the overlay's
|
||||
// font atlas like the chat emotes. All of it on the background draw list, under ReSkate's own menus
|
||||
// and chat, taking no input.
|
||||
#include <chrono>
|
||||
|
||||
struct ImFontAtlas;
|
||||
|
||||
namespace dingosdk::overlay {
|
||||
// Startup: starts reading the images in the background, once.
|
||||
void prepare_hall_of_meat_images() noexcept;
|
||||
// Each atlas the overlay builds: their room reserved first, waiting up to `wait` for a read still
|
||||
// running, and filled after the atlas is built.
|
||||
void reserve_hall_of_meat_images(ImFontAtlas& atlas, std::chrono::milliseconds wait) noexcept;
|
||||
void fill_hall_of_meat_images(ImFontAtlas& atlas) noexcept;
|
||||
// Every presented frame: whether there is something to draw (so the overlay renders with the menu
|
||||
// closed), then the drawing.
|
||||
bool hall_of_meat_pending();
|
||||
void draw_hall_of_meat();
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
#pragma once
|
||||
#include "hall_of_meat_model.h"
|
||||
#include "Extension/Skater/no_bail.h"
|
||||
#include <cstdint>
|
||||
|
||||
// The local skater as Hall of Meat reads it, every physics step No Bail hands on (no_bail.h): what
|
||||
// each body touched (Engine/Game/Build/20260929/skater_body.h) and how the skater moves, on the board
|
||||
// or off it and whether in a ragdoll (Engine/Game/Build/20260929/skater_state.h). Read-only, but for
|
||||
// the physics step length the slow motion gives the skater.
|
||||
namespace dingosdk::hall_of_meat {
|
||||
// Requires the validated build and No Bail started: verifies the layout once.
|
||||
bool start_skater(std::uintptr_t image_base) noexcept;
|
||||
bool skater_available() noexcept;
|
||||
// Physics thread, in the step: the step as hall_of_meat_model.h takes it. Exact here; read outside
|
||||
// the step, the contacts may be half written.
|
||||
Step read_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept;
|
||||
// The local skater's physics step length (seconds of game time): the game sets it only when it builds
|
||||
// the skater's core (hall_of_meat_slow_motion.h). 0 while there is no local skater.
|
||||
float step_length() noexcept;
|
||||
// Game update thread: gives the local skater this physics step length. False while there is no local
|
||||
// skater.
|
||||
bool set_step_length(float seconds) noexcept;
|
||||
}
|
||||
@@ -64,6 +64,30 @@ struct FakeGame final : Game {
|
||||
}
|
||||
SkaterNews news;
|
||||
SkaterNews take_skater_news() override { return std::exchange(news, {}); }
|
||||
std::vector<PhysicsStep> steps;
|
||||
std::vector<PhysicsStep> take_physics_steps() override { return std::exchange(steps, {}); }
|
||||
std::string xray(std::string_view plugin, bool on) override {
|
||||
calls.push_back(std::format("xray {} {}", plugin, on ? "on" : "off"));
|
||||
return {};
|
||||
}
|
||||
std::string hud_corner(std::string_view plugin, bool hidden) override {
|
||||
calls.push_back(std::format("corner {} {}", plugin, hidden ? "on" : "off"));
|
||||
return {};
|
||||
}
|
||||
std::string game_speed(std::string_view plugin, float speed) override {
|
||||
calls.push_back(std::format("slow {} {:.2f}", plugin, speed));
|
||||
return {};
|
||||
}
|
||||
std::string grind_exit(std::string_view plugin, std::optional<GrindExitWish> wish) override {
|
||||
calls.push_back(wish ? std::format("grind {} {} {} {:.2f}", plugin, wish->side, wish->stance, wish->strength) : std::format("grind {} none", plugin));
|
||||
return {};
|
||||
}
|
||||
std::vector<GrindPop> pops;
|
||||
std::vector<GrindPop> take_grind_pops(std::string_view) override { return std::exchange(pops, {}); }
|
||||
std::string hide_pad(std::string_view plugin, bool lt, bool rt, bool left_x) override {
|
||||
calls.push_back(std::format("hide {} {} {} {}", plugin, lt, rt, left_x));
|
||||
return {};
|
||||
}
|
||||
std::optional<Vec3> board_velocity() override { return Vec3{1, -2, 3}; }
|
||||
std::optional<SlamNews> last_slam() override { return SlamNews{7.5f, 12, 100}; }
|
||||
std::string wipeout(float force) override {
|
||||
@@ -125,7 +149,8 @@ struct FakeGame final : Game {
|
||||
ClipRef take_recording(std::string_view) override { return std::exchange(recorded, nullptr); }
|
||||
std::string current_trick() override { return "kickflip"; }
|
||||
std::optional<FlightInput> flight_input(std::string_view) override { return FlightInput{1, 0, 0.5f, 3, 4, true}; }
|
||||
std::optional<PadInput> gamepad() override { return PadInput{0.25f, 0, 0, 0, 0, 1, 0x1000, false}; }
|
||||
std::optional<PadInput> pad = PadInput{0.25f, 0, 0, 0, 0, 1, 0x1000, false};
|
||||
std::optional<PadInput> gamepad() override { return pad; }
|
||||
std::optional<StickInput> joystick() override {
|
||||
StickInput stick;
|
||||
stick.axes = {0, -1, 0.5f, 0, 0, 0};
|
||||
@@ -1611,6 +1636,73 @@ void board_break_snaps_the_board_on_a_slam() {
|
||||
// The FPV drone as a mod (contrib/plugins/fpv-drone): its own tests of the flight maths pass, and
|
||||
// over the fake world (flat ground at y = 0) it flies the camera, keeps the game off the input,
|
||||
// plays its motor, follows the skater and tells the session where it is, within its 2 ms a tick.
|
||||
// Hall of Meat as a mod (contrib/plugins/hall-of-meat): its own checks pass, and a bail fed through
|
||||
// physics_step shows the x-ray, hides the HUD corner, slows the game at a break and keeps the map's best.
|
||||
void hall_of_meat_scores_a_bail() {
|
||||
TestEnvironment environment;
|
||||
std::filesystem::remove_all(scratch / "storage" / "hall-of-meat");
|
||||
auto& game = environment.fake;
|
||||
Host host(environment, Side::client);
|
||||
host.apply({contributed("hall-of-meat")});
|
||||
running(host, "hall-of-meat", environment);
|
||||
check(host.run_console("hallofmeat test"), "hallofmeat test");
|
||||
check(environment.printed("hallofmeat: 19 passed, 0 failed") && !environment.printed("FAIL"),
|
||||
"Hall of Meat's own checks pass:\n" + printed_lines(environment) + environment.all());
|
||||
check(game.called("xray hall-of-meat on") && game.called("corner hall-of-meat off"), "It gets the x-ray and the HUD corner ready\n" + environment.all());
|
||||
host.set_level("sanvan");
|
||||
// A wipeout that breaks the head, then the body lying for two seconds.
|
||||
const auto step = [&](bool wipeout, std::optional<bool> ragdoll, std::vector<PhysicsStep::Body> bodies) {
|
||||
game.steps.push_back({environment.now, 1.0f / 60, wipeout, false, ragdoll, Vec3{0, 0, 0}, 0.0f, std::move(bodies)});
|
||||
ticks(host, environment, 1);
|
||||
};
|
||||
step(true, true, {{"Head", 20, 0, false, false, true}});
|
||||
for (int i = 0; i < 120; ++i) step(false, true, {});
|
||||
check(game.called("corner hall-of-meat on"), "The card takes the HUD corner\n" + environment.all());
|
||||
check(game.called("slow hall-of-meat 0.30"), "A break slows the game\n" + environment.all());
|
||||
const auto& shapes = environment.layers.empty() ? std::vector<plugin_ui::Shape>{} : environment.layers[0].shapes;
|
||||
check(std::any_of(shapes.begin(), shapes.end(), [](const auto& s) { return s.kind == plugin_ui::Shape::Kind::skeleton && s.colours[1] != 0; }) &&
|
||||
std::any_of(shapes.begin(), shapes.end(), [](const auto& s) { return s.kind == plugin_ui::Shape::Kind::game_image && s.text == "thrasher"; }),
|
||||
"The broken head shows on the x-ray, and the card with skate.'s wordmark");
|
||||
// Standing up ends it; it is the map's first, so its best.
|
||||
step(false, false, {});
|
||||
ticks(host, environment, 200);
|
||||
check(game.called("corner hall-of-meat off") && game.called("slow hall-of-meat 1.00"), "The corner and the speed are given back\n" + environment.all());
|
||||
environment.lines.clear();
|
||||
check(host.run_console("hallofmeat") && environment.printed("hallofmeat: on; this map's best ") && environment.printed("Meat (1 hits, 1 broken"),
|
||||
"It says the map's best and the last bail:\n" + printed_lines(environment));
|
||||
}
|
||||
// Grind exits as a mod (contrib/plugins/grind-exits): its own checks pass, and RT held in a grind asks
|
||||
// for the heel side and keeps RT and the stick's lean from the game.
|
||||
void grind_exits_pick_the_side() {
|
||||
TestEnvironment environment;
|
||||
std::filesystem::remove_all(scratch / "storage" / "grind-exits");
|
||||
auto& game = environment.fake;
|
||||
Host host(environment, Side::client);
|
||||
host.apply({contributed("grind-exits")});
|
||||
running(host, "grind-exits", environment);
|
||||
check(host.run_console("grindexit test"), "grindexit test");
|
||||
check(environment.printed("grindexit: 7 passed, 0 failed") && !environment.printed("FAIL"),
|
||||
"Grind exits' own checks pass:\n" + printed_lines(environment) + environment.all());
|
||||
check(game.called("grind grind-exits none"), "It hears its pops from the start");
|
||||
check(host.run_console("grindexit stance goofy"), "grindexit stance");
|
||||
game.pad = PadInput{};
|
||||
game.state = 100;
|
||||
ticks(host, environment, 2);
|
||||
game.state = 400; // grinding
|
||||
ticks(host, environment, 2);
|
||||
game.pad->right_trigger = 1;
|
||||
ticks(host, environment, 2);
|
||||
check(game.called("grind grind-exits heel 2 1.00"), "RT asks for the heel side\n" + environment.all());
|
||||
check(game.called("hide grind-exits false true true"), "and keeps RT and the stick's lean from the game\n" + environment.all());
|
||||
game.pops.push_back({true, "applied", "toe_heel", -1, 1.5f, -1.5f});
|
||||
ticks(host, environment, 1);
|
||||
game.pad->right_trigger = 0;
|
||||
ticks(host, environment, 10);
|
||||
check(game.called("grind grind-exits none") && game.called("hide grind-exits false false false"), "Let go, the game's own again");
|
||||
environment.lines.clear();
|
||||
check(host.run_console("grindexit") && environment.printed("sides set: 1. Last: right of travel (toe_heel)"),
|
||||
"It counts the pop:\n" + printed_lines(environment));
|
||||
}
|
||||
void fpv_drone_flies_and_follows() {
|
||||
TestEnvironment environment;
|
||||
std::filesystem::remove_all(scratch / "storage" / "fpv-drone");
|
||||
@@ -1765,6 +1857,8 @@ int main() {
|
||||
road_rash_marks_the_skater_as_it_bails();
|
||||
board_break_snaps_the_board_on_a_slam();
|
||||
fpv_drone_flies_and_follows();
|
||||
hall_of_meat_scores_a_bail();
|
||||
grind_exits_pick_the_side();
|
||||
reskate_hostile_is_stopped_or_refused_every_time();
|
||||
std::filesystem::remove_all(scratch);
|
||||
std::cout << "plugin tests passed\n";
|
||||
|
||||
@@ -17,13 +17,14 @@ namespace {
|
||||
struct Event {
|
||||
std::string_view name, permission;
|
||||
};
|
||||
constexpr std::array<Event, 22> events{{
|
||||
constexpr std::array<Event, 24> events{{
|
||||
{"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}},
|
||||
{"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"},
|
||||
{"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"},
|
||||
{"skater_state", "events.skater"}, {"bail", "events.skater"}, {"chat", "chat.read"}, {"track_changed", "music"},
|
||||
{"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"},
|
||||
{"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"},
|
||||
{"physics_step", "events.physics"}, {"grind_exit", "events.skater"},
|
||||
}};
|
||||
constexpr std::size_t maximum_key = 128;
|
||||
constexpr std::size_t maximum_command = 48;
|
||||
|
||||
@@ -81,6 +81,15 @@ plugin_ui::Style style_of(lua_State* L, int index) {
|
||||
lua_pop(L, 1);
|
||||
if (field("shadow")) style.shadow = lua_toboolean(L, -1) != 0;
|
||||
lua_pop(L, 1);
|
||||
if (field("font")) {
|
||||
const std::string font = luaL_checkstring(L, -1);
|
||||
if (font == "bold") style.font = 0;
|
||||
else if (font == "body") style.font = 1;
|
||||
else if (font == "heading") style.font = 2;
|
||||
else if (font == "title") style.font = 3;
|
||||
else luaL_error(L, "font is \"bold\", \"body\", \"heading\" or \"title\"");
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
// A picture's source rectangle, {u0, v0, u1, v1} from 0 to 1: one glyph of a font atlas, a tile.
|
||||
if (field("uv")) {
|
||||
luaL_checktype(L, -1, LUA_TTABLE);
|
||||
@@ -184,6 +193,70 @@ struct UiApi {
|
||||
add(L, plugin, {plugin_ui::Shape::Kind::world_image, {point(L, 3, "the picture's place")}, height, std::move(file), style_of(L, 5)});
|
||||
return 0;
|
||||
}
|
||||
// d:game_image(name, top_left, bottom_right, style): one of the game's own pictures; style.fit
|
||||
// "contain" (the default), "shape" or "panel", style.edge a panel's corner size in pixels.
|
||||
static int game_image(lua_State* L) {
|
||||
auto& plugin = drawing(L);
|
||||
auto name = checked_text(L, 2, 40, "a game picture's name");
|
||||
if (std::find(plugin_ui::game_images.begin(), plugin_ui::game_images.end(), name) == plugin_ui::game_images.end()) {
|
||||
std::string known;
|
||||
for (const auto one : plugin_ui::game_images) known += (known.empty() ? "" : ", ") + std::string(one);
|
||||
luaL_error(L, "the game's pictures are %s", known.c_str());
|
||||
}
|
||||
plugin_ui::Shape shape{plugin_ui::Shape::Kind::game_image, {point(L, 3, "a picture's top left"), point(L, 4, "a picture's bottom right")}, 0,
|
||||
std::move(name), style_of(L, 5)};
|
||||
if (lua_istable(L, 5)) {
|
||||
if (lua_getfield(L, 5, "fit") != LUA_TNIL) {
|
||||
const std::string fit = luaL_checkstring(L, -1);
|
||||
if (fit == "contain") shape.fit = 0;
|
||||
else if (fit == "shape") shape.fit = 1;
|
||||
else if (fit == "panel") shape.fit = 2;
|
||||
else luaL_error(L, "fit is \"contain\", \"shape\" or \"panel\"");
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
if (lua_getfield(L, 5, "edge") != LUA_TNIL) shape.edge = std::clamp(finite(L, -1, "edge"), 0.0f, 400.0f);
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
add(L, plugin, std::move(shape));
|
||||
return 0;
|
||||
}
|
||||
// d:gradient(top_left, bottom_right, {top_left, top_right, bottom_right, bottom_left}): a box
|
||||
// shading between the colours of its corners.
|
||||
static int gradient(lua_State* L) {
|
||||
auto& plugin = drawing(L);
|
||||
plugin_ui::Shape shape{plugin_ui::Shape::Kind::gradient, {point(L, 2, "a box's top left"), point(L, 3, "a box's bottom right")}};
|
||||
luaL_checktype(L, 4, LUA_TTABLE);
|
||||
for (int i = 1; i <= 4; ++i) {
|
||||
lua_rawgeti(L, 4, i);
|
||||
if (lua_isnil(L, -1)) luaL_argerror(L, 4, "a gradient has four colours: top left, top right, bottom right, bottom left");
|
||||
shape.colours.push_back(colour_of(L, -1));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
add(L, plugin, std::move(shape));
|
||||
return 0;
|
||||
}
|
||||
// d:skeleton(paint, alpha): the local skater's x-ray (reskate.skater.xray has it ready), each body
|
||||
// named in `paint` in its colour, the rest left out; `alpha` (0 to 1) fades it all.
|
||||
static int skeleton(lua_State* L) {
|
||||
auto& plugin = drawing(L);
|
||||
luaL_checktype(L, 2, LUA_TTABLE);
|
||||
const double alpha = luaL_optnumber(L, 3, 1.0);
|
||||
if (!(alpha >= 0 && alpha <= 1)) luaL_argerror(L, 3, "alpha is 0 to 1");
|
||||
plugin_ui::Shape shape{plugin_ui::Shape::Kind::skeleton};
|
||||
shape.colours.assign(plugin_ui::body_names.size(), 0);
|
||||
shape.style.colour = 0x00ffffffu | static_cast<std::uint32_t>(alpha * 255.0 + 0.5) << 24;
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, 2)) {
|
||||
if (lua_type(L, -2) != LUA_TSTRING) luaL_error(L, "the skeleton's paint is by body name");
|
||||
const std::string name = lua_tostring(L, -2);
|
||||
const auto found = std::find(plugin_ui::body_names.begin() + 1, plugin_ui::body_names.end(), name);
|
||||
if (found == plugin_ui::body_names.end()) luaL_error(L, "\"%s\" is not a body: the bodies are body_hit's names", name.c_str());
|
||||
shape.colours[static_cast<std::size_t>(found - plugin_ui::body_names.begin())] = colour_of(L, -1);
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
add(L, plugin, std::move(shape));
|
||||
return 0;
|
||||
}
|
||||
static int text(lua_State* L) { return text_shape(L, plugin_ui::Shape::Kind::text); }
|
||||
static int world_text(lua_State* L) { return text_shape(L, plugin_ui::Shape::Kind::world_text); }
|
||||
static int rect(lua_State* L) { return two_points(L, plugin_ui::Shape::Kind::rect); }
|
||||
@@ -497,6 +570,9 @@ void UiApi::install(Plugin& plugin, lua_State* L) {
|
||||
set_function(L, "world_circle", UiApi::world_circle);
|
||||
set_function(L, "image", UiApi::image);
|
||||
set_function(L, "world_image", UiApi::world_image);
|
||||
set_function(L, "game_image", UiApi::game_image);
|
||||
set_function(L, "gradient", UiApi::gradient);
|
||||
set_function(L, "skeleton", UiApi::skeleton);
|
||||
set_function(L, "size", UiApi::size);
|
||||
set_function(L, "world_to_screen", UiApi::world_to_screen);
|
||||
readonly();
|
||||
|
||||
@@ -120,6 +120,42 @@ struct WorldApi {
|
||||
plugin.wipeout_at_ = now;
|
||||
return result(L, game(plugin).wipeout(static_cast<float>(force)));
|
||||
}
|
||||
// skater.xray(on): the x-ray ready (the renderer followed) while on; d:skeleton draws it.
|
||||
static int xray(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "overlay", "reskate.skater.xray");
|
||||
return result(L, game(plugin).xray(plugin.id(), lua_toboolean(L, 1) != 0));
|
||||
}
|
||||
// skater.grind_exit(wish): the next pop out of a grind, {side = "toe"|"heel"|"left"|"right",
|
||||
// stance = "regular"|"goofy", strength = 0.1 to 1}; nil the game's own exit.
|
||||
static int grind_exit(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "skater.control", "reskate.skater.grind_exit");
|
||||
if (lua_isnoneornil(L, 1)) return result(L, game(plugin).grind_exit(plugin.id(), std::nullopt));
|
||||
luaL_checktype(L, 1, LUA_TTABLE);
|
||||
GrindExitWish wish;
|
||||
lua_getfield(L, 1, "side");
|
||||
wish.side = luaL_checkstring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
if (wish.side != "toe" && wish.side != "heel" && wish.side != "left" && wish.side != "right")
|
||||
luaL_error(L, "a grind exit's side is \"toe\", \"heel\", \"left\" or \"right\"");
|
||||
lua_getfield(L, 1, "stance");
|
||||
if (!lua_isnil(L, -1)) {
|
||||
const std::string stance = luaL_checkstring(L, -1);
|
||||
if (stance == "regular") wish.stance = 1;
|
||||
else if (stance == "goofy") wish.stance = 2;
|
||||
else luaL_error(L, "a stance is \"regular\" or \"goofy\"");
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, 1, "strength");
|
||||
if (!lua_isnil(L, -1)) {
|
||||
const double strength = luaL_checknumber(L, -1);
|
||||
if (!(strength >= 0.1 && strength <= 1)) luaL_error(L, "a grind exit's strength is 0.1 to 1");
|
||||
wish.strength = static_cast<float>(strength);
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
return result(L, game(plugin).grind_exit(plugin.id(), std::move(wish)));
|
||||
}
|
||||
// board.motion(): the board's velocity in the last physics step, or nil.
|
||||
static int board_motion(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
@@ -249,6 +285,26 @@ struct WorldApi {
|
||||
game(plugin).hide_hud(plugin.id(), lua_toboolean(L, 1) != 0);
|
||||
return 0;
|
||||
}
|
||||
// hud.corner(hidden): skate.'s bottom left HUD hidden while true; false keeps it ready to hide.
|
||||
static int hud_corner(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "hud", "reskate.hud.corner");
|
||||
return result(L, game(plugin).hud_corner(plugin.id(), lua_toboolean(L, 1) != 0));
|
||||
}
|
||||
// time.slow(speed): the game at `speed` (0.05 to 1; 1 or nil gives it back); single player only.
|
||||
static int slow(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "game.speed", "reskate.time.slow");
|
||||
const double speed = luaL_optnumber(L, 1, 1.0);
|
||||
if (!(speed >= 0.05 && speed <= 1)) luaL_argerror(L, 1, "a game speed is 0.05 to 1");
|
||||
return result(L, game(plugin).game_speed(plugin.id(), static_cast<float>(speed)));
|
||||
}
|
||||
static int speed(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "game.speed", "reskate.time.speed");
|
||||
lua_pushnumber(L, game(plugin).game_speed_now());
|
||||
return 1;
|
||||
}
|
||||
// ---- world.objects -----------------------------------------------------------------------------
|
||||
static int catalog(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
@@ -1234,6 +1290,28 @@ struct WorldApi {
|
||||
need(L, plugin, "input", "reskate.input.capture");
|
||||
return result(L, game(plugin).capture_input(plugin.id(), lua_toboolean(L, 1) != 0));
|
||||
}
|
||||
// input.hide({left_trigger = true, right_trigger = true, left_x = "grinding"}): those kept from the
|
||||
// game while asked; nil or {} gives them back.
|
||||
static int input_hide(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "input", "reskate.input.hide");
|
||||
bool lt{}, rt{}, left_x{};
|
||||
if (!lua_isnoneornil(L, 1)) {
|
||||
luaL_checktype(L, 1, LUA_TTABLE);
|
||||
lua_getfield(L, 1, "left_trigger");
|
||||
lt = lua_toboolean(L, -1) != 0;
|
||||
lua_getfield(L, 1, "right_trigger");
|
||||
rt = lua_toboolean(L, -1) != 0;
|
||||
lua_getfield(L, 1, "left_x");
|
||||
if (!lua_isnil(L, -1)) {
|
||||
if (lua_type(L, -1) != LUA_TSTRING || std::string_view(lua_tostring(L, -1)) != "grinding")
|
||||
luaL_error(L, "left_x is hidden only while grinding: left_x = \"grinding\"");
|
||||
left_x = true;
|
||||
}
|
||||
lua_pop(L, 3);
|
||||
}
|
||||
return result(L, game(plugin).hide_pad(plugin.id(), lt, rt, left_x));
|
||||
}
|
||||
// ---- ui.screen ---------------------------------------------------------------------------------------
|
||||
// screen(title, build): a full-screen screen, drawn while open() has it open; Esc closes it.
|
||||
static int screen(lua_State* L) {
|
||||
@@ -1295,12 +1373,14 @@ static void into(lua_State* L, const char* field, std::initializer_list<std::pai
|
||||
|
||||
void WorldApi::install(Plugin& plugin, lua_State* L) {
|
||||
(void)plugin;
|
||||
into(L, "skater", {{"state", state}, {"noclip", noclip}, {"boost", boost}, {"freeze", freeze}, {"wipeout", wipeout}});
|
||||
into(L, "skater", {{"state", state}, {"noclip", noclip}, {"boost", boost}, {"freeze", freeze}, {"wipeout", wipeout}, {"xray", xray},
|
||||
{"grind_exit", grind_exit}});
|
||||
into(L, "board", {{"motion", board_motion}, {"bones", board_bones}, {"set", board_set}, {"hide", board_hide}, {"clear", board_clear},
|
||||
{"park", board_park}, {"toss", board_toss}});
|
||||
into(L, "camera", {{"look", camera_look}, {"set", camera_set}, {"follow", camera_follow}, {"release", camera_release}, {"held", camera_held}, {"get", camera_get}});
|
||||
into(L, "trainer", {{"set_point", set_point}, {"back", back}});
|
||||
into(L, "hud", {{"hide", hide_hud}});
|
||||
into(L, "hud", {{"hide", hide_hud}, {"corner", hud_corner}});
|
||||
into(L, "time", {{"slow", slow}, {"speed", speed}});
|
||||
into(L, "objects", {{"catalog", catalog}, {"list", objects}, {"place", place}, {"move", move}, {"remove", remove}});
|
||||
into(L, "world", {{"set_time", set_time}, {"set_control", set_control}, {"set_park", set_park}});
|
||||
into(L, "travel", {{"locations", locations}, {"levels", levels}, {"go", go}, {"load", load}});
|
||||
@@ -1318,7 +1398,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
|
||||
{"game", game_current}, {"game_next", game_next}});
|
||||
into(L, "progression", {{"get", progress}});
|
||||
into(L, "settings", {{"game", setting}, {"names", setting_names}});
|
||||
into(L, "input", {{"bind", bind}, {"flight", input_flight}, {"gamepad", input_gamepad}, {"joystick", input_joystick}, {"capture", input_capture}});
|
||||
into(L, "input", {{"bind", bind}, {"flight", input_flight}, {"gamepad", input_gamepad}, {"joystick", input_joystick}, {"capture", input_capture},
|
||||
{"hide", input_hide}});
|
||||
into(L, "ui", {{"screen", screen}, {"open", open}, {"close", close}});
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,36 @@ struct SkaterNews {
|
||||
std::vector<Hit> hits;
|
||||
std::vector<Fall> falls;
|
||||
};
|
||||
// One physics step of the local skater (events.physics): when (the steady clock, ms, as
|
||||
// reskate.time.now), how long it simulated, and what the body did; only the bodies touching something.
|
||||
struct PhysicsStep {
|
||||
double at{};
|
||||
float seconds{};
|
||||
bool wipeout{}, airborne{};
|
||||
std::optional<bool> ragdoll;
|
||||
std::optional<Vec3> velocity;
|
||||
std::optional<float> spin;
|
||||
struct Body {
|
||||
std::string name; // as body_hit names it
|
||||
float impact{}, slide{};
|
||||
bool board{}, vehicle{}, world{}; // world: anything but the board and vehicles
|
||||
};
|
||||
std::vector<Body> bodies;
|
||||
};
|
||||
// What a plugin asks of the next pop out of a grind: "toe", "heel", "left" or "right" (of travel),
|
||||
// the player's stance (0 not set, 1 regular, 2 goofy: what toe and heel need), and 0.1 to 1 of the
|
||||
// full sideways speed. And a pop as it went (Extension/Skater/grind_exit.h).
|
||||
struct GrindExitWish {
|
||||
std::string side;
|
||||
int stance{};
|
||||
float strength{1};
|
||||
};
|
||||
struct GrindPop {
|
||||
bool applied{};
|
||||
std::string reason, basis;
|
||||
int toward{}; // +1 the left of travel, -1 the right, 0 the game's own
|
||||
float before{}, after{};
|
||||
};
|
||||
// The last slam: its force and the game's cause number, and how long ago (ms).
|
||||
struct SlamNews {
|
||||
float force{};
|
||||
@@ -268,6 +298,23 @@ public:
|
||||
virtual SkaterNews take_skater_news() { return {}; }
|
||||
virtual std::optional<Vec3> board_velocity() { return std::nullopt; }
|
||||
virtual std::optional<SlamNews> last_slam() { return std::nullopt; }
|
||||
// Every physics step since the last tick (kept once asked for: at most 512).
|
||||
virtual std::vector<PhysicsStep> take_physics_steps() { return {}; }
|
||||
// The skater's x-ray ready while any plugin asks (Extension/Skater/xray.h); d:skeleton draws it.
|
||||
virtual std::string xray(std::string_view /*plugin*/, bool) { return unavailable; }
|
||||
// skate.'s bottom left HUD hidden while any plugin asks; false keeps the plugin ready to hide it.
|
||||
virtual std::string hud_corner(std::string_view /*plugin*/, bool) { return unavailable; }
|
||||
// The game run at `speed` (0.05 to 1; 1 drops the ask) while it is the slowest asked; never in a
|
||||
// multiplayer session. And the speed it runs at for plugins now.
|
||||
virtual std::string game_speed(std::string_view /*plugin*/, float) { return unavailable; }
|
||||
virtual float game_speed_now() { return 1; }
|
||||
// The next pop out of a grind (Extension/Skater/grind_exit.h); empty: the game's own exit. One
|
||||
// plugin at a time. Its pops come back from take_grind_pops.
|
||||
virtual std::string grind_exit(std::string_view /*plugin*/, std::optional<GrindExitWish>) { return unavailable; }
|
||||
virtual std::vector<GrindPop> take_grind_pops(std::string_view /*plugin*/) { return {}; }
|
||||
// Pad controls kept from the game while any plugin asks: the triggers, and the left stick's
|
||||
// left/right while the skater grinds.
|
||||
virtual std::string hide_pad(std::string_view /*plugin*/, bool /*lt*/, bool /*rt*/, bool /*left_x while grinding*/) { return unavailable; }
|
||||
// The skater bails as from a slam of `force` (0.5 to 3); not in a throwdown. While No Bail protects
|
||||
// the skater the wipeout is dropped, as any other is.
|
||||
virtual std::string wipeout(float /*force*/) { return unavailable; }
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
#include "Extension/Skater/skater_events.h"
|
||||
#include "Extension/Skater/outfit_overlay.h"
|
||||
#include "Extension/Skater/board_pose.h"
|
||||
#include "Extension/Skater/game_speed.h"
|
||||
#include "Extension/Skater/grind_exit.h"
|
||||
#include "Extension/Skater/xray.h"
|
||||
#include "Extension/UI/hud_corner.h"
|
||||
#include "plugin_camera.h"
|
||||
#include "Extension/Multiplayer/Session/session.h"
|
||||
#include "Extension/Music/external_player.h"
|
||||
@@ -343,6 +347,58 @@ public:
|
||||
}
|
||||
return news;
|
||||
}
|
||||
std::vector<PhysicsStep> take_physics_steps() override {
|
||||
skater_events::want_steps();
|
||||
std::vector<PhysicsStep> out;
|
||||
for (const auto& news : skater_events::take_steps()) {
|
||||
const auto& s = news.step;
|
||||
PhysicsStep step{news.at, s.seconds, s.wipeout, s.airborne, s.ragdoll, std::nullopt, s.spin, {}};
|
||||
if (s.velocity) step.velocity = Vec3{(*s.velocity)[0], (*s.velocity)[1], (*s.velocity)[2]};
|
||||
for (std::size_t i = 1; i < skater_body::count; ++i) {
|
||||
const auto& c = s.body.bodies[i];
|
||||
if (!c.touching) continue;
|
||||
const float slide = std::sqrt(c.slide[0] * c.slide[0] + c.slide[1] * c.slide[1] + c.slide[2] * c.slide[2]);
|
||||
step.bodies.push_back({skater_body::names[i], c.impact, std::isfinite(slide) ? slide : 0.0f, c.hit.board, c.hit.vehicle,
|
||||
c.hit.world || c.hit.kind_5 || c.hit.kind_11});
|
||||
}
|
||||
out.push_back(std::move(step));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::string xray(std::string_view plugin, bool on) override {
|
||||
if (!xray::available()) return "the x-ray is unavailable for this game build";
|
||||
xray::prepare(plugin, on);
|
||||
return {};
|
||||
}
|
||||
std::string hud_corner(std::string_view plugin, bool hidden) override {
|
||||
hud_corner::ask(plugin, hidden);
|
||||
return {};
|
||||
}
|
||||
std::string game_speed(std::string_view plugin, float speed) override {
|
||||
if (speed < 1.0f && multiplayer_session_active()) return "never in a multiplayer session";
|
||||
game_speed::ask(plugin, speed);
|
||||
return {};
|
||||
}
|
||||
float game_speed_now() override { return game_speed::current(); }
|
||||
std::string grind_exit(std::string_view plugin, std::optional<GrindExitWish> wish) override {
|
||||
std::optional<grind_exit::Wish> w;
|
||||
if (wish) {
|
||||
using Side = grind_exit::Wish::Side;
|
||||
w = grind_exit::Wish{wish->side == "toe" ? Side::toe : wish->side == "heel" ? Side::heel : wish->side == "right" ? Side::right : Side::left,
|
||||
wish->stance, wish->strength};
|
||||
}
|
||||
return grind_exit::wish(plugin, w);
|
||||
}
|
||||
std::vector<GrindPop> take_grind_pops(std::string_view plugin) override {
|
||||
std::vector<GrindPop> out;
|
||||
for (auto& p : grind_exit::take_pops(plugin)) out.push_back({p.applied, std::move(p.reason), std::move(p.basis), p.toward, p.before, p.after});
|
||||
return out;
|
||||
}
|
||||
std::string hide_pad(std::string_view plugin, bool lt, bool rt, bool left_x) override {
|
||||
if (!grind_exit::available()) return "hiding pad controls is unavailable for this game build";
|
||||
grind_exit::hide(plugin, {lt, rt, left_x});
|
||||
return {};
|
||||
}
|
||||
std::optional<Vec3> board_velocity() override {
|
||||
skater_events::want();
|
||||
return skater_events::board_velocity();
|
||||
@@ -469,6 +525,10 @@ public:
|
||||
}
|
||||
std::string current_trick() override { return skater_animation::current_trick(); }
|
||||
void tick_animation(std::uintptr_t base, std::uintptr_t client) {
|
||||
xray::tick();
|
||||
hud_corner::tick();
|
||||
game_speed::tick();
|
||||
grind_exit::tick(client);
|
||||
outfit_overlay::tick();
|
||||
board_pose::tick(base, local_skater_component(base, client));
|
||||
const bool allowed = !multiplayer::local_throwdown_active();
|
||||
@@ -570,6 +630,10 @@ public:
|
||||
release_camera(plugin);
|
||||
(void)freeze(plugin, false);
|
||||
hide_hud(plugin, false);
|
||||
xray::prepare(plugin, false);
|
||||
hud_corner::release(plugin);
|
||||
game_speed::ask(plugin, 1.0f);
|
||||
grind_exit::release(plugin);
|
||||
plugin_feed::release_tags(std::string(plugin));
|
||||
if (std::erase_if(binds_, [&](const auto& item) { return item.first.first == plugin; })) publish_binds();
|
||||
}
|
||||
|
||||
@@ -287,6 +287,42 @@ void Host::follow_game() {
|
||||
each([&](Plugin& plugin, const std::string&) { plugin.game_event("bail_end", "events.skater", data); });
|
||||
}
|
||||
}
|
||||
if (wants("events.physics"))
|
||||
for (const auto& step : game.take_physics_steps()) {
|
||||
auto data = Json::object();
|
||||
data["at"] = step.at;
|
||||
data["seconds"] = step.seconds;
|
||||
data["wipeout"] = step.wipeout;
|
||||
data["airborne"] = step.airborne;
|
||||
if (step.ragdoll) data["ragdoll"] = *step.ragdoll;
|
||||
if (step.velocity) data["velocity"] = Json::array({(*step.velocity)[0], (*step.velocity)[1], (*step.velocity)[2]});
|
||||
if (step.spin) data["spin"] = *step.spin;
|
||||
auto bodies = Json::object();
|
||||
for (const auto& b : step.bodies) {
|
||||
auto body = Json::object();
|
||||
body["impact"] = b.impact;
|
||||
body["slide"] = b.slide;
|
||||
body["board"] = b.board;
|
||||
body["vehicle"] = b.vehicle;
|
||||
body["world"] = b.world;
|
||||
bodies[b.name] = std::move(body);
|
||||
}
|
||||
data["bodies"] = std::move(bodies);
|
||||
each([&](Plugin& plugin, const std::string&) { plugin.game_event("physics_step", "events.physics", data); });
|
||||
}
|
||||
if (wants("skater.control"))
|
||||
each([&](Plugin& plugin, const std::string& id) {
|
||||
for (const auto& pop : game.take_grind_pops(id)) {
|
||||
auto data = Json::object();
|
||||
data["applied"] = pop.applied;
|
||||
data["reason"] = pop.reason;
|
||||
if (!pop.basis.empty()) data["basis"] = pop.basis;
|
||||
data["side"] = pop.toward > 0 ? "left" : pop.toward < 0 ? "right" : "";
|
||||
data["before"] = pop.before;
|
||||
data["after"] = pop.after;
|
||||
plugin.game_event("grind_exit", "events.skater", data);
|
||||
}
|
||||
});
|
||||
if (wants("chat.read")) {
|
||||
auto lines = game.chat_since(chat_seen_);
|
||||
// Lines from before the first plugin listened are not news.
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
|
||||
namespace dingosdk::plugins {
|
||||
namespace {
|
||||
constexpr std::array<std::string_view, 40> permissions{
|
||||
constexpr std::array<std::string_view, 42> permissions{
|
||||
"console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks", "events.skater",
|
||||
"skater.read", "skater.teleport", "skater.control", "skater.animation", "trainer", "camera",
|
||||
"world.raycast", "world.objects", "world.environment", "world.ai", "travel", "session.read",
|
||||
"session.skaters", "session.nametags", "net.session", "net.http", "voice.control", "chat.read",
|
||||
"chat.send", "chat.local", "server", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read"};
|
||||
"chat.send", "chat.local", "server", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read",
|
||||
"events.physics", "game.speed"};
|
||||
}
|
||||
|
||||
bool known_permission(std::string_view name) noexcept {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// What plugins show, handed from the client thread (where plugins run) to the render thread (the
|
||||
@@ -26,17 +27,36 @@ struct Style {
|
||||
int align{}; // text: 0 left, 1 centre, 2 right of its point
|
||||
bool shadow = true;
|
||||
std::array<float, 4> uv{0, 0, 1, 1}; // pictures: the part of the picture drawn (u0, v0, u1, v1)
|
||||
int font{}; // text: 0 bold, 1 body, 2 heading, 3 title (the menus' fonts)
|
||||
};
|
||||
// The skater's bodies by the ragdoll's order (Engine/Game/Skater/skater_body.h's names): d:skeleton's paint.
|
||||
inline constexpr std::array<std::string_view, 24> body_names{
|
||||
"Board", "Head", "Neck", "Left hand", "Left forearm", "Left upper arm", "Left collarbone",
|
||||
"Right hand", "Right forearm", "Right upper arm", "Right collarbone", "Upper chest", "Chest",
|
||||
"Lower back", "Spine", "Left toes", "Left foot", "Left shin", "Left thigh",
|
||||
"Right toes", "Right foot", "Right shin", "Right thigh", "Pelvis"};
|
||||
// The game's own pictures a plugin draws by name (game_image; Extension/UI/Overlay/game_images.h).
|
||||
inline constexpr std::array<std::string_view, 13> game_images{
|
||||
"stopwatch", "wipeout", "wipeout_broken", "spread_eagle", "airtime", "gap_height", "flaming_wheel", "roll",
|
||||
"thrasher", "brush_bar", "rough_tile", "scratches", "streak"};
|
||||
// Screen shapes are in pixels from the top left; world shapes in world metres, projected through
|
||||
// the camera as the frame is drawn, and left out behind it.
|
||||
struct Shape {
|
||||
// image: `text` is the picture's file, points[0] and [1] its corners; world_image: centred on
|
||||
// points[0], `radius` metres tall.
|
||||
enum class Kind { text, rect, line, circle, poly, world_text, world_line, world_circle, image, world_image } kind{Kind::text};
|
||||
// points[0], `radius` metres tall. game_image: `text` names one of game_images, `fit` 0 contain,
|
||||
// 1 shape, 2 panel (its corners `edge` pixels). gradient: points[0] and [1] the corners, `colours`
|
||||
// the top left, top right, bottom right and bottom left. skeleton: the local skater's x-ray, a
|
||||
// colour for each body in `colours` (body_names' order; 0 leaves it out), the style's alpha
|
||||
// fading the whole.
|
||||
enum class Kind { text, rect, line, circle, poly, world_text, world_line, world_circle, image, world_image, game_image, gradient, skeleton } kind{
|
||||
Kind::text};
|
||||
std::vector<Vec3> points;
|
||||
float radius{};
|
||||
std::string text;
|
||||
Style style;
|
||||
std::vector<std::uint32_t> colours;
|
||||
int fit{};
|
||||
float edge{};
|
||||
};
|
||||
struct Layer {
|
||||
std::string plugin;
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# Grind exit triggers
|
||||
|
||||
Pick the side you pop out of a grind to with **LT / RT**, independently of the trick and of the
|
||||
body's rotation. In skate.'s own controls both come from the left stick at the same moment, so a
|
||||
frontside 180 always pops out toward one side and a backside 180 toward the other. With this on, any
|
||||
trick and any rotation can go out either side: a frontside 180 out to your toe side, a backside 180
|
||||
out to your heel side, a kickflip out to whichever side you pick.
|
||||
|
||||
## Using it
|
||||
|
||||
Off until you switch it on: skate.'s menu, **Mod Options > Player > Grind exit triggers** (or the
|
||||
ReSkate menu's skater page, or the console (`~`) with `grindexit exits on|off`). The choices are kept.
|
||||
|
||||
- **While grinding, press LT or RT** (or up to 1 s before the grind connects,
|
||||
for example as you ollie up to the rail), then do any trick
|
||||
out of the grind with the right stick as usual. Keep the trigger held until you land, then let go.
|
||||
- **Input Mode** (`grindexit sides toeheel|travel`):
|
||||
- **Toe / Heel**: LT pops out to your toe side, RT to your heel side, riding normal, fakie or
|
||||
switch. Needs **your stance** (`grindexit stance goofy|regular`). On slides (boardslide,
|
||||
lipslide), where toes and heels point along the rail, LT / RT are left / right of travel.
|
||||
- **Left / Right**: LT pops out to the left of your direction of travel, RT to the right.
|
||||
- **No trigger, or both**: the game's own exit.
|
||||
- **Grabs**: a trigger used to pick the side is hidden from the game until you let go of it, so
|
||||
holding it through the pop does not grab. Let go and press it again in the air to grab
|
||||
(`grindexit guard on|off`).
|
||||
- **Spins**: push the left stick for your spin as you always do. While the trigger is held in the
|
||||
grind, the stick's left / right reaches the game at the pop instead of before it (it cannot
|
||||
lean-balance the grind meanwhile), so the game's own lean exit cannot drag the board toward the
|
||||
spin and into the rail (`grindexit leanguard on|off`).
|
||||
- **Pop-out strength** (`grindexit strength 10-100`, kept): how far to the side a trigger exit
|
||||
pops, as a share of the full exit (100%, the default, is the game's own sideways speed). Lower it
|
||||
for rails next to a wall. Very low values leave little clearance on exits over the rail.
|
||||
- **Multiplayer**: works in any session or server, whatever the host's settings, throwdowns
|
||||
included. It adds no speed or height (the sideways speed is the game's own); it only changes which
|
||||
input picks the exit side. Server checks do not see it, since it changes no game data.
|
||||
|
||||
What it does not do: it does not make an exit easier than the game's physics allow. Popping out of a
|
||||
smith or feeble over the rail, away from the side the board hangs on, still needs a good pop, as it
|
||||
would on a real rail.
|
||||
|
||||
## How it works
|
||||
|
||||
At the pop the game calls its jump-trajectory setter with the launch velocity; the trainer already
|
||||
hooks that call (`Extension/Trainer/trainer_jump.cpp`). For a pop out of a grind (its call site,
|
||||
measured: `Engine/Game/Build/20260929/grind_exit.h`) only the sideways part of that velocity is
|
||||
replaced, across the rail: the game's own sideways speed when it had one, mirrored to the chosen
|
||||
side, else the game's usual 1.8 m/s. The speed along the rail, the pop's height, the trick, the flip
|
||||
and the rotation are the game's.
|
||||
|
||||
| Part | Files |
|
||||
|---|---|
|
||||
| The switch, triggers, lean guard, toe / heel and the launch change | `grind_exit_control.*` |
|
||||
| Console verbs | `grind_exit_commands.cpp` |
|
||||
| Each physics step of the local skater, from No Bail's skeleton response hook | `no_bail.h` (`add_no_bail_step_listener`) |
|
||||
| The launch hook | `Extension/Trainer/trainer_jump.*` (`set_trajectory_modifier`) |
|
||||
| The game's view of the pad (triggers, left stick) | `Extension/UI/Overlay/input_capture.cpp` (`DingoSDKOverlaySetGamepadFilter`) |
|
||||
| Measured addresses and behaviour | `Engine/Game/Build/20260929/grind_exit.h` |
|
||||
| The menus | `skate_menu_skater.cpp`, `hub_page.cpp`, `native_tools_view.h` |
|
||||
|
||||
**Toe / heel.** The game keeps which foot leads in a flag (context + 0x13c4, bit 5), found by
|
||||
recording the skater's state while riding regular and switch: it changed exactly at each change of
|
||||
stance and never during grinds, pops or 180s. With the player's stance it gives the lead foot; the
|
||||
toes face the skater root's right axis when the right foot leads (measured), the other way when the
|
||||
left foot does. It was measured with a goofy skater, for whom "switch" and "left foot leads" are the
|
||||
same thing; a regular skater's recording would confirm which of the two the flag means.
|
||||
|
||||
**How it was found.** Everything above was measured with a logging-only recorder (every physics step
|
||||
around grinds, the pad and the launch calls, to CSV, plus snapshots of the skater's state memory). It
|
||||
is not part of this change; it can be offered separately for re-measuring after a game update.
|
||||
|
||||
With the log level set to debug (`loglevel debug`), every pop out of a grind is written to
|
||||
`ReSkate.log` with what was decided and why.
|
||||
|
||||
## Measured
|
||||
|
||||
Tested on build 25414733 with a goofy skater over four sessions: 48 pops out of grinds with the game's
|
||||
own controls to learn its exits, then 144 pops whose side the triggers set. Forward, fakie and switch
|
||||
50-50s, ollies, kick- and heelflips, shove-its and frontside / backside 180s went out to the chosen
|
||||
side with the trick and the rotation unchanged. Frontside 180 out to the toe side, which the game's
|
||||
controls cannot make, clipped the rail until the lean guard was added; with it, all 4 tries landed.
|
||||
Heel-side exits from a fakie smith (over the rail) got out about 6 times in 16.
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
// Hall of Meat's skeleton: a made-up skeleton and mesh bound and skinned, and, given an installed
|
||||
// game (dingosdk_hall_of_meat_skeleton_tests <Skate folder>), the game's own read and posed.
|
||||
#include "Extension/HallOfMeat/hall_of_meat_skeleton.h"
|
||||
// The x-ray skeleton: a made-up skeleton and mesh bound and skinned, and, given an installed
|
||||
// game (dingosdk_xray_skeleton_tests <Skate folder>), the game's own read and posed.
|
||||
#include "Extension/Skater/xray_skeleton.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
@@ -8,7 +8,7 @@
|
||||
#include <string>
|
||||
|
||||
using namespace dingosdk;
|
||||
using namespace dingosdk::hall_of_meat;
|
||||
using namespace dingosdk::xray;
|
||||
using skater_body::Bone;
|
||||
namespace {
|
||||
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
|
||||
@@ -104,6 +104,6 @@ int main(int argc, char** argv) {
|
||||
std::cerr << "FAILED: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
std::cout << "Hall of Meat skeleton tests passed.\n";
|
||||
std::cout << "X-ray skeleton tests passed.\n";
|
||||
return 0;
|
||||
}
|
||||
+45
-3
@@ -1,13 +1,16 @@
|
||||
#include "hall_of_meat_slow_motion.h"
|
||||
#include "hall_of_meat_skater.h"
|
||||
#include "game_speed.h"
|
||||
#include "skater_physics.h"
|
||||
#include "Engine/Game/Multiplayer/session_tools.h"
|
||||
#include "Engine/Game/Settings/simulation_time.h"
|
||||
#include "Extension/Settings/named_settings.h"
|
||||
#include <charconv>
|
||||
#include <map>
|
||||
#include <utility>
|
||||
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace dingosdk::game_speed {
|
||||
namespace {
|
||||
using skater_physics::set_step_length;
|
||||
using skater_physics::step_length;
|
||||
constexpr const char* time_scale = "SimulationTime.TimeScale";
|
||||
constexpr const char* sim_rate = "SimulationTime.ForceSimRate";
|
||||
constexpr const char* max_sim_fps = "SimulationTime.MaxSimFps";
|
||||
@@ -98,3 +101,42 @@ void SlowMotion::release() {
|
||||
if (!skater_changed_) base_rate_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
namespace dingosdk::game_speed {
|
||||
namespace {
|
||||
struct Asks {
|
||||
std::map<std::string, float, std::less<>> by_owner;
|
||||
SlowMotion motion;
|
||||
bool running{};
|
||||
};
|
||||
Asks& asks() {
|
||||
static auto* value = new Asks; // the game's settings may still be held at exit
|
||||
return *value;
|
||||
}
|
||||
}
|
||||
void ask(std::string_view owner, float speed) {
|
||||
auto& a = asks();
|
||||
if (!(speed < 1.0f) || !std::isfinite(speed)) {
|
||||
if (const auto found = a.by_owner.find(owner); found != a.by_owner.end()) a.by_owner.erase(found);
|
||||
return;
|
||||
}
|
||||
a.by_owner.insert_or_assign(std::string(owner), std::max(speed, slowest_speed));
|
||||
}
|
||||
void tick() noexcept {
|
||||
auto& a = asks();
|
||||
try {
|
||||
float slowest = 1.0f;
|
||||
for (const auto& [owner, speed] : a.by_owner) slowest = std::min(slowest, speed);
|
||||
if (slowest < 1.0f || !a.motion.idle()) a.running = a.motion.set(slowest);
|
||||
} catch (...) {
|
||||
a.running = false;
|
||||
}
|
||||
}
|
||||
float current() noexcept {
|
||||
auto& a = asks();
|
||||
if (!a.running) return 1.0f;
|
||||
float slowest = 1.0f;
|
||||
for (const auto& [owner, speed] : a.by_owner) slowest = std::min(slowest, speed);
|
||||
return slowest;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
// The slow motion after a break: the game run below its own speed for a while, smoothly. skate.
|
||||
// Plugins' slow motion (reskate.time.slow): the game run below its own speed for a while, smoothly. skate.
|
||||
// steps its simulation at a fixed rate on a clock its time scale slows
|
||||
// (Engine/Game/Settings/simulation_time.h): at 0.3 alone only 18 steps come each real second, and the
|
||||
// camera, moving every frame, follows a skater that jumps 18 times a second. So the rate goes up as
|
||||
@@ -28,7 +29,7 @@
|
||||
//
|
||||
// Never during a multiplayer session: the local simulation must keep the others' pace, so a session
|
||||
// that starts while it runs ends it. Other skaters (AI skaters) are not followed. Game update thread only.
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace dingosdk::game_speed {
|
||||
inline constexpr float slowest_speed = 0.05f; // what the steps a second can follow
|
||||
inline constexpr std::uint32_t highest_simulation_rate = 1200; // steps a second of game time, at most
|
||||
|
||||
@@ -71,4 +72,11 @@ private:
|
||||
std::optional<std::uint32_t> base_rate_; // the game's own simulation rate, while it matters
|
||||
bool skater_changed_{}; // the local skater's step is ours, to be given back
|
||||
};
|
||||
|
||||
// Plugins' asks: the slowest one runs. `speed` 1 or more drops the owner's ask. Client thread.
|
||||
void ask(std::string_view owner, float speed);
|
||||
// Client thread, every tick: the slow motion at the slowest ask, or the game's own speed back.
|
||||
void tick() noexcept;
|
||||
// The speed the game runs at for plugins now: the slowest ask while it holds, else 1.
|
||||
float current() noexcept;
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
#include "grind_exit.h"
|
||||
#include "client_source_spawn_internal.h"
|
||||
#include "no_bail.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/grind_exit.h"
|
||||
#include "Engine/Game/Build/20260929/skater_state.h"
|
||||
#include "Extension/Trainer/trainer_jump.h"
|
||||
#include "Extension/UI/Overlay/overlay.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
|
||||
namespace dingosdk::grind_exit {
|
||||
namespace {
|
||||
namespace sstate = addr::skater_state;
|
||||
namespace gx = addr::grind_exit;
|
||||
|
||||
constexpr double fresh_seconds = 0.1; // a grind step this recent means "grinding now"
|
||||
constexpr double axes_fresh_seconds = 0.25; // the skater's facing, sampled on the client tick
|
||||
constexpr std::size_t rail_steps = 6; // grind steps the rail's direction is averaged over
|
||||
constexpr float min_rail_speed = 0.5f, min_kept_side_speed = 1.0f;
|
||||
constexpr float min_toe_across = 0.35f; // below this the toe points along the rail (slides): left/right of travel instead
|
||||
constexpr std::size_t most_pops = 32;
|
||||
|
||||
struct Control {
|
||||
std::atomic<std::uintptr_t> base{};
|
||||
std::atomic<bool> started{}, listening{}, modifying{}, hooked{};
|
||||
// Physics thread -> the launch hook and the pad filter.
|
||||
std::atomic<std::uint32_t> state{};
|
||||
std::atomic<std::int64_t> step_qpc{};
|
||||
std::atomic<float> rail_x{}, rail_z{}, rail_speed{};
|
||||
std::atomic<bool> switch_stance{};
|
||||
// Client tick -> the launch hook: the skater root's right axis, flattened.
|
||||
std::atomic<float> right_x{}, right_z{};
|
||||
std::atomic<std::int64_t> axes_qpc{};
|
||||
std::uint64_t next_axes_try{};
|
||||
// Physics thread only.
|
||||
std::array<std::array<float, 2>, rail_steps> rail{};
|
||||
std::size_t rail_head{}, rail_count{};
|
||||
// The plugin's wish (client thread -> the launch hook), and the pad's hidden controls.
|
||||
std::mutex mutex;
|
||||
std::string wisher;
|
||||
std::optional<Wish> wanted;
|
||||
std::map<std::string, Hidden, std::less<>> hidden;
|
||||
std::atomic<bool> hide_lt{}, hide_rt{}, hide_left_x{};
|
||||
std::vector<Pop> pops; // under mutex
|
||||
std::atomic<float> side_speed{gx::measured_side_speed};
|
||||
};
|
||||
Control& control() {
|
||||
static auto* value = new Control; // process lifetime: hooks may call in until exit
|
||||
return *value;
|
||||
}
|
||||
std::int64_t qpc_now() noexcept {
|
||||
LARGE_INTEGER value{};
|
||||
QueryPerformanceCounter(&value);
|
||||
return value.QuadPart;
|
||||
}
|
||||
double seconds(std::int64_t ticks) noexcept {
|
||||
static const double frequency = [] {
|
||||
LARGE_INTEGER value{};
|
||||
QueryPerformanceFrequency(&value);
|
||||
return static_cast<double>(value.QuadPart);
|
||||
}();
|
||||
return static_cast<double>(ticks) / frequency;
|
||||
}
|
||||
bool grind_state(std::uint32_t state) noexcept { return state >= gx::grind_states_first && state < gx::grind_states_end; }
|
||||
bool grinding_now(const Control& c, std::int64_t now) noexcept {
|
||||
return grind_state(c.state.load(std::memory_order_acquire)) && seconds(now - c.step_qpc.load(std::memory_order_acquire)) < fresh_seconds;
|
||||
}
|
||||
|
||||
// ---- physics side --------------------------------------------------------------------------
|
||||
// The board's root body velocity (as skater_events.cpp reads it).
|
||||
bool board_velocity(const NoBailSkater& skater, std::array<float, 3>& velocity) noexcept {
|
||||
std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{};
|
||||
std::uint32_t count{};
|
||||
if (!memory::peek(skater.core + sstate::board_holder_offset, holder) || !memory::peek(holder + sstate::board_physics_offset, physics) ||
|
||||
!memory::peek(physics, type) || type != skater.base + sstate::board_physics_vtable ||
|
||||
!memory::peek(physics + sstate::physics_bodies_offset, bodies) || !memory::peek(bodies, count) || count != sstate::board_body_count)
|
||||
return false;
|
||||
const auto body = bodies + sstate::board_root_body * sstate::physics_body_size;
|
||||
if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || !memory::peek(body + sstate::body_velocity_offset, velocity))
|
||||
return false;
|
||||
return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; });
|
||||
}
|
||||
// Physics thread, every step of the local skater.
|
||||
void on_step(const NoBailSkater& skater, float, bool) noexcept {
|
||||
auto& c = control();
|
||||
std::uint32_t state{};
|
||||
if (!memory::peek(skater.context + sstate::physics_state_offset, state)) return;
|
||||
const auto now = qpc_now();
|
||||
std::uint8_t flags{};
|
||||
if (memory::peek(skater.context + gx::stance_flags_offset, flags))
|
||||
c.switch_stance.store((flags & gx::stance_switch_mask) != 0, std::memory_order_relaxed);
|
||||
if (grind_state(state)) {
|
||||
std::array<float, 3> v{};
|
||||
if (board_velocity(skater, v)) {
|
||||
c.rail[c.rail_head] = {v[0], v[2]};
|
||||
c.rail_head = (c.rail_head + 1) % rail_steps;
|
||||
c.rail_count = std::min(c.rail_count + 1, rail_steps);
|
||||
float x{}, z{};
|
||||
for (std::size_t i = 0; i < c.rail_count; ++i) {
|
||||
x += c.rail[i][0];
|
||||
z += c.rail[i][1];
|
||||
}
|
||||
x /= static_cast<float>(c.rail_count);
|
||||
z /= static_cast<float>(c.rail_count);
|
||||
const float speed = std::hypot(x, z);
|
||||
if (speed > 0.0f) {
|
||||
c.rail_x.store(x / speed, std::memory_order_relaxed);
|
||||
c.rail_z.store(z / speed, std::memory_order_relaxed);
|
||||
}
|
||||
c.rail_speed.store(speed, std::memory_order_relaxed);
|
||||
}
|
||||
} else {
|
||||
c.rail_count = c.rail_head = 0;
|
||||
}
|
||||
c.step_qpc.store(now, std::memory_order_release);
|
||||
c.state.store(state, std::memory_order_release);
|
||||
}
|
||||
|
||||
// ---- input side ----------------------------------------------------------------------------
|
||||
// Any thread the game polls its pad from.
|
||||
void filter_pad(unsigned, overlay::GamepadValues& pad) noexcept {
|
||||
auto& c = control();
|
||||
if (c.hide_lt.load(std::memory_order_acquire)) pad.left_trigger = 0;
|
||||
if (c.hide_rt.load(std::memory_order_acquire)) pad.right_trigger = 0;
|
||||
if (c.hide_left_x.load(std::memory_order_acquire) && grinding_now(c, qpc_now())) pad.left_x = 0;
|
||||
}
|
||||
void publish_hidden(Control& c) {
|
||||
Hidden all;
|
||||
for (const auto& [owner, h] : c.hidden) {
|
||||
all.left_trigger = all.left_trigger || h.left_trigger;
|
||||
all.right_trigger = all.right_trigger || h.right_trigger;
|
||||
all.left_x_grinding = all.left_x_grinding || h.left_x_grinding;
|
||||
}
|
||||
c.hide_lt.store(all.left_trigger, std::memory_order_release);
|
||||
c.hide_rt.store(all.right_trigger, std::memory_order_release);
|
||||
c.hide_left_x.store(all.left_x_grinding, std::memory_order_release);
|
||||
DingoSDKOverlaySetGamepadFilter(c.hidden.empty() ? nullptr : &filter_pad);
|
||||
}
|
||||
|
||||
// ---- the launch ----------------------------------------------------------------------------
|
||||
// Which way across the rail (+1 the left of travel, n = (tz, -tx); -1 the right) the wish points.
|
||||
int toward_side(Control& c, const Wish& w, float nx, float nz, std::int64_t now, Pop& pop) noexcept {
|
||||
using Side = Wish::Side;
|
||||
const int fallback = w.side == Side::toe || w.side == Side::left ? 1 : -1;
|
||||
if (w.side == Side::left || w.side == Side::right) { pop.basis = "travel"; return fallback; }
|
||||
if (w.stance != 1 && w.stance != 2) { pop.basis = "no_stance"; return fallback; }
|
||||
if (seconds(now - c.axes_qpc.load(std::memory_order_acquire)) > axes_fresh_seconds) { pop.basis = "no_facing"; return fallback; }
|
||||
float rx = c.right_x.load(std::memory_order_relaxed), rz = c.right_z.load(std::memory_order_relaxed);
|
||||
const float length = std::hypot(rx, rz);
|
||||
if (length < 0.2f) { pop.basis = "no_facing"; return fallback; }
|
||||
rx /= length;
|
||||
rz /= length;
|
||||
// The toes point to the root's right axis when the right foot leads (goofy riding normal or
|
||||
// fakie: measured), to the other side when the left foot does (regular, or goofy switch).
|
||||
const bool right_foot_leads = (w.stance == 2) != c.switch_stance.load(std::memory_order_relaxed);
|
||||
const float toe_x = right_foot_leads ? rx : -rx, toe_z = right_foot_leads ? rz : -rz;
|
||||
const float across = toe_x * nx + toe_z * nz;
|
||||
if (std::abs(across) < min_toe_across) { pop.basis = "slide"; return fallback; }
|
||||
pop.basis = "toe_heel";
|
||||
const int toe = across > 0 ? 1 : -1;
|
||||
return w.side == Side::toe ? toe : -toe;
|
||||
}
|
||||
void record(Control& c, Pop pop) {
|
||||
std::lock_guard lock(c.mutex);
|
||||
if (c.pops.size() < most_pops) c.pops.push_back(std::move(pop));
|
||||
}
|
||||
// Physics thread: every call of the jump-trajectory setter.
|
||||
void modify_launch(std::uintptr_t caller_rva, std::uintptr_t, float (&v)[3]) noexcept {
|
||||
auto& c = control();
|
||||
if (caller_rva != gx::grind_pop_call) return;
|
||||
const auto now = qpc_now();
|
||||
if (!grinding_now(c, now)) return; // no complies and pops from a manual launch from the same call
|
||||
try {
|
||||
std::optional<Wish> wish;
|
||||
{
|
||||
std::lock_guard lock(c.mutex);
|
||||
if (c.wisher.empty()) return;
|
||||
wish = c.wanted;
|
||||
}
|
||||
Pop pop;
|
||||
const float tx = c.rail_x.load(std::memory_order_relaxed), tz = c.rail_z.load(std::memory_order_relaxed);
|
||||
const float speed = c.rail_speed.load(std::memory_order_relaxed);
|
||||
const float nx = tz, nz = -tx;
|
||||
const float along = v[0] * tx + v[2] * tz;
|
||||
pop.before = pop.after = v[0] * nx + v[2] * nz;
|
||||
if (!wish) { pop.reason = "none"; return record(c, std::move(pop)); }
|
||||
if (speed < min_rail_speed) { pop.reason = "slow"; return record(c, std::move(pop)); }
|
||||
// The launch must be this skater's: mostly along its rail, at about its speed (measured 0.93x).
|
||||
const float horizontal = std::hypot(v[0], v[2]);
|
||||
if (!(along > 0.5f * speed && along < 1.6f * speed && horizontal < 2.0f * speed + 3.0f)) {
|
||||
pop.reason = "not_ours";
|
||||
return record(c, std::move(pop));
|
||||
}
|
||||
pop.toward = toward_side(c, *wish, nx, nz, now, pop);
|
||||
const float side = static_cast<float>(pop.toward);
|
||||
// Strength scales the sideways speed; at full strength the game's own exit toward the chosen
|
||||
// side is kept as it is.
|
||||
const float strength = std::clamp(wish->strength, 0.1f, 1.0f);
|
||||
if (strength >= 1.0f && pop.before * side >= min_kept_side_speed) { pop.reason = "kept"; return record(c, std::move(pop)); }
|
||||
const float full = std::abs(pop.before) >= min_kept_side_speed ? std::abs(pop.before) : c.side_speed.load(std::memory_order_relaxed);
|
||||
const float magnitude = full * strength;
|
||||
const float change = side * magnitude - pop.before;
|
||||
v[0] += change * nx;
|
||||
v[2] += change * nz;
|
||||
pop.after = side * magnitude;
|
||||
pop.applied = true;
|
||||
pop.reason = "applied";
|
||||
record(c, std::move(pop));
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
// The step listener and the launch hook, from the first use on.
|
||||
void listen(Control& c) {
|
||||
if (!c.listening.load(std::memory_order_acquire) && add_no_bail_step_listener(&on_step)) c.listening.store(true, std::memory_order_release);
|
||||
if (!c.modifying.exchange(true)) trainer::set_trajectory_modifier(&modify_launch);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool start(std::uintptr_t image_base) noexcept {
|
||||
auto& c = control();
|
||||
if (c.started.load(std::memory_order_acquire)) return true;
|
||||
if (!no_bail_available()) {
|
||||
logging::write(logging::Level::warning, logging::Channel::skater,
|
||||
"Grind exits for plugins are unavailable: they follow the skater's physics steps through No Bail's hooks, which did not start.");
|
||||
return false;
|
||||
}
|
||||
c.base.store(image_base, std::memory_order_release);
|
||||
c.started.store(true, std::memory_order_release);
|
||||
return true;
|
||||
}
|
||||
bool available() noexcept { return control().started.load(std::memory_order_acquire); }
|
||||
|
||||
std::string wish(std::string_view owner, std::optional<Wish> w) {
|
||||
auto& c = control();
|
||||
if (!c.started.load(std::memory_order_acquire)) return "grind exits are unavailable for this game build";
|
||||
std::lock_guard lock(c.mutex);
|
||||
if (!c.wisher.empty() && c.wisher != owner) return c.wisher + " picks the grind exits";
|
||||
c.wisher = owner;
|
||||
c.wanted = w;
|
||||
listen(c);
|
||||
return {};
|
||||
}
|
||||
void hide(std::string_view owner, Hidden hidden) {
|
||||
auto& c = control();
|
||||
if (!c.started.load(std::memory_order_acquire)) return;
|
||||
std::lock_guard lock(c.mutex);
|
||||
if (hidden.left_trigger || hidden.right_trigger || hidden.left_x_grinding) c.hidden.insert_or_assign(std::string(owner), hidden);
|
||||
else if (const auto found = c.hidden.find(owner); found != c.hidden.end()) c.hidden.erase(found);
|
||||
if (hidden.left_x_grinding) listen(c);
|
||||
publish_hidden(c);
|
||||
}
|
||||
void release(std::string_view owner) {
|
||||
auto& c = control();
|
||||
std::lock_guard lock(c.mutex);
|
||||
if (c.wisher == owner) {
|
||||
c.wisher.clear();
|
||||
c.wanted.reset();
|
||||
c.pops.clear();
|
||||
}
|
||||
if (const auto found = c.hidden.find(owner); found != c.hidden.end()) {
|
||||
c.hidden.erase(found);
|
||||
publish_hidden(c);
|
||||
}
|
||||
}
|
||||
std::vector<Pop> take_pops(std::string_view owner) {
|
||||
auto& c = control();
|
||||
std::lock_guard lock(c.mutex);
|
||||
if (c.wisher != owner) return {};
|
||||
return std::exchange(c.pops, {});
|
||||
}
|
||||
bool grinding() noexcept { return grinding_now(control(), qpc_now()); }
|
||||
|
||||
void tick(std::uintptr_t client) noexcept {
|
||||
auto& c = control();
|
||||
if (!c.started.load(std::memory_order_acquire) || !c.modifying.load(std::memory_order_acquire)) return;
|
||||
const auto base = c.base.load(std::memory_order_acquire);
|
||||
if (!c.hooked.load(std::memory_order_acquire) && trainer::start_trick_heights(base)) c.hooked.store(true, std::memory_order_release);
|
||||
// The skater's facing (rows: right, up, forward, position), for toe and heel.
|
||||
const auto now = GetTickCount64();
|
||||
if (now < c.next_axes_try) return;
|
||||
try {
|
||||
overlay::DebugModel skater;
|
||||
const auto matrix = client_source::detail::debug_skater(base, client, skater);
|
||||
if (skater.skater_position_valid && std::isfinite(matrix[0]) && std::isfinite(matrix[2])) {
|
||||
c.right_x.store(matrix[0], std::memory_order_relaxed);
|
||||
c.right_z.store(matrix[2], std::memory_order_relaxed);
|
||||
c.axes_qpc.store(qpc_now(), std::memory_order_release);
|
||||
}
|
||||
} catch (...) {
|
||||
c.next_axes_try = now + 250; // no skater: menus, loading, a respawn
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// Which side the local skater pops out of a grind to, as a plugin asks (reskate.skater.grind_exit;
|
||||
// the grind-exits mod picks it with LT / RT), and pad controls kept from the game meanwhile
|
||||
// (reskate.input.hide). What decides the side, when and why, is the plugin's; this is what has to
|
||||
// happen in the game's own threads.
|
||||
//
|
||||
// At the pop the game calls its jump-trajectory setter with the launch velocity; the trainer hooks
|
||||
// that call (Extension/Trainer/trainer_jump.cpp). For a pop out of a grind (its call site, measured:
|
||||
// Engine/Game/Build/20260929/grind_exit.h) only the sideways part of the launch is replaced, across
|
||||
// the rail: the game's own sideways speed when it had one, mirrored to the chosen side, else
|
||||
// `side_speed` (1.8 m/s, the game's own), scaled by the wish's strength. Along the rail, the pop's
|
||||
// height, the trick, the flip and the body's rotation stay the game's.
|
||||
//
|
||||
// Toe and heel come from which foot leads (the game's stance flag, grind_exit.h, and the player's
|
||||
// stance as the wish gives it) and the skater root's right axis: the toes face it when the right foot
|
||||
// leads (measured). On slides, where toe and heel point along the rail, and without a stance, toe is
|
||||
// the left of travel and heel the right.
|
||||
//
|
||||
// The pad: a hidden trigger reads as let go; the left stick's left/right, hidden "while grinding",
|
||||
// reads as centred only while the skater grinds, so it reaches the game again at the pop.
|
||||
namespace dingosdk::grind_exit {
|
||||
// Requires the validated build and No Bail started.
|
||||
bool start(std::uintptr_t image_base) noexcept;
|
||||
bool available() noexcept;
|
||||
|
||||
struct Wish {
|
||||
enum class Side : std::uint8_t { toe, heel, left, right } side{Side::left};
|
||||
int stance{}; // 0 not set, 1 regular, 2 goofy: what toe and heel need
|
||||
float strength{1.0f}; // 0.1 to 1 of the full sideways speed
|
||||
};
|
||||
// Client thread: what the next pop out of a grind does (empty: the game's own exit). One plugin at a
|
||||
// time: empty when taken, else why not.
|
||||
std::string wish(std::string_view owner, std::optional<Wish> wish);
|
||||
struct Hidden {
|
||||
bool left_trigger{}, right_trigger{}, left_x_grinding{};
|
||||
};
|
||||
void hide(std::string_view owner, Hidden hidden);
|
||||
// Whatever the plugin held.
|
||||
void release(std::string_view owner);
|
||||
|
||||
// A pop out of a grind, as it went: whether the side was set, and why (or why not).
|
||||
struct Pop {
|
||||
bool applied{};
|
||||
std::string reason; // "applied", "kept", "none", "slow", "not_ours"
|
||||
std::string basis; // "toe_heel", "slide", "no_stance", "no_facing", "travel"
|
||||
int toward{}; // +1 the left of travel, -1 the right, 0 the game's own
|
||||
float before{}, after{}; // the sideways speed, m/s (+ the left)
|
||||
};
|
||||
// Client thread: the pops since the last call, for the plugin that wished.
|
||||
std::vector<Pop> take_pops(std::string_view owner);
|
||||
// The skater is grinding now (as the physics steps last said).
|
||||
bool grinding() noexcept;
|
||||
// Client thread, every tick, with the local client: the launch hook made ready while used, the
|
||||
// skater's facing sampled.
|
||||
void tick(std::uintptr_t client) noexcept;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
#include "grind_exit_control.h"
|
||||
#include "Extension/Console/commands.h"
|
||||
|
||||
// The grind exit triggers' console verbs (grind_exit_control.h).
|
||||
namespace dingosdk::console {
|
||||
void register_grind_exit_commands(Commands ®istry) {
|
||||
struct Verb {
|
||||
const char *name;
|
||||
const char *description;
|
||||
const char *usage;
|
||||
};
|
||||
const Verb verbs[]{
|
||||
{"exits", "Grind exit triggers: LT/RT pressed while grinding pick the side you pop out to (kept)", "on|off|status"},
|
||||
{"stance", "Grind exits: your skater's stance, for toe/heel sides (kept)", "goofy|regular"},
|
||||
{"sides", "Grind exits: LT toe / RT heel, or LT left / RT right of travel (kept)", "toeheel|travel"},
|
||||
{"strength", "Grind exits: how far to the side a trigger exit pops, percent of the full exit (kept; 100 is the game's own)", "<10-100>"},
|
||||
{"guard", "Grind exits: hide a trigger used to pick the side from the game until released (no accidental grab)", "on|off"},
|
||||
{"leanguard", "Grind exits: the left stick's left/right reaches the game at the pop while a trigger picks the side", "on|off"},
|
||||
{"prewindow", "Grind exits: seconds a trigger may be pressed before the grind connects and still count (1)", "<seconds>"},
|
||||
{"sidespeed", "Grind exits: sideways speed of a chosen exit when the game had none (m/s, game's own 1.8)", "<m/s>"},
|
||||
};
|
||||
for (const auto &verb : verbs) {
|
||||
std::vector<Argument> arguments;
|
||||
if (verb.usage) {
|
||||
auto rest = argument(verb.usage, Type::text, true);
|
||||
rest.rest = true;
|
||||
arguments.push_back(std::move(rest));
|
||||
}
|
||||
auto entry = action(std::string("grindexit ") + verb.name, verb.description, Group::gameplay, std::move(arguments));
|
||||
entry.run = [name = std::string(verb.name)](const Model &, const Values &values, const Output &out) {
|
||||
std::vector<std::string> words{name};
|
||||
if (!values.empty())
|
||||
if (const auto *text = std::get_if<std::string>(&values[0])) {
|
||||
std::size_t start = 0;
|
||||
while (start < text->size()) {
|
||||
auto end = text->find(' ', start);
|
||||
if (end == std::string::npos) end = text->size();
|
||||
if (end > start) words.push_back(text->substr(start, end - start));
|
||||
start = end + 1;
|
||||
}
|
||||
}
|
||||
out(grind_exit_control::command(words));
|
||||
};
|
||||
registry.add(std::move(entry));
|
||||
}
|
||||
}
|
||||
} // namespace dingosdk::console
|
||||
@@ -1,537 +0,0 @@
|
||||
#include "grind_exit_control.h"
|
||||
#include "client_source_spawn_internal.h"
|
||||
#include "no_bail.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/grind_exit.h"
|
||||
#include "Engine/Game/Build/20260929/skater_state.h"
|
||||
#include "Extension/Trainer/trainer_jump.h"
|
||||
#include "Extension/UI/Overlay/overlay.h"
|
||||
#include <Windows.h>
|
||||
#include <shlobj.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::grind_exit_control {
|
||||
namespace {
|
||||
namespace sstate = addr::skater_state;
|
||||
namespace gx = addr::grind_exit;
|
||||
|
||||
constexpr std::uint8_t press_level = 102, release_level = 51; // of 255: about 40 % in, 20 % out
|
||||
constexpr double fresh_seconds = 0.1; // a grind step this recent means "grinding now"
|
||||
constexpr double release_grace_seconds = 0.1; // a trigger let go this shortly before the pop still counts
|
||||
constexpr double axes_fresh_seconds = 0.25; // the skater's facing, sampled on the client tick
|
||||
constexpr std::size_t rail_steps = 6; // grind steps the rail's direction is averaged over
|
||||
constexpr float min_rail_speed = 0.5f, min_kept_side_speed = 1.0f;
|
||||
constexpr int min_strength = 10; // pop-out strength floor, percent: below it a trigger barely picks a side
|
||||
constexpr float min_toe_across = 0.35f; // below this the toe points along the rail (slides): left/right of travel instead
|
||||
|
||||
enum class Stance : int { unset, regular, goofy };
|
||||
enum class Sides : int { toeheel, travel };
|
||||
enum class Basis : std::uint8_t { travel, toeheel, slide, no_stance, no_facing };
|
||||
enum class Reason : std::uint8_t { applied, kept, no_trigger, both_triggers, slow, not_ours };
|
||||
const char* reason_text(Reason r) {
|
||||
switch (r) {
|
||||
case Reason::applied: return "side set";
|
||||
case Reason::kept: return "game already chose that side";
|
||||
case Reason::no_trigger: return "no trigger: the game's own exit";
|
||||
case Reason::both_triggers: return "both triggers: the game's own exit";
|
||||
case Reason::slow: return "skipped: too slow to tell the rail's direction";
|
||||
case Reason::not_ours: return "skipped: the launch did not match the local skater's grind";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
const char* basis_text(Basis b) {
|
||||
switch (b) {
|
||||
case Basis::travel: return "left/right of travel";
|
||||
case Basis::toeheel: return "toe/heel";
|
||||
case Basis::slide: return "toe/heel along the rail (slide): left/right of travel";
|
||||
case Basis::no_stance: return "stance not set: left/right of travel";
|
||||
case Basis::no_facing: return "facing unknown: left/right of travel";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
struct Decision {
|
||||
std::int64_t qpc{};
|
||||
Reason reason{};
|
||||
Basis basis{};
|
||||
int trigger{}; // +1 LT, -1 RT, 0 none
|
||||
int toward{}; // +1 the tester's left of travel, -1 right
|
||||
bool switch_stance{}, lean_blocked{};
|
||||
std::uint32_t state{};
|
||||
float along{}, before_side{}, after_side{}, up{}, rail_speed{}, toe_across{};
|
||||
};
|
||||
struct Trigger {
|
||||
bool pressed{}, claimed{};
|
||||
std::int64_t pressed_at{}, released{};
|
||||
};
|
||||
|
||||
struct Control {
|
||||
std::atomic<std::uintptr_t> base{};
|
||||
std::atomic<bool> started{}, enabled{}, guard{true}, lean_guard{true}, hooked{};
|
||||
std::atomic<float> side_speed{gx::measured_side_speed}, prewindow{1.0f};
|
||||
std::atomic<int> strength{100}; // pop-out strength, percent of the sideways speed (10-100)
|
||||
std::atomic<int> stance{static_cast<int>(Stance::unset)}, sides{static_cast<int>(Sides::toeheel)};
|
||||
std::atomic<bool> wanted{}; // the player's on/off choice, kept; `enabled` is whether it is installed now
|
||||
// Physics thread -> the launch hook and the pad filter.
|
||||
std::atomic<std::uint32_t> state{};
|
||||
std::atomic<std::int64_t> step_qpc{}, grind_start{};
|
||||
std::atomic<float> rail_x{}, rail_z{}, rail_speed{};
|
||||
std::atomic<bool> switch_stance{};
|
||||
// Client tick -> the launch hook: the skater root's right axis, flattened.
|
||||
std::atomic<float> right_x{}, right_z{};
|
||||
std::atomic<std::int64_t> axes_qpc{};
|
||||
std::uint64_t next_axes_try{};
|
||||
// Physics thread only.
|
||||
std::array<std::array<float, 2>, rail_steps> rail{};
|
||||
std::size_t rail_head{}, rail_count{};
|
||||
std::uint32_t last_state{};
|
||||
// The game's input thread(s) and the launch hook.
|
||||
SRWLOCK trigger_lock = SRWLOCK_INIT;
|
||||
std::array<std::array<Trigger, 2>, 4> triggers{}; // [XInput slot][0 LT, 1 RT]
|
||||
std::atomic<std::int64_t> lean_masked{}; // last time the lean guard hid a pushed stick
|
||||
// Physics thread -> client tick.
|
||||
SRWLOCK decision_lock = SRWLOCK_INIT;
|
||||
std::array<Decision, 32> decisions{};
|
||||
std::size_t decision_count{};
|
||||
std::atomic<std::uint64_t> pops{}, applied{};
|
||||
std::filesystem::path settings_path;
|
||||
};
|
||||
Control& control() {
|
||||
static auto* value = new Control; // process lifetime: hooks may call in until exit
|
||||
return *value;
|
||||
}
|
||||
std::int64_t qpc_now() noexcept {
|
||||
LARGE_INTEGER value{};
|
||||
QueryPerformanceCounter(&value);
|
||||
return value.QuadPart;
|
||||
}
|
||||
double seconds(std::int64_t ticks) noexcept {
|
||||
static const double frequency = [] {
|
||||
LARGE_INTEGER value{};
|
||||
QueryPerformanceFrequency(&value);
|
||||
return static_cast<double>(value.QuadPart);
|
||||
}();
|
||||
return static_cast<double>(ticks) / frequency;
|
||||
}
|
||||
std::int64_t ticks(double s) noexcept {
|
||||
static const double frequency = [] {
|
||||
LARGE_INTEGER value{};
|
||||
QueryPerformanceFrequency(&value);
|
||||
return static_cast<double>(value.QuadPart);
|
||||
}();
|
||||
return static_cast<std::int64_t>(s * frequency);
|
||||
}
|
||||
bool grind_state(std::uint32_t state) noexcept { return state >= gx::grind_states_first && state < gx::grind_states_end; }
|
||||
bool grinding_now(const Control& c, std::int64_t now) noexcept {
|
||||
return grind_state(c.state.load(std::memory_order_acquire)) &&
|
||||
seconds(now - c.step_qpc.load(std::memory_order_acquire)) < fresh_seconds;
|
||||
}
|
||||
|
||||
// ---- settings ------------------------------------------------------------------------------
|
||||
std::filesystem::path settings_file() {
|
||||
PWSTR raw{};
|
||||
std::filesystem::path result;
|
||||
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &raw)) && raw)
|
||||
result = std::filesystem::path(raw) / L"ReSkate" / L"grindexit" / L"control.txt";
|
||||
if (raw) CoTaskMemFree(raw);
|
||||
return result;
|
||||
}
|
||||
void load_settings(Control& c) {
|
||||
if (c.settings_path.empty()) return;
|
||||
std::ifstream in(c.settings_path);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (line == "stance=goofy") c.stance.store(static_cast<int>(Stance::goofy));
|
||||
else if (line == "stance=regular") c.stance.store(static_cast<int>(Stance::regular));
|
||||
else if (line == "sides=travel") c.sides.store(static_cast<int>(Sides::travel));
|
||||
else if (line == "sides=toeheel") c.sides.store(static_cast<int>(Sides::toeheel));
|
||||
else if (line == "enabled=1") c.wanted.store(true);
|
||||
else if (line == "enabled=0") c.wanted.store(false);
|
||||
else if (line.starts_with("strength=")) {
|
||||
int value{};
|
||||
const auto parsed = std::from_chars(line.data() + 9, line.data() + line.size(), value);
|
||||
if (parsed.ec == std::errc{} && value >= min_strength && value <= 100) c.strength.store(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
void save_settings(Control& c) {
|
||||
if (c.settings_path.empty()) return;
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(c.settings_path.parent_path(), error);
|
||||
std::ofstream out(c.settings_path, std::ios::trunc);
|
||||
const auto stance = static_cast<Stance>(c.stance.load());
|
||||
if (stance != Stance::unset) out << "stance=" << (stance == Stance::goofy ? "goofy" : "regular") << "\n";
|
||||
out << "sides=" << (static_cast<Sides>(c.sides.load()) == Sides::travel ? "travel" : "toeheel") << "\n";
|
||||
out << "enabled=" << (c.wanted.load() ? 1 : 0) << "\n";
|
||||
out << "strength=" << c.strength.load() << "\n";
|
||||
}
|
||||
|
||||
// ---- physics side --------------------------------------------------------------------------
|
||||
// The board's root body velocity (the same checks as Hall of Meat's read).
|
||||
bool board_velocity(const NoBailSkater& skater, std::array<float, 3>& velocity) noexcept {
|
||||
std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{};
|
||||
std::uint32_t count{};
|
||||
if (!memory::peek(skater.core + sstate::board_holder_offset, holder) ||
|
||||
!memory::peek(holder + sstate::board_physics_offset, physics) || !memory::peek(physics, type) ||
|
||||
type != skater.base + sstate::board_physics_vtable || !memory::peek(physics + sstate::physics_bodies_offset, bodies) ||
|
||||
!memory::peek(bodies, count) || count != sstate::board_body_count)
|
||||
return false;
|
||||
const auto body = bodies + sstate::board_root_body * sstate::physics_body_size;
|
||||
if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics ||
|
||||
!memory::peek(body + sstate::body_velocity_offset, velocity))
|
||||
return false;
|
||||
return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; });
|
||||
}
|
||||
// Physics thread, every step of the local skater.
|
||||
void on_step(const NoBailSkater& skater, float, bool) noexcept {
|
||||
auto& c = control();
|
||||
std::uint32_t state{};
|
||||
if (!memory::peek(skater.context + sstate::physics_state_offset, state)) return;
|
||||
const auto now = qpc_now();
|
||||
std::uint8_t flags{};
|
||||
if (memory::peek(skater.context + gx::stance_flags_offset, flags))
|
||||
c.switch_stance.store((flags & gx::stance_switch_mask) != 0, std::memory_order_relaxed);
|
||||
if (grind_state(state)) {
|
||||
if (!grind_state(c.last_state)) c.grind_start.store(now, std::memory_order_release);
|
||||
std::array<float, 3> v{};
|
||||
if (board_velocity(skater, v)) {
|
||||
c.rail[c.rail_head] = {v[0], v[2]};
|
||||
c.rail_head = (c.rail_head + 1) % rail_steps;
|
||||
c.rail_count = std::min(c.rail_count + 1, rail_steps);
|
||||
float x{}, z{};
|
||||
for (std::size_t i = 0; i < c.rail_count; ++i) {
|
||||
x += c.rail[i][0];
|
||||
z += c.rail[i][1];
|
||||
}
|
||||
x /= static_cast<float>(c.rail_count);
|
||||
z /= static_cast<float>(c.rail_count);
|
||||
const float speed = std::hypot(x, z);
|
||||
if (speed > 0.0f) {
|
||||
c.rail_x.store(x / speed, std::memory_order_relaxed);
|
||||
c.rail_z.store(z / speed, std::memory_order_relaxed);
|
||||
}
|
||||
c.rail_speed.store(speed, std::memory_order_relaxed);
|
||||
}
|
||||
} else {
|
||||
c.rail_count = c.rail_head = 0;
|
||||
}
|
||||
c.last_state = state;
|
||||
c.step_qpc.store(now, std::memory_order_release);
|
||||
c.state.store(state, std::memory_order_release);
|
||||
}
|
||||
|
||||
// ---- input side ----------------------------------------------------------------------------
|
||||
// Any thread the game polls its pad from.
|
||||
void filter_pad(unsigned user, overlay::GamepadValues& pad) noexcept {
|
||||
auto& c = control();
|
||||
if (!c.enabled.load(std::memory_order_acquire)) return;
|
||||
const auto now = qpc_now();
|
||||
const bool grinding = grinding_now(c, now);
|
||||
const auto window_start = c.grind_start.load(std::memory_order_acquire) - ticks(c.prewindow.load(std::memory_order_relaxed));
|
||||
const bool guard = c.guard.load(std::memory_order_acquire);
|
||||
std::uint8_t* values[2]{&pad.left_trigger, &pad.right_trigger};
|
||||
bool any_claimed = false;
|
||||
AcquireSRWLockExclusive(&c.trigger_lock);
|
||||
for (std::size_t side = 0; side < 2; ++side) {
|
||||
auto& t = c.triggers[user & 3][side];
|
||||
const auto raw = *values[side];
|
||||
if (!t.pressed && raw >= press_level) {
|
||||
t.pressed = true;
|
||||
t.pressed_at = now;
|
||||
if (grinding) t.claimed = true;
|
||||
} else if (t.pressed && raw <= release_level) {
|
||||
t.pressed = false;
|
||||
if (t.claimed) {
|
||||
t.claimed = false;
|
||||
t.released = now;
|
||||
}
|
||||
}
|
||||
// Pressed a moment before the grind connected: it counts too.
|
||||
if (grinding && t.pressed && !t.claimed && t.pressed_at >= window_start) t.claimed = true;
|
||||
if (t.claimed && guard) *values[side] = 0;
|
||||
}
|
||||
for (const auto& slot : c.triggers)
|
||||
for (const auto& t : slot) any_claimed = any_claimed || t.claimed;
|
||||
ReleaseSRWLockExclusive(&c.trigger_lock);
|
||||
if (grinding && any_claimed && c.lean_guard.load(std::memory_order_acquire)) {
|
||||
if (pad.left_x > 8000 || pad.left_x < -8000) c.lean_masked.store(now, std::memory_order_relaxed);
|
||||
pad.left_x = 0;
|
||||
}
|
||||
}
|
||||
// +1 LT, -1 RT, 0: none, or both.
|
||||
int chosen_trigger(Control& c, std::int64_t now, bool& both) noexcept {
|
||||
bool held[2]{}, recent[2]{};
|
||||
AcquireSRWLockShared(&c.trigger_lock);
|
||||
for (const auto& slot : c.triggers)
|
||||
for (std::size_t side = 0; side < 2; ++side) {
|
||||
held[side] = held[side] || slot[side].claimed;
|
||||
recent[side] = recent[side] || (slot[side].released && seconds(now - slot[side].released) < release_grace_seconds);
|
||||
}
|
||||
ReleaseSRWLockShared(&c.trigger_lock);
|
||||
both = false;
|
||||
if (held[0] && held[1]) { both = true; return 0; }
|
||||
if (held[0] || held[1]) return held[0] ? 1 : -1;
|
||||
if (recent[0] && recent[1]) { both = true; return 0; }
|
||||
if (recent[0] || recent[1]) return recent[0] ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
void record(Control& c, const Decision& d) noexcept {
|
||||
AcquireSRWLockExclusive(&c.decision_lock);
|
||||
if (c.decision_count < c.decisions.size()) c.decisions[c.decision_count++] = d;
|
||||
ReleaseSRWLockExclusive(&c.decision_lock);
|
||||
}
|
||||
|
||||
// ---- the launch ----------------------------------------------------------------------------
|
||||
// Which way across the rail (+1 the tester's "left" = n = (tz, -tx), the skater root's right axis
|
||||
// when riding forward; -1 the other) the claimed trigger points.
|
||||
int toward_side(Control& c, int trigger, float nx, float nz, std::int64_t now, Decision& d) noexcept {
|
||||
d.switch_stance = c.switch_stance.load(std::memory_order_relaxed);
|
||||
if (static_cast<Sides>(c.sides.load()) == Sides::travel) { d.basis = Basis::travel; return trigger; }
|
||||
const auto stance = static_cast<Stance>(c.stance.load());
|
||||
if (stance == Stance::unset) { d.basis = Basis::no_stance; return trigger; }
|
||||
if (seconds(now - c.axes_qpc.load(std::memory_order_acquire)) > axes_fresh_seconds) { d.basis = Basis::no_facing; return trigger; }
|
||||
float rx = c.right_x.load(std::memory_order_relaxed), rz = c.right_z.load(std::memory_order_relaxed);
|
||||
const float length = std::hypot(rx, rz);
|
||||
if (length < 0.2f) { d.basis = Basis::no_facing; return trigger; }
|
||||
rx /= length;
|
||||
rz /= length;
|
||||
// The toes point to the root's right axis when the right foot leads (goofy riding normal or
|
||||
// fakie: measured), to the other side when the left foot leads (regular, or goofy switch).
|
||||
const bool right_foot_leads = (stance == Stance::goofy) != d.switch_stance;
|
||||
const float toe_x = right_foot_leads ? rx : -rx, toe_z = right_foot_leads ? rz : -rz;
|
||||
d.toe_across = toe_x * nx + toe_z * nz;
|
||||
if (std::abs(d.toe_across) < min_toe_across) { d.basis = Basis::slide; return trigger; }
|
||||
d.basis = Basis::toeheel;
|
||||
const int toe = d.toe_across > 0 ? 1 : -1;
|
||||
return trigger > 0 ? toe : -toe; // LT toe, RT heel
|
||||
}
|
||||
// Physics thread: every call of the jump-trajectory setter.
|
||||
void modify_launch(std::uintptr_t caller_rva, std::uintptr_t, float (&v)[3]) noexcept {
|
||||
auto& c = control();
|
||||
if (!c.enabled.load(std::memory_order_acquire) || caller_rva != gx::grind_pop_call) return;
|
||||
const auto now = qpc_now();
|
||||
if (!grinding_now(c, now)) return; // no complies and pops from a manual launch from the same call
|
||||
c.pops.fetch_add(1, std::memory_order_relaxed);
|
||||
Decision d;
|
||||
d.qpc = now;
|
||||
d.state = c.state.load(std::memory_order_acquire);
|
||||
d.up = v[1];
|
||||
d.lean_blocked = seconds(now - c.lean_masked.load(std::memory_order_relaxed)) < 0.15;
|
||||
const float tx = c.rail_x.load(std::memory_order_relaxed), tz = c.rail_z.load(std::memory_order_relaxed);
|
||||
const float speed = c.rail_speed.load(std::memory_order_relaxed);
|
||||
const float nx = tz, nz = -tx;
|
||||
d.rail_speed = speed;
|
||||
d.along = v[0] * tx + v[2] * tz;
|
||||
d.before_side = d.after_side = v[0] * nx + v[2] * nz;
|
||||
if (speed < min_rail_speed) { d.reason = Reason::slow; record(c, d); return; }
|
||||
// The launch must be this skater's: mostly along its rail, at about its speed (measured 0.93x).
|
||||
const float horizontal = std::hypot(v[0], v[2]);
|
||||
if (!(d.along > 0.5f * speed && d.along < 1.6f * speed && horizontal < 2.0f * speed + 3.0f)) {
|
||||
d.reason = Reason::not_ours;
|
||||
record(c, d);
|
||||
return;
|
||||
}
|
||||
bool both{};
|
||||
d.trigger = chosen_trigger(c, now, both);
|
||||
if (d.trigger == 0) { d.reason = both ? Reason::both_triggers : Reason::no_trigger; record(c, d); return; }
|
||||
d.toward = toward_side(c, d.trigger, nx, nz, now, d);
|
||||
const float side = static_cast<float>(d.toward);
|
||||
// Pop-out strength scales the sideways speed of every trigger-set exit; at 100% (the default)
|
||||
// the game's own exit toward the chosen side is kept as it is.
|
||||
const int strength = c.strength.load(std::memory_order_relaxed);
|
||||
if (strength >= 100 && d.before_side * side >= min_kept_side_speed) { d.reason = Reason::kept; record(c, d); return; }
|
||||
const float full = std::abs(d.before_side) >= min_kept_side_speed ? std::abs(d.before_side)
|
||||
: c.side_speed.load(std::memory_order_relaxed);
|
||||
const float magnitude = full * static_cast<float>(strength) / 100.0f;
|
||||
const float change = side * magnitude - d.before_side;
|
||||
v[0] += change * nx;
|
||||
v[2] += change * nz;
|
||||
d.after_side = side * magnitude;
|
||||
d.reason = Reason::applied;
|
||||
c.applied.fetch_add(1, std::memory_order_relaxed);
|
||||
record(c, d);
|
||||
}
|
||||
|
||||
void clear_triggers(Control& c) noexcept {
|
||||
AcquireSRWLockExclusive(&c.trigger_lock);
|
||||
c.triggers = {};
|
||||
ReleaseSRWLockExclusive(&c.trigger_lock);
|
||||
}
|
||||
std::string status_text(Control& c) {
|
||||
if (!c.started.load(std::memory_order_acquire)) return "Grind exit control is unavailable for this game build.";
|
||||
const bool on = c.enabled.load(std::memory_order_acquire);
|
||||
const auto stance = static_cast<Stance>(c.stance.load());
|
||||
const bool toeheel = static_cast<Sides>(c.sides.load()) == Sides::toeheel;
|
||||
std::string text = std::format(
|
||||
"Grind exit control: {}. Sides: {}. Stance: {}. Pop-out strength {}%. Grab guard {}, lean guard {}, pre-press window {:.2f} s, "
|
||||
"side speed {:.2f} m/s. Pops out of grinds seen: {}, sides set: {}.",
|
||||
on ? "ON" : "off", toeheel ? "LT toe / RT heel" : "LT left / RT right of travel",
|
||||
stance == Stance::goofy ? "goofy" : stance == Stance::regular ? "regular" : "NOT SET (type: grindexit stance goofy|regular)",
|
||||
c.strength.load(), c.guard.load() ? "on" : "off", c.lean_guard.load() ? "on" : "off", c.prewindow.load(), c.side_speed.load(), c.pops.load(),
|
||||
c.applied.load());
|
||||
if (on && toeheel && stance == Stance::unset) text += " Until the stance is set, LT/RT are left/right of travel.";
|
||||
if (on && !c.hooked.load()) text += " The launch hook is not ready yet (it installs once the game's code is recognised).";
|
||||
return text;
|
||||
}
|
||||
bool install(Control& c) {
|
||||
if (c.enabled.load(std::memory_order_acquire)) return true;
|
||||
if (!add_no_bail_step_listener(&on_step)) return false;
|
||||
c.state.store(0);
|
||||
c.step_qpc.store(0);
|
||||
c.last_state = 0;
|
||||
clear_triggers(c);
|
||||
trainer::set_trajectory_modifier(&modify_launch);
|
||||
DingoSDKOverlaySetGamepadFilter(&filter_pad);
|
||||
c.enabled.store(true, std::memory_order_release);
|
||||
logging::write(logging::Level::info, logging::Channel::skater, "Grind exit control on.");
|
||||
return true;
|
||||
}
|
||||
void uninstall(Control& c) {
|
||||
if (!c.enabled.exchange(false, std::memory_order_acq_rel)) return;
|
||||
DingoSDKOverlaySetGamepadFilter(nullptr);
|
||||
trainer::set_trajectory_modifier(nullptr);
|
||||
remove_no_bail_step_listener(&on_step);
|
||||
clear_triggers(c);
|
||||
logging::write(logging::Level::info, logging::Channel::skater, "Grind exit control off.");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool available() noexcept { return control().started.load(std::memory_order_acquire); }
|
||||
bool enabled() noexcept { return control().enabled.load(std::memory_order_acquire); }
|
||||
bool toe_heel() noexcept { return static_cast<Sides>(control().sides.load()) == Sides::toeheel; }
|
||||
int stance() noexcept { return control().stance.load(); }
|
||||
int strength() noexcept { return control().strength.load(); }
|
||||
|
||||
bool start(std::uintptr_t image_base) noexcept {
|
||||
auto& c = control();
|
||||
if (c.started.load(std::memory_order_acquire)) return true;
|
||||
if (!no_bail_available()) {
|
||||
logging::write(logging::Level::warning, logging::Channel::skater,
|
||||
"Grind exit control is unavailable: it follows the skater's physics steps through No Bail's hooks, which did not start.");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
c.settings_path = settings_file();
|
||||
load_settings(c);
|
||||
} catch (...) {}
|
||||
c.base.store(image_base, std::memory_order_release);
|
||||
c.started.store(true, std::memory_order_release);
|
||||
if (c.wanted.load() && !install(c))
|
||||
logging::write(logging::Level::warning, logging::Channel::skater, "Grind exit control could not start: no free step listener.");
|
||||
return true;
|
||||
}
|
||||
|
||||
void on_client_tick(std::uintptr_t client) noexcept {
|
||||
auto& c = control();
|
||||
if (!c.started.load(std::memory_order_acquire) || !c.enabled.load(std::memory_order_acquire)) return;
|
||||
const auto base = c.base.load(std::memory_order_acquire);
|
||||
if (!c.hooked.load(std::memory_order_acquire) && trainer::start_trick_heights(base)) c.hooked.store(true, std::memory_order_release);
|
||||
// The skater's facing (rows: right, up, forward, position), for toe/heel.
|
||||
const auto tick = GetTickCount64();
|
||||
if (tick >= c.next_axes_try) {
|
||||
try {
|
||||
overlay::DebugModel skater;
|
||||
const auto matrix = client_source::detail::debug_skater(base, client, skater);
|
||||
if (skater.skater_position_valid && std::isfinite(matrix[0]) && std::isfinite(matrix[2])) {
|
||||
c.right_x.store(matrix[0], std::memory_order_relaxed);
|
||||
c.right_z.store(matrix[2], std::memory_order_relaxed);
|
||||
c.axes_qpc.store(qpc_now(), std::memory_order_release);
|
||||
}
|
||||
} catch (...) {
|
||||
c.next_axes_try = tick + 250; // no skater: menus, loading, a respawn
|
||||
}
|
||||
}
|
||||
std::array<Decision, 32> taken{};
|
||||
std::size_t count{};
|
||||
AcquireSRWLockExclusive(&c.decision_lock);
|
||||
count = c.decision_count;
|
||||
std::copy_n(c.decisions.begin(), count, taken.begin());
|
||||
c.decision_count = 0;
|
||||
ReleaseSRWLockExclusive(&c.decision_lock);
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
const auto& d = taken[i];
|
||||
try {
|
||||
const char* trigger = d.trigger > 0 ? "LT" : d.trigger < 0 ? "RT" : "-";
|
||||
const char* toward = d.toward > 0 ? "left of travel" : d.toward < 0 ? "right of travel" : "-";
|
||||
logging::log(logging::Level::debug, logging::Channel::skater,
|
||||
"Grind exit: {} ({} -> {}; {}; {}{}). state {}, rail speed {:.2f}, along {:.2f}, up {:.2f}, sideways {:+.2f} -> {:+.2f}, toe across {:+.2f}",
|
||||
reason_text(d.reason), trigger, toward, d.trigger ? basis_text(d.basis) : "-",
|
||||
d.switch_stance ? "switch" : "normal/fakie", d.lean_blocked ? ", lean held back" : "", d.state, d.rail_speed,
|
||||
d.along, d.up, d.before_side, d.after_side, d.toe_across);
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
|
||||
std::string command(const std::vector<std::string>& words) {
|
||||
auto& c = control();
|
||||
const auto verb = words.empty() ? std::string("status") : words[0];
|
||||
const auto arg = words.size() > 1 ? words[1] : std::string{};
|
||||
const auto number = [&](float low, float high, float& out) {
|
||||
const auto parsed = std::from_chars(arg.data(), arg.data() + arg.size(), out);
|
||||
return parsed.ec == std::errc{} && out >= low && out <= high;
|
||||
};
|
||||
if (verb == "status") return status_text(c);
|
||||
if (verb == "exits") {
|
||||
if (arg == "on" || arg == "off") {
|
||||
if (!c.started.load()) return status_text(c);
|
||||
c.wanted.store(arg == "on");
|
||||
try { save_settings(c); } catch (...) {}
|
||||
if (arg == "off") uninstall(c);
|
||||
else if (!install(c)) return "Grind exit control could not follow the skater (no free step listener).";
|
||||
return status_text(c);
|
||||
}
|
||||
if (arg.empty() || arg == "status") return status_text(c);
|
||||
return "Usage: grindexit exits on|off|status";
|
||||
}
|
||||
if (verb == "stance") {
|
||||
if (arg != "goofy" && arg != "regular") return "Usage: grindexit stance goofy|regular (your skater's stance in the game settings)";
|
||||
c.stance.store(static_cast<int>(arg == "goofy" ? Stance::goofy : Stance::regular));
|
||||
try { save_settings(c); } catch (...) {}
|
||||
return status_text(c);
|
||||
}
|
||||
if (verb == "sides") {
|
||||
if (arg != "toeheel" && arg != "travel") return "Usage: grindexit sides toeheel|travel";
|
||||
c.sides.store(static_cast<int>(arg == "travel" ? Sides::travel : Sides::toeheel));
|
||||
try { save_settings(c); } catch (...) {}
|
||||
return status_text(c);
|
||||
}
|
||||
if (verb == "strength") {
|
||||
int value{};
|
||||
const auto parsed = std::from_chars(arg.data(), arg.data() + arg.size(), value);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != arg.data() + arg.size() || value < min_strength || value > 100)
|
||||
return "Usage: grindexit strength <10 to 100> (percent of the full pop-out to the side; 100 is the game's own)";
|
||||
c.strength.store(value);
|
||||
try { save_settings(c); } catch (...) {}
|
||||
return status_text(c);
|
||||
}
|
||||
if (verb == "guard" || verb == "leanguard") {
|
||||
if (arg != "on" && arg != "off") return "Usage: grindexit " + verb + " on|off";
|
||||
(verb == "guard" ? c.guard : c.lean_guard).store(arg == "on");
|
||||
if (verb == "guard" && arg == "off") clear_triggers(c);
|
||||
return status_text(c);
|
||||
}
|
||||
if (verb == "prewindow") {
|
||||
float value{};
|
||||
if (!number(0.0f, 2.0f, value)) return "Usage: grindexit prewindow <0 to 2> (seconds a trigger may be pressed before the grind connects; 1)";
|
||||
c.prewindow.store(value);
|
||||
return status_text(c);
|
||||
}
|
||||
if (verb == "sidespeed") {
|
||||
float value{};
|
||||
if (!number(0.5f, 4.0f, value)) return "Usage: grindexit sidespeed <0.5 to 4> (metres per second; the game's own is 1.8)";
|
||||
c.side_speed.store(value);
|
||||
return status_text(c);
|
||||
}
|
||||
return "Usage: grindexit exits on|off|status, stance goofy|regular, sides toeheel|travel, strength <10-100>, guard on|off, leanguard on|off, prewindow <s>, sidespeed <m/s>";
|
||||
}
|
||||
} // namespace dingosdk::grind_exit_control
|
||||
@@ -1,55 +0,0 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Grind exit control: pick the side you pop out of a grind to with LT / RT, independently of the
|
||||
// trick and of the body's rotation (Skate's own controls tie both to the left stick).
|
||||
//
|
||||
// While on:
|
||||
// - A trigger pressed DURING a grind, or up to `prewindow` seconds (1) before the grind
|
||||
// connects, is "claimed": it picks the side of the next pop out of the grind. The player
|
||||
// chooses what LT / RT mean:
|
||||
// toe/heel LT the toe side, RT the heel side, worked out from which foot leads (the
|
||||
// game's stance flag, Engine/Game/Build/20260929/grind_exit.h, and the player's
|
||||
// stance setting) and the skater's facing. On slides, where toe and heel point
|
||||
// along the rail, and while the stance is not set: left/right of travel.
|
||||
// left/right LT the left of the direction of travel, RT the right.
|
||||
// Both claimed: the game's own exit.
|
||||
// - At the pop, only the sideways part of the game's launch is replaced: the game's own
|
||||
// sideways speed when it had one, mirrored to the chosen side, else `sidespeed` (1.8 m/s, the
|
||||
// game's own). Along the rail, the pop's height, the trick, the flip and the body's rotation
|
||||
// are the game's.
|
||||
// - Pop-out strength (10-100%, kept): scales the sideways speed of every trigger-set exit, for
|
||||
// rails next to a wall. 100% is the behaviour above, the game's own full exit.
|
||||
// - Grab guard: a claimed trigger is hidden from the game until it is let go, so holding it
|
||||
// through the pop does not start a grab; press it again in the air to grab.
|
||||
// - Lean guard: while a claimed trigger is held in a grind, the left stick's left/right is
|
||||
// hidden from the game until the pop, so the game's own lean exit cannot start and pull the
|
||||
// board the other way; from the pop on the stick is the game's again, for the body's rotation.
|
||||
// - Multiplayer: on in any session, whatever the host's settings. It changes only how the local
|
||||
// player's inputs pick the exit side (the speed is the game's own); other players see the
|
||||
// result through the poses ReSkate already syncs.
|
||||
// It is limited to the supported game build: the launch hook is the trainer's, which installs
|
||||
// only when the game's code matches (Extension/Trainer/trainer_jump.cpp), and every address
|
||||
// comes from Engine/Game/Build/20260929. With debug logging on, every
|
||||
// pop out of a grind is written to ReSkate.log with
|
||||
// what was decided and why. On/off, the side convention and the stance are kept in
|
||||
// %LOCALAPPDATA%\ReSkate\grindexit\control.txt.
|
||||
namespace dingosdk::grind_exit_control {
|
||||
// Requires the validated build and No Bail started. Switches itself on when it was left on.
|
||||
bool start(std::uintptr_t image_base) noexcept;
|
||||
// Client thread, every tick, with the local client: keeps the launch hook ready, samples the
|
||||
// skater's facing, writes the physics thread's decisions to the log.
|
||||
void on_client_tick(std::uintptr_t client) noexcept;
|
||||
// The console verbs: exits on|off|status, stance goofy|regular, sides toeheel|travel, strength <10-100>,
|
||||
// guard on|off, leanguard on|off, prewindow <seconds>, sidespeed <m/s>.
|
||||
std::string command(const std::vector<std::string>& words);
|
||||
|
||||
// For the menus, any thread.
|
||||
bool available() noexcept;
|
||||
bool enabled() noexcept;
|
||||
bool toe_heel() noexcept; // else left/right of travel
|
||||
int stance() noexcept; // 0 not set, 1 regular, 2 goofy
|
||||
int strength() noexcept; // pop-out strength, percent of the sideways speed (10-100; 100 the full exit)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ PhysicsStateWatch watched_physics_state() noexcept;
|
||||
// Stopping flight must not discard the independent manual preference.
|
||||
void clear_no_bail_flight() noexcept;
|
||||
// The local skater as No Bail follows it (update_no_bail publishes it), for a feature that reads it
|
||||
// (Hall of Meat): any thread, its ownership chain resolved again now.
|
||||
// (the plugins' physics steps, grind exits): any thread, its ownership chain resolved again now.
|
||||
struct NoBailSkater {
|
||||
std::uintptr_t base{}, entity{}, core{}, context{}, rig{};
|
||||
};
|
||||
@@ -40,8 +40,8 @@ bool no_bail_skater(NoBailSkater& skater) noexcept;
|
||||
// One observer, nullptr for none; it runs on the physics thread.
|
||||
using NoBailStepObserver = void (*)(const NoBailSkater& skater, float seconds, bool wipeout) noexcept;
|
||||
void set_no_bail_step_observer(NoBailStepObserver observer) noexcept;
|
||||
// Further listeners on the same steps (the grind exit control, grind_exit_control.h),
|
||||
// independent of the observer above so none displaces Hall of Meat's. Same thread and timing. Adding one already there, or removing one that is not, does nothing;
|
||||
// Further listeners on the same steps (skater_events.h, grind_exit.h), independent of the observer
|
||||
// above so none displaces another. Same thread and timing. Adding one already there, or removing one that is not, does nothing;
|
||||
// false when all slots are taken.
|
||||
inline constexpr std::size_t no_bail_step_listener_slots = 4;
|
||||
bool add_no_bail_step_listener(NoBailStepObserver listener) noexcept;
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/skater_state.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat_skater.h"
|
||||
#include "Extension/Skater/skater_physics.h"
|
||||
#include "Extension/Skater/no_bail.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
@@ -19,12 +20,15 @@ constexpr std::size_t most_kept = 32;
|
||||
constexpr std::size_t cause_count = 35;
|
||||
constexpr float heard_hit = 3.0f; // m/s: lighter touches are not news (Board Break's body_hit)
|
||||
constexpr float fall_at_least = 0.4f, fall_at_most = 3.0f;
|
||||
constexpr std::size_t most_steps = 512;
|
||||
std::atomic<bool> steps_wanted{};
|
||||
|
||||
struct Shared {
|
||||
std::mutex mutex;
|
||||
std::deque<Landing> landings;
|
||||
std::deque<BodyHit> hits;
|
||||
std::deque<Fall> falls;
|
||||
std::deque<StepNews> steps;
|
||||
std::array<float, 3> velocity{};
|
||||
std::uint64_t velocity_at{};
|
||||
};
|
||||
@@ -119,8 +123,14 @@ void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept {
|
||||
}
|
||||
w.was_wipeout = wiping;
|
||||
}
|
||||
if (hall_of_meat::skater_available()) {
|
||||
const auto step = hall_of_meat::read_step(skater, seconds, wipeout);
|
||||
if (skater_physics::available()) {
|
||||
const auto step = skater_physics::read_step(skater, seconds, wipeout);
|
||||
if (steps_wanted.load(std::memory_order_acquire)) {
|
||||
const double at = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
std::lock_guard lock(s.mutex);
|
||||
if (s.steps.size() >= most_steps) s.steps.pop_front();
|
||||
s.steps.push_back({at, step});
|
||||
}
|
||||
const bool ragdoll = step.ragdoll.value_or(false);
|
||||
if (ragdoll) {
|
||||
std::optional<BodyHit> heard;
|
||||
@@ -170,6 +180,17 @@ void want() noexcept {
|
||||
listening.store(false);
|
||||
}
|
||||
}
|
||||
void want_steps() noexcept {
|
||||
want();
|
||||
steps_wanted.store(true, std::memory_order_release);
|
||||
}
|
||||
std::vector<StepNews> take_steps() {
|
||||
auto& s = shared();
|
||||
std::lock_guard lock(s.mutex);
|
||||
std::vector<StepNews> out(s.steps.begin(), s.steps.end());
|
||||
s.steps.clear();
|
||||
return out;
|
||||
}
|
||||
std::vector<Landing> take_landings() {
|
||||
auto& s = shared();
|
||||
std::lock_guard lock(s.mutex);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Skater/skater_body.h"
|
||||
#include "Extension/Skater/skater_physics.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -33,8 +34,17 @@ struct Slam {
|
||||
std::int32_t cause{-1};
|
||||
std::uint64_t at{};
|
||||
};
|
||||
// Every physics step as it was read (the physics_step event): when, on the steady clock in
|
||||
// milliseconds (as reskate.time.now), and what it read.
|
||||
struct StepNews {
|
||||
double at{};
|
||||
skater_physics::Step step;
|
||||
};
|
||||
// Client thread: starts reading the steps (once).
|
||||
void want() noexcept;
|
||||
// Client thread: from now on every step is kept for take_steps too (at most the last 512).
|
||||
void want_steps() noexcept;
|
||||
std::vector<StepNews> take_steps();
|
||||
std::vector<Landing> take_landings();
|
||||
std::vector<BodyHit> take_hits();
|
||||
std::vector<Fall> take_falls();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "hall_of_meat_skater.h"
|
||||
#include "skater_physics.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
@@ -11,7 +11,7 @@
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace dingosdk::skater_physics {
|
||||
namespace {
|
||||
namespace body = addr::skater_body;
|
||||
namespace state = addr::skater_state;
|
||||
@@ -112,26 +112,26 @@ std::uintptr_t step_length_address() noexcept {
|
||||
}
|
||||
}
|
||||
|
||||
bool start_skater(std::uintptr_t base) noexcept {
|
||||
bool start(std::uintptr_t base) noexcept {
|
||||
if (ready().load(std::memory_order_acquire)) return true;
|
||||
for (const auto& contract : body::contracts) {
|
||||
std::array<unsigned char, 32> actual{};
|
||||
if (!memory::peek(base + contract.rva, actual) || actual != contract.bytes) {
|
||||
logging::log(logging::Level::warning, logging::Channel::skater,
|
||||
"Hall of Meat cannot read the skater's body: the native contract at 0x{:x} did not match.", contract.rva);
|
||||
"Plugins cannot read the skater's body: the native contract at 0x{:x} did not match.", contract.rva);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!no_bail_available()) {
|
||||
logging::write(logging::Level::warning, logging::Channel::skater,
|
||||
"Hall of Meat cannot follow the skater: its physics steps come through No Bail's hooks, which did not start.");
|
||||
"The skater's physics steps are unavailable: they come through No Bail's hooks, which did not start.");
|
||||
return false;
|
||||
}
|
||||
ready().store(true, std::memory_order_release);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool skater_available() noexcept { return ready().load(std::memory_order_acquire); }
|
||||
bool available() noexcept { return ready().load(std::memory_order_acquire); }
|
||||
|
||||
Step read_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept {
|
||||
Step step;
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Skater/skater_body.h"
|
||||
#include "Extension/Skater/no_bail.h"
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
// The local skater as its physics simulates it, every physics step No Bail hands on (no_bail.h): what
|
||||
// each body touched (Engine/Game/Build/20260929/skater_body.h) and how the skater moves, on the board
|
||||
// or off it and whether in a ragdoll (Engine/Game/Build/20260929/skater_state.h). Plugins hear it as
|
||||
// the physics_step event (skater_events.h). Read-only, but for the physics step length the game speed
|
||||
// (game_speed.h) gives the skater.
|
||||
namespace dingosdk::skater_physics {
|
||||
// One physics step of the local skater.
|
||||
struct Step {
|
||||
float seconds{}; // how long the step simulated, in the game's time
|
||||
bool wipeout{}; // the step asks for a wipeout
|
||||
bool airborne{}; // the skater is in the air, on the board or off it
|
||||
// The body is a ragdoll; empty when the skater state could not be read.
|
||||
std::optional<bool> ragdoll;
|
||||
// How the skater moves (with the board while on it, else as its pelvis), metres per second;
|
||||
// empty when unknown.
|
||||
std::optional<game::Vec3> velocity;
|
||||
// How fast the pelvis turns, any way round, radians per second; empty when unknown.
|
||||
std::optional<float> spin;
|
||||
skater_body::Contacts body; // what each body touched in the step
|
||||
};
|
||||
|
||||
// Requires the validated build and No Bail started: verifies the layout once.
|
||||
bool start(std::uintptr_t image_base) noexcept;
|
||||
bool available() noexcept;
|
||||
// Physics thread, in the step. Exact here; read outside the step, the contacts may be half written.
|
||||
Step read_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept;
|
||||
// The local skater's physics step length (seconds of game time): the game sets it only when it builds
|
||||
// the skater's core (game_speed.h). 0 while there is no local skater.
|
||||
float step_length() noexcept;
|
||||
// Game update thread: gives the local skater this physics step length. False while there is no local
|
||||
// skater.
|
||||
bool set_step_length(float seconds) noexcept;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
#include "xray.h"
|
||||
#include "xray_render.h"
|
||||
#include <Windows.h>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace dingosdk::xray {
|
||||
namespace {
|
||||
// The picture is followed this long after the last frame that drew the x-ray.
|
||||
constexpr std::uint64_t follow_ms = 1000;
|
||||
struct State {
|
||||
std::atomic<bool> ready{};
|
||||
std::mutex mutex;
|
||||
std::set<std::string, std::less<>> owners; // client thread
|
||||
bool hooked{}; // client thread
|
||||
std::atomic<std::uint64_t> drawn_at{}; // render thread -> client tick
|
||||
};
|
||||
State& state() {
|
||||
static auto* value = new State; // the renderer's hooks may call in until exit
|
||||
return *value;
|
||||
}
|
||||
}
|
||||
|
||||
bool start(std::uintptr_t base) noexcept {
|
||||
auto& s = state();
|
||||
if (s.ready.load(std::memory_order_acquire)) return true;
|
||||
if (!start_render(base)) return false; // logged
|
||||
s.ready.store(true, std::memory_order_release);
|
||||
return true;
|
||||
}
|
||||
bool available() noexcept { return state().ready.load(std::memory_order_acquire); }
|
||||
|
||||
void prepare(std::string_view owner, bool on) {
|
||||
auto& s = state();
|
||||
std::lock_guard lock(s.mutex);
|
||||
if (on) {
|
||||
s.owners.emplace(owner);
|
||||
prepare_skeleton(); // once, in the background
|
||||
} else if (const auto found = s.owners.find(owner); found != s.owners.end()) {
|
||||
s.owners.erase(found);
|
||||
}
|
||||
}
|
||||
|
||||
void tick() noexcept {
|
||||
auto& s = state();
|
||||
if (!s.ready.load(std::memory_order_acquire)) return;
|
||||
try {
|
||||
bool wanted{};
|
||||
{
|
||||
std::lock_guard lock(s.mutex);
|
||||
wanted = !s.owners.empty();
|
||||
}
|
||||
const bool drawn = GetTickCount64() - s.drawn_at.load(std::memory_order_acquire) < follow_ms;
|
||||
follow_render(wanted && drawn);
|
||||
if (wanted != s.hooked && hook_render(wanted)) s.hooked = wanted;
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
bool latest(Pose& pose) {
|
||||
auto& s = state();
|
||||
if (!s.ready.load(std::memory_order_acquire)) return false;
|
||||
s.drawn_at.store(GetTickCount64(), std::memory_order_release);
|
||||
auto mesh = skeleton_mesh();
|
||||
Picture picture;
|
||||
PosedSkeleton posed;
|
||||
if (!mesh || !latest_picture(picture) || !pose_skeleton(*mesh, picture.skin, posed)) return false;
|
||||
pose.camera = picture.camera;
|
||||
pose.vertical_fov = picture.vertical_fov;
|
||||
pose.positions = std::move(posed.positions);
|
||||
pose.normals = std::move(posed.normals);
|
||||
pose.mesh = std::move(mesh);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
#include "xray_skeleton.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// The local skater's x-ray, for plugins (reskate.skater.xray, the draw handler's d:skeleton): skate.'s
|
||||
// own skeleton mesh (xray_skeleton.h) posed by the renderer's skinning matrices as it drew the skater
|
||||
// (xray_render.h), seen by the camera of that same picture. The overlay draws it, each body in the
|
||||
// colour a plugin gives it (Extension/UI/Overlay/xray_overlay.cpp).
|
||||
//
|
||||
// The renderer's two hooks are on while any plugin keeps the x-ray ready (off, the renderer never
|
||||
// calls them), and the picture is read only while one draws it.
|
||||
namespace dingosdk::xray {
|
||||
// Requires the validated build: prepares the renderer's hooks, which patches nothing.
|
||||
bool start(std::uintptr_t image_base) noexcept;
|
||||
bool available() noexcept;
|
||||
// Client thread: a plugin wants the x-ray ready (true) or no longer (false). The mesh is read in the
|
||||
// background the first time.
|
||||
void prepare(std::string_view owner, bool on);
|
||||
// Client thread, every tick: the hooks on while any plugin keeps it ready; the picture read while it
|
||||
// was drawn lately.
|
||||
void tick() noexcept;
|
||||
|
||||
// The skeleton as the renderer last drew the skater, in world space, with that picture's camera.
|
||||
struct Pose {
|
||||
std::array<float, 16> camera{}; // world matrix: right, up, back, position rows
|
||||
float vertical_fov{}; // degrees
|
||||
std::vector<game::Vec3> positions, normals; // one per vertex
|
||||
std::shared_ptr<const SkeletonMesh> mesh; // its triangles, and each vertex's body
|
||||
};
|
||||
// Render thread, as a plugin draws it: false while there is no picture of the skater.
|
||||
bool latest(Pose& pose);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "hall_of_meat_render.h"
|
||||
#include "xray_render.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
@@ -15,7 +15,7 @@
|
||||
#include <mutex>
|
||||
#include <numbers>
|
||||
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace dingosdk::xray {
|
||||
namespace {
|
||||
namespace build = addr::skater_render;
|
||||
using DrawPacket = void (*)(std::uintptr_t object, std::uintptr_t packet);
|
||||
@@ -109,7 +109,7 @@ void observe_packet(std::uintptr_t object, std::uintptr_t packet) {
|
||||
}
|
||||
if (!s.found_logged.exchange(true))
|
||||
logging::log(logging::Level::info, logging::Channel::graphics,
|
||||
"Hall of Meat: the renderer draws the local skater (render object {}, {} bones).", index, count);
|
||||
"X-ray: the renderer draws the local skater (render object {}, {} bones).", index, count);
|
||||
}
|
||||
void draw_packet_hook(std::uintptr_t object, std::uintptr_t packet) {
|
||||
auto& s = state();
|
||||
@@ -179,7 +179,7 @@ bool start_render(std::uintptr_t base) noexcept {
|
||||
std::array<unsigned char, 32> actual{};
|
||||
if (!memory::peek(base + contract.rva, actual) || actual != contract.bytes) {
|
||||
logging::log(logging::Level::warning, logging::Channel::graphics,
|
||||
"Hall of Meat cannot see where the skater is drawn: the native contract at 0x{:x} did not match.", contract.rva);
|
||||
"The skater x-ray cannot see where the skater is drawn: the native contract at 0x{:x} did not match.", contract.rva);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -206,7 +206,7 @@ bool start_render(std::uintptr_t base) noexcept {
|
||||
return true;
|
||||
}
|
||||
logging::log(logging::Level::warning, logging::Channel::graphics,
|
||||
"Hall of Meat cannot see where the skater is drawn: hooking the renderer failed (status {}).", static_cast<LONG>(status));
|
||||
"The skater x-ray cannot see where the skater is drawn: hooking the renderer failed (status {}).", static_cast<LONG>(status));
|
||||
while (prepared) (void)hook_remove(targets[--prepared]);
|
||||
return false;
|
||||
}
|
||||
@@ -226,7 +226,7 @@ bool hook_render(bool on) noexcept {
|
||||
s.hooked = on;
|
||||
return true;
|
||||
}
|
||||
logging::log(logging::Level::warning, logging::Channel::graphics, "Hall of Meat could not {} its renderer hooks (status {}).",
|
||||
logging::log(logging::Level::warning, logging::Channel::graphics, "The skater x-ray could not {} its renderer hooks (status {}).",
|
||||
on ? "enable" : "disable", static_cast<LONG>(status));
|
||||
// Both or neither.
|
||||
while (changed) {
|
||||
@@ -5,16 +5,16 @@
|
||||
#include <vector>
|
||||
|
||||
// The local skater as the renderer draws it (Engine/Game/Build/20260929/skater_render.h): the
|
||||
// skinning matrices its mesh was drawn with and the camera of the same picture, so Hall of Meat's
|
||||
// skeleton lies exactly where the game draws the skater. Two hooks that only observe: the skinned
|
||||
// skinning matrices its mesh was drawn with and the camera of the same picture, so the x-ray's
|
||||
// skeleton (xray.h) lies exactly where the game draws the skater. Two hooks that only observe: the skinned
|
||||
// draw packets, and the main view's camera.
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace dingosdk::xray {
|
||||
// Requires the validated build: verifies the layout and prepares both hooks, which patches nothing.
|
||||
bool start_render(std::uintptr_t image_base) noexcept;
|
||||
// Client tick: both hooks on while Hall of Meat is, off otherwise, so a renderer it is switched off
|
||||
// for never calls it. False when they could not be changed (logged; both stay as they were).
|
||||
// Client tick: both hooks on while a plugin keeps the x-ray ready, off otherwise, so the renderer
|
||||
// never calls them for nothing. False when they could not be changed (logged; both stay as they were).
|
||||
bool hook_render(bool on) noexcept;
|
||||
// Client tick: while `wanted` (a bail shows), which render objects are the local skater's
|
||||
// Client tick: while `wanted` (a plugin draws the x-ray), which render objects are the local skater's
|
||||
// (no_bail.h no_bail_skater), riding and walking; otherwise both hooks pass every call straight on.
|
||||
void follow_render(bool wanted) noexcept;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "hall_of_meat_skeleton.h"
|
||||
#include "xray_skeleton.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/skater_skeleton.h"
|
||||
@@ -13,7 +13,7 @@
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace dingosdk::xray {
|
||||
namespace {
|
||||
namespace build = addr::skater_skeleton;
|
||||
|
||||
@@ -167,16 +167,16 @@ void prepare_skeleton() noexcept {
|
||||
try {
|
||||
auto read = std::make_shared<const SkeletonMesh>(read_skeleton_mesh(game_folder()));
|
||||
logging::log(logging::Level::info, logging::Channel::skater,
|
||||
"Hall of Meat: read the skater's skeleton ({} vertices, {} triangles) in {} ms.", read->vertices.size(),
|
||||
"X-ray: read the skater's skeleton ({} vertices, {} triangles) in {} ms.", read->vertices.size(),
|
||||
read->triangles.size() / 3, GetTickCount64() - started);
|
||||
loader().mesh.store(std::move(read));
|
||||
} catch (const std::exception& failure) {
|
||||
logging::log(logging::Level::warning, logging::Channel::skater, "Hall of Meat shows no skeleton: {}.", failure.what());
|
||||
logging::log(logging::Level::warning, logging::Channel::skater, "The skater x-ray shows no skeleton: {}.", failure.what());
|
||||
}
|
||||
}).detach();
|
||||
});
|
||||
} catch (...) {
|
||||
logging::write(logging::Level::warning, logging::Channel::skater, "Hall of Meat shows no skeleton: its reading could not start.");
|
||||
logging::write(logging::Level::warning, logging::Channel::skater, "The skater x-ray shows no skeleton: its reading could not start.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// The skeleton Hall of Meat draws: skate.'s own skeleton mesh, read once from the installed game's
|
||||
// The skeleton the x-ray draws (xray.h): skate.'s own skeleton mesh, read once from the installed game's
|
||||
// data (Engine/Game/Build/20260929/skater_skeleton.h) and posed by the renderer's own skinning
|
||||
// matrices (hall_of_meat_render.h), so it lies where the game draws the skater. Each vertex knows the
|
||||
// matrices (xray_render.h), so it lies where the game draws the skater. Each vertex knows the
|
||||
// ragdoll's body (skater_body.h) it moves with, for whatever that body went through.
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace dingosdk::xray {
|
||||
using game::LinearTransform;
|
||||
using game::Vec3;
|
||||
|
||||
@@ -49,7 +49,7 @@ struct PosedSkeleton {
|
||||
// world). False, leaving `posed` as it was, when it lacks some.
|
||||
bool pose_skeleton(const SkeletonMesh& mesh, std::span<const LinearTransform> skin, PosedSkeleton& posed);
|
||||
|
||||
// The game's folder (the one with Skate.exe), whose data the skeleton and the overlay's images come from.
|
||||
// The game's folder (the one with Skate.exe), whose data the skeleton and the game's pictures come from.
|
||||
std::filesystem::path game_folder();
|
||||
// From the game folder; throws when it cannot be read.
|
||||
[[nodiscard]] SkeletonMesh read_skeleton_mesh(const std::filesystem::path& game_root);
|
||||
@@ -16,7 +16,7 @@ bool start_trick_heights(std::uintptr_t base) noexcept;
|
||||
void set_trick_heights(float no_comply, float boneless) noexcept;
|
||||
// The scripted launches since the last call, oldest first; false when there are none.
|
||||
bool take_trick_launch(TrickLaunch &out) noexcept;
|
||||
// The grind exit control (Extension/Skater/grind_exit_control.h): may change the launch velocity
|
||||
// Grind exits (Extension/Skater/grind_exit.h): may change the launch velocity
|
||||
// (x, y, z at parameters + 0x80) of any call of the game's jump-trajectory setter before the game
|
||||
// uses it, given the caller's address relative to the game image (the return address minus 5) and
|
||||
// the trajectory object it fills. Runs before the trainer's own trick multipliers. Physics thread;
|
||||
|
||||
@@ -112,53 +112,6 @@ int main() {
|
||||
const auto find = [](const std::vector<Row>& rows, std::string_view id) {
|
||||
return std::find_if(rows.begin(), rows.end(), [&](const auto& row) { return row.id == id; });
|
||||
};
|
||||
// Hall of Meat sits with the player's other switches.
|
||||
const auto meat_row = [&] {
|
||||
const auto page = render(state, model, cb, player_section);
|
||||
return *find(page.main, "hall-of-meat");
|
||||
};
|
||||
check(meat_row().title == "Hall of Meat: Off" && meat_row().command.empty(), "Hall of Meat waits until it started");
|
||||
model.hall_of_meat = {true, false};
|
||||
activate(state, model, cb, "hall-of-meat", "");
|
||||
check(meat_row().command == "hall-of-meat" && capture.command == "hallofmeat 1", "Hall of Meat turns on");
|
||||
model.hall_of_meat = {true, true};
|
||||
activate(state, model, cb, "hall-of-meat", "");
|
||||
check(meat_row().title == "Hall of Meat: On" && capture.command == "hallofmeat 0", "and off again");
|
||||
// Grind exit triggers: the switch, what LT / RT mean, and the stance toe/heel needs.
|
||||
const auto grind_row = [&](std::string_view id) {
|
||||
const auto page = render(state, model, cb, player_section);
|
||||
const auto row = find(page.main, id);
|
||||
return row == page.main.end() ? Row{} : *row;
|
||||
};
|
||||
check(grind_row("grind-exits").title == "Grind exit triggers: Off" && grind_row("grind-exits").command.empty(),
|
||||
"Grind exit triggers wait until they started");
|
||||
model.grind_exit = {true, false, true, 0};
|
||||
activate(state, model, cb, "grind-exits", "");
|
||||
check(grind_row("grind-exits").command == "grind-exits" && capture.command == "grindexit exits on", "Grind exit triggers turn on");
|
||||
check(grind_row("grind-exit-sides").title == "Input Mode: Toe / Heel" && grind_row("grind-exit-stance").title == "Your stance: Not set",
|
||||
"Toe / heel shows the stance it needs");
|
||||
activate(state, model, cb, "grind-exit-stance", "");
|
||||
check(capture.command == "grindexit stance goofy", "The stance is set");
|
||||
model.grind_exit = {true, true, true, 2};
|
||||
activate(state, model, cb, "grind-exit-stance", "");
|
||||
check(capture.command == "grindexit stance regular", "and changed");
|
||||
activate(state, model, cb, "grind-exit-sides", "");
|
||||
check(capture.command == "grindexit sides travel", "LT / RT switch to left / right of travel");
|
||||
model.grind_exit = {true, true, false, 2};
|
||||
check(grind_row("grind-exit-sides").title == "Input Mode: Left / Right" && grind_row("grind-exit-stance").id.empty(),
|
||||
"which needs no stance");
|
||||
activate(state, model, cb, "grind-exits", "");
|
||||
check(capture.command == "grindexit exits off", "and the triggers turn off");
|
||||
// Pop-out strength steps down from the full exit and wraps back to it.
|
||||
check(grind_row("grind-exit-strength").title == "Pop-out strength: 100%", "Pop-out strength shows the full exit");
|
||||
activate(state, model, cb, "grind-exit-strength", "");
|
||||
check(capture.command == "grindexit strength 75", "and steps down");
|
||||
model.grind_exit.strength = 40;
|
||||
activate(state, model, cb, "grind-exit-strength", "");
|
||||
check(capture.command == "grindexit strength 25", "a slider value steps to the next lower step");
|
||||
model.grind_exit.strength = 25;
|
||||
activate(state, model, cb, "grind-exit-strength", "");
|
||||
check(capture.command == "grindexit strength 100", "and the lowest wraps to the full exit");
|
||||
// Keyframe smoothing sits with what you see, under the replay editor.
|
||||
const auto smoothing_row = [&] {
|
||||
const auto page = render(state, model, cb, visuals_section);
|
||||
|
||||
@@ -200,20 +200,6 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks
|
||||
const overlay::OfflineFeatureRequest request{overlay::OfflineFeatureGroup::board_wear, !wear.effective};
|
||||
report(wear.available && cb.queue_offline_feature &&
|
||||
cb.queue_offline_feature(cb.user, request, result.data(), result.size()));
|
||||
} else if (command == "hall-of-meat") {
|
||||
console(m.hall_of_meat.enabled ? "hallofmeat 0" : "hallofmeat 1", m.hall_of_meat.available);
|
||||
} else if (command == "grind-exits") {
|
||||
console(m.grind_exit.enabled ? "grindexit exits off" : "grindexit exits on", m.grind_exit.available);
|
||||
} else if (command == "grind-exit-sides") {
|
||||
console(m.grind_exit.toe_heel ? "grindexit sides travel" : "grindexit sides toeheel", m.grind_exit.available);
|
||||
} else if (command == "grind-exit-stance") {
|
||||
// Not set or regular -> goofy -> regular.
|
||||
console(m.grind_exit.stance == 2 ? "grindexit stance regular" : "grindexit stance goofy", m.grind_exit.available);
|
||||
} else if (command == "grind-exit-strength") {
|
||||
// 100 -> 75 -> 50 -> 25 -> 100 (the ReSkate menu's slider sets any value from 10 to 100).
|
||||
const int strength = m.grind_exit.strength;
|
||||
const int next = strength > 75 ? 75 : strength > 50 ? 50 : strength > 25 ? 25 : 100;
|
||||
console("grindexit strength " + std::to_string(next), m.grind_exit.available);
|
||||
} else if (command == "keyframe-smoothing") {
|
||||
console(m.keyframe_smoothing.enabled ? "keysmooth off" : "keysmooth on", m.keyframe_smoothing.available);
|
||||
} else if (command == "keyframe-smoothness") {
|
||||
@@ -316,18 +302,6 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3
|
||||
toggle(p.main, "noclip", "Noclip", m.debug.noclip, (m.debug.noclip_available || m.debug.noclip) && cb.queue_debug);
|
||||
toggle(p.main, "no-bail", "No bail", m.debug.no_bail, (m.debug.no_bail_available || m.debug.no_bail) && cb.queue_debug);
|
||||
toggle(p.main, "freecam", "Freecam", m.debug.free_camera, m.debug.available && m.debug.camera_available && cb.queue_debug);
|
||||
toggle(p.main, "hall-of-meat", "Hall of Meat", m.hall_of_meat.enabled, m.hall_of_meat.available && cb.queue_console_command);
|
||||
const auto& grind = m.grind_exit;
|
||||
const bool grind_console = grind.available && cb.queue_console_command;
|
||||
toggle(p.main, "grind-exits", "Grind exit triggers", grind.enabled, grind_console);
|
||||
button(p.main, "grind-exit-sides", std::string("Input Mode: ") + (grind.toe_heel ? "Toe / Heel" : "Left / Right"),
|
||||
"grind-exit-sides", grind_console);
|
||||
if (grind.toe_heel)
|
||||
button(p.main, "grind-exit-stance",
|
||||
std::string("Your stance: ") + (grind.stance == 2 ? "Goofy" : grind.stance == 1 ? "Regular" : "Not set"),
|
||||
"grind-exit-stance", grind_console);
|
||||
button(p.main, "grind-exit-strength", "Pop-out strength: " + std::to_string(grind.strength) + "%", "grind-exit-strength",
|
||||
grind_console);
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#include "game_images.h"
|
||||
#include "Extension/Skater/xray_skeleton.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/ui_textures.h"
|
||||
#include "Engine/Vfs/game_textures.h"
|
||||
#include "Extension/UI/Overlay/overlay_internal.h"
|
||||
#include "Extension/UI/skate_theme.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <future>
|
||||
#include <optional>
|
||||
|
||||
namespace dingosdk::overlay {
|
||||
namespace {
|
||||
namespace ui = addr::ui_textures;
|
||||
namespace theme = dingosdk::skate_theme;
|
||||
|
||||
// The pictures, by the names plugins give them.
|
||||
enum Picture : std::size_t { picture_count = 13 };
|
||||
constexpr std::array<std::string_view, picture_count> names{
|
||||
"stopwatch", "wipeout", "wipeout_broken", "spread_eagle", "airtime", "gap_height", "flaming_wheel", "roll",
|
||||
"thrasher", "brush_bar", "rough_tile", "scratches", "streak"};
|
||||
struct Source {
|
||||
const ui::Texture* texture;
|
||||
std::uint32_t side; // fitted into a side x side square, its aspect kept
|
||||
bool brightness{}; // white with the texture's brightness as alpha (light marks on black), else its own alpha
|
||||
};
|
||||
constexpr std::array<Source, picture_count> sources{{
|
||||
{&ui::stopwatch, 64}, {&ui::wipeout, 64}, {&ui::wipeout_broken, 64}, {&ui::spread_eagle, 64},
|
||||
{&ui::airtime, 64}, {&ui::gap_height, 64}, {&ui::flaming_wheel, 64}, {&ui::roll, 64},
|
||||
{&ui::thrasher_wordmark, 512}, {&ui::brush_bar, 512}, {&ui::rough_tile, 256}, {&ui::scratches, 512, true},
|
||||
{&ui::streak, 512},
|
||||
}};
|
||||
using Images = std::array<std::optional<frostbite::Image>, picture_count>;
|
||||
|
||||
struct Reads {
|
||||
std::mutex mutex;
|
||||
bool started{};
|
||||
std::future<Images> pending;
|
||||
Images decoded; // kept: every atlas the overlay builds (one per swapchain) needs them
|
||||
};
|
||||
Reads& reads() {
|
||||
static auto* state = new Reads;
|
||||
return *state;
|
||||
}
|
||||
|
||||
// The picture's part of the texture, white: with its own alpha, or its brightness as alpha.
|
||||
frostbite::Image cut(const frostbite::Image& image, const ui::Region& region, bool brightness) {
|
||||
const auto at = [](float fraction, std::uint32_t size) { return static_cast<std::uint32_t>(fraction * static_cast<float>(size) + 0.5f); };
|
||||
if (!image.width || !image.height || image.rgba.size() != std::size_t{image.width} * image.height * 4)
|
||||
throw std::runtime_error("the texture is empty");
|
||||
const auto x0 = std::min(at(region.left, image.width), image.width - 1), y0 = std::min(at(region.top, image.height), image.height - 1);
|
||||
const auto x1 = std::clamp(at(region.right, image.width), x0 + 1, image.width);
|
||||
const auto y1 = std::clamp(at(region.bottom, image.height), y0 + 1, image.height);
|
||||
frostbite::Image result{x1 - x0, y1 - y0, std::vector<std::uint8_t>(std::size_t{x1 - x0} * (y1 - y0) * 4)};
|
||||
for (std::uint32_t y = y0; y < y1; ++y)
|
||||
std::memcpy(result.rgba.data() + std::size_t{y - y0} * result.width * 4,
|
||||
image.rgba.data() + (std::size_t{y} * image.width + x0) * 4, std::size_t{result.width} * 4);
|
||||
for (std::size_t i = 0; i < result.rgba.size(); i += 4) {
|
||||
auto* p = result.rgba.data() + i;
|
||||
if (brightness) p[3] = std::max({p[0], p[1], p[2]});
|
||||
p[0] = p[1] = p[2] = 255;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Images decode() {
|
||||
Images result;
|
||||
try {
|
||||
vfs::GameTextures textures(xray::game_folder());
|
||||
for (std::size_t index = 0; index < picture_count; ++index) {
|
||||
const auto& source = sources[index];
|
||||
try {
|
||||
result[index] = cut(textures.read(source.texture->toc, source.texture->bundle, source.texture->name, source.side),
|
||||
source.texture->region, source.brightness);
|
||||
} catch (const std::exception& error) {
|
||||
logging::log(logging::Level::warning, logging::Channel::graphics, "The game's picture {} is unavailable: {}.",
|
||||
source.texture->name, error.what());
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
logging::log(logging::Level::warning, logging::Channel::graphics, "The game's pictures are unavailable: {}.", error.what());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// The images in the atlas being built or drawn with: render thread only.
|
||||
struct Loaded {
|
||||
ImFontAtlas* atlas{};
|
||||
bool filled{};
|
||||
std::array<int, picture_count> rects{}; // each one's custom rect in the atlas; -1 none
|
||||
};
|
||||
Loaded& loaded() {
|
||||
static Loaded state;
|
||||
return state;
|
||||
}
|
||||
// A picture in the atlas the overlay draws with: where, and its texture coordinates.
|
||||
struct Found {
|
||||
const ImFontAtlasCustomRect* rect{};
|
||||
ImVec2 uv0, uv1;
|
||||
};
|
||||
bool find(Picture picture, Found& found) {
|
||||
const auto& l = loaded();
|
||||
if (!l.filled || !l.atlas || ImGui::GetIO().Fonts != l.atlas || !l.atlas->TexID || l.rects[picture] < 0) return false;
|
||||
found.rect = l.atlas->GetCustomRectByIndex(l.rects[picture]);
|
||||
if (!found.rect || !found.rect->IsPacked() || !found.rect->Width || !found.rect->Height) return false;
|
||||
l.atlas->CalcCustomRectUV(found.rect, &found.uv0, &found.uv1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fitted into the box with its aspect kept, centred: an icon, a logo.
|
||||
bool draw_image(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint) {
|
||||
Found found;
|
||||
if (!find(picture, found)) return false;
|
||||
const auto* rect = found.rect;
|
||||
const float scale = std::min((max.x - min.x) / rect->Width, (max.y - min.y) / rect->Height);
|
||||
const ImVec2 size(rect->Width * scale, rect->Height * scale);
|
||||
const ImVec2 at(min.x + (max.x - min.x - size.x) * 0.5f, min.y + (max.y - min.y - size.y) * 0.5f);
|
||||
draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + size.x, at.y + size.y), found.uv0, found.uv1, tint);
|
||||
return true;
|
||||
}
|
||||
// Its body (ui_textures.h Texture::body) stretched onto the box, the rest of it around: a brush
|
||||
// stroke's bar on the box, its splatter beyond.
|
||||
bool draw_shape(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint) {
|
||||
Found found;
|
||||
if (!find(picture, found)) return false;
|
||||
const auto& body = sources[picture].texture->body;
|
||||
const float width = (max.x - min.x) / (body.right - body.left), height = (max.y - min.y) / (body.bottom - body.top);
|
||||
const ImVec2 at(min.x - body.left * width, min.y - body.top * height);
|
||||
draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + width, at.y + height), found.uv0, found.uv1, tint);
|
||||
return true;
|
||||
}
|
||||
// Nine-sliced onto the box: its corners (Texture::slice of the picture) kept `edge` pixels square,
|
||||
// its edges stretched along them and its middle across the rest. A box smaller than two edges
|
||||
// shrinks them to half of it.
|
||||
bool draw_panel(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, float edge, ImU32 tint) {
|
||||
Found found;
|
||||
if (!find(picture, found)) return false;
|
||||
const float slice = sources[picture].texture->slice;
|
||||
const float edge_x = std::clamp(edge, 0.0f, (max.x - min.x) * 0.5f), edge_y = std::clamp(edge, 0.0f, (max.y - min.y) * 0.5f);
|
||||
const float uv_x = slice * (found.uv1.x - found.uv0.x), uv_y = slice * (found.uv1.y - found.uv0.y);
|
||||
const std::array<float, 4> xs{min.x, min.x + edge_x, max.x - edge_x, max.x}, ys{min.y, min.y + edge_y, max.y - edge_y, max.y};
|
||||
const std::array<float, 4> us{found.uv0.x, found.uv0.x + uv_x, found.uv1.x - uv_x, found.uv1.x};
|
||||
const std::array<float, 4> vs{found.uv0.y, found.uv0.y + uv_y, found.uv1.y - uv_y, found.uv1.y};
|
||||
for (std::size_t row = 0; row < 3; ++row)
|
||||
for (std::size_t column = 0; column < 3; ++column)
|
||||
draw->AddImage(loaded().atlas->TexID, ImVec2(xs[column], ys[row]), ImVec2(xs[column + 1], ys[row + 1]),
|
||||
ImVec2(us[column], vs[row]), ImVec2(us[column + 1], vs[row + 1]), tint);
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void prepare_game_images() noexcept {
|
||||
try {
|
||||
auto& state = reads();
|
||||
std::lock_guard lock(state.mutex);
|
||||
if (std::exchange(state.started, true)) return;
|
||||
state.pending = std::async(std::launch::async, &decode);
|
||||
} catch (...) {
|
||||
logging::write(logging::Level::warning, logging::Channel::graphics, "The game's pictures are unavailable: their reading could not start.");
|
||||
}
|
||||
}
|
||||
|
||||
void reserve_game_images(ImFontAtlas& atlas, std::chrono::milliseconds wait) noexcept {
|
||||
auto& l = loaded();
|
||||
l = {};
|
||||
l.rects.fill(-1);
|
||||
try {
|
||||
auto& state = reads();
|
||||
std::lock_guard lock(state.mutex);
|
||||
// A read still running stays pending for the next atlas.
|
||||
if (state.pending.valid() && state.pending.wait_for(wait) == std::future_status::ready) state.decoded = state.pending.get();
|
||||
for (std::size_t index = 0; index < picture_count; ++index) {
|
||||
const auto& image = state.decoded[index];
|
||||
if (!image) continue;
|
||||
if (!l.atlas) {
|
||||
atlas.TexDesiredWidth = std::max(atlas.TexDesiredWidth, 2048);
|
||||
atlas.Flags |= ImFontAtlasFlags_NoPowerOfTwoHeight;
|
||||
l.atlas = &atlas;
|
||||
}
|
||||
l.rects[index] = atlas.AddCustomRectRegular(static_cast<int>(image->width), static_cast<int>(image->height));
|
||||
}
|
||||
} catch (...) {
|
||||
l = {};
|
||||
}
|
||||
}
|
||||
|
||||
void fill_game_images(ImFontAtlas& atlas) noexcept {
|
||||
auto& l = loaded();
|
||||
if (l.atlas != &atlas) return;
|
||||
try {
|
||||
unsigned char* pixels{};
|
||||
int width{}, height{};
|
||||
atlas.GetTexDataAsRGBA32(&pixels, &width, &height);
|
||||
if (!pixels || width <= 0 || height <= 0) {
|
||||
l = {};
|
||||
return;
|
||||
}
|
||||
auto& state = reads();
|
||||
std::lock_guard lock(state.mutex);
|
||||
for (std::size_t index = 0; index < picture_count; ++index) {
|
||||
if (l.rects[index] < 0) continue;
|
||||
const auto& image = *state.decoded[index];
|
||||
const auto* rect = atlas.GetCustomRectByIndex(l.rects[index]);
|
||||
if (!rect || !rect->IsPacked() || rect->X + image.width > static_cast<unsigned>(width) ||
|
||||
rect->Y + image.height > static_cast<unsigned>(height)) {
|
||||
l = {};
|
||||
return;
|
||||
}
|
||||
for (std::uint32_t y = 0; y < image.height; ++y)
|
||||
std::memcpy(pixels + ((static_cast<std::size_t>(rect->Y) + y) * width + rect->X) * 4,
|
||||
image.rgba.data() + static_cast<std::size_t>(y) * image.width * 4, static_cast<std::size_t>(image.width) * 4);
|
||||
}
|
||||
atlas.TexPixelsUseColors = true;
|
||||
l.filled = true;
|
||||
} catch (...) {
|
||||
l = {};
|
||||
}
|
||||
}
|
||||
|
||||
bool game_image_known(std::string_view name) noexcept { return std::find(names.begin(), names.end(), name) != names.end(); }
|
||||
bool draw_game_image(ImDrawList* draw, std::string_view name, ImVec2 min, ImVec2 max, ImU32 tint, GameImageFit fit, float edge) {
|
||||
const auto at = std::find(names.begin(), names.end(), name);
|
||||
if (at == names.end()) return false;
|
||||
const auto picture = static_cast<Picture>(at - names.begin());
|
||||
switch (fit) {
|
||||
case GameImageFit::contain: return draw_image(draw, picture, min, max, tint);
|
||||
case GameImageFit::shape: return draw_shape(draw, picture, min, max, tint);
|
||||
case GameImageFit::panel: return draw_panel(draw, picture, min, max, edge, tint);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
float thrasher_arch() noexcept { return ui::thrasher_wordmark_arch; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
// Pictures of skate.'s own that plugins draw by name (the draw handler's d:game_image): the scoring
|
||||
// icons, the THRASHER wordmark and the brush strokes and tiles of its menus, read from the installed
|
||||
// game's data (Engine/Game/Build/20260929/ui_textures.h) and placed in the overlay's font atlas like
|
||||
// the chat emotes. Nothing of the game's is shipped.
|
||||
#include <chrono>
|
||||
#include <string_view>
|
||||
#include <imgui.h>
|
||||
|
||||
namespace dingosdk::overlay {
|
||||
// Startup: starts reading the pictures in the background, once.
|
||||
void prepare_game_images() noexcept;
|
||||
// Each atlas the overlay builds: their room reserved first, waiting up to `wait` for a read still
|
||||
// running, and filled after the atlas is built.
|
||||
void reserve_game_images(ImFontAtlas& atlas, std::chrono::milliseconds wait) noexcept;
|
||||
void fill_game_images(ImFontAtlas& atlas) noexcept;
|
||||
// Whether `name` is one of the pictures: stopwatch, wipeout, wipeout_broken, spread_eagle, airtime,
|
||||
// gap_height, flaming_wheel, roll, thrasher, brush_bar, rough_tile, scratches, streak.
|
||||
bool game_image_known(std::string_view name) noexcept;
|
||||
// How the picture goes onto the box: fitted with its aspect kept and centred; its body stretched onto
|
||||
// the box with the rest of it around (a brush stroke's splatter beyond its bar); or nine-sliced, its
|
||||
// corners `edge` pixels square.
|
||||
enum class GameImageFit { contain, shape, panel };
|
||||
// Render thread. False while the picture is not loaded (yet, or at all).
|
||||
bool draw_game_image(ImDrawList* draw, std::string_view name, ImVec2 min, ImVec2 max, ImU32 tint, GameImageFit fit, float edge);
|
||||
// The THRASHER wordmark's arch: between a fifth in from either side its letters end above this
|
||||
// fraction of its height, leaving room beneath.
|
||||
float thrasher_arch() noexcept;
|
||||
}
|
||||
@@ -1211,14 +1211,6 @@ void tools_page(Ui &ui, ImVec2 a, ImVec2 z) {
|
||||
list.heading("SKATE YOUR WAY");
|
||||
if (list.setting("Noclip", on_off(d.noclip), (d.noclip_available || d.noclip) && debugging)) debug({DebugAction::set_noclip, !d.noclip});
|
||||
if (list.setting("No bail", on_off(d.no_bail), (d.no_bail_available || d.no_bail) && debugging)) debug({DebugAction::set_no_bail, !d.no_bail});
|
||||
if (list.setting("Hall of Meat", on_off(m.hall_of_meat.enabled), m.hall_of_meat.available && console)) act("hall-of-meat");
|
||||
const auto &grind = m.grind_exit;
|
||||
if (list.setting("Grind exit triggers", on_off(grind.enabled), grind.available && console)) act("grind-exits");
|
||||
if (list.setting("Input Mode", grind.toe_heel ? "Toe / Heel" : "Left / Right", grind.available && console)) act("grind-exit-sides");
|
||||
if (grind.toe_heel &&
|
||||
list.setting("Your stance", grind.stance == 2 ? "Goofy" : grind.stance == 1 ? "Regular" : "Not set", grind.available && console))
|
||||
act("grind-exit-stance");
|
||||
if (list.setting("Pop-out strength", std::to_string(grind.strength) + "%", grind.available && console)) act("grind-exit-strength");
|
||||
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");
|
||||
|
||||
@@ -96,7 +96,7 @@ struct FlightInput {
|
||||
|
||||
// May change what the GAME reads from an XInput pad (never what ReSkate itself reads). `user` is
|
||||
// the XInput slot. Called on whatever thread the game polls from, once per poll that returns a
|
||||
// pad. The grind exit control's grab and lean guards (grind_exit_control.h).
|
||||
// pad. Plugins' hidden pad controls (Extension/Skater/grind_exit.h, reskate.input.hide).
|
||||
struct GamepadValues {
|
||||
std::uint16_t buttons = 0;
|
||||
std::uint8_t left_trigger = 0, right_trigger = 0;
|
||||
@@ -216,20 +216,6 @@ struct MissionRow {
|
||||
int completed = -1;
|
||||
};
|
||||
|
||||
// Hall of Meat's switch (Extension/HallOfMeat/hall_of_meat.h): started, and switched on.
|
||||
struct HallOfMeatModel {
|
||||
bool available = false, enabled = false;
|
||||
};
|
||||
|
||||
// Grind exit control's switches (Extension/Skater/grind_exit_control.h): started, switched on,
|
||||
// LT/RT toe/heel (else left/right of travel), and the player's stance (0 not set, 1 regular,
|
||||
// 2 goofy).
|
||||
struct GrindExitModel {
|
||||
bool available = false, enabled = false, toe_heel = true;
|
||||
int stance = 0;
|
||||
int strength = 100; // pop-out strength, percent (10-100)
|
||||
};
|
||||
|
||||
// Keyframe smoothing (Extension/ReplayKeyframes/replay_keyframes.h): started, switched on, and how
|
||||
// smooth (0..1) when on.
|
||||
struct KeyframeSmoothingModel {
|
||||
@@ -257,8 +243,6 @@ struct Model {
|
||||
WorldControlsModel world_controls;
|
||||
ProgressionModel progression;
|
||||
PlayerCardModel player_card;
|
||||
HallOfMeatModel hall_of_meat;
|
||||
GrindExitModel grind_exit;
|
||||
KeyframeSmoothingModel keyframe_smoothing;
|
||||
ObjectPersistenceModel object_persistence;
|
||||
ParkEditorModel editor;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include "Engine/Core/Profiling/profiler.h"
|
||||
#include "overlay_internal.h"
|
||||
#include "Extension/Trainer/trainer_page.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat_overlay.h"
|
||||
#include "Extension/UI/Overlay/game_images.h"
|
||||
#include "plugin_overlay.h"
|
||||
#include "park_previews.h"
|
||||
#include "chat_emotes.h"
|
||||
@@ -383,10 +383,10 @@ bool setup_graphics() {
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(waited_until - std::chrono::steady_clock::now()));
|
||||
};
|
||||
const auto emote_count = reserve_chat_emotes(*ImGui::GetIO().Fonts, left());
|
||||
reserve_hall_of_meat_images(*ImGui::GetIO().Fonts, left());
|
||||
reserve_game_images(*ImGui::GetIO().Fonts, left());
|
||||
const auto preview_count = load_park_previews(*ImGui::GetIO().Fonts, left());
|
||||
fill_chat_emotes(*ImGui::GetIO().Fonts);
|
||||
fill_hall_of_meat_images(*ImGui::GetIO().Fonts);
|
||||
fill_game_images(*ImGui::GetIO().Fonts);
|
||||
if (emote_count)
|
||||
dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::graphics,
|
||||
"Chat emotes: %zu ready.", emote_count);
|
||||
@@ -759,7 +759,6 @@ void render(IDXGISwapChain* presented, UINT flags) {
|
||||
const bool game_text_frame = game_text_pending();
|
||||
const bool skate_hud_frame = skate_hud_pending();
|
||||
const bool nametag_frame = nametags_pending();
|
||||
const bool meat_frame = hall_of_meat_pending();
|
||||
const bool plugins_frame = plugins_pending();
|
||||
const bool item_browser_frame = item_browser_pending();
|
||||
const bool perf_frame = perf_hud_pending() || trainer_hud_pending();
|
||||
@@ -782,7 +781,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
|
||||
}
|
||||
// Hidden, the overlay still draws while a notice or chat line is on screen.
|
||||
if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame &&
|
||||
!nametag_frame && !meat_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame) return;
|
||||
!nametag_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame) return;
|
||||
} else if (!s.ui_was_interactive) {
|
||||
s.ui_was_interactive = true;
|
||||
s.last_model = {}; // Reopening immediately reads fresh state.
|
||||
@@ -834,7 +833,6 @@ void render(IDXGISwapChain* presented, UINT flags) {
|
||||
draw_perf_window();
|
||||
}
|
||||
draw_hub_page();
|
||||
draw_hall_of_meat();
|
||||
draw_plugins();
|
||||
draw_nametags();
|
||||
draw_game_text();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "plugin_overlay.h"
|
||||
#include "Extension/Plugins/plugin_camera.h"
|
||||
#include "Extension/Plugins/plugin_ui.h"
|
||||
#include "Extension/UI/Overlay/game_images.h"
|
||||
#include "Extension/UI/Overlay/overlay_internal.h"
|
||||
#include "Extension/UI/Overlay/xray_overlay.h"
|
||||
#include <algorithm>
|
||||
#include <imgui_internal.h>
|
||||
#include <memory>
|
||||
@@ -157,7 +159,9 @@ void draw_plugins() {
|
||||
if (display.x <= 0 || display.y <= 0) return;
|
||||
const float k = display.y / 1080.0f;
|
||||
auto& s = detail::state();
|
||||
auto* font = s.menu.bold ? s.menu.bold : ImGui::GetFont();
|
||||
auto* fallback = ImGui::GetFont();
|
||||
const std::array<ImFont*, 4> fonts{s.menu.bold ? s.menu.bold : fallback, s.menu.body ? s.menu.body : fallback,
|
||||
s.menu.heading ? s.menu.heading : fallback, s.menu.title ? s.menu.title : fallback};
|
||||
auto* draw = ImGui::GetBackgroundDrawList();
|
||||
const auto camera = plugin_camera::projector(display.x, display.y);
|
||||
using Kind = plugin_ui::Shape::Kind;
|
||||
@@ -165,6 +169,7 @@ void draw_plugins() {
|
||||
for (const auto& shape : layer.shapes) {
|
||||
const auto& style = shape.style;
|
||||
const auto& p = shape.points;
|
||||
auto* font = fonts[static_cast<std::size_t>(std::clamp(style.font, 0, 3))];
|
||||
switch (shape.kind) {
|
||||
case Kind::text: text(draw, font, k, at(p[0]), shape); break;
|
||||
case Kind::rect:
|
||||
@@ -212,6 +217,21 @@ void draw_plugins() {
|
||||
ImVec2(style.uv[0], style.uv[1]), ImVec2(style.uv[2], style.uv[3]), style.colour);
|
||||
break;
|
||||
}
|
||||
case Kind::game_image:
|
||||
(void)draw_game_image(draw, shape.text, at(p[0]), at(p[1]), style.colour, static_cast<GameImageFit>(std::clamp(shape.fit, 0, 2)),
|
||||
shape.edge * k);
|
||||
break;
|
||||
case Kind::gradient:
|
||||
if (shape.colours.size() == 4)
|
||||
draw->AddRectFilledMultiColor(at(p[0]), at(p[1]), shape.colours[0], shape.colours[1], shape.colours[2], shape.colours[3]);
|
||||
break;
|
||||
case Kind::skeleton: {
|
||||
if (shape.colours.size() != skater_body::count) break;
|
||||
std::array<ImU32, skater_body::count> paint{};
|
||||
std::copy(shape.colours.begin(), shape.colours.end(), paint.begin());
|
||||
draw_xray(draw, paint, static_cast<float>(style.colour >> IM_COL32_A_SHIFT & 0xff) / 255.0f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,8 @@ constexpr std::array<const char*, page_count> page_subtitles{
|
||||
"Everything, a button away.", "Pick your spot. Set the vibe.", "Make the park yours.", "Ride it your way.",
|
||||
"Tune it. Drill it.", "What ReSkate adds to skate.", "Bring your crew.", "Pick up where you want.",
|
||||
"Bring your own.", "Your controls, your screen.", "Under the hood."};
|
||||
constexpr std::array<const char*, addon_count> addon_names{"ADDONS", "HALL OF MEAT", "GRIND EXITS", "MUSIC", "PLUGINS"};
|
||||
constexpr std::array<const char*, addon_count> addon_subtitles{
|
||||
"What ReSkate adds to skate.", "Every bone counts.", "Pick the side you pop out.", "Your sound, in the game.", "What your plugins add."};
|
||||
constexpr std::array<const char*, addon_count> addon_names{"ADDONS", "MUSIC", "PLUGINS"};
|
||||
constexpr std::array<const char*, addon_count> addon_subtitles{"What ReSkate adds to skate.", "Your sound, in the game.", "What your plugins add."};
|
||||
// The side rail's groups: the heading above the page that starts each.
|
||||
const char* group_of(int page) {
|
||||
switch (page) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#include <imgui_internal.h>
|
||||
#include <string>
|
||||
|
||||
// The ADDONS page (what ReSkate adds to skate.: Hall of Meat, grind exits, music, the plugins' panels)
|
||||
// The ADDONS page (what ReSkate adds to skate.: music, and the plugins' panels)
|
||||
// and the full screen menu's HOME page.
|
||||
namespace dingosdk::overlay::menu {
|
||||
using namespace theme;
|
||||
@@ -52,22 +52,12 @@ void addon_tile(SkateMenu& menu, const char* id, const char* title, const char*
|
||||
end_card();
|
||||
}
|
||||
|
||||
void all_addons(SkateMenu& menu, const Model& model) {
|
||||
const auto on_off = [](bool available, bool on) -> const char* { return !available ? "NOT AVAILABLE" : on ? "ON" : "OFF"; };
|
||||
const auto colour = [](bool available, bool on) { return !available ? skate_theme::tile_light : on ? skate_theme::good : skate_theme::tile_light; };
|
||||
void all_addons(SkateMenu& menu) {
|
||||
// Two a row full screen.
|
||||
int placed = 1;
|
||||
const auto next = [&] {
|
||||
if (console_style() && placed++ % 2) ImGui::SameLine(0, px(12));
|
||||
};
|
||||
const auto& meat = model.hall_of_meat;
|
||||
addon_tile(menu, "addon-hall-of-meat", "HALL OF MEAT", "Your bails show the bones they hurt and score a Meat card.",
|
||||
on_off(meat.available, meat.enabled), colour(meat.available, meat.enabled), addon_hall_of_meat);
|
||||
const auto& grind = model.grind_exit;
|
||||
next();
|
||||
addon_tile(menu, "addon-grind-exits", "GRIND EXITS", "Press LT or RT while grinding to pick the side you pop out to.",
|
||||
on_off(grind.available, grind.enabled), colour(grind.available, grind.enabled), addon_grind_exits);
|
||||
next();
|
||||
addon_tile(menu, "addon-music", "MUSIC", "Spotify, YouTube Music, your own files and online radio through the game's music player.",
|
||||
nullptr, 0, addon_music);
|
||||
next();
|
||||
@@ -82,7 +72,7 @@ void addons_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbac
|
||||
// A window lists the addons in its side rail; full screen has no rail, so they are tabs here
|
||||
// (LT / RT on a controller).
|
||||
if (menu.fullscreen_drawn) {
|
||||
static constexpr std::array<const char*, addon_count> names{"ALL ADDONS", "HALL OF MEAT", "GRIND EXITS", "MUSIC", "PLUGINS"};
|
||||
static constexpr std::array<const char*, addon_count> names{"ALL ADDONS", "MUSIC", "PLUGINS"};
|
||||
std::vector<const char*> labels(names.begin(), names.end());
|
||||
category_tabs(menu, menu.addon, labels, "addon-tabs");
|
||||
menu.addon = std::clamp(menu.addon, 0, static_cast<int>(addon_count) - 1);
|
||||
@@ -92,10 +82,8 @@ void addons_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbac
|
||||
case addon_music: music_page(menu, model, callbacks); break;
|
||||
default:
|
||||
ImGui::BeginChild("addon", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened);
|
||||
if (menu.addon == addon_hall_of_meat) hall_of_meat_addon(menu, model, callbacks);
|
||||
else if (menu.addon == addon_grind_exits) grind_exits_addon(menu, model, callbacks);
|
||||
else if (menu.addon == addon_plugins) plugins_addon(menu);
|
||||
else all_addons(menu, model);
|
||||
if (menu.addon == addon_plugins) plugins_addon(menu);
|
||||
else all_addons(menu);
|
||||
ImGui::EndChild();
|
||||
break;
|
||||
}
|
||||
@@ -223,11 +211,6 @@ void home_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks
|
||||
if (home_tile(menu, "##freecam", "FREECAM", on_off(debug.free_camera), size, debug.camera_available && callbacks.queue_debug,
|
||||
debug.free_camera, false, "Leave your skater and fly the camera.") == 1)
|
||||
request(DebugAction::set_free_camera, !debug.free_camera);
|
||||
place(width, 1);
|
||||
const auto& meat = model.hall_of_meat;
|
||||
if (home_tile(menu, "##hall-of-meat", "HALL OF MEAT", on_off(meat.enabled), size, meat.available && callbacks.queue_console_command,
|
||||
meat.enabled, false, "Your bails show the bones they hurt and score a Meat card.") == 1)
|
||||
quietly(callbacks, meat.enabled ? "hallofmeat 0" : "hallofmeat 1");
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, px(18)));
|
||||
|
||||
@@ -249,7 +232,7 @@ void home_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks
|
||||
if (home_tile(menu, "##go-map", "MAP", "Travel, parks, time and weather", size, true, false, false, "Pick a map and load it.", false) == 1)
|
||||
open_page(menu, page_map);
|
||||
place(width, 3);
|
||||
if (home_tile(menu, "##go-addons", "ADDONS", "Hall of Meat, grind exits, music, plugins", size, true, false, false,
|
||||
if (home_tile(menu, "##go-addons", "ADDONS", "Music, and your plugins' panels", size, true, false, false,
|
||||
"What ReSkate adds to skate.", false) == 1)
|
||||
open_page(menu, page_addons, addon_all);
|
||||
place(width, 4);
|
||||
|
||||
@@ -120,8 +120,6 @@ bool trainer_page_wanted();
|
||||
// Skater > CHEATS: noclip, No Bail and the boosts (skate_menu_skater.cpp).
|
||||
void cheats_tab(SkateMenu&, const Model&, const CallbacksV3&);
|
||||
// The addons' own pages (skate_menu_skater.cpp), and the ADDONS page itself (skate_menu_addons.cpp).
|
||||
void hall_of_meat_addon(SkateMenu&, const Model&, const CallbacksV3&);
|
||||
void grind_exits_addon(SkateMenu&, const Model&, const CallbacksV3&);
|
||||
void addons_page(SkateMenu&, const Model&, const CallbacksV3&);
|
||||
// The time of day the menu shows: the one last asked for while the game is still changing to it
|
||||
// (skate_menu_world.cpp). Setting it from a menu control goes through set_time_of_day, `quiet`
|
||||
@@ -143,6 +141,6 @@ enum Page {
|
||||
page_home, page_map, page_build, page_skater, page_sandbox, page_addons, page_online, page_progress, page_mods,
|
||||
page_settings, page_developer, page_count
|
||||
};
|
||||
enum Addon { addon_all, addon_hall_of_meat, addon_grind_exits, addon_music, addon_plugins, addon_count, addon_first = addon_hall_of_meat };
|
||||
enum Addon { addon_all, addon_music, addon_plugins, addon_count, addon_first = addon_music };
|
||||
void open_page(SkateMenu&, int page, int addon = -1);
|
||||
}
|
||||
@@ -206,68 +206,7 @@ void cheats_tab(SkateMenu& menu, const Model& model, const CallbacksV3& callback
|
||||
end_card();
|
||||
}
|
||||
|
||||
// Addons > HALL OF MEAT: bails show the bones they hurt and score a Meat card.
|
||||
void hall_of_meat_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
||||
const auto& debug = model.debug;
|
||||
begin_card(menu, "hall-of-meat", "HALL OF MEAT");
|
||||
bool hall_of_meat = model.hall_of_meat.enabled;
|
||||
// No Bail (and noclip, which includes it) stops the bails Hall of Meat scores.
|
||||
const bool bails_off = debug.no_bail || debug.no_bail_active;
|
||||
const char* meat_help = hall_of_meat && bails_off
|
||||
? "On, but No Bail is stopping your bails, so nothing shows. Turn No Bail (and noclip) off in Skater > Cheats."
|
||||
: bails_off
|
||||
? "Your bails show the bones they hurt and score a Meat card. Needs No Bail (and noclip) off."
|
||||
: "Your bails show the bones they hurt and score a Meat card. A break slows the game in single player.";
|
||||
if (toggle_row(menu, "Hall of Meat", meat_help, hall_of_meat, model.hall_of_meat.available && callbacks.queue_console_command))
|
||||
send_console(menu, callbacks, hall_of_meat ? "hallofmeat 1" : "hallofmeat 0");
|
||||
end_card();
|
||||
}
|
||||
|
||||
// Addons > GRIND EXITS: grind exit control (Extension/Skater/grind_exit_control.h).
|
||||
void grind_exits_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
||||
const auto& grind = model.grind_exit;
|
||||
const bool grind_console = grind.available && callbacks.queue_console_command;
|
||||
begin_card(menu, "grind-exits", "GRIND EXITS", "Choose the side you pop out of a grind using LT/RT");
|
||||
bool grind_on = grind.enabled;
|
||||
if (toggle_row(menu, "Grind exit triggers", "Press LT or RT while grinding to pick the side you pop out to.",
|
||||
grind_on, grind_console))
|
||||
send_console(menu, callbacks, grind_on ? "grindexit exits on" : "grindexit exits off");
|
||||
field(menu, "Input Mode");
|
||||
int grind_sides = grind.toe_heel ? 0 : 1;
|
||||
if (choice(menu, "grind-sides", grind_sides, {"Toe / Heel", "Left / Right"}, grind_console, 0.0f,
|
||||
{"Direction is based on your skater's toe/heel side", "Direction is based on left or right of your direction of travel"}))
|
||||
send_console(menu, callbacks, grind_sides == 0 ? "grindexit sides toeheel" : "grindexit sides travel");
|
||||
if (grind.toe_heel) {
|
||||
field(menu, "Your stance");
|
||||
int grind_stance = grind.stance == 2 ? 1 : grind.stance == 1 ? 0 : -1;
|
||||
if (choice(menu, "grind-stance", grind_stance, {"Regular", "Goofy"}, grind_console))
|
||||
send_console(menu, callbacks, grind_stance == 1 ? "grindexit stance goofy" : "grindexit stance regular");
|
||||
if (grind.stance == 0) warn("Pick your stance. Until then LT / RT are left / right of travel.");
|
||||
}
|
||||
{
|
||||
// Sent once the slider is let go (not every frame of a drag); the dragged value shows
|
||||
// until the setting comes back, in steps of 5.
|
||||
static int edited = -1;
|
||||
static double edited_at = 0;
|
||||
int strength = edited >= 0 ? edited : grind.strength;
|
||||
field(menu, "Pop-out strength", "How far to the side a trigger exit pops. 100% is the full exit; lower it for rails next to a wall.");
|
||||
ImGui::BeginDisabled(!grind_console);
|
||||
if (slider_int("##grind-strength", &strength, 10, 100, "%d%%", ImGuiSliderFlags_AlwaysClamp)) {
|
||||
edited = std::clamp((strength + 2) / 5 * 5, 10, 100);
|
||||
edited_at = ImGui::GetTime();
|
||||
}
|
||||
if (ImGui::IsItemDeactivatedAfterEdit() && edited >= 0) {
|
||||
send_console(menu, callbacks, "grindexit strength " + std::to_string(edited));
|
||||
edited_at = ImGui::GetTime();
|
||||
}
|
||||
if (edited >= 0 && !ImGui::IsItemActive() && (grind.strength == edited || ImGui::GetTime() - edited_at > 2.0)) edited = -1;
|
||||
ImGui::EndDisabled();
|
||||
}
|
||||
end_card();
|
||||
}
|
||||
|
||||
void skater_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
||||
// (Grind exits are an addon: Addons > Grind Exits.)
|
||||
category_tabs(menu, menu.skater_tab, {"FREECAM", "FIRST PERSON", "CHEATS"}, "skater-tabs");
|
||||
ImGui::PushID(menu.skater_tab);
|
||||
ImGui::BeginChild("skater-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#include "xray_overlay.h"
|
||||
#include "Extension/Skater/xray.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::overlay {
|
||||
namespace {
|
||||
// `colour` darkened to `light` (0 black, 1 itself), at `alpha`.
|
||||
ImU32 lit(ImU32 colour, float light, float alpha) {
|
||||
const auto channel = [&](int shift) {
|
||||
return static_cast<ImU32>(static_cast<float>((colour >> shift) & 0xff) * std::clamp(light, 0.0f, 1.0f)) << shift;
|
||||
};
|
||||
const auto a = static_cast<ImU32>(255.0f * std::clamp(alpha, 0.0f, 1.0f));
|
||||
return channel(IM_COL32_R_SHIFT) | channel(IM_COL32_G_SHIFT) | channel(IM_COL32_B_SHIFT) | a << IM_COL32_A_SHIFT;
|
||||
}
|
||||
}
|
||||
|
||||
// The bodies a plugin painted, as an x-ray over the world projected like the nametags; the rest are
|
||||
// not drawn. Every triangle of a painted body is drawn back to front, each vertex lit by how it faces
|
||||
// the camera: an outline near solid and a middle fainter, so the bones read through each other and
|
||||
// against the world. A triangle reaching into an unpainted body fades out.
|
||||
void draw_xray(ImDrawList* draw, const std::array<ImU32, skater_body::count>& paint, float alpha) {
|
||||
static xray::Pose skeleton; // render thread only: reused frame to frame
|
||||
if (alpha <= 0 || !xray::latest(skeleton)) return;
|
||||
using Vec3 = std::array<float, 3>;
|
||||
const auto dot = [](const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; };
|
||||
const auto& positions = skeleton.positions;
|
||||
const auto count = positions.size();
|
||||
const auto* mesh = skeleton.mesh.get();
|
||||
if (!count || count > 0xffff || !mesh || mesh->parts.size() != count || skeleton.normals.size() != count ||
|
||||
!(skeleton.vertical_fov > 1 && skeleton.vertical_fov < 175))
|
||||
return;
|
||||
constexpr float solid = 0.6f;
|
||||
const auto display = ImGui::GetIO().DisplaySize;
|
||||
const auto& m = skeleton.camera;
|
||||
const Vec3 right{m[0], m[1], m[2]}, up{m[4], m[5], m[6]}, back{m[8], m[9], m[10]}, origin{m[12], m[13], m[14]};
|
||||
const float focal = display.y / (2.0f * std::tan(skeleton.vertical_fov * 3.14159265f / 360.0f));
|
||||
const ImVec2 centre(display.x * 0.5f, display.y * 0.5f);
|
||||
// Render thread only: reused frame to frame.
|
||||
static std::vector<ImVec2> at;
|
||||
static std::vector<float> depth;
|
||||
static std::vector<ImU32> colour; // 0 for an unpainted body's vertex
|
||||
static std::vector<std::pair<float, std::uint32_t>> order; // a triangle's depth, its first index
|
||||
at.resize(count);
|
||||
depth.resize(count);
|
||||
colour.resize(count);
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
const auto& p = positions[i];
|
||||
const Vec3 delta{p[0] - origin[0], p[1] - origin[1], p[2] - origin[2]};
|
||||
depth[i] = -dot(delta, back);
|
||||
const float z = std::max(depth[i], 0.1f);
|
||||
at[i] = ImVec2(centre.x + dot(delta, right) * focal / z, centre.y - dot(delta, up) * focal / z);
|
||||
const float distance = std::sqrt(dot(delta, delta));
|
||||
const float facing = distance > 1e-4f ? std::abs(dot(skeleton.normals[i], delta)) / distance : 1.0f;
|
||||
const auto painted = paint[std::min<std::size_t>(mesh->parts[i], skater_body::count - 1)];
|
||||
const float faint = 0.25f + 0.6f * (1.0f - facing);
|
||||
colour[i] = painted ? lit(painted, 0.55f + 0.45f * facing, alpha * (solid + (1.0f - solid) * faint)) : 0;
|
||||
}
|
||||
const auto& triangles = mesh->triangles;
|
||||
order.clear();
|
||||
for (std::uint32_t t = 0; t + 2 < triangles.size(); t += 3) {
|
||||
const auto a = triangles[t], b = triangles[t + 1], c = triangles[t + 2];
|
||||
if (a >= count || b >= count || c >= count || depth[a] <= 0.1f || depth[b] <= 0.1f || depth[c] <= 0.1f ||
|
||||
(!colour[a] && !colour[b] && !colour[c]))
|
||||
continue;
|
||||
order.emplace_back(depth[a] + depth[b] + depth[c], t);
|
||||
}
|
||||
if (order.empty()) return;
|
||||
std::sort(order.begin(), order.end(), [](const auto& x, const auto& y) { return x.first > y.first; }); // far first
|
||||
const auto uv = ImGui::GetFontTexUvWhitePixel();
|
||||
draw->PrimReserve(static_cast<int>(order.size() * 3), static_cast<int>(count));
|
||||
const auto first = draw->_VtxCurrentIdx;
|
||||
for (std::size_t i = 0; i < count; ++i) draw->PrimWriteVtx(at[i], uv, colour[i]);
|
||||
for (const auto& [unused, t] : order)
|
||||
for (std::uint32_t corner = 0; corner < 3; ++corner) draw->PrimWriteIdx(static_cast<ImDrawIdx>(first + triangles[t + corner]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
// The local skater's x-ray (Extension/Skater/xray.h) as a plugin's draw handler paints it
|
||||
// (d:skeleton): each body in its colour, the rest left out.
|
||||
#include "Engine/Game/Skater/skater_body.h"
|
||||
#include <array>
|
||||
#include <imgui.h>
|
||||
|
||||
namespace dingosdk::overlay {
|
||||
// Render thread. `paint` by body (skater_body.h's order; 0 leaves a body out), `alpha` the whole.
|
||||
void draw_xray(ImDrawList* draw, const std::array<ImU32, skater_body::count>& paint, float alpha);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "hall_of_meat_hud.h"
|
||||
#include "hud_corner.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
@@ -13,10 +13,11 @@
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace dingosdk::hud_corner {
|
||||
namespace {
|
||||
namespace corner = addr::hud_corner;
|
||||
namespace menu_data = multiplayer::menu_data;
|
||||
@@ -94,7 +95,7 @@ bool start_model_write_hook(Address base) noexcept {
|
||||
}
|
||||
if (status != HookOk) {
|
||||
logging::log(logging::Level::warning, logging::Channel::ui,
|
||||
"Hall of Meat leaves skate.'s score HUD as it is: hooking the UI model's write failed ({}).", hook_status_string(status));
|
||||
"Plugins leave skate.'s score HUD as it is: hooking the UI model's write failed ({}).", hook_status_string(status));
|
||||
return false;
|
||||
}
|
||||
h.ready = true;
|
||||
@@ -256,7 +257,7 @@ void hold_score(Corner& c, const menu_data::Context& context, bool held) {
|
||||
const bool taken = field.taken.load(std::memory_order_acquire);
|
||||
if (taken && now != sw.down && !std::exchange(c.slip_logged, true))
|
||||
logging::write(logging::Level::warning, logging::Channel::ui,
|
||||
"Hall of Meat: the game wrote the score HUD past the model write hook.");
|
||||
"HUD corner: the game wrote the score HUD past the model write hook.");
|
||||
if (!taken) field.meant.store(now, std::memory_order_release); // what the game means until it writes otherwise
|
||||
field.taken.store(true, std::memory_order_release);
|
||||
if (now != sw.down) context.publish(context.field(c.score_model, sw.field), &sw.down);
|
||||
@@ -279,7 +280,7 @@ bool hook_hud(bool on) noexcept {
|
||||
if (h.hooked.load(std::memory_order_acquire) == on) return true;
|
||||
const auto status = on ? hook_enable(h.target) : hook_disable(h.target);
|
||||
if (status != HookOk) {
|
||||
logging::log(logging::Level::warning, logging::Channel::ui, "Hall of Meat could not {} its UI model write hook ({}).",
|
||||
logging::log(logging::Level::warning, logging::Channel::ui, "The HUD corner could not {} its UI model write hook ({}).",
|
||||
on ? "enable" : "disable", hook_status_string(status));
|
||||
return false;
|
||||
}
|
||||
@@ -303,7 +304,7 @@ bool hide_hud(bool hidden) noexcept {
|
||||
c.stack = {};
|
||||
c.score_model = {};
|
||||
if (!std::exchange(c.warned, true))
|
||||
logging::log(logging::Level::warning, logging::Channel::ui, "Hall of Meat cannot hide skate.'s HUD corner: {}.", error.what());
|
||||
logging::log(logging::Level::warning, logging::Channel::ui, "Plugins cannot hide skate.'s HUD corner: {}.", error.what());
|
||||
} catch (...) {
|
||||
c.stack = {};
|
||||
c.score_model = {};
|
||||
@@ -311,3 +312,34 @@ bool hide_hud(bool hidden) noexcept {
|
||||
return !c.cover.handle && !score_held();
|
||||
}
|
||||
}
|
||||
|
||||
namespace dingosdk::hud_corner {
|
||||
namespace {
|
||||
struct Asks {
|
||||
std::map<std::string, bool, std::less<>> owners; // hidden, by plugin
|
||||
bool hooked{};
|
||||
};
|
||||
Asks& asks() {
|
||||
static auto* value = new Asks;
|
||||
return *value;
|
||||
}
|
||||
}
|
||||
void ask(std::string_view owner, bool hidden) { asks().owners.insert_or_assign(std::string(owner), hidden); }
|
||||
void release(std::string_view owner) {
|
||||
auto& a = asks();
|
||||
if (const auto found = a.owners.find(owner); found != a.owners.end()) a.owners.erase(found);
|
||||
}
|
||||
void tick() noexcept {
|
||||
auto& a = asks();
|
||||
try {
|
||||
bool hidden{};
|
||||
for (const auto& [owner, on] : a.owners) hidden = hidden || on;
|
||||
if (!a.owners.empty() && !a.hooked) a.hooked = hook_hud(true);
|
||||
if (!a.hooked) return;
|
||||
const bool back = hide_hud(hidden);
|
||||
// No plugin left: the hook goes once the corner is the game's again.
|
||||
if (a.owners.empty() && back && hook_hud(false)) a.hooked = false;
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
// skate.'s HUD in the bottom left corner (the d-pad menu, and the score HUD there while a line goes
|
||||
// on), hidden while Hall of Meat's card shows in its place (Engine/Game/Build/20260929/hud_corner.h):
|
||||
// on), hidden while a plugin shows something of its own there (reskate.hud.corner) (Engine/Game/Build/20260929/hud_corner.h):
|
||||
// - the d-pad hides itself while it is not alone in its stack: an empty item of ours lies in the
|
||||
// stack beside it (the game's items are never touched);
|
||||
// - the score HUD shows by its view model alone: a hook on the UI model's write of one value
|
||||
@@ -11,14 +12,21 @@
|
||||
// ever sees the HUD down, until it is given back.
|
||||
// The game's models are read and written on the client thread only, under the model lock its own
|
||||
// writers take too.
|
||||
namespace dingosdk::hall_of_meat {
|
||||
namespace dingosdk::hud_corner {
|
||||
// Startup, with the game's image base: prepares the model write hook, which patches nothing; without
|
||||
// it the score HUD stays as it is.
|
||||
void start_hud(std::uintptr_t base) noexcept;
|
||||
// Client thread: the model write hook on while Hall of Meat is, off otherwise, so the game's UI writes
|
||||
// never pass through it while it is switched off. False when it could not be changed (logged).
|
||||
// Client thread: the model write hook on while a plugin may hide the corner, off otherwise, so the
|
||||
// game's UI writes never pass through it for nothing. False when it could not be changed (logged).
|
||||
bool hook_hud(bool on) noexcept;
|
||||
// Client thread, every tick: hides the corner while `hidden`, and gives the game's own back after.
|
||||
// True once nothing of ours is left in it.
|
||||
bool hide_hud(bool hidden) noexcept;
|
||||
|
||||
// Plugins: `hidden` hides the corner while any plugin asks; false keeps the plugin ready to (the
|
||||
// hook on, so hiding it later is immediate); release drops the plugin. Client thread.
|
||||
void ask(std::string_view owner, bool hidden);
|
||||
void release(std::string_view owner);
|
||||
// Client thread, every tick.
|
||||
void tick() noexcept;
|
||||
}
|
||||
@@ -22,10 +22,8 @@ the launcher, the runtime that loads into the game, and the dedicated server.
|
||||
- The **Park Editor**: place, move and save objects with freecam, snapping and undo. Save a selection as a
|
||||
set and place it again anywhere, on any map.
|
||||
- Skater options: first person, movement, boosts, noclip.
|
||||
- **Hall of Meat** (off by default): bail and see the bones you hurt, with a skate. 3 style Meat card.
|
||||
- **Grind exit triggers** (off by default): press LT or RT while grinding to pick the side you pop
|
||||
out to (toe / heel, or left / right), whatever trick and spin you do
|
||||
([how it works](Extension/Skater/GRIND_EXITS.md)).
|
||||
- Hall of Meat and grind exit triggers are plugin mods now
|
||||
([contrib/plugins/hall-of-meat](contrib/plugins/hall-of-meat), [contrib/plugins/grind-exits](contrib/plugins/grind-exits)).
|
||||
- Progression, controls, graphics and multiplayer settings.
|
||||
- **Mods.**
|
||||
- Drop a mod in `Mods/` and it is merged into the game at launch. Mods can add custom maps, loading
|
||||
|
||||
@@ -15,11 +15,9 @@
|
||||
#include "Extension/Settings/job_spin.h"
|
||||
#include "Extension/Settings/replay_drafts.h"
|
||||
#include "Engine/Game/World/client_state.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat.h"
|
||||
#include "Extension/Plugins/plugin_runtime.h"
|
||||
#include "Extension/Plugins/plugin_game_runtime.h"
|
||||
#include "Extension/ReplayKeyframes/replay_keyframes.h"
|
||||
#include "Extension/Skater/grind_exit_control.h"
|
||||
#include "Extension/Skater/camera_observer.h"
|
||||
#include "Extension/UI/NativeMenu/native_menu.h"
|
||||
#include "Extension/Multiplayer/Hud/native_party.h"
|
||||
@@ -1092,9 +1090,7 @@ void update_model(std::uintptr_t client, TickState& frame) {
|
||||
DINGO_PROFILE_ZONE("tick/item browser");
|
||||
dingosdk::update_local_item_browser();
|
||||
}
|
||||
dingosdk::hall_of_meat::on_client_tick();
|
||||
dingosdk::breakable_deck::tick();
|
||||
dingosdk::grind_exit_control::on_client_tick(client);
|
||||
{
|
||||
DINGO_PROFILE_ZONE("tick/plugins");
|
||||
dingosdk::plugins::on_client_tick();
|
||||
@@ -1106,7 +1102,6 @@ void update_model(std::uintptr_t client, TickState& frame) {
|
||||
r.multiplayer_map = description.level.empty() ? std::string{} : description.level + "|" + description.lm_level;
|
||||
const auto& current_level = description.lm_level.empty() ? description.level : description.lm_level;
|
||||
dingosdk::live_mods::set_current_level(current_level);
|
||||
dingosdk::hall_of_meat::set_level(current_level);
|
||||
dingosdk::plugins::set_level(current_level);
|
||||
dingosdk::replay_drafts::on_client_tick(r.base, state);
|
||||
// A live mod apply hands over the destinations its mods declare. A map
|
||||
@@ -1237,10 +1232,6 @@ void update_model(std::uintptr_t client, TickState& frame) {
|
||||
const auto missions_model = dingosdk::local_profile_missions();
|
||||
model.progression = dingosdk::local_profile_progression();
|
||||
model.player_card = dingosdk::local_profile_player_card();
|
||||
model.hall_of_meat = {dingosdk::hall_of_meat::available(), dingosdk::hall_of_meat::enabled()};
|
||||
model.grind_exit = {dingosdk::grind_exit_control::available(), dingosdk::grind_exit_control::enabled(),
|
||||
dingosdk::grind_exit_control::toe_heel(), dingosdk::grind_exit_control::stance(),
|
||||
dingosdk::grind_exit_control::strength()};
|
||||
model.keyframe_smoothing = {dingosdk::replay_keyframes::available(), dingosdk::replay_keyframes::on(),
|
||||
dingosdk::replay_keyframes::smoothness()};
|
||||
model.bindings = dingosdk::local_profile_controller_bindings();
|
||||
|
||||
@@ -29,9 +29,12 @@
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Extension/Assets/map_download.h"
|
||||
#include "Extension/Boot/offline_boot.h"
|
||||
#include "Extension/HallOfMeat/hall_of_meat.h"
|
||||
#include "Extension/Skater/skater_physics.h"
|
||||
#include "Extension/Skater/xray.h"
|
||||
#include "Extension/UI/hud_corner.h"
|
||||
#include "Extension/UI/Overlay/game_images.h"
|
||||
#include "Extension/Plugins/plugin_runtime.h"
|
||||
#include "Extension/Skater/grind_exit_control.h"
|
||||
#include "Extension/Skater/grind_exit.h"
|
||||
#include "Extension/Skater/camera_observer.h"
|
||||
#include "Extension/Progression/entitlement_request_hook.h"
|
||||
#include "Extension/Skater/skater_observer.h"
|
||||
@@ -445,10 +448,16 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() {
|
||||
(void)dingosdk::start_no_bail(r.base);
|
||||
const bool plugins = dingosdk::plugins::start(r.base);
|
||||
record("{\"event\":\"plugins_initialized\",\"active\":" + std::string(plugins ? "true" : "false") + "}");
|
||||
const bool meat = dingosdk::hall_of_meat::start(r.base);
|
||||
record("{\"event\":\"hall_of_meat_initialized\",\"active\":" + std::string(meat ? "true" : "false") + "}");
|
||||
const bool grind_control = dingosdk::grind_exit_control::start(r.base);
|
||||
record("{\"event\":\"grind_exit_control_initialized\",\"active\":" + std::string(grind_control ? "true" : "false") + "}");
|
||||
// What plugins reach of the skater's physics, its x-ray and skate.'s HUD corner; the game's
|
||||
// pictures are read now, as the overlay builds its atlas once.
|
||||
const bool physics = dingosdk::skater_physics::start(r.base);
|
||||
const bool xray = dingosdk::xray::start(r.base);
|
||||
dingosdk::hud_corner::start_hud(r.base);
|
||||
dingosdk::overlay::prepare_game_images();
|
||||
record("{\"event\":\"skater_physics_initialized\",\"active\":" + std::string(physics ? "true" : "false") + ",\"xray\":" +
|
||||
std::string(xray ? "true" : "false") + "}");
|
||||
const bool grind_exits = dingosdk::grind_exit::start(r.base);
|
||||
record("{\"event\":\"grind_exit_initialized\",\"active\":" + std::string(grind_exits ? "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);
|
||||
|
||||
+7
-11
@@ -128,6 +128,13 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/Skater/skater_events.cpp
|
||||
Extension/Skater/outfit_overlay.cpp
|
||||
Extension/Skater/board_pose.cpp
|
||||
Extension/Skater/skater_physics.cpp
|
||||
Extension/Skater/xray.cpp
|
||||
Extension/Skater/xray_render.cpp
|
||||
Extension/Skater/xray_skeleton.cpp
|
||||
Extension/Skater/game_speed.cpp
|
||||
Extension/Skater/grind_exit.cpp
|
||||
Extension/UI/hud_corner.cpp
|
||||
Extension/Assets/morph_memory_pool.cpp
|
||||
Extension/Assets/native_render_resource_pool.cpp
|
||||
Extension/World/native_entity_pages.cpp
|
||||
@@ -157,20 +164,9 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/Skater/client_debug.cpp
|
||||
Extension/Skater/ai_skaters.cpp
|
||||
Extension/Skater/no_bail.cpp
|
||||
Extension/Skater/grind_exit_commands.cpp
|
||||
Extension/Skater/grind_exit_control.cpp
|
||||
Extension/Skater/physics_tuning.cpp
|
||||
Extension/Multiplayer/Remote/remote_collision.cpp
|
||||
Extension/Skater/physics_tuning_model.cpp
|
||||
Extension/HallOfMeat/hall_of_meat.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_card.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_commands.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_hud.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_model.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_render.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_skater.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_skeleton.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_slow_motion.cpp
|
||||
Extension/Skater/breakable_deck.cpp
|
||||
Extension/Skater/breakable_deck_commands.cpp
|
||||
Extension/Trainer/trainer.cpp
|
||||
|
||||
+2
-1
@@ -26,6 +26,8 @@ add_library(dingosdk_overlay STATIC
|
||||
Extension/UI/Overlay/skate_menu_addons.cpp
|
||||
Extension/UI/Overlay/plugins_page.cpp
|
||||
Extension/UI/Overlay/plugin_overlay.cpp
|
||||
Extension/UI/Overlay/game_images.cpp
|
||||
Extension/UI/Overlay/xray_overlay.cpp
|
||||
Extension/UI/Overlay/plugin_images.cpp
|
||||
Extension/UI/Overlay/menu_map_pictures.cpp
|
||||
Extension/UI/Overlay/skate_menu_skater.cpp
|
||||
@@ -47,7 +49,6 @@ add_library(dingosdk_overlay STATIC
|
||||
Extension/Trainer/trainer_page.cpp
|
||||
Extension/Music/music_page.cpp
|
||||
Extension/Trainer/trainer_view.cpp
|
||||
Extension/HallOfMeat/hall_of_meat_overlay.cpp
|
||||
)
|
||||
target_link_libraries(dingosdk_overlay PUBLIC dingosdk_logging dingosdk_profiler dingosdk_imgui dingosdk_hooks dingosdk_console_core dxguid PRIVATE dingosdk_playstation_input shell32 dingosdk_initfs dingosdk_custom_scripts dingosdk_game_archives dingosdk_mods dingosdk_json windowscodecs ole32)
|
||||
|
||||
|
||||
@@ -16,9 +16,18 @@ Both also run without the game, in `dingosdk_plugin_tests` (`DINGOSDK_BUILD_PLUG
|
||||
|
||||
## Ports
|
||||
|
||||
- **road-rash** is Road Rash (`Extension/RoadRash`) as a mod: bails leave marks that build up,
|
||||
worn over the outfit with `reskate.outfit`. It needs the Road Rash items mod, and the built-in
|
||||
Road Rash switched off (`roadrash 0`), since ReSkate refuses outfit changes while that one is on.
|
||||
While the built-in one exists it owns the `roadrash` command, so this one answers to `road rash`
|
||||
(`road rash heal`, `road rash bails 20`, `road rash blood 0`, `road rash tattoos 1`,
|
||||
`road rash test` for its own checks). Its switches are on its panel in the PLUGINS tab too.
|
||||
Features that were built into ReSkate, as mods of their own. Each has its own checks (`<command>
|
||||
test`) that `dingosdk_plugin_tests` runs too, and its switches on its panel in the PLUGINS tab.
|
||||
|
||||
- **road-rash**: bails leave marks that build up, worn over the outfit with `reskate.outfit`. It
|
||||
needs the Road Rash items mod. `roadrash`, `roadrash heal`, `roadrash bails 20`.
|
||||
- **board-break**: a hard slam can snap the board, drawn with `reskate.board`. It ships inside the
|
||||
Board Break deck pack. `boardbreak`, `boardbreak snap mid`. Which deck every board has stays in
|
||||
ReSkate (`boarddeck`).
|
||||
- **fpv-drone**: the FPV drone, flown from the free camera with the keyboard, a pad or a radio.
|
||||
`fpv fly`, `fpv follow`.
|
||||
- **hall-of-meat**: skate. 3's Hall of Meat: bails show the bones they hurt on an x-ray of the
|
||||
skater (`reskate.skater.xray`, `d:skeleton`) and score a Meat card drawn with skate.'s own
|
||||
pictures (`d:game_image`). `hallofmeat 0|1`.
|
||||
- **grind-exits**: LT / RT in a grind pick the side of the pop (`reskate.skater.grind_exit`,
|
||||
`reskate.input.hide`). `grindexit status`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Grind Exits (plugin)
|
||||
|
||||
Pick the side you pop out of a grind to with **LT / RT**, independently of the trick and of the body's
|
||||
rotation. In skate.'s own controls both come from the left stick at the same moment, so a frontside
|
||||
180 always pops out toward one side and a backside 180 toward the other. With this, any trick and any
|
||||
rotation can go out either side: a frontside 180 out to your toe side, a backside 180 out to your heel
|
||||
side, a kickflip out to whichever side you pick.
|
||||
|
||||
## Using it
|
||||
|
||||
On once installed. Its settings are on the ReSkate menu's ADDONS page, PLUGINS tab, and on the console
|
||||
(`~`): `grindexit exits on|off|status`.
|
||||
|
||||
- **While grinding, press LT or RT** (or up to 1 s before the grind connects, for example as you ollie
|
||||
up to the rail), then do any trick out of the grind with the right stick as usual. Keep the trigger
|
||||
held until you land, then let go.
|
||||
- **Input mode** (`grindexit sides toeheel|travel`):
|
||||
- **Toe / Heel**: LT pops out to your toe side, RT to your heel side, riding normal, fakie or switch.
|
||||
Needs **your stance** (`grindexit stance goofy|regular`). On slides (boardslide, lipslide), where
|
||||
toes and heels point along the rail, LT / RT are left / right of travel.
|
||||
- **Left / Right**: LT pops out to the left of your direction of travel, RT to the right.
|
||||
- **No trigger, or both**: the game's own exit.
|
||||
- **Grabs**: a trigger used to pick the side is kept from the game until you let go of it, so holding
|
||||
it through the pop does not grab. Let go and press it again in the air to grab (`grindexit guard on|off`).
|
||||
- **Spins**: push the left stick for your spin as you always do. While the trigger is held in the grind,
|
||||
the stick's left / right reaches the game at the pop instead of before it, so the game's own lean
|
||||
exit cannot drag the board toward the spin and into the rail (`grindexit leanguard on|off`).
|
||||
- **Pop-out strength** (`grindexit strength 10-100`): how far to the side a trigger exit pops, as a
|
||||
share of the full exit (100%, the default, is the game's own sideways speed). Lower it for rails next
|
||||
to a wall.
|
||||
- **Multiplayer**: works in any session or server. It adds no speed or height; it only changes which
|
||||
input picks the exit side.
|
||||
|
||||
`grindexit test` runs the plugin's own checks of the trigger rules.
|
||||
|
||||
## How it works
|
||||
|
||||
- `plugin/triggers.lua`: each tick, which trigger picks the side and what the game must not see of
|
||||
the pad. No game access, so `plugin/tests.lua` checks it.
|
||||
- `plugin/main.lua`: the wiring to `reskate`: the pad (`reskate.input.gamepad`, which sees the
|
||||
triggers even while they are kept from the game), the hiding (`reskate.input.hide`), the side asked
|
||||
of the next pop (`reskate.skater.grind_exit`) and each pop as it went (the `grind_exit` event), the
|
||||
console command and the menu panel.
|
||||
|
||||
ReSkate does what has to happen in the game's own threads (`Extension/Skater/grind_exit.h`): at the
|
||||
pop the game calls its jump-trajectory setter with the launch velocity, which the trainer hooks; for a
|
||||
pop out of a grind only the sideways part of that velocity is replaced, across the rail: the game's
|
||||
own sideways speed when it had one, mirrored to the chosen side, else the game's usual 1.8 m/s. The
|
||||
speed along the rail, the pop's height, the trick, the flip and the rotation are the game's. The pad
|
||||
filter hides the triggers and the left stick's left / right in the game's input thread.
|
||||
|
||||
**Toe / heel.** The game keeps which foot leads in a flag (context + 0x13c4, bit 5), found by recording
|
||||
the skater's state while riding regular and switch: it changed exactly at each change of stance and
|
||||
never during grinds, pops or 180s. With the player's stance it gives the lead foot; the toes face the
|
||||
skater root's right axis when the right foot leads (measured), the other way when the left foot does.
|
||||
|
||||
**Measured** (when it was built into ReSkate, on build 25414733, with a goofy skater over four
|
||||
sessions): 48 pops out of grinds with the game's own controls to learn its exits, then 144 pops whose
|
||||
side the triggers set. Forward, fakie and switch 50-50s, ollies, kick- and heelflips, shove-its and
|
||||
frontside / backside 180s went out to the chosen side with the trick and the rotation unchanged.
|
||||
Frontside 180 out to the toe side clipped the rail until the lean guard was added; with it, all 4 tries
|
||||
landed. As a plugin the triggers are read once a client tick rather than at every pad poll, so the game
|
||||
may see a trigger for a frame before it is hidden.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Grind_Exits",
|
||||
"version_number": "0.1.0",
|
||||
"website_url": "https://github.com/Dingo-Shenanigans/ReSkate",
|
||||
"description": "Press LT or RT while grinding to pick the side you pop out to, whatever trick and spin you do.",
|
||||
"dependencies": []
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
-- Grind exit triggers: pick the side you pop out of a grind to with LT / RT, independently of the
|
||||
-- trick and of the body's rotation (skate.'s own controls tie both to the left stick).
|
||||
--
|
||||
-- triggers.lua decides, each tick, which trigger picks the side and what the game must not see of
|
||||
-- the pad (reskate.input.hide). At the pop ReSkate replaces only the sideways part of the game's
|
||||
-- launch with the side asked for (reskate.skater.grind_exit): the game's own sideways speed when it
|
||||
-- had one, mirrored to that side, else the game's usual 1.8 m/s, times the pop-out strength. Along
|
||||
-- the rail, the pop's height, the trick, the flip and the rotation stay the game's.
|
||||
local triggers = require("triggers")
|
||||
|
||||
local function setting(key, default, kind)
|
||||
local value = reskate.storage.get(key)
|
||||
if type(value) == kind then return value end
|
||||
return default
|
||||
end
|
||||
local settings = {
|
||||
on = setting("on", true, "boolean"),
|
||||
toe_heel = setting("toe_heel", true, "boolean"), -- else left/right of travel
|
||||
stance = setting("stance", "", "string"), -- "", "regular" or "goofy"
|
||||
strength = setting("strength", 100, "number"), -- percent of the full sideways speed, 10 to 100
|
||||
guard = setting("guard", true, "boolean"),
|
||||
lean_guard = setting("lean_guard", true, "boolean"),
|
||||
prewindow = setting("prewindow", 1, "number"), -- seconds a trigger may be pressed before the grind connects
|
||||
}
|
||||
local state = triggers.new()
|
||||
local grinding = false
|
||||
local wished = nil -- the side last asked of ReSkate ("toe", "heel", "left", "right"), or nil
|
||||
local hidden_key = ""
|
||||
local pops, applied = 0, 0
|
||||
local last_pop = "none yet"
|
||||
local why_not = nil -- ReSkate's refusal, when it refused
|
||||
|
||||
local function save(key, value)
|
||||
settings[key] = value
|
||||
reskate.storage.set(key, value)
|
||||
end
|
||||
local function wish(side)
|
||||
if side == wished then return end
|
||||
wished = side
|
||||
local ok, why
|
||||
if side then
|
||||
local stance = settings.stance ~= "" and settings.stance or nil
|
||||
ok, why = reskate.skater.grind_exit({side = side, stance = stance, strength = settings.strength / 100})
|
||||
else
|
||||
ok, why = reskate.skater.grind_exit(nil)
|
||||
end
|
||||
why_not = not ok and tostring(why) or nil
|
||||
end
|
||||
local function hide(h)
|
||||
local key = tostring(h.left_trigger) .. tostring(h.right_trigger) .. tostring(h.left_x)
|
||||
if key == hidden_key then return end
|
||||
hidden_key = key
|
||||
reskate.input.hide(h)
|
||||
end
|
||||
local function reset()
|
||||
state = triggers.new()
|
||||
wish(nil)
|
||||
hide({})
|
||||
end
|
||||
|
||||
reskate.on("skater_state", function(now) grinding = now == "grinding" end)
|
||||
|
||||
reskate.on("tick", function()
|
||||
if not settings.on then return end
|
||||
local now = reskate.time.now()
|
||||
local pad = reskate.input.gamepad()
|
||||
triggers.step(state, now, pad and pad.left_trigger, pad and pad.right_trigger, grinding, settings.prewindow * 1000)
|
||||
hide(triggers.hidden(state, settings.guard, settings.lean_guard))
|
||||
local trigger = triggers.chosen(state, now)
|
||||
wish(trigger and triggers.side(trigger, settings.toe_heel) or nil)
|
||||
end)
|
||||
|
||||
-- Each pop out of a grind, as it went.
|
||||
reskate.on("grind_exit", function(pop)
|
||||
pops += 1
|
||||
if pop.applied then applied += 1 end
|
||||
last_pop = pop.applied and string.format("%s of travel (%s), sideways %+.2f -> %+.2f m/s", pop.side, pop.basis or "-", pop.before, pop.after)
|
||||
or pop.reason == "kept" and "the game already chose that side"
|
||||
or pop.reason == "none" and "no trigger: the game's own exit"
|
||||
or pop.reason == "slow" and "too slow to tell the rail's direction"
|
||||
or "the launch did not match the skater's grind"
|
||||
end)
|
||||
|
||||
-- ---- the switches ----------------------------------------------------------------------------
|
||||
|
||||
local function set_on(on)
|
||||
if on == settings.on then return end
|
||||
save("on", on)
|
||||
if not on then reset() end
|
||||
end
|
||||
local function status()
|
||||
local stance = settings.stance ~= "" and settings.stance or "NOT SET (grindexit stance goofy|regular)"
|
||||
local text = string.format("%s. Sides: %s. Stance: %s. Pop-out strength %d%%. Grab guard %s, lean guard %s, pre-press window %.2f s. " ..
|
||||
"Pops out of grinds seen: %d, sides set: %d. Last: %s.",
|
||||
settings.on and "ON" or "off", settings.toe_heel and "LT toe / RT heel" or "LT left / RT right of travel", stance, settings.strength,
|
||||
settings.guard and "on" or "off", settings.lean_guard and "on" or "off", settings.prewindow, pops, applied, last_pop)
|
||||
if settings.on and settings.toe_heel and settings.stance == "" then text ..= " Until the stance is set, LT/RT are left/right of travel." end
|
||||
if why_not then text ..= " ReSkate refused: " .. why_not end
|
||||
return text
|
||||
end
|
||||
|
||||
local function answer(args)
|
||||
local function say(text) reskate.console.print("grindexit: " .. text) end
|
||||
local verb, arg = args[1] or "status", args[2]
|
||||
if verb == "status" then return say(status()) end
|
||||
if verb == "exits" then
|
||||
if arg == "on" or arg == "off" then set_on(arg == "on") end
|
||||
return say(status())
|
||||
end
|
||||
if verb == "stance" then
|
||||
if arg ~= "goofy" and arg ~= "regular" then return say("grindexit stance goofy|regular (your skater's stance in the game settings)") end
|
||||
save("stance", arg)
|
||||
wished = nil -- asked again with the stance
|
||||
return say(status())
|
||||
end
|
||||
if verb == "sides" then
|
||||
if arg ~= "toeheel" and arg ~= "travel" then return say("grindexit sides toeheel|travel") end
|
||||
save("toe_heel", arg == "toeheel")
|
||||
return say(status())
|
||||
end
|
||||
if verb == "strength" then
|
||||
local value = tonumber(arg)
|
||||
if not value or value ~= math.floor(value) or value < 10 or value > 100 then
|
||||
return say("grindexit strength <10 to 100> (percent of the full pop-out to the side; 100 is the game's own)")
|
||||
end
|
||||
save("strength", value)
|
||||
wished = nil
|
||||
return say(status())
|
||||
end
|
||||
if verb == "guard" or verb == "leanguard" then
|
||||
if arg ~= "on" and arg ~= "off" then return say("grindexit " .. verb .. " on|off") end
|
||||
save(verb == "guard" and "guard" or "lean_guard", arg == "on")
|
||||
return say(status())
|
||||
end
|
||||
if verb == "prewindow" then
|
||||
local value = tonumber(arg)
|
||||
if not value or value < 0 or value > 2 then return say("grindexit prewindow <0 to 2> (seconds a trigger may be pressed before the grind connects; 1)") end
|
||||
save("prewindow", value)
|
||||
return say(status())
|
||||
end
|
||||
if verb == "test" then
|
||||
local passed, failures = require("tests").run()
|
||||
for _, failure in failures do say("FAIL " .. failure) end
|
||||
return say(string.format("%d passed, %d failed", passed, #failures))
|
||||
end
|
||||
say("grindexit exits on|off|status, stance goofy|regular, sides toeheel|travel, strength <10-100>, guard on|off, leanguard on|off, prewindow <s>, test")
|
||||
end
|
||||
reskate.console.command("grindexit", "Pick the side you pop out of a grind with LT / RT: exits on|off, stance, sides, strength, guard, leanguard",
|
||||
answer)
|
||||
|
||||
reskate.menu.panel("Grind Exits", function(ui)
|
||||
set_on(ui:toggle("on", "Grind exit triggers", settings.on))
|
||||
ui:label("Press LT or RT while grinding (or up to a second before the grind connects) to pick the side you pop out to, then do any trick out of it. Keep the trigger held until you land.")
|
||||
local mode = ui:dropdown("mode", "Input mode", {"Toe / Heel", "Left / Right"}, settings.toe_heel and 1 or 2)
|
||||
if (mode == 1) ~= settings.toe_heel then save("toe_heel", mode == 1) end
|
||||
if settings.toe_heel then
|
||||
local stance = ui:dropdown("stance", "Your stance", {"Not set", "Regular", "Goofy"},
|
||||
settings.stance == "regular" and 2 or settings.stance == "goofy" and 3 or 1)
|
||||
local wanted = stance == 2 and "regular" or stance == 3 and "goofy" or ""
|
||||
if wanted ~= settings.stance then
|
||||
save("stance", wanted)
|
||||
wished = nil
|
||||
end
|
||||
end
|
||||
local strength = math.floor(ui:slider("strength", "Pop-out strength (%)", settings.strength, 10, 100) + 0.5)
|
||||
if strength ~= settings.strength then
|
||||
save("strength", strength)
|
||||
wished = nil
|
||||
end
|
||||
ui:label("Lower it for rails next to a wall. 100% is the game's own full exit.")
|
||||
if ui:toggle("guard", "Grab guard: the trigger that picked the side does not grab", settings.guard) ~= settings.guard then
|
||||
save("guard", not settings.guard)
|
||||
end
|
||||
if ui:toggle("lean", "Lean guard: the left stick's left/right waits for the pop", settings.lean_guard) ~= settings.lean_guard then
|
||||
save("lean_guard", not settings.lean_guard)
|
||||
end
|
||||
ui:label(string.format("Pops out of grinds seen: %d, sides set: %d. Last: %s.", pops, applied, last_pop))
|
||||
end)
|
||||
|
||||
-- Its pops are heard from the start, before any trigger picks a side.
|
||||
reskate.on("load", function() reskate.skater.grind_exit(nil) end)
|
||||
reskate.on("unload", reset)
|
||||
reskate.log("grind exits " .. (settings.on and "on" or "off") .. ": grindexit status")
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Checks of the trigger rules, run by `grindexit test` (and by dingosdk_plugin_tests, without the game).
|
||||
local triggers = require("triggers")
|
||||
|
||||
local checks = {}
|
||||
local function check(title, fn) table.insert(checks, {title = title, fn = fn}) end
|
||||
|
||||
check("a trigger pressed in a grind picks the side and is kept from the game", function()
|
||||
local s = triggers.new()
|
||||
triggers.step(s, 0, 0, 0, true)
|
||||
triggers.step(s, 16, 1, 0, true)
|
||||
assert(triggers.chosen(s, 16) == "lt")
|
||||
local h = triggers.hidden(s, true, true)
|
||||
assert(h.left_trigger and not h.right_trigger and h.left_x == "grinding")
|
||||
end)
|
||||
check("a trigger pressed before the grind counts within the window", function()
|
||||
local s = triggers.new()
|
||||
triggers.step(s, 0, 0, 1, false) -- pressed in the air
|
||||
assert(triggers.chosen(s, 0) == nil, "not before the grind connects")
|
||||
triggers.step(s, 600, 0, 1, true, 1000)
|
||||
assert(triggers.chosen(s, 600) == "rt", "it counts once it does")
|
||||
local late = triggers.new()
|
||||
triggers.step(late, 0, 0, 1, false)
|
||||
triggers.step(late, 1500, 0, 1, true, 1000)
|
||||
assert(triggers.chosen(late, 1500) == nil, "pressed too long before: the game's own")
|
||||
end)
|
||||
check("both triggers are the game's own exit", function()
|
||||
local s = triggers.new()
|
||||
triggers.step(s, 0, 1, 1, true)
|
||||
assert(triggers.chosen(s, 0) == nil)
|
||||
end)
|
||||
check("a trigger let go just before the pop still counts, then not", function()
|
||||
local s = triggers.new()
|
||||
triggers.step(s, 0, 1, 0, true)
|
||||
triggers.step(s, 50, 0, 0, true)
|
||||
assert(triggers.chosen(s, 100) == "lt", "within the grace")
|
||||
assert(triggers.chosen(s, 200) == nil, "past it")
|
||||
assert(not triggers.hidden(s, true, true).left_trigger, "let go, it is the game's again")
|
||||
end)
|
||||
check("a press needs 40 %, a release 20 %", function()
|
||||
local s = triggers.new()
|
||||
triggers.step(s, 0, 0.3, 0, true)
|
||||
assert(triggers.chosen(s, 0) == nil)
|
||||
triggers.step(s, 16, 0.5, 0, true)
|
||||
triggers.step(s, 32, 0.3, 0, true)
|
||||
assert(triggers.chosen(s, 32) == "lt", "still held at 30 %")
|
||||
end)
|
||||
check("the guards can be off", function()
|
||||
local s = triggers.new()
|
||||
triggers.step(s, 0, 1, 0, true)
|
||||
local h = triggers.hidden(s, false, false)
|
||||
assert(not h.left_trigger and h.left_x == nil)
|
||||
end)
|
||||
check("toe and heel, or left and right of travel", function()
|
||||
assert(triggers.side("lt", true) == "toe" and triggers.side("rt", true) == "heel")
|
||||
assert(triggers.side("lt", false) == "left" and triggers.side("rt", false) == "right")
|
||||
end)
|
||||
|
||||
local tests = {}
|
||||
function tests.run()
|
||||
local passed, failures = 0, {}
|
||||
for _, c in checks do
|
||||
local ok, why = pcall(c.fn)
|
||||
if ok then passed += 1 else table.insert(failures, c.title .. ": " .. tostring(why)) end
|
||||
end
|
||||
return passed, failures
|
||||
end
|
||||
return tests
|
||||
@@ -0,0 +1,74 @@
|
||||
-- The triggers, as grind exits reads them each tick: which one picks the side of the next pop out of
|
||||
-- a grind, and which the game must not see. No game access.
|
||||
--
|
||||
-- - A trigger pressed DURING a grind, or up to `prewindow` seconds before the grind connects, is
|
||||
-- "claimed": it picks the side of the next pop. Both claimed: the game's own exit.
|
||||
-- - A claimed trigger let go shortly before the pop still counts (release_grace_ms).
|
||||
-- - Grab guard: a claimed trigger is kept from the game until it is let go, so holding it through
|
||||
-- the pop does not start a grab; pressed again in the air, it grabs.
|
||||
-- - Lean guard: while a claimed trigger is held, the left stick's left/right is kept from the game
|
||||
-- while the skater grinds (ReSkate gives it back at the pop), so the game's own lean exit cannot
|
||||
-- start and pull the board the other way.
|
||||
local triggers = {}
|
||||
|
||||
triggers.press_level, triggers.release_level = 0.4, 0.2 -- of a full pull: about 40 % in, 20 % out
|
||||
triggers.release_grace_ms = 100
|
||||
|
||||
function triggers.new()
|
||||
return {
|
||||
sides = {lt = {pressed = false, claimed = false, pressed_at = -math.huge, released = -math.huge},
|
||||
rt = {pressed = false, claimed = false, pressed_at = -math.huge, released = -math.huge}},
|
||||
grinding = false,
|
||||
grind_start = -math.huge,
|
||||
}
|
||||
end
|
||||
|
||||
-- One tick at `now` (ms): the pad's triggers (0 to 1, nil without a pad) and whether the skater grinds.
|
||||
function triggers.step(state, now, lt, rt, grinding, prewindow_ms)
|
||||
if grinding and not state.grinding then state.grind_start = now end
|
||||
state.grinding = grinding
|
||||
local window_start = state.grind_start - (prewindow_ms or 1000)
|
||||
for side, value in {lt = lt or 0, rt = rt or 0} do
|
||||
local t = state.sides[side]
|
||||
if not t.pressed and value >= triggers.press_level then
|
||||
t.pressed = true
|
||||
t.pressed_at = now
|
||||
if grinding then t.claimed = true end
|
||||
elseif t.pressed and value <= triggers.release_level then
|
||||
t.pressed = false
|
||||
if t.claimed then
|
||||
t.claimed = false
|
||||
t.released = now
|
||||
end
|
||||
end
|
||||
-- Pressed a moment before the grind connected: it counts too.
|
||||
if grinding and t.pressed and not t.claimed and t.pressed_at >= window_start then t.claimed = true end
|
||||
end
|
||||
end
|
||||
|
||||
-- The trigger that picks the side now: "lt", "rt", or nil (none, or both).
|
||||
function triggers.chosen(state, now)
|
||||
local lt, rt = state.sides.lt, state.sides.rt
|
||||
if lt.claimed and rt.claimed then return nil end
|
||||
if lt.claimed or rt.claimed then return lt.claimed and "lt" or "rt" end
|
||||
local recent_lt = now - lt.released < triggers.release_grace_ms
|
||||
local recent_rt = now - rt.released < triggers.release_grace_ms
|
||||
if recent_lt and recent_rt then return nil end
|
||||
if recent_lt or recent_rt then return recent_lt and "lt" or "rt" end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- What the game is kept from: each claimed trigger (with the grab guard), and the left stick's
|
||||
-- left/right while grinding (with the lean guard) while any is claimed.
|
||||
function triggers.hidden(state, guard, lean_guard)
|
||||
local lt, rt = state.sides.lt.claimed, state.sides.rt.claimed
|
||||
return {left_trigger = guard and lt, right_trigger = guard and rt, left_x = lean_guard and (lt or rt) and "grinding" or nil}
|
||||
end
|
||||
|
||||
-- The side a trigger asks for: toe/heel (LT the toe side, RT the heel side) or left/right of travel.
|
||||
function triggers.side(trigger, toe_heel)
|
||||
if toe_heel then return trigger == "lt" and "toe" or "heel" end
|
||||
return trigger == "lt" and "left" or "right"
|
||||
end
|
||||
|
||||
return triggers
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"api": 1,
|
||||
"entry": "plugin/main.lua",
|
||||
"side": "client",
|
||||
"permissions": ["events.skater", "skater.control", "input", "storage", "menu", "console"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Hall of Meat (plugin)
|
||||
|
||||
skate. 3's Hall of Meat for skate., as a sandboxed Luau plugin: when you bail, the bones you hurt show
|
||||
through your skater, yellow where a hit bruised one and red where one broke it, with the bail's Meat
|
||||
card in the bottom left corner. It ships no game data: the skeleton (the Dem Bones costume's mesh),
|
||||
the card's icons, the THRASHER wordmark and the brush strokes are read from the installed game by
|
||||
ReSkate.
|
||||
|
||||
## What it does
|
||||
|
||||
- **The skeleton.** From the first hurt bone until you are back on your feet, then it fades out. A fall
|
||||
that hurts nothing shows nothing. A fresh hit flashes the bone it hit.
|
||||
- **The card.** It takes the place of skate.'s own HUD in that corner (the d-pad and the score HUD come
|
||||
back after). Time, hits (head hits count double, hits from vehicles half again), broken bones, road
|
||||
rash, airtime, fall, top speed and rotations, each with its points, then the Meat. Small stats join
|
||||
the card once they are worth showing. Each map keeps its best Meat (**NEW BEST** / **BEST 12,345**).
|
||||
- **A break.** The screen's edges pulse red and, in single player only, the game slows down for a
|
||||
moment. ReSkate never slows the game in a multiplayer session.
|
||||
|
||||
On once installed: switch it on or off on the ReSkate menu's ADDONS page, PLUGINS tab, or with the
|
||||
console (`~`): `hallofmeat 0|1`; `hallofmeat` on its own says this map's best and the last bail.
|
||||
`hallofmeat test` runs the plugin's own checks. With No Bail on, a wipeout No Bail stops is not a bail,
|
||||
so nothing shows.
|
||||
|
||||
## How it works
|
||||
|
||||
- `plugin/model.lua`: the bail's phases (riding, bailing, down, getting up) and its Meat, fed every
|
||||
physics step of the local skater (the `physics_step` event: what each body hit and how hard, how it
|
||||
moves, whether it is a ragdoll), on the game's clock.
|
||||
- `plugin/card.lua`: the card's rows.
|
||||
- `plugin/draw.lua`: the x-ray (`reskate.skater.xray`, `d:skeleton`), the red edge (`d:gradient`) and
|
||||
the card (`d:game_image`, skate.'s own pictures).
|
||||
- `plugin/main.lua`: the wiring to `reskate`, skate.'s HUD corner hidden while the card shows
|
||||
(`reskate.hud.corner`), the slow motion (`reskate.time.slow`), each map's best (`reskate.storage`),
|
||||
the console command and the menu panel.
|
||||
|
||||
ReSkate keeps what has to run in the game itself: the skater's physics steps
|
||||
(`Extension/Skater/skater_physics.h`), the x-ray's renderer hooks and skeleton mesh
|
||||
(`Extension/Skater/xray.h`), the HUD corner (`Extension/UI/hud_corner.h`) and the slow motion
|
||||
(`Extension/Skater/game_speed.h`).
|
||||
|
||||
The skater's render hooks, its skeleton and its body maps were first found by
|
||||
[gBGYo's ReSkate fork](https://github.com/gBGYo/ReSkate).
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Hall_of_Meat",
|
||||
"version_number": "0.1.0",
|
||||
"website_url": "https://github.com/Dingo-Shenanigans/ReSkate",
|
||||
"description": "skate. 3's Hall of Meat: your bails show the bones they hurt and score a Meat card.",
|
||||
"dependencies": []
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Hall of Meat's score card, as in skate. 3: a row per stat, with skate.'s own icon for it, then the
|
||||
-- Meat. No game access.
|
||||
local model = require("model")
|
||||
local card = {}
|
||||
|
||||
-- When a stat comes onto the card: once the bail has done something worth showing, so a small bail's
|
||||
-- card stays short and a big one grows as it goes. The time shows from the start; the Meat counts
|
||||
-- every stat whether shown or not.
|
||||
card.hits_shown, card.broken_shown = 5, 5
|
||||
card.road_rash_shown_feet, card.airtime_shown_seconds, card.fall_shown_feet = 15, 3, 100
|
||||
card.speed_shown_mph, card.rotations_shown = 20, 2
|
||||
-- skate. shows lengths in feet and speeds in miles per hour.
|
||||
card.feet_per_metre = 3.28084
|
||||
card.mph_per_metre_per_second = 2.23694
|
||||
-- Each stat's row, by the game's picture it shows, in the card's order.
|
||||
card.icons = {
|
||||
time = "stopwatch", hits = "wipeout", broken = "wipeout_broken", road_rash = "spread_eagle",
|
||||
airtime = "airtime", fall = "gap_height", speed = "flaming_wheel", rotations = "roll",
|
||||
}
|
||||
|
||||
-- 12,345 and -1,234, as skate. shows its numbers.
|
||||
function card.grouped(value)
|
||||
value = math.floor(value + 0.5)
|
||||
local negative = value < 0
|
||||
local digits = tostring(math.abs(value))
|
||||
local out = digits:reverse():gsub("(%d%d%d)", "%1,"):reverse()
|
||||
if out:sub(1, 1) == "," then out = out:sub(2) end
|
||||
return (negative and "-" or "") .. out
|
||||
end
|
||||
|
||||
-- The card for what is drawn now, against the map's best before the bail (nil: unknown). No card
|
||||
-- while riding.
|
||||
function card.make(view, against)
|
||||
if view.phase == "riding" then return {opacity = 0, rows = {}, total = 0, badge = ""} end
|
||||
local t = view.tally
|
||||
local result = {opacity = view.alpha, rows = {}, total = t.score, badge = "", highlight = false}
|
||||
local function row(shown, stat, value, points)
|
||||
if shown then table.insert(result.rows, {stat = stat, value = value, points = points}) end
|
||||
end
|
||||
local road_rash, fallen = t.scraped * card.feet_per_metre, t.fallen * card.feet_per_metre
|
||||
local speed = t.top_speed * card.mph_per_metre_per_second
|
||||
row(true, "time", string.format("%.1f s", t.seconds), t.time_points)
|
||||
row(t.impacts >= card.hits_shown, "hits", card.grouped(t.impacts) .. (t.impacts == 1 and " hit" or " hits"),
|
||||
t.hit_points + t.head_bonus + t.vehicle_bonus)
|
||||
row(t.broken >= card.broken_shown, "broken", card.grouped(t.broken) .. " broken", t.broken * model.points_per_break)
|
||||
row(road_rash >= card.road_rash_shown_feet, "road_rash", string.format("%.1f ft", road_rash), t.scrape_points)
|
||||
row(t.airtime >= card.airtime_shown_seconds, "airtime", string.format("%.1f s", t.airtime), t.airtime_points)
|
||||
row(fallen >= card.fall_shown_feet, "fall", string.format("%.1f ft", fallen), t.fall_points)
|
||||
row(speed >= card.speed_shown_mph, "speed", string.format("%.1f MPH", speed), t.speed_points)
|
||||
row(t.rotations >= card.rotations_shown, "rotations", string.format("%.1f rotations", t.rotations), t.rotation_points)
|
||||
if against and against.new_best then
|
||||
result.badge, result.highlight = "NEW BEST", true
|
||||
elseif against and against.best > 0 then
|
||||
result.badge = "BEST " .. card.grouped(against.best)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
return card
|
||||
@@ -0,0 +1,130 @@
|
||||
-- What Hall of Meat draws: from the local skater's bail until they get up, the bones it hurt as an
|
||||
-- x-ray over the world, the bail's score card in the bottom left corner (where skate.'s own HUD shows,
|
||||
-- hidden meanwhile with reskate.hud.corner), and the screen's red edge when a bone breaks. Drawn with
|
||||
-- skate.'s own icons, logo and brush strokes (d:game_image), read from the installed game.
|
||||
local card = require("card")
|
||||
local draw = {}
|
||||
|
||||
-- skate.'s colours, as the overlay's theme has them: dark tiles, its blue, white numbers.
|
||||
local tile, blue, white, good, grey = {26, 26, 26, 240}, {1, 131, 255, 255}, {245, 245, 245, 255}, {46, 184, 92, 255}, {150, 152, 158, 255}
|
||||
local bruised, broken, dark_red, hot = {255, 196, 36, 255}, {232, 32, 32, 255}, {150, 12, 12, 255}, {255, 255, 255, 255}
|
||||
|
||||
-- "#RRGGBBAA" for a colour at `opacity` of its own alpha.
|
||||
local function hex(c, opacity)
|
||||
local a = math.clamp(math.floor(c[4] * math.clamp(opacity or 1, 0, 1) + 0.5), 0, 255)
|
||||
return string.format("#%02X%02X%02X%02X", c[1], c[2], c[3], a)
|
||||
end
|
||||
local function mix(from, to, amount)
|
||||
amount = math.clamp(amount, 0, 1)
|
||||
local out = {}
|
||||
for i = 1, 4 do out[i] = math.floor(from[i] + (to[i] - from[i]) * amount + 0.5) end
|
||||
return out
|
||||
end
|
||||
local function v(x, y) return vector.create(x, y, 0) end
|
||||
|
||||
-- The hurt bodies, painted: bruised ones yellow, broken ones a slowly pulsing red, a fresh hit
|
||||
-- flashing white-hot; the rest are left out.
|
||||
function draw.skeleton(d, view, seconds)
|
||||
local paint, any = {}, false
|
||||
for name, injury in view.injuries do
|
||||
if injury == "none" then continue end
|
||||
local colour = injury == "broken" and mix(dark_red, broken, 0.5 + 0.5 * math.sin(seconds * 6)) or bruised
|
||||
paint[name] = hex(mix(colour, hot, (view.flashes[name] or 0) * 0.8))
|
||||
any = true
|
||||
end
|
||||
if any then d:skeleton(paint, math.clamp(view.alpha, 0, 1)) end
|
||||
end
|
||||
|
||||
-- The screen at `strength` (0 to 1), as a hard hit shows it: darkened a little, the broken bones' red
|
||||
-- washing in from all four edges, top and bottom deeper than the sides.
|
||||
function draw.break_pulse(d, strength)
|
||||
strength = math.clamp(strength, 0, 1)
|
||||
if strength <= 0 then return end
|
||||
local size = d:size()
|
||||
local w, h = size.x, size.y
|
||||
d:rect(v(0, 0), v(w, h), {colour = hex({0, 0, 0, 255}, 0.3 * strength), fill = true})
|
||||
local edge, clear = hex(broken, 0.7 * strength), hex(broken, 0)
|
||||
local vertical, horizontal = h * 0.3 * strength, w * 0.18 * strength
|
||||
-- Each band from its edge (the colour) inwards (clear); corners: top left, top right, bottom right, bottom left.
|
||||
d:gradient(v(0, 0), v(w, vertical), {edge, edge, clear, clear})
|
||||
d:gradient(v(0, h - vertical), v(w, h), {clear, clear, edge, edge})
|
||||
d:gradient(v(0, 0), v(horizontal, h), {edge, clear, clear, edge})
|
||||
d:gradient(v(w - horizontal, 0), v(w, h), {clear, edge, edge, clear})
|
||||
end
|
||||
|
||||
-- The card from frame to frame: the total counts up from what it last showed, and a row fades in from
|
||||
-- when it came.
|
||||
local motion = {total = 0, at = 0, since = {}}
|
||||
|
||||
-- The score card as skate. 3's Hall of Meat shows it, in the bottom left corner, growing upwards as rows
|
||||
-- come: a brush bar per stat with its icon, value and points, then a scratched panel with the THRASHER
|
||||
-- logo, the title in its arch, the total on a blue stroke and the badge.
|
||||
function draw.card(d, shown, now)
|
||||
local size = d:size()
|
||||
if shown.opacity <= 0 or size.x <= 0 or size.y <= 0 then
|
||||
motion = {total = 0, at = 0, since = {}}
|
||||
return
|
||||
end
|
||||
-- The total catches up with its value in about a quarter of a second.
|
||||
local step = math.clamp((now - motion.at) / 1000, 0, 0.1)
|
||||
motion.at = now
|
||||
motion.total += (shown.total - motion.total) * (1 - math.exp(-step * 12))
|
||||
if math.abs(shown.total - motion.total) < 1 then motion.total = shown.total end
|
||||
|
||||
-- From the screen's bottom left corner, in 1080p pixels; as wide as the logo it carries.
|
||||
local k, o = size.y / 1080, shown.opacity
|
||||
local left = 64 * k
|
||||
local right, pad = left + 250 * k, 14 * k
|
||||
local logo_height, title_size, total_size, badge_size = 72 * k, 22, 56, 18 -- text sizes are at 1080 lines
|
||||
local logo_step = logo_height * 0.70 + 4 * k -- the wordmark's arch leaves room beneath it
|
||||
local height = pad * 2 + logo_step + (title_size + total_size) * k + (shown.badge == "" and 0 or (badge_size + 4) * k)
|
||||
local row_height, row_gap, icon, text = 30 * k, 10 * k, 22 * k, 18
|
||||
|
||||
-- A bar per stat. A new one opens and fades in, and the card grows upwards by it.
|
||||
local lines, rows_height = {}, 0
|
||||
for _, row in shown.rows do
|
||||
motion.since[row.stat] = motion.since[row.stat] or now
|
||||
local opened = math.clamp((now - motion.since[row.stat]) / 300, 0, 1)
|
||||
table.insert(lines, {row = row, top = rows_height, shown = opened})
|
||||
rows_height += (row_height + row_gap) * opened * (2 - opened) -- eases out
|
||||
end
|
||||
local y = size.y - 96 * k - height - rows_height
|
||||
for _, line in lines do line.top += y end
|
||||
y += rows_height
|
||||
-- Every bar before any row's content, so no bar's splatter covers another's numbers.
|
||||
for _, line in lines do
|
||||
d:game_image("brush_bar", v(left, line.top), v(right, line.top + row_height), {colour = hex(tile, o * line.shown * 0.92), fit = "shape"})
|
||||
end
|
||||
for _, line in lines do
|
||||
local row, middle = line.row, line.top + row_height / 2
|
||||
local x = left + pad
|
||||
d:game_image(card.icons[row.stat], v(x, middle - icon / 2), v(x + icon, middle + icon / 2), {colour = hex(blue, o * line.shown)})
|
||||
x += icon + 10 * k
|
||||
local style = {colour = hex(white, o * line.shown), size = text, font = "bold"}
|
||||
d:text(v(x, middle - text * k / 2), row.value, style)
|
||||
d:text(v(right - pad, middle - text * k / 2), card.grouped(row.points), {colour = style.colour, size = text, font = "bold", align = "right"})
|
||||
end
|
||||
|
||||
local edge = 16 * k
|
||||
d:game_image("rough_tile", v(left, y), v(right, y + height), {colour = hex(tile, o * 0.92), fit = "panel", edge = 16})
|
||||
d:game_image("scratches", v(left + edge, y + edge), v(right - edge, y + height - edge), {colour = hex(white, o * 0.5), fit = "shape"})
|
||||
local centre = (left + right) / 2
|
||||
y += pad
|
||||
d:game_image("thrasher", v(left + pad, y), v(right - pad, y + logo_height), {colour = hex(blue, o)})
|
||||
y += logo_step
|
||||
d:text(v(centre, y), "Hall of Meat", {colour = hex(white, o), size = title_size, font = "heading", align = "centre"})
|
||||
y += title_size * k
|
||||
local total = card.grouped(motion.total)
|
||||
-- The blue stroke under the total, about as wide as its digits.
|
||||
local stroke = #total * total_size * k * 0.3 + 20 * k
|
||||
d:game_image("streak", v(centre - stroke, y + total_size * k * 0.55), v(centre + stroke, y + total_size * k * 0.95),
|
||||
{colour = hex(blue, o), fit = "shape"})
|
||||
d:text(v(centre, y), total, {colour = hex(white, o), size = total_size, font = "title", align = "centre"})
|
||||
y += total_size * k
|
||||
if shown.badge ~= "" then
|
||||
y += 4 * k
|
||||
d:text(v(centre, y), shown.badge, {colour = hex(shown.highlight and good or grey, o), size = badge_size, font = "bold", align = "centre"})
|
||||
end
|
||||
end
|
||||
|
||||
return draw
|
||||
@@ -0,0 +1,151 @@
|
||||
-- Hall of Meat, as in skate. 3: when the local skater bails, the bones it hurt show through the
|
||||
-- skater, yellow where a hit bruised one and red where one broke it, with the bail's Meat card in the
|
||||
-- bottom left corner; both stay a second after the skater gets up, then fade out. A bone breaking
|
||||
-- flashes the screen's edges red and, in single player, slows the game down a moment.
|
||||
--
|
||||
-- Each physics step of the local skater (physics_step) feeds the bail (model.lua). The skeleton is
|
||||
-- skate.'s own skeleton mesh as the renderer draws the skater (reskate.skater.xray, d:skeleton); the
|
||||
-- card (card.lua, draw.lua) takes the place of skate.'s bottom left HUD while it shows
|
||||
-- (reskate.hud.corner). Each map's best Meat is kept (reskate.storage).
|
||||
local model = require("model")
|
||||
local card = require("card")
|
||||
local draw = require("draw")
|
||||
|
||||
local on = reskate.storage.get("on")
|
||||
if type(on) ~= "boolean" then on = true end
|
||||
local tracker = model.tracker()
|
||||
local level = ""
|
||||
local best = nil -- the current map's best Meat; nil while no map is known
|
||||
local standing = nil -- where the last finished bail that showed stands against the map's best
|
||||
local last = nil -- the last finished bail's summary
|
||||
local speed_asked = 1 -- the game speed last asked for
|
||||
local corner_hidden = false
|
||||
|
||||
local function best_key(name) return "best." .. string.lower(name) end
|
||||
local function set_level(name)
|
||||
level = name or ""
|
||||
best = level ~= "" and (tonumber(reskate.storage.get(best_key(level))) or 0) or nil
|
||||
standing = nil
|
||||
end
|
||||
|
||||
-- A bail is over: it stands against the map's best, which a new best replaces.
|
||||
local function finished(summary)
|
||||
if not summary then return end
|
||||
last = summary
|
||||
standing = summary.shown and best and model.standing(best, summary.tally.score) or nil
|
||||
if standing and standing.new_best then
|
||||
best = standing.best
|
||||
reskate.storage.set(best_key(level), best)
|
||||
end
|
||||
end
|
||||
|
||||
local function set_on(value)
|
||||
if value == on then return end
|
||||
on = value
|
||||
reskate.storage.set("on", on)
|
||||
reskate.skater.xray(on)
|
||||
reskate.hud.corner(false)
|
||||
if not on then
|
||||
tracker:reset()
|
||||
speed_asked = 1
|
||||
reskate.time.slow(1)
|
||||
end
|
||||
end
|
||||
|
||||
reskate.on("load", function()
|
||||
reskate.skater.xray(on)
|
||||
reskate.hud.corner(false) -- ready to hide it the moment a card shows
|
||||
local current = reskate.level.current()
|
||||
if current then set_level(current.name) end
|
||||
end)
|
||||
reskate.on("level_loaded", function(name)
|
||||
finished(tracker:lose(reskate.time.now()))
|
||||
set_level(name)
|
||||
end)
|
||||
reskate.on("level_unloading", function() finished(tracker:lose(reskate.time.now())) end)
|
||||
-- The skater gone (a respawn, a teleport): a bail ends.
|
||||
reskate.on("skater_state", function(state)
|
||||
if state == "none" then finished(tracker:lose(reskate.time.now())) end
|
||||
end)
|
||||
|
||||
reskate.on("physics_step", function(step)
|
||||
if not on then return end
|
||||
finished(tracker:step(step.at, step))
|
||||
end)
|
||||
|
||||
reskate.on("tick", function()
|
||||
if not on then return end
|
||||
local now = reskate.time.now()
|
||||
local showing = tracker:view(now).phase ~= "riding"
|
||||
-- The card takes the place of skate.'s bottom left HUD while it shows: the bail is what counts.
|
||||
if showing ~= corner_hidden then
|
||||
corner_hidden = showing
|
||||
reskate.hud.corner(showing)
|
||||
end
|
||||
-- A break slows the game down (never in a multiplayer session: ReSkate refuses it there).
|
||||
local speed = tracker:game_speed(now)
|
||||
if math.abs(speed - speed_asked) > 0.005 or (speed >= 1 and speed_asked < 1) then
|
||||
speed_asked = speed
|
||||
reskate.time.slow(speed >= 0.999 and 1 or speed)
|
||||
end
|
||||
end)
|
||||
|
||||
reskate.on("draw", function(d)
|
||||
if not on then return end
|
||||
local now = reskate.time.now()
|
||||
local view = tracker:view(now)
|
||||
draw.break_pulse(d, view.break_pulse)
|
||||
if view.phase == "riding" then
|
||||
draw.card(d, {opacity = 0, rows = {}, total = 0, badge = ""}, now)
|
||||
return
|
||||
end
|
||||
draw.skeleton(d, view, now / 1000)
|
||||
-- The card's bail against the map's best before it: while it goes on, as it would stand; once
|
||||
-- over, as it stood (the best already counts it).
|
||||
local against
|
||||
if view.phase == "getting_up" then
|
||||
against = standing
|
||||
elseif best then
|
||||
against = model.standing(best, view.tally.score)
|
||||
end
|
||||
draw.card(d, card.make(view, against), now)
|
||||
end)
|
||||
|
||||
-- ---- the console and the menu ----------------------------------------------------------------
|
||||
|
||||
local function describe(summary)
|
||||
if not summary then return "no bail yet" end
|
||||
local t = summary.tally
|
||||
return string.format("%s Meat (%d hits, %d broken, %.1f s, %.1f s airtime, fell %.1f m, %.1f m/s top, %.1f rotations, road rash %.1f m)%s",
|
||||
card.grouped(t.score), t.impacts, t.broken, t.seconds, t.airtime, t.fallen, t.top_speed, t.rotations, t.scraped,
|
||||
summary.shown and "" or ", hurt nothing so it did not show")
|
||||
end
|
||||
local function answer(name)
|
||||
return function(args)
|
||||
local function say(text) reskate.console.print(name .. ": " .. text) end
|
||||
local what = args[1]
|
||||
if what == "1" or what == "on" then
|
||||
set_on(true)
|
||||
elseif what == "0" or what == "off" then
|
||||
set_on(false)
|
||||
elseif what == "test" then
|
||||
local passed, failures = require("tests").run()
|
||||
for _, failure in failures do say("FAIL " .. failure) end
|
||||
return say(string.format("%d passed, %d failed", passed, #failures))
|
||||
elseif what ~= nil then
|
||||
return say("say " .. name .. " 0|1, or test")
|
||||
end
|
||||
say(string.format("%s; this map's best %s; last bail: %s", on and "on" or "off", best and card.grouped(best) or "unknown",
|
||||
describe(last)))
|
||||
end
|
||||
end
|
||||
reskate.console.command("hallofmeat", "Your bails show the bones they hurt and score a Meat card: 0|1, test", answer("hallofmeat"))
|
||||
|
||||
reskate.menu.panel("Hall of Meat", function(ui)
|
||||
set_on(ui:toggle("on", "Hall of Meat", on))
|
||||
ui:label("Your bails show the bones they hurt and score a Meat card. A break slows the game in single player. No Bail stops the bails it scores.")
|
||||
ui:label("This map's best: " .. (best and card.grouped(best) or "no map yet"))
|
||||
ui:label("Last bail: " .. describe(last))
|
||||
end)
|
||||
|
||||
reskate.log("Hall of Meat " .. (on and "on" or "off") .. ": hallofmeat 0|1")
|
||||
@@ -0,0 +1,391 @@
|
||||
-- Hall of Meat, the model: what a bail did to the skater, and what it scores. Fed one physics step at
|
||||
-- a time (the physics_step event): what each body touched, how the skater moves and whether the game
|
||||
-- simulates the body as a ragdoll. No game access.
|
||||
--
|
||||
-- A bail follows the skater, not a clock:
|
||||
-- riding -> bailing the game's wipeout, and nothing else: a fall from height ragdolls in the
|
||||
-- air before the wipeout of its impact (its flight and its hits of just
|
||||
-- before carry into the bail), and skate.'s flumps (a dive, a roll along the
|
||||
-- ground, from any height) are ragdolls the game never wipes out
|
||||
-- bailing -> down the body has come to rest: the bail's time and points are final
|
||||
-- bailing, down -> getting_up the ragdoll ends (the skater stands up), or the skater is gone
|
||||
-- (a respawn, a teleport); the bail is over, stays and fades out
|
||||
-- Measured in play: the ragdoll begins with the wipeout or in the flight before it, and ends exactly
|
||||
-- once, when the skater stands up on foot. The bail shows only once it has hurt a bone: a fall that
|
||||
-- leaves the skater unbruised shows nothing.
|
||||
--
|
||||
-- Two clocks. The bail runs on the game's: its steps' own lengths added up, so in slow motion (a
|
||||
-- break) the moment the ragdoll has to begin, the rest, the impacts and every stat still measure what
|
||||
-- the body did. What the player sees runs on the real one (the steps' `at`, reskate.time.now): a
|
||||
-- hit's flash, a break's effect, the linger and the fade after the bail.
|
||||
local model = {}
|
||||
|
||||
-- Impact speeds along the contact normal, as measured in play: a fall on flat ground hits the bones
|
||||
-- it lands on at 3 to 9 m/s and grazes the rest at 2 to 3, a hard landing hits the legs at 8 to 10,
|
||||
-- a drop from height everything at 13 to 35. So a hard fall on flat ground bruises, and only a drop
|
||||
-- from height breaks bones.
|
||||
model.hit_speed = 7.0
|
||||
model.broken_speed = 12.0
|
||||
-- A wipeout whose ragdoll does not begin within this long was a stumble: its bail ends.
|
||||
model.ragdoll_wait_ms = 500
|
||||
-- The body has come to rest once it moves slower than still_speed for rest_ms: from then on nothing
|
||||
-- counts. A rest is final: getting up starts inside the ragdoll, moving the body fast again.
|
||||
model.still_speed = 0.5
|
||||
model.rest_ms = 750
|
||||
-- Once the skater gets up, the skeleton and the card stay linger_ms, then fade out over fade_ms.
|
||||
model.linger_ms = 1000
|
||||
model.fade_ms = 800
|
||||
model.flash_ms = 350 -- a fresh hit flashes this long
|
||||
-- A bone breaking hits the whole screen: its edges pulse red and the game slows down to
|
||||
-- break_game_speed (single player only), together, by one effect: easing in over break_rise_ms, held
|
||||
-- break_hold_ms, easing out over break_fall_ms; in real time, from the step that saw the break. Each
|
||||
-- break has its own and the strongest counts.
|
||||
model.break_rise_ms = 150
|
||||
model.break_hold_ms = 1000
|
||||
model.break_fall_ms = 1000
|
||||
model.break_effect_ms = model.break_rise_ms + model.break_hold_ms + model.break_fall_ms
|
||||
model.break_game_speed = 0.3
|
||||
-- A bone's contact lasts several physics steps: hits of one bone closer together than this are one
|
||||
-- impact, as hard as its hardest step.
|
||||
model.impact_gap_ms = 200
|
||||
model.max_impacts = 64
|
||||
-- The game reports the wipeout of an impact a few physics steps after it: a bail takes the flight
|
||||
-- and the hits of this long before it starts.
|
||||
model.wipeout_after_impact_ms = 250
|
||||
|
||||
-- The Meat a bail scores: every hit by how hard it was, and each bone broken a bonus once. The
|
||||
-- lightest hit (hit_speed) scores 100, a 30 m/s slam about 900. A hit to the head counts double, one
|
||||
-- from a vehicle half again.
|
||||
model.points_per_reference_hit = 100.0
|
||||
model.hit_points_exponent = 1.5
|
||||
model.points_per_break = 1000
|
||||
model.head_multiplier = 2.0
|
||||
model.vehicle_multiplier = 1.5
|
||||
-- Road rash: a body sliding along the ground or a wall, not the board, at least this fast scrapes.
|
||||
-- The skater's road rash is how far it slid: in each step, how fast its scraping bodies slid on
|
||||
-- average. A metre scores what four of the lightest hits do.
|
||||
model.scrape_speed = 1.5
|
||||
model.points_per_scraped_metre = 400.0
|
||||
model.bruising_scrape = 1.0 -- metres one bone slid: it shows as bruised
|
||||
-- The rest of the bail scores as skate. 3's Hall of Meat did, each stat its own points (a fall half of it).
|
||||
model.points_per_second = 600.0
|
||||
model.points_per_air_second = 2000.0
|
||||
model.points_per_metre_fallen = 125.0
|
||||
model.points_per_speed = 130.0 -- per metre per second
|
||||
model.points_per_rotation = 600.0
|
||||
model.radians_per_rotation = 2 * math.pi
|
||||
|
||||
-- The bodies as the physics_step event names them; the board is never a body hit. The ragdoll has no
|
||||
-- head body of its own: the upper neck, which the head rides on, is shown as the head.
|
||||
model.bodies = {
|
||||
"Head", "Neck", "Left hand", "Left forearm", "Left upper arm", "Left collarbone",
|
||||
"Right hand", "Right forearm", "Right upper arm", "Right collarbone", "Upper chest", "Chest",
|
||||
"Lower back", "Spine", "Left toes", "Left foot", "Left shin", "Left thigh",
|
||||
"Right toes", "Right foot", "Right shin", "Right thigh", "Pelvis",
|
||||
}
|
||||
local known = {}
|
||||
for _, name in model.bodies do known[name] = true end
|
||||
-- The feet touch the ground and the board all the time.
|
||||
local feet = {["Left toes"] = true, ["Left foot"] = true, ["Right toes"] = true, ["Right foot"] = true}
|
||||
|
||||
local function finite(v) return type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge end
|
||||
local function speed_of(v) return finite(v) and v > 0 and v or 0 end
|
||||
local function length_ms(seconds) return finite(seconds) and seconds > 0 and seconds * 1000 or 0 end
|
||||
local function elapsed(now, since) return now > since and now - since or 0 end
|
||||
local function points(amount, per) return math.floor(amount * per + 0.5) end
|
||||
local function length(v) return math.sqrt(v[1] * v[1] + v[2] * v[2] + v[3] * v[3]) end
|
||||
|
||||
function model.injury(peak)
|
||||
if peak >= model.broken_speed then return "broken" end
|
||||
if peak >= model.hit_speed then return "hit" end
|
||||
return "none"
|
||||
end
|
||||
-- A hit's points by its speed alone.
|
||||
function model.hit_points(speed)
|
||||
if not (speed >= model.hit_speed) then return 0 end
|
||||
return math.floor(model.points_per_reference_hit * (speed / model.hit_speed) ^ model.hit_points_exponent + 0.5)
|
||||
end
|
||||
local function head_bonus(impact)
|
||||
return impact.body == "Head" and math.floor(model.hit_points(impact.speed) * (model.head_multiplier - 1) + 0.5) or 0
|
||||
end
|
||||
local function vehicle_bonus(impact)
|
||||
return impact.vehicle and math.floor(model.hit_points(impact.speed) * (model.vehicle_multiplier - 1) + 0.5) or 0
|
||||
end
|
||||
function model.impact_points(impact) return model.hit_points(impact.speed) + head_bonus(impact) + vehicle_bonus(impact) end
|
||||
-- A bail's score against the best on the same map before it.
|
||||
function model.standing(best, score)
|
||||
if score > best then return {best = score, new_best = true} end
|
||||
return {best = best, new_best = false}
|
||||
end
|
||||
|
||||
local Tracker = {}
|
||||
Tracker.__index = Tracker
|
||||
|
||||
function model.tracker()
|
||||
local self = setmetatable({}, Tracker)
|
||||
self:reset()
|
||||
return self
|
||||
end
|
||||
function Tracker:reset()
|
||||
self.phase = "riding" -- riding, bailing or down; getting_up is the linger and the fade after a bail's end
|
||||
self.ragdolled = false
|
||||
self.hurt = false
|
||||
self.breaks, self.seen_breaks = 0, 0
|
||||
self.breaks_seen_at = {} -- real times of the steps that saw breaks
|
||||
self.played = 0 -- the game's clock: every step's length added up
|
||||
self.started = 0 -- the game's clock at the bail's wipeout
|
||||
self.ended = nil -- real time: when the bail ended
|
||||
self.still_since = nil
|
||||
self.stopped_at = nil
|
||||
self.flight_ms, self.flight_fallen = 0, 0 -- while riding: the last flight, which a bail takes over
|
||||
self.landed = nil
|
||||
self.airtime_ms, self.fallen, self.top_speed, self.turned, self.scraped = 0, 0, 0, 0, 0
|
||||
self.bones = {}
|
||||
self.impacts = {}
|
||||
end
|
||||
function Tracker:bone(name)
|
||||
local bone = self.bones[name]
|
||||
if not bone then
|
||||
bone = {peak = 0, hit_at = nil, flashed_at = nil, impact = 0, scraped = 0, lead = {}}
|
||||
self.bones[name] = bone
|
||||
end
|
||||
return bone
|
||||
end
|
||||
|
||||
-- One physics step, at `now` in real time (ms). Returns the bail's summary when this step ended one.
|
||||
function Tracker:step(now, step)
|
||||
local step_ms = length_ms(step.seconds)
|
||||
self.played += step_ms
|
||||
local flown = step.airborne and step_ms or 0
|
||||
if step.airborne then
|
||||
self.landed = nil
|
||||
elseif self.landed == nil then
|
||||
self.landed = self.played
|
||||
end
|
||||
local ragdoll = step.ragdoll == true
|
||||
local v = step.velocity
|
||||
local descent = v and math.max(0, -(v[2] or 0)) * step_ms / 1000 or 0
|
||||
local began = false
|
||||
if self.phase == "riding" then
|
||||
if step.airborne then
|
||||
self.flight_ms += flown
|
||||
self.flight_fallen += descent
|
||||
elseif self.played - self.landed > model.wipeout_after_impact_ms then -- landed, and stayed up
|
||||
self.flight_ms, self.flight_fallen = 0, 0
|
||||
end
|
||||
-- Each body's hardest recent hit, for a bail that starts a few steps after it.
|
||||
for name, contact in step.bodies or {} do
|
||||
if not known[name] then continue end
|
||||
local speed = speed_of(contact.impact)
|
||||
local lead = self:bone(name).lead
|
||||
if speed <= 0 or (lead.at and self.played - lead.at <= model.wipeout_after_impact_ms and speed <= lead.speed) then continue end
|
||||
self:bone(name).lead = {speed = speed, vehicle = contact.vehicle == true, at = self.played}
|
||||
end
|
||||
if not step.wipeout then return nil end
|
||||
self:begin(now) -- the flight it came from holds this step's airtime and fall
|
||||
began = true
|
||||
end
|
||||
self.ragdolled = self.ragdolled or ragdoll
|
||||
local speed = v and length(v) or nil
|
||||
self:rest(speed)
|
||||
if self.phase == "bailing" then
|
||||
if not began then
|
||||
self.airtime_ms += flown
|
||||
self.fallen += descent
|
||||
end
|
||||
if speed then self.top_speed = math.max(self.top_speed, speed) end
|
||||
if step.spin then self.turned += speed_of(step.spin) * step_ms / 1000 end
|
||||
self:count(now, step, step_ms)
|
||||
end
|
||||
-- The skater stood up, or never went down.
|
||||
local stood_up
|
||||
if self.ragdolled then stood_up = step.ragdoll == false else stood_up = self.played - self.started >= model.ragdoll_wait_ms end
|
||||
if self.breaks ~= self.seen_breaks then -- a break this step: its effect runs from now
|
||||
self.seen_breaks = self.breaks
|
||||
if #self.breaks_seen_at < #model.bodies then table.insert(self.breaks_seen_at, now) end
|
||||
end
|
||||
if stood_up then return self:finish(now) end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether the body has come to rest, by how fast it moves: then the bail's time and points are final.
|
||||
function Tracker:rest(speed)
|
||||
if self.phase ~= "bailing" or not speed then return end
|
||||
if speed >= model.still_speed then
|
||||
self.still_since = nil
|
||||
elseif not self.still_since then
|
||||
self.still_since = self.played
|
||||
elseif self.played - self.still_since >= model.rest_ms then
|
||||
self.phase = "down"
|
||||
self.stopped_at = self.still_since -- it has rested since it went still
|
||||
end
|
||||
end
|
||||
|
||||
-- What each body hit and how far it slid in one step of the bail.
|
||||
function Tracker:count(now, step, step_ms)
|
||||
local seconds = step_ms / 1000
|
||||
local sliding, scraping = 0, 0
|
||||
for name, contact in step.bodies or {} do
|
||||
if not known[name] then continue end
|
||||
local speed = speed_of(contact.impact)
|
||||
if speed > 0 then self:hit(name, speed, contact.vehicle == true, self.played, now) end
|
||||
-- Sliding along anything but the board scrapes: the feet ride on it all the time.
|
||||
local slide = contact.slide
|
||||
if feet[name] or not (contact.world or contact.vehicle) or not finite(slide) or slide < model.scrape_speed then continue end
|
||||
local bone = self:bone(name)
|
||||
bone.scraped += slide * seconds
|
||||
if bone.scraped >= model.bruising_scrape then self.hurt = true end
|
||||
sliding += slide
|
||||
scraping += 1
|
||||
end
|
||||
if scraping > 0 then self.scraped += sliding / scraping * seconds end
|
||||
end
|
||||
|
||||
-- The skater is gone (a respawn, a teleport, a map change): a bail ends now. Its summary when one did.
|
||||
function Tracker:lose(now)
|
||||
-- Nothing from before the skater went carries over to the one that comes back.
|
||||
self.flight_ms, self.flight_fallen = 0, 0
|
||||
self.landed = nil
|
||||
self.breaks, self.seen_breaks = 0, 0
|
||||
self.breaks_seen_at = {}
|
||||
for _, bone in self.bones do bone.lead = {} end
|
||||
if self.phase ~= "riding" then return self:finish(now) end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- A bail from this step, with the flight it came from and the hits just before it.
|
||||
function Tracker:begin(now)
|
||||
local played, flight, fallen, before = self.played, self.flight_ms, self.flight_fallen, self.bones
|
||||
self:reset()
|
||||
self.played = played
|
||||
self.phase = "bailing"
|
||||
self.started = played
|
||||
self.airtime_ms = flight -- this step's share is in it
|
||||
self.fallen = fallen
|
||||
for name, bone in before do
|
||||
local lead = bone.lead
|
||||
if lead.at and played - lead.at <= model.wipeout_after_impact_ms then self:hit(name, lead.speed, lead.vehicle, lead.at, now) end
|
||||
end
|
||||
end
|
||||
|
||||
-- A hit of one bone at `at` on the game's clock, shown from `now`: a new impact, or the harder step of
|
||||
-- the contact it belongs to.
|
||||
function Tracker:hit(name, speed, vehicle, at, now)
|
||||
local bone = self:bone(name)
|
||||
if bone.peak < model.broken_speed and speed >= model.broken_speed then self.breaks += 1 end
|
||||
bone.peak = math.max(bone.peak, speed)
|
||||
if speed < model.hit_speed then return end
|
||||
self.hurt = true
|
||||
if bone.hit_at and at - bone.hit_at <= model.impact_gap_ms then
|
||||
local impact = self.impacts[bone.impact]
|
||||
impact.speed = math.max(impact.speed, speed)
|
||||
impact.vehicle = impact.vehicle or vehicle
|
||||
elseif #self.impacts < model.max_impacts then
|
||||
table.insert(self.impacts, {body = name, speed = speed, vehicle = vehicle})
|
||||
bone.impact = #self.impacts
|
||||
end
|
||||
bone.hit_at = at
|
||||
bone.flashed_at = now
|
||||
end
|
||||
|
||||
function Tracker:finish(now)
|
||||
self.phase = "riding"
|
||||
self.ended = now
|
||||
if not self.stopped_at then self.stopped_at = self.played end
|
||||
local summary = {shown = self.hurt, peaks = {}, scraped = {}, tally = self:tally()}
|
||||
for name, bone in self.bones do
|
||||
summary.peaks[name] = bone.peak
|
||||
summary.scraped[name] = bone.scraped
|
||||
end
|
||||
return summary
|
||||
end
|
||||
|
||||
-- The bail's time, on the game's clock: until the body came to rest, or the bail ended, or the latest step.
|
||||
function Tracker:bail_ms() return math.max(0, (self.stopped_at or self.played) - self.started) end
|
||||
|
||||
-- Where the bail is at `now`, whether it hurt a bone (and so shows) or not.
|
||||
function Tracker:phase_at(now)
|
||||
if self.phase ~= "riding" then return self.phase end
|
||||
if self.ended and elapsed(now, self.ended) < model.linger_ms + model.fade_ms then return "getting_up" end
|
||||
return "riding"
|
||||
end
|
||||
|
||||
local function injury_of(bone)
|
||||
local by_hits = model.injury(bone.peak)
|
||||
if by_hits == "none" and bone.scraped >= model.bruising_scrape then return "hit" end
|
||||
return by_hits
|
||||
end
|
||||
|
||||
-- A bail's numbers, so far or in the end.
|
||||
function Tracker:tally()
|
||||
local t = {impacts = #self.impacts, hit_points = 0, head_bonus = 0, vehicle_bonus = 0, broken = 0}
|
||||
for _, impact in self.impacts do
|
||||
t.hit_points += model.hit_points(impact.speed)
|
||||
t.head_bonus += head_bonus(impact)
|
||||
t.vehicle_bonus += vehicle_bonus(impact)
|
||||
end
|
||||
for _, bone in self.bones do
|
||||
if model.injury(bone.peak) == "broken" then t.broken += 1 end
|
||||
end
|
||||
t.scraped = self.scraped
|
||||
t.scrape_points = points(t.scraped, model.points_per_scraped_metre)
|
||||
t.damage = t.hit_points + t.head_bonus + t.vehicle_bonus + t.scrape_points
|
||||
t.seconds = self:bail_ms() / 1000
|
||||
t.time_points = points(t.seconds, model.points_per_second)
|
||||
t.airtime = self.airtime_ms / 1000
|
||||
t.airtime_points = points(t.airtime, model.points_per_air_second)
|
||||
t.fallen = self.fallen
|
||||
t.fall_points = points(t.fallen, model.points_per_metre_fallen)
|
||||
t.top_speed = self.top_speed
|
||||
t.speed_points = points(t.top_speed, model.points_per_speed)
|
||||
t.rotations = self.turned / model.radians_per_rotation
|
||||
t.rotation_points = points(t.rotations, model.points_per_rotation)
|
||||
t.score = t.damage + t.broken * model.points_per_break + t.time_points + t.airtime_points + t.fall_points + t.speed_points +
|
||||
t.rotation_points
|
||||
return t
|
||||
end
|
||||
|
||||
-- What is drawn at `now`: riding (nothing) until the bail hurts a bone.
|
||||
function Tracker:view(now)
|
||||
local phase = self:phase_at(now)
|
||||
if phase == "riding" or not self.hurt then return {phase = "riding", alpha = 0, injuries = {}, flashes = {}, break_pulse = 0} end
|
||||
local since_end = phase == "getting_up" and elapsed(now, self.ended) or 0
|
||||
local view = {phase = phase, injuries = {}, flashes = {}, tally = self:tally(), break_pulse = self:break_effect(now)}
|
||||
view.alpha = since_end > model.linger_ms and 1 - (since_end - model.linger_ms) / model.fade_ms or 1
|
||||
for name, bone in self.bones do
|
||||
view.injuries[name] = injury_of(bone)
|
||||
if bone.flashed_at then
|
||||
local since = elapsed(now, bone.flashed_at)
|
||||
if since < model.flash_ms then view.flashes[name] = 1 - since / model.flash_ms end
|
||||
end
|
||||
end
|
||||
return view
|
||||
end
|
||||
|
||||
function Tracker:break_effect(now)
|
||||
local function smooth(part, whole)
|
||||
local t = part / whole
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
local strongest = 0
|
||||
for _, at in self.breaks_seen_at do
|
||||
local since = elapsed(now, at)
|
||||
if since < model.break_effect_ms then
|
||||
local effect
|
||||
if since < model.break_rise_ms then
|
||||
effect = smooth(since, model.break_rise_ms)
|
||||
elseif since < model.break_rise_ms + model.break_hold_ms then
|
||||
effect = 1
|
||||
else
|
||||
effect = 1 - smooth(since - model.break_rise_ms - model.break_hold_ms, model.break_fall_ms)
|
||||
end
|
||||
strongest = math.max(strongest, effect)
|
||||
end
|
||||
end
|
||||
return strongest
|
||||
end
|
||||
|
||||
-- How fast the game is to run now: down to break_game_speed as a break hits, else 1.
|
||||
function Tracker:game_speed(now) return 1 - (1 - model.break_game_speed) * self:break_effect(now) end
|
||||
|
||||
return model
|
||||
@@ -0,0 +1,292 @@
|
||||
-- Checks of Hall of Meat's model and card with made-up physics steps, run by `hallofmeat test` (and by
|
||||
-- dingosdk_plugin_tests, without the game).
|
||||
local model = require("model")
|
||||
local card = require("card")
|
||||
|
||||
local checks = {}
|
||||
local function check(title, fn) table.insert(checks, {title = title, fn = fn}) end
|
||||
local function near(a, b, within) return math.abs(a - b) < (within or 1e-4) end
|
||||
local t0 = 100000
|
||||
|
||||
-- The game stepping its physics at `speed` (1 full speed): each step as long in the game's time as
|
||||
-- the real time since the one before, times the speed.
|
||||
local function game(speed)
|
||||
local g = {tracker = model.tracker(), speed = speed or 1, last = nil}
|
||||
function g:step(now, step)
|
||||
local s = table.clone(step)
|
||||
s.seconds = self.last and now > self.last and (now - self.last) / 1000 * self.speed or 0
|
||||
self.last = now
|
||||
return self.tracker:step(now, s)
|
||||
end
|
||||
function g:view(now) return self.tracker:view(now) end
|
||||
function g:phase(now) return self.tracker:phase_at(now) end
|
||||
-- Steps `step` from `now` on, every 16 ms, for `ms`; returns the time after the last.
|
||||
function g:keep(now, ms, step)
|
||||
local stop = now + ms
|
||||
while now < stop do
|
||||
self:step(now, step)
|
||||
now += 16
|
||||
end
|
||||
return now
|
||||
end
|
||||
return g
|
||||
end
|
||||
local function lying() return {ragdoll = true, bodies = {}} end
|
||||
local function standing_up() return {ragdoll = false, bodies = {}} end
|
||||
local function hit(body, speed, wipeout)
|
||||
local s = lying()
|
||||
s.wipeout = wipeout == true
|
||||
s.bodies[body] = {impact = speed, slide = 0, world = true}
|
||||
return s
|
||||
end
|
||||
local function riding_hit(body, speed)
|
||||
local s = hit(body, speed)
|
||||
s.ragdoll = false
|
||||
return s
|
||||
end
|
||||
local function wipeout() return {ragdoll = true, wipeout = true, bodies = {}} end
|
||||
local function flying(ragdoll) return {airborne = true, ragdoll = ragdoll ~= false, bodies = {}} end
|
||||
local function slide(body, speed, wipeout)
|
||||
local s = lying()
|
||||
s.wipeout = wipeout == true
|
||||
s.bodies[body] = {impact = 0, slide = speed, world = true}
|
||||
return s
|
||||
end
|
||||
local function moving(speed)
|
||||
local s = lying()
|
||||
s.velocity = {speed, 0, 0}
|
||||
return s
|
||||
end
|
||||
|
||||
check("impacts count only during a bail", function()
|
||||
local g = game()
|
||||
assert(g:step(t0, riding_hit("Head", 9)) == nil and g:view(t0).phase == "riding")
|
||||
local later = t0 + model.wipeout_after_impact_ms + 16
|
||||
g:step(later, wipeout())
|
||||
assert(g:phase(later) == "bailing", "a wipeout starts a bail")
|
||||
assert((g:view(later).injuries["Head"] or "none") == "none", "an impact long before the bail is not counted")
|
||||
end)
|
||||
check("the hit that causes the wipeout counts, at its hardest", function()
|
||||
local g = game()
|
||||
g:step(t0, riding_hit("Right thigh", 35))
|
||||
g:step(t0 + 16, riding_hit("Right thigh", 12))
|
||||
g:step(t0 + 32, wipeout())
|
||||
local view = g:view(t0 + 32)
|
||||
assert(view.phase == "bailing" and view.injuries["Right thigh"] == "broken")
|
||||
assert(view.tally.impacts == 1 and view.tally.hit_points == model.hit_points(35))
|
||||
end)
|
||||
check("nothing shows until a bone is hurt", function()
|
||||
local g = game()
|
||||
g:step(t0, wipeout())
|
||||
g:step(t0 + 16, hit("Spine", model.hit_speed - 0.5))
|
||||
assert(g:phase(t0 + 16) == "bailing" and g:view(t0 + 16).phase == "riding")
|
||||
g:step(t0 + 32, hit("Spine", model.hit_speed))
|
||||
local view = g:view(t0 + 32)
|
||||
assert(view.phase == "bailing" and near(view.alpha, 1) and view.tally.impacts == 1)
|
||||
local unhurt = game()
|
||||
unhurt:step(t0, hit("Spine", model.hit_speed - 0.5, true))
|
||||
local summary = unhurt:step(t0 + 16, standing_up())
|
||||
assert(summary and not summary.shown and unhurt:view(t0 + 32).phase == "riding")
|
||||
end)
|
||||
check("hard hits break, light ones bruise, a fresh hit flashes", function()
|
||||
local g = game()
|
||||
g:step(t0, hit("Left forearm", model.hit_speed + 0.1, true))
|
||||
g:step(t0 + 16, hit("Head", model.broken_speed + 0.1))
|
||||
g:step(t0 + 32, hit("Spine", model.hit_speed - 0.1))
|
||||
g:step(t0 + 48, hit("Left forearm", 1))
|
||||
local view = g:view(t0 + 48)
|
||||
assert(view.injuries["Left forearm"] == "hit" and view.injuries["Head"] == "broken" and view.injuries["Spine"] == "none")
|
||||
assert(view.flashes["Head"] > 0.9 and (g:view(t0 + 16 + model.flash_ms).flashes["Head"] or 0) == 0)
|
||||
end)
|
||||
check("a flump starts no bail", function()
|
||||
local g = game()
|
||||
local now = g:keep(t0, 1500, flying())
|
||||
for i = 1, 60 do
|
||||
local s = hit(i % 2 == 0 and "Left upper arm" or "Spine", model.broken_speed + 5)
|
||||
s.velocity = {8, -6, 0}
|
||||
g:step(now, s)
|
||||
now += 16
|
||||
end
|
||||
assert(g:view(now).phase == "riding" and g:step(now, standing_up()) == nil)
|
||||
end)
|
||||
check("a break pulses the screen edge and slows the game as one", function()
|
||||
local g = game()
|
||||
g:step(t0, hit("Left hand", model.hit_speed + 1, true))
|
||||
assert(g:view(t0 + 16).break_pulse == 0 and g.tracker:game_speed(t0 + 16) == 1)
|
||||
g:step(t0 + 16, hit("Left forearm", model.broken_speed + 1))
|
||||
local broke = t0 + 16
|
||||
assert(near(g:view(broke + model.break_rise_ms / 2).break_pulse, 0.5), "pulses up")
|
||||
assert(near(g:view(broke + model.break_rise_ms).break_pulse, 1), "to its full strength")
|
||||
assert(near(g.tracker:game_speed(broke + model.break_rise_ms), model.break_game_speed), "slow")
|
||||
assert(near(g:view(broke + model.break_rise_ms + model.break_hold_ms + model.break_fall_ms / 2).break_pulse, 0.5), "and fades")
|
||||
assert(g:view(broke + model.break_effect_ms).break_pulse == 0 and g.tracker:game_speed(broke + model.break_effect_ms) == 1)
|
||||
g:step(broke + model.break_effect_ms, hit("Left forearm", model.broken_speed + 5))
|
||||
assert(g:view(broke + model.break_effect_ms + model.break_rise_ms).break_pulse == 0, "a broken bone hit again does not pulse")
|
||||
end)
|
||||
check("the board and bad values are ignored", function()
|
||||
local g = game()
|
||||
local s = wipeout()
|
||||
s.bodies["Board"] = {impact = 20}
|
||||
s.bodies["Pelvis"] = {impact = 0 / 0}
|
||||
s.bodies["Lower back"] = {impact = -8}
|
||||
g:step(t0, s)
|
||||
for _, injury in g:view(t0).injuries do assert(injury == "none") end
|
||||
end)
|
||||
check("a bail lasts until the skater gets up, then lingers and fades", function()
|
||||
local g = game()
|
||||
g:step(t0, hit("Pelvis", model.hit_speed + 1, true))
|
||||
local now = g:keep(t0 + 16, 3000, hit("Chest", 2))
|
||||
now = g:keep(now, 6000, lying())
|
||||
g:step(now, hit("Head", model.hit_speed + 1))
|
||||
now = g:keep(now + 16, 1000, lying())
|
||||
assert(g:view(now).phase == "bailing" and g:view(now).tally.impacts == 2)
|
||||
local summary = g:step(now, standing_up())
|
||||
assert(summary and near(summary.tally.seconds, (now - t0) / 1000, 0.02) and summary.shown)
|
||||
assert(g:view(now + model.linger_ms - 16).phase == "getting_up" and near(g:view(now + model.linger_ms - 16).alpha, 1))
|
||||
assert(near(g:view(now + model.linger_ms + model.fade_ms / 2).alpha, 0.5))
|
||||
assert(g:view(now + model.linger_ms + model.fade_ms).phase == "riding")
|
||||
end)
|
||||
check("a rest ends the counting", function()
|
||||
local g = game()
|
||||
g:step(t0, hit("Pelvis", model.hit_speed + 1, true))
|
||||
local now = g:keep(t0 + 16, 1000, moving(4))
|
||||
local went_still = now
|
||||
now = g:keep(now, model.rest_ms - 16, moving(0.2))
|
||||
assert(g:phase(now - 16) == "bailing")
|
||||
now = g:keep(now, 32, moving(0.1))
|
||||
assert(g:phase(now) == "down" and near(g:view(now).tally.seconds, (went_still - t0) / 1000, 0.02))
|
||||
local rested = g:view(now).tally.score
|
||||
local getting_up = hit("Left foot", model.hit_speed + 1)
|
||||
getting_up.velocity = {0, 3, 2}
|
||||
now = g:keep(now + 3000, 600, getting_up)
|
||||
assert(g:view(now).tally.score == rested, "getting up adds nothing")
|
||||
end)
|
||||
check("a fall from height bails at its wipeout, with the whole flight's airtime", function()
|
||||
local g = game()
|
||||
local now = g:keep(t0, 500, flying(false))
|
||||
now = g:keep(now, 2000, flying())
|
||||
assert(g:phase(now) == "riding")
|
||||
g:step(now, hit("Head", 20, true))
|
||||
assert(g:view(now).phase == "bailing" and g:view(now).tally.impacts == 1)
|
||||
assert(math.abs(g:view(now).tally.airtime - 2.5) < 0.02)
|
||||
end)
|
||||
check("a stumble without a ragdoll ends", function()
|
||||
local g = game()
|
||||
local stumble = wipeout()
|
||||
stumble.ragdoll = false
|
||||
g:step(t0, stumble)
|
||||
assert(g:phase(g:keep(t0 + 16, model.ragdoll_wait_ms - 32, standing_up())) == "bailing")
|
||||
assert(g:step(t0 + model.ragdoll_wait_ms, standing_up()) ~= nil)
|
||||
end)
|
||||
check("a lost skater ends the bail", function()
|
||||
local g = game()
|
||||
assert(g.tracker:lose(t0) == nil)
|
||||
g:step(t0, hit("Pelvis", model.hit_speed + 1, true))
|
||||
local summary = g.tracker:lose(t0 + 500)
|
||||
assert(summary and summary.shown and g:view(t0 + 500).phase == "getting_up" and g.tracker:lose(t0 + 516) == nil)
|
||||
end)
|
||||
check("one contact is one impact; points grow faster than speed", function()
|
||||
local g = game()
|
||||
local light, harder = model.hit_speed + 0.5, model.broken_speed - 1
|
||||
g:step(t0, hit("Spine", light, true))
|
||||
g:step(t0 + 16, hit("Spine", harder))
|
||||
g:step(t0 + 32, hit("Spine", 2))
|
||||
local tally = g:view(t0 + 32).tally
|
||||
assert(tally.impacts == 1 and tally.damage == model.hit_points(harder))
|
||||
g:step(t0 + 32 + model.impact_gap_ms + 16, hit("Spine", light))
|
||||
tally = g:view(t0 + 32 + model.impact_gap_ms + 16).tally
|
||||
assert(tally.impacts == 2 and tally.damage == model.hit_points(harder) + model.hit_points(light))
|
||||
assert(model.hit_points(model.hit_speed - 0.1) == 0 and model.hit_points(model.hit_speed) == 100 and model.hit_points(30) == 887)
|
||||
end)
|
||||
check("the Meat adds up: a hit to the head counts double, a vehicle half again", function()
|
||||
local g = game()
|
||||
local breaks, bruises = model.broken_speed + 1, model.hit_speed + 1
|
||||
g:step(t0, hit("Head", breaks, true))
|
||||
g:step(t0 + 16, hit("Left hand", bruises))
|
||||
local tally = g:view(t0 + 16).tally
|
||||
assert(tally.broken == 1 and tally.impacts == 2 and tally.head_bonus == model.hit_points(breaks))
|
||||
assert(tally.score == 2 * model.hit_points(breaks) + model.hit_points(bruises) + model.points_per_break + tally.time_points)
|
||||
local car = game()
|
||||
local s = hit("Spine", 10, true)
|
||||
s.bodies["Spine"].vehicle = true
|
||||
car:step(t0, s)
|
||||
car:step(t0 + 16, hit("Spine", 12))
|
||||
assert(car:view(t0 + 16).tally.vehicle_bonus == math.floor(model.hit_points(12) * 0.5 + 0.5))
|
||||
end)
|
||||
check("sliding along the ground is road rash, the board and the feet are not", function()
|
||||
local g = game()
|
||||
g:step(t0, slide("Pelvis", 5, true))
|
||||
local now = t0
|
||||
for _ = 1, 50 do
|
||||
now += 20
|
||||
g:step(now, slide("Pelvis", 5))
|
||||
end
|
||||
local view = g:view(now)
|
||||
assert(math.abs(view.tally.scraped - 5) < 1e-3 and view.injuries["Pelvis"] == "hit" and view.tally.impacts == 0)
|
||||
local board = game()
|
||||
local on_board = slide("Spine", 5, true)
|
||||
on_board.bodies["Spine"] = {impact = 0, slide = 5, board = true}
|
||||
board:step(t0, on_board)
|
||||
board:step(t0 + 1000, on_board)
|
||||
board:step(t0 + 1020, slide("Left foot", 5))
|
||||
board:step(t0 + 1040, slide("Spine", model.scrape_speed - 0.1))
|
||||
assert(board.tracker:tally().scraped == 0)
|
||||
end)
|
||||
check("the fall, the top speed and the turning score", function()
|
||||
local g = game()
|
||||
local falling = flying(false)
|
||||
falling.velocity = {3, -10, 0}
|
||||
local now = g:keep(t0, 2000, falling)
|
||||
local impact = hit("Pelvis", model.hit_speed + 2, true)
|
||||
impact.velocity = {12, -16, 0}
|
||||
g:step(now, impact)
|
||||
local rolling = lying()
|
||||
rolling.velocity = {0, 2, 4}
|
||||
rolling.spin = 2 * model.radians_per_rotation
|
||||
now = g:keep(now + 16, 500, rolling)
|
||||
local t = g:view(now).tally
|
||||
assert(math.abs(t.fallen - 19.84) < 0.05 and near(t.top_speed, 20, 0.01))
|
||||
assert(t.score == t.damage + t.broken * model.points_per_break + t.time_points + t.airtime_points + t.fall_points + t.speed_points +
|
||||
t.rotation_points)
|
||||
end)
|
||||
check("a bail runs on the game's clock", function()
|
||||
local slow = game(model.break_game_speed)
|
||||
local thrown = hit("Left shin", model.hit_speed + 1, true)
|
||||
thrown.ragdoll = false
|
||||
slow:step(t0, thrown)
|
||||
local falling = flying(false)
|
||||
falling.velocity = {0, -10, 0}
|
||||
local now = slow:keep(t0 + 16, 1000, falling)
|
||||
assert(slow:phase(now) == "bailing", "the ragdoll still has the game's moment to begin")
|
||||
end)
|
||||
check("a best is set only by beating it", function()
|
||||
assert(model.standing(0, 300).new_best and model.standing(0, 300).best == 300)
|
||||
assert(not model.standing(500, 300).new_best and not model.standing(500, 500).new_best)
|
||||
end)
|
||||
check("the card shows the bail", function()
|
||||
assert(card.grouped(0) == "0" and card.grouped(999) == "999" and card.grouped(1000) == "1,000" and card.grouped(1234567) == "1,234,567")
|
||||
assert(card.grouped(-1234) == "-1,234")
|
||||
local g = game()
|
||||
assert(card.make(g:view(t0), nil).opacity == 0)
|
||||
g:step(t0, hit("Head", model.broken_speed + 1, true))
|
||||
g:step(t0 + 16, hit("Left hand", model.hit_speed + 1))
|
||||
g:step(g:keep(t0 + 32, 1456, lying()) + 12, lying())
|
||||
local view = g:view(t0 + 1500)
|
||||
local shown = card.make(view, model.standing(0, view.tally.score))
|
||||
assert(near(shown.opacity, 1) and shown.total == view.tally.score)
|
||||
assert(#shown.rows == 1 and shown.rows[1].stat == "time" and shown.rows[1].value == "1.5 s")
|
||||
assert(shown.badge == "NEW BEST" and shown.highlight)
|
||||
shown = card.make(view, model.standing(99999, view.tally.score))
|
||||
assert(shown.badge == "BEST 99,999" and not shown.highlight and card.make(view, nil).badge == "")
|
||||
end)
|
||||
|
||||
local tests = {}
|
||||
function tests.run()
|
||||
local passed, failures = 0, {}
|
||||
for _, c in checks do
|
||||
local ok, why = pcall(c.fn)
|
||||
if ok then passed += 1 else table.insert(failures, c.title .. ": " .. tostring(why)) end
|
||||
end
|
||||
return passed, failures
|
||||
end
|
||||
return tests
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"api": 1,
|
||||
"entry": "plugin/main.lua",
|
||||
"side": "client",
|
||||
"permissions": ["events.skater", "events.physics", "overlay", "hud", "game.speed", "storage", "menu", "console"]
|
||||
}
|
||||
Reference in new issue
Block a user