diff --git a/angular-rpg.tiled-session b/angular-rpg.tiled-session index 935cb9c..08ef33a 100644 --- a/angular-rpg.tiled-session +++ b/angular-rpg.tiled-session @@ -1,38 +1,258 @@ { - "activeFile": "src/assets/maps/town.tmx", + "activeFile": "src/assets/maps/wilderness.tmx", "expandedProjectPaths": [ + ".", + "src/assets", + "src/art/sprites/characters/magic", + "src/tiled/createdialogtool", "src", - "src/assets/maps/templates", - "src/art/sprites/characters/punch", "src/assets/maps", - "src/art/sprites", - "src/tiled/createdialogtool", - ".", - "src/art", "src/tiled", - "src/assets/maps/tiles", - "src/art/sprites/characters/magic", + "src/art/sprites", + "src/art/sprites/characters/punch", "src/assets/test", - "src/assets" + "src/art", + "src/assets/maps/templates", + "src/assets/maps/tiles" ], "file.lastUsedOpenFilter": "All Files (*)", - "fileStates": {}, + "fileStates": { + "src/assets/maps/castle.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 4, + "selectedLayer": 1, + "viewCenter": { + "x": 263.875, + "y": 135.875 + } + }, + "src/assets/maps/combat.tmx": { + "scale": 3.5070833333333336, + "selectedLayer": 9, + "viewCenter": { + "x": 239.94297255554233, + "y": 159.8194130925508 + } + }, + "src/assets/maps/crypt.tmx": { + "expandedObjectLayers": [ + 2, + 3 + ], + "scale": 1.9243589743589742, + "selectedLayer": 1, + "viewCenter": { + "x": 199.80679546968688, + "y": 311.53231179213856 + } + }, + "src/assets/maps/fortress1.tmx": { + "expandedObjectLayers": [ + 2, + 3 + ], + "scale": 2.420967741935484, + "selectedLayer": 1, + "viewCenter": { + "x": 223.6708860759494, + "y": 247.62824783477686 + } + }, + "src/assets/maps/fortress2.tmx": { + "expandedObjectLayers": [ + 4, + 3 + ], + "scale": 3.002, + "selectedLayer": 2, + "viewCenter": { + "x": 199.70019986675553, + "y": 199.70019986675553 + } + }, + "src/assets/maps/isle.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 3.9499999999999997, + "selectedLayer": 1, + "viewCenter": { + "x": 199.873417721519, + "y": 151.7721518987342 + } + }, + "src/assets/maps/keep.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 3.2630434782608697, + "selectedLayer": 1, + "viewCenter": { + "x": 183.72418387741504, + "y": 183.72418387741504 + } + }, + "src/assets/maps/lair.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 1.4715686274509803, + "selectedLayer": 1, + "viewCenter": { + "x": 231.38574283810794, + "y": 407.3884077281813 + } + }, + "src/assets/maps/port.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 3.411363636363636, + "selectedLayer": 1, + "viewCenter": { + "x": 231.7255163224517, + "y": 175.73617588274487 + } + }, + "src/assets/maps/ruins.tmx": { + "expandedObjectLayers": [ + 3 + ], + "scale": 2.587931034482758, + "selectedLayer": 2, + "viewCenter": { + "x": 151.66555629580284, + "y": 231.6522318454364 + } + }, + "src/assets/maps/sewer.tmx": { + "expandedObjectLayers": [ + 2, + 3 + ], + "scale": 1.8304878048780486, + "selectedLayer": 1, + "viewCenter": { + "x": 183.83077948034645, + "y": 327.508327781479 + } + }, + "src/assets/maps/tiles/creatures.tsx": { + "dynamicWrapping": true, + "scaleInDock": 1 + }, + "src/assets/maps/tiles/environment.tsx": { + "scaleInDock": 3 + }, + "src/assets/maps/tiles/objects.tsx": { + "dynamicWrapping": true, + "scaleInDock": 4, + "scaleInEditor": 11 + }, + "src/assets/maps/tower1.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 3.9499999999999997, + "selectedLayer": 1, + "viewCenter": { + "x": 183.92405063291142, + "y": 151.7721518987342 + } + }, + "src/assets/maps/tower2.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 3.9499999999999997, + "selectedLayer": 1, + "viewCenter": { + "x": 151.7721518987342, + "y": 151.7721518987342 + } + }, + "src/assets/maps/tower3.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 6.822727272727272, + "selectedLayer": 1, + "viewCenter": { + "x": 87.86808794137241, + "y": 87.86808794137244 + } + }, + "src/assets/maps/town.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 3.411363636363636, + "selectedLayer": 1, + "viewCenter": { + "x": 215.89606928714193, + "y": 175.73617588274487 + } + }, + "src/assets/maps/village.tmx": { + "expandedObjectLayers": [ + 2 + ], + "scale": 3.5738095238095235, + "selectedLayer": 1, + "viewCenter": { + "x": 111.7854763491006, + "y": 167.7481678880746 + } + }, + "src/assets/maps/wilderness.tmx": { + "expandedObjectLayers": [ + 6, + 5 + ], + "scale": 1.5, + "selectedLayer": 3, + "viewCenter": { + "x": 1108.6666666666665, + "y": 290 + } + } + }, + "last.imagePath": "C:/Users/justi/Source/angular-rpg/src/art/sprites/objects", "openFiles": [ - "src/assets/maps/town.tmx" + "src/assets/maps/town.tmx", + "src/assets/maps/sewer.tmx", + "src/assets/maps/ruins.tmx", + "src/assets/maps/village.tmx", + "src/assets/maps/wilderness.tmx", + "src/assets/maps/castle.tmx", + "src/assets/maps/port.tmx", + "src/assets/maps/lair.tmx", + "src/assets/maps/fortress1.tmx", + "src/assets/maps/fortress2.tmx", + "src/assets/maps/combat.tmx", + "src/assets/maps/crypt.tmx", + "src/assets/maps/isle.tmx", + "src/assets/maps/keep.tmx", + "src/assets/maps/tower1.tmx", + "src/assets/maps/tower2.tmx", + "src/assets/maps/tower3.tmx" ], "project": "angular-rpg.tiled-project", + "property.type": "string", "recentFiles": [ "src/assets/maps/town.tmx", - "src/assets/maps/castle.tmx", + "src/assets/maps/sewer.tmx", + "src/assets/maps/ruins.tmx", "src/assets/maps/village.tmx", + "src/assets/maps/tower3.tmx", + "src/assets/maps/tower2.tmx", + "src/assets/maps/tower1.tmx", + "src/assets/maps/keep.tmx", "src/assets/maps/isle.tmx", + "src/assets/maps/crypt.tmx", "src/assets/maps/combat.tmx", - "src/assets/maps/ruins.tmx", - "src/assets/maps/wilderness.tmx", - "src/assets/maps/lair.tmx", - "src/assets/maps/port.tmx", - "src/assets/maps/sewer.tmx", - "src/assets/maps/tower1.tmx", - "src/assets/maps/tower2.tmx" + "src/assets/maps/fortress2.tmx" ] } diff --git a/karma.conf.js b/karma.conf.js index 4592e8a..fb282b8 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -40,6 +40,8 @@ module.exports = function (config) { // Don't hang rAF/timers when in background "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", + // Don't play audio effects + "--mute-audio", ], }, ChromeDebug: { diff --git a/package-lock.json b/package-lock.json index 00316fa..33df2a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "@angular/compiler-cli": "^17.1.3", "@babel/core": "^7.23.9", "@compodoc/compodoc": "^1.1.19", - "@mapeditor/tiled-api": "^1.9.2", "@semantic-release/changelog": "^6.0.0", "@semantic-release/commit-analyzer": "^9.0.2", "@semantic-release/git": "^10.0.0", diff --git a/package.json b/package.json index 76db8ff..9819302 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,6 @@ "@angular/compiler-cli": "^17.1.3", "@babel/core": "^7.23.9", "@compodoc/compodoc": "^1.1.19", - "@mapeditor/tiled-api": "^1.9.2", "@semantic-release/changelog": "^6.0.0", "@semantic-release/commit-analyzer": "^9.0.2", "@semantic-release/git": "^10.0.0", diff --git a/src/app/core/animator.ts b/src/app/core/animator.ts index af3ef20..3ba0201 100644 --- a/src/app/core/animator.ts +++ b/src/app/core/animator.ts @@ -21,7 +21,6 @@ export class Animator { sourceAnims: any = null; setAnimationSource(spriteName: string) { - console.log(`Sprite is ${spriteName}`); this.sourceMeta = getSpriteMeta(spriteName); if (this.sourceMeta) { this.sourceAnims = this.sourceMeta.animations; diff --git a/src/app/core/point.spec.ts b/src/app/core/point.spec.ts index 36e7e31..f4c5894 100644 --- a/src/app/core/point.spec.ts +++ b/src/app/core/point.spec.ts @@ -75,6 +75,13 @@ describe('Point', () => { }); }); + describe('ceil', () => { + it('should truncate floating point by rounding up', () => { + const p1: Point = new Point(15.1, 15.1); + expect(p1.ceil()).toEqual(new Point(16, 16)); + }); + }); + describe('round', () => { it('should round up when decimal is greater than or equal to 0.5', () => { const p1: Point = new Point(15.6, 15.6).round(); diff --git a/src/app/core/point.ts b/src/app/core/point.ts index 515207e..d54fd57 100644 --- a/src/app/core/point.ts +++ b/src/app/core/point.ts @@ -52,6 +52,12 @@ export class Point implements IPoint { return new Point(this.x, this.y); } + ceil(): Point { + this.x = Math.ceil(this.x); + this.y = Math.ceil(this.y); + return this; + } + floor(): Point { this.x = Math.floor(this.x); this.y = Math.floor(this.y); diff --git a/src/app/core/state-machine.ts b/src/app/core/state-machine.ts index 425ea1e..85aa306 100644 --- a/src/app/core/state-machine.ts +++ b/src/app/core/state-machine.ts @@ -11,7 +11,7 @@ export interface IResumeCallback { /** A state change description */ export interface IStateChange { from: State | null; - to: State; + to: State | null; } // Implementation @@ -33,6 +33,19 @@ export class StateMachine { private _previousState: State | null = null; private _pendingStates: [State, (result: boolean) => void][] = []; + /** Destroy the state machine, and exit any current state. */ + async destroy() { + const state = this._currentState; + this._currentState = null; + this._pendingStates = []; + this._previousState = null; + this._transitioning = false; + if (state) { + this.onExitState$.emit({ from: state, to: null }); + await state.exit(this); + } + } + addState(state: State): void { this.states.push(state); } diff --git a/src/app/models/game-data/magic.ts b/src/app/models/game-data/magic.ts index 005f156..13cf1d9 100644 --- a/src/app/models/game-data/magic.ts +++ b/src/app/models/game-data/magic.ts @@ -4,14 +4,14 @@ export const MAGIC_DATA: ITemplateMagic[] = [ { id: 'push', type: 'spell', - name: 'Minor Wind Stone', + name: 'Wind (Push)', level: 1, - magicname: 'Forceful Gust', + magicname: 'Push', icon: 'blueGem.png', target: 'target', magiccost: 4, effect: 'elemental-damage', - magnitude: 2, + magnitude: 1, usedby: ['mage', 'healer'], groups: ['default'], elements: ['wind'], @@ -21,9 +21,9 @@ export const MAGIC_DATA: ITemplateMagic[] = [ { id: 'heal', type: 'spell', - name: 'Minor Life Stone', + name: 'Heal', level: 2, - magicname: 'Healing Bubbles', + magicname: 'Heal', icon: 'turqoiseGem.png', target: 'target', magiccost: 12, diff --git a/src/app/models/levels.ts b/src/app/models/levels.ts index 87995be..05bd308 100644 --- a/src/app/models/levels.ts +++ b/src/app/models/levels.ts @@ -88,7 +88,7 @@ const CLASS_STAT_LEVEL_MAP: IClassStatLevelMap = { luckValue: 1, }, ranger: { - strength: 0.4, + strength: 0.7, strengthValue: 1, agility: 0.9, agilityValue: 1, @@ -113,7 +113,7 @@ const CLASS_STAT_LEVEL_MAP: IClassStatLevelMap = { }, healer: { strength: 0.25, - strengthValue: 2, + strengthValue: 1, agility: 0.45, agilityValue: 2, intelligence: 1.0, diff --git a/src/app/models/mechanics.ts b/src/app/models/mechanics.ts index 43d1621..373fdee 100644 --- a/src/app/models/mechanics.ts +++ b/src/app/models/mechanics.ts @@ -218,9 +218,11 @@ export function spellElementalDamage( target: ICalculateMagicTarget, ): IMagicTargetDelta { // TODO: Check equipment for element-specific bonuses + const randomness = 1 + Math.random(); + const damage = Math.floor(spell.magnitude * (caster.intelligence[0] / randomness)); return { target: target.entity, - healthDelta: -((spell.magnitude * caster.intelligence[0]) / 4), + healthDelta: -damage, }; } @@ -231,7 +233,7 @@ export function spellModifyHP( ): IMagicTargetDelta { // If the spell benefits a user, it restores health, otherwise it drains health. const directionMultiplier = spell.benefit ? 1 : -1; - const delta = (spell.magnitude * caster.intelligence[0]) / 4; + const delta = Math.floor(spell.magnitude * caster.intelligence[0]); return { target: target.entity, healthDelta: delta * directionMultiplier, @@ -321,6 +323,7 @@ export function calculateDamage(config: ICalculateDamageConfig): ICombatDamage { if (defender.status.indexOf('guarding') !== -1) { buffDefense = Math.max(3, Math.round(defense * 1.3)); } + // Minimum of 1 damage const damage: number = Math.max(1, adjustedAttack - (defense + buffDefense)); tableData.push({ attacker: attacker, @@ -477,7 +480,7 @@ export function diffPartyMember( luck: after.luck[0] - before.luck[0], hitpercent: after.hitpercent[0] - before.hitpercent[0], magicdefense: after.magicdefense[0] - before.magicdefense[0], - hp: after.hp - before.hp, - mp: after.mp - before.mp, + hp: after.maxhp - before.maxhp, + mp: after.maxmp - before.maxmp, }; } diff --git a/src/app/routes/combat/behaviors/actions/combat-guard.behavior.ts b/src/app/routes/combat/behaviors/actions/combat-guard.behavior.ts index 8fe3d3e..32d7595 100644 --- a/src/app/routes/combat/behaviors/actions/combat-guard.behavior.ts +++ b/src/app/routes/combat/behaviors/actions/combat-guard.behavior.ts @@ -69,7 +69,7 @@ export class CombatGuardBehavior extends CombatActionBehavior { 'defeat', 'escape', ]; - if (exitStates.includes(to.name)) { + if (to && exitStates.includes(to.name)) { const model: CombatantTypes = this.from?.model as CombatantTypes; assertTrue(model, 'invalid guard behavior model'); this.store.dispatch( diff --git a/src/app/routes/combat/behaviors/actions/combat-item.behavior.ts b/src/app/routes/combat/behaviors/actions/combat-item.behavior.ts index 2165ea0..5d92be9 100644 --- a/src/app/routes/combat/behaviors/actions/combat-item.behavior.ts +++ b/src/app/routes/combat/behaviors/actions/combat-item.behavior.ts @@ -1,20 +1,22 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, ViewChild } from '@angular/core'; import { Store } from '@ngrx/store'; -import { take } from 'rxjs/operators'; +import { BehaviorSubject } from 'rxjs'; +import { filter, take } from 'rxjs/operators'; import { AppState } from '../../../../app.model'; -import { AnimatedSpriteBehavior } from '../../../../behaviors/animated-sprite.behavior'; import { SoundBehavior } from '../../../../behaviors/sound-behavior'; -import { SpriteComponent } from '../../../../behaviors/sprite.behavior'; -import { ResourceManager } from '../../../../core'; +import { AnimatedComponent } from '../../../../components'; +import { Point, ResourceManager } from '../../../../core'; import { getSoundEffectUrl } from '../../../../core/api'; import { CombatAttackAction } from '../../../../models/combat/combat.actions'; import { CombatAttack } from '../../../../models/combat/combat.model'; import { GameStateRemoveInventoryAction } from '../../../../models/game-state/game-state.actions'; import { assertTrue } from '../../../../models/util'; import { GameEntityObject } from '../../../../scene/objects/game-entity-object'; +import { GameFeatureObject } from '../../../../scene/objects/game-feature-object'; import { GameWorld } from '../../../../services/game-world'; import { CombatPlayerComponent } from '../../combat-player.component'; import { CombatComponent } from '../../combat.component'; +import { CombatAttackSummary } from '../../combat.types'; import { CombatEndTurnStateComponent } from '../../states/combat-end-turn.state'; import { CombatActionBehavior } from '../combat-action.behavior'; @@ -23,12 +25,14 @@ import { CombatActionBehavior } from '../combat-action.behavior'; */ @Component({ selector: 'combat-item-behavior', - template: '', + template: '', }) export class CombatItemBehavior extends CombatActionBehavior { name: string = 'item'; @Input() combat: CombatComponent; + @ViewChild(AnimatedComponent) animation: AnimatedComponent; + sounds = { healSound: getSoundEffectUrl('heal'), }; @@ -43,6 +47,15 @@ export class CombatItemBehavior extends CombatActionBehavior { ) { super(loader, gameWorld); } + + ngAfterViewInit(): void { + this.combat.scene.addObject(this.animation); + } + + ngOnDestroy(): void { + this.combat?.scene?.removeObject(this.animation); + } + canBeUsedBy(entity: GameEntityObject): boolean { return this.combat.machine.items.size > 0; } @@ -50,7 +63,19 @@ export class CombatItemBehavior extends CombatActionBehavior { async act(): Promise { const user: CombatPlayerComponent = this.from as CombatPlayerComponent; assertTrue(user instanceof CombatPlayerComponent, 'invalid item user'); - await user.magic(() => this._useItem()); + const done$ = new BehaviorSubject(false); + const actionCompletePromise = done$ + .pipe( + filter((d) => d === true), + take(1), + ) + .toPromise(); + + await user.magic(async () => { + await this._useItem(); + done$.next(true); + }); + await actionCompletePromise; this.combat.machine.setCurrentState(CombatEndTurnStateComponent.NAME); return true; } @@ -70,6 +95,11 @@ export class CombatItemBehavior extends CombatActionBehavior { assertTrue(userRender, 'item user has no render behavior'); assertTrue(item.effects, 'item with no valid effects'); const [effectName, effectValue] = item.effects; + const data: CombatAttackSummary = { + damage: -effectValue, + attacker: user, + defender: target, + }; switch (effectName) { case 'heal': const healData: CombatAttack = { @@ -78,25 +108,34 @@ export class CombatItemBehavior extends CombatActionBehavior { damage: -effectValue, }; this.store.dispatch(new CombatAttackAction(healData)); - break; } this.store.dispatch(new GameStateRemoveInventoryAction(item)); var behaviors = { - animation: new AnimatedSpriteBehavior({ - spriteName: 'heal', - lengthMS: 550, - }), - sprite: new SpriteComponent({ - name: 'heal', - icon: this.sprites.useItem, - }), sound: new SoundBehavior({ url: this.sounds.healSound, volume: 0.3, }), }; target.addComponentDictionary(behaviors); - await behaviors.animation.onDone$.pipe(take(1)).toPromise(); + const itemObject = new GameFeatureObject(); + await itemObject.setSprite(item.icon); + itemObject.point = user.point.clone().add(0, -0.5); + this.combat.scene.addObject(itemObject); + const emitDone = this.combat.machine.onAttack$.emit(data); + if (this.animation) { + await this.animation.playChain([ + { + name: 'Use Item', + repeats: 0, + duration: 1000, + move: new Point(0, -0.5), + host: itemObject, + }, + ]); + } + this.combat.scene.removeObject(itemObject); + await behaviors.sound.onDone$.pipe(take(1)).toPromise(); target.removeComponentDictionary(behaviors); + await emitDone; } } diff --git a/src/app/routes/combat/behaviors/choose-action.machine.ts b/src/app/routes/combat/behaviors/choose-action.machine.ts index b7654cd..b6b7c39 100644 --- a/src/app/routes/combat/behaviors/choose-action.machine.ts +++ b/src/app/routes/combat/behaviors/choose-action.machine.ts @@ -79,9 +79,14 @@ export class ChooseActionType extends State { let sub: Subscription | null = null; let clickSelect = (click: CombatSceneClick) => { + const targetClick = click.hits[0]; + // If the target has no HP, it's not a valid target. + if (!targetClick?.model?.hp) { + return; + } sub?.unsubscribe(); sub = null; - machine.target = click.hits[0]; + machine.target = targetClick; machine.parent.items[0].select(); }; assertTrue(machine.current, 'Requires Current Player'); @@ -221,6 +226,10 @@ export class ChooseActionTarget extends State { machine.setCurrentState(ChooseActionSubmit.NAME); return; } + // If the target has no HP, it's not a valid target. + if (!target?.model?.hp) { + return; + } machine.target = target; machine.parent.setPointerTarget(target, 'left'); }; diff --git a/src/app/routes/combat/combat-player.component.ts b/src/app/routes/combat/combat-player.component.ts index 421c46d..5cbb854 100644 --- a/src/app/routes/combat/combat-player.component.ts +++ b/src/app/routes/combat/combat-player.component.ts @@ -49,6 +49,12 @@ export class CombatPlayerComponent attackDirection: Headings = Headings.WEST; tickRateMS: number = 300; + get rotation(): number { + return this.model.hp > 0 ? 0 : -Math.PI / 2; + } + get visible(): boolean { + return Boolean(this._visible && this.model); + } constructor(public world: GameWorld) { super(); } diff --git a/src/app/routes/combat/combat.component.html b/src/app/routes/combat/combat.component.html index aa96c12..4762750 100644 --- a/src/app/routes/combat/combat.component.html +++ b/src/app/routes/combat/combat.component.html @@ -31,5 +31,10 @@ [enemies]="enemies" [defaultState]="defaultState" [encounter]="encounter$ | async" + #machine > + + diff --git a/src/app/routes/combat/combat.component.scss b/src/app/routes/combat/combat.component.scss index 891eca6..a4efd6d 100644 --- a/src/app/routes/combat/combat.component.scss +++ b/src/app/routes/combat/combat.component.scss @@ -21,4 +21,9 @@ animation-iteration-count: infinite; } } + button[mat-fab] { + position: fixed; + top: 48px; + left: 12px; + } } diff --git a/src/app/routes/combat/combat.component.ts b/src/app/routes/combat/combat.component.ts index 7e65661..2985f32 100644 --- a/src/app/routes/combat/combat.component.ts +++ b/src/app/routes/combat/combat.component.ts @@ -31,6 +31,7 @@ import { sliceCombatState, } from '../../models/selectors'; import { GameEntityObject } from '../../scene/objects/game-entity-object'; +import { GameFeatureObject } from '../../scene/objects/game-feature-object'; import { TileMapRenderer } from '../../scene/render/tile-map-renderer'; import { TileObjectRenderer, @@ -73,32 +74,21 @@ export class CombatComponent implements IProcessObject, OnDestroy, AfterViewInit { @Input() scene: Scene = new Scene(); - /** - * A pointing UI element that can be attached to `SceneObject`s to attract attention - * @type {null} - */ + /** A pointing UI element that can be attached to `SceneObject`s to attract attention */ pointer: UIAttachment | null = null; - /** - * Available menu items for selection. - */ - @Input() - items: ICombatMenuItem[] = []; + /** Available menu items for selection. */ + @Input() items: ICombatMenuItem[] = []; /** The combat state machine */ @ViewChild(CombatStateMachineComponent) machine: CombatStateMachineComponent; - /** - * Damages displaying on screen. - * @type {Array} - */ + /** Damages displaying on screen. */ @Input() damages: ICombatDamageSummary[] = []; @Input() defaultState: CombatStateNames | null = 'start'; - /** - * Mouse hook for capturing input with world and screen coordinates. - */ + /** Mouse hook for capturing input with world and screen coordinates. */ mouse: NamedMouseElement | null = null; @ViewChild('combatCanvas') canvasElementRef: ElementRef; @@ -201,7 +191,12 @@ export class CombatComponent if (data.damage > 0 && data.defender instanceof CombatPlayerComponent) { this.shake(this.canvasElementRef.nativeElement); } - this.notify.show(msg, _done); + if (this.machine.autoCombat) { + // Wait a moment between turns + _.delay(() => _done(), 750); + } else { + this.notify.show(msg, _done); + } }); const runSub = this.machine.onRun$.subscribe((data: CombatRunSummary) => { const _done = this.machine.onRun$.notifyWait(); @@ -252,6 +247,11 @@ export class CombatComponent // Events // + toggleAutoCombat() { + this.machine.autoCombat = !this.machine.autoCombat; + localStorage.setItem('rpgAutoCombat', `${this.machine.autoCombat}`); + } + onAddToScene(scene: Scene) { super.onAddToScene(scene); if (scene.world && scene.world.input) { @@ -305,12 +305,9 @@ export class CombatComponent super.processCamera(); } - beforeFrame(view: SceneView, elapsed: number) { - // Nope - } /** - * Render all of the map feature components + * Render all of the map features and combatants. */ renderFrame(elapsed: number) { this.clearRect(); @@ -337,11 +334,20 @@ export class CombatComponent this.objectRenderer.render(sprite as TileRenderable, sprite.host.point, this); }); }); - return this; - } - afterFrame(view: SceneView, elapsed: number) { - // Nope + this.scene.objectsByType(GameFeatureObject).forEach((object: GameFeatureObject) => { + const renderData: TileRenderable = { + frame: object.frame, + icon: object.icon, + image: object.image, + scale: object.scale, + visible: object.visible, + meta: object.meta, + }; + this.objectRenderer.render(renderData, object.point, this); + }); + + return this; } // diff --git a/src/app/routes/combat/index.ts b/src/app/routes/combat/index.ts index 40ef818..e7e9af9 100644 --- a/src/app/routes/combat/index.ts +++ b/src/app/routes/combat/index.ts @@ -1,5 +1,7 @@ import { CommonModule } from '@angular/common'; import { ModuleWithProviders, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; import { BehaviorsModule } from '../../behaviors/index'; import { AppComponentsModule } from '../../components/index'; import { ServicesModule } from '../../services'; @@ -53,7 +55,14 @@ export const COMBAT_PROVIDERS = [CanActivateCombat]; declarations: COMBAT_EXPORTS, exports: COMBAT_EXPORTS, providers: COMBAT_PROVIDERS, - imports: [CommonModule, BehaviorsModule, AppComponentsModule, ServicesModule], + imports: [ + CommonModule, + BehaviorsModule, + AppComponentsModule, + ServicesModule, + MatButtonModule, + MatIconModule, + ], }) export class CombatModule { static forRoot(): ModuleWithProviders { diff --git a/src/app/routes/combat/states/combat-choose-action.state.spec.ts b/src/app/routes/combat/states/combat-choose-action.state.spec.ts index ab24c1d..5ebb6ae 100644 --- a/src/app/routes/combat/states/combat-choose-action.state.spec.ts +++ b/src/app/routes/combat/states/combat-choose-action.state.spec.ts @@ -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(); @@ -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$', () => { diff --git a/src/app/routes/combat/states/combat-choose-action.state.ts b/src/app/routes/combat/states/combat-choose-action.state.ts index 96aba67..ec501e7 100644 --- a/src/app/routes/combat/states/combat-choose-action.state.ts +++ b/src/app/routes/combat/states/combat-choose-action.state.ts @@ -1,18 +1,91 @@ import { Component, Input } from '@angular/core'; -import { interval, Observable } from 'rxjs'; +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, + 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); + 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); + 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, + ); + 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. */ @@ -45,6 +118,18 @@ export class CombatChooseActionStateComponent extends CombatMachineState { /** The scene view container. Used to calculating screen space pointer coordinates */ @Input() view: SceneView | null = null; + private _autoCombat$ = new BehaviorSubject(false); + /** Automatically select moves for players for interactionless combat */ + @Input() set autoCombat(value: boolean) { + this._autoCombat$.next(value); + if (value && this.machine) { + this._doAutoSelection(); + } + } + get autoCombat(): boolean { + return this._autoCombat$.value; + } + private _currentMachine: ChooseActionStateMachine | null = null; private toChoose: GameEntityObject[] = []; @@ -93,6 +178,10 @@ export class CombatChooseActionStateComponent extends CombatMachineState { machine.currentDone = true; machine.playerChoices = {}; + if (this.autoCombat) { + return this._doAutoSelection(); + } + this._currentMachine = new ChooseActionStateMachine( this, machine.scene, @@ -123,6 +212,51 @@ export class CombatChooseActionStateComponent extends CombatMachineState { this._next(); } + private _doAutoSelection() { + const remainder = [...this.toChoose]; + if (this._currentMachine?.current) { + remainder.unshift(this._currentMachine.current); + } + if (this._currentMachine) { + this._currentMachine.destroy(); + this._currentMachine = null; + } + this.toChoose.length = 0; + this.pointer = false; + 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]; + + 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 enemies = this.machine.enemies + .toArray() + .filter((p) => Number(p.model?.hp) > 0); + + 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'); + } + private _next() { const p: GameEntityObject | null = this.toChoose.shift() || null; if (!p || !this._currentMachine) { diff --git a/src/app/routes/combat/states/combat-start.state.ts b/src/app/routes/combat/states/combat-start.state.ts index 06cec77..ba637cf 100644 --- a/src/app/routes/combat/states/combat-start.state.ts +++ b/src/app/routes/combat/states/combat-start.state.ts @@ -21,9 +21,10 @@ export class CombatStartStateComponent extends CombatMachineState { async enter(machine: CombatStateMachineComponent) { super.enter(machine); - _.defer(() => { + await new Promise(async (resolve) => { const encounter = machine.encounter; const _done = () => { + resolve(); machine.setCurrentState(CombatChooseActionStateComponent.NAME); }; if (encounter && encounter.message) { @@ -35,12 +36,12 @@ export class CombatStartStateComponent extends CombatMachineState { others.forEach((m) => this.notify.show(m || '', undefined, 0)); if (last) { this.notify.show(last, _done, 0); - } else { - _done(); + return; } - } else { - _done(); } + // Wait a short period before starting combat + await new Promise((next) => _.delay(() => next(), 750)); + _done(); }); } } diff --git a/src/app/routes/combat/states/combat.machine.html b/src/app/routes/combat/states/combat.machine.html index 3d6608a..b3058dd 100644 --- a/src/app/routes/combat/states/combat.machine.html +++ b/src/app/routes/combat/states/combat.machine.html @@ -1,6 +1,7 @@ ; ngOnDestroy(): void { diff --git a/src/app/routes/world/world-player.component.ts b/src/app/routes/world/world-player.component.ts index ac4dd5d..769e0a7 100644 --- a/src/app/routes/world/world-player.component.ts +++ b/src/app/routes/world/world-player.component.ts @@ -116,10 +116,6 @@ export class WorldPlayerComponent // objectRenderer: TileObjectRenderer = new TileObjectRenderer(); - beforeFrame(view: SceneView, elapsed: number) { - // Nope - } - /** * Render all of the map feature components */ @@ -172,10 +168,6 @@ export class WorldPlayerComponent } } } - - afterFrame(view: SceneView, elapsed: number) { - // Nope - } } /** Components associated with world player */ diff --git a/src/app/routes/world/world.component.ts b/src/app/routes/world/world.component.ts index 2d492c9..89dee91 100644 --- a/src/app/routes/world/world.component.ts +++ b/src/app/routes/world/world.component.ts @@ -135,7 +135,8 @@ export class WorldComponent extends SceneView implements AfterViewInit, OnDestro /** Observable of Entity representing the player-card leader to be rendered in the world */ partyLeader$: Observable = this.store.select(getGameParty).pipe( map((party: Immutable.List) => { - return party.get(0) || null; + const partyAlive = party.filter((p) => Number(p?.hp) > 0); + return partyAlive.get(0) || null; }), ); private _renderPoint$: BehaviorSubject = new BehaviorSubject( diff --git a/src/app/scene/objects/game-entity-object.ts b/src/app/scene/objects/game-entity-object.ts index 2ad6f01..ecaf929 100644 --- a/src/app/scene/objects/game-entity-object.ts +++ b/src/app/scene/objects/game-entity-object.ts @@ -8,7 +8,7 @@ export class GameEntityObject extends TileObject { groups: any; world: GameWorld; - private _visible: boolean = true; + protected _visible: boolean = true; // @ts-ignore get visible(): boolean { return Boolean(this._visible && this.model && this.model.hp > 0); diff --git a/src/app/scene/render/tile-object-renderer.ts b/src/app/scene/render/tile-object-renderer.ts index f8bf690..caeab59 100644 --- a/src/app/scene/render/tile-object-renderer.ts +++ b/src/app/scene/render/tile-object-renderer.ts @@ -8,9 +8,9 @@ export interface TileRenderable { visible: boolean; scale?: number; frame: number; + rotation?: number; meta: ISpriteMeta | null; } - export class TileObjectRenderer { private _renderPoint: Point = new Point(); @@ -37,9 +37,28 @@ export class TileObjectRenderer { // Offset position and floor to align on pixel boundaries point.subtract((sourceWidth * scale) / 2, (sourceHeight * scale) / 2).floor(); + // Context transformation for rotation + if (object.rotation) { + view.context.save(); // Save the current state + // Move to the center of where the object should be drawn, then rotate + view.context.translate( + point.x - 1.5 + (sourceWidth * scale) / 2, + point.y + (sourceHeight * scale) / 2, + ); + view.context.rotate(object.rotation); + // Move back to the top-left of the object, taking into account the scaling + view.context.translate(-(sourceWidth * scale) / 2, -(sourceHeight * scale) / 2); + } + + // Determine source x,y for sprites with frames + let cx = 0; + let cy = 0; + const drawX = object.rotation ? -sourceWidth / 2 : point.x; + const drawY = object.rotation ? -sourceHeight / 2 : point.y; + if (object.icon && object.meta) { - let cx = object.meta.x; - let cy = object.meta.y; + cx = object.meta.x; + cy = object.meta.y; if (object.meta.frames > 1) { const cwidth = object.meta.width / sourceWidth; const fx = object.frame % cwidth; @@ -53,19 +72,23 @@ export class TileObjectRenderer { cy, sourceWidth, sourceHeight, - point.x, - point.y, + drawX, + drawY, sourceWidth * scale, sourceHeight * scale, ); } else { view.context.drawImage( object.image, - point.x, - point.y, + drawX, + drawY, sourceWidth * scale, sourceHeight * scale, ); } + + if (object.rotation) { + view.context.restore(); + } } } diff --git a/src/app/scene/scene-view.ts b/src/app/scene/scene-view.ts index 587193f..c5de288 100644 --- a/src/app/scene/scene-view.ts +++ b/src/app/scene/scene-view.ts @@ -99,9 +99,13 @@ export class SceneView extends SceneObject implements ISceneView { if (!this.map) { return this.camera; } - const clipGrow = this.camera.clone(); - clipGrow.point.round(); - clipGrow.extent.round(); + // Inflate the camera by 1 unit on the x/y axes to make sure + // the floating point camera covers the whole screen. + const clipGrow = this.camera.clone().inflate(); + // Round the camera to avoid tile bleeding during rendering from + // floating point precision issues. + clipGrow.point.floor(); + clipGrow.extent.ceil(); return clipGrow; } @@ -217,7 +221,7 @@ export class SceneView extends SceneObject implements ISceneView { let x = 0; let y = 0; if (this.camera) { - renderPos = this.worldToScreen(this.camera.point); + renderPos = this.worldToScreen(this.getCameraClip().point); x = renderPos.x; y = renderPos.y; } diff --git a/src/app/scene/scene.model.ts b/src/app/scene/scene.model.ts index 9b0715a..a0a1a7e 100644 --- a/src/app/scene/scene.model.ts +++ b/src/app/scene/scene.model.ts @@ -55,9 +55,7 @@ export interface IScene { * will be invoked during the scene render. */ export interface ISceneViewRenderer { - beforeFrame(view: ISceneView, elapsed: number): void; renderFrame(view: ISceneView, elapsed: number): void; - afterFrame(view: ISceneView, elapsed: number): void; } /**