Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
PrismarineJS
GitHub Repository: PrismarineJS/mineflayer
Path: blob/master/lib/plugins/physics.js
9427 views
1
const { Vec3 } = require('vec3')
2
const assert = require('assert')
3
const math = require('../math')
4
const conv = require('../conversions')
5
const { performance } = require('perf_hooks')
6
const { createDoneTask, createTask } = require('../promise_utils')
7
8
const { Physics, PlayerState } = require('prismarine-physics')
9
10
module.exports = inject
11
12
const PI = Math.PI
13
const PI_2 = Math.PI * 2
14
const PHYSICS_INTERVAL_MS = 50
15
const PHYSICS_TIMESTEP = PHYSICS_INTERVAL_MS / 1000 // 0.05
16
17
function inject (bot, { physicsEnabled, maxCatchupTicks }) {
18
const PHYSICS_CATCHUP_TICKS = maxCatchupTicks ?? 4
19
const world = { getBlock: (pos) => { return bot.blockAt(pos, false) } }
20
const physics = Physics(bot.registry, world)
21
22
const positionUpdateSentEveryTick = bot.supportFeature('positionUpdateSentEveryTick')
23
24
bot.jumpQueued = false
25
bot.jumpTicks = 0 // autojump cooldown
26
27
const controlState = {
28
forward: false,
29
back: false,
30
left: false,
31
right: false,
32
jump: false,
33
sprint: false,
34
sneak: false
35
}
36
let lastSentYaw = null
37
let lastSentPitch = null
38
let doPhysicsTimer = null
39
let lastPhysicsFrameTime = null
40
let shouldUsePhysics = false
41
bot.physicsEnabled = physicsEnabled ?? true
42
let deadTicks = 21
43
44
const lastSent = {
45
x: 0,
46
y: 0,
47
z: 0,
48
yaw: 0,
49
pitch: 0,
50
onGround: false,
51
time: 0,
52
flags: { onGround: false, hasHorizontalCollision: false }
53
}
54
55
// This function should be executed each tick (every 0.05 seconds)
56
// How it works: https://gafferongames.com/post/fix_your_timestep/
57
58
// WARNING: THIS IS NOT ACCURATE ON WINDOWS (15.6 Timer Resolution)
59
// use WSL or switch to Linux
60
// see: https://discord.com/channels/413438066984747026/519952494768685086/901948718255833158
61
let timeAccumulator = 0
62
let catchupTicks = 0
63
function doPhysics () {
64
const now = performance.now()
65
const deltaSeconds = (now - lastPhysicsFrameTime) / 1000
66
lastPhysicsFrameTime = now
67
68
timeAccumulator += deltaSeconds
69
catchupTicks = 0
70
while (timeAccumulator >= PHYSICS_TIMESTEP) {
71
tickPhysics(now)
72
timeAccumulator -= PHYSICS_TIMESTEP
73
catchupTicks++
74
if (catchupTicks >= PHYSICS_CATCHUP_TICKS) break
75
}
76
}
77
78
function tickPhysics (now) {
79
if (!bot.entity?.position || !Number.isFinite(bot.entity.position.x)) return // entity not ready
80
if (bot.blockAt(bot.entity.position) == null) return // check if chunk is unloaded
81
if (bot.physicsEnabled && shouldUsePhysics) {
82
physics.simulatePlayer(new PlayerState(bot, controlState), world).apply(bot)
83
bot.emit('physicsTick')
84
bot.emit('physicTick') // Deprecated, only exists to support old plugins. May be removed in the future
85
}
86
if (shouldUsePhysics) {
87
updatePosition(now)
88
}
89
}
90
91
// remove this when 'physicTick' is removed
92
bot.on('newListener', (name) => {
93
if (name === 'physicTick') console.warn('Mineflayer detected that you are using a deprecated event (physicTick)! Please use this event (physicsTick) instead.')
94
})
95
96
function cleanup () {
97
clearInterval(doPhysicsTimer)
98
doPhysicsTimer = null
99
}
100
101
function sendPacketPosition (position, onGround) {
102
// sends data, no logic
103
if (!Number.isFinite(position.x) || !Number.isFinite(position.y) || !Number.isFinite(position.z)) return
104
const oldPos = new Vec3(lastSent.x, lastSent.y, lastSent.z)
105
lastSent.x = position.x
106
lastSent.y = position.y
107
lastSent.z = position.z
108
lastSent.onGround = onGround
109
lastSent.flags = { onGround, hasHorizontalCollision: undefined } // 1.21.3+
110
bot._client.write('position', lastSent)
111
bot.emit('move', oldPos)
112
}
113
114
function sendPacketLook (yaw, pitch, onGround) {
115
// sends data, no logic
116
const oldPos = new Vec3(lastSent.x, lastSent.y, lastSent.z)
117
lastSent.yaw = yaw
118
lastSent.pitch = pitch
119
lastSent.onGround = onGround
120
lastSent.flags = { onGround, hasHorizontalCollision: undefined } // 1.21.3+
121
bot._client.write('look', lastSent)
122
bot.emit('move', oldPos)
123
}
124
125
function sendPacketPositionAndLook (position, yaw, pitch, onGround) {
126
// sends data, no logic
127
if (!Number.isFinite(position.x) || !Number.isFinite(position.y) || !Number.isFinite(position.z)) return
128
const oldPos = new Vec3(lastSent.x, lastSent.y, lastSent.z)
129
lastSent.x = position.x
130
lastSent.y = position.y
131
lastSent.z = position.z
132
lastSent.yaw = yaw
133
lastSent.pitch = pitch
134
lastSent.onGround = onGround
135
lastSent.flags = { onGround, hasHorizontalCollision: undefined } // 1.21.3+
136
bot._client.write('position_look', lastSent)
137
bot.emit('move', oldPos)
138
}
139
140
function deltaYaw (yaw1, yaw2) {
141
let dYaw = (yaw1 - yaw2) % PI_2
142
if (dYaw < -PI) dYaw += PI_2
143
else if (dYaw > PI) dYaw -= PI_2
144
145
return dYaw
146
}
147
148
// returns false if bot should send position packets
149
function isEntityRemoved () {
150
if (bot.isAlive === true) deadTicks = 0
151
if (bot.isAlive === false && deadTicks <= 20) deadTicks++
152
if (deadTicks >= 20) return true
153
return false
154
}
155
156
function updatePosition (now) {
157
// Only send updates for 20 ticks after death
158
if (isEntityRemoved()) return
159
// Don't send position with invalid coordinates (NaN after death)
160
if (!Number.isFinite(bot.entity.position.x)) return
161
162
// Increment the yaw in baby steps so that notchian clients (not the server) can keep up.
163
const dYaw = deltaYaw(bot.entity.yaw, lastSentYaw)
164
const dPitch = bot.entity.pitch - (lastSentPitch || 0)
165
166
// Vanilla doesn't clamp yaw, so we don't want to do it either
167
const maxDeltaYaw = PHYSICS_TIMESTEP * physics.yawSpeed
168
const maxDeltaPitch = PHYSICS_TIMESTEP * physics.pitchSpeed
169
lastSentYaw += math.clamp(-maxDeltaYaw, dYaw, maxDeltaYaw)
170
lastSentPitch += math.clamp(-maxDeltaPitch, dPitch, maxDeltaPitch)
171
172
const yaw = Math.fround(conv.toNotchianYaw(lastSentYaw))
173
const pitch = Math.fround(conv.toNotchianPitch(lastSentPitch))
174
const position = bot.entity.position
175
const onGround = bot.entity.onGround
176
177
// Only send a position update if necessary, select the appropriate packet
178
const positionUpdated = lastSent.x !== position.x || lastSent.y !== position.y || lastSent.z !== position.z ||
179
// Send a position update every second, even if no other update was made
180
// This function rounds to the nearest 50ms (or PHYSICS_INTERVAL_MS) and checks if a second has passed.
181
(Math.round((now - lastSent.time) / PHYSICS_INTERVAL_MS) * PHYSICS_INTERVAL_MS) >= 1000
182
const lookUpdated = lastSent.yaw !== yaw || lastSent.pitch !== pitch
183
184
if (positionUpdated && lookUpdated) {
185
sendPacketPositionAndLook(position, yaw, pitch, onGround)
186
lastSent.time = now // only reset if positionUpdated is true
187
} else if (positionUpdated) {
188
sendPacketPosition(position, onGround)
189
lastSent.time = now // only reset if positionUpdated is true
190
} else if (lookUpdated) {
191
sendPacketLook(yaw, pitch, onGround)
192
} else if (positionUpdateSentEveryTick || onGround !== lastSent.onGround) {
193
// For versions < 1.12, one player packet should be sent every tick
194
// for the server to update health correctly
195
// For versions >= 1.12, onGround !== lastSent.onGround should be used, but it doesn't ever trigger outside of login
196
bot._client.write('flying', {
197
onGround: bot.entity.onGround,
198
flags: { onGround: bot.entity.onGround, hasHorizontalCollision: undefined } // 1.21.3+
199
})
200
}
201
202
lastSent.onGround = bot.entity.onGround // onGround is always set
203
}
204
205
bot.physics = physics
206
207
function getEffectLevel (mcData, effectName, effects) {
208
const effectDescriptor = mcData.effectsByName[effectName]
209
if (!effectDescriptor) {
210
return 0
211
}
212
const effectInfo = effects[effectDescriptor.id]
213
if (!effectInfo) {
214
return 0
215
}
216
return effectInfo.amplifier + 1
217
}
218
219
bot.elytraFly = async () => {
220
if (bot.entity.elytraFlying) {
221
throw new Error('Already elytra flying')
222
} else if (bot.entity.onGround) {
223
throw new Error('Unable to fly from ground')
224
} else if (bot.entity.isInWater) {
225
throw new Error('Unable to elytra fly while in water')
226
}
227
228
const mcData = require('minecraft-data')(bot.version)
229
if (getEffectLevel(mcData, 'Levitation', bot.entity.effects) > 0) {
230
throw new Error('Unable to elytra fly with levitation effect')
231
}
232
233
const torsoSlot = bot.getEquipmentDestSlot('torso')
234
const item = bot.inventory.slots[torsoSlot]
235
if (item == null || item.name !== 'elytra') {
236
throw new Error('Elytra must be equip to start flying')
237
}
238
bot._client.write('entity_action', {
239
entityId: bot.entity.id,
240
actionId: bot.supportFeature('entityActionUsesStringMapper') ? 'start_elytra_flying' : 8,
241
jumpBoost: 0
242
})
243
}
244
245
bot.setControlState = (control, state) => {
246
assert.ok(control in controlState, `invalid control: ${control}`)
247
assert.ok(typeof state === 'boolean', `invalid state: ${state}`)
248
if (controlState[control] === state) return
249
controlState[control] = state
250
if (control === 'jump' && state) {
251
bot.jumpQueued = true
252
} else if (control === 'sprint') {
253
bot._client.write('entity_action', {
254
entityId: bot.entity.id,
255
actionId: bot.supportFeature('entityActionUsesStringMapper')
256
? (state ? 'start_sprinting' : 'stop_sprinting')
257
: (state ? 3 : 4),
258
jumpBoost: 0
259
})
260
} else if (control === 'sneak') {
261
if (bot.supportFeature('newPlayerInputPacket')) {
262
// In 1.21.6+, sneak is handled via player_input packet
263
bot._client.write('player_input', {
264
inputs: {
265
shift: state
266
}
267
})
268
} else {
269
// Legacy entity_action approach for older versions
270
bot._client.write('entity_action', {
271
entityId: bot.entity.id,
272
actionId: state ? 0 : 1,
273
jumpBoost: 0
274
})
275
}
276
}
277
}
278
279
bot.getControlState = (control) => {
280
assert.ok(control in controlState, `invalid control: ${control}`)
281
return controlState[control]
282
}
283
284
bot.clearControlStates = () => {
285
for (const control in controlState) {
286
bot.setControlState(control, false)
287
}
288
}
289
290
bot.controlState = {}
291
292
for (const control of Object.keys(controlState)) {
293
Object.defineProperty(bot.controlState, control, {
294
get () {
295
return controlState[control]
296
},
297
set (state) {
298
bot.setControlState(control, state)
299
return state
300
}
301
})
302
}
303
304
let lookingTask = createDoneTask()
305
306
bot.on('move', () => {
307
if (!lookingTask.done && Math.abs(deltaYaw(bot.entity.yaw, lastSentYaw)) < 0.001) {
308
lookingTask.finish()
309
}
310
})
311
312
bot._client.on('explosion', explosion => {
313
// TODO: emit an explosion event with more info
314
if (bot.physicsEnabled && bot.game.gameMode !== 'creative') {
315
if (explosion.playerKnockback) { // 1.21.3+
316
// Fixes issue #3635
317
bot.entity.velocity.x += explosion.playerKnockback.x
318
bot.entity.velocity.y += explosion.playerKnockback.y
319
bot.entity.velocity.z += explosion.playerKnockback.z
320
}
321
if ('playerMotionX' in explosion) {
322
bot.entity.velocity.x += explosion.playerMotionX
323
bot.entity.velocity.y += explosion.playerMotionY
324
bot.entity.velocity.z += explosion.playerMotionZ
325
}
326
}
327
})
328
329
bot.look = async (yaw, pitch, force) => {
330
if (!lookingTask.done) {
331
lookingTask.finish() // finish the previous one
332
}
333
lookingTask = createTask()
334
335
// this is done to bypass certain anticheat checks that detect the player's sensitivity
336
// by calculating the gcd of how much they move the mouse each tick
337
const sensitivity = conv.fromNotchianPitch(0.15) // this is equal to 100% sensitivity in vanilla
338
const yawChange = Math.round((yaw - bot.entity.yaw) / sensitivity) * sensitivity
339
const pitchChange = Math.round((pitch - bot.entity.pitch) / sensitivity) * sensitivity
340
341
if (yawChange === 0 && pitchChange === 0) {
342
return
343
}
344
345
bot.entity.yaw += yawChange
346
bot.entity.pitch += pitchChange
347
348
if (force) {
349
lastSentYaw = yaw
350
lastSentPitch = pitch
351
return
352
}
353
354
await lookingTask.promise
355
}
356
357
bot.lookAt = async (point, force) => {
358
const delta = point.minus(bot.entity.position.offset(0, bot.entity.eyeHeight, 0))
359
const yaw = Math.atan2(-delta.x, -delta.z)
360
const groundDistance = Math.sqrt(delta.x * delta.x + delta.z * delta.z)
361
const pitch = Math.atan2(delta.y, groundDistance)
362
await bot.look(yaw, pitch, force)
363
}
364
365
// 1.21.3+
366
bot._client.on('player_rotation', (packet) => {
367
bot.entity.yaw = conv.fromNotchianYaw(packet.yaw)
368
bot.entity.pitch = conv.fromNotchianPitch(packet.pitch)
369
})
370
371
// player position and look (clientbound)
372
bot._client.on('position', (packet) => {
373
// Is this necessary? Feels like it might wrongly overwrite hitbox size sometimes
374
// e.g. when crouching/crawling/swimming. Can someone confirm?
375
bot.entity.height = 1.8
376
377
const vel = bot.entity.velocity
378
const pos = bot.entity.position
379
let newYaw, newPitch
380
381
// Note: 1.20.5+ uses a bitflags object, older versions use a bitmask number
382
if (typeof packet.flags === 'object') {
383
// Modern path with bitflags object
384
// Velocity is only set to 0 if the flag is not set, otherwise keep current velocity
385
vel.set(
386
packet.flags.x ? vel.x : 0,
387
packet.flags.y ? vel.y : 0,
388
packet.flags.z ? vel.z : 0
389
)
390
// If flag is set, then the corresponding value is relative, else it is absolute
391
pos.set(
392
packet.flags.x ? (pos.x + packet.x) : packet.x,
393
packet.flags.y ? (pos.y + packet.y) : packet.y,
394
packet.flags.z ? (pos.z + packet.z) : packet.z
395
)
396
newYaw = (packet.flags.yaw ? conv.toNotchianYaw(bot.entity.yaw) : 0) + packet.yaw
397
newPitch = (packet.flags.pitch ? conv.toNotchianPitch(bot.entity.pitch) : 0) + packet.pitch
398
} else {
399
// Legacy path with bitmask number
400
// Velocity is only set to 0 if the flag is not set, otherwise keep current velocity
401
vel.set(
402
packet.flags & 1 ? vel.x : 0,
403
packet.flags & 2 ? vel.y : 0,
404
packet.flags & 4 ? vel.z : 0
405
)
406
// If flag is set, then the corresponding value is relative, else it is absolute
407
pos.set(
408
packet.flags & 1 ? (pos.x + packet.x) : packet.x,
409
packet.flags & 2 ? (pos.y + packet.y) : packet.y,
410
packet.flags & 4 ? (pos.z + packet.z) : packet.z
411
)
412
newYaw = (packet.flags & 8 ? conv.toNotchianYaw(bot.entity.yaw) : 0) + packet.yaw
413
newPitch = (packet.flags & 16 ? conv.toNotchianPitch(bot.entity.pitch) : 0) + packet.pitch
414
}
415
416
bot.entity.yaw = conv.fromNotchianYaw(newYaw)
417
bot.entity.pitch = conv.fromNotchianPitch(newPitch)
418
bot.entity.onGround = false
419
420
if (bot.supportFeature('teleportUsesOwnPacket')) {
421
bot._client.write('teleport_confirm', { teleportId: packet.teleportId })
422
}
423
424
// After death/respawn, delay the forced position_look response.
425
// Sending it immediately causes "Invalid move player packet" kicks
426
// on older servers, but the server needs it to complete the respawn.
427
if (respawnTimer > 0 && Date.now() - respawnTimer < 2000) {
428
respawnTimer = 0 // only delay once
429
const delayedPos = pos.clone()
430
const delayedYaw = newYaw
431
const delayedPitch = newPitch
432
const delayedOnGround = bot.entity.onGround
433
setTimeout(() => {
434
sendPacketPositionAndLook(delayedPos, delayedYaw, delayedPitch, delayedOnGround)
435
shouldUsePhysics = true
436
bot.jumpTicks = 0
437
lastSentYaw = bot.entity.yaw
438
lastSentPitch = bot.entity.pitch
439
bot.emit('forcedMove')
440
}, 1500)
441
return
442
}
443
444
sendPacketPositionAndLook(pos, newYaw, newPitch, bot.entity.onGround)
445
446
shouldUsePhysics = true
447
bot.jumpTicks = 0
448
lastSentYaw = bot.entity.yaw
449
lastSentPitch = bot.entity.pitch
450
451
bot.emit('forcedMove')
452
})
453
454
bot.waitForTicks = async function (ticks) {
455
if (ticks <= 0) return
456
await new Promise((resolve, reject) => {
457
// Assuming 20 ticks per second, add extra time for lag
458
const timeout = setTimeout(() => {
459
bot.removeListener('physicsTick', tickListener)
460
reject(new Error(`Timeout waiting for ${ticks} ticks after ${(ticks * 50 + 5000)}ms`))
461
}, ticks * 50 + 5000) // 50ms per tick + 5s buffer
462
463
const tickListener = () => {
464
ticks--
465
if (ticks === 0) {
466
clearTimeout(timeout)
467
bot.removeListener('physicsTick', tickListener)
468
resolve()
469
}
470
}
471
472
bot.on('physicsTick', tickListener)
473
})
474
}
475
476
let respawnTimer = 0
477
bot.on('mount', () => { shouldUsePhysics = false })
478
bot.on('death', () => {
479
shouldUsePhysics = false
480
respawnTimer = Date.now()
481
})
482
bot.on('respawn', () => { shouldUsePhysics = false })
483
bot.on('login', () => {
484
shouldUsePhysics = false
485
if (doPhysicsTimer === null) {
486
lastPhysicsFrameTime = performance.now()
487
doPhysicsTimer = setInterval(doPhysics, PHYSICS_INTERVAL_MS)
488
}
489
})
490
bot.on('end', cleanup)
491
}
492
493