diff --git a/game/src/main/java/jake2/game/SubgameEntity.java b/game/src/main/java/jake2/game/SubgameEntity.java index 55370825..3ad77dcb 100644 --- a/game/src/main/java/jake2/game/SubgameEntity.java +++ b/game/src/main/java/jake2/game/SubgameEntity.java @@ -1,7 +1,7 @@ package jake2.game; import jake2.game.adapters.*; -import jake2.game.character.BehaviorTree; +import jake2.game.character.BtContext; import jake2.game.character.GameCharacter; import jake2.game.components.ThinkComponent; import jake2.game.items.GameItem; @@ -232,7 +232,7 @@ public SubgameEntity(int i) { public Map components = new HashMap<>(); public GameCharacter character; // todo: move to a component - public BehaviorTree controller; // todo: move to a component? + public BtContext btContext; // todo: move to a component? // todo: replace with a constructor call? void G_InitEdict(int i) { diff --git a/game/src/main/kotlin/jake2/game/character/AnimationSequenceState.kt b/game/src/main/kotlin/jake2/game/character/AnimationSequenceState.kt index b42ee1b0..6a8f1260 100644 --- a/game/src/main/kotlin/jake2/game/character/AnimationSequenceState.kt +++ b/game/src/main/kotlin/jake2/game/character/AnimationSequenceState.kt @@ -13,13 +13,13 @@ open class AnimationSequenceState( eventProcessor.process(listOf(zeroEvent)) } - override fun update(time: Float): String? { + override fun update(time: Float): Pair> { val events = animationSequence.update(time) eventProcessor.process(events) if (animationSequence.finished) - return nextState + return nextState to events - return null + return null to emptySet() } override val currentFrame: Int diff --git a/game/src/main/kotlin/jake2/game/character/BehaviorTree.kt b/game/src/main/kotlin/jake2/game/character/BehaviorTree.kt index 2226c855..f28313c9 100644 --- a/game/src/main/kotlin/jake2/game/character/BehaviorTree.kt +++ b/game/src/main/kotlin/jake2/game/character/BehaviorTree.kt @@ -1,75 +1,109 @@ package jake2.game.character -enum class BtNodeState { - Success, - Failure, - Running +sealed class BtNodeResult + +/** + * Represents a logical outcome of an internal behaviour tree node. Used to control the execution flow (see sequence & selector). + */ +internal class BtFinalResult(val success: Boolean) : BtNodeResult() + +/** + * Represents a decision that the behavior tree has made - an action that can be executed by the character. + * Usually the last node of a sequence (leaf). + */ +abstract class BtTask(val action: T): BtNodeResult() +class BtActionTask(action: T, var duration: Int = 1): BtTask(action) + +class BtWaitingTask(action: T, val waitingFor: String): BtTask(action) + +class BtContext( + private val tree: BehaviorTree +) { + private var currentTask: BtNodeResult? = null + + fun reset() { + currentTask = null + } + + fun update(events: Collection): BtNodeResult? { + println("Bt events: $events") + val task = currentTask + currentTask = if (task != null) { + when (task) { + is BtActionTask<*> -> { + if (task.duration > 0) { + task.duration-- + task + } else { + tree.run() + } + } + is BtWaitingTask<*> -> { + if (task.waitingFor in events) { + tree.run() + } else { + task + } + } + // fixme: these outcomes are errors + is BtFinalResult -> null + is BtTask<*> -> null + } + } else { + tree.run() + } + return currentTask + } } interface BehaviorTree { - fun run(): BtNodeState + fun run(): BtNodeResult } abstract class BhAbstractNode(protected val nodes: List): BehaviorTree { - abstract override fun run(): BtNodeState + abstract override fun run(): BtNodeResult } /** - * Executes all nodes until a failure, then returns failure. Returns success otherwise + * Executes all nodes until a failure or a task outcome, then returns. Returns success otherwise. + * Used to represent a series of nodes related to a certain task. + * Usually consists of checks returning a [BtFinalResult] and a terminal node with a [BtActionTask] decision. */ class BhSequence(vararg nodes: BhAbstractNode) : BhAbstractNode(nodes.asList()) { - override fun run(): BtNodeState { + override fun run(): BtNodeResult { nodes.forEach { - var result = it.run() - while (result == BtNodeState.Running) { - result = it.run() + when (val result = it.run()) { + is BtFinalResult -> if (!result.success) return result + is BtTask<*> -> return result } - if (result == BtNodeState.Failure) return BtNodeState.Failure } - return BtNodeState.Success + return BtFinalResult(true) } } /** - * Executes all nodes until a success, then returns success. Returns failure otherwise + * Executes all nodes until a success or a task outcome, then returns. Returns failure otherwise. + * Usually aggregates a group of sequences and defines priorities between them. */ class BhSelector(vararg nodes: BhAbstractNode) : BhAbstractNode(nodes.asList()) { - override fun run(): BtNodeState { + override fun run(): BtNodeResult { nodes.forEach { - var result = it.run() - while (result == BtNodeState.Running) { - result = it.run() + when (val result = it.run()) { + is BtFinalResult -> if (result.success) return result + is BtTask<*> -> return result } - if (result == BtNodeState.Success) return BtNodeState.Success } - return BtNodeState.Failure + return BtFinalResult(false) } } -/** - * Leaf node, Can be condition or action (side effect) - */ -class BtNode(private val condition: () -> Boolean) : BhAbstractNode(emptyList()) { - override fun run(): BtNodeState { - return if (condition.invoke()) - BtNodeState.Success - else - BtNodeState.Failure - } +class BtConditionNode(private val condition: () -> Boolean) : BhAbstractNode(emptyList()) { + override fun run(): BtNodeResult = BtFinalResult(condition.invoke()) } -class BtEventNode(private val event: String, private val condition: () -> Boolean): BhAbstractNode(emptyList()) { - val events: Set = TODO() - - override fun run(): BtNodeState { - return if (!events.contains(event)) - BtNodeState.Running - else if (condition.invoke()) - BtNodeState.Success - else - BtNodeState.Failure - } +class BtLeafNode(private val action: () -> BtTask<*>) : BhAbstractNode(emptyList()) { + override fun run(): BtNodeResult = action.invoke() } // short names (come up with better names?) @@ -77,10 +111,6 @@ fun selector(vararg nodes: BhAbstractNode) = BhSelector(*nodes) fun sequence(vararg nodes: BhAbstractNode) = BhSequence(*nodes) -fun check(condition: () -> Boolean) = BtNode(condition) - -fun run(condition: () -> Unit) = BtNode { condition.invoke(); true } +fun check(condition: () -> Boolean) = BtConditionNode(condition) -fun runUntil(event: String, condition: () -> Unit) = BtEventNode(event) { - condition.invoke(); true -} \ No newline at end of file +fun run(action: () -> BtTask<*>) = BtLeafNode(action) \ No newline at end of file diff --git a/game/src/main/kotlin/jake2/game/character/GameCharacter.kt b/game/src/main/kotlin/jake2/game/character/GameCharacter.kt index a3e574a1..f05db0a9 100644 --- a/game/src/main/kotlin/jake2/game/character/GameCharacter.kt +++ b/game/src/main/kotlin/jake2/game/character/GameCharacter.kt @@ -157,7 +157,7 @@ class GameCharacter( override fun process(events: Collection) { events.forEach { - println("processing event: $it") + println("Character: processing event: $it") when { it == "try-fidget" -> { @@ -182,31 +182,10 @@ class GameCharacter( println("finished firing") return@forEach } - // todo: this piece is awful - val start = floatArrayOf(0f, 0f, 0f) - val target = floatArrayOf(0f, 0f, 0f) - val forward = floatArrayOf(0f, 0f, 0f) - val right = floatArrayOf(0f, 0f, 0f) - val vec = floatArrayOf(0f, 0f, 0f) - val flash_number = Defines.MZ2_INFANTRY_MACHINEGUN_1 - - Math3D.AngleVectors(self.s.angles, forward, right, null) - Math3D.G_ProjectSource( - self.s.origin, - M_Flash.monster_flash_offset[flash_number], forward, - right, start - ) - if (self.enemy != null) { - Math3D.VectorMA( - self.enemy.s.origin, -0.2f, - self.enemy.velocity, target - ) - target[2] += self.enemy.viewheight.toFloat() - Math3D.VectorSubtract(target, start, forward) - Math3D.VectorNormalize(forward) - } else { - Math3D.AngleVectors(self.s.angles, forward, right, null) - } + + aimAtEnemy() + + val (start, forward, flash_number) = monsterFirePrepare() Monster.monster_fire_bullet( self, start, forward, 3, 4, @@ -228,6 +207,35 @@ class GameCharacter( } } + private fun monsterFirePrepare(): Triple { + // todo: this piece is awful + val start = floatArrayOf(0f, 0f, 0f) + val target = floatArrayOf(0f, 0f, 0f) + val forward = floatArrayOf(0f, 0f, 0f) + val right = floatArrayOf(0f, 0f, 0f) + val vec = floatArrayOf(0f, 0f, 0f) + val flash_number = Defines.MZ2_INFANTRY_MACHINEGUN_1 + + Math3D.AngleVectors(self.s.angles, forward, right, null) + Math3D.G_ProjectSource( + self.s.origin, + M_Flash.monster_flash_offset[flash_number], forward, + right, start + ) + if (self.enemy != null) { + Math3D.VectorMA( + self.enemy.s.origin, -0.2f, + self.enemy.velocity, target + ) + target[2] += self.enemy.viewheight.toFloat() + Math3D.VectorSubtract(target, start, forward) + Math3D.VectorNormalize(forward) + } else { + Math3D.AngleVectors(self.s.angles, forward, right, null) + } + return Triple(start, forward, flash_number) + } + // ai wants to walk, currently stunned // ai wants to walk, currently idle override fun transitionAllowed(from: StateType, to: StateType): Boolean { @@ -239,7 +247,7 @@ class GameCharacter( } } - fun update(time: Float) = stateMachine.update(time) + fun updateStateMachine(time: Float): Collection = stateMachine.update(time) // // these commands are called either by AI or a Player. @@ -248,9 +256,13 @@ class GameCharacter( // todo: all these actions should check if character is not dead or somehow disabled // usually it's verified by the state machine, but when it's not used (like in the aim() method) - should be checked explicitly - fun aim(enemyYaw: Float) { + fun aimAtEnemy() { if (notDisabled()) { - self.ideal_yaw = enemyYaw + if (self.enemy != null) { + val distance = floatArrayOf(0f, 0f, 0f) + Math3D.VectorSubtract(self.enemy.s.origin, self.s.origin, distance) + self.ideal_yaw = Math3D.vectoyaw(distance) + } M.rotateToIdealYaw(self) } } @@ -258,12 +270,14 @@ class GameCharacter( fun walk() { if (stateMachine.attemptStateChange("walk")) { + self.character.aimAtEnemy() M.M_walkmove(self, self.ideal_yaw, 5f, game) } } fun run() { if (stateMachine.attemptStateChange("run")) { + self.character.aimAtEnemy() M.M_walkmove(self, self.ideal_yaw, 15f, game) } } @@ -302,7 +316,7 @@ class GameCharacter( stateMachine.attemptStateChange("dead") } - private fun notDisabled() = + fun notDisabled() = stateMachine.currentState.type != StateType.PAIN && stateMachine.currentState.type != StateType.DEAD // todo: refactor to SoundEvent @@ -313,7 +327,25 @@ class GameCharacter( timeOffset: Float = 0f) = game.gameImports.sound(self, channel, soundIndex, volume, attenuation, timeOffset) - + fun executeAction(action: Any) { + when (action as? EnforcerActions) { + EnforcerActions.ATTACK_AIM_RANGED -> { + self.character.attackRanged(Random.nextInt(15) + 10) + } + EnforcerActions.ATTACK_MELEE -> self.character.attackMelee() + EnforcerActions.WALK -> self.character.walk() + EnforcerActions.RUN -> self.character.run() + EnforcerActions.IDLE -> self.character.idle() + null -> TODO() + } + } +} +enum class EnforcerActions { + ATTACK_AIM_RANGED, + ATTACK_MELEE, + WALK, + RUN, + IDLE } fun spawnNewMonster(self: SubgameEntity, game: GameExportsImpl) { @@ -343,51 +375,49 @@ fun spawnNewMonster(self: SubgameEntity, game: GameExportsImpl) { 5. if !triggered & has path-target -> goto path-target 6. else: Idle */ - self.controller = selector( + self.btContext = BtContext(selector( // triggered (attacked or activated from another entity) sequence( // should hunt the enemy? - check { self.enemy != null }, - // rotate towards the enemy - run { - val distance = floatArrayOf(0f, 0f, 0f) - Math3D.VectorSubtract(self.enemy.s.origin, self.s.origin, distance) - val enemyYaw = Math3D.vectoyaw(distance) - - self.character.aim(enemyYaw) - }, + check { self.enemy != null && self.enemy.health > 0 }, // attack or chase selector( sequence( check { SV.SV_CloseEnough(self, self.enemy, 16f) }, // todo: see jake2.game.GameUtil.range - run { self.character.attackMelee() } + run { BtWaitingTask(EnforcerActions.ATTACK_MELEE, "finished-attack-melee") } ), sequence( check { SV.SV_CloseEnough(self, self.enemy, 32f) }, - runUntil("attack-finished") { - val framesToAttack = Random.nextInt(15) + 10 - self.character.attackRanged(framesToAttack) - } + run { BtWaitingTask(EnforcerActions.ATTACK_AIM_RANGED, "finished-attack-ranged-finish") } ), sequence( check { SV.SV_CloseEnough(self, self.enemy, 100f) }, - run { self.character.walk() } + run { BtActionTask(EnforcerActions.WALK) } ), - run { self.character.run() } + run { BtActionTask(EnforcerActions.RUN) } ), ), sequence( - run { self.character.idle() } + run { BtActionTask(EnforcerActions.IDLE) } ) - ) + )) self.think = ThinkComponent().apply { nextTime = game.level.time + Defines.FRAMETIME // fixme: register think should be called before level loading (due to de/serialization) action = registerThink("new_monster_think") { self, game -> // YES! new good stuff - self.controller.run() // todo: don't need to run the controller every frame. Sometimes can be paused - self.character.update(Defines.FRAMETIME) + val events = self.character.updateStateMachine(Defines.FRAMETIME) + + // think (run BT) only if not disabled + if (self.character.notDisabled()) { + val task = self.btContext.update(events) + if (task is BtTask<*>) + self.character.executeAction(task.action!!) + // fixme: else -> throw an error because all leafs should be tasks + } else { + self.btContext.reset() + } // leftovers self.s.frame = self.character.currentFrame diff --git a/game/src/main/kotlin/jake2/game/character/State.kt b/game/src/main/kotlin/jake2/game/character/State.kt index 41c18701..c0446639 100644 --- a/game/src/main/kotlin/jake2/game/character/State.kt +++ b/game/src/main/kotlin/jake2/game/character/State.kt @@ -19,6 +19,11 @@ abstract class State( abstract val currentFrame: Int // meh.. need to rethink the applicability of OOP here open fun enter() {} - open fun update(time: Float): String? = null + + /** + * returns a pair of next state name and the set of events (passed to the BT) + */ + open fun update(time: Float): Pair> = null to emptySet() + open fun exit() = true } \ No newline at end of file diff --git a/game/src/main/kotlin/jake2/game/character/StateMachine.kt b/game/src/main/kotlin/jake2/game/character/StateMachine.kt index c3039cc4..64657e4e 100644 --- a/game/src/main/kotlin/jake2/game/character/StateMachine.kt +++ b/game/src/main/kotlin/jake2/game/character/StateMachine.kt @@ -14,10 +14,16 @@ class StateMachine( } - fun update(time: Float) { - val nextState = currentState.update(time) - if (nextState != null) + /** + * returns a set of events executed during the timeframe [time] + */ + fun update(time: Float): Collection { + var (nextState, events) = currentState.update(time) + if (nextState != null) { + events += "finished-" + currentState.name attemptStateChange(nextState, true) + } + return events } fun attemptStateChange(nextStateName: String, force: Boolean = false): Boolean { diff --git a/game/src/test/java/BehaviorTreeTest.kt b/game/src/test/java/BehaviorTreeTest.kt deleted file mode 100644 index 31ac0711..00000000 --- a/game/src/test/java/BehaviorTreeTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -import jake2.game.character.BhSelector -import jake2.game.character.BhSequence -import jake2.game.character.BtNode -import jake2.game.character.BtNodeState -import org.junit.Assert.assertTrue -import org.junit.Test - -class BehaviorTreeTest { - @Test - fun testBhSequenceSuccess() { - val sequence = BhSequence( - BtNode { true }, - BtNode { true } - ) - - val result = sequence.run() - - assertTrue(result == BtNodeState.Success) - } - - @Test - fun testBhSequenceFailure() { - val sequence = BhSequence( - BtNode { true }, - BtNode { false } - ) - - val result = sequence.run() - - assertTrue(result == BtNodeState.Failure) - } - - @Test - fun testBhSelectorSuccess() { - val selector = BhSelector( - BtNode { false }, - BtNode { true } - ) - - val result = selector.run() - - assertTrue(result == BtNodeState.Success) - } - - @Test - fun testBhSelectorFailure() { - val selector = BhSelector( - BtNode { false }, - BtNode { false } - ) - - val result = selector.run() - - assertTrue(result == BtNodeState.Failure) - } -}