Skip to content

Commit

Permalink
feat(combat): auto-combat can use items / spells
Browse files Browse the repository at this point in the history
 - add `chooseMove` helper to determine which actions to select.
 - use heal spell when party members are injured if it's owned
 - use push spell to damage enemies if there are no injured party members and it's owned
 - use a potion on injured party members if one is owned
 - attack the enemy with the least HP as a final resort
 - add tests
  • Loading branch information
justindujardin committed Nov 28, 2022
1 parent 88f4999 commit c1c7ed8
Show file tree
Hide file tree
Showing 2 changed files with 190 additions and 12 deletions.
98 changes: 97 additions & 1 deletion src/app/routes/combat/states/combat-choose-action.state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,26 @@ import { discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/te
import { RouterTestingModule } from '@angular/router/testing';
import { take } from 'rxjs/operators';
import { APP_IMPORTS } from '../../../app.imports';
import { testAppAddToInventory } from '../../../app.testing';
import { Point, Rect } from '../../../core';
import { ITEMS_DATA } from '../../../models/game-data/items';
import { MAGIC_DATA } from '../../../models/game-data/magic';
import { assertTrue } from '../../../models/util';
import { GameEntityObject } from '../../../scene/objects/game-entity-object';
import { SceneView } from '../../../scene/scene-view';
import { CombatChooseActionStateComponent } from './combat-choose-action.state';
import { GameWorld } from '../../../services/game-world';
import { RPGGame } from '../../../services/rpg-game';
import {
CombatAttackBehaviorComponent,
CombatItemBehavior,
CombatMagicBehavior,
} from '../behaviors/actions';
import { CombatEnemyComponent } from '../combat-enemy.component';
import { testCombatCreateComponent } from '../combat.testing';
import {
chooseMove,
CombatChooseActionStateComponent,
} from './combat-choose-action.state';

function getPointerPosition(comp: CombatChooseActionStateComponent): Point {
let point = new Point();
Expand All @@ -17,11 +33,91 @@ function getPointerPosition(comp: CombatChooseActionStateComponent): Point {
}

describe('CombatChooseActionStateComponent', () => {
let world: GameWorld;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RouterTestingModule, ...APP_IMPORTS],
declarations: [CombatChooseActionStateComponent],
}).compileComponents();
const game = TestBed.inject(RPGGame);
await game.initGame(false);
world = TestBed.inject(GameWorld);
world.time.start();
});
afterEach(() => {
world.time.stop();
});

describe('chooseMove', async () => {
it('casts heal on injured party members', async () => {
testAppAddToInventory(world.store, 'heal', MAGIC_DATA);
const combat = testCombatCreateComponent(null);
const party = combat.party.toArray();
const player = party.find((p) => p.model.type === 'healer');
assertTrue(player, 'could not find healer to cast heal');
player.model = { ...player.model, hp: 10 };
const enemies = combat.enemies.toArray();
const action = chooseMove(
player,
enemies,
party,
combat.machine.spells.toJS(),
combat.machine.items.toJS()
);
expect(action instanceof CombatMagicBehavior).toBe(true);
expect(action.spell?.id).toBe('heal');
});
it('casts push on enemies if available', async () => {
testAppAddToInventory(world.store, 'push', MAGIC_DATA);
const combat = testCombatCreateComponent(null);
const party = combat.party.toArray();
const player = party.find((p) => p.model.type === 'healer');
assertTrue(player, 'could not find healer to cast heal');
const enemies = combat.enemies.toArray();
const action = chooseMove(
player,
enemies,
party,
combat.machine.spells.toJS(),
combat.machine.items.toJS()
);
expect(action instanceof CombatMagicBehavior).toBe(true);
expect(action.spell?.id).toBe('push');
});
it('uses potion on injured party members', async () => {
testAppAddToInventory(world.store, 'potion', ITEMS_DATA);
const combat = testCombatCreateComponent(null);
const party = combat.party.toArray();
const player = party.find((p) => p.model.type === 'warrior');
assertTrue(player, 'could not find player');
player.model = { ...player.model, hp: 10 };
const enemies = combat.enemies.toArray();
const action = chooseMove(
player,
enemies,
party,
combat.machine.spells.toJS(),
combat.machine.items.toJS()
);
expect(action instanceof CombatItemBehavior).toBe(true);
expect(action.item?.id).toBe('potion');
});
it('attacks enemies as a last resort', async () => {
const combat = testCombatCreateComponent(null);
const party = combat.party.toArray();
const player = party.find((p) => p.model.type === 'warrior');
assertTrue(player, 'could not find player');
const enemies = combat.enemies.toArray();
const action = chooseMove(
player,
enemies,
party,
combat.machine.spells.toJS(),
combat.machine.items.toJS()
);
expect(action instanceof CombatAttackBehaviorComponent).toBe(true);
expect(action.to instanceof CombatEnemyComponent).toBe(true);
});
});

describe('pointerPosition$', () => {
Expand Down
104 changes: 93 additions & 11 deletions src/app/routes/combat/states/combat-choose-action.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,89 @@ import { BehaviorSubject, interval, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map } from 'rxjs/operators';
import * as _ from 'underscore';
import { Point } from '../../../../app/core/point';
import { Item, Magic } from '../../../models/item';
import { assertTrue } from '../../../models/util';
import { GameEntityObject } from '../../../scene/objects/game-entity-object';
import { SceneView } from '../../../scene/scene-view';
import { CombatAttackBehaviorComponent } from '../behaviors/actions';
import {
CombatAttackBehaviorComponent,
CombatItemBehavior,
CombatMagicBehavior,
} from '../behaviors/actions';
import { ChooseActionStateMachine } from '../behaviors/choose-action.machine';
import { CombatActionBehavior } from '../behaviors/combat-action.behavior';
import { CombatPlayerComponent } from '../combat-player.component';
import { ICombatMenuItem } from '../combat.types';
import { CombatMachineState } from './combat-base.state';
import { CombatStateMachineComponent } from './combat.machine';
import { CombatStateNames } from './states';

export function chooseMove(
player: GameEntityObject,
enemies: GameEntityObject[],
party: CombatPlayerComponent[],
spells: Magic[],
items: Item[]
): CombatActionBehavior {
const magicAction = player.findBehavior<CombatMagicBehavior>(CombatMagicBehavior);
const hurtPartyMember = party
// Sort the member with least HP to the front
.sort((a, b) => (a.model.hp < b.model.hp ? -1 : 1))
// Find a member with < threshold hp
.find((p) => p.model.hp < p.model.maxhp * 0.65);

// Choose the enemy with the least HP
const enemy = enemies.sort((a, b) => {
const aHP = a?.model?.hp as number;
const bHP = b?.model?.hp as number;
return aHP < bHP ? -1 : 1;
})[0];

// Magic User
if (magicAction && magicAction.canBeUsedBy(player)) {
const hasHeal = spells.find((s) => s.id === 'heal');
const hasPush = spells.find((s) => s.id === 'push');

// Heal hurt party members
if (hurtPartyMember && hasHeal) {
magicAction.from = player;
magicAction.to = hurtPartyMember;
magicAction.spell = hasHeal;
return magicAction;
}

// Hurt enemies
if (hasPush) {
magicAction.from = player;
magicAction.to = enemy;
magicAction.spell = hasPush;
return magicAction;
}
}

// Usable items
const itemAction = player.findBehavior<CombatItemBehavior>(CombatItemBehavior);
if (itemAction && itemAction.canBeUsedBy(player)) {
const hasPotion = items.find((i) => i.id.includes('potion'));
// Use potions on hurt party members
if (hurtPartyMember && hasPotion) {
itemAction.from = player;
itemAction.to = hurtPartyMember;
itemAction.item = hasPotion;
return itemAction;
}
}

// Default to attacking
const action = player.findBehavior<CombatAttackBehaviorComponent>(
CombatAttackBehaviorComponent
);
assertTrue(action, `attack action not found on: ${player.name}`);
action.from = player;
action.to = enemy;
return action;
}

/**
* Choose actions for all characters in the player-card.
*/
Expand Down Expand Up @@ -169,22 +241,32 @@ export class CombatChooseActionStateComponent extends CombatMachineState {
this.pointerClass = '';

assertTrue(this.machine, 'invalid state machine');
let items: Item[] = this.machine.items.toJS();

for (let i = 0; i < remainder.length; i++) {
const player = remainder[i];
// TODO: Support more than attack actions
const action = player.findBehavior<CombatAttackBehaviorComponent>(
CombatAttackBehaviorComponent
);

assertTrue(this.machine.party, 'no party');
const party = this.machine.party.toArray().filter((p) => p.model.hp > 0);

assertTrue(this.machine.enemies, 'no enemies');
const enemy = this.machine.enemies.toArray()[0];
const enemies = this.machine.enemies
.toArray()
.filter((p) => Number(p.model?.hp) > 0);

assertTrue(action, `attack action not found on: ${player.name}`);
const id = player._uid;
action.from = player;
action.to = enemy;
this.machine.playerChoices[id] = action;
const action = chooseMove(
player,
enemies,
party,
this.machine.spells.toJS(),
items
);

// Filter used items from next user choices
if (action.item) {
items = items.filter((i) => i.eid !== action.item?.eid);
}
this.machine.playerChoices[player._uid] = action;
console.log(`[autoCombat] ${player.model?.name} chose ${action.name}`);
}
this.machine.setCurrentState('begin-turn');
Expand Down

0 comments on commit c1c7ed8

Please sign in to comment.