refactor(minecraft): replace look-at and teabag behaviors with idle-gaze behavior
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import type { Bot } from 'mineflayer'
|
||||
import type { Entity } from 'prismarine-entity'
|
||||
|
||||
import type { ReflexBehavior } from '../types/behavior'
|
||||
|
||||
// Minimum velocity magnitude to consider a player "moving"
|
||||
const MOVEMENT_THRESHOLD = 0.1
|
||||
// Max distance (blocks) to care about player movement
|
||||
const GAZE_RANGE = 16
|
||||
// After looking at a moving player, don't re-trigger for this long
|
||||
const COOLDOWN_MS = 2_000
|
||||
// Chance to skip a valid gaze trigger (so the bot doesn't robotically track every movement)
|
||||
const IGNORE_CHANCE = 0.3
|
||||
// Attention events older than this are ignored to avoid stale reactions.
|
||||
const ATTENTION_MAX_AGE_MS = 2_000
|
||||
// Skip tiny orientation changes to avoid visible head jitter.
|
||||
const LOOK_DEADZONE_RAD = 0.07
|
||||
// Avoid rapid oscillation when switching between nearby candidates.
|
||||
const RETARGET_DEBOUNCE_MS = 650
|
||||
|
||||
const smoothingByBot = new WeakMap<object, { lastTargetId: number | null, lastLookAtAt: number }>()
|
||||
|
||||
interface GazeCandidate {
|
||||
entity: Entity
|
||||
score: number
|
||||
isAttention: boolean
|
||||
}
|
||||
|
||||
function horizontalSpeed(entity: Entity): number {
|
||||
const v = entity.velocity
|
||||
if (!v)
|
||||
return 0
|
||||
return Math.sqrt(v.x * v.x + v.z * v.z)
|
||||
}
|
||||
|
||||
function headLookOffset(entity: Entity): number {
|
||||
return Number.isFinite(entity.height) ? entity.height * 0.85 : 1.5
|
||||
}
|
||||
|
||||
function normalizeAngle(rad: number): number {
|
||||
let a = rad
|
||||
while (a > Math.PI) a -= Math.PI * 2
|
||||
while (a < -Math.PI) a += Math.PI * 2
|
||||
return a
|
||||
}
|
||||
|
||||
function getLookDeltaRad(from: Entity, to: { x: number, y: number, z: number }): number {
|
||||
const dx = to.x - from.position.x
|
||||
const dy = to.y - from.position.y
|
||||
const dz = to.z - from.position.z
|
||||
const hDist = Math.hypot(dx, dz)
|
||||
|
||||
const yawDelta = Math.abs(normalizeAngle(Math.atan2(-dx, -dz) - (from.yaw ?? 0)))
|
||||
const pitchDelta = Math.abs(Math.atan2(dy, hDist) - (from.pitch ?? 0))
|
||||
return Math.max(yawDelta, pitchDelta)
|
||||
}
|
||||
|
||||
function getSmoothingState(bot: object): { lastTargetId: number | null, lastLookAtAt: number } {
|
||||
let state = smoothingByBot.get(bot)
|
||||
if (!state) {
|
||||
state = { lastTargetId: null, lastLookAtAt: 0 }
|
||||
smoothingByBot.set(bot, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best gaze target: either a recent attention entity or the closest moving player.
|
||||
* Returns null when there is nothing worth looking at.
|
||||
*/
|
||||
function findGazeTarget(
|
||||
bot: Bot,
|
||||
ctx: Parameters<ReflexBehavior['when']>[0],
|
||||
): GazeCandidate | null {
|
||||
const selfPos = bot.entity?.position
|
||||
if (!selfPos)
|
||||
return null
|
||||
|
||||
// 1. Recent attention signal takes priority
|
||||
if (
|
||||
ctx.attention.lastSignalType === 'entity_attention'
|
||||
&& ctx.attention.lastSignalAt
|
||||
&& ctx.now - ctx.attention.lastSignalAt <= ATTENTION_MAX_AGE_MS
|
||||
&& ctx.attention.lastSignalSourceId
|
||||
) {
|
||||
const target = bot.entities[Number(ctx.attention.lastSignalSourceId)]
|
||||
if (target) {
|
||||
const dist = selfPos.distanceTo(target.position)
|
||||
if (dist <= GAZE_RANGE)
|
||||
return { entity: target, score: 25 + 35 * (1 - dist / GAZE_RANGE), isAttention: true }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Closest moving player
|
||||
let best: GazeCandidate | null = null
|
||||
let bestDist = Infinity
|
||||
|
||||
for (const [name, player] of Object.entries(bot.players)) {
|
||||
if (name === bot.username)
|
||||
continue
|
||||
const entity = player?.entity
|
||||
if (!entity || horizontalSpeed(entity) <= MOVEMENT_THRESHOLD)
|
||||
continue
|
||||
|
||||
const dist = selfPos.distanceTo(entity.position)
|
||||
if (dist > GAZE_RANGE)
|
||||
continue
|
||||
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist
|
||||
best = { entity, score: 10 + 30 * (1 - dist / GAZE_RANGE), isAttention: false }
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle-gaze: the bot occasionally glances at nearby players who are moving.
|
||||
*
|
||||
* Design goals for realism:
|
||||
* - Only fires in idle/social modes (not during work, combat, etc.)
|
||||
* - Ignores stationary players — no creepy staring
|
||||
* - Drawn to movement: picks the closest moving player
|
||||
* - Probabilistic skip so the bot doesn't track every single movement
|
||||
* - Smooth look turns with anti-jitter retarget guards
|
||||
*/
|
||||
export const idleGazeBehavior: ReflexBehavior = {
|
||||
id: 'idle-gaze',
|
||||
modes: ['idle', 'social'],
|
||||
cooldownMs: COOLDOWN_MS,
|
||||
|
||||
when: (ctx, api) => api ? findGazeTarget(api.bot.bot, ctx) !== null : false,
|
||||
|
||||
score: (ctx, api) => api ? (findGazeTarget(api.bot.bot, ctx)?.score ?? 0) : 0,
|
||||
|
||||
run: async ({ bot: mfBot, context }) => {
|
||||
const bot = mfBot.bot
|
||||
const selfEntity = bot.entity
|
||||
if (!selfEntity)
|
||||
return
|
||||
|
||||
const candidate = findGazeTarget(bot, context.getSnapshot())
|
||||
if (!candidate)
|
||||
return
|
||||
|
||||
// Probabilistic skip for ambient movement scans (attention signals always fire).
|
||||
if (!candidate.isAttention && Math.random() < IGNORE_CHANCE)
|
||||
return
|
||||
|
||||
const targetId = candidate.entity.id ?? null
|
||||
const lookTarget = candidate.entity.position.offset(0, headLookOffset(candidate.entity), 0)
|
||||
|
||||
if (getLookDeltaRad(selfEntity, lookTarget) < LOOK_DEADZONE_RAD)
|
||||
return
|
||||
|
||||
const smoothing = getSmoothingState(bot)
|
||||
const now = Date.now()
|
||||
const isSwitch = smoothing.lastTargetId !== null && targetId !== null && smoothing.lastTargetId !== targetId
|
||||
if (isSwitch && now - smoothing.lastLookAtAt < RETARGET_DEBOUNCE_MS)
|
||||
return
|
||||
|
||||
// force=false uses mineflayer's smoother turn path instead of snap rotation.
|
||||
await bot.lookAt(lookTarget, false)
|
||||
smoothing.lastTargetId = targetId
|
||||
smoothing.lastLookAtAt = now
|
||||
},
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ReflexBehavior } from '../types/behavior'
|
||||
|
||||
export const lookAtBehavior: ReflexBehavior = {
|
||||
id: 'look-at',
|
||||
modes: ['idle', 'social'],
|
||||
cooldownMs: 100,
|
||||
|
||||
when: (ctx) => {
|
||||
// Check if we have a recent attention signal
|
||||
const { lastSignalType, lastSignalAt } = ctx.attention
|
||||
if (!lastSignalType || !lastSignalAt)
|
||||
return false
|
||||
|
||||
// Must be fresh (within 2 seconds)
|
||||
if (ctx.now - lastSignalAt > 2000)
|
||||
return false
|
||||
|
||||
// Respond to entity_attention signals
|
||||
return lastSignalType === 'entity_attention'
|
||||
},
|
||||
|
||||
score: () => {
|
||||
// High priority but not override-level (100)
|
||||
// Allows critical survival behaviors to take precedence
|
||||
return 50
|
||||
},
|
||||
|
||||
run: async ({ bot, context }) => {
|
||||
const { lastSignalSourceId } = context.getSnapshot().attention
|
||||
|
||||
if (!lastSignalSourceId)
|
||||
return
|
||||
|
||||
// Find the entity
|
||||
const target = bot.bot.entities[Number(lastSignalSourceId)]
|
||||
if (!target)
|
||||
return
|
||||
|
||||
// Look at the entity smoothly
|
||||
await bot.bot.lookAt(target.position.offset(0, target.height * 0.85, 0), true)
|
||||
},
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ReflexBehavior } from '../types/behavior'
|
||||
|
||||
export const teabagBehavior: ReflexBehavior = {
|
||||
id: 'teabag',
|
||||
modes: ['social', 'idle'],
|
||||
cooldownMs: 5000,
|
||||
|
||||
when: (_ctx, api) => {
|
||||
// Check if any player is teabagging with high confidence
|
||||
if (!api?.perception)
|
||||
return false
|
||||
const teabaggers = api.perception.entitiesWithBelief('teabag', 0.6)
|
||||
return teabaggers.length > 0
|
||||
},
|
||||
|
||||
score: (_ctx, api) => {
|
||||
// Higher priority than LookAt (50), scaled by confidence
|
||||
if (!api?.perception)
|
||||
return 0
|
||||
const teabaggers = api.perception.entitiesWithBelief('teabag', 0.6)
|
||||
if (teabaggers.length === 0)
|
||||
return 0
|
||||
// Use highest confidence as score boost
|
||||
const maxConfidence = Math.max(...teabaggers.map(e => e.beliefs.teabag?.confidence ?? 0))
|
||||
return 60 + (maxConfidence * 20)
|
||||
},
|
||||
|
||||
run: async ({ bot }) => {
|
||||
// Perform rapid squats
|
||||
for (let i = 0; i < 4; i++) {
|
||||
bot.bot.setControlState('sneak', true)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
bot.bot.setControlState('sneak', false)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import type { MineflayerWithAgents } from '../types'
|
||||
import type { ReflexContextState } from './context'
|
||||
|
||||
import { DebugService } from '../../debug'
|
||||
import { lookAtBehavior } from './behaviors/look-at'
|
||||
import { teabagBehavior } from './behaviors/teabag'
|
||||
import { idleGazeBehavior } from './behaviors/idle-gaze'
|
||||
import { ReflexRuntime } from './runtime'
|
||||
|
||||
export class ReflexManager {
|
||||
@@ -33,8 +32,7 @@ export class ReflexManager {
|
||||
onModeChange: () => this.emitReflexState(),
|
||||
})
|
||||
|
||||
this.runtime.registerBehavior(lookAtBehavior)
|
||||
this.runtime.registerBehavior(teabagBehavior)
|
||||
this.runtime.registerBehavior(idleGazeBehavior)
|
||||
}
|
||||
|
||||
public init(bot: MineflayerWithAgents): void {
|
||||
|
||||
Reference in New Issue
Block a user