feat: Add bot AI toggle and spawn delay for weapon draw

New commands:
- !boton  - Enable bot AI (targeting, movement, combat)
- !botoff - Disable bot AI (bots stand still, passive)

Bot AI defaults to OFF so you can spawn multiple bots then activate.

Fixes:
- 2 second delay before weapon draw (lets client fully load)
- Weapon draw broadcast delayed until readyTick passes
- weaponInHand in PlayerState reflects actual drawn state

This fixes the visual bug where weapon appeared to shoot from bot's back
because the draw happened before client loaded the model.
This commit is contained in:
Claude 2025-11-23 06:39:54 +00:00
parent 905f156e50
commit ba0e43cfed
No known key found for this signature in database
3 changed files with 68 additions and 16 deletions

View file

@ -37,6 +37,9 @@ class BotManager(zone: Zone) extends Actor {
private val bots: mutable.Map[Int, BotState] = mutable.Map() private val bots: mutable.Map[Int, BotState] = mutable.Map()
private val random = new Random() private val random = new Random()
// AI toggle - starts OFF so bots spawn passive
private var aiEnabled: Boolean = false
// Pool of available bot IDs (DB IDs 2-301 = xxBOTxxTestBot1 through xxBOTxxTestBot300) // Pool of available bot IDs (DB IDs 2-301 = xxBOTxxTestBot1 through xxBOTxxTestBot300)
private val availableBotIds: mutable.Queue[Int] = mutable.Queue.from(2 to 301) private val availableBotIds: mutable.Queue[Int] = mutable.Queue.from(2 to 301)
private val usedBotIds: mutable.Set[Int] = mutable.Set() private val usedBotIds: mutable.Set[Int] = mutable.Set()
@ -98,6 +101,19 @@ class BotManager(zone: Zone) extends Actor {
case DespawnAllBots => case DespawnAllBots =>
bots.keys.toSeq.foreach(despawnBot) bots.keys.toSeq.foreach(despawnBot)
case SetAIEnabled(enabled) =>
aiEnabled = enabled
val state = if (enabled) "ON" else "OFF"
log.info(s"Bot AI is now $state (${bots.size} bots affected)")
// If turning off, stop all bots from firing
if (!enabled) {
bots.values.foreach { botState =>
if (botState.combat.isFiring) {
stopFiring(botState)
}
}
}
case Tick => case Tick =>
tickCount += 1 tickCount += 1
checkForDeadBots() checkForDeadBots()
@ -180,13 +196,7 @@ class BotManager(zone: Zone) extends Actor {
) )
) )
// Broadcast that bot has drawn a weapon (slot 2 = suppressor) // Don't broadcast weapon draw yet - wait for readyTick to let client fully load
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.ObjectHeld(player.GUID, player.DrawnSlot, player.LastDrawnSlot)
)
log.info(s"$name has drawn a suppressor from its holster")
// Initialize movement state // Initialize movement state
val moveAngle = random.nextFloat() * 360f val moveAngle = random.nextFloat() * 360f
val moveState = MovementState( val moveState = MovementState(
@ -195,7 +205,9 @@ class BotManager(zone: Zone) extends Actor {
moveUntilTick = tickCount + 30 + random.nextInt(50) moveUntilTick = tickCount + 30 + random.nextInt(50)
) )
bots(botId) = BotState(botId, name, avatar, player, botAvatarActor, moveState, CombatState(), player.Position) // Set readyTick 2 seconds from now (20 ticks) to let client fully load before combat
val readyAt = tickCount + 20
bots(botId) = BotState(botId, name, avatar, player, botAvatarActor, moveState, CombatState(), player.Position, readyAt, weaponDrawn = false)
log.info(s"Bot '$name' spawned successfully with GUID ${player.GUID} (${bots.size} bots active, ${availableBotIds.size} available)") log.info(s"Bot '$name' spawned successfully with GUID ${player.GUID} (${bots.size} bots active, ${availableBotIds.size} available)")
} }
@ -318,13 +330,33 @@ class BotManager(zone: Zone) extends Actor {
bots.values.foreach { botState => bots.values.foreach { botState =>
val player = botState.player val player = botState.player
if (player.isAlive) { if (player.isAlive) {
// Update combat first (finds targets, fires) var currentBotState = botState
val newCombatState = updateCombat(botState)
val updatedBotState = botState.copy(combat = newCombatState)
// Update movement (will face target if in combat) // Check if bot is ready (spawn delay passed) and weapon not yet drawn
val newMoveState = updateMovement(updatedBotState, player) val isReady = tickCount >= botState.readyTick
bots(botState.id) = updatedBotState.copy(movement = newMoveState) if (isReady && !botState.weaponDrawn) {
// Now broadcast the weapon draw - client has had time to load
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.ObjectHeld(player.GUID, player.DrawnSlot, player.LastDrawnSlot)
)
log.info(s"${botState.name} has drawn a suppressor from its holster")
currentBotState = currentBotState.copy(weaponDrawn = true)
}
// Only update combat and movement if AI is enabled AND bot is ready
if (aiEnabled && isReady) {
// Update combat (finds targets, fires)
val newCombatState = updateCombat(currentBotState)
currentBotState = currentBotState.copy(combat = newCombatState)
// Update movement (will face target if in combat)
val newMoveState = updateMovement(currentBotState, player)
currentBotState = currentBotState.copy(movement = newMoveState)
}
// If AI disabled or not ready, bot just stands still (no movement/combat updates)
bots(botState.id) = currentBotState
zone.AvatarEvents ! AvatarServiceMessage( zone.AvatarEvents ! AvatarServiceMessage(
zone.id, zone.id,
@ -341,7 +373,7 @@ class BotManager(zone: Zone) extends Actor {
jump_thrust = false, jump_thrust = false,
is_cloaked = false, is_cloaked = false,
spectator = false, spectator = false,
weaponInHand = true weaponInHand = currentBotState.weaponDrawn
) )
) )
} }
@ -640,6 +672,7 @@ object BotManager {
final case class SpawnBot(faction: PlanetSideEmpire.Value, position: Vector3) extends Command final case class SpawnBot(faction: PlanetSideEmpire.Value, position: Vector3) extends Command
final case class DespawnBot(botId: Int) extends Command final case class DespawnBot(botId: Int) extends Command
case object DespawnAllBots extends Command case object DespawnAllBots extends Command
final case class SetAIEnabled(enabled: Boolean) extends Command
private case object Tick extends Command private case object Tick extends Command
private[bot] final case class CompleteSpawn( private[bot] final case class CompleteSpawn(
@ -677,7 +710,9 @@ object BotManager {
avatarActor: ActorRef[AvatarActor.Command], avatarActor: ActorRef[AvatarActor.Command],
movement: MovementState = MovementState(), movement: MovementState = MovementState(),
combat: CombatState = CombatState(), combat: CombatState = CombatState(),
spawnPosition: Vector3 = Vector3.Zero spawnPosition: Vector3 = Vector3.Zero,
readyTick: Int = 0, // Tick when bot is ready for combat (after spawn delay)
weaponDrawn: Boolean = false // Whether weapon draw has been broadcast to clients
) )
case class TapOutInfo( case class TapOutInfo(

View file

@ -146,6 +146,8 @@ class ChatLogic(val ops: ChatOperations, implicit val context: ActorContext) ext
case "botnc" => ops.customCommandBot(session, PlanetSideEmpire.NC) case "botnc" => ops.customCommandBot(session, PlanetSideEmpire.NC)
case "bottr" => ops.customCommandBot(session, PlanetSideEmpire.TR) case "bottr" => ops.customCommandBot(session, PlanetSideEmpire.TR)
case "botvs" => ops.customCommandBot(session, PlanetSideEmpire.VS) case "botvs" => ops.customCommandBot(session, PlanetSideEmpire.VS)
case "boton" => ops.customCommandBotAI(session, enabled = true)
case "botoff" => ops.customCommandBotAI(session, enabled = false)
case _ => case _ =>
// command was not handled // command was not handled
sendResponse( sendResponse(

View file

@ -1434,6 +1434,21 @@ class ChatOperations(
true true
} }
def customCommandBotAI(
session: Session,
enabled: Boolean
): Boolean = {
val zone = session.zone
val state = if (enabled) "ON" else "OFF"
zone.BotManager ! BotManager.SetAIEnabled(enabled)
sendResponse(
ChatMsg(CMT_GMOPEN, wideContents = false, "Server", s"Bot AI is now $state", None)
)
true
}
override protected[session] def stop(): Unit = { override protected[session] def stop(): Unit = {
silenceTimer.cancel() silenceTimer.cancel()
chatService ! ChatService.LeaveAllChannels(chatServiceAdapter) chatService ! ChatService.LeaveAllChannels(chatServiceAdapter)