From ef2293af4d8dfd037eb8e4c2a80450ee7b796f06 Mon Sep 17 00:00:00 2001 From: NaySurGithub <96527285+NaySurGithub@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:58:22 +0100 Subject: [PATCH] fix(place): drop reach-around placement, click only where the ray hits The reach-around assist placed against the block under the player when the look ray hit nothing, and made up a click on that block's edge (y 0.999). The vanilla client takes the build click from its HitResult only, so these clicks did not match the player's view and got flagged as scaffolding. Keeps the held-build tests as heldbuild_selftest. --- CMakeLists.txt | 15 +- docs/bedrock-bridging.md | 21 +- docs/bedrock-native-placement.md | 5 +- src/client/PerlinInteraction.cpp | 34 ---- src/client/PerlinInternal.hpp | 1 - src/client/player/Bridging.cpp | 167 ---------------- src/client/player/Bridging.hpp | 57 ------ tests/bridging_selftest.cpp | 327 ------------------------------- tests/heldbuild_selftest.cpp | 138 +++++++++++++ 9 files changed, 163 insertions(+), 602 deletions(-) delete mode 100644 src/client/player/Bridging.cpp delete mode 100644 src/client/player/Bridging.hpp delete mode 100644 tests/bridging_selftest.cpp create mode 100644 tests/heldbuild_selftest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 06d960f8..c28b5ef9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -770,9 +770,9 @@ endif() # The build intention rejects non-finite rays and velocities; keep those guards when the application enables # relaxed floating point. if(MSVC AND NOT PERLIN_CLANG_CL_BUILD) - set_source_files_properties(src/client/player/Bridging.cpp src/client/player/HeldBuild.cpp PROPERTIES COMPILE_OPTIONS "/fp:strict") + set_source_files_properties(src/client/player/HeldBuild.cpp PROPERTIES COMPILE_OPTIONS "/fp:strict") else() - set_source_files_properties(src/client/player/Bridging.cpp src/client/player/HeldBuild.cpp PROPERTIES COMPILE_OPTIONS "-fno-fast-math;-ffp-contract=off") + set_source_files_properties(src/client/player/HeldBuild.cpp PROPERTIES COMPILE_OPTIONS "-fno-fast-math;-ffp-contract=off") endif() include(CheckIPOSupported) @@ -1028,15 +1028,14 @@ if(PERLIN_BUILD_TESTS AND NOT EMSCRIPTEN AND NOT ANDROID AND NOT PERLIN_IOS) target_include_directories(hand_anim_selftest PRIVATE src) target_compile_features(hand_anim_selftest PRIVATE cxx_std_23) add_test(NAME hand_anim_selftest COMMAND hand_anim_selftest) - add_executable(bridging_selftest - tests/bridging_selftest.cpp - src/client/player/Bridging.cpp + add_executable(heldbuild_selftest + tests/heldbuild_selftest.cpp src/client/player/HeldBuild.cpp src/client/player/PlayerPhysics.cpp src/client/player/BlockUse.cpp) - target_include_directories(bridging_selftest PRIVATE src "${glm_SOURCE_DIR}") - target_compile_features(bridging_selftest PRIVATE cxx_std_23) - add_test(NAME bridging_selftest COMMAND bridging_selftest) + target_include_directories(heldbuild_selftest PRIVATE src "${glm_SOURCE_DIR}") + target_compile_features(heldbuild_selftest PRIVATE cxx_std_23) + add_test(NAME heldbuild_selftest COMMAND heldbuild_selftest) add_executable(entity_animation_selftest tests/entity_animation_selftest.cpp src/client/entity/JavaAnimation.cpp src/client/attachable/Geometry.cpp src/client/attachable/Animation.cpp src/client/attachable/Molang.cpp src/client/ui/Json.cpp) diff --git a/docs/bedrock-bridging.md b/docs/bedrock-bridging.md index 31788d49..0e583158 100644 --- a/docs/bedrock-bridging.md +++ b/docs/bedrock-bridging.md @@ -2,13 +2,22 @@ Placing blocks always follows Bedrock Edition's own rules. They were read out of the client (`ClientInstance::_tickBuildAction`, `GameMode::startBuildBlock`, `continueBuildBlock`, -`stopBuildBlock` and `GameMode::useItemOn`) and live in `src/client/player/HeldBuild.{hpp,cpp}`. Reach-around for -fresh presses (placing against the block you stand on while aiming past its edge) lives in -`src/client/player/Bridging.{hpp,cpp}`. +`stopBuildBlock` and `GameMode::useItemOn`) and live in `src/client/player/HeldBuild.{hpp,cpp}`. + +## Click position + +The build transaction's click position comes from the look ray, as in the Windows 26.60 client's build lambda +(`0x143771570`, reached from `GameMode` vtable slot 6 `0x14376B290`): + +- block hit (`HitResult` type 0): `HitResult::mPos - BlockPos`, the real ray hit on the clicked face; +- no block hit (types 2 / 3): the block position itself, so the click is `(0, 0, 0)`; +- a build that continues an earlier one reuses the hit stored at `GameMode + 108` on the first placement. + +There is no assist that targets a block the ray misses: a fresh press only places on the crosshair block. ## Fresh press -A new press places at the crosshair (or the reach-around target) immediately and starts a new build: the previous +A new press places at the crosshair block immediately and starts a new build: the previous block's kind and any build line are forgotten. A block is never placed into the player's own body. ## Held use @@ -53,8 +62,8 @@ corrected by the existing prediction expiry. ## Validation -`bridging_selftest` covers reach-around targeting. +`heldbuild_selftest` covers held repeats, build lines, body overlap and block-use rules. ```sh -cmake --build build --target bridging_selftest && ./build/bridging_selftest +cmake --build build --target heldbuild_selftest && ./build/heldbuild_selftest ``` diff --git a/docs/bedrock-native-placement.md b/docs/bedrock-native-placement.md index e71b6826..8796a708 100644 --- a/docs/bedrock-native-placement.md +++ b/docs/bedrock-native-placement.md @@ -43,8 +43,9 @@ strings and field usage, not from symbols. `sendContinuousPickHitResult`. These are event notifications, not the ray itself. - There are no strings for a bridging assist or air-targeting placement. - Whether the vanilla client builds against air next to a block is therefore - still unconfirmed. + The 26.60 build lambda (`0x143771570`) takes the click from the player's + `HitResult`: `mPos - BlockPos` on a block hit, `(0, 0, 0)` without one, so a + click that the look ray does not reach never comes from the vanilla client. ## Open questions diff --git a/src/client/PerlinInteraction.cpp b/src/client/PerlinInteraction.cpp index 2c17cc49..7b7d636f 100644 --- a/src/client/PerlinInteraction.cpp +++ b/src/client/PerlinInteraction.cpp @@ -303,8 +303,6 @@ namespace perlin { const glm::vec3 buildEye(camera.x, camera.y, camera.z); const int64_t buildNow = std::chrono::duration_cast(detail::Clock::now().time_since_epoch()).count(); - static const int kFaceNormal[6][3] = {{0, -1, 0}, {0, 1, 0}, {0, 0, -1}, - {0, 0, 1}, {-1, 0, 0}, {1, 0, 0}}; const auto aimAt = [&](glm::ivec3 support, int face, glm::dvec3 point) { useAim.kind = AimHit::Kind::Block; useAim.bx = support.x; @@ -313,38 +311,6 @@ namespace perlin { useAim.face = face; useAim.point = point; }; - if (bedrockBuild && live && levelRenderer && aimHit.kind == AimHit::Kind::None) { - Level& level = live->NetLevel(); - const LevelCollision collision(level, &levelRenderer->Blocks()); - auto& palette = BlockPalette::Instance(); - const auto& shape = - LevelCollision::ShapeOf(palette.InternRuntime(static_cast(held.blockRuntimeId))); - const std::vector heldShape(shape.boxes.begin(), shape.boxes.end()); - bridging::Request request; - request.eye = {camera.x, camera.y, camera.z}; - request.direction = glm::dvec3(targeting::ViewVector(camera.yaw, camera.pitch)); - request.body = physics::BoundingBox(player.State(), player.Sneaking()); - request.sneaking = player.Sneaking(); - request.reach = PickRange(creative, 1); - request.placedShape = heldShape; - request.horizontalSlab = held.name.contains("slab") && heldShape.size() == 1 && - std::abs(heldShape[0].y1 - heldShape[0].y0 - 0.5) < 1e-5; - const auto around = bridging::FindTarget(request, [&](glm::ivec3 position) { - bridging::Cell cell; - bool loaded = false; - const uint16_t id = level.GetBlock(position.x, position.y, position.z, loaded); - const std::string& name = palette.Name(id); - cell.loaded = loaded; - cell.replaceable = blockuse::IsReplaceableCell(loaded, id, name); - cell.interactive = blockuse::IsInteractive(name); - if (loaded && !cell.replaceable) { - collision.Selection(position.x, position.y, position.z, cell.collision); - } - return cell; - }); - if (around) - aimAt(around->anchor, around->face, around->point); - } if (bedrockBuild && inputManager.Pressed(Action::Use)) heldBuild.Start(player.TickCount(), useAim.kind == AimHit::Kind::Block); bool heldRepeat = false; diff --git a/src/client/PerlinInternal.hpp b/src/client/PerlinInternal.hpp index 18bbfb9c..e0cdb3d1 100644 --- a/src/client/PerlinInternal.hpp +++ b/src/client/PerlinInternal.hpp @@ -18,7 +18,6 @@ #include "client/player/BlockCollision.hpp" #include "client/player/BlockDestroy.hpp" #include "client/player/BlockUse.hpp" -#include "client/player/Bridging.hpp" #include "client/player/DamageEffect.hpp" #include "client/renderer/DynamicLights.hpp" #include "client/renderer/Environment.hpp" diff --git a/src/client/player/Bridging.cpp b/src/client/player/Bridging.cpp deleted file mode 100644 index 2ecb4bc9..00000000 --- a/src/client/player/Bridging.cpp +++ /dev/null @@ -1,167 +0,0 @@ -#include "client/player/Bridging.hpp" -#include "client/player/BlockUse.hpp" - -#include -#include -#include -#include -#include - -namespace perlin::bridging { - namespace { - constexpr double kEpsilon = 1.0e-5; - constexpr double kJumpSupportDistance = 1.5; - constexpr double kWorldLimit = 33554432.0; - constexpr physics::Box kCube{0, 0, 0, 1, 1, 1}; - - bool Finite(glm::dvec3 value) { - return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.z); - } - - bool Valid(const physics::Box& box) { - return Finite({box.x0, box.y0, box.z0}) && Finite({box.x1, box.y1, box.z1}) && box.x0 < box.x1 && - box.y0 < box.y1 && box.z0 < box.z1; - } - - bool Intersects(const physics::Box& a, const physics::Box& b) { - return a.x1 > b.x0 + kEpsilon && a.x0 < b.x1 - kEpsilon && a.y1 > b.y0 + kEpsilon && - a.y0 < b.y1 - kEpsilon && a.z1 > b.z0 + kEpsilon && a.z0 < b.z1 - kEpsilon; - } - } - - bool OverlapsPlacement(const physics::Box& body, glm::ivec3 destination, - std::span placedShape) { - const auto overlaps = [&](const physics::Box& shape) { - return Valid(shape) && - Intersects(body, shape.Offset(destination.x, destination.y, destination.z)); - }; - if (placedShape.empty()) - return overlaps(kCube); - if (std::none_of(placedShape.begin(), placedShape.end(), Valid)) - return overlaps(kCube); - return std::any_of(placedShape.begin(), placedShape.end(), overlaps); - } - - std::optional FindTarget(const Request& request, const ReadCell& read) { - if (!Valid(request.body) || !Finite(request.eye) || !Finite(request.direction) || - !std::isfinite(request.reach) || request.reach <= 0 || request.reach > 64 || - request.body.x1 - request.body.x0 > 4 || request.body.z1 - request.body.z0 > 4 || - std::abs(request.eye.x) > kWorldLimit || std::abs(request.eye.z) > kWorldLimit || - std::abs(request.body.x0) > kWorldLimit || std::abs(request.body.x1) > kWorldLimit || - std::abs(request.body.z0) > kWorldLimit || std::abs(request.body.z1) > kWorldLimit || - std::abs(request.body.y0) > kWorldLimit) - return std::nullopt; - const double length = glm::length(request.direction); - if (!std::isfinite(length) || length < 1.0e-9) - return std::nullopt; - const glm::dvec3 direction = request.direction / length; - if (direction.y >= -1.0e-6) - return std::nullopt; - - std::map, Cell> cache; - const auto cell = [&](glm::ivec3 position) -> const Cell& { - const std::array key{position.x, position.y, position.z}; - const auto found = cache.find(key); - if (found != cache.end()) - return found->second; - return cache.emplace(key, read(position)).first->second; - }; - - const int minX = static_cast(std::floor(request.body.x0 + kEpsilon)); - const int maxX = static_cast(std::floor(request.body.x1 - kEpsilon)); - const int minZ = static_cast(std::floor(request.body.z0 + kEpsilon)); - const int maxZ = static_cast(std::floor(request.body.z1 - kEpsilon)); - const int maxY = static_cast(std::floor(request.body.y0 + kEpsilon)); - const int minY = static_cast(std::floor(request.body.y0 - kJumpSupportDistance)) - 1; - std::optional best; - double bestHeight = -std::numeric_limits::infinity(); - for (int y = maxY; y >= minY; --y) - for (int x = minX; x <= maxX; ++x) - for (int z = minZ; z <= maxZ; ++z) { - const glm::ivec3 anchor{x, y, z}; - const Cell& support = cell(anchor); - if (!support.loaded || support.replaceable || (support.interactive && !request.sneaking)) - continue; - for (const auto& local : support.collision) { - if (!Valid(local)) - continue; - const auto shape = local.Offset(x, y, z); - const double top = shape.y1; - if (top > request.body.y0 + kEpsilon || - top < request.body.y0 - kJumpSupportDistance || - shape.x1 <= request.body.x0 + kEpsilon || - shape.x0 >= request.body.x1 - kEpsilon || - shape.z1 <= request.body.z0 + kEpsilon || shape.z0 >= request.body.z1 - kEpsilon) - continue; - const double along = (top - request.eye.y) / direction.y; - if (along <= 0 || along > request.reach) - continue; - const glm::dvec3 projected = request.eye + direction * along; - if (!Finite(projected) || std::abs(projected.x) > kWorldLimit || - std::abs(projected.z) > kWorldLimit) - continue; - const glm::ivec3 destination{static_cast(std::floor(projected.x)), y, - static_cast(std::floor(projected.z))}; - const int dx = destination.x - x, dz = destination.z - z; - if (std::abs(dx) + std::abs(dz) != 1) - continue; - const Cell& target = cell(destination); - if (!target.loaded || !target.replaceable) - continue; - - Target candidate; - candidate.anchor = anchor; - candidate.destination = destination; - candidate.face = dx < 0 ? 4 : dx > 0 ? 5 : dz < 0 ? 2 : 3; - const double clickLower = std::clamp(shape.y0, double(y), y + 1.0); - const double clickY = std::clamp(top - 0.001, clickLower, y + 1.0); - candidate.point = {std::clamp(projected.x, shape.x0, shape.x1), clickY, - std::clamp(projected.z, shape.z0, shape.z1)}; - if (dx) - candidate.point.x = dx < 0 ? shape.x0 : shape.x1; - else - candidate.point.z = dz < 0 ? shape.z0 : shape.z1; - if (request.placedShape.empty()) - candidate.placedShape.push_back(kCube); - else - candidate.placedShape.assign(request.placedShape.begin(), - request.placedShape.end()); - if (request.horizontalSlab && candidate.placedShape.size() == 1) { - const bool upper = blockuse::IsUpperSlabPlacement( - candidate.face, static_cast(candidate.point.y - y)); - candidate.placedShape[0].y0 = upper ? 0.5 : 0.0; - candidate.placedShape[0].y1 = upper ? 1.0 : 0.5; - } - if (OverlapsPlacement(request.body, destination, candidate.placedShape)) - continue; - candidate.distance = glm::length(candidate.point - request.eye); - if (candidate.distance > request.reach) - continue; - if (!best || top > bestHeight + kEpsilon || - (std::abs(top - bestHeight) <= kEpsilon && candidate.distance < best->distance)) { - best = candidate; - bestHeight = top; - } - } - } - return best; - } - - bool PlacementRepeat::Ready(uint64_t now, glm::ivec3 destination, uint32_t item, bool pressed) const { - if (pressed || !active || now < at) - return true; - if (now - firstAt < 200u) - return false; - const bool same = destination == lastDestination && item == lastItem; - return now - at >= (same ? 200u : 50u); - } - - void PlacementRepeat::Record(uint64_t now, glm::ivec3 destination, uint32_t item) { - if (!active) - firstAt = now; - active = true; - at = now; - lastDestination = destination; - lastItem = item; - } -} diff --git a/src/client/player/Bridging.hpp b/src/client/player/Bridging.hpp deleted file mode 100644 index aab0db71..00000000 --- a/src/client/player/Bridging.hpp +++ /dev/null @@ -1,57 +0,0 @@ -#pragma once - -#include "client/player/PlayerPhysics.hpp" - -#include -#include -#include -#include -#include -#include - -namespace perlin::bridging { - struct Cell { - bool loaded = false; - bool replaceable = false; - bool interactive = false; - std::vector collision; - }; - - struct Target { - glm::ivec3 anchor{}, destination{}; - int face = 1; - glm::dvec3 point{}; - double distance = 0; - std::vector placedShape; - }; - - struct Request { - glm::dvec3 eye{}, direction{}; - physics::Box body; - double reach = 5.7; - bool sneaking = false; - std::span placedShape; - bool horizontalSlab = false; - }; - - using ReadCell = std::function; - - std::optional FindTarget(const Request& request, const ReadCell& read); - - bool OverlapsPlacement(const physics::Box& body, glm::ivec3 destination, - std::span placedShape = {}); - - class PlacementRepeat { - public: - bool Ready(uint64_t now, glm::ivec3 destination, uint32_t item, bool pressed) const; - void Record(uint64_t now, glm::ivec3 destination, uint32_t item); - void Reset() { active = false; } - - private: - bool active = false; - uint64_t at = 0; - uint64_t firstAt = 0; - glm::ivec3 lastDestination{}; - uint32_t lastItem = 0; - }; -} diff --git a/tests/bridging_selftest.cpp b/tests/bridging_selftest.cpp deleted file mode 100644 index afb6101e..00000000 --- a/tests/bridging_selftest.cpp +++ /dev/null @@ -1,327 +0,0 @@ -#include "client/player/Bridging.hpp" -#include "client/player/HeldBuild.hpp" -#include "client/player/BlockUse.hpp" - -#include -#include -#include -#include -#include - -using namespace perlin; -using namespace perlin::bridging; - -static int checks = 0, failures = 0; -#define CHECK(condition) \ - do { \ - ++checks; \ - if (!(condition)) { \ - std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); \ - ++failures; \ - } \ - } while (0) - -constexpr physics::Box kCube{0, 0, 0, 1, 1, 1}; -constexpr physics::Box kSlab{0, 0, 0, 1, 0.5, 1}; - -struct World { - std::map, Cell> cells; - Cell Read(glm::ivec3 p) const { - const auto found = cells.find({p.x, p.y, p.z}); - return found == cells.end() ? Cell{true, true, false, {}} : found->second; - } - void Solid(glm::ivec3 p, physics::Box shape = kCube) { - cells[{p.x, p.y, p.z}] = {true, false, false, {shape}}; - } - std::optional Find(const Request& request) const { - return FindTarget(request, [&](glm::ivec3 p) { return Read(p); }); - } -}; - -static Request Looking(glm::dvec3 feet, glm::dvec3 at, bool sneak = false) { - Request request; - request.eye = feet + glm::dvec3(0, sneak ? 1.27 : 1.62, 0); - request.direction = at - request.eye; - request.body = {feet.x - 0.3, feet.y, feet.z - 0.3, feet.x + 0.3, feet.y + (sneak ? 1.5 : 1.8), - feet.z + 0.3}; - request.sneaking = sneak; - return request; -} - -int main() { - { - constexpr int64_t ms = 1000000; - heldbuild::HeldBuild build; - heldbuild::Hit hit{true, {{0, 0, 0}, 5}, {1, 0.5f, 0.5f}}; - const auto solid = [](glm::ivec3) { return false; }; - const glm::vec3 eye{0.5f, 2, 0.5f}, movement{0.2f, 0, 0}; - build.Start(20, true); - build.Built(hit.target, {1, 0, 0}, true, false, 1000 * ms, false); - // snappable without a line: 300 ms even while moving; extends from the last block on the movement face - CHECK(!build.Next(hit, eye, movement, movement, false, 1300 * ms, 26, solid)); - const auto repeat = build.Next(hit, eye, movement, movement, false, 1300 * ms + 1, 27, solid); - CHECK(repeat && repeat->support == glm::ivec3(1, 0, 0) && repeat->face == 5); - if (repeat) build.Built(*repeat, {2, 0, 0}, true, false, 1300 * ms + 1, true); - // one attempt per tick - CHECK(!build.Next(hit, eye, movement, movement, false, 1500 * ms, 27, solid)); - // line: 4 blocks/s gives min(900 / 4, 180) ms, and only while the look ray crosses the next cell - hit.point = {3.5f, 0.5f, 0.5f}; - CHECK(!build.Next(hit, eye, movement, movement, false, 1480 * ms, 28, solid)); - const auto line = build.Next(hit, eye, movement, movement, false, 1481 * ms, 29, solid); - CHECK(line && line->support == glm::ivec3(2, 0, 0) && line->face == 5); - if (line) build.Built(*line, {3, 0, 0}, true, false, 1481 * ms, true); - hit.point = {1, 0.5f, 0.5f}; - CHECK(!build.Next(hit, eye, movement, movement, false, 2000 * ms, 30, solid)); - build.Stop(); - // a press on nothing keeps the last build time: the first repeat on a block goes at once - build.Start(40, false); - hit.target = {{10, 0, 0}, 1}; - hit.point = {10.5f, 1, 0.5f}; - CHECK(!build.Next(hit, eye, {}, {}, false, 2000 * ms, 40, solid)); - const auto retry = build.Next(hit, eye, {}, {}, false, 2000 * ms, 41, solid); - CHECK(retry && retry->support == hit.target.support && retry->face == 1); - if (retry) build.Built(*retry, {10, 1, 0}, true, false, 2000 * ms, true); - // snappable, not next to the last block: placed anyway, no line - hit.target = {{20, 0, 0}, 1}; - CHECK(!build.Next(hit, eye, {}, {}, false, 2300 * ms, 42, solid)); - const auto apart = build.Next(hit, eye, {}, {}, false, 2300 * ms + 1, 43, solid); - CHECK(apart && apart->support == glm::ivec3(20, 0, 0) && apart->face == 1); - if (apart) build.Built(*apart, {20, 1, 0}, false, false, 2300 * ms + 1, true); - // replaceable support, standing, not snappable: 200 ms, built into and clicked on top - const auto grass = [](glm::ivec3 p) { return p == glm::ivec3(30, 0, 0); }; - hit.target = {{30, 0, 0}, 4}; - CHECK(!build.Next(hit, eye, {}, {}, false, 2500 * ms + 1, 44, grass)); - const auto over = build.Next(hit, eye, {}, {}, false, 2500 * ms + 2, 45, grass); - CHECK(over && over->support == glm::ivec3(30, 0, 0) && over->face == 1); - if (over) build.Built(*over, {30, 0, 0}, true, false, 2500 * ms + 2, true); - // sneaking: 300 ms, crosshair block with the movement face - hit.target = {{40, 0, 0}, 1}; - CHECK(!build.Next(hit, eye, {0, 0, 0.2f}, {}, true, 2800 * ms + 2, 46, solid)); - const auto sneak = build.Next(hit, eye, {0, 0, 0.2f}, {}, true, 2800 * ms + 3, 47, solid); - CHECK(sneak && sneak->support == glm::ivec3(40, 0, 0) && sneak->face == 3); - // ties go to z - CHECK(heldbuild::FacingFromVec({0.2f, 0, 0.2f}) == 3); - CHECK(heldbuild::FacingFromVec({-0.2f, 0.2f, 0}) == 2); - CHECK(heldbuild::FacingFromVec({0, -0.3f, 0.2f}) == 0); - CHECK(heldbuild::SegmentHitsCell({0.5f, 2, 0.5f}, {3.5f, 0.5f, 0.5f}, {3, 0, 0})); - CHECK(!heldbuild::SegmentHitsCell({0.5f, 2, 0.5f}, {1, 0.5f, 0.5f}, {3, 0, 0})); - } - - CHECK(blockuse::IsReplaceableCell(true, 0, "")); - CHECK(blockuse::IsReplaceableCell(true, 1, "minecraft:air")); - CHECK(blockuse::IsReplaceableCell(true, 2, "minecraft:water")); - CHECK(blockuse::IsReplaceableCell(true, 3, "minecraft:short_grass")); - CHECK(!blockuse::IsReplaceableCell(false, 0, "minecraft:air")); - CHECK(!blockuse::IsReplaceableCell(false, 2, "minecraft:water")); - CHECK(!blockuse::IsReplaceableCell(true, 4, "")); - CHECK(!blockuse::IsReplaceableCell(true, 5, "minecraft:stone")); - CHECK(blockuse::IsUpperSlabPlacement(0, 0)); - CHECK(!blockuse::IsUpperSlabPlacement(1, 1)); - for (int face = 2; face <= 5; ++face) { - CHECK(!blockuse::IsUpperSlabPlacement(face, 0.499f)); - CHECK(!blockuse::IsUpperSlabPlacement(face, 0.5f)); - CHECK(blockuse::IsUpperSlabPlacement(face, 0.501f)); - } - CHECK(blockuse::IsUpsideDownStairPlacement(0, 0, false)); - CHECK(!blockuse::IsUpsideDownStairPlacement(1, 0.9f, false)); - CHECK(blockuse::IsUpsideDownStairPlacement(1, 0.1f, true)); - CHECK(!blockuse::IsUpsideDownStairPlacement(2, 0.5f, false)); - CHECK(blockuse::IsUpsideDownStairPlacement(2, 0.501f, false)); - CHECK(blockuse::IsUpsideDownStairPlacement(5, 0.2f, true)); - CHECK(blockuse::StairDirection(0) == 2); - CHECK(blockuse::StairDirection(44.9f) == 2); - CHECK(blockuse::StairDirection(45) == 1); - CHECK(blockuse::StairDirection(90) == 1); - CHECK(blockuse::StairDirection(180) == 3); - CHECK(blockuse::StairDirection(270) == 0); - CHECK(blockuse::StairDirection(-45) == 2); - CHECK(blockuse::StairDirection(-90) == 0); - CHECK(blockuse::StairDirection(-180) == 3); - for (int face = 0; face < 6; ++face) - CHECK(blockuse::PillarAxis(face) == (face < 2 ? 0 : face < 4 ? 2 : 1)); - const std::array offsets{glm::ivec3(0, 0, -1), {0, 0, 1}, {-1, 0, 0}, {1, 0, 0}}; - for (int direction = 0; direction < 4; ++direction) - for (int negative = 0; negative < 2; ++negative) - for (bool crouch : {false, true}) { - const glm::ivec3 anchor = negative ? glm::ivec3(-17, -2, -17) : glm::ivec3(0, 0, 0); - const glm::dvec3 feet = glm::dvec3(anchor) + glm::dvec3(0.5, 1, 0.5); - const glm::dvec3 aim = feet + glm::dvec3(offsets[direction]) * 0.75; - World world; - world.Solid(anchor); - const auto target = world.Find(Looking(feet, aim, crouch)); - CHECK(target.has_value()); - if (!target) - continue; - CHECK(target->anchor == anchor); - CHECK(target->destination == anchor + offsets[direction]); - CHECK(target->face == direction + 2); - const auto click = target->point - glm::dvec3(anchor); - CHECK(click.x >= 0 && click.x <= 1 && click.y >= 0 && click.y <= 1 && click.z >= 0 && - click.z <= 1); - CHECK((direction == 0 && click.z == 0) || (direction == 1 && click.z == 1) || - (direction == 2 && click.x == 0) || (direction == 3 && click.x == 1)); - int clicked[3]{anchor.x, anchor.y, anchor.z}; - const auto use = blockuse::Classify("minecraft:stone", "minecraft:air", clicked, target->face, - true, true, crouch); - CHECK(use.use == blockuse::Use::Place); - CHECK(glm::ivec3(use.dest[0], use.dest[1], use.dest[2]) == target->destination); - } - - World world; - world.Solid({0, 0, 0}); - Request request = Looking({0.5, 1, 0.5}, {1.25, 1, 0.5}); - const auto baseline = world.Find(request); - CHECK(baseline && baseline->anchor.y == 0); - - for (double height : {1.0, 1.05, 1.25, 2.25, 2.5}) { - const auto target = world.Find(Looking({1.1, height, 0.5}, {1.4, 1, 0.5})); - CHECK(target && target->anchor == glm::ivec3(0) && target->destination == glm::ivec3(1, 0, 0)); - } - CHECK(!world.Find(Looking({1.31, 1, 0.5}, {1.5, 1, 0.5}))); - CHECK(!world.Find(Looking({0.5, 2.51, 0.5}, {1.25, 1, 0.5}))); - CHECK(!world.Find(Looking({0.5, 0.9, 0.5}, {1.25, 1, 0.5}))); - - CHECK(!world.Find(Looking({0.5, 1, 0.5}, {1.2, 1, 1.2}))); - world.Solid({1, 0, 0}); - const auto diagonal = world.Find(Looking({0.9, 1, 0.9}, {1.2, 1, 1.2})); - CHECK(diagonal && diagonal->anchor == glm::ivec3(1, 0, 0) && - diagonal->destination == glm::ivec3(1, 0, 1)); - - world = {}; - world.Solid({0, 0, 0}, kSlab); - const auto slab = world.Find(Looking({0.5, 0.5, 0.5}, {1.25, 0.5, 0.5})); - CHECK(slab && slab->anchor.y == 0 && std::abs(slab->point.y - 0.499) < 1e-6); - request = Looking({1.1, 0.5, 0.5}, {1.4, 0.5, 0.5}); - CHECK(!world.Find(request)); - const std::array slabShape{kSlab}; - request.placedShape = slabShape; - request.horizontalSlab = true; - CHECK(world.Find(request).has_value()); - - world.Solid({0, 0, 0}, kCube); - request = Looking({0.5, 1, 0.5}, {1.25, 1, 0.5}); - request.placedShape = slabShape; - request.horizontalSlab = true; - const auto upperSlab = world.Find(request); - CHECK(upperSlab && upperSlab->placedShape.size() == 1 && upperSlab->placedShape[0].y0 == 0.5 && - upperSlab->placedShape[0].y1 == 1); - world.Solid({0, 0, 0}, {0, 0, 0, 1, 0.75, 1}); - request = Looking({1.1, 0.75, 0.5}, {1.4, 0.75, 0.5}); - request.placedShape = slabShape; - request.horizontalSlab = true; - CHECK(!world.Find(request)); - - world = {}; - world.Solid({0, 0, 0}, {0.375, 0, 0.375, 0.625, 1.5, 0.625}); - const auto fence = world.Find(Looking({0.5, 1.5, 0.5}, {1.2, 1.5, 0.5})); - CHECK(fence && fence->point.y <= 1 && fence->point.x == 0.625); - CHECK(!world.Find(Looking({1.0, 1.5, 0.5}, {1.4, 1.5, 0.5}))); - - world = {}; - world.Solid({0, 0, 0}); - world.cells[{1, 0, 0}] = {false, true, false, {}}; - request = Looking({0.5, 1, 0.5}, {1.25, 1, 0.5}); - CHECK(!world.Find(request)); - world.Solid({1, 0, 0}); - CHECK(!world.Find(request)); - world.cells.erase({1, 0, 0}); - world.cells[{0, 0, 0}].loaded = false; - CHECK(!world.Find(request)); - world.cells[{0, 0, 0}].loaded = true; - world.cells[{0, 0, 0}].replaceable = true; - CHECK(!world.Find(request)); - world.cells[{0, 0, 0}].replaceable = false; - world.cells[{0, 0, 0}].interactive = true; - CHECK(!world.Find(request)); - request.sneaking = true; - CHECK(world.Find(request).has_value()); - world.cells[{0, 0, 0}].interactive = false; - - request = Looking({0.5, 1, 0.5}, {1.25, 1, 0.5}); - request.reach = 1; - CHECK(!world.Find(request)); - request.reach = 5.7; - request.direction = {1, 0, 0}; - CHECK(!world.Find(request)); - request.direction = {0, 1, 0}; - CHECK(!world.Find(request)); - request.direction = {0, 0, 0}; - CHECK(!world.Find(request)); - request.direction = {std::numeric_limits::quiet_NaN(), -1, 0}; - CHECK(!world.Find(request)); - request.direction = {1, -1, 0}; - request.body.x0 = std::numeric_limits::infinity(); - CHECK(!world.Find(request)); - - PlacementRepeat repeat; - CHECK(repeat.Ready(1000, {1, 0, 0}, 42, false)); - repeat.Record(1000, {1, 0, 0}, 42); - CHECK(!repeat.Ready(1199, {1, 0, 0}, 42, false)); - CHECK(repeat.Ready(1200, {1, 0, 0}, 42, false)); - CHECK(!repeat.Ready(1049, {2, 0, 0}, 42, false)); - CHECK(!repeat.Ready(1050, {2, 0, 0}, 42, false)); - CHECK(!repeat.Ready(1050, {1, 0, 0}, 43, false)); - CHECK(repeat.Ready(1200, {2, 0, 0}, 42, false)); - CHECK(repeat.Ready(1001, {1, 0, 0}, 42, true)); - CHECK(repeat.Ready(999, {1, 0, 0}, 42, false)); - repeat.Reset(); - CHECK(repeat.Ready(1001, {1, 0, 0}, 42, false)); - - for (int material : {0, 1, 2}) - for (bool jumping : {false, true}) { - World route; - const physics::Box initial = material == 1 ? kSlab - : material == 2 ? physics::Box{0, 0.5, 0, 1, 1, 1} - : kCube; - route.Solid({0, 0, 0}, initial); - PlacementRepeat held; - int placed = 0; - for (int tick = 0; tick < 160; ++tick) { - const double x = 0.5 + tick * 0.27; - const double y = - initial.y1 + (jumping ? 1.25 * std::sin((tick % 12) * 3.141592653589793 / 12) : 0); - auto move = Looking({x, y, 0.5}, {x + 0.85, initial.y1, 0.5}); - move.direction = {0.85, -1.62, 0}; - if (material) { - move.placedShape = slabShape; - move.horizontalSlab = true; - } - CHECK(!route.Read({static_cast(std::floor(x)), 0, 0}).replaceable); - const auto target = route.Find(move); - if (!target || !held.Ready(tick * 50, target->destination, 42, tick == 0)) - continue; - CHECK(target->destination == target->anchor + glm::ivec3(1, 0, 0)); - CHECK(target->destination.y == 0); - CHECK(!OverlapsPlacement(move.body, target->destination, target->placedShape)); - CHECK(target->placedShape.size() == 1 && target->placedShape[0].y1 == initial.y1); - route.Solid(target->destination, target->placedShape[0]); - held.Record(tick * 50, target->destination, 42); - ++placed; - } - CHECK(placed >= 40); - CHECK(!route.Read({43, 0, 0}).replaceable); - } - - { - // standing in a cell, or mid-jump inside a 1x1 hole: a block there would land in the body - physics::State standing; - standing.x = 0.5; - standing.y = 0.0; - standing.z = 0.5; - CHECK(heldbuild::OverlapsBody({0, 0, 0}, {kCube}, physics::BoundingBox(standing, false))); - CHECK(heldbuild::OverlapsBody({0, 1, 0}, {kCube}, physics::BoundingBox(standing, false))); - physics::State jumping = standing; - jumping.y = 0.42; - CHECK(heldbuild::OverlapsBody({0, 0, 0}, {kCube}, physics::BoundingBox(jumping, false))); - // high enough to clear the cell: pillaring under the feet stays allowed - jumping.y = 1.0; - CHECK(!heldbuild::OverlapsBody({0, 0, 0}, {kCube}, physics::BoundingBox(jumping, false))); - // the block under the feet and the cell beside the body are free - CHECK(!heldbuild::OverlapsBody({0, -1, 0}, {kCube}, physics::BoundingBox(standing, false))); - CHECK(!heldbuild::OverlapsBody({1, 0, 0}, {kCube}, physics::BoundingBox(standing, false))); - // a slab in the feet cell still hits the body - CHECK(heldbuild::OverlapsBody({0, 0, 0}, {kSlab}, physics::BoundingBox(standing, false))); - } - std::printf("Bridging: %d checks, %d failures\n", checks, failures); - return failures ? 1 : 0; -} diff --git a/tests/heldbuild_selftest.cpp b/tests/heldbuild_selftest.cpp new file mode 100644 index 00000000..79122144 --- /dev/null +++ b/tests/heldbuild_selftest.cpp @@ -0,0 +1,138 @@ +#include "client/player/HeldBuild.hpp" +#include "client/player/BlockUse.hpp" + +#include +#include +#include +#include +#include + +using namespace perlin; + +static int checks = 0, failures = 0; +#define CHECK(condition) \ + do { \ + ++checks; \ + if (!(condition)) { \ + std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); \ + ++failures; \ + } \ + } while (0) + +constexpr physics::Box kCube{0, 0, 0, 1, 1, 1}; +constexpr physics::Box kSlab{0, 0, 0, 1, 0.5, 1}; + +int main() { + { + constexpr int64_t ms = 1000000; + heldbuild::HeldBuild build; + heldbuild::Hit hit{true, {{0, 0, 0}, 5}, {1, 0.5f, 0.5f}}; + const auto solid = [](glm::ivec3) { return false; }; + const glm::vec3 eye{0.5f, 2, 0.5f}, movement{0.2f, 0, 0}; + build.Start(20, true); + build.Built(hit.target, {1, 0, 0}, true, false, 1000 * ms, false); + // snappable without a line: 300 ms even while moving; extends from the last block on the movement face + CHECK(!build.Next(hit, eye, movement, movement, false, 1300 * ms, 26, solid)); + const auto repeat = build.Next(hit, eye, movement, movement, false, 1300 * ms + 1, 27, solid); + CHECK(repeat && repeat->support == glm::ivec3(1, 0, 0) && repeat->face == 5); + if (repeat) build.Built(*repeat, {2, 0, 0}, true, false, 1300 * ms + 1, true); + // one attempt per tick + CHECK(!build.Next(hit, eye, movement, movement, false, 1500 * ms, 27, solid)); + // line: 4 blocks/s gives min(900 / 4, 180) ms, and only while the look ray crosses the next cell + hit.point = {3.5f, 0.5f, 0.5f}; + CHECK(!build.Next(hit, eye, movement, movement, false, 1480 * ms, 28, solid)); + const auto line = build.Next(hit, eye, movement, movement, false, 1481 * ms, 29, solid); + CHECK(line && line->support == glm::ivec3(2, 0, 0) && line->face == 5); + if (line) build.Built(*line, {3, 0, 0}, true, false, 1481 * ms, true); + hit.point = {1, 0.5f, 0.5f}; + CHECK(!build.Next(hit, eye, movement, movement, false, 2000 * ms, 30, solid)); + build.Stop(); + // a press on nothing keeps the last build time: the first repeat on a block goes at once + build.Start(40, false); + hit.target = {{10, 0, 0}, 1}; + hit.point = {10.5f, 1, 0.5f}; + CHECK(!build.Next(hit, eye, {}, {}, false, 2000 * ms, 40, solid)); + const auto retry = build.Next(hit, eye, {}, {}, false, 2000 * ms, 41, solid); + CHECK(retry && retry->support == hit.target.support && retry->face == 1); + if (retry) build.Built(*retry, {10, 1, 0}, true, false, 2000 * ms, true); + // snappable, not next to the last block: placed anyway, no line + hit.target = {{20, 0, 0}, 1}; + CHECK(!build.Next(hit, eye, {}, {}, false, 2300 * ms, 42, solid)); + const auto apart = build.Next(hit, eye, {}, {}, false, 2300 * ms + 1, 43, solid); + CHECK(apart && apart->support == glm::ivec3(20, 0, 0) && apart->face == 1); + if (apart) build.Built(*apart, {20, 1, 0}, false, false, 2300 * ms + 1, true); + // replaceable support, standing, not snappable: 200 ms, built into and clicked on top + const auto grass = [](glm::ivec3 p) { return p == glm::ivec3(30, 0, 0); }; + hit.target = {{30, 0, 0}, 4}; + CHECK(!build.Next(hit, eye, {}, {}, false, 2500 * ms + 1, 44, grass)); + const auto over = build.Next(hit, eye, {}, {}, false, 2500 * ms + 2, 45, grass); + CHECK(over && over->support == glm::ivec3(30, 0, 0) && over->face == 1); + if (over) build.Built(*over, {30, 0, 0}, true, false, 2500 * ms + 2, true); + // sneaking: 300 ms, crosshair block with the movement face + hit.target = {{40, 0, 0}, 1}; + CHECK(!build.Next(hit, eye, {0, 0, 0.2f}, {}, true, 2800 * ms + 2, 46, solid)); + const auto sneak = build.Next(hit, eye, {0, 0, 0.2f}, {}, true, 2800 * ms + 3, 47, solid); + CHECK(sneak && sneak->support == glm::ivec3(40, 0, 0) && sneak->face == 3); + // ties go to z + CHECK(heldbuild::FacingFromVec({0.2f, 0, 0.2f}) == 3); + CHECK(heldbuild::FacingFromVec({-0.2f, 0.2f, 0}) == 2); + CHECK(heldbuild::FacingFromVec({0, -0.3f, 0.2f}) == 0); + CHECK(heldbuild::SegmentHitsCell({0.5f, 2, 0.5f}, {3.5f, 0.5f, 0.5f}, {3, 0, 0})); + CHECK(!heldbuild::SegmentHitsCell({0.5f, 2, 0.5f}, {1, 0.5f, 0.5f}, {3, 0, 0})); + } + + CHECK(blockuse::IsReplaceableCell(true, 0, "")); + CHECK(blockuse::IsReplaceableCell(true, 1, "minecraft:air")); + CHECK(blockuse::IsReplaceableCell(true, 2, "minecraft:water")); + CHECK(blockuse::IsReplaceableCell(true, 3, "minecraft:short_grass")); + CHECK(!blockuse::IsReplaceableCell(false, 0, "minecraft:air")); + CHECK(!blockuse::IsReplaceableCell(false, 2, "minecraft:water")); + CHECK(!blockuse::IsReplaceableCell(true, 4, "")); + CHECK(!blockuse::IsReplaceableCell(true, 5, "minecraft:stone")); + CHECK(blockuse::IsUpperSlabPlacement(0, 0)); + CHECK(!blockuse::IsUpperSlabPlacement(1, 1)); + for (int face = 2; face <= 5; ++face) { + CHECK(!blockuse::IsUpperSlabPlacement(face, 0.499f)); + CHECK(!blockuse::IsUpperSlabPlacement(face, 0.5f)); + CHECK(blockuse::IsUpperSlabPlacement(face, 0.501f)); + } + CHECK(blockuse::IsUpsideDownStairPlacement(0, 0, false)); + CHECK(!blockuse::IsUpsideDownStairPlacement(1, 0.9f, false)); + CHECK(blockuse::IsUpsideDownStairPlacement(1, 0.1f, true)); + CHECK(!blockuse::IsUpsideDownStairPlacement(2, 0.5f, false)); + CHECK(blockuse::IsUpsideDownStairPlacement(2, 0.501f, false)); + CHECK(blockuse::IsUpsideDownStairPlacement(5, 0.2f, true)); + CHECK(blockuse::StairDirection(0) == 2); + CHECK(blockuse::StairDirection(44.9f) == 2); + CHECK(blockuse::StairDirection(45) == 1); + CHECK(blockuse::StairDirection(90) == 1); + CHECK(blockuse::StairDirection(180) == 3); + CHECK(blockuse::StairDirection(270) == 0); + CHECK(blockuse::StairDirection(-45) == 2); + CHECK(blockuse::StairDirection(-90) == 0); + CHECK(blockuse::StairDirection(-180) == 3); + for (int face = 0; face < 6; ++face) + CHECK(blockuse::PillarAxis(face) == (face < 2 ? 0 : face < 4 ? 2 : 1)); + { + // standing in a cell, or mid-jump inside a 1x1 hole: a block there would land in the body + physics::State standing; + standing.x = 0.5; + standing.y = 0.0; + standing.z = 0.5; + CHECK(heldbuild::OverlapsBody({0, 0, 0}, {kCube}, physics::BoundingBox(standing, false))); + CHECK(heldbuild::OverlapsBody({0, 1, 0}, {kCube}, physics::BoundingBox(standing, false))); + physics::State jumping = standing; + jumping.y = 0.42; + CHECK(heldbuild::OverlapsBody({0, 0, 0}, {kCube}, physics::BoundingBox(jumping, false))); + // high enough to clear the cell: pillaring under the feet stays allowed + jumping.y = 1.0; + CHECK(!heldbuild::OverlapsBody({0, 0, 0}, {kCube}, physics::BoundingBox(jumping, false))); + // the block under the feet and the cell beside the body are free + CHECK(!heldbuild::OverlapsBody({0, -1, 0}, {kCube}, physics::BoundingBox(standing, false))); + CHECK(!heldbuild::OverlapsBody({1, 0, 0}, {kCube}, physics::BoundingBox(standing, false))); + // a slab in the feet cell still hits the body + CHECK(heldbuild::OverlapsBody({0, 0, 0}, {kSlab}, physics::BoundingBox(standing, false))); + } + std::printf("HeldBuild: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +}