spectra-physics 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +168 -0
- package/index.js +27 -0
- package/lib/engine/aabb.js +46 -0
- package/lib/engine/boat.js +137 -0
- package/lib/engine/collision.js +124 -0
- package/lib/engine/mth.js +47 -0
- package/lib/engine/physics.js +780 -0
- package/lib/engine/version.js +55 -0
- package/lib/engine/world.js +162 -0
- package/lib/plugin.js +574 -0
- package/package.json +51 -0
package/lib/plugin.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// SpectraPhysics: drop-in replacement for mineflayer's `physics` plugin that behaves like the vanilla client.
|
|
3
|
+
// * ticks like Minecraft#tick (held item sync -> entity tick -> movement packets -> tick end)
|
|
4
|
+
// * LocalPlayer#sendPosition semantics (stale last-sent values after teleports, 20-tick reminder, 2e-4 threshold)
|
|
5
|
+
// * version specific teleport answers, client-loaded gating (1.21.4+), tick_end (1.21.2+)
|
|
6
|
+
// * boat / minecart passenger ticking (paddle, rotation, input and vehicle move packets)
|
|
7
|
+
// * vanilla echoes: held-slot confirmation, arm swing from animation packets, configuration-phase brand
|
|
8
|
+
|
|
9
|
+
const { Vec3 } = require('vec3')
|
|
10
|
+
const { performance } = require('perf_hooks')
|
|
11
|
+
const conv = require('mineflayer/lib/conversions')
|
|
12
|
+
const { versionFlags } = require('./engine/version')
|
|
13
|
+
const WorldView = require('./engine/world')
|
|
14
|
+
const { PlayerPhysics } = require('./engine/physics')
|
|
15
|
+
const BoatPhysics = require('./engine/boat')
|
|
16
|
+
const { f32, wrapDegrees, clamp } = require('./engine/mth')
|
|
17
|
+
|
|
18
|
+
const TICK_MS = 50
|
|
19
|
+
const F_015 = f32(0.15)
|
|
20
|
+
const SPRINT_UUID = '662a6b8d-da3e-4c1c-8813-96ea6097278d'
|
|
21
|
+
const OP = { add_value: 0, add_multiplied_base: 1, add_multiplied_total: 2 }
|
|
22
|
+
|
|
23
|
+
function spectraPhysics (opts = {}) {
|
|
24
|
+
const turnPerTick = opts.turnSpeed ?? 35 // max degrees of "mouse" rotation per tick
|
|
25
|
+
const debug = typeof opts.debug === 'function' ? opts.debug : (opts.debug ? (...a) => console.log('[spectra]', ...a) : null)
|
|
26
|
+
const logPacket = debug ? (name, params) => debug('C->S', name, JSON.stringify(params)) : null
|
|
27
|
+
|
|
28
|
+
return function inject (bot) {
|
|
29
|
+
const client = bot._client
|
|
30
|
+
const registry = bot.registry
|
|
31
|
+
const V = versionFlags(registry)
|
|
32
|
+
const world = new WorldView(bot)
|
|
33
|
+
|
|
34
|
+
// ---------------- attribute / effect helpers ----------------
|
|
35
|
+
const normKey = (k) => String(k).replace(/^minecraft:/, '').replace(/^(generic|player)\./, '')
|
|
36
|
+
.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase()
|
|
37
|
+
function findAttr (name) {
|
|
38
|
+
const attrs = bot.entity?.attributes
|
|
39
|
+
if (!attrs) return null
|
|
40
|
+
for (const k of Object.keys(attrs)) if (normKey(k) === name) return attrs[k]
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
const isSprintMod = (m) => m.uuid === SPRINT_UUID || m.id === 'minecraft:sprinting' || m.id === 'sprinting'
|
|
44
|
+
function attrValue (prop, extraMods = []) {
|
|
45
|
+
const mods = prop.modifiers.filter(m => !isSprintMod(m)).concat(extraMods)
|
|
46
|
+
const op = (m) => typeof m.operation === 'number' ? m.operation : (OP[m.operation] ?? 0)
|
|
47
|
+
let x = prop.value
|
|
48
|
+
for (const m of mods) if (op(m) === 0) x += m.amount
|
|
49
|
+
let y = x
|
|
50
|
+
for (const m of mods) if (op(m) === 1) y += x * m.amount
|
|
51
|
+
for (const m of mods) if (op(m) === 2) y *= 1 + m.amount
|
|
52
|
+
return y
|
|
53
|
+
}
|
|
54
|
+
const effectIds = {}
|
|
55
|
+
for (const [name, e] of Object.entries(registry.effectsByName ?? {})) effectIds[name.toLowerCase()] = e.id
|
|
56
|
+
const env = {
|
|
57
|
+
attribute (name, def) {
|
|
58
|
+
const a = findAttr(name)
|
|
59
|
+
return a ? attrValue(a) : def
|
|
60
|
+
},
|
|
61
|
+
movementSpeed (sprinting) {
|
|
62
|
+
const a = findAttr('movement_speed')
|
|
63
|
+
const base = a ?? { value: 0.1, modifiers: [] }
|
|
64
|
+
return attrValue(base, sprinting ? [{ amount: 0.3, operation: 2 }] : [])
|
|
65
|
+
},
|
|
66
|
+
effect (name) {
|
|
67
|
+
const id = effectIds[name.replace(/_/g, '')]
|
|
68
|
+
if (id == null) return null
|
|
69
|
+
const e = bot.entity?.effects?.[id]
|
|
70
|
+
return e ? e.amplifier : null
|
|
71
|
+
},
|
|
72
|
+
food: () => bot.food ?? 20,
|
|
73
|
+
usingItem: () => !!bot.usingHeldItem,
|
|
74
|
+
gameMode: () => bot.game?.gameMode,
|
|
75
|
+
ultraWarm: () => /nether/.test(String(bot.game?.dimension ?? ''))
|
|
76
|
+
}
|
|
77
|
+
const phys = new PlayerPhysics(world, V, env)
|
|
78
|
+
|
|
79
|
+
// ---------------- raw writing / vanilla ordering fixes ----------------
|
|
80
|
+
const rawWrite = client.write.bind(client)
|
|
81
|
+
let internal = false
|
|
82
|
+
function send (name, params) {
|
|
83
|
+
if (logPacket) logPacket(name, params)
|
|
84
|
+
internal = true
|
|
85
|
+
try { rawWrite(name, params) } finally { internal = false }
|
|
86
|
+
}
|
|
87
|
+
const isBrand = (ch) => ch === 'minecraft:brand' || ch === 'MC|Brand'
|
|
88
|
+
const brandName = opts.brand ?? bot._client.options?.brand ?? 'vanilla'
|
|
89
|
+
function brandPayload () {
|
|
90
|
+
const s = Buffer.from(brandName, 'utf8')
|
|
91
|
+
const len = []
|
|
92
|
+
let n = s.length
|
|
93
|
+
do { let b = n & 0x7f; n >>>= 7; if (n) b |= 0x80; len.push(b) } while (n)
|
|
94
|
+
return { channel: 'minecraft:brand', data: Buffer.concat([Buffer.from(len), s]) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const S = {
|
|
98
|
+
inLevel: false,
|
|
99
|
+
positionReceived: false,
|
|
100
|
+
clientLoaded: false,
|
|
101
|
+
loadedReadyTicks: 0,
|
|
102
|
+
loadTimeout: 60,
|
|
103
|
+
last: { x: 0, y: 0, z: 0, yRot: 0, xRot: 0, onGround: false, hc: false },
|
|
104
|
+
positionReminder: 0,
|
|
105
|
+
wasSprinting: false,
|
|
106
|
+
wasShift: false,
|
|
107
|
+
lastInput: { forward: false, backward: false, left: false, right: false, jump: false, shift: false, sprint: false },
|
|
108
|
+
selected: 0,
|
|
109
|
+
carriedIndex: 0,
|
|
110
|
+
boat: null,
|
|
111
|
+
configBrandSent: false,
|
|
112
|
+
suppressPlaySettings: false,
|
|
113
|
+
playSettingsSent: false,
|
|
114
|
+
heldBrand: null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
client.write = function (name, params) {
|
|
118
|
+
if (!internal) {
|
|
119
|
+
if (name === 'player_loaded' && V.clientLoaded) return // sent by us when the level screen would close
|
|
120
|
+
if (name === 'custom_payload' && isBrand(params?.channel) && client.state === 'play') {
|
|
121
|
+
if (V.configPhase && S.configBrandSent) return // 1.20.2+: brand only once, during configuration
|
|
122
|
+
if (!V.configPhase && !S.playSettingsSent) { S.heldBrand = params; return } // vanilla: settings, then brand
|
|
123
|
+
}
|
|
124
|
+
if (name === 'settings') {
|
|
125
|
+
if (client.state === 'configuration' && V.configPhase && !S.configBrandSent) {
|
|
126
|
+
send('custom_payload', brandPayload()) // LoginAcknowledged -> brand -> client information
|
|
127
|
+
S.configBrandSent = true
|
|
128
|
+
} else if (client.state === 'play') {
|
|
129
|
+
if (S.suppressPlaySettings) { S.suppressPlaySettings = false; return }
|
|
130
|
+
if (logPacket) logPacket(name, params)
|
|
131
|
+
rawWrite(name, params)
|
|
132
|
+
S.playSettingsSent = true
|
|
133
|
+
if (S.heldBrand) { const b = S.heldBrand; S.heldBrand = null; rawWrite('custom_payload', b) }
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (logPacket && !internal) logPacket(name, params)
|
|
139
|
+
return rawWrite(name, params)
|
|
140
|
+
}
|
|
141
|
+
client.on('finish_configuration', () => { S.suppressPlaySettings = true })
|
|
142
|
+
|
|
143
|
+
// ---------------- controls / API ----------------
|
|
144
|
+
const controlState = { forward: false, back: false, left: false, right: false, jump: false, sprint: false, sneak: false }
|
|
145
|
+
bot.physicsEnabled = true
|
|
146
|
+
bot.physics = { gravity: 0.08, yawSpeed: 3, pitchSpeed: 3, stepHeight: 0.6, engine: 'spectra' }
|
|
147
|
+
bot.spectra = { flags: V, player: phys, state: S, world }
|
|
148
|
+
|
|
149
|
+
bot.setControlState = (control, state) => {
|
|
150
|
+
if (!(control in controlState)) throw new Error(`invalid control: ${control}`)
|
|
151
|
+
controlState[control] = !!state
|
|
152
|
+
}
|
|
153
|
+
bot.getControlState = (control) => controlState[control]
|
|
154
|
+
bot.clearControlStates = () => { for (const k in controlState) controlState[k] = false }
|
|
155
|
+
bot.controlState = {}
|
|
156
|
+
for (const k of Object.keys(controlState)) {
|
|
157
|
+
Object.defineProperty(bot.controlState, k, { get: () => controlState[k], set: (v) => { controlState[k] = !!v } })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// rotation targets (degrees, vanilla convention) derived from bot.entity.yaw/pitch
|
|
161
|
+
let lookWaiters = []
|
|
162
|
+
function targetYaw () { return conv.toNotchianYaw(bot.entity.yaw) }
|
|
163
|
+
function targetPitch () { return conv.toNotchianPitch(bot.entity.pitch) }
|
|
164
|
+
bot.look = async (yaw, pitch, force) => {
|
|
165
|
+
bot.entity.yaw = yaw
|
|
166
|
+
bot.entity.pitch = pitch
|
|
167
|
+
if (force) {
|
|
168
|
+
applyTurn(Infinity)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
await new Promise(resolve => lookWaiters.push(resolve))
|
|
172
|
+
}
|
|
173
|
+
bot.lookAt = async (point, force) => {
|
|
174
|
+
const delta = point.minus(bot.entity.position.offset(0, phys.eyeHeight(), 0))
|
|
175
|
+
const yaw = Math.atan2(-delta.x, -delta.z)
|
|
176
|
+
const pitch = Math.atan2(delta.y, Math.sqrt(delta.x * delta.x + delta.z * delta.z))
|
|
177
|
+
await bot.look(yaw, pitch, force)
|
|
178
|
+
}
|
|
179
|
+
bot.waitForTicks = async (ticks) => {
|
|
180
|
+
if (ticks <= 0) return
|
|
181
|
+
await new Promise(resolve => {
|
|
182
|
+
const l = () => { if (--ticks <= 0) { bot.removeListener('physicsTick', l); resolve() } }
|
|
183
|
+
bot.on('physicsTick', l)
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
bot.elytraFly = async () => { throw new Error('elytra flight is not simulated') }
|
|
187
|
+
|
|
188
|
+
// vanilla mouse turning: whole "pixels" of 0.15 degrees, accumulated in float
|
|
189
|
+
function applyTurn (maxDeg = turnPerTick, target) {
|
|
190
|
+
const ty = target?.yaw ?? targetYaw()
|
|
191
|
+
const tp = target?.pitch ?? targetPitch()
|
|
192
|
+
const dy = wrapDegrees(ty - phys.yRot)
|
|
193
|
+
const dp = tp - phys.xRot
|
|
194
|
+
const lim = maxDeg / F_015
|
|
195
|
+
const py = clamp(Math.round(dy / F_015), -lim, lim)
|
|
196
|
+
const pp = clamp(Math.round(dp / F_015), -lim, lim)
|
|
197
|
+
if (py !== 0) phys.yRot = f32(phys.yRot + f32(f32(py) * F_015))
|
|
198
|
+
if (pp !== 0) phys.xRot = f32(clamp(f32(phys.xRot + f32(f32(pp) * F_015)), -90, 90))
|
|
199
|
+
if (Math.abs(wrapDegrees(ty - phys.yRot)) < F_015 && Math.abs(tp - phys.xRot) < F_015 && lookWaiters.length) {
|
|
200
|
+
const w = lookWaiters; lookWaiters = []
|
|
201
|
+
for (const r of w) r()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------- level lifecycle ----------------
|
|
206
|
+
function resetForNewPlayer () {
|
|
207
|
+
const abilities = phys.abilities
|
|
208
|
+
phys.reset()
|
|
209
|
+
phys.abilities = abilities
|
|
210
|
+
S.positionReceived = false
|
|
211
|
+
S.clientLoaded = false
|
|
212
|
+
S.loadedReadyTicks = 0
|
|
213
|
+
S.loadTimeout = 60
|
|
214
|
+
S.last = { x: 0, y: 0, z: 0, yRot: 0, xRot: 0, onGround: false, hc: false }
|
|
215
|
+
S.positionReminder = 0
|
|
216
|
+
S.wasSprinting = false
|
|
217
|
+
S.wasShift = false
|
|
218
|
+
S.lastInput = { forward: false, backward: false, left: false, right: false, jump: false, shift: false, sprint: false }
|
|
219
|
+
S.boat = null
|
|
220
|
+
}
|
|
221
|
+
client.on('login', () => {
|
|
222
|
+
S.inLevel = true
|
|
223
|
+
S.playSettingsSent = false
|
|
224
|
+
S.selected = 0
|
|
225
|
+
S.carriedIndex = 0
|
|
226
|
+
resetForNewPlayer()
|
|
227
|
+
startTicking()
|
|
228
|
+
})
|
|
229
|
+
client.on('respawn', () => { resetForNewPlayer() })
|
|
230
|
+
client.on('start_configuration', () => { S.inLevel = false })
|
|
231
|
+
|
|
232
|
+
// ---------------- server -> client handlers ----------------
|
|
233
|
+
function relFlags (flags) {
|
|
234
|
+
if (typeof flags === 'object' && flags !== null) {
|
|
235
|
+
return { x: !!flags.x, y: !!flags.y, z: !!flags.z, yaw: !!flags.yaw, pitch: !!flags.pitch, dx: !!flags.dx, dy: !!flags.dy, dz: !!flags.dz, rotDelta: !!flags.yawDelta }
|
|
236
|
+
}
|
|
237
|
+
const n = flags | 0
|
|
238
|
+
return { x: !!(n & 1), y: !!(n & 2), z: !!(n & 4), yaw: !!(n & 8), pitch: !!(n & 16) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function sendTeleportPosRot () {
|
|
242
|
+
send('position_look', {
|
|
243
|
+
x: phys.pos.x, y: phys.pos.y, z: phys.pos.z, yaw: phys.yRot, pitch: phys.xRot,
|
|
244
|
+
onGround: false, flags: { onGround: false, hasHorizontalCollision: false }
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
client.on('position', (packet) => {
|
|
249
|
+
const r = relFlags(packet.flags)
|
|
250
|
+
const p = phys
|
|
251
|
+
let nx, ny, nz, yaw, pitch
|
|
252
|
+
let vx = p.vel.x; let vy = p.vel.y; let vz = p.vel.z
|
|
253
|
+
if (!V.relativeTeleportVelocity) {
|
|
254
|
+
if (r.x) { nx = p.pos.x + packet.x } else { nx = packet.x; vx = 0 }
|
|
255
|
+
if (r.y) { ny = p.pos.y + packet.y } else { ny = packet.y; vy = 0 }
|
|
256
|
+
if (r.z) { nz = p.pos.z + packet.z } else { nz = packet.z; vz = 0 }
|
|
257
|
+
yaw = f32(packet.yaw); pitch = f32(packet.pitch)
|
|
258
|
+
if (r.pitch) pitch = f32(pitch + p.xRot)
|
|
259
|
+
if (r.yaw) yaw = f32(yaw + p.yRot)
|
|
260
|
+
yaw = f32(yaw % 360)
|
|
261
|
+
pitch = f32(clamp(pitch, -90, 90) % 360)
|
|
262
|
+
} else {
|
|
263
|
+
nx = (r.x ? p.pos.x : 0) + packet.x
|
|
264
|
+
ny = (r.y ? p.pos.y : 0) + packet.y
|
|
265
|
+
nz = (r.z ? p.pos.z : 0) + packet.z
|
|
266
|
+
yaw = f32((r.yaw ? p.yRot : 0) + packet.yaw)
|
|
267
|
+
pitch = f32(clamp(f32((r.pitch ? p.xRot : 0) + packet.pitch), -90, 90))
|
|
268
|
+
if (r.rotDelta) {
|
|
269
|
+
const ax = (p.xRot - pitch) * Math.PI / 180; const ay = (p.yRot - yaw) * Math.PI / 180
|
|
270
|
+
const cy1 = Math.cos(ax); const sy1 = Math.sin(ax)
|
|
271
|
+
let tx = vx; let ty = vy * cy1 + vz * sy1; let tz = vz * cy1 - vy * sy1
|
|
272
|
+
const c2 = Math.cos(ay); const s2 = Math.sin(ay)
|
|
273
|
+
const ux = tx * c2 + tz * s2; const uz = tz * c2 - tx * s2
|
|
274
|
+
tx = ux; tz = uz
|
|
275
|
+
vx = tx; vy = ty; vz = tz
|
|
276
|
+
}
|
|
277
|
+
vx = r.dx ? vx + (packet.dx ?? 0) : (packet.dx ?? 0)
|
|
278
|
+
vy = r.dy ? vy + (packet.dy ?? 0) : (packet.dy ?? 0)
|
|
279
|
+
vz = r.dz ? vz + (packet.dz ?? 0) : (packet.dz ?? 0)
|
|
280
|
+
}
|
|
281
|
+
if (!bot.vehicle) {
|
|
282
|
+
p.pos.x = nx; p.pos.y = ny; p.pos.z = nz
|
|
283
|
+
p.vel.x = vx; p.vel.y = vy; p.vel.z = vz
|
|
284
|
+
}
|
|
285
|
+
p.yRot = yaw
|
|
286
|
+
p.xRot = pitch
|
|
287
|
+
bot.entity.yaw = conv.fromNotchianYaw(yaw)
|
|
288
|
+
bot.entity.pitch = conv.fromNotchianPitch(pitch)
|
|
289
|
+
syncEntity()
|
|
290
|
+
|
|
291
|
+
if (V.posRotBeforeConfirm) {
|
|
292
|
+
sendTeleportPosRot()
|
|
293
|
+
send('teleport_confirm', { teleportId: packet.teleportId })
|
|
294
|
+
} else {
|
|
295
|
+
if (bot.supportFeature('teleportUsesOwnPacket')) send('teleport_confirm', { teleportId: packet.teleportId })
|
|
296
|
+
sendTeleportPosRot()
|
|
297
|
+
}
|
|
298
|
+
S.positionReceived = true
|
|
299
|
+
bot.emit('forcedMove')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
client.on('player_rotation', (packet) => {
|
|
303
|
+
phys.yRot = f32(packet.yaw)
|
|
304
|
+
phys.xRot = f32(clamp(packet.pitch, -90, 90))
|
|
305
|
+
bot.entity.yaw = conv.fromNotchianYaw(phys.yRot)
|
|
306
|
+
bot.entity.pitch = conv.fromNotchianPitch(phys.xRot)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
client.on('abilities', (packet) => {
|
|
310
|
+
const fl = packet.flags
|
|
311
|
+
phys.abilities.invulnerable = !!(fl & 1)
|
|
312
|
+
phys.abilities.flying = !!(fl & 2)
|
|
313
|
+
phys.abilities.mayfly = !!(fl & 4)
|
|
314
|
+
phys.abilities.instabuild = !!(fl & 8)
|
|
315
|
+
phys.abilities.flyingSpeed = f32(packet.flyingSpeed)
|
|
316
|
+
phys.abilities.walkingSpeed = f32(packet.walkingSpeed)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
client.on('entity_velocity', (packet) => {
|
|
320
|
+
if (!bot.entity || packet.entityId !== bot.entity.id) return
|
|
321
|
+
const v = packet.velocity
|
|
322
|
+
const k = V.lpVelocity ? 1 : 1 / 8000
|
|
323
|
+
phys.vel.x = v.x * k; phys.vel.y = v.y * k; phys.vel.z = v.z * k
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
client.on('explosion', (packet) => {
|
|
327
|
+
const kb = packet.playerKnockback ?? (('playerMotionX' in packet) ? { x: packet.playerMotionX, y: packet.playerMotionY, z: packet.playerMotionZ } : null)
|
|
328
|
+
if (kb) { phys.vel.x += kb.x; phys.vel.y += kb.y; phys.vel.z += kb.z }
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// Held item: vanilla only changes the selected slot for valid hotbar indices and confirms it on the next tick.
|
|
332
|
+
client.removeAllListeners('held_item_slot')
|
|
333
|
+
client.on('held_item_slot', (packet) => {
|
|
334
|
+
const slot = packet.slot ?? packet.slotId
|
|
335
|
+
if (slot >= 0 && slot <= 8) {
|
|
336
|
+
S.selected = slot
|
|
337
|
+
bot.quickBarSlot = slot
|
|
338
|
+
try { bot.updateHeldItem?.() } catch (e) {}
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
bot.setQuickBarSlot = (slot) => {
|
|
342
|
+
if (!(slot >= 0 && slot < 9)) throw new Error('invalid hotbar slot ' + slot)
|
|
343
|
+
S.selected = slot
|
|
344
|
+
bot.quickBarSlot = slot
|
|
345
|
+
try { bot.updateHeldItem?.() } catch (e) {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// LocalPlayer#swing is triggered by ClientboundAnimatePacket for our own entity and echoes a swing packet
|
|
349
|
+
client.on('animation', (packet) => {
|
|
350
|
+
if (!bot.entity || packet.entityId !== bot.entity.id) return
|
|
351
|
+
if (packet.animation === 0) send('arm_animation', { hand: 0 })
|
|
352
|
+
else if (packet.animation === 3) send('arm_animation', { hand: 1 })
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
// Pre-1.17: a full chunk with an empty section bitmap is a *loaded* empty chunk for the vanilla client,
|
|
356
|
+
// mineflayer drops it as an unload and the player would never tick inside it.
|
|
357
|
+
const Chunk = require('prismarine-chunk')(registry)
|
|
358
|
+
client.on('map_chunk', (packet) => {
|
|
359
|
+
const empty = packet.bitMap === 0 || (Array.isArray(packet.bitMap) && packet.bitMap.every(n => !n || (Array.isArray(n) && n.every(m => !m))))
|
|
360
|
+
if (!empty || !packet.groundUp) return
|
|
361
|
+
if (bot.world.getColumn(packet.x, packet.z)) return
|
|
362
|
+
const column = new Chunk({ minY: bot.game.minY, worldHeight: bot.game.height })
|
|
363
|
+
try { if (packet.biomes !== undefined) column.loadBiomes(packet.biomes) } catch (e) {}
|
|
364
|
+
bot.world.setColumn(packet.x, packet.z, column)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
// mineflayer listens for 'entityGone' on the wrong emitter, so a removed vehicle is never left: do it here
|
|
368
|
+
client.on('entity_destroy', (packet) => {
|
|
369
|
+
const v = bot.vehicle
|
|
370
|
+
if (!v || !packet.entityIds.includes(v.id)) return
|
|
371
|
+
bot.vehicle = null
|
|
372
|
+
if (bot.entity) bot.entity.vehicle = null
|
|
373
|
+
S.boat = null
|
|
374
|
+
bot.emit('dismount', v)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// server corrected our vehicle
|
|
378
|
+
client.on('vehicle_move', (packet) => {
|
|
379
|
+
if (!S.boat) return
|
|
380
|
+
S.boat.pos.x = packet.x; S.boat.pos.y = packet.y; S.boat.pos.z = packet.z
|
|
381
|
+
S.boat.yRot = f32(packet.yaw); S.boat.xRot = f32(packet.pitch)
|
|
382
|
+
sendVehicleMove()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// ---------------- per tick ----------------
|
|
386
|
+
function syncEntity () {
|
|
387
|
+
if (!bot.entity) return
|
|
388
|
+
bot.entity.position.set(phys.pos.x, phys.pos.y, phys.pos.z)
|
|
389
|
+
bot.entity.velocity.set(phys.vel.x, phys.vel.y, phys.vel.z)
|
|
390
|
+
bot.entity.onGround = phys.onGround
|
|
391
|
+
bot.entity.isInWater = phys.isInWater()
|
|
392
|
+
bot.entity.isInLava = phys.isInLava()
|
|
393
|
+
bot.entity.height = phys.height()
|
|
394
|
+
bot.entity.eyeHeight = phys.eyeHeight()
|
|
395
|
+
bot.entity.isCollidedHorizontally = phys.horizontalCollision
|
|
396
|
+
bot.entity.isCollidedVertically = phys.verticalCollision
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const flagsOf = () => ({ onGround: phys.onGround, hasHorizontalCollision: phys.horizontalCollision })
|
|
400
|
+
|
|
401
|
+
function sendIsSprintingIfNeeded () {
|
|
402
|
+
if (phys.sprinting !== S.wasSprinting) {
|
|
403
|
+
send('entity_action', { entityId: bot.entity.id, actionId: phys.sprinting ? 'start_sprinting' : 'stop_sprinting', jumpBoost: 0 })
|
|
404
|
+
S.wasSprinting = phys.sprinting
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function sendShiftKeyState () {
|
|
408
|
+
if (V.shiftInInput) return
|
|
409
|
+
const sh = phys.input.shift
|
|
410
|
+
if (sh !== S.wasShift) {
|
|
411
|
+
send('entity_action', { entityId: bot.entity.id, actionId: sh ? 'start_sneaking' : 'stop_sneaking', jumpBoost: 0 })
|
|
412
|
+
S.wasShift = sh
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function sendPlayerInputIfChanged () {
|
|
416
|
+
const k = controlState
|
|
417
|
+
const cur = { forward: k.forward, backward: k.back, left: k.left, right: k.right, jump: k.jump, shift: k.sneak, sprint: k.sprint }
|
|
418
|
+
const l = S.lastInput
|
|
419
|
+
if (Object.keys(cur).some(n => cur[n] !== l[n])) {
|
|
420
|
+
send('player_input', { inputs: cur })
|
|
421
|
+
S.lastInput = cur
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// LocalPlayer#sendPosition
|
|
426
|
+
function sendPosition () {
|
|
427
|
+
sendIsSprintingIfNeeded()
|
|
428
|
+
if (!V.playerInputPacket) sendShiftKeyState()
|
|
429
|
+
const p = phys
|
|
430
|
+
const L = S.last
|
|
431
|
+
const dx = p.pos.x - L.x; const dy = p.pos.y - L.y; const dz = p.pos.z - L.z
|
|
432
|
+
const dr = p.yRot - L.yRot; const dp = p.xRot - L.xRot
|
|
433
|
+
S.positionReminder++
|
|
434
|
+
const moved = (dx * dx + dy * dy + dz * dz) > 4.0000000000000003e-8 || S.positionReminder >= 20
|
|
435
|
+
const rotated = dr !== 0 || dp !== 0
|
|
436
|
+
const base = { onGround: p.onGround, flags: flagsOf() }
|
|
437
|
+
const old = new Vec3(L.x, L.y, L.z)
|
|
438
|
+
if (moved && rotated) send('position_look', { x: p.pos.x, y: p.pos.y, z: p.pos.z, yaw: p.yRot, pitch: p.xRot, ...base })
|
|
439
|
+
else if (moved) send('position', { x: p.pos.x, y: p.pos.y, z: p.pos.z, ...base })
|
|
440
|
+
else if (rotated) send('look', { yaw: p.yRot, pitch: p.xRot, ...base })
|
|
441
|
+
else if (L.onGround !== p.onGround || (V.movementFlags && L.hc !== p.horizontalCollision)) send('flying', base)
|
|
442
|
+
if (moved) { L.x = p.pos.x; L.y = p.pos.y; L.z = p.pos.z; S.positionReminder = 0 }
|
|
443
|
+
if (rotated) { L.yRot = p.yRot; L.xRot = p.xRot; S.lastRotationSentAt = Date.now() }
|
|
444
|
+
L.onGround = p.onGround
|
|
445
|
+
L.hc = p.horizontalCollision
|
|
446
|
+
if (moved || rotated) bot.emit('move', old)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function sendVehicleMove () {
|
|
450
|
+
const b = S.boat
|
|
451
|
+
send('vehicle_move', { x: b.pos.x, y: b.pos.y, z: b.pos.z, yaw: b.yRot, pitch: b.xRot, onGround: b.onGround })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const isBoat = (e) => /boat|raft/.test(e?.name ?? '')
|
|
455
|
+
function ridingOffset (vehicle) {
|
|
456
|
+
if (isBoat(vehicle)) return f32(-0.1 + -0.35)
|
|
457
|
+
if (/minecart/.test(vehicle?.name ?? '')) return f32(0 + -0.35)
|
|
458
|
+
return (vehicle?.height ?? 1) * 0.75 - 0.35
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function ridingTick (vehicle) {
|
|
462
|
+
const controller = vehicle.passengers?.[0] === bot.entity
|
|
463
|
+
if (isBoat(vehicle) && controller) {
|
|
464
|
+
if (!S.boat || S.boat.entityId !== vehicle.id) {
|
|
465
|
+
S.boat = new BoatPhysics(world, V, { position: vehicle.position, yawDegrees: f32(conv.toNotchianYaw(vehicle.yaw)) })
|
|
466
|
+
S.boat.entityId = vehicle.id
|
|
467
|
+
}
|
|
468
|
+
const k = controlState
|
|
469
|
+
S.boat.tick({ forward: k.forward, back: k.back, left: k.left, right: k.right })
|
|
470
|
+
send('steer_boat', { leftPaddle: S.boat.paddle[0], rightPaddle: S.boat.paddle[1] })
|
|
471
|
+
vehicle.position.set(S.boat.pos.x, S.boat.pos.y, S.boat.pos.z)
|
|
472
|
+
} else {
|
|
473
|
+
S.boat = null
|
|
474
|
+
}
|
|
475
|
+
// Entity#rideTick: velocity zeroed, then positionRider
|
|
476
|
+
phys.vel.x = 0; phys.vel.y = 0; phys.vel.z = 0
|
|
477
|
+
phys.computeInput(controlState, false)
|
|
478
|
+
const xxa = f32(phys.input.left * f32(0.98))
|
|
479
|
+
const zza = f32(phys.input.forward * f32(0.98))
|
|
480
|
+
phys.pos.x = vehicle.position.x
|
|
481
|
+
phys.pos.y = vehicle.position.y + ridingOffset(vehicle)
|
|
482
|
+
phys.pos.z = vehicle.position.z
|
|
483
|
+
applyTurn()
|
|
484
|
+
if (isBoat(vehicle)) {
|
|
485
|
+
// Boat#clampRotation
|
|
486
|
+
const by = S.boat ? S.boat.yRot : f32(conv.toNotchianYaw(vehicle.yaw))
|
|
487
|
+
const f = wrapDegrees(phys.yRot - by)
|
|
488
|
+
const f1 = clamp(f, -105, 105)
|
|
489
|
+
phys.yRot = f32(phys.yRot + f1 - f)
|
|
490
|
+
}
|
|
491
|
+
syncEntity()
|
|
492
|
+
if (!V.playerInputPacket) {
|
|
493
|
+
send('look', { yaw: phys.yRot, pitch: phys.xRot, onGround: phys.onGround })
|
|
494
|
+
S.lastRotationSentAt = Date.now()
|
|
495
|
+
send('steer_vehicle', { sideways: xxa, forward: zza, jump: (phys.input.jumping ? 1 : 0) | (phys.input.shift ? 2 : 0) })
|
|
496
|
+
if (S.boat) sendVehicleMove()
|
|
497
|
+
} else {
|
|
498
|
+
if (!V.shiftInInput) sendShiftKeyState()
|
|
499
|
+
sendPlayerInputIfChanged()
|
|
500
|
+
send('look', { yaw: phys.yRot, pitch: phys.xRot, onGround: phys.onGround, flags: flagsOf() })
|
|
501
|
+
S.lastRotationSentAt = Date.now()
|
|
502
|
+
if (S.boat) { sendVehicleMove(); sendIsSprintingIfNeeded() }
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function playerTick () {
|
|
507
|
+
if (!S.positionReceived) return
|
|
508
|
+
if (!world.hasChunk(phys.pos.x, phys.pos.z)) return // LocalPlayer#tick needs its chunk
|
|
509
|
+
if (V.clientLoaded && !S.clientLoaded) {
|
|
510
|
+
const outside = phys.pos.y < world.minY || phys.pos.y >= world.maxY
|
|
511
|
+
S.loadedReadyTicks = (outside || world.hasChunk(phys.pos.x, phys.pos.z)) ? S.loadedReadyTicks + 1 : 0
|
|
512
|
+
if (S.loadedReadyTicks >= 2 || --S.loadTimeout <= 0) {
|
|
513
|
+
S.clientLoaded = true
|
|
514
|
+
send('player_loaded', {})
|
|
515
|
+
bot.emit('clientLoaded')
|
|
516
|
+
}
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
if (bot.isAlive === false) return
|
|
520
|
+
applyTurn()
|
|
521
|
+
if (bot.physicsEnabled) {
|
|
522
|
+
phys.tick(controlState)
|
|
523
|
+
if (phys.events.abilitiesChanged) {
|
|
524
|
+
phys.events.abilitiesChanged = false
|
|
525
|
+
send('abilities', { flags: phys.abilities.flying ? 2 : 0 })
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
syncEntity()
|
|
529
|
+
if (V.playerInputPacket) {
|
|
530
|
+
sendShiftKeyState()
|
|
531
|
+
sendPlayerInputIfChanged()
|
|
532
|
+
}
|
|
533
|
+
sendPosition()
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function clientTick () {
|
|
537
|
+
if (client.state !== 'play' || !S.inLevel || !bot.entity) return
|
|
538
|
+
// MultiPlayerGameMode#tick -> ensureHasSentCarriedItem
|
|
539
|
+
if (S.selected !== S.carriedIndex) {
|
|
540
|
+
S.carriedIndex = S.selected
|
|
541
|
+
send('held_item_slot', { slotId: S.selected })
|
|
542
|
+
}
|
|
543
|
+
if (S.heldBrand) { const b = S.heldBrand; S.heldBrand = null; send('custom_payload', b) }
|
|
544
|
+
const vehicle = bot.vehicle
|
|
545
|
+
if (vehicle && vehicle !== bot.entity && S.positionReceived) ridingTick(vehicle)
|
|
546
|
+
else { S.boat = null; playerTick() }
|
|
547
|
+
if (V.tickEnd) send('tick_end', {})
|
|
548
|
+
bot.emit('physicsTick')
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---------------- scheduler (Minecraft timer: catch up at most 10 ticks) ----------------
|
|
552
|
+
let timer = null
|
|
553
|
+
let nextAt = 0
|
|
554
|
+
function startTicking () {
|
|
555
|
+
if (timer) return
|
|
556
|
+
nextAt = performance.now() + TICK_MS
|
|
557
|
+
const loop = () => {
|
|
558
|
+
const now = performance.now()
|
|
559
|
+
let n = 0
|
|
560
|
+
while (now >= nextAt && n < 10) {
|
|
561
|
+
try { clientTick() } catch (err) { bot.emit('error', err) }
|
|
562
|
+
nextAt += TICK_MS
|
|
563
|
+
n++
|
|
564
|
+
}
|
|
565
|
+
if (now - nextAt > 1000) nextAt = now + TICK_MS
|
|
566
|
+
timer = setTimeout(loop, Math.max(0, nextAt - performance.now()))
|
|
567
|
+
}
|
|
568
|
+
timer = setTimeout(loop, TICK_MS)
|
|
569
|
+
}
|
|
570
|
+
bot.on('end', () => { clearTimeout(timer); timer = null })
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
module.exports = spectraPhysics
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spectra-physics",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Vanilla-accurate client physics and movement for mineflayer (1.16.5 - 26.1)",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"lib/",
|
|
9
|
+
"LICENSE",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test \"test/**/*.test.js\""
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mineflayer",
|
|
17
|
+
"minecraft",
|
|
18
|
+
"physics",
|
|
19
|
+
"movement",
|
|
20
|
+
"bot",
|
|
21
|
+
"prismarine"
|
|
22
|
+
],
|
|
23
|
+
"author": "trqxyz",
|
|
24
|
+
"license": "GPL-3.0-only",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/trqxyz/SpectraPhysics.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/trqxyz/SpectraPhysics/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/trqxyz/SpectraPhysics#readme",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"prismarine-block": "^1.23.0",
|
|
41
|
+
"prismarine-chunk": "^1.39.0",
|
|
42
|
+
"vec3": "^0.1.10"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"mineflayer": ">=4.30.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"mineflayer": "^4.39.0",
|
|
49
|
+
"prismarine-registry": "^1.12.0"
|
|
50
|
+
}
|
|
51
|
+
}
|