- Added 'Shoot Bow' action for Elf with Ballistic Skill mechanics (1995 rules). - Implemented strict Line of Sight (LOS) raycasting (Amanatides & Woo) with UI feedback for blockers. - Added 'Pinned' status: Heroes adjacent to monsters (without intervening walls) cannot move. - Enhanced UI with visual indicators for blocked shots (red circles) and temporary modals. - Polished 'End Phase' button layout and hidden it during Monster phase.
217 lines
7.2 KiB
JavaScript
217 lines
7.2 KiB
JavaScript
export const TO_HIT_CHART = [
|
|
// Defender WS 1 2 3 4 5 6 7 8 9 10
|
|
/* Attacker 1 */[4, 4, 5, 6, 6, 6, 6, 6, 6, 6],
|
|
/* Attacker 2 */[3, 4, 4, 4, 5, 5, 6, 6, 6, 6],
|
|
/* Attacker 3 */[2, 3, 4, 4, 4, 4, 5, 5, 5, 6],
|
|
/* Attacker 4 */[2, 3, 3, 4, 4, 4, 4, 4, 5, 5],
|
|
/* Attacker 5 */[2, 2, 3, 3, 4, 4, 4, 4, 4, 4],
|
|
/* Attacker 6 */[2, 2, 3, 3, 3, 4, 4, 4, 4, 4],
|
|
/* Attacker 7 */[2, 2, 2, 3, 3, 3, 4, 4, 4, 4],
|
|
/* Attacker 8 */[2, 2, 2, 3, 3, 3, 3, 4, 4, 4],
|
|
/* Attacker 9 */[2, 2, 2, 2, 3, 3, 3, 3, 4, 4],
|
|
/* Attacker 10*/[2, 2, 2, 2, 3, 3, 3, 3, 3, 4]
|
|
];
|
|
|
|
export class CombatMechanics {
|
|
|
|
/**
|
|
* Resolves a melee attack sequence between two entities.
|
|
* @param {Object} attacker
|
|
* @param {Object} defender
|
|
* @returns {Object} Result log
|
|
*/
|
|
static resolveMeleeAttack(attacker, defender, gameEngine = null) {
|
|
const log = {
|
|
attackerId: attacker.id,
|
|
defenderId: defender.id,
|
|
hitRoll: 0,
|
|
targetToHit: 0,
|
|
hitSuccess: false,
|
|
damageRoll: 0,
|
|
damageTotal: 0,
|
|
woundsCaused: 0,
|
|
defenderDied: false,
|
|
message: ''
|
|
};
|
|
|
|
// 1. Determine Stats
|
|
// Use stats object if available, otherwise direct property (fallback)
|
|
const attStats = attacker.stats || attacker;
|
|
const defStats = defender.stats || defender;
|
|
|
|
const attWS = Math.min(Math.max(attStats.ws || 1, 1), 10);
|
|
const defWS = Math.min(Math.max(defStats.ws || 1, 1), 10);
|
|
|
|
// 2. Roll To Hit
|
|
log.targetToHit = this.getToHitTarget(attWS, defWS);
|
|
log.hitRoll = this.rollD6();
|
|
|
|
// Debug
|
|
// console.log(`Combat: ${attacker.name} (WS${attWS}) vs ${defender.name} (WS${defWS}) -> Need ${log.targetToHit}+. Rolled ${log.hitRoll}`);
|
|
|
|
if (log.hitRoll < log.targetToHit) {
|
|
log.hitSuccess = false;
|
|
log.message = `${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
|
|
return log;
|
|
}
|
|
|
|
log.hitSuccess = true;
|
|
|
|
// 3. Roll To Damage
|
|
const attStr = attStats.str || 3;
|
|
const defTough = defStats.toughness || 3;
|
|
const damageDice = attStats.damageDice || 1; // Default 1D6
|
|
|
|
let damageSum = 0;
|
|
let rolls = [];
|
|
for (let i = 0; i < damageDice; i++) {
|
|
const r = this.rollD6();
|
|
rolls.push(r);
|
|
damageSum += r;
|
|
}
|
|
|
|
log.damageRoll = damageSum; // Just sum for simple log, or we could array it
|
|
log.damageTotal = damageSum + attStr;
|
|
|
|
// 4. Calculate Wounds
|
|
// Wounds = (Dice + Str) - Toughness
|
|
let wounds = log.damageTotal - defTough;
|
|
if (wounds < 0) wounds = 0;
|
|
|
|
log.woundsCaused = wounds;
|
|
|
|
// 5. Build Message
|
|
if (wounds > 0) {
|
|
log.message = `${attacker.name} impacta y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
|
} else {
|
|
log.message = `${attacker.name} impacta pero no logra herir. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
|
}
|
|
|
|
// 6. Apply Damage to Defender State
|
|
this.applyDamage(defender, wounds, gameEngine);
|
|
|
|
if (defender.isDead) {
|
|
log.defenderDied = true;
|
|
log.message += ` ¡${defender.name} ha muerto!`;
|
|
} else if (defender.isUnconscious) {
|
|
log.message += ` ¡${defender.name} cae inconsciente!`;
|
|
}
|
|
|
|
return log;
|
|
}
|
|
|
|
static resolveRangedAttack(attacker, defender, gameEngine = null) {
|
|
const log = {
|
|
attackerId: attacker.id,
|
|
defenderId: defender.id,
|
|
hitSuccess: false,
|
|
damageTotal: 0,
|
|
woundsCaused: 0,
|
|
defenderDied: false,
|
|
message: ''
|
|
};
|
|
|
|
// 1. Roll To Hit (BS vs WS)
|
|
// Use attacker BS or default to WS if missing (fallback).
|
|
const attackerBS = attacker.stats.bs || attacker.stats.ws;
|
|
const defenderWS = defender.stats.ws;
|
|
|
|
const toHitTarget = this.getToHitTarget(attackerBS, defenderWS);
|
|
const hitRoll = this.rollD6();
|
|
log.hitRoll = hitRoll;
|
|
log.toHitTarget = toHitTarget;
|
|
|
|
if (hitRoll === 1) {
|
|
log.hitSuccess = false;
|
|
log.message = `${attacker.name} dispara y falla (1 es fallo automático)`;
|
|
return log;
|
|
}
|
|
|
|
if (hitRoll < toHitTarget) {
|
|
log.hitSuccess = false;
|
|
log.message = `${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
|
|
return log;
|
|
}
|
|
|
|
log.hitSuccess = true;
|
|
|
|
// 2. Roll Damage
|
|
// Elf Bow Strength = 3
|
|
const weaponStrength = 3;
|
|
const damageRoll = this.rollD6();
|
|
const damageTotal = weaponStrength + damageRoll;
|
|
log.damageRoll = damageRoll;
|
|
log.damageTotal = damageTotal;
|
|
|
|
// 3. Compare vs Toughness
|
|
const defTough = defender.stats.toughness || 1;
|
|
const wounds = Math.max(0, damageTotal - defTough);
|
|
|
|
log.woundsCaused = wounds;
|
|
|
|
// 4. Build Message
|
|
if (wounds > 0) {
|
|
log.message = `${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
|
} else {
|
|
log.message = `${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
|
}
|
|
|
|
// 5. Apply Damage
|
|
this.applyDamage(defender, wounds, gameEngine);
|
|
|
|
if (defender.isDead) {
|
|
log.defenderDied = true;
|
|
log.message += ` ¡${defender.name} ha muerto!`;
|
|
}
|
|
|
|
return log;
|
|
}
|
|
|
|
static getToHitTarget(attackerWS, defenderWS) {
|
|
// Adjust for 0-index array
|
|
const row = attackerWS - 1;
|
|
const col = defenderWS - 1;
|
|
if (TO_HIT_CHART[row] && TO_HIT_CHART[row][col]) {
|
|
return TO_HIT_CHART[row][col];
|
|
}
|
|
return 6; // Fallback
|
|
}
|
|
|
|
static applyDamage(entity, amount, gameEngine = null) {
|
|
if (!entity.stats) entity.stats = {};
|
|
|
|
// If entity doesn't have current wounds tracked, init it from max
|
|
if (entity.currentWounds === undefined) {
|
|
// For Heros it is 'wounds', for Monsters typical just 'wounds' in def
|
|
// We assume entity has been initialized properly before,
|
|
// but if not, we grab max from definition
|
|
entity.currentWounds = entity.stats.wounds || 1;
|
|
}
|
|
|
|
entity.currentWounds -= amount;
|
|
|
|
// Check Status
|
|
if (entity.type === 'hero') {
|
|
if (entity.currentWounds <= 0) {
|
|
entity.currentWounds = 0;
|
|
entity.isConscious = false;
|
|
// entity.isDead is not immediate for heroes usually, but let's handle via isConscious
|
|
}
|
|
} else {
|
|
// Monsters die at 0
|
|
if (entity.currentWounds <= 0) {
|
|
entity.currentWounds = 0;
|
|
entity.isDead = true;
|
|
// Trigger death callback if available
|
|
if (gameEngine && gameEngine.onEntityDeath) {
|
|
gameEngine.onEntityDeath(entity.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static rollD6() {
|
|
return Math.floor(Math.random() * 6) + 1;
|
|
}
|
|
}
|