From 2d4b08558d7452e4f8136b1abc3eae6e072492b7 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 06:15:00 +0900 Subject: [PATCH] Apply treasure equipment battle effects --- docs/roadmap.md | 7 +- scripts/verify-flow.mjs | 11 +- src/game/scenes/BattleScene.ts | 197 +++++++++++++++++++++++++++++++-- 3 files changed, 201 insertions(+), 14 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index b064415..60d62f5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 5bb0cf4..e120248 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -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')); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index b911d78..07b36f4 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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 => Boolean(preview)); + } + debugForceBattleOutcome(outcome: BattleOutcome = 'victory') { if (!import.meta.env.DEV || this.battleOutcome) { return;