Skip to content

Commit

Permalink
Mineflayer physics refactor (#2492)
Browse files Browse the repository at this point in the history
* add tests that fail

* fixed a test

* fix: set velocity to 0 only if it is absolute

* refactor: precise physics timer & limiter

* refactor: position update logic

* fix: minor issues

* add: death ticking

chunk check was not working properly

* change packet object

* unhardcode deltaSeconds limiter

* add deprecated notice for physicTick

* add physics chunk check

* Karang changes

added back lastSent optimization
moved depercated event to bot.on('newListener'
removed tick limiter for now

* Karang changes part 2

* Karang changes part 3

* remove speedy setInterval

* remove 50ms test

* cache supportFeature check (thanks Ic3Tank)

* remove tests that rely on precise timing

* lint

* change console.log to warn
remove debug comment

* add configurable "maxCatchupTicks"
rename isDead to sendPositionPacketInDeath
  • Loading branch information
U5B committed Aug 17, 2023
1 parent a16d270 commit d0eb3a1
Show file tree
Hide file tree
Showing 3 changed files with 216 additions and 32 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ versions/
server_jars/
test/server_*
.vscode
.DS_Store
.DS_Store
launcher_accounts.json
104 changes: 73 additions & 31 deletions lib/plugins/physics.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ module.exports = inject
const PI = Math.PI
const PI_2 = Math.PI * 2
const PHYSICS_INTERVAL_MS = 50
const PHYSICS_TIMESTEP = PHYSICS_INTERVAL_MS / 1000
const PHYSICS_TIMESTEP = PHYSICS_INTERVAL_MS / 1000 // 0.05

function inject (bot, { physicsEnabled }) {
function inject (bot, { physicsEnabled, maxCatchupTicks }) {
const PHYSICS_CATCHUP_TICKS = maxCatchupTicks ?? 4
const world = { getBlock: (pos) => { return bot.blockAt(pos, false) } }
const physics = Physics(bot.registry, world)

Expand All @@ -38,6 +39,7 @@ function inject (bot, { physicsEnabled }) {
let lastPhysicsFrameTime = null
let shouldUsePhysics = false
bot.physicsEnabled = physicsEnabled ?? true
let deadTicks = 21

const lastSent = {
x: 0,
Expand All @@ -51,25 +53,44 @@ function inject (bot, { physicsEnabled }) {

// This function should be executed each tick (every 0.05 seconds)
// How it works: https://gafferongames.com/post/fix_your_timestep/

// WARNING: THIS IS NOT ACCURATE ON WINDOWS (15.6 Timer Resolution)
// use WSL or switch to Linux
// see: https://discord.com/channels/413438066984747026/519952494768685086/901948718255833158
let timeAccumulator = 0
let catchupTicks = 0
function doPhysics () {
const now = performance.now()
const deltaSeconds = (now - lastPhysicsFrameTime) / 1000
lastPhysicsFrameTime = now

timeAccumulator += deltaSeconds

catchupTicks = 0
while (timeAccumulator >= PHYSICS_TIMESTEP) {
if (bot.physicsEnabled && shouldUsePhysics) {
physics.simulatePlayer(new PlayerState(bot, controlState), world).apply(bot)
bot.emit('physicsTick')
bot.emit('physicTick') // Deprecated, only exists to support old plugins. May be removed in the future
}
updatePosition(PHYSICS_TIMESTEP)
tickPhysics(now)
timeAccumulator -= PHYSICS_TIMESTEP
catchupTicks++
if (catchupTicks >= PHYSICS_CATCHUP_TICKS) break
}
}

function tickPhysics (now) {
if (bot.blockAt(bot.entity.position) == null) return // check if chunk is unloaded
if (bot.physicsEnabled && shouldUsePhysics) {
physics.simulatePlayer(new PlayerState(bot, controlState), world).apply(bot)
bot.emit('physicsTick')
bot.emit('physicTick') // Deprecated, only exists to support old plugins. May be removed in the future
}
if (shouldUsePhysics) {
updatePosition(now)
}
}

// remove this when 'physicTick' is removed
bot.on('newListener', (name) => {
if (name === 'physicTick') console.warn('Mineflayer detected that you are using a deprecated event (physicTick)! Please use this event (physicsTick) instead.')
})

function cleanup () {
clearInterval(doPhysicsTimer)
doPhysicsTimer = null
Expand Down Expand Up @@ -117,17 +138,25 @@ function inject (bot, { physicsEnabled }) {
return dYaw
}

function updatePosition (dt) {
// If you're dead, you're probably on the ground though ...
if (!bot.isAlive) bot.entity.onGround = true
// returns false if packet should be sent, true if not
function sendPositionPacketInDeath () {
if (bot.isAlive === true) deadTicks = 0
if (bot.isAlive === false && deadTicks <= 20) deadTicks++
if (deadTicks >= 20) return true
return false
}

function updatePosition (now) {
// Only send updates for 20 ticks after death
if (sendPositionPacketInDeath()) return

// Increment the yaw in baby steps so that notchian clients (not the server) can keep up.
const dYaw = deltaYaw(bot.entity.yaw, lastSentYaw)
const dPitch = bot.entity.pitch - (lastSentPitch || 0)

// Vanilla doesn't clamp yaw, so we don't want to do it either
const maxDeltaYaw = dt * physics.yawSpeed
const maxDeltaPitch = dt * physics.pitchSpeed
const maxDeltaYaw = PHYSICS_TIMESTEP * physics.yawSpeed
const maxDeltaPitch = PHYSICS_TIMESTEP * physics.pitchSpeed
lastSentYaw += math.clamp(-maxDeltaYaw, dYaw, maxDeltaYaw)
lastSentPitch += math.clamp(-maxDeltaPitch, dPitch, maxDeltaPitch)

Expand All @@ -137,24 +166,28 @@ function inject (bot, { physicsEnabled }) {
const onGround = bot.entity.onGround

// Only send a position update if necessary, select the appropriate packet
const positionUpdated = lastSent.x !== position.x || lastSent.y !== position.y || lastSent.z !== position.z
const positionUpdated = lastSent.x !== position.x || lastSent.y !== position.y || lastSent.z !== position.z ||
// Send a position update every second, even if no other update was made
// This function rounds to the nearest 50ms (or PHYSICS_INTERVAL_MS) and checks if a second has passed.
(Math.round((now - lastSent.time) / PHYSICS_INTERVAL_MS) * PHYSICS_INTERVAL_MS) >= 1000
const lookUpdated = lastSent.yaw !== yaw || lastSent.pitch !== pitch

if (positionUpdated && lookUpdated && bot.isAlive) {
if (positionUpdated && lookUpdated) {
sendPacketPositionAndLook(position, yaw, pitch, onGround)
} else if (positionUpdated && bot.isAlive) {
lastSent.time = now // only reset if positionUpdated is true
} else if (positionUpdated) {
sendPacketPosition(position, onGround)
} else if (lookUpdated && bot.isAlive) {
lastSent.time = now // only reset if positionUpdated is true
} else if (lookUpdated) {
sendPacketLook(yaw, pitch, onGround)
} else if (performance.now() - lastSent.time >= 1000) {
// Send a position packet every second, even if no update was made
sendPacketPosition(position, onGround)
lastSent.time = performance.now()
} else if (positionUpdateSentEveryTick && bot.isAlive) {
} else if (positionUpdateSentEveryTick || onGround !== lastSent.onGround) {
// For versions < 1.12, one player packet should be sent every tick
// for the server to update health correctly
// For versions >= 1.12, onGround !== lastSent.onGround should be used, but it doesn't ever trigger outside of login
bot._client.write('flying', { onGround: bot.entity.onGround })
}

lastSent.onGround = bot.entity.onGround // onGround is always set
}

bot.physics = physics
Expand Down Expand Up @@ -262,7 +295,14 @@ function inject (bot, { physicsEnabled }) {
// player position and look (clientbound)
bot._client.on('position', (packet) => {
bot.entity.height = 1.62
bot.entity.velocity.set(0, 0, 0)

// Velocity is only set to 0 if the flag is not set, otherwise keep current velocity
const vel = bot.entity.velocity
vel.set(
packet.flags & 1 ? vel.x : 0,
packet.flags & 2 ? vel.y : 0,
packet.flags & 4 ? vel.z : 0
)

// If flag is set, then the corresponding value is relative, else it is absolute
const pos = bot.entity.position
Expand All @@ -280,19 +320,14 @@ function inject (bot, { physicsEnabled }) {

if (bot.supportFeature('teleportUsesOwnPacket')) {
bot._client.write('teleport_confirm', { teleportId: packet.teleportId })
// Force send an extra packet to be like vanilla client
sendPacketPositionAndLook(pos, newYaw, newPitch, bot.entity.onGround)
}
sendPacketPositionAndLook(pos, newYaw, newPitch, bot.entity.onGround)

shouldUsePhysics = true
bot.entity.timeSinceOnGround = 0
bot.jumpTicks = 0
lastSentYaw = bot.entity.yaw
lastSentPitch = bot.entity.pitch

if (doPhysicsTimer === null) {
lastPhysicsFrameTime = performance.now()
doPhysicsTimer = setInterval(doPhysics, PHYSICS_INTERVAL_MS)
}
bot.emit('forcedMove')
})

Expand All @@ -313,5 +348,12 @@ function inject (bot, { physicsEnabled }) {

bot.on('mount', () => { shouldUsePhysics = false })
bot.on('respawn', () => { shouldUsePhysics = false })
bot.on('login', () => {
shouldUsePhysics = false
if (doPhysicsTimer === null) {
lastPhysicsFrameTime = performance.now()
doPhysicsTimer = setInterval(doPhysics, PHYSICS_INTERVAL_MS)
}
})
bot.on('end', cleanup)
}
141 changes: 141 additions & 0 deletions test/internalTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const mineflayer = require('../')
const vec3 = require('vec3')
const mc = require('minecraft-protocol')
const assert = require('assert')
const { sleep } = require('../lib/promise_utils')

for (const supportedVersion of mineflayer.testedVersions) {
const registry = require('prismarine-registry')(supportedVersion)
Expand Down Expand Up @@ -224,7 +225,147 @@ for (const supportedVersion of mineflayer.testedVersions) {

describe('physics', () => {
const pos = vec3(1, 65, 1)
const pos2 = vec3(2, 65, 1)
const goldId = 41
it('no physics if there is no chunk', (done) => {
let fail = 0
const basePosition = {
x: 1.5,
y: 66,
z: 1.5,
pitch: 0,
yaw: 0,
flags: 0,
teleportId: 0
}
server.on('login', async (client) => {
await client.write('login', bot.test.generateLoginPacket())
await client.write('position', basePosition)
client.on('packet', (data, meta) => {
const packetName = meta.name
switch (packetName) {
case 'position':
fail++
break
case 'position_look':
fail++
break
case 'look':
fail++
break
}
if (fail > 1) assert.fail('position packet sent')
})
await sleep(2000)
done()
})
})
it('absolute position & relative position (velocity)', (done) => {
server.on('login', async (client) => {
await client.write('login', bot.test.generateLoginPacket())
const chunk = await bot.test.buildChunk()

await chunk.setBlockType(pos, goldId)
await chunk.setBlockType(pos2, goldId)
await client.write('map_chunk', generateChunkPacket(chunk))
let check = true
let absolute = true
const basePosition = {
x: 1.5,
y: 66,
z: 1.5,
pitch: 0,
yaw: 0,
flags: 0,
teleportId: 0
}
client.on('packet', (data, meta) => {
const packetName = meta.name
switch (packetName) {
case 'teleport_confirm': {
assert.ok(basePosition.teleportId === data.teleportId)
break
}
case 'position_look': {
if (!check) return
if (absolute) {
assert.ok(bot.entity.velocity.y === 0)
} else {
assert.ok(bot.entity.velocity.y !== 0)
}
assert.ok(basePosition.x === data.x)
assert.ok(basePosition.y === data.y)
assert.ok(basePosition.z === data.z)
assert.ok(basePosition.yaw === data.yaw)
assert.ok(basePosition.pitch === data.pitch)
check = false
break
}
default:
break
}
})
// Absolute Position Tests
// absolute position test
check = true
await client.write('position', basePosition)
await bot.waitForTicks(5)
// absolute position test 2
basePosition.x = 2.5
basePosition.teleportId = 1
await bot.waitForTicks(1)
check = true
await client.write('position', basePosition)
await bot.waitForTicks(2)
// absolute position test 3
basePosition.x = 1.5
basePosition.teleportId = 2
await bot.waitForTicks(1)
check = true
await client.write('position', basePosition)
await bot.waitForTicks(2)

// Relative Position Tests
const relativePosition = {
x: 1,
y: 0,
z: 0,
pitch: 0,
yaw: 0,
flags: 31,
teleportId: 3
}
absolute = false
// relative position test 1
basePosition.x = 2.5
basePosition.teleportId = 3
relativePosition.x = 1
relativePosition.teleportId = 3
await bot.waitForTicks(1)
check = true
await client.write('position', relativePosition)
await bot.waitForTicks(2)
// relative position test 2
basePosition.x = 1.5
basePosition.teleportId = 4
relativePosition.x = -1
relativePosition.teleportId = 4
await bot.waitForTicks(1)
check = true
await client.write('position', relativePosition)
await bot.waitForTicks(2)
// relative position test 3
basePosition.x = 2.5
basePosition.teleportId = 5
relativePosition.x = 1
relativePosition.teleportId = 5
await bot.waitForTicks(1)
check = true
await client.write('position', relativePosition)
await bot.waitForTicks(2)
done()
})
})
it('gravity + land on solid block + jump', (done) => {
let y = 80
let landed = false
Expand Down

0 comments on commit d0eb3a1

Please sign in to comment.