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