Ensure that damage takes into effect stat bonuses from equipment
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { type World, type Vec2, type EntityId } from "../../core/types";
|
||||
import { type World, type Vec2, type EntityId, type Stats, type Item } from "../../core/types";
|
||||
import { isBlocked } from "../world/world-logic";
|
||||
import { raycast } from "../../core/math";
|
||||
import { type EntityAccessor } from "../EntityAccessor";
|
||||
@@ -9,6 +9,73 @@ export interface ProjectileResult {
|
||||
hitActorId?: EntityId;
|
||||
}
|
||||
|
||||
export interface DamageResult {
|
||||
dmg: number;
|
||||
hit: boolean;
|
||||
isCrit: boolean;
|
||||
isBlock: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized damage calculation for both melee and ranged attacks.
|
||||
*/
|
||||
export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?: Item): DamageResult {
|
||||
const result: DamageResult = {
|
||||
dmg: 0,
|
||||
hit: false,
|
||||
isCrit: false,
|
||||
isBlock: false
|
||||
};
|
||||
|
||||
// 1. Accuracy vs Evasion Check
|
||||
const hitChance = attackerStats.accuracy - targetStats.evasion;
|
||||
const hitRoll = Math.random() * 100;
|
||||
|
||||
if (hitRoll > hitChance) {
|
||||
return result; // Miss
|
||||
}
|
||||
result.hit = true;
|
||||
|
||||
// 2. Base Damage Calculation
|
||||
// Use player attack as base, add item attack if it's a weapon
|
||||
let baseAttack = attackerStats.attack;
|
||||
if (item && "stats" in item && item.stats && "attack" in item.stats) {
|
||||
// For weapons, the item stats are already added to player stats in EquipmentService
|
||||
// However, if we want to support 'thrown' items having their own base damage, we can add it here.
|
||||
// For ranged weapons, executeThrow was using item.stats.attack.
|
||||
// If it's a weapon, we assume the item.stats.attack is what should be used (or added).
|
||||
// Actually, equipmentService adds item.stats.attack to player.stats.attack.
|
||||
// So baseAttack is already "player + weapon".
|
||||
// BUT for projectiles/thrown, we might want to ensure we're using the right value.
|
||||
|
||||
// If it's a weapon item, it's likely already factored in.
|
||||
// If it's a CONSUMABLE (thrown), it might NOT be.
|
||||
if (item.type === "Consumable") {
|
||||
baseAttack += (item.stats as any).attack || 0;
|
||||
}
|
||||
}
|
||||
|
||||
let dmg = Math.max(1, baseAttack - targetStats.defense);
|
||||
|
||||
// 3. Critical Strike Check
|
||||
const critRoll = Math.random() * 100;
|
||||
const isCrit = critRoll < attackerStats.critChance;
|
||||
if (isCrit) {
|
||||
dmg = Math.floor(dmg * (attackerStats.critMultiplier / 100));
|
||||
result.isCrit = true;
|
||||
}
|
||||
|
||||
// 4. Block Chance Check
|
||||
const blockRoll = Math.random() * 100;
|
||||
if (blockRoll < targetStats.blockChance) {
|
||||
dmg = Math.floor(dmg * 0.5);
|
||||
result.isBlock = true;
|
||||
}
|
||||
|
||||
result.dmg = dmg;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the path and impact of a projectile.
|
||||
*/
|
||||
|
||||
133
src/engine/gameplay/__tests__/CombatDamage.test.ts
Normal file
133
src/engine/gameplay/__tests__/CombatDamage.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { calculateDamage } from "../CombatLogic";
|
||||
import { type Stats, type Item } from "../../../core/types";
|
||||
|
||||
describe("CombatLogic - calculateDamage", () => {
|
||||
const createStats = (overrides: Partial<Stats> = {}): Stats => ({
|
||||
hp: 100, maxHp: 100, attack: 10, defense: 5,
|
||||
accuracy: 100, evasion: 0, critChance: 0, critMultiplier: 200,
|
||||
blockChance: 0, lifesteal: 0, mana: 50, maxMana: 50,
|
||||
level: 1, exp: 0, expToNextLevel: 100, luck: 0,
|
||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||
passiveNodes: [],
|
||||
...overrides
|
||||
});
|
||||
|
||||
it("should calculate base damage correctly (attack - defense)", () => {
|
||||
const attacker = createStats({ attack: 15 });
|
||||
const target = createStats({ defense: 5 });
|
||||
|
||||
// Mock Math.random to ensure hit and no crit/block
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
|
||||
const result = calculateDamage(attacker, target);
|
||||
expect(result.hit).toBe(true);
|
||||
expect(result.dmg).toBe(10); // 15 - 5
|
||||
expect(result.isCrit).toBe(false);
|
||||
expect(result.isBlock).toBe(false);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should ensure minimum damage of 1", () => {
|
||||
const attacker = createStats({ attack: 5 });
|
||||
const target = createStats({ defense: 10 });
|
||||
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
|
||||
const result = calculateDamage(attacker, target);
|
||||
expect(result.dmg).toBe(1);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should handle misses (accuracy vs evasion)", () => {
|
||||
const attacker = createStats({ accuracy: 50 });
|
||||
const target = createStats({ evasion: 0 });
|
||||
|
||||
// Mock random to be > 50 (miss)
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.6);
|
||||
|
||||
const result = calculateDamage(attacker, target);
|
||||
expect(result.hit).toBe(false);
|
||||
expect(result.dmg).toBe(0);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should handle critical hits", () => {
|
||||
const attacker = createStats({ attack: 10, critChance: 100, critMultiplier: 200 });
|
||||
const target = createStats({ defense: 0 });
|
||||
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
|
||||
const result = calculateDamage(attacker, target);
|
||||
expect(result.isCrit).toBe(true);
|
||||
expect(result.dmg).toBe(20); // 10 * 2.0
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should handle blocking", () => {
|
||||
const attacker = createStats({ attack: 20 });
|
||||
const target = createStats({ defense: 0, blockChance: 100 });
|
||||
|
||||
// We need multiple random calls or a smarter mock if calculateDamage calls random multiple times.
|
||||
// 1. Hit check
|
||||
// 2. Crit check
|
||||
// 3. Block check
|
||||
const mockRandom = vi.fn()
|
||||
.mockReturnValueOnce(0.1) // Hit (chance 100)
|
||||
.mockReturnValueOnce(0.9) // No Crit (chance 0)
|
||||
.mockReturnValueOnce(0.1); // Block (chance 100)
|
||||
vi.spyOn(Math, 'random').mockImplementation(mockRandom);
|
||||
|
||||
const result = calculateDamage(attacker, target);
|
||||
expect(result.isBlock).toBe(true);
|
||||
expect(result.dmg).toBe(10); // (20-0) * 0.5
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should consider item attack for consumables (thrown items)", () => {
|
||||
const attacker = createStats({ attack: 10 });
|
||||
const target = createStats({ defense: 0 });
|
||||
const item: Item = {
|
||||
id: "bomb",
|
||||
name: "Bomb",
|
||||
type: "Consumable",
|
||||
textureKey: "items",
|
||||
spriteIndex: 0,
|
||||
stats: { attack: 20 }
|
||||
} as any;
|
||||
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.1);
|
||||
|
||||
const result = calculateDamage(attacker, target, item);
|
||||
expect(result.dmg).toBe(30); // 10 (player) + 20 (item)
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should NOT add weapon attack twice (assumes it's already in player stats)", () => {
|
||||
const attacker = createStats({ attack: 30 }); // Player 10 + Weapon 20
|
||||
const target = createStats({ defense: 0 });
|
||||
const item: Item = {
|
||||
id: "pistol",
|
||||
name: "Pistol",
|
||||
type: "Weapon",
|
||||
weaponType: "ranged",
|
||||
textureKey: "items",
|
||||
spriteIndex: 0,
|
||||
stats: { attack: 20 }
|
||||
} as any;
|
||||
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.1);
|
||||
|
||||
const result = calculateDamage(attacker, target, item);
|
||||
expect(result.dmg).toBe(30); // Should remain 30, not 50
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
||||
import { calculateDamage } from "../gameplay/CombatLogic";
|
||||
|
||||
import { isBlocked, tryDestructTile } from "../world/world-logic";
|
||||
import { isDestructibleByWalk } from "../../core/terrain";
|
||||
@@ -119,11 +120,10 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
|
||||
if (target && target.category === "combatant" && actor.category === "combatant") {
|
||||
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
|
||||
|
||||
// 1. Accuracy vs Evasion Check
|
||||
const hitChance = actor.stats.accuracy - target.stats.evasion;
|
||||
const hitRoll = Math.random() * 100;
|
||||
// 1. Calculate Damage
|
||||
const result = calculateDamage(actor.stats, target.stats);
|
||||
|
||||
if (hitRoll > hitChance) {
|
||||
if (!result.hit) {
|
||||
events.push({
|
||||
type: "dodged",
|
||||
targetId: action.targetId,
|
||||
@@ -133,23 +133,9 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
|
||||
return events;
|
||||
}
|
||||
|
||||
// 2. Base Damage Calculation
|
||||
let dmg = Math.max(1, actor.stats.attack - target.stats.defense);
|
||||
|
||||
// 3. Critical Strike Check
|
||||
const critRoll = Math.random() * 100;
|
||||
const isCrit = critRoll < actor.stats.critChance;
|
||||
if (isCrit) {
|
||||
dmg = Math.floor(dmg * (actor.stats.critMultiplier / 100));
|
||||
}
|
||||
|
||||
// 4. Block Chance Check
|
||||
const blockRoll = Math.random() * 100;
|
||||
let isBlock = false;
|
||||
if (blockRoll < target.stats.blockChance) {
|
||||
dmg = Math.floor(dmg * 0.5);
|
||||
isBlock = true;
|
||||
}
|
||||
const dmg = result.dmg;
|
||||
const isCrit = result.isCrit;
|
||||
const isBlock = result.isBlock;
|
||||
|
||||
target.stats.hp -= dmg;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
||||
import { EventBus } from "../engine/ecs/EventBus";
|
||||
import { generateLoot } from "../engine/systems/LootSystem";
|
||||
import { renderSimEvents, getEffectColor, getEffectName, type EventRenderCallbacks } from "./systems/EventRenderer";
|
||||
import { calculateDamage } from "../engine/gameplay/CombatLogic";
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private world!: World;
|
||||
@@ -848,12 +849,17 @@ export class GameScene extends Phaser.Scene {
|
||||
// Damage Logic
|
||||
if (hitActorId !== undefined) {
|
||||
const victim = this.entityAccessor.getCombatant(hitActorId);
|
||||
if (victim) {
|
||||
const stats = 'stats' in item ? item.stats : undefined;
|
||||
const dmg = (stats && 'attack' in stats) ? (stats.attack ?? 1) : 1;
|
||||
victim.stats.hp -= dmg;
|
||||
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, dmg);
|
||||
this.dungeonRenderer.shakeCamera();
|
||||
const player = this.entityAccessor.getPlayer();
|
||||
if (victim && player) {
|
||||
const damageResult = calculateDamage(player.stats, victim.stats, item);
|
||||
|
||||
if (damageResult.hit) {
|
||||
victim.stats.hp -= damageResult.dmg;
|
||||
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, damageResult.dmg, damageResult.isCrit, damageResult.isBlock);
|
||||
this.dungeonRenderer.shakeCamera();
|
||||
} else {
|
||||
this.dungeonRenderer.showDodge(victim.pos.x, victim.pos.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user