Ensure that damage takes into effect stat bonuses from equipment

This commit is contained in:
Peter Stockings
2026-01-27 17:48:20 +11:00
parent 165cde6ca3
commit cdedf47e0d
4 changed files with 220 additions and 28 deletions

View File

@@ -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.
*/

View 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();
});
});

View File

@@ -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;

View File

@@ -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);
}
}
}