Apply treasure equipment battle effects

This commit is contained in:
2026-06-23 06:15:00 +09:00
parent 8f083a6ca4
commit 2d4b08558d
3 changed files with 201 additions and 14 deletions

View File

@@ -43,14 +43,15 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- Camp progress timeline tab that summarizes Liu Bei's long campaign arc, completed battles, current chapter, latest battle, and next major chapter
- Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer
- Officer collection support in camp, including full roster status, selected/reserve/recommended markers, and post-battle reserve training growth for benched officers
- Treasure equipment effects wired into battle previews and resolution, including named weapon damage bonuses, defensive treasure mitigation, support recovery bonuses, turn-start charm recovery, and equipment-specific growth bonuses
- Flow verification script from title through the eighteenth battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang camp state, campaign timeline state, and camp save state
## Next Steps
1. Continue the Red Cliffs preparation chapter into Changban and the Jiangdong envoy route
2. Apply treasure equipment effects to damage, defense, recovery, and command rules
3. Add more recruitable officers as the campaign moves toward Red Cliffs, Jing Province, Yi Province, and Shu Han
4. Expand reserve training into explicit drill choices, class practice, and bond-focused camp assignments
2. Add more recruitable officers as the campaign moves toward Red Cliffs, Jing Province, Yi Province, and Shu Han
3. Expand reserve training into explicit drill choices, class practice, and bond-focused camp assignments
4. Add a dedicated treasure/equipment management view so players can compare special effects and growth
5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending
## Content Direction

View File

@@ -1814,6 +1814,9 @@ try {
const eighteenthEnemies = eighteenthBattleState.units.filter((unit) => unit.faction === 'enemy');
const eighteenthAllies = eighteenthBattleState.units.filter((unit) => unit.faction === 'ally');
const eighteenthEnemyBehaviors = new Set(eighteenthEnemies.map((unit) => unit.ai));
const treasureLabels = new Set(
eighteenthBattleState.treasureEffectPreviews?.flatMap((preview) => preview.equipmentEffectLabels ?? []) ?? []
);
if (
eighteenthBattleState.camera?.mapWidth !== 36 ||
eighteenthBattleState.camera?.mapHeight !== 26 ||
@@ -1823,9 +1826,13 @@ try {
!eighteenthEnemyBehaviors.has('guard') ||
!eighteenthEnemyBehaviors.has('hold') ||
!eighteenthEnemies.some((unit) => unit.id === 'bowang-leader-xiahou-dun') ||
!eighteenthAllies.some((unit) => unit.id === 'zhuge-liang')
!eighteenthAllies.some((unit) => unit.id === 'zhuge-liang') ||
!treasureLabels.has('자웅일대검 공명') ||
(!treasureLabels.has('청룡언월도 무력 우위') && !treasureLabels.has('청룡언월도 대도')) ||
!treasureLabels.has('장팔사모 치명') ||
!treasureLabels.has('백우선 책략')
) {
throw new Error(`Expected eighteenth battle to use Bowang map, Xiahou Dun objective, mixed AI, and deployed Zhuge Liang: ${JSON.stringify(eighteenthBattleState)}`);
throw new Error(`Expected eighteenth battle to use Bowang map, Xiahou Dun objective, mixed AI, deployed Zhuge Liang, and treasure equipment effects: ${JSON.stringify(eighteenthBattleState)}`);
}
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));

View File

@@ -404,6 +404,11 @@ type CombatPreview = {
damage: number;
bondDamageBonus: number;
bondLabel?: string;
equipmentDamageBonus: number;
equipmentDamageReduction: number;
equipmentHitBonus: number;
equipmentCriticalBonus: number;
equipmentEffectLabels: string[];
hitRate: number;
criticalRate: number;
counterAvailable: boolean;
@@ -2244,9 +2249,12 @@ export class BattleScene extends Phaser.Scene {
private renderAttackPreview(preview: CombatPreview) {
const counter = preview.counterAvailable ? ' / 반격 가능' : '';
const bond = preview.bondDamageBonus > 0 && preview.bondLabel ? `\n공명 ${preview.bondLabel} 피해 +${preview.bondDamageBonus}%` : '';
const equipment = preview.equipmentEffectLabels.length > 0
? `\n보물 ${preview.equipmentEffectLabels.join(', ')} (${this.equipmentPreviewBonusText(preview)})`
: '';
this.renderUnitDetail(
preview.defender,
`${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 치명 ${preview.criticalRate}%\n지형 ${preview.terrainLabel}${counter}${bond}`
`${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 치명 ${preview.criticalRate}%\n지형 ${preview.terrainLabel}${counter}${bond}${equipment}`
);
}
@@ -2263,6 +2271,16 @@ export class BattleScene extends Phaser.Scene {
);
}
private equipmentPreviewBonusText(preview: CombatPreview) {
const parts = [
preview.equipmentDamageBonus > 0 ? `피해 +${preview.equipmentDamageBonus}%` : '',
preview.equipmentDamageReduction > 0 ? `피해 -${preview.equipmentDamageReduction}%` : '',
preview.equipmentHitBonus > 0 ? `명중 +${preview.equipmentHitBonus}` : '',
preview.equipmentCriticalBonus > 0 ? `치명 +${preview.equipmentCriticalBonus}` : ''
].filter(Boolean);
return parts.join(' / ') || '보정 없음';
}
private finishUnitAction(unit: UnitData, message: string) {
this.actedUnitIds.add(unit.id);
this.phase = 'idle';
@@ -3198,7 +3216,9 @@ export class BattleScene extends Phaser.Scene {
const attackPower = this.actionPower(attacker, action, usable);
const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus);
const bondBonus = attacker.faction === 'ally' ? this.strongestBondBonus(attacker.id) : { damageBonus: 0, chainRate: 0 };
const damage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + bondBonus.damageBonus / 100)), 3, defender.maxHp);
const equipmentEffect = this.combatEquipmentEffect(attacker, defender, action, usable);
const totalDamageBonus = bondBonus.damageBonus + equipmentEffect.damageBonus - equipmentEffect.damageReduction;
const damage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + totalDamageBonus / 100)), 3, defender.maxHp);
const defenderTerrainRating = getUnitClass(defender.classKey).terrainRatings[terrain] ?? 100;
const terrainHitPenalty = Math.floor(terrainRule.defenseBonus / 2) + Math.max(0, Math.floor((defenderTerrainRating - 100) / 4));
const hitRate = Phaser.Math.Clamp(
@@ -3207,6 +3227,7 @@ export class BattleScene extends Phaser.Scene {
Math.floor((attacker.stats.luck - defender.stats.luck) / 8) -
terrainHitPenalty +
(usable?.accuracyBonus ?? 0) +
equipmentEffect.hitBonus +
this.hitBuff(attacker),
45,
98
@@ -3217,6 +3238,7 @@ export class BattleScene extends Phaser.Scene {
Math.floor((attacker.stats.might - defender.stats.leadership) / 12) +
(action === 'attack' && getItem(attacker.equipment.weapon.itemId).rank === 'treasure' ? 2 : 0) +
(usable?.criticalBonus ?? 0) +
equipmentEffect.criticalBonus +
this.criticalBuff(attacker),
2,
28
@@ -3230,6 +3252,11 @@ export class BattleScene extends Phaser.Scene {
damage,
bondDamageBonus: bondBonus.damageBonus,
bondLabel: bondBonus.label,
equipmentDamageBonus: equipmentEffect.damageBonus,
equipmentDamageReduction: equipmentEffect.damageReduction,
equipmentHitBonus: equipmentEffect.hitBonus,
equipmentCriticalBonus: equipmentEffect.criticalBonus,
equipmentEffectLabels: equipmentEffect.labels,
hitRate,
criticalRate,
counterAvailable: this.canCounterAttack(defender, attacker, action),
@@ -3277,7 +3304,7 @@ export class BattleScene extends Phaser.Scene {
const critical = hit && this.rollPercent(preview.criticalRate);
const damage = hit ? Phaser.Math.Clamp(Math.round(preview.damage * (critical ? 1.5 : 1)), 1, defender.hp) : 0;
const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action));
const armorGrowth = this.awardEquipmentExp(defender, 'armor', hit ? 6 : 3);
const armorGrowth = this.awardEquipmentExp(defender, 'armor', this.defenderArmorExpGain(defender, hit, preview));
const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical));
const bondGrowth = attacker.faction === 'ally' && !isCounter ? this.recordBondAttackIntent(attacker, defender) : [];
const bondExp = bondGrowth.reduce((total, growth) => total + growth.amount, 0);
@@ -3367,7 +3394,7 @@ export class BattleScene extends Phaser.Scene {
this.flashSupport(target, usable.name, '#ffdf7b');
}
const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', usable.command === 'strategy' ? 7 : 5);
const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', this.supportEquipmentExpGain(user, usable));
const characterGrowth = this.awardCharacterExp(user, usable.effect === 'heal' ? 8 : 6);
this.recordSupportStats(user, healAmount);
@@ -3385,7 +3412,89 @@ export class BattleScene extends Phaser.Scene {
private supportHealAmount(user: UnitData, target: UnitData, usable: BattleUsable) {
const bonus = usable.command === 'strategy' ? Math.floor(user.stats.intelligence / 12) : Math.floor(user.stats.luck / 14);
return Math.min(target.maxHp - target.hp, Math.max(1, usable.power + bonus));
const equipmentBonus =
getItem(user.equipment.weapon.itemId).id === 'white-feather-fan' && usable.command === 'strategy'
? 3
: getItem(user.equipment.accessory.itemId).id === 'peach-charm'
? 2
: 0;
return Math.min(target.maxHp - target.hp, Math.max(1, usable.power + bonus + equipmentBonus));
}
private combatEquipmentEffect(attacker: UnitData, defender: UnitData, action: DamageCommand, usable?: BattleUsable) {
const labels: string[] = [];
let damageBonus = 0;
let damageReduction = 0;
let hitBonus = 0;
let criticalBonus = 0;
const weapon = getItem(attacker.equipment.weapon.itemId);
const armor = getItem(defender.equipment.armor.itemId);
const accessory = getItem(attacker.equipment.accessory.itemId);
const defenderAccessory = getItem(defender.equipment.accessory.itemId);
const bondBonus = attacker.faction === 'ally' ? this.strongestBondBonus(attacker.id) : { damageBonus: 0, chainRate: 0 };
if (action === 'attack' && weapon.id === 'twin-oath-blades' && bondBonus.chainRate > 0) {
damageBonus += 6;
hitBonus += 4;
labels.push('자웅일대검 공명');
}
if (action === 'attack' && weapon.id === 'green-dragon-glaive') {
damageBonus += attacker.stats.might > defender.stats.might ? 8 : 4;
labels.push(attacker.stats.might > defender.stats.might ? '청룡언월도 무력 우위' : '청룡언월도 대도');
}
if (action === 'attack' && weapon.id === 'serpent-spear') {
criticalBonus += 8;
if (attacker.hp <= Math.ceil(attacker.maxHp * 0.55)) {
damageBonus += 5;
labels.push('장팔사모 투지');
} else {
labels.push('장팔사모 치명');
}
}
if ((action === 'strategy' || usable?.command === 'strategy') && weapon.id === 'white-feather-fan') {
damageBonus += usable?.effect === 'damage' || action === 'strategy' ? 7 : 0;
hitBonus += 6;
labels.push('백우선 책략');
}
if (accessory.id === 'war-manual' && attacker.faction === 'ally' && bondBonus.damageBonus > 0) {
damageBonus += 3;
hitBonus += 3;
labels.push('병법서 공명 보정');
}
if (accessory.id === 'bravery-token' && attacker.hp <= Math.ceil(attacker.maxHp * 0.45)) {
damageBonus += 6;
criticalBonus += 4;
labels.push('호담패 저력');
}
if (accessory.id === 'wind-quiver' && (attacker.classKey === 'archer' || weapon.id === 'short-bow')) {
hitBonus += 8;
criticalBonus += 3;
labels.push('바람 화살통 명중');
}
if (armor.id === 'reinforced-lamellar' && action === 'attack') {
damageReduction += 8;
labels.push('철린갑 피해 감소');
}
if (armor.id === 'oath-robe' && defender.faction === 'ally' && this.strongestBondBonus(defender.id).damageBonus > 0) {
damageReduction += 4;
labels.push('도원포 공명 방호');
}
if (defenderAccessory.id === 'peach-charm' && defender.hp <= Math.ceil(defender.maxHp * 0.4)) {
damageReduction += 4;
labels.push('복숭아 부적 가호');
}
return { damageBonus, damageReduction, hitBonus, criticalBonus, labels };
}
private actionPower(unit: UnitData, action: DamageCommand, usable?: BattleUsable) {
@@ -3436,9 +3545,10 @@ export class BattleScene extends Phaser.Scene {
const outcome = this.formatCombatOutcome(result);
const defeated = result.defeated ? `\n${result.defender.name} 퇴각!` : `\n${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`;
const bondBonus = result.bondDamageBonus > 0 && result.bondLabel ? `\n공명 효과 ${result.bondLabel}: 피해 +${result.bondDamageBonus}%` : '';
const equipment = result.equipmentEffectLabels.length > 0 ? `\n보물 효과: ${result.equipmentEffectLabels.join(', ')}` : '';
const bond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : '';
const counter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : '';
return `${result.attacker.name} ${result.usable?.name ?? commandLabels[result.action]}\n${outcome}${defeated}${bondBonus}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`;
return `${result.attacker.name} ${result.usable?.name ?? commandLabels[result.action]}\n${outcome}${defeated}${bondBonus}${equipment}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`;
}
private formatCombatOutcome(result: CombatResult) {
@@ -3469,6 +3579,9 @@ export class BattleScene extends Phaser.Scene {
if (result.bondExp > 0) {
lines.push(`공명 경험치 +${result.bondExp}`);
}
if (result.equipmentEffectLabels.length > 0) {
lines.push(`보물 효과: ${result.equipmentEffectLabels.join(', ')}`);
}
return lines;
}
@@ -5409,7 +5522,8 @@ export class BattleScene extends Phaser.Scene {
.forEach((unit) => {
const terrain = battleMap.terrain[unit.y][unit.x];
const terrainRule = getTerrainRule(terrain);
const hpGain = Math.min(terrainRule.recoveryHp ?? 0, unit.maxHp - unit.hp);
const itemRecovery = this.turnStartEquipmentRecovery(unit);
const hpGain = Math.min((terrainRule.recoveryHp ?? 0) + itemRecovery.amount, unit.maxHp - unit.hp);
const moraleBonus = terrainRule.moraleBonus ?? 0;
if (hpGain <= 0 && moraleBonus <= 0) {
return;
@@ -5423,7 +5537,7 @@ export class BattleScene extends Phaser.Scene {
}
const parts = [
hpGain > 0 ? `병력 +${hpGain}` : '',
hpGain > 0 ? `병력 +${hpGain}${itemRecovery.label ? ` (${itemRecovery.label})` : ''}` : '',
moraleBonus > 0 ? `사기 +${moraleBonus}` : ''
].filter(Boolean);
messages.push(`${unit.name} ${terrainRule.label}: ${parts.join(' / ')}`);
@@ -5440,6 +5554,24 @@ export class BattleScene extends Phaser.Scene {
return message;
}
private turnStartEquipmentRecovery(unit: UnitData) {
let amount = 0;
const labels: string[] = [];
const accessory = getItem(unit.equipment.accessory.itemId);
const armor = getItem(unit.equipment.armor.itemId);
if (accessory.id === 'peach-charm' && unit.hp <= Math.ceil(unit.maxHp * 0.55)) {
amount += 4;
labels.push('복숭아 부적');
}
if (armor.id === 'oath-robe' && this.strongestBondBonus(unit.id).damageBonus > 0) {
amount += 2;
labels.push('도원포');
}
return { amount, label: labels.join('/') };
}
private applyTerrainMorale(unit: UnitData, moraleBonus: number) {
const existing = this.battleBuffs.get(unit.id);
this.battleBuffs.set(unit.id, {
@@ -5611,7 +5743,25 @@ export class BattleScene extends Phaser.Scene {
private weaponExpGain(unit: UnitData) {
const weapon = getItem(unit.equipment.weapon.itemId);
const bonus = this.strongestBondBonus(unit.id);
return 8 + (weapon.rank === 'treasure' ? 2 : 0) + (bonus.chainRate > 0 ? 2 : 0);
const oathBonus = weapon.id === 'twin-oath-blades' && bonus.chainRate > 0 ? 2 : 0;
const strategyWeaponBonus = weapon.id === 'white-feather-fan' ? 2 : 0;
return 8 + (weapon.rank === 'treasure' ? 2 : 0) + (bonus.chainRate > 0 ? 2 : 0) + oathBonus + strategyWeaponBonus;
}
private defenderArmorExpGain(unit: UnitData, hit: boolean, preview: CombatPreview) {
const armor = getItem(unit.equipment.armor.itemId);
const bondGuardBonus = armor.id === 'oath-robe' && preview.equipmentEffectLabels.includes('도원포 공명 방호') ? 2 : 0;
const treasureGuardBonus = armor.id === 'reinforced-lamellar' && hit ? 2 : 0;
return (hit ? 6 : 3) + bondGuardBonus + treasureGuardBonus;
}
private supportEquipmentExpGain(user: UnitData, usable: BattleUsable) {
const accessory = getItem(user.equipment.accessory.itemId);
const weapon = getItem(user.equipment.weapon.itemId);
const strategyBonus = usable.command === 'strategy' ? 2 : 0;
const manualBonus = accessory.id === 'war-manual' ? 2 : 0;
const featherBonus = weapon.id === 'white-feather-fan' && usable.command === 'strategy' ? 2 : 0;
return (usable.command === 'strategy' ? 7 : 5) + strategyBonus + manualBonus + featherBonus;
}
private awardEquipmentExp(unit: UnitData, slot: EquipmentSlot, amount: number): EquipmentGrowthResult {
@@ -6846,6 +6996,7 @@ export class BattleScene extends Phaser.Scene {
selectedUsable: this.selectedUsable?.id ?? null,
itemStocks: this.serializeItemStocks(),
battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
treasureEffectPreviews: this.debugTreasureEffectPreviews(),
units: battleUnits.map((unit) => ({
id: unit.id,
name: unit.name,
@@ -6873,6 +7024,34 @@ export class BattleScene extends Phaser.Scene {
};
}
private debugTreasureEffectPreviews() {
return ['liu-bei', 'guan-yu', 'zhang-fei', 'zhuge-liang']
.map((unitId) => battleUnits.find((unit) => unit.id === unitId && unit.hp > 0))
.filter((unit): unit is UnitData => Boolean(unit))
.map((unit) => {
const target = this.findNearestEnemy(unit);
if (!target) {
return undefined;
}
const action: DamageCommand = unit.id === 'zhuge-liang' ? 'strategy' : 'attack';
const preview = this.combatPreview(unit, target, action);
return {
attackerId: unit.id,
defenderId: target.id,
action,
damage: preview.damage,
hitRate: preview.hitRate,
criticalRate: preview.criticalRate,
equipmentDamageBonus: preview.equipmentDamageBonus,
equipmentDamageReduction: preview.equipmentDamageReduction,
equipmentHitBonus: preview.equipmentHitBonus,
equipmentCriticalBonus: preview.equipmentCriticalBonus,
equipmentEffectLabels: preview.equipmentEffectLabels
};
})
.filter((preview): preview is NonNullable<typeof preview> => Boolean(preview));
}
debugForceBattleOutcome(outcome: BattleOutcome = 'victory') {
if (!import.meta.env.DEV || this.battleOutcome) {
return;