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; +}