mirror of
https://gitlab.com/OpenMW/openmw.git
synced 2025-02-26 06:40:22 +00:00
Merge branch 'lua' into 'master'
More Lua bindings for the camera + some minor improvements See merge request OpenMW/openmw!1742
This commit is contained in:
commit
9282f8f091
@ -64,6 +64,7 @@ namespace MWRender
|
||||
{
|
||||
class Animation;
|
||||
class Camera;
|
||||
class RenderingManager;
|
||||
}
|
||||
|
||||
namespace MWMechanics
|
||||
@ -664,6 +665,8 @@ namespace MWBase
|
||||
virtual std::vector<MWWorld::Ptr> getAll(const std::string& id) = 0;
|
||||
|
||||
virtual Misc::Rng::Generator& getPrng() = 0;
|
||||
|
||||
virtual MWRender::RenderingManager* getRenderingManager() = 0;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,10 @@
|
||||
#include "luabindings.hpp"
|
||||
|
||||
#include <components/lua/utilpackage.hpp>
|
||||
#include <components/settings/settings.hpp>
|
||||
|
||||
#include "../mwrender/camera.hpp"
|
||||
#include "../mwrender/renderingmanager.hpp"
|
||||
|
||||
namespace MWLua
|
||||
{
|
||||
@ -10,6 +14,7 @@ namespace MWLua
|
||||
sol::table initCameraPackage(const Context& context)
|
||||
{
|
||||
MWRender::Camera* camera = MWBase::Environment::get().getWorld()->getCamera();
|
||||
MWRender::RenderingManager* renderingManager = MWBase::Environment::get().getWorld()->getRenderingManager();
|
||||
|
||||
sol::table api(context.mLua->sol(), sol::create);
|
||||
api["MODE"] = LuaUtil::makeReadOnly(context.mLua->sol().create_table_with(
|
||||
@ -77,6 +82,31 @@ namespace MWLua
|
||||
api["setFocalTransitionSpeed"] = [camera](float v) { camera->setFocalPointTransitionSpeed(v); };
|
||||
api["instantTransition"] = [camera]() { camera->instantTransition(); };
|
||||
|
||||
api["getCollisionType"] = [camera]() { return camera->getCollisionType(); };
|
||||
api["setCollisionType"] = [camera](int collisionType) { camera->setCollisionType(collisionType); };
|
||||
|
||||
api["getBaseFieldOfView"] = []()
|
||||
{
|
||||
return osg::DegreesToRadians(std::clamp(Settings::Manager::getFloat("field of view", "Camera"), 1.f, 179.f));
|
||||
};
|
||||
api["getFieldOfView"] = [renderingManager]() { return osg::DegreesToRadians(renderingManager->getFieldOfView()); };
|
||||
api["setFieldOfView"] = [renderingManager](float v) { renderingManager->setFieldOfView(osg::RadiansToDegrees(v)); };
|
||||
|
||||
api["getViewTransform"] = [camera]() { return LuaUtil::TransformM{camera->getViewMatrix()}; };
|
||||
|
||||
api["viewportToWorldVector"] = [camera, renderingManager](osg::Vec2f pos) -> osg::Vec3f
|
||||
{
|
||||
double width = Settings::Manager::getInt("resolution x", "Video");
|
||||
double height = Settings::Manager::getInt("resolution y", "Video");
|
||||
double aspect = (height == 0.0) ? 1.0 : width / height;
|
||||
double fovTan = std::tan(osg::DegreesToRadians(renderingManager->getFieldOfView()) / 2);
|
||||
osg::Matrixf invertedViewMatrix;
|
||||
invertedViewMatrix.invert(camera->getViewMatrix());
|
||||
float x = (pos.x() * 2 - 1) * aspect * fovTan;
|
||||
float y = (1 - pos.y() * 2) * fovTan;
|
||||
return invertedViewMatrix.preMult(osg::Vec3f(x, y, -1)) - camera->getPosition();
|
||||
};
|
||||
|
||||
return LuaUtil::makeReadOnly(api);
|
||||
}
|
||||
|
||||
|
@ -41,7 +41,7 @@ namespace MWLua
|
||||
{
|
||||
auto* lua = context.mLua;
|
||||
sol::table api(lua->sol(), sol::create);
|
||||
api["API_REVISION"] = 19;
|
||||
api["API_REVISION"] = 20;
|
||||
api["quit"] = [lua]()
|
||||
{
|
||||
Log(Debug::Warning) << "Quit requested by a Lua script.\n" << lua->debugTraceback();
|
||||
|
@ -88,7 +88,7 @@ namespace MWLua
|
||||
[](const LObject& o) { return Inventory<LObject>{o}; },
|
||||
[](const GObject& o) { return Inventory<GObject>{o}; }
|
||||
);
|
||||
actor["equipment"] = [context](const Object& o)
|
||||
auto getAllEquipment = [context](const Object& o)
|
||||
{
|
||||
const MWWorld::Ptr& ptr = o.ptr();
|
||||
sol::table equipment(context.mLua->sol(), sol::create);
|
||||
@ -106,6 +106,20 @@ namespace MWLua
|
||||
}
|
||||
return equipment;
|
||||
};
|
||||
auto getEquipmentFromSlot = [context](const Object& o, int slot) -> sol::object
|
||||
{
|
||||
const MWWorld::Ptr& ptr = o.ptr();
|
||||
sol::table equipment(context.mLua->sol(), sol::create);
|
||||
if (!ptr.getClass().hasInventoryStore(ptr))
|
||||
return sol::nil;
|
||||
MWWorld::InventoryStore& store = ptr.getClass().getInventoryStore(ptr);
|
||||
auto it = store.getSlot(slot);
|
||||
if (it == store.end())
|
||||
return sol::nil;
|
||||
context.mWorldView->getObjectRegistry()->registerPtr(*it);
|
||||
return o.getObject(context.mLua->sol(), getId(*it));
|
||||
};
|
||||
actor["equipment"] = sol::overload(getAllEquipment, getEquipmentFromSlot);
|
||||
actor["hasEquipped"] = [](const Object& o, const Object& item)
|
||||
{
|
||||
const MWWorld::Ptr& ptr = o.ptr();
|
||||
|
@ -1556,7 +1556,7 @@ namespace MWMechanics
|
||||
mov.mRotation[2] = luaControls->mYawChange;
|
||||
mov.mSpeedFactor = osg::Vec2(luaControls->mMovement, luaControls->mSideMovement).length();
|
||||
stats.setMovementFlag(MWMechanics::CreatureStats::Flag_Run, luaControls->mRun);
|
||||
stats.setAttackingOrSpell(luaControls->mUse == 1);
|
||||
stats.setAttackingOrSpell((luaControls->mUse & 1) == 1);
|
||||
luaControls->mChanged = false;
|
||||
}
|
||||
luaControls->mSideMovement = movement.x();
|
||||
|
@ -53,6 +53,7 @@ namespace MWRender
|
||||
|
||||
Camera::Camera (osg::Camera* camera)
|
||||
: mHeightScale(1.f),
|
||||
mCollisionType(MWPhysics::CollisionType::CollisionType_Default & ~MWPhysics::CollisionType::CollisionType_Actor),
|
||||
mCamera(camera),
|
||||
mAnimation(nullptr),
|
||||
mFirstPersonView(true),
|
||||
@ -127,6 +128,7 @@ namespace MWRender
|
||||
pos = calculateFirstPersonPosition(recalculatedTrackedPosition);
|
||||
}
|
||||
cam->setViewMatrixAsLookAt(pos, pos + forward, up);
|
||||
mViewMatrix = cam->getViewMatrix();
|
||||
}
|
||||
|
||||
void Camera::update(float duration, bool paused)
|
||||
@ -174,7 +176,6 @@ namespace MWRender
|
||||
constexpr float focalObstacleLimit = 10.f;
|
||||
|
||||
const auto* rayCasting = MWBase::Environment::get().getWorld()->getRayCasting();
|
||||
constexpr int collisionType = (MWPhysics::CollisionType::CollisionType_Default & ~MWPhysics::CollisionType::CollisionType_Actor);
|
||||
|
||||
// Adjust focal point to prevent clipping.
|
||||
osg::Vec3d focalOffset = getFocalPointOffset();
|
||||
@ -184,7 +185,7 @@ namespace MWRender
|
||||
float offsetLen = focalOffset.length();
|
||||
if (offsetLen > 0)
|
||||
{
|
||||
MWPhysics::RayCastingResult result = rayCasting->castSphere(focal - focalOffset, focal, focalObstacleLimit, collisionType);
|
||||
MWPhysics::RayCastingResult result = rayCasting->castSphere(focal - focalOffset, focal, focalObstacleLimit, mCollisionType);
|
||||
if (result.mHit)
|
||||
{
|
||||
double adjustmentCoef = -(result.mHitPos + result.mHitNormal * focalObstacleLimit - focal).length() / offsetLen;
|
||||
@ -196,7 +197,7 @@ namespace MWRender
|
||||
mCameraDistance = mPreferredCameraDistance;
|
||||
osg::Quat orient = osg::Quat(mPitch + mExtraPitch, osg::Vec3d(1,0,0)) * osg::Quat(mYaw + mExtraYaw, osg::Vec3d(0,0,1));
|
||||
osg::Vec3d offset = orient * osg::Vec3d(0.f, -mCameraDistance, 0.f);
|
||||
MWPhysics::RayCastingResult result = rayCasting->castSphere(focal, focal + offset, cameraObstacleLimit, collisionType);
|
||||
MWPhysics::RayCastingResult result = rayCasting->castSphere(focal, focal + offset, cameraObstacleLimit, mCollisionType);
|
||||
if (result.mHit)
|
||||
{
|
||||
mCameraDistance = (result.mHitPos + result.mHitNormal * cameraObstacleLimit - focal).length();
|
||||
@ -211,7 +212,7 @@ namespace MWRender
|
||||
if (mMode == newMode)
|
||||
return;
|
||||
Mode oldMode = mMode;
|
||||
if (!force && (newMode == Mode::FirstPerson || oldMode == Mode::FirstPerson) && !mAnimation->upperBodyReady())
|
||||
if (!force && (newMode == Mode::FirstPerson || oldMode == Mode::FirstPerson) && mAnimation && !mAnimation->upperBodyReady())
|
||||
{
|
||||
// Changing the view will stop all playing animations, so if we are playing
|
||||
// anything important, queue the view change for later
|
||||
|
@ -5,6 +5,7 @@
|
||||
#include <string>
|
||||
|
||||
#include <osg/ref_ptr>
|
||||
#include <osg/Matrix>
|
||||
#include <osg/Vec3>
|
||||
#include <osg/Vec3d>
|
||||
|
||||
@ -95,11 +96,17 @@ namespace MWRender
|
||||
void setFirstPersonOffset(const osg::Vec3f& v) { mFirstPersonOffset = v; }
|
||||
osg::Vec3f getFirstPersonOffset() const { return mFirstPersonOffset; }
|
||||
|
||||
int getCollisionType() const { return mCollisionType; }
|
||||
void setCollisionType(int collisionType) { mCollisionType = collisionType; }
|
||||
|
||||
const osg::Matrixf& getViewMatrix() const { return mViewMatrix; }
|
||||
|
||||
private:
|
||||
MWWorld::Ptr mTrackingPtr;
|
||||
osg::ref_ptr<const osg::Node> mTrackingNode;
|
||||
osg::Vec3d mTrackedPosition;
|
||||
float mHeightScale;
|
||||
int mCollisionType;
|
||||
|
||||
osg::ref_ptr<osg::Camera> mCamera;
|
||||
|
||||
@ -121,6 +128,7 @@ namespace MWRender
|
||||
float mExtraPitch = 0, mExtraYaw = 0;
|
||||
bool mLockPitch = false, mLockYaw = false;
|
||||
osg::Vec3d mPosition;
|
||||
osg::Matrixf mViewMatrix;
|
||||
|
||||
float mCameraDistance, mPreferredCameraDistance;
|
||||
|
||||
|
@ -817,6 +817,11 @@ namespace MWRender
|
||||
updateNavMesh();
|
||||
updateRecastMesh();
|
||||
|
||||
if (mUpdateProjectionMatrix)
|
||||
{
|
||||
mUpdateProjectionMatrix = false;
|
||||
updateProjectionMatrix();
|
||||
}
|
||||
mCamera->update(dt, paused);
|
||||
|
||||
bool isUnderwater = mWater->isUnderwater(mCamera->getPosition());
|
||||
@ -1161,8 +1166,7 @@ namespace MWRender
|
||||
|
||||
// Since our fog is not radial yet, we should take FOV in account, otherwise terrain near viewing distance may disappear.
|
||||
// Limit FOV here just for sure, otherwise viewing distance can be too high.
|
||||
fov = std::min(mFieldOfView, 140.f);
|
||||
float distanceMult = std::cos(osg::DegreesToRadians(fov)/2.f);
|
||||
float distanceMult = std::cos(osg::DegreesToRadians(std::min(fov, 140.f))/2.f);
|
||||
mTerrain->setViewDistance(mViewDistance * (distanceMult ? 1.f/distanceMult : 1.f));
|
||||
}
|
||||
|
||||
@ -1300,6 +1304,17 @@ namespace MWRender
|
||||
}
|
||||
}
|
||||
|
||||
void RenderingManager::setFieldOfView(float val)
|
||||
{
|
||||
mFieldOfView = val;
|
||||
mUpdateProjectionMatrix = true;
|
||||
}
|
||||
|
||||
float RenderingManager::getFieldOfView() const
|
||||
{
|
||||
return mFieldOfViewOverridden ? mFieldOfViewOverridden : mFieldOfView;
|
||||
}
|
||||
|
||||
osg::Vec3f RenderingManager::getHalfExtents(const MWWorld::ConstPtr& object) const
|
||||
{
|
||||
osg::Vec3f halfExtents(0, 0, 0);
|
||||
|
@ -214,6 +214,8 @@ namespace MWRender
|
||||
|
||||
/// temporarily override the field of view with given value.
|
||||
void overrideFieldOfView(float val);
|
||||
void setFieldOfView(float val);
|
||||
float getFieldOfView() const;
|
||||
/// reset a previous overrideFieldOfView() call, i.e. revert to field of view specified in the settings file.
|
||||
void resetFieldOfView();
|
||||
|
||||
@ -301,6 +303,7 @@ namespace MWRender
|
||||
float mFieldOfViewOverride;
|
||||
float mFieldOfView;
|
||||
float mFirstPersonFieldOfView;
|
||||
bool mUpdateProjectionMatrix = false;
|
||||
|
||||
void operator = (const RenderingManager&);
|
||||
RenderingManager(const RenderingManager&);
|
||||
|
@ -743,6 +743,8 @@ namespace MWWorld
|
||||
std::vector<MWWorld::Ptr> getAll(const std::string& id) override;
|
||||
|
||||
Misc::Rng::Generator& getPrng() override;
|
||||
|
||||
MWRender::RenderingManager* getRenderingManager() override { return mRendering.get(); }
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -51,7 +51,7 @@ namespace LuaUtil
|
||||
|
||||
LuaState::LuaState(const VFS::Manager* vfs, const ScriptsConfiguration* conf) : mConf(conf), mVFS(vfs)
|
||||
{
|
||||
mLua.open_libraries(sol::lib::base, sol::lib::coroutine, sol::lib::math,
|
||||
mLua.open_libraries(sol::lib::base, sol::lib::coroutine, sol::lib::math, sol::lib::bit32,
|
||||
sol::lib::string, sol::lib::table, sol::lib::os, sol::lib::debug);
|
||||
|
||||
mLua["math"]["randomseed"](static_cast<unsigned>(std::time(nullptr)));
|
||||
|
@ -216,6 +216,37 @@ namespace LuaUtil
|
||||
util["normalizeAngle"] = &Misc::normalizeAngle;
|
||||
util["makeReadOnly"] = &makeReadOnly;
|
||||
|
||||
if (lua["bit32"] != sol::nil)
|
||||
{
|
||||
sol::table bit = lua["bit32"];
|
||||
util["bitOr"] = bit["bor"];
|
||||
util["bitAnd"] = bit["band"];
|
||||
util["bitXor"] = bit["bxor"];
|
||||
util["bitNot"] = bit["bnot"];
|
||||
}
|
||||
else
|
||||
{
|
||||
util["bitOr"] = [](unsigned a, sol::variadic_args va)
|
||||
{
|
||||
for (auto v : va)
|
||||
a |= v.as<unsigned>();
|
||||
return a;
|
||||
};
|
||||
util["bitAnd"] = [](unsigned a, sol::variadic_args va)
|
||||
{
|
||||
for (auto v : va)
|
||||
a &= v.as<unsigned>();
|
||||
return a;
|
||||
};
|
||||
util["bitXor"] = [](unsigned a, sol::variadic_args va)
|
||||
{
|
||||
for (auto v : va)
|
||||
a ^= v.as<unsigned>();
|
||||
return a;
|
||||
};
|
||||
util["bitNot"] = [](unsigned a) { return ~a; };
|
||||
}
|
||||
|
||||
return util;
|
||||
}
|
||||
|
||||
|
@ -2,29 +2,22 @@ if (NOT DEFINED OPENMW_RESOURCES_ROOT)
|
||||
return()
|
||||
endif()
|
||||
|
||||
# Copy resource files into the build directory
|
||||
set(SDIR ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
set(DDIRRELATIVE resources/vfs)
|
||||
copy_all_resource_files(${CMAKE_CURRENT_SOURCE_DIR} ${OPENMW_RESOURCES_ROOT} ${DDIRRELATIVE} "builtin.omwscripts")
|
||||
set(LUA_BUILTIN_FILES
|
||||
builtin.omwscripts
|
||||
|
||||
set(LUA_AUX_FILES
|
||||
openmw_aux/util.lua
|
||||
openmw_aux/time.lua
|
||||
openmw_aux/calendar.lua
|
||||
)
|
||||
|
||||
set(DDIRRELATIVE resources/vfs/openmw_aux)
|
||||
copy_all_resource_files(${CMAKE_CURRENT_SOURCE_DIR} ${OPENMW_RESOURCES_ROOT} ${DDIRRELATIVE} "${LUA_AUX_FILES}")
|
||||
|
||||
set(LUA_SCRIPTS_FILES
|
||||
scripts/omw/ai.lua
|
||||
scripts/omw/camera.lua
|
||||
scripts/omw/head_bobbing.lua
|
||||
scripts/omw/third_person.lua
|
||||
|
||||
i18n/Calendar/en.lua
|
||||
)
|
||||
|
||||
set(DDIRRELATIVE resources/vfs/scripts/omw)
|
||||
copy_all_resource_files(${CMAKE_CURRENT_SOURCE_DIR} ${OPENMW_RESOURCES_ROOT} ${DDIRRELATIVE} "${LUA_SCRIPTS_FILES}")
|
||||
foreach (f ${LUA_BUILTIN_FILES})
|
||||
copy_resource_file("${CMAKE_CURRENT_SOURCE_DIR}/${f}" "${OPENMW_RESOURCES_ROOT}" "resources/vfs/${f}")
|
||||
endforeach (f)
|
||||
|
||||
set(DDIRRELATIVE resources/vfs/i18n/Calendar)
|
||||
copy_all_resource_files(${CMAKE_CURRENT_SOURCE_DIR} ${OPENMW_RESOURCES_ROOT} ${DDIRRELATIVE} "i18n/Calendar/en.lua")
|
||||
|
@ -4,6 +4,7 @@ local input = require('openmw.input')
|
||||
local settings = require('openmw.settings')
|
||||
local util = require('openmw.util')
|
||||
local self = require('openmw.self')
|
||||
local nearby = require('openmw.nearby')
|
||||
|
||||
local Actor = require('openmw.types').Actor
|
||||
|
||||
@ -23,6 +24,8 @@ local noHeadBobbing = 0
|
||||
local noZoom = 0
|
||||
|
||||
local function init()
|
||||
camera.setCollisionType(util.bitAnd(nearby.COLLISION_TYPE.Default, util.bitNot(nearby.COLLISION_TYPE.Actor)))
|
||||
camera.setFieldOfView(camera.getBaseFieldOfView())
|
||||
camera.allowCharacterDeferredRotation(settings._getBoolFromSettingsCfg('Camera', 'deferred preview rotation'))
|
||||
if camera.getMode() == MODE.FirstPerson then
|
||||
primaryMode = MODE.FirstPerson
|
||||
|
@ -78,6 +78,8 @@ local function updateState()
|
||||
state = STATE.Swimming
|
||||
elseif oldState == STATE.Combat or oldState == STATE.Swimming then
|
||||
state = defaultShoulder
|
||||
elseif not state then
|
||||
state = defaultShoulder
|
||||
end
|
||||
if autoSwitchShoulder and (mode == MODE.ThirdPerson or state ~= oldState or noThirdPersonLastFrame)
|
||||
and (state == STATE.LeftShoulder or state == STATE.RightShoulder) then
|
||||
|
@ -166,6 +166,36 @@
|
||||
-- Make instant the current transition of camera focal point and the current deferred rotation (see `allowCharacterDeferredRotation`).
|
||||
-- @function [parent=#camera] instantTransition
|
||||
|
||||
--- Get current camera collision type (see @{openmw.nearby#COLLISION_TYPE}).
|
||||
-- @function [parent=#camera] getCollisionType
|
||||
-- @return #number
|
||||
|
||||
--- Set camera collision type (see @{openmw.nearby#COLLISION_TYPE}).
|
||||
-- @function [parent=#camera] setCollisionType
|
||||
-- @param #number collisionType
|
||||
|
||||
--- Return base field of view vertical angle in radians
|
||||
-- @function [parent=#camera] getBaseFieldOfView
|
||||
-- @return #number
|
||||
|
||||
--- Return current field of view vertical angle in radians
|
||||
-- @function [parent=#camera] getFieldOfView
|
||||
-- @return #number
|
||||
|
||||
--- Set field of view
|
||||
-- @function [parent=#camera] setFieldOfView
|
||||
-- @param #number fov Field of view vertical angle in radians
|
||||
|
||||
--- Get world to local transform for the camera.
|
||||
-- @function [parent=#camera] getViewTransform
|
||||
-- @return openmw.util#Transform
|
||||
|
||||
--- Get vector from the camera to the world for the given point in viewport.
|
||||
-- (0, 0) is the top left corner of the screen.
|
||||
-- @function [parent=#camera] viewportToWorldVector
|
||||
-- @param openmw.util#Vector2 normalizedScreenPos
|
||||
-- @return openmw.util#Vector3
|
||||
|
||||
|
||||
return nil
|
||||
|
||||
|
@ -108,12 +108,14 @@
|
||||
|
||||
---
|
||||
-- Get equipment.
|
||||
-- Returns a table `slot` -> @{openmw.core#GameObject} of currently equipped items.
|
||||
-- Has two overloads:
|
||||
-- 1) With single argument: returns a table `slot` -> @{openmw.core#GameObject} of currently equipped items.
|
||||
-- See @{#EQUIPMENT_SLOT}. Returns empty table if the actor doesn't have
|
||||
-- equipment slots.
|
||||
-- equipment slots.
|
||||
-- 2) With two arguments: returns an item equipped to the given slot.
|
||||
-- @function [parent=#Actor] equipment
|
||||
-- @param openmw.core#GameObject actor
|
||||
-- @return #map<#number,openmw.core#GameObject>
|
||||
-- @param #number slot (optional argument)
|
||||
|
||||
---
|
||||
-- Set equipment.
|
||||
|
@ -25,6 +25,33 @@
|
||||
-- @param #table table Any table.
|
||||
-- @return #table The same table wrapped with read only userdata.
|
||||
|
||||
---
|
||||
-- Bitwise And (supports any number of arguments).
|
||||
-- @function [parent=#util] bitAnd
|
||||
-- @param #number A First argument (integer).
|
||||
-- @param #number B Second argument (integer).
|
||||
-- @return #number Bitwise And of A and B.
|
||||
|
||||
---
|
||||
-- Bitwise Or (supports any number of arguments).
|
||||
-- @function [parent=#util] bitOr
|
||||
-- @param #number A First argument (integer).
|
||||
-- @param #number B Second argument (integer).
|
||||
-- @return #number Bitwise Or of A and B.
|
||||
|
||||
---
|
||||
-- Bitwise Xor (supports any number of arguments).
|
||||
-- @function [parent=#util] bitXor
|
||||
-- @param #number A First argument (integer).
|
||||
-- @param #number B Second argument (integer).
|
||||
-- @return #number Bitwise Xor of A and B.
|
||||
|
||||
---
|
||||
-- Bitwise inversion.
|
||||
-- @function [parent=#util] bitNot
|
||||
-- @param #number A Argument (integer).
|
||||
-- @return #number Bitwise Not of A.
|
||||
|
||||
|
||||
---
|
||||
-- Immutable 2D vector
|
||||
|
Loading…
x
Reference in New Issue
Block a user