import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { battleMapAssets } from '../data/battleMapAssets'; import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; import { defaultBattleScenario, getBattleScenario, type BattleObjectiveDefinition, type BattleScenarioDefinition } from '../data/battles'; import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { ensureUnitAnimations, hasUnitSheetAsset, loadUnitSheets, unitBaseFramesPerDirection, unitTextureVariantKeys as unitAssetTextureVariantKeys, type UnitDirection } from '../data/unitAssets'; import { battleUiIconFrames, battleUiIconTextureKey, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot, type ItemDefinition } from '../data/battleItems'; import { applySortieDeploymentPlan, createSortieDeploymentPlan, normalizeSortieFormationAssignments, type SortieFormationAssignments, type SortieFormationRole } from '../data/sortieDeployment'; import { campaignSaveSlotCount, getCampaignState, listCampaignSaveSlots, loadCampaignState, markCampaignStep, saveCampaignState, setFirstBattleReport, type CampaignState, type CampaignStep } from '../state/campaignState'; import { palette } from '../ui/palette'; import { startLazyScene } from './lazyScenes'; const unitTexture: Record = { 'liu-bei': 'unit-liu-bei', 'guan-yu': 'unit-guan-yu', 'zhang-fei': 'unit-zhang-fei', 'zhao-yun': 'unit-zhao-yun', 'zhuge-liang': 'unit-zhuge-liang', 'jiang-wei': 'unit-jiang-wei', 'northern-second-officer-jiang-wei': 'unit-jiang-wei', 'northern-third-officer-jiang-wei': 'unit-jiang-wei', 'ma-liang': 'unit-ma-liang', 'yi-ji': 'unit-yi-ji', 'gong-zhi': 'unit-gong-zhi', 'huang-zhong': 'unit-huang-zhong', 'wei-yan': 'unit-wei-yan', 'pang-tong': 'unit-pang-tong', 'fa-zheng': 'unit-fa-zheng', 'wu-yi': 'unit-wu-yi', 'yan-yan': 'unit-yan-yan', 'li-yan': 'unit-li-yan', 'rebel-a': 'unit-rebel', 'rebel-b': 'unit-rebel', 'rebel-c': 'unit-rebel-archer', 'rebel-d': 'unit-rebel', 'rebel-e': 'unit-rebel', 'rebel-f': 'unit-rebel', 'rebel-g': 'unit-rebel', 'rebel-cavalry-a': 'unit-rebel-cavalry', 'rebel-cavalry-b': 'unit-rebel-cavalry', 'rebel-archer-b': 'unit-rebel-archer', 'rebel-leader': 'unit-rebel-leader', 'pursuit-raider-a': 'unit-rebel', 'pursuit-raider-b': 'unit-rebel', 'pursuit-bandit-a': 'unit-rebel', 'pursuit-archer-a': 'unit-rebel-archer', 'pursuit-cavalry-a': 'unit-rebel-cavalry', 'pursuit-raider-c': 'unit-rebel', 'pursuit-archer-b': 'unit-rebel-archer', 'pursuit-cavalry-b': 'unit-rebel-cavalry', 'pursuit-guard-a': 'unit-rebel', 'pursuit-guard-b': 'unit-rebel', 'pursuit-leader-han-seok': 'unit-rebel-leader', 'xuzhou-raider-a': 'unit-rebel', 'xuzhou-infantry-a': 'unit-rebel', 'xuzhou-archer-a': 'unit-rebel-archer', 'xuzhou-cavalry-a': 'unit-rebel-cavalry', 'xuzhou-raider-b': 'unit-rebel', 'xuzhou-archer-b': 'unit-rebel-archer', 'xuzhou-cavalry-b': 'unit-rebel-cavalry', 'xuzhou-guard-a': 'unit-rebel', 'xuzhou-cavalry-c': 'unit-rebel-cavalry', 'xuzhou-guard-b': 'unit-rebel', 'xuzhou-archer-c': 'unit-rebel-archer', 'xuzhou-leader-xiahou-dun': 'unit-rebel-leader', 'xiapi-cavalry-a': 'unit-rebel-cavalry', 'xiapi-cavalry-b': 'unit-rebel-cavalry', 'xiapi-cavalry-c': 'unit-rebel-cavalry', 'xiapi-dike-guard-a': 'unit-rebel', 'xiapi-dike-guard-b': 'unit-rebel', 'xiapi-dike-guard-c': 'unit-rebel', 'xiapi-archer-a': 'unit-rebel-archer', 'xiapi-archer-b': 'unit-rebel-archer', 'xiapi-archer-c': 'unit-rebel-archer', 'xiapi-camp-raider-a': 'unit-rebel', 'xiapi-camp-raider-b': 'unit-rebel', 'xiapi-strategist-chen-gong': 'unit-rebel-archer', 'xiapi-leader-zhang-liao': 'unit-rebel-leader', 'xiapi-final-cavalry-a': 'unit-rebel-cavalry', 'xiapi-final-cavalry-b': 'unit-rebel-cavalry', 'xiapi-final-cavalry-c': 'unit-rebel-cavalry', 'xiapi-final-cavalry-d': 'unit-rebel-cavalry', 'xiapi-final-gate-guard-a': 'unit-rebel', 'xiapi-final-gate-guard-b': 'unit-rebel', 'xiapi-final-gate-guard-c': 'unit-rebel', 'xiapi-final-gate-guard-d': 'unit-rebel', 'xiapi-final-archer-a': 'unit-rebel-archer', 'xiapi-final-archer-b': 'unit-rebel-archer', 'xiapi-final-archer-c': 'unit-rebel-archer', 'xiapi-final-raider-a': 'unit-rebel', 'xiapi-final-raider-b': 'unit-rebel', 'xiapi-final-strategist-chen-gong': 'unit-rebel-archer', 'xiapi-final-leader-lu-bu': 'unit-lu-bu', 'cao-break-scout-a': 'unit-rebel', 'cao-break-scout-b': 'unit-rebel', 'cao-break-infantry-a': 'unit-rebel', 'cao-break-infantry-b': 'unit-rebel', 'cao-break-infantry-c': 'unit-rebel', 'cao-break-infantry-d': 'unit-rebel', 'cao-break-archer-a': 'unit-rebel-archer', 'cao-break-archer-b': 'unit-rebel-archer', 'cao-break-archer-c': 'unit-rebel-archer', 'cao-break-cavalry-a': 'unit-rebel-cavalry', 'cao-break-cavalry-b': 'unit-rebel-cavalry', 'cao-break-cavalry-c': 'unit-rebel-cavalry', 'cao-break-guard-a': 'unit-rebel', 'cao-break-guard-b': 'unit-rebel', 'cao-break-leader-che-zhou': 'unit-rebel-leader', 'yuan-refuge-scout-a': 'unit-rebel', 'yuan-refuge-scout-b': 'unit-rebel', 'yuan-refuge-infantry-a': 'unit-rebel', 'yuan-refuge-infantry-b': 'unit-rebel', 'yuan-refuge-infantry-c': 'unit-rebel', 'yuan-refuge-infantry-d': 'unit-rebel', 'yuan-refuge-archer-a': 'unit-rebel-archer', 'yuan-refuge-archer-b': 'unit-rebel-archer', 'yuan-refuge-archer-c': 'unit-rebel-archer', 'yuan-refuge-cavalry-a': 'unit-rebel-cavalry', 'yuan-refuge-cavalry-b': 'unit-rebel-cavalry', 'yuan-refuge-cavalry-c': 'unit-rebel-cavalry', 'yuan-refuge-guard-a': 'unit-rebel', 'yuan-refuge-guard-b': 'unit-rebel', 'yuan-refuge-guard-c': 'unit-rebel', 'yuan-refuge-strategist-a': 'unit-rebel-archer', 'yuan-refuge-leader-cai-yang': 'unit-rebel-leader', 'liu-biao-road-scout-a': 'unit-rebel', 'liu-biao-road-scout-b': 'unit-rebel', 'liu-biao-road-infantry-a': 'unit-rebel', 'liu-biao-road-infantry-b': 'unit-rebel', 'liu-biao-road-infantry-c': 'unit-rebel', 'liu-biao-road-infantry-d': 'unit-rebel', 'liu-biao-road-archer-a': 'unit-rebel-archer', 'liu-biao-road-archer-b': 'unit-rebel-archer', 'liu-biao-road-archer-c': 'unit-rebel-archer', 'liu-biao-road-archer-d': 'unit-rebel-archer', 'liu-biao-road-cavalry-a': 'unit-rebel-cavalry', 'liu-biao-road-cavalry-b': 'unit-rebel-cavalry', 'liu-biao-road-cavalry-c': 'unit-rebel-cavalry', 'liu-biao-road-cavalry-d': 'unit-rebel-cavalry', 'liu-biao-road-guard-a': 'unit-rebel', 'liu-biao-road-guard-b': 'unit-rebel', 'liu-biao-road-strategist-a': 'unit-rebel-archer', 'liu-biao-road-leader-cao-ren': 'unit-rebel-leader', 'wolong-road-scout-a': 'unit-rebel', 'wolong-road-scout-b': 'unit-rebel', 'wolong-road-infantry-a': 'unit-rebel', 'wolong-road-infantry-b': 'unit-rebel', 'wolong-road-infantry-c': 'unit-rebel', 'wolong-road-infantry-d': 'unit-rebel', 'wolong-road-archer-a': 'unit-rebel-archer', 'wolong-road-archer-b': 'unit-rebel-archer', 'wolong-road-archer-c': 'unit-rebel-archer', 'wolong-road-cavalry-a': 'unit-rebel-cavalry', 'wolong-road-cavalry-b': 'unit-rebel-cavalry', 'wolong-road-guard-a': 'unit-rebel', 'wolong-road-guard-b': 'unit-rebel', 'wolong-road-strategist-a': 'unit-rebel-archer', 'wolong-road-leader-cai-xun': 'unit-rebel-leader', 'bowang-scout-a': 'unit-rebel', 'bowang-scout-b': 'unit-rebel', 'bowang-infantry-a': 'unit-rebel', 'bowang-infantry-b': 'unit-rebel', 'bowang-infantry-c': 'unit-rebel', 'bowang-infantry-d': 'unit-rebel', 'bowang-archer-a': 'unit-rebel-archer', 'bowang-archer-b': 'unit-rebel-archer', 'bowang-archer-c': 'unit-rebel-archer', 'bowang-cavalry-a': 'unit-rebel-cavalry', 'bowang-cavalry-b': 'unit-rebel-cavalry', 'bowang-cavalry-c': 'unit-rebel-cavalry', 'bowang-guard-a': 'unit-rebel', 'bowang-guard-b': 'unit-rebel', 'bowang-strategist-a': 'unit-rebel-archer', 'bowang-leader-xiahou-dun': 'unit-rebel-leader', 'changban-scout-a': 'unit-rebel', 'changban-scout-b': 'unit-rebel', 'changban-cavalry-a': 'unit-rebel-cavalry', 'changban-cavalry-b': 'unit-rebel-cavalry', 'changban-cavalry-c': 'unit-rebel-cavalry', 'changban-cavalry-d': 'unit-rebel-cavalry', 'changban-infantry-a': 'unit-rebel', 'changban-infantry-b': 'unit-rebel', 'changban-infantry-c': 'unit-rebel', 'changban-infantry-d': 'unit-rebel', 'changban-archer-a': 'unit-rebel-archer', 'changban-archer-b': 'unit-rebel-archer', 'changban-archer-c': 'unit-rebel-archer', 'changban-guard-a': 'unit-rebel', 'changban-guard-b': 'unit-rebel', 'changban-strategist-a': 'unit-rebel-archer', 'changban-leader-cao-chun': 'unit-rebel-leader', 'envoy-road-scout-a': 'unit-rebel', 'envoy-road-scout-b': 'unit-rebel', 'envoy-road-cavalry-a': 'unit-rebel-cavalry', 'envoy-road-cavalry-b': 'unit-rebel-cavalry', 'envoy-road-cavalry-c': 'unit-rebel-cavalry', 'envoy-road-cavalry-d': 'unit-rebel-cavalry', 'envoy-road-infantry-a': 'unit-rebel', 'envoy-road-infantry-b': 'unit-rebel', 'envoy-road-infantry-c': 'unit-rebel', 'envoy-road-infantry-d': 'unit-rebel', 'envoy-road-archer-a': 'unit-rebel-archer', 'envoy-road-archer-b': 'unit-rebel-archer', 'envoy-road-archer-c': 'unit-rebel-archer', 'envoy-road-guard-a': 'unit-rebel', 'envoy-road-guard-b': 'unit-rebel', 'envoy-road-strategist-a': 'unit-rebel-archer', 'envoy-road-leader-wen-pin': 'unit-rebel-leader', 'redcliff-vanguard-scout-a': 'unit-rebel', 'redcliff-vanguard-scout-b': 'unit-rebel', 'redcliff-vanguard-marine-a': 'unit-rebel', 'redcliff-vanguard-marine-b': 'unit-rebel', 'redcliff-vanguard-marine-c': 'unit-rebel', 'redcliff-vanguard-marine-d': 'unit-rebel', 'redcliff-vanguard-archer-a': 'unit-rebel-archer', 'redcliff-vanguard-archer-b': 'unit-rebel-archer', 'redcliff-vanguard-archer-c': 'unit-rebel-archer', 'redcliff-vanguard-cavalry-a': 'unit-rebel-cavalry', 'redcliff-vanguard-cavalry-b': 'unit-rebel-cavalry', 'redcliff-vanguard-cavalry-c': 'unit-rebel-cavalry', 'redcliff-vanguard-guard-a': 'unit-rebel', 'redcliff-vanguard-guard-b': 'unit-rebel', 'redcliff-vanguard-strategist-a': 'unit-rebel-archer', 'redcliff-vanguard-strategist-b': 'unit-rebel-archer', 'redcliff-vanguard-leader-cai-mao': 'unit-rebel-leader', 'redcliff-fire-scout-a': 'unit-rebel', 'redcliff-fire-scout-b': 'unit-rebel', 'redcliff-fire-marine-a': 'unit-rebel', 'redcliff-fire-marine-b': 'unit-rebel', 'redcliff-fire-marine-c': 'unit-rebel', 'redcliff-fire-marine-d': 'unit-rebel', 'redcliff-fire-archer-a': 'unit-rebel-archer', 'redcliff-fire-archer-b': 'unit-rebel-archer', 'redcliff-fire-archer-c': 'unit-rebel-archer', 'redcliff-fire-archer-d': 'unit-rebel-archer', 'redcliff-fire-cavalry-a': 'unit-rebel-cavalry', 'redcliff-fire-cavalry-b': 'unit-rebel-cavalry', 'redcliff-fire-cavalry-c': 'unit-rebel-cavalry', 'redcliff-fire-guard-a': 'unit-rebel', 'redcliff-fire-guard-b': 'unit-rebel', 'redcliff-fire-strategist-a': 'unit-rebel-archer', 'redcliff-fire-strategist-b': 'unit-rebel-archer', 'redcliff-fire-leader-cao-cao': 'unit-cao-cao', 'jing-south-scout-a': 'unit-rebel', 'jing-south-scout-b': 'unit-rebel', 'jing-south-infantry-a': 'unit-rebel', 'jing-south-infantry-b': 'unit-rebel', 'jing-south-infantry-c': 'unit-rebel', 'jing-south-infantry-d': 'unit-rebel', 'jing-south-archer-a': 'unit-rebel-archer', 'jing-south-archer-b': 'unit-rebel-archer', 'jing-south-archer-c': 'unit-rebel-archer', 'jing-south-cavalry-a': 'unit-rebel-cavalry', 'jing-south-cavalry-b': 'unit-rebel-cavalry', 'jing-south-cavalry-c': 'unit-rebel-cavalry', 'jing-south-guard-a': 'unit-rebel', 'jing-south-guard-b': 'unit-rebel', 'jing-south-strategist-a': 'unit-rebel-archer', 'jing-south-strategist-b': 'unit-rebel-archer', 'jing-south-leader-xing-daorong': 'unit-rebel-leader', 'guiyang-scout-a': 'unit-rebel', 'guiyang-scout-b': 'unit-rebel', 'guiyang-infantry-a': 'unit-rebel', 'guiyang-infantry-b': 'unit-rebel', 'guiyang-infantry-c': 'unit-rebel', 'guiyang-infantry-d': 'unit-rebel', 'guiyang-archer-a': 'unit-rebel-archer', 'guiyang-archer-b': 'unit-rebel-archer', 'guiyang-archer-c': 'unit-rebel-archer', 'guiyang-cavalry-a': 'unit-rebel-cavalry', 'guiyang-cavalry-b': 'unit-rebel-cavalry', 'guiyang-cavalry-c': 'unit-rebel-cavalry', 'guiyang-guard-a': 'unit-rebel', 'guiyang-guard-b': 'unit-rebel', 'guiyang-strategist-a': 'unit-rebel-archer', 'guiyang-strategist-b': 'unit-rebel-archer', 'guiyang-leader-zhao-fan': 'unit-rebel-leader', 'wuling-scout-a': 'unit-rebel', 'wuling-scout-b': 'unit-rebel', 'wuling-scout-c': 'unit-rebel', 'wuling-bandit-a': 'unit-rebel', 'wuling-bandit-b': 'unit-rebel', 'wuling-bandit-c': 'unit-rebel', 'wuling-infantry-a': 'unit-rebel', 'wuling-infantry-b': 'unit-rebel', 'wuling-infantry-c': 'unit-rebel', 'wuling-infantry-d': 'unit-rebel', 'wuling-archer-a': 'unit-rebel-archer', 'wuling-archer-b': 'unit-rebel-archer', 'wuling-archer-c': 'unit-rebel-archer', 'wuling-cavalry-a': 'unit-rebel-cavalry', 'wuling-cavalry-b': 'unit-rebel-cavalry', 'wuling-guard-a': 'unit-rebel', 'wuling-guard-b': 'unit-rebel', 'wuling-strategist-a': 'unit-rebel-archer', 'wuling-leader-jin-xuan': 'unit-rebel-leader', 'changsha-scout-a': 'unit-rebel', 'changsha-scout-b': 'unit-rebel', 'changsha-infantry-a': 'unit-rebel', 'changsha-infantry-b': 'unit-rebel', 'changsha-infantry-c': 'unit-rebel', 'changsha-infantry-d': 'unit-rebel', 'changsha-archer-a': 'unit-rebel-archer', 'changsha-archer-b': 'unit-rebel-archer', 'changsha-archer-c': 'unit-rebel-archer', 'changsha-cavalry-a': 'unit-rebel-cavalry', 'changsha-cavalry-b': 'unit-rebel-cavalry', 'changsha-guard-a': 'unit-rebel', 'changsha-guard-b': 'unit-rebel', 'changsha-strategist-a': 'unit-rebel-archer', 'changsha-strategist-b': 'unit-rebel-archer', 'changsha-veteran-huang-zhong': 'unit-huang-zhong', 'changsha-officer-wei-yan': 'unit-wei-yan', 'changsha-leader-han-xuan': 'unit-rebel-leader', 'yizhou-scout-a': 'unit-rebel', 'yizhou-scout-b': 'unit-rebel', 'yizhou-infantry-a': 'unit-rebel', 'yizhou-infantry-b': 'unit-rebel', 'yizhou-infantry-c': 'unit-rebel', 'yizhou-infantry-d': 'unit-rebel', 'yizhou-archer-a': 'unit-rebel-archer', 'yizhou-archer-b': 'unit-rebel-archer', 'yizhou-archer-c': 'unit-rebel-archer', 'yizhou-cavalry-a': 'unit-rebel-cavalry', 'yizhou-cavalry-b': 'unit-rebel-cavalry', 'yizhou-cavalry-c': 'unit-rebel-cavalry', 'yizhou-guard-a': 'unit-rebel', 'yizhou-guard-b': 'unit-rebel', 'yizhou-strategist-a': 'unit-rebel-archer', 'yizhou-strategist-b': 'unit-rebel-archer', 'yizhou-ambusher-a': 'unit-rebel', 'yizhou-ambusher-b': 'unit-rebel', 'yizhou-leader-yang-huai': 'unit-rebel-leader', 'fupass-scout-a': 'unit-rebel', 'fupass-scout-b': 'unit-rebel', 'fupass-infantry-a': 'unit-rebel', 'fupass-infantry-b': 'unit-rebel', 'fupass-infantry-c': 'unit-rebel', 'fupass-infantry-d': 'unit-rebel', 'fupass-archer-a': 'unit-rebel-archer', 'fupass-archer-b': 'unit-rebel-archer', 'fupass-archer-c': 'unit-rebel-archer', 'fupass-cavalry-a': 'unit-rebel-cavalry', 'fupass-cavalry-b': 'unit-rebel-cavalry', 'fupass-cavalry-c': 'unit-rebel-cavalry', 'fupass-guard-a': 'unit-rebel', 'fupass-guard-b': 'unit-rebel', 'fupass-strategist-a': 'unit-rebel-archer', 'fupass-strategist-b': 'unit-rebel-archer', 'fupass-ambusher-a': 'unit-rebel', 'fupass-ambusher-b': 'unit-rebel', 'fupass-ambusher-c': 'unit-rebel', 'fupass-leader-gao-pei': 'unit-rebel-leader', 'luo-scout-a': 'unit-rebel', 'luo-scout-b': 'unit-rebel', 'luo-infantry-a': 'unit-rebel', 'luo-infantry-b': 'unit-rebel', 'luo-infantry-c': 'unit-rebel', 'luo-infantry-d': 'unit-rebel', 'luo-archer-a': 'unit-rebel-archer', 'luo-archer-b': 'unit-rebel-archer', 'luo-archer-c': 'unit-rebel-archer', 'luo-cavalry-a': 'unit-rebel-cavalry', 'luo-cavalry-b': 'unit-rebel-cavalry', 'luo-cavalry-c': 'unit-rebel-cavalry', 'luo-guard-a': 'unit-rebel', 'luo-guard-b': 'unit-rebel', 'luo-strategist-a': 'unit-rebel-archer', 'luo-strategist-b': 'unit-rebel-archer', 'luo-ambusher-a': 'unit-rebel', 'luo-ambusher-b': 'unit-rebel', 'luo-officer-wu-yi': 'unit-wu-yi', 'luo-leader-zhang-ren': 'unit-rebel-leader', 'luofeng-scout-a': 'unit-rebel', 'luofeng-scout-b': 'unit-rebel', 'luofeng-scout-c': 'unit-rebel', 'luofeng-infantry-a': 'unit-rebel', 'luofeng-infantry-b': 'unit-rebel', 'luofeng-infantry-c': 'unit-rebel', 'luofeng-infantry-d': 'unit-rebel', 'luofeng-archer-a': 'unit-rebel-archer', 'luofeng-archer-b': 'unit-rebel-archer', 'luofeng-archer-c': 'unit-rebel-archer', 'luofeng-archer-d': 'unit-rebel-archer', 'luofeng-cavalry-a': 'unit-rebel-cavalry', 'luofeng-cavalry-b': 'unit-rebel-cavalry', 'luofeng-cavalry-c': 'unit-rebel-cavalry', 'luofeng-guard-a': 'unit-rebel', 'luofeng-guard-b': 'unit-rebel', 'luofeng-strategist-a': 'unit-rebel-archer', 'luofeng-strategist-b': 'unit-rebel-archer', 'luofeng-ambusher-a': 'unit-rebel', 'luofeng-ambusher-b': 'unit-rebel', 'luofeng-ambusher-c': 'unit-rebel', 'luofeng-ambusher-d': 'unit-rebel', 'luofeng-leader-zhang-ren': 'unit-rebel-leader', 'luo-main-scout-a': 'unit-rebel', 'luo-main-scout-b': 'unit-rebel', 'luo-main-infantry-a': 'unit-rebel', 'luo-main-infantry-b': 'unit-rebel', 'luo-main-infantry-c': 'unit-rebel', 'luo-main-infantry-d': 'unit-rebel', 'luo-main-infantry-e': 'unit-rebel', 'luo-main-archer-a': 'unit-rebel-archer', 'luo-main-archer-b': 'unit-rebel-archer', 'luo-main-archer-c': 'unit-rebel-archer', 'luo-main-archer-d': 'unit-rebel-archer', 'luo-main-cavalry-a': 'unit-rebel-cavalry', 'luo-main-cavalry-b': 'unit-rebel-cavalry', 'luo-main-cavalry-c': 'unit-rebel-cavalry', 'luo-main-guard-a': 'unit-rebel', 'luo-main-guard-b': 'unit-rebel', 'luo-main-guard-c': 'unit-rebel', 'luo-main-strategist-a': 'unit-rebel-archer', 'luo-main-strategist-b': 'unit-rebel-archer', 'luo-main-ambusher-a': 'unit-rebel', 'luo-main-ambusher-b': 'unit-rebel', 'luo-main-ambusher-c': 'unit-rebel', 'luo-main-veteran-yan-yan': 'unit-yan-yan', 'luo-main-officer-a': 'unit-rebel-leader', 'luo-main-leader-zhang-ren': 'unit-rebel-leader', 'chengdu-scout-a': 'unit-rebel', 'chengdu-scout-b': 'unit-rebel', 'chengdu-scout-c': 'unit-rebel', 'chengdu-infantry-a': 'unit-rebel', 'chengdu-infantry-b': 'unit-rebel', 'chengdu-infantry-c': 'unit-rebel', 'chengdu-infantry-d': 'unit-rebel', 'chengdu-infantry-e': 'unit-rebel', 'chengdu-infantry-f': 'unit-rebel', 'chengdu-archer-a': 'unit-rebel-archer', 'chengdu-archer-b': 'unit-rebel-archer', 'chengdu-archer-c': 'unit-rebel-archer', 'chengdu-archer-d': 'unit-rebel-archer', 'chengdu-cavalry-a': 'unit-rebel-cavalry', 'chengdu-cavalry-b': 'unit-rebel-cavalry', 'chengdu-cavalry-c': 'unit-rebel-cavalry', 'chengdu-guard-a': 'unit-rebel', 'chengdu-guard-b': 'unit-rebel', 'chengdu-guard-c': 'unit-rebel', 'chengdu-strategist-a': 'unit-rebel-archer', 'chengdu-strategist-b': 'unit-rebel-archer', 'chengdu-ambusher-a': 'unit-rebel', 'chengdu-ambusher-b': 'unit-rebel', 'chengdu-ambusher-c': 'unit-rebel', 'chengdu-officer-fei-guan': 'unit-rebel-leader', 'chengdu-leader-li-yan': 'unit-li-yan', 'huang-quan': 'unit-huang-quan', 'chengdu-final-scout-a': 'unit-rebel', 'chengdu-final-scout-b': 'unit-rebel', 'chengdu-final-scout-c': 'unit-rebel', 'chengdu-final-infantry-a': 'unit-rebel', 'chengdu-final-infantry-b': 'unit-rebel', 'chengdu-final-infantry-c': 'unit-rebel', 'chengdu-final-infantry-d': 'unit-rebel', 'chengdu-final-infantry-e': 'unit-rebel', 'chengdu-final-infantry-f': 'unit-rebel', 'chengdu-final-archer-a': 'unit-rebel-archer', 'chengdu-final-archer-b': 'unit-rebel-archer', 'chengdu-final-archer-c': 'unit-rebel-archer', 'chengdu-final-archer-d': 'unit-rebel-archer', 'chengdu-final-cavalry-a': 'unit-rebel-cavalry', 'chengdu-final-cavalry-b': 'unit-rebel-cavalry', 'chengdu-final-cavalry-c': 'unit-rebel-cavalry', 'chengdu-final-guard-a': 'unit-rebel', 'chengdu-final-guard-b': 'unit-rebel', 'chengdu-final-guard-c': 'unit-rebel', 'chengdu-final-strategist-a': 'unit-rebel-archer', 'chengdu-final-strategist-b': 'unit-rebel-archer', 'chengdu-final-ambusher-a': 'unit-rebel', 'chengdu-final-ambusher-b': 'unit-rebel', 'chengdu-final-ambusher-c': 'unit-rebel', 'chengdu-final-officer-a': 'unit-rebel-leader', 'chengdu-final-leader-huang-quan': 'unit-huang-quan', 'ma-chao': 'unit-ma-chao', 'ma-dai': 'unit-ma-dai', 'wang-ping': 'unit-wang-ping', 'jiameng-scout-a': 'unit-rebel', 'jiameng-scout-b': 'unit-rebel', 'jiameng-scout-c': 'unit-rebel', 'jiameng-infantry-a': 'unit-rebel', 'jiameng-infantry-b': 'unit-rebel', 'jiameng-infantry-c': 'unit-rebel', 'jiameng-infantry-d': 'unit-rebel', 'jiameng-archer-a': 'unit-rebel-archer', 'jiameng-archer-b': 'unit-rebel-archer', 'jiameng-archer-c': 'unit-rebel-archer', 'jiameng-cavalry-a': 'unit-rebel-cavalry', 'jiameng-cavalry-b': 'unit-rebel-cavalry', 'jiameng-cavalry-c': 'unit-rebel-cavalry', 'jiameng-cavalry-d': 'unit-rebel-cavalry', 'jiameng-guard-a': 'unit-rebel', 'jiameng-guard-b': 'unit-rebel', 'jiameng-strategist-a': 'unit-rebel-archer', 'jiameng-strategist-b': 'unit-rebel-archer', 'jiameng-ambusher-a': 'unit-rebel', 'jiameng-ambusher-b': 'unit-rebel', 'jiameng-officer-pang-de': 'unit-rebel-leader', 'jiameng-leader-ma-chao': 'unit-ma-chao', 'yangping-scout-a': 'unit-rebel', 'yangping-scout-b': 'unit-rebel', 'yangping-scout-c': 'unit-rebel', 'yangping-infantry-a': 'unit-rebel', 'yangping-infantry-b': 'unit-rebel', 'yangping-infantry-c': 'unit-rebel', 'yangping-infantry-d': 'unit-rebel', 'yangping-infantry-e': 'unit-rebel', 'yangping-archer-a': 'unit-rebel-archer', 'yangping-archer-b': 'unit-rebel-archer', 'yangping-archer-c': 'unit-rebel-archer', 'yangping-archer-d': 'unit-rebel-archer', 'yangping-cavalry-a': 'unit-rebel-cavalry', 'yangping-cavalry-b': 'unit-rebel-cavalry', 'yangping-cavalry-c': 'unit-rebel-cavalry', 'yangping-cavalry-d': 'unit-rebel-cavalry', 'yangping-guard-a': 'unit-rebel', 'yangping-guard-b': 'unit-rebel', 'yangping-strategist-a': 'unit-rebel-archer', 'yangping-strategist-b': 'unit-rebel-archer', 'yangping-ambusher-a': 'unit-rebel', 'yangping-ambusher-b': 'unit-rebel', 'yangping-officer-yan-pu': 'unit-rebel-archer', 'yangping-leader-zhang-wei': 'unit-rebel-leader', 'dingjun-scout-a': 'unit-rebel', 'dingjun-scout-b': 'unit-rebel', 'dingjun-scout-c': 'unit-rebel', 'dingjun-infantry-a': 'unit-rebel', 'dingjun-infantry-b': 'unit-rebel', 'dingjun-infantry-c': 'unit-rebel', 'dingjun-infantry-d': 'unit-rebel', 'dingjun-infantry-e': 'unit-rebel', 'dingjun-archer-a': 'unit-rebel-archer', 'dingjun-archer-b': 'unit-rebel-archer', 'dingjun-archer-c': 'unit-rebel-archer', 'dingjun-archer-d': 'unit-rebel-archer', 'dingjun-cavalry-a': 'unit-rebel-cavalry', 'dingjun-cavalry-b': 'unit-rebel-cavalry', 'dingjun-cavalry-c': 'unit-rebel-cavalry', 'dingjun-cavalry-d': 'unit-rebel-cavalry', 'dingjun-guard-a': 'unit-rebel', 'dingjun-guard-b': 'unit-rebel', 'dingjun-strategist-a': 'unit-rebel-archer', 'dingjun-strategist-b': 'unit-rebel-archer', 'dingjun-ambusher-a': 'unit-rebel', 'dingjun-ambusher-b': 'unit-rebel', 'dingjun-officer-zhang-he': 'unit-rebel-cavalry', 'dingjun-officer-wang-ping': 'unit-wang-ping', 'dingjun-leader-xiahou-yuan': 'unit-rebel-leader', 'hanzhong-scout-a': 'unit-rebel', 'hanzhong-scout-b': 'unit-rebel', 'hanzhong-scout-c': 'unit-rebel', 'hanzhong-infantry-a': 'unit-rebel', 'hanzhong-infantry-b': 'unit-rebel', 'hanzhong-infantry-c': 'unit-rebel', 'hanzhong-infantry-d': 'unit-rebel', 'hanzhong-infantry-e': 'unit-rebel', 'hanzhong-infantry-f': 'unit-rebel', 'hanzhong-archer-a': 'unit-rebel-archer', 'hanzhong-archer-b': 'unit-rebel-archer', 'hanzhong-archer-c': 'unit-rebel-archer', 'hanzhong-archer-d': 'unit-rebel-archer', 'hanzhong-archer-e': 'unit-rebel-archer', 'hanzhong-cavalry-a': 'unit-rebel-cavalry', 'hanzhong-cavalry-b': 'unit-rebel-cavalry', 'hanzhong-cavalry-c': 'unit-rebel-cavalry', 'hanzhong-cavalry-d': 'unit-rebel-cavalry', 'hanzhong-cavalry-e': 'unit-rebel-cavalry', 'hanzhong-guard-a': 'unit-rebel', 'hanzhong-guard-b': 'unit-rebel', 'hanzhong-guard-c': 'unit-rebel', 'hanzhong-strategist-a': 'unit-rebel-archer', 'hanzhong-strategist-b': 'unit-rebel-archer', 'hanzhong-strategist-c': 'unit-rebel-archer', 'hanzhong-ambusher-a': 'unit-rebel', 'hanzhong-ambusher-b': 'unit-rebel', 'hanzhong-officer-zhang-he': 'unit-rebel-cavalry', 'hanzhong-officer-xu-huang': 'unit-rebel-leader', 'hanzhong-leader-cao-cao': 'unit-cao-cao', 'jing-defense-wu-scout-a': 'unit-rebel', 'jing-defense-wu-scout-b': 'unit-rebel', 'jing-defense-wei-scout-a': 'unit-rebel', 'jing-defense-wei-scout-b': 'unit-rebel', 'jing-defense-wu-infantry-a': 'unit-rebel', 'jing-defense-wu-infantry-b': 'unit-rebel', 'jing-defense-wei-infantry-a': 'unit-rebel', 'jing-defense-wei-infantry-b': 'unit-rebel', 'jing-defense-wei-infantry-c': 'unit-rebel', 'jing-defense-wu-archer-a': 'unit-rebel-archer', 'jing-defense-wu-archer-b': 'unit-rebel-archer', 'jing-defense-wei-archer-a': 'unit-rebel-archer', 'jing-defense-wei-archer-b': 'unit-rebel-archer', 'jing-defense-wu-cavalry-a': 'unit-rebel-cavalry', 'jing-defense-wu-cavalry-b': 'unit-rebel-cavalry', 'jing-defense-wei-cavalry-a': 'unit-rebel-cavalry', 'jing-defense-wei-cavalry-b': 'unit-rebel-cavalry', 'jing-defense-wu-guard-a': 'unit-rebel', 'jing-defense-wu-guard-b': 'unit-rebel', 'jing-defense-wei-guard-a': 'unit-rebel', 'jing-defense-wei-guard-b': 'unit-rebel', 'jing-defense-wu-strategist-a': 'unit-rebel-archer', 'jing-defense-wu-strategist-b': 'unit-rebel-archer', 'jing-defense-wei-strategist-a': 'unit-rebel-archer', 'jing-defense-ambusher-a': 'unit-rebel', 'jing-defense-ambusher-b': 'unit-rebel', 'jing-defense-ambusher-c': 'unit-rebel', 'jing-defense-officer-lu-xun': 'unit-rebel-archer', 'jing-defense-officer-cao-ren': 'unit-rebel-leader', 'jing-defense-leader-lu-meng': 'unit-lu-meng', 'fan-castle-scout-a': 'unit-rebel', 'fan-castle-scout-b': 'unit-rebel', 'fan-castle-scout-c': 'unit-rebel', 'fan-castle-infantry-a': 'unit-rebel', 'fan-castle-infantry-b': 'unit-rebel', 'fan-castle-infantry-c': 'unit-rebel', 'fan-castle-infantry-d': 'unit-rebel', 'fan-castle-infantry-e': 'unit-rebel', 'fan-castle-archer-a': 'unit-rebel-archer', 'fan-castle-archer-b': 'unit-rebel-archer', 'fan-castle-archer-c': 'unit-rebel-archer', 'fan-castle-archer-d': 'unit-rebel-archer', 'fan-castle-archer-e': 'unit-rebel-archer', 'fan-castle-cavalry-a': 'unit-rebel-cavalry', 'fan-castle-cavalry-b': 'unit-rebel-cavalry', 'fan-castle-cavalry-c': 'unit-rebel-cavalry', 'fan-castle-cavalry-d': 'unit-rebel-cavalry', 'fan-castle-guard-a': 'unit-rebel', 'fan-castle-guard-b': 'unit-rebel', 'fan-castle-guard-c': 'unit-rebel', 'fan-castle-guard-d': 'unit-rebel', 'fan-castle-strategist-a': 'unit-rebel-archer', 'fan-castle-strategist-b': 'unit-rebel-archer', 'fan-castle-strategist-c': 'unit-rebel-archer', 'fan-castle-ambusher-a': 'unit-rebel', 'fan-castle-ambusher-b': 'unit-rebel', 'fan-castle-ambusher-c': 'unit-rebel', 'fan-castle-officer-yu-jin': 'unit-rebel-leader', 'fan-castle-officer-cao-ren': 'unit-rebel-leader', 'fan-castle-leader-pang-de': 'unit-rebel-cavalry', 'han-river-scout-a': 'unit-rebel', 'han-river-scout-b': 'unit-rebel', 'han-river-scout-c': 'unit-rebel', 'han-river-infantry-a': 'unit-rebel', 'han-river-infantry-b': 'unit-rebel', 'han-river-infantry-c': 'unit-rebel', 'han-river-infantry-d': 'unit-rebel', 'han-river-infantry-e': 'unit-rebel', 'han-river-archer-a': 'unit-rebel-archer', 'han-river-archer-b': 'unit-rebel-archer', 'han-river-archer-c': 'unit-rebel-archer', 'han-river-archer-d': 'unit-rebel-archer', 'han-river-archer-e': 'unit-rebel-archer', 'han-river-cavalry-a': 'unit-rebel-cavalry', 'han-river-cavalry-b': 'unit-rebel-cavalry', 'han-river-cavalry-c': 'unit-rebel-cavalry', 'han-river-cavalry-d': 'unit-rebel-cavalry', 'han-river-guard-a': 'unit-rebel', 'han-river-guard-b': 'unit-rebel', 'han-river-guard-c': 'unit-rebel', 'han-river-guard-d': 'unit-rebel', 'han-river-strategist-a': 'unit-rebel-archer', 'han-river-strategist-b': 'unit-rebel-archer', 'han-river-strategist-c': 'unit-rebel-archer', 'han-river-ambusher-a': 'unit-rebel', 'han-river-ambusher-b': 'unit-rebel', 'han-river-ambusher-c': 'unit-rebel', 'han-river-officer-pang-de': 'unit-rebel-cavalry', 'han-river-officer-cao-ren': 'unit-rebel-leader', 'han-river-leader-yu-jin': 'unit-rebel-leader', 'fan-siege-scout-a': 'unit-rebel', 'fan-siege-scout-b': 'unit-rebel', 'fan-siege-scout-c': 'unit-rebel', 'fan-siege-scout-d': 'unit-rebel', 'fan-siege-infantry-a': 'unit-rebel', 'fan-siege-infantry-b': 'unit-rebel', 'fan-siege-infantry-c': 'unit-rebel', 'fan-siege-infantry-d': 'unit-rebel', 'fan-siege-infantry-e': 'unit-rebel', 'fan-siege-infantry-f': 'unit-rebel', 'fan-siege-archer-a': 'unit-rebel-archer', 'fan-siege-archer-b': 'unit-rebel-archer', 'fan-siege-archer-c': 'unit-rebel-archer', 'fan-siege-archer-d': 'unit-rebel-archer', 'fan-siege-archer-e': 'unit-rebel-archer', 'fan-siege-archer-f': 'unit-rebel-archer', 'fan-siege-cavalry-a': 'unit-rebel-cavalry', 'fan-siege-cavalry-b': 'unit-rebel-cavalry', 'fan-siege-cavalry-c': 'unit-rebel-cavalry', 'fan-siege-cavalry-d': 'unit-rebel-cavalry', 'fan-siege-cavalry-e': 'unit-rebel-cavalry', 'fan-siege-guard-a': 'unit-rebel', 'fan-siege-guard-b': 'unit-rebel', 'fan-siege-guard-c': 'unit-rebel', 'fan-siege-guard-d': 'unit-rebel', 'fan-siege-guard-e': 'unit-rebel', 'fan-siege-strategist-a': 'unit-rebel-archer', 'fan-siege-strategist-b': 'unit-rebel-archer', 'fan-siege-strategist-c': 'unit-rebel-archer', 'fan-siege-ambusher-a': 'unit-rebel', 'fan-siege-ambusher-b': 'unit-rebel', 'fan-siege-ambusher-c': 'unit-rebel', 'fan-siege-officer-pang-de': 'unit-rebel-cavalry', 'fan-siege-officer-lu-meng': 'unit-lu-meng', 'fan-siege-leader-cao-ren': 'unit-rebel-leader', 'jing-rear-scout-a': 'unit-rebel', 'jing-rear-scout-b': 'unit-rebel', 'jing-rear-scout-c': 'unit-rebel', 'jing-rear-scout-d': 'unit-rebel', 'jing-rear-scout-e': 'unit-rebel', 'jing-rear-infantry-a': 'unit-rebel', 'jing-rear-infantry-b': 'unit-rebel', 'jing-rear-infantry-c': 'unit-rebel', 'jing-rear-infantry-d': 'unit-rebel', 'jing-rear-infantry-e': 'unit-rebel', 'jing-rear-infantry-f': 'unit-rebel', 'jing-rear-archer-a': 'unit-rebel-archer', 'jing-rear-archer-b': 'unit-rebel-archer', 'jing-rear-archer-c': 'unit-rebel-archer', 'jing-rear-archer-d': 'unit-rebel-archer', 'jing-rear-archer-e': 'unit-rebel-archer', 'jing-rear-cavalry-a': 'unit-rebel-cavalry', 'jing-rear-cavalry-b': 'unit-rebel-cavalry', 'jing-rear-cavalry-c': 'unit-rebel-cavalry', 'jing-rear-cavalry-d': 'unit-rebel-cavalry', 'jing-rear-guard-a': 'unit-rebel', 'jing-rear-guard-b': 'unit-rebel', 'jing-rear-guard-c': 'unit-rebel', 'jing-rear-guard-d': 'unit-rebel', 'jing-rear-strategist-a': 'unit-rebel-archer', 'jing-rear-strategist-b': 'unit-rebel-archer', 'jing-rear-strategist-c': 'unit-rebel-archer', 'jing-rear-ambusher-a': 'unit-rebel', 'jing-rear-ambusher-b': 'unit-rebel', 'jing-rear-ambusher-c': 'unit-rebel', 'jing-rear-marine-a': 'unit-rebel', 'jing-rear-marine-b': 'unit-rebel', 'jing-rear-officer-lu-xun': 'unit-rebel-archer', 'jing-rear-officer-mi-fang': 'unit-rebel-leader', 'jing-rear-leader-lu-meng': 'unit-lu-meng', 'jing-collapse-scout-a': 'unit-rebel', 'jing-collapse-scout-b': 'unit-rebel', 'jing-collapse-scout-c': 'unit-rebel', 'jing-collapse-scout-d': 'unit-rebel', 'jing-collapse-infantry-a': 'unit-rebel', 'jing-collapse-infantry-b': 'unit-rebel', 'jing-collapse-infantry-c': 'unit-rebel', 'jing-collapse-infantry-d': 'unit-rebel', 'jing-collapse-infantry-e': 'unit-rebel', 'jing-collapse-infantry-f': 'unit-rebel', 'jing-collapse-archer-a': 'unit-rebel-archer', 'jing-collapse-archer-b': 'unit-rebel-archer', 'jing-collapse-archer-c': 'unit-rebel-archer', 'jing-collapse-archer-d': 'unit-rebel-archer', 'jing-collapse-archer-e': 'unit-rebel-archer', 'jing-collapse-cavalry-a': 'unit-rebel-cavalry', 'jing-collapse-cavalry-b': 'unit-rebel-cavalry', 'jing-collapse-cavalry-c': 'unit-rebel-cavalry', 'jing-collapse-cavalry-d': 'unit-rebel-cavalry', 'jing-collapse-guard-a': 'unit-rebel', 'jing-collapse-guard-b': 'unit-rebel', 'jing-collapse-guard-c': 'unit-rebel', 'jing-collapse-guard-d': 'unit-rebel', 'jing-collapse-strategist-a': 'unit-rebel-archer', 'jing-collapse-strategist-b': 'unit-rebel-archer', 'jing-collapse-strategist-c': 'unit-rebel-archer', 'jing-collapse-ambusher-a': 'unit-rebel', 'jing-collapse-ambusher-b': 'unit-rebel', 'jing-collapse-ambusher-c': 'unit-rebel', 'jing-collapse-ambusher-d': 'unit-rebel', 'jing-collapse-marine-a': 'unit-rebel', 'jing-collapse-marine-b': 'unit-rebel', 'jing-collapse-marine-c': 'unit-rebel', 'jing-collapse-officer-lu-xun': 'unit-rebel-archer', 'jing-collapse-officer-fu-shiren': 'unit-rebel-leader', 'jing-collapse-officer-mi-fang': 'unit-rebel-leader', 'jing-collapse-leader-lu-meng': 'unit-lu-meng', 'maicheng-wu-scout-a': 'unit-rebel', 'maicheng-wu-scout-b': 'unit-rebel', 'maicheng-wu-scout-c': 'unit-rebel', 'maicheng-wu-scout-d': 'unit-rebel', 'maicheng-wu-infantry-a': 'unit-rebel', 'maicheng-wu-infantry-b': 'unit-rebel', 'maicheng-wu-infantry-c': 'unit-rebel', 'maicheng-wu-infantry-d': 'unit-rebel', 'maicheng-wu-infantry-e': 'unit-rebel', 'maicheng-wu-infantry-f': 'unit-rebel', 'maicheng-wu-archer-a': 'unit-rebel-archer', 'maicheng-wu-archer-b': 'unit-rebel-archer', 'maicheng-wu-archer-c': 'unit-rebel-archer', 'maicheng-wu-archer-d': 'unit-rebel-archer', 'maicheng-wu-archer-e': 'unit-rebel-archer', 'maicheng-wu-archer-f': 'unit-rebel-archer', 'maicheng-wu-cavalry-a': 'unit-rebel-cavalry', 'maicheng-wu-cavalry-b': 'unit-rebel-cavalry', 'maicheng-wu-cavalry-c': 'unit-rebel-cavalry', 'maicheng-wu-cavalry-d': 'unit-rebel-cavalry', 'maicheng-wu-guard-a': 'unit-rebel', 'maicheng-wu-guard-b': 'unit-rebel', 'maicheng-wu-guard-c': 'unit-rebel', 'maicheng-wu-guard-d': 'unit-rebel', 'maicheng-wu-ambusher-a': 'unit-rebel', 'maicheng-wu-ambusher-b': 'unit-rebel', 'maicheng-wu-ambusher-c': 'unit-rebel', 'maicheng-wu-ambusher-d': 'unit-rebel', 'maicheng-wu-ambusher-e': 'unit-rebel', 'maicheng-wu-marine-a': 'unit-rebel', 'maicheng-wu-marine-b': 'unit-rebel', 'maicheng-wu-marine-c': 'unit-rebel', 'maicheng-wei-cavalry-a': 'unit-rebel-cavalry', 'maicheng-wei-cavalry-b': 'unit-rebel-cavalry', 'maicheng-wei-cavalry-c': 'unit-rebel-cavalry', 'maicheng-wei-infantry-a': 'unit-rebel', 'maicheng-wei-infantry-b': 'unit-rebel', 'maicheng-wei-infantry-c': 'unit-rebel', 'maicheng-strategist-a': 'unit-rebel-archer', 'maicheng-strategist-b': 'unit-rebel-archer', 'maicheng-strategist-c': 'unit-rebel-archer', 'maicheng-officer-pan-zhang': 'unit-rebel-leader', 'maicheng-officer-ma-zhong': 'unit-rebel', 'maicheng-officer-lu-xun': 'unit-rebel-archer', 'maicheng-officer-xu-huang': 'unit-rebel-cavalry', 'maicheng-leader-lu-meng': 'unit-lu-meng', 'yiling-strategist-a': 'unit-rebel-archer', 'yiling-strategist-b': 'unit-rebel-archer', 'yiling-strategist-c': 'unit-rebel-archer', 'yiling-officer-gan-ning-remnant': 'unit-rebel-leader', 'yiling-officer-zhu-ran': 'unit-rebel-leader', 'yiling-officer-pan-zhang': 'unit-rebel-leader', 'yiling-officer-ma-zhong': 'unit-rebel', 'yiling-leader-lu-xun': 'unit-rebel-leader', 'yiling-fire-strategist-a': 'unit-rebel-archer', 'yiling-fire-strategist-b': 'unit-rebel-archer', 'yiling-fire-strategist-c': 'unit-rebel-archer', 'yiling-fire-officer-zhu-ran': 'unit-rebel-leader', 'yiling-fire-officer-han-dang': 'unit-rebel-leader', 'yiling-fire-officer-pan-zhang': 'unit-rebel-leader', 'yiling-fire-officer-ma-zhong': 'unit-rebel', 'yiling-fire-leader-lu-xun': 'unit-rebel-leader', 'nanzhong-strategist-a': 'unit-rebel-archer', 'nanzhong-strategist-b': 'unit-rebel-archer', 'nanzhong-strategist-c': 'unit-rebel-archer', 'nanzhong-officer-gao-ding': 'unit-rebel-leader', 'nanzhong-officer-zhu-bao': 'unit-rebel-leader', 'nanzhong-officer-meng-huo-vanguard': 'unit-rebel-leader', 'nanzhong-leader-yong-kai': 'unit-rebel-leader', 'menghuo-shaman-a': 'unit-rebel-archer', 'menghuo-shaman-b': 'unit-rebel-archer', 'menghuo-shaman-c': 'unit-rebel-archer', 'menghuo-officer-ahuinan': 'unit-rebel-leader', 'menghuo-officer-dongtuna': 'unit-rebel-leader', 'menghuo-officer-mangyachang': 'unit-rebel-leader', 'menghuo-leader': 'unit-meng-huo', 'menghuo-return-shaman-a': 'unit-rebel-archer', 'menghuo-return-shaman-b': 'unit-rebel-archer', 'menghuo-return-shaman-c': 'unit-rebel-archer', 'menghuo-return-officer-meng-you': 'unit-rebel-leader', 'menghuo-return-officer-duosi': 'unit-rebel-leader', 'menghuo-return-officer-mulu': 'unit-rebel-leader', 'menghuo-second-leader': 'unit-meng-huo', 'menghuo-third-shaman-a': 'unit-rebel-archer', 'menghuo-third-shaman-b': 'unit-rebel-archer', 'menghuo-third-shaman-c': 'unit-rebel-archer', 'menghuo-third-officer-dailai': 'unit-rebel-leader', 'menghuo-third-officer-zhurong-vanguard': 'unit-rebel-leader', 'menghuo-third-officer-jinhuan': 'unit-rebel-leader', 'menghuo-third-officer-ahuinan': 'unit-rebel-leader', 'menghuo-third-leader': 'unit-meng-huo', 'menghuo-fourth-shaman-a': 'unit-rebel-archer', 'menghuo-fourth-shaman-b': 'unit-rebel-archer', 'menghuo-fourth-shaman-c': 'unit-rebel-archer', 'menghuo-fourth-shaman-d': 'unit-rebel-archer', 'menghuo-fourth-officer-wutugu': 'unit-rebel-leader', 'menghuo-fourth-officer-mangyachang': 'unit-rebel-leader', 'menghuo-fourth-officer-dongtuna': 'unit-rebel-leader', 'menghuo-fourth-officer-ahuinan': 'unit-rebel-leader', 'menghuo-fourth-leader': 'unit-meng-huo', 'menghuo-fifth-shaman-a': 'unit-rebel-archer', 'menghuo-fifth-shaman-b': 'unit-rebel-archer', 'menghuo-fifth-shaman-c': 'unit-rebel-archer', 'menghuo-fifth-shaman-d': 'unit-rebel-archer', 'menghuo-fifth-officer-zhurong': 'unit-rebel-leader', 'menghuo-fifth-officer-mulu': 'unit-rebel-leader', 'menghuo-fifth-officer-duosi': 'unit-rebel-leader', 'menghuo-fifth-officer-mengyou': 'unit-rebel-leader', 'menghuo-fifth-leader': 'unit-meng-huo', 'menghuo-sixth-shaman-a': 'unit-rebel-archer', 'menghuo-sixth-shaman-b': 'unit-rebel-archer', 'menghuo-sixth-shaman-c': 'unit-rebel-archer', 'menghuo-sixth-shaman-d': 'unit-rebel-archer', 'menghuo-sixth-officer-zhurong': 'unit-rebel-leader', 'menghuo-sixth-officer-mulu': 'unit-rebel-leader', 'menghuo-sixth-officer-duosi': 'unit-rebel-leader', 'menghuo-sixth-officer-mengyou': 'unit-rebel-leader', 'menghuo-sixth-officer-dailai': 'unit-rebel-leader', 'menghuo-sixth-leader': 'unit-meng-huo', 'menghuo-final-shaman-a': 'unit-rebel-archer', 'menghuo-final-shaman-b': 'unit-rebel-archer', 'menghuo-final-shaman-c': 'unit-rebel-archer', 'menghuo-final-shaman-d': 'unit-rebel-archer', 'menghuo-final-officer-zhurong': 'unit-rebel-leader', 'menghuo-final-officer-mulu': 'unit-rebel-leader', 'menghuo-final-officer-duosi': 'unit-rebel-leader', 'menghuo-final-officer-mengyou': 'unit-rebel-leader', 'menghuo-final-officer-dailai': 'unit-rebel-leader', 'menghuo-final-officer-ahuinan': 'unit-rebel-leader', 'menghuo-final-leader': 'unit-meng-huo' }; const canonicalAllyNames: Record = { 'liu-bei': '유비', 'guan-yu': '관우', 'zhang-fei': '장비', 'jian-yong': '간옹', 'mi-zhu': '미축', 'sun-qian': '손건', 'zhao-yun': '조운', 'zhuge-liang': '제갈량', 'ma-liang': '마량', 'yi-ji': '이적', 'gong-zhi': '공지', 'huang-zhong': '황충', 'wei-yan': '위연', 'pang-tong': '방통', 'fa-zheng': '법정', 'wu-yi': '오의', 'yan-yan': '엄안', 'li-yan': '이엄', 'huang-quan': '황권', 'ma-chao': '마초', 'ma-dai': '마대', 'wang-ping': '왕평', 'jiang-wei': '강유' }; const unitTextureByClass: Partial> = { lord: 'unit-liu-bei', infantry: 'unit-guan-yu', spearman: 'unit-zhang-fei', archer: 'unit-rebel-archer', strategist: 'unit-liu-bei', quartermaster: 'unit-liu-bei', cavalry: 'unit-rebel-cavalry', bandit: 'unit-rebel', yellowTurban: 'unit-rebel', rebelLeader: 'unit-rebel-leader' }; const coreAllyTextureIds = new Set(['liu-bei', 'guan-yu', 'zhang-fei']); const signatureUnitTextureIds = new Set([ 'zhao-yun', 'zhuge-liang', 'jiang-wei', 'northern-second-officer-jiang-wei', 'northern-third-officer-jiang-wei', 'ma-liang', 'yi-ji', 'gong-zhi', 'huang-zhong', 'changsha-veteran-huang-zhong', 'wei-yan', 'changsha-officer-wei-yan', 'pang-tong', 'fa-zheng', 'wu-yi', 'luo-officer-wu-yi', 'yan-yan', 'luo-main-veteran-yan-yan', 'li-yan', 'chengdu-leader-li-yan', 'huang-quan', 'chengdu-final-leader-huang-quan', 'ma-chao', 'jiameng-leader-ma-chao', 'ma-dai', 'wang-ping', 'dingjun-officer-wang-ping' ]); const earlyRebelTexturePrefixes = ['rebel-', 'pursuit-', 'guangzong-']; const wuTexturePrefixes = [ 'jing-defense-wu-', 'jing-rear-', 'jing-collapse-', 'maicheng-wu-', 'yiling-' ]; const nanmanTexturePrefixes = [ 'nanzhong-', 'menghuo-' ]; const unitFrameRows: Record = { south: 0, east: 1, north: 2, west: 3 }; const unitActionFrameCounts: Record = { attack: 10, strategy: 8, item: 8, hurt: 4, celebrate: 6 }; const unitActionColumnOffsets: Record = { attack: 0, strategy: 10, item: 18, hurt: 26, celebrate: 30 }; const unitActionFramesPerDirection = Object.values(unitActionFrameCounts).reduce((sum, count) => sum + count, 0); type BattleLayout = { mapX: number; mapY: number; mapWidth: number; mapHeight: number; gridX: number; gridY: number; gridSize: number; gridWidth: number; gridHeight: number; visibleColumns: number; visibleRows: number; tileSize: number; panelX: number; panelY: number; panelWidth: number; panelHeight: number; }; type TerrainTileView = { x: number; y: number; tile: Phaser.GameObjects.Rectangle; }; type MiniMapLayout = { x: number; y: number; width: number; height: number; cellSize: number; }; type UnitView = { sprite: Phaser.GameObjects.Sprite; hitZone: Phaser.GameObjects.Zone; label: Phaser.GameObjects.Text; textureBase: string; direction: UnitDirection; baseX: number; baseY: number; baseScaleX: number; baseScaleY: number; idleTween?: Phaser.Tweens.Tween; }; type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt' | 'celebrate'; const levelUpCelebrationStepOffsets = [0, -7, -2, -9, 0, -6, -3, -8]; type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved'; type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; type DamageCommand = Exclude; type UsableCommand = Extract; type UsableEffect = 'damage' | 'heal' | 'focus'; type UsableTarget = 'enemy' | 'ally' | 'self'; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const; type BattleSpeed = (typeof battleSpeedOptions)[number]; type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'bgm' | 'close'; type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type BattleOutcome = 'victory' | 'defeat'; type SaveSlotMode = 'save' | 'load'; type TurnPromptMode = 'turn-end' | 'post-move'; const battleSpeedStorageKey = 'heros-web:battle-speed'; const battleSpeedLabels: Record = { normal: '보통', fast: '빠름', 'very-fast': '매우 빠름' }; const battleSpeedFactors: Record = { normal: 1, fast: 1.45, 'very-fast': 2.05 }; type TurnEndPromptOptions = { mode?: TurnPromptMode; title?: string; body?: string; primaryLabel?: string; secondaryLabel?: string; primaryAction?: () => void; secondaryAction?: () => void; }; type BattleSceneData = { battleId?: string; selectedSortieUnitIds?: string[]; sortieFormationAssignments?: SortieFormationAssignments; }; type CommandButton = { command: BattleCommand; background: Phaser.GameObjects.Rectangle; text: Phaser.GameObjects.Text; }; type PendingMove = { unit: UnitData; fromX: number; fromY: number; toX: number; toY: number; }; type AttackIntent = { attackerId: string; targetId: string; }; type BondState = BattleBond & { battleExp: number; }; type BondCombatBonus = { damageBonus: number; chainRate: number; label?: string; partnerIds: string[]; }; type UnitBattleStats = { damageDealt: number; damageTaken: number; defeats: number; actions: number; support: number; }; type ThreatTile = { x: number; y: number; enemies: UnitData[]; strongestBehavior: EnemyAiBehavior; }; type MapMenuLayout = { left: number; top: number; width: number; buttonHeight: number; padding: number; actions: MapMenuAction[]; }; type BattleObjectiveResult = { id: string; label: string; achieved: boolean; status: 'active' | 'done' | 'failed'; detail: string; rewardGold: number; }; type BattleObjectiveState = BattleObjectiveResult & { status: 'active' | 'done' | 'failed'; }; type EquipmentGrowthResult = { slot: EquipmentSlot; itemName: string; amount: number; previousLevel: number; previousExp: number; level: number; exp: number; next: number; leveled: boolean; }; type LevelUpStatGain = { label: string; amount: number; icon: BattleUiIconKey; }; type CharacterGrowthResult = { amount: number; previousLevel: number; previousExp: number; level: number; exp: number; next: number; leveled: boolean; levelUps: number; statGains: LevelUpStatGain[]; }; type BondGrowthResult = { label: string; amount: number; previousLevel: number; previousExp: number; level: number; exp: number; next: number; leveled: boolean; }; type GrowthGaugeEntry = { kind: 'character' | 'equipment' | 'bond'; slot?: EquipmentSlot; label: string; owner: string; amountLabel: string; previousLevel: number; previousExp: number; previousNext: number; level: number; exp: number; next: number; leveled: boolean; color: number; statGains?: LevelUpStatGain[]; }; type GrowthGaugeView = { fill: Phaser.GameObjects.Rectangle; valueText: Phaser.GameObjects.Text; levelText: Phaser.GameObjects.Text; eventText: Phaser.GameObjects.Text; }; type ResultGaugeAnimation = { fill: Phaser.GameObjects.Rectangle; valueText: Phaser.GameObjects.Text; levelText: Phaser.GameObjects.Text; eventText: Phaser.GameObjects.Text; unit?: UnitData; icon?: BattleUiIconKey; levelPrefix: string; previousLevel: number; previousExp: number; previousNext: number; level: number; exp: number; next: number; leveled: boolean; statGains?: LevelUpStatGain[]; flashArea: { x: number; y: number; width: number; height: number; depth: number; }; }; type CombatPreview = { action: DamageCommand; usable?: BattleUsable; attacker: UnitData; defender: UnitData; damage: number; bondDamageBonus: number; bondLabel?: string; bondPartnerIds: string[]; equipmentDamageBonus: number; equipmentDamageReduction: number; equipmentHitBonus: number; equipmentCriticalBonus: number; equipmentEffectLabels: string[]; hitRate: number; criticalRate: number; terrainDefenseBonus: number; terrainHitPenalty: number; counterAvailable: boolean; terrainLabel: string; matchupLabel: string; }; type CombatResult = CombatPreview & { expectedDamage: number; previousDefenderHp: number; hit: boolean; critical: boolean; isCounter: boolean; defeated: boolean; characterGrowth: CharacterGrowthResult; attackerGrowth: EquipmentGrowthResult; defenderGrowth: EquipmentGrowthResult; bondExp: number; bondGrowth: BondGrowthResult[]; counter?: CombatResult; }; type BattleUsable = { id: string; command: UsableCommand; name: string; target: UsableTarget; effect: UsableEffect; range: number; power: number; accuracyBonus?: number; criticalBonus?: number; attackBonus?: number; hitBonus?: number; duration?: number; description: string; }; type SupportPreview = { usable: BattleUsable; user: UnitData; target: UnitData; healAmount: number; projectedHp: number; attackBonus: number; hitBonus: number; criticalBonus: number; duration: number; }; type UsableMenuForecast = { available: boolean; status: string; targetCount: number; valueLabel: string; value: string; detail: string; secondary: string; chips: UsableMenuChip[]; }; type LockedTargetPreview = { kind: 'damage' | 'support'; userId: string; targetId: string; action: DamageCommand; usableId?: string; }; type SupportResult = { usable: BattleUsable; user: UnitData; target: UnitData; previousTargetHp: number; healAmount: number; buff?: BattleBuffState; characterGrowth: CharacterGrowthResult; equipmentGrowth: EquipmentGrowthResult; }; type BattleBuffState = { unitId: string; label: string; turns: number; attackBonus: number; hitBonus: number; criticalBonus: number; }; type UnitEffectSummary = { icon: BattleUiIconKey; label: string; value: string; tone: number; turns?: number; }; type PreviewModifierSummary = { icon: BattleUiIconKey; label: string; value: string; tone: number; }; type UsableMenuChip = { icon: BattleUiIconKey; label: string; value: string; tone: number; }; type TargetingGuideMetric = { icon: BattleUiIconKey; label: string; value: string; tone: number; }; type TargetingGuideCandidate = { icon: BattleUiIconKey; name: string; meta: string; primary: string; secondary: string; tone: number; metrics?: TargetingGuideMetric[]; }; type TargetingGuideNotice = { icon: BattleUiIconKey; text: string; tone: number; }; type SavedUnitState = Pick & { equipment: UnitData['equipment']; direction?: UnitDirection; }; type BattleSaveState = { version: 1; battleId: string; campaignStep?: CampaignStep; savedAt: string; turnNumber: number; activeFaction: ActiveFaction; rosterTab: RosterTab; actedUnitIds: string[]; attackIntents: AttackIntent[]; battleLog: string[]; units: SavedUnitState[]; bonds: BondState[]; itemStocks?: Record>; battleBuffs?: BattleBuffState[]; battleStats?: Record; triggeredBattleEvents?: string[]; }; const maxEquipmentLevel = 9; const maxCharacterLevel = 99; let battleScenario = defaultBattleScenario; let battleMap = battleScenario.map; let battleUnits = battleScenario.units; let battleBonds = battleScenario.bonds; const legacyBattleSaveStorageKey = 'heros-web:first-battle-state'; let battleSaveStorageKey = `heros-web:battle:${battleScenario.id}`; let initialBattleUnits = battleUnits.map(cloneUnitData); let initialBattleBonds = battleBonds.map(cloneBattleBond); const usableCatalog: Record = { aid: { id: 'aid', command: 'strategy', name: '응급', target: 'ally', effect: 'heal', range: 2, power: 12, description: '가까운 아군의 병력을 조금 회복한다.' }, encourage: { id: 'encourage', command: 'strategy', name: '격려', target: 'ally', effect: 'focus', range: 2, power: 0, attackBonus: 2, hitBonus: 8, criticalBonus: 6, duration: 2, description: '아군의 공격, 명중, 치명 보정을 잠시 올린다.' }, fireTactic: { id: 'fireTactic', command: 'strategy', name: '소화', target: 'enemy', effect: 'damage', range: 2, power: 12, accuracyBonus: 6, criticalBonus: 0, description: '적 한 부대에 책략 피해를 준다.' }, roar: { id: 'roar', command: 'strategy', name: '고함', target: 'enemy', effect: 'damage', range: 1, power: 9, accuracyBonus: 8, criticalBonus: 4, description: '가까운 적을 위압해 피해를 준다.' }, bean: { id: 'bean', command: 'item', name: '콩', target: 'ally', effect: 'heal', range: 1, power: 10, description: '아군 한 부대의 병력을 소량 회복한다.' }, salve: { id: 'salve', command: 'item', name: '상처약', target: 'ally', effect: 'heal', range: 1, power: 18, description: '아군 한 부대의 병력을 크게 회복한다.' }, wine: { id: 'wine', command: 'item', name: '술', target: 'self', effect: 'focus', range: 0, power: 0, attackBonus: 3, hitBonus: 6, criticalBonus: 10, duration: 2, description: '사용자의 공격과 치명 보정을 잠시 올린다.' } }; const unitStrategyIds: Record = { 'liu-bei': ['aid', 'encourage'], 'guan-yu': ['fireTactic'], 'zhang-fei': ['roar'], 'jian-yong': ['aid', 'encourage', 'fireTactic'], 'mi-zhu': ['aid', 'encourage'], 'sun-qian': ['aid', 'encourage', 'fireTactic'], 'zhao-yun': ['encourage'], 'zhuge-liang': ['aid', 'encourage', 'fireTactic'], 'ma-liang': ['aid', 'encourage', 'fireTactic'], 'yi-ji': ['aid', 'encourage'], 'gong-zhi': ['encourage'], 'huang-zhong': ['encourage'], 'wei-yan': ['roar'], 'pang-tong': ['aid', 'encourage', 'fireTactic'], 'fa-zheng': ['aid', 'encourage', 'fireTactic'], 'wu-yi': ['encourage'], 'yan-yan': ['encourage'], 'li-yan': ['aid', 'encourage'], 'huang-quan': ['aid', 'encourage'], 'ma-chao': ['roar', 'encourage'], 'ma-dai': ['encourage'], 'wang-ping': ['aid', 'encourage'], 'jiang-wei': ['aid', 'encourage', 'fireTactic'] }; const initialItemStocks: Record> = { 'liu-bei': { bean: 2, salve: 1 }, 'guan-yu': { bean: 1 }, 'zhang-fei': { bean: 1, wine: 1 }, 'jian-yong': { bean: 1 }, 'mi-zhu': { bean: 3, salve: 2 }, 'sun-qian': { bean: 1, salve: 1 }, 'zhao-yun': { bean: 2, salve: 1, wine: 1 }, 'zhuge-liang': { bean: 2, salve: 1, wine: 1 }, 'ma-liang': { bean: 2, salve: 2 }, 'yi-ji': { bean: 2, salve: 1 }, 'gong-zhi': { bean: 2 }, 'huang-zhong': { bean: 2, salve: 1, wine: 1 }, 'wei-yan': { bean: 2 }, 'pang-tong': { bean: 1, salve: 1, wine: 1 }, 'fa-zheng': { bean: 2, salve: 2, wine: 1 }, 'wu-yi': { bean: 2, wine: 1 }, 'yan-yan': { bean: 2 }, 'li-yan': { bean: 2, salve: 2 }, 'huang-quan': { bean: 2, salve: 2 }, 'ma-chao': { bean: 2, salve: 1, wine: 1 }, 'ma-dai': { bean: 2, salve: 1, wine: 1 }, 'wang-ping': { bean: 3, salve: 1 }, 'jiang-wei': { bean: 2, salve: 1, wine: 1 } }; const attackRangeByClass: Partial> = { archer: 2, strategist: 2 }; const enemyAiByUnitId: Record = { 'rebel-a': 'aggressive', 'rebel-b': 'guard', 'rebel-c': 'hold', 'rebel-d': 'guard', 'rebel-e': 'aggressive', 'rebel-f': 'guard', 'rebel-g': 'guard', 'rebel-cavalry-a': 'aggressive', 'rebel-cavalry-b': 'aggressive', 'rebel-archer-b': 'hold', 'rebel-leader': 'guard', 'pursuit-raider-a': 'aggressive', 'pursuit-raider-b': 'guard', 'pursuit-bandit-a': 'aggressive', 'pursuit-archer-a': 'hold', 'pursuit-cavalry-a': 'aggressive', 'pursuit-raider-c': 'aggressive', 'pursuit-archer-b': 'hold', 'pursuit-cavalry-b': 'aggressive', 'pursuit-guard-a': 'guard', 'pursuit-guard-b': 'guard', 'pursuit-leader-han-seok': 'guard', 'xuzhou-raider-a': 'aggressive', 'xuzhou-infantry-a': 'guard', 'xuzhou-archer-a': 'hold', 'xuzhou-cavalry-a': 'aggressive', 'xuzhou-raider-b': 'aggressive', 'xuzhou-archer-b': 'hold', 'xuzhou-cavalry-b': 'aggressive', 'xuzhou-guard-a': 'guard', 'xuzhou-cavalry-c': 'aggressive', 'xuzhou-guard-b': 'guard', 'xuzhou-archer-c': 'hold', 'xuzhou-leader-xiahou-dun': 'guard', 'xiaopei-raider-a': 'aggressive', 'xiaopei-raider-b': 'aggressive', 'xiaopei-infantry-a': 'guard', 'xiaopei-infantry-b': 'guard', 'xiaopei-archer-a': 'hold', 'xiaopei-archer-b': 'hold', 'xiaopei-guard-a': 'aggressive', 'xiaopei-guard-b': 'aggressive', 'xiaopei-cavalry-a': 'aggressive', 'xiaopei-cavalry-b': 'aggressive', 'xiaopei-leader-gao-shun': 'guard', 'xuzhou-gate-traitor-a': 'aggressive', 'xuzhou-gate-traitor-b': 'aggressive', 'xuzhou-gate-infantry-a': 'guard', 'xuzhou-gate-infantry-b': 'guard', 'xuzhou-gate-archer-a': 'hold', 'xuzhou-gate-archer-b': 'hold', 'lubu-cavalry-a': 'aggressive', 'lubu-cavalry-b': 'aggressive', 'lubu-cavalry-c': 'aggressive', 'xuzhou-leader-cao-bao': 'guard', 'xuzhou-escape-cavalry-a': 'aggressive', 'xuzhou-escape-cavalry-b': 'aggressive', 'xuzhou-escape-cavalry-c': 'aggressive', 'xuzhou-escape-guard-a': 'guard', 'xuzhou-escape-guard-b': 'guard', 'xuzhou-escape-guard-c': 'guard', 'xuzhou-escape-archer-a': 'hold', 'xuzhou-escape-archer-b': 'hold', 'xuzhou-escape-raider-a': 'aggressive', 'xuzhou-escape-raider-b': 'aggressive', 'xuzhou-escape-leader-song-xian': 'guard', 'xudu-road-raider-a': 'guard', 'xudu-road-raider-b': 'guard', 'xudu-road-raider-c': 'guard', 'xudu-road-bandit-a': 'aggressive', 'xudu-road-bandit-b': 'aggressive', 'xudu-road-archer-a': 'hold', 'xudu-road-archer-b': 'hold', 'xudu-road-cavalry-a': 'aggressive', 'xudu-road-cavalry-b': 'aggressive', 'xudu-road-guard-a': 'guard', 'xudu-road-guard-b': 'guard', 'xudu-road-leader-ji-ling': 'guard', 'xiapi-cavalry-a': 'aggressive', 'xiapi-cavalry-b': 'aggressive', 'xiapi-cavalry-c': 'aggressive', 'xiapi-dike-guard-a': 'guard', 'xiapi-dike-guard-b': 'guard', 'xiapi-dike-guard-c': 'guard', 'xiapi-archer-a': 'hold', 'xiapi-archer-b': 'hold', 'xiapi-archer-c': 'hold', 'xiapi-camp-raider-a': 'aggressive', 'xiapi-camp-raider-b': 'aggressive', 'xiapi-strategist-chen-gong': 'hold', 'xiapi-leader-zhang-liao': 'guard', 'xiapi-final-cavalry-a': 'aggressive', 'xiapi-final-cavalry-b': 'aggressive', 'xiapi-final-cavalry-c': 'aggressive', 'xiapi-final-cavalry-d': 'aggressive', 'xiapi-final-gate-guard-a': 'guard', 'xiapi-final-gate-guard-b': 'guard', 'xiapi-final-gate-guard-c': 'guard', 'xiapi-final-gate-guard-d': 'guard', 'xiapi-final-archer-a': 'hold', 'xiapi-final-archer-b': 'hold', 'xiapi-final-archer-c': 'hold', 'xiapi-final-raider-a': 'aggressive', 'xiapi-final-raider-b': 'aggressive', 'xiapi-final-strategist-chen-gong': 'hold', 'xiapi-final-leader-lu-bu': 'guard', 'cao-break-scout-a': 'aggressive', 'cao-break-scout-b': 'aggressive', 'cao-break-infantry-a': 'guard', 'cao-break-infantry-b': 'guard', 'cao-break-infantry-c': 'guard', 'cao-break-infantry-d': 'guard', 'cao-break-archer-a': 'hold', 'cao-break-archer-b': 'hold', 'cao-break-archer-c': 'hold', 'cao-break-cavalry-a': 'aggressive', 'cao-break-cavalry-b': 'aggressive', 'cao-break-cavalry-c': 'aggressive', 'cao-break-guard-a': 'guard', 'cao-break-guard-b': 'guard', 'cao-break-leader-che-zhou': 'guard', 'yuan-refuge-scout-a': 'aggressive', 'yuan-refuge-scout-b': 'aggressive', 'yuan-refuge-infantry-a': 'guard', 'yuan-refuge-infantry-b': 'guard', 'yuan-refuge-infantry-c': 'guard', 'yuan-refuge-infantry-d': 'guard', 'yuan-refuge-archer-a': 'hold', 'yuan-refuge-archer-b': 'hold', 'yuan-refuge-archer-c': 'hold', 'yuan-refuge-cavalry-a': 'aggressive', 'yuan-refuge-cavalry-b': 'aggressive', 'yuan-refuge-cavalry-c': 'aggressive', 'yuan-refuge-guard-a': 'guard', 'yuan-refuge-guard-b': 'guard', 'yuan-refuge-guard-c': 'guard', 'yuan-refuge-strategist-a': 'hold', 'yuan-refuge-leader-cai-yang': 'guard', 'liu-biao-road-scout-a': 'aggressive', 'liu-biao-road-scout-b': 'aggressive', 'liu-biao-road-infantry-a': 'guard', 'liu-biao-road-infantry-b': 'guard', 'liu-biao-road-infantry-c': 'guard', 'liu-biao-road-infantry-d': 'guard', 'liu-biao-road-archer-a': 'hold', 'liu-biao-road-archer-b': 'hold', 'liu-biao-road-archer-c': 'hold', 'liu-biao-road-archer-d': 'hold', 'liu-biao-road-cavalry-a': 'aggressive', 'liu-biao-road-cavalry-b': 'aggressive', 'liu-biao-road-cavalry-c': 'guard', 'liu-biao-road-cavalry-d': 'guard', 'liu-biao-road-guard-a': 'guard', 'liu-biao-road-guard-b': 'guard', 'liu-biao-road-strategist-a': 'hold', 'liu-biao-road-leader-cao-ren': 'guard', 'wolong-road-scout-a': 'aggressive', 'wolong-road-scout-b': 'aggressive', 'wolong-road-infantry-a': 'guard', 'wolong-road-infantry-b': 'guard', 'wolong-road-infantry-c': 'guard', 'wolong-road-infantry-d': 'guard', 'wolong-road-archer-a': 'hold', 'wolong-road-archer-b': 'hold', 'wolong-road-archer-c': 'hold', 'wolong-road-cavalry-a': 'aggressive', 'wolong-road-cavalry-b': 'aggressive', 'wolong-road-guard-a': 'guard', 'wolong-road-guard-b': 'guard', 'wolong-road-strategist-a': 'hold', 'wolong-road-leader-cai-xun': 'guard', 'bowang-scout-a': 'aggressive', 'bowang-scout-b': 'aggressive', 'bowang-infantry-a': 'guard', 'bowang-infantry-b': 'guard', 'bowang-infantry-c': 'guard', 'bowang-infantry-d': 'guard', 'bowang-archer-a': 'hold', 'bowang-archer-b': 'hold', 'bowang-archer-c': 'hold', 'bowang-cavalry-a': 'aggressive', 'bowang-cavalry-b': 'aggressive', 'bowang-cavalry-c': 'aggressive', 'bowang-guard-a': 'guard', 'bowang-guard-b': 'guard', 'bowang-strategist-a': 'hold', 'bowang-leader-xiahou-dun': 'guard', 'changban-scout-a': 'aggressive', 'changban-scout-b': 'aggressive', 'changban-cavalry-a': 'aggressive', 'changban-cavalry-b': 'aggressive', 'changban-cavalry-c': 'aggressive', 'changban-cavalry-d': 'aggressive', 'changban-infantry-a': 'guard', 'changban-infantry-b': 'guard', 'changban-infantry-c': 'guard', 'changban-infantry-d': 'guard', 'changban-archer-a': 'hold', 'changban-archer-b': 'hold', 'changban-archer-c': 'hold', 'changban-guard-a': 'guard', 'changban-guard-b': 'guard', 'changban-strategist-a': 'hold', 'changban-leader-cao-chun': 'guard', 'envoy-road-scout-a': 'aggressive', 'envoy-road-scout-b': 'aggressive', 'envoy-road-cavalry-a': 'aggressive', 'envoy-road-cavalry-b': 'aggressive', 'envoy-road-cavalry-c': 'guard', 'envoy-road-cavalry-d': 'guard', 'envoy-road-infantry-a': 'guard', 'envoy-road-infantry-b': 'guard', 'envoy-road-infantry-c': 'guard', 'envoy-road-infantry-d': 'guard', 'envoy-road-archer-a': 'hold', 'envoy-road-archer-b': 'hold', 'envoy-road-archer-c': 'hold', 'envoy-road-guard-a': 'guard', 'envoy-road-guard-b': 'guard', 'envoy-road-strategist-a': 'hold', 'envoy-road-leader-wen-pin': 'guard', 'redcliff-vanguard-scout-a': 'aggressive', 'redcliff-vanguard-scout-b': 'aggressive', 'redcliff-vanguard-marine-a': 'guard', 'redcliff-vanguard-marine-b': 'guard', 'redcliff-vanguard-marine-c': 'guard', 'redcliff-vanguard-marine-d': 'guard', 'redcliff-vanguard-archer-a': 'hold', 'redcliff-vanguard-archer-b': 'hold', 'redcliff-vanguard-archer-c': 'hold', 'redcliff-vanguard-cavalry-a': 'aggressive', 'redcliff-vanguard-cavalry-b': 'aggressive', 'redcliff-vanguard-cavalry-c': 'aggressive', 'redcliff-vanguard-guard-a': 'guard', 'redcliff-vanguard-guard-b': 'guard', 'redcliff-vanguard-strategist-a': 'hold', 'redcliff-vanguard-strategist-b': 'hold', 'redcliff-vanguard-leader-cai-mao': 'guard', 'redcliff-fire-scout-a': 'aggressive', 'redcliff-fire-scout-b': 'aggressive', 'redcliff-fire-marine-a': 'guard', 'redcliff-fire-marine-b': 'guard', 'redcliff-fire-marine-c': 'guard', 'redcliff-fire-marine-d': 'guard', 'redcliff-fire-archer-a': 'hold', 'redcliff-fire-archer-b': 'hold', 'redcliff-fire-archer-c': 'hold', 'redcliff-fire-archer-d': 'hold', 'redcliff-fire-cavalry-a': 'aggressive', 'redcliff-fire-cavalry-b': 'aggressive', 'redcliff-fire-cavalry-c': 'guard', 'redcliff-fire-guard-a': 'guard', 'redcliff-fire-guard-b': 'guard', 'redcliff-fire-strategist-a': 'hold', 'redcliff-fire-strategist-b': 'hold', 'redcliff-fire-leader-cao-cao': 'guard', 'jing-south-scout-a': 'aggressive', 'jing-south-scout-b': 'aggressive', 'jing-south-infantry-a': 'guard', 'jing-south-infantry-b': 'guard', 'jing-south-infantry-c': 'guard', 'jing-south-infantry-d': 'guard', 'jing-south-archer-a': 'hold', 'jing-south-archer-b': 'hold', 'jing-south-archer-c': 'hold', 'jing-south-cavalry-a': 'aggressive', 'jing-south-cavalry-b': 'aggressive', 'jing-south-cavalry-c': 'aggressive', 'jing-south-guard-a': 'guard', 'jing-south-guard-b': 'guard', 'jing-south-strategist-a': 'hold', 'jing-south-strategist-b': 'hold', 'jing-south-leader-xing-daorong': 'guard', 'guiyang-scout-a': 'aggressive', 'guiyang-scout-b': 'aggressive', 'guiyang-infantry-a': 'guard', 'guiyang-infantry-b': 'guard', 'guiyang-infantry-c': 'guard', 'guiyang-infantry-d': 'guard', 'guiyang-archer-a': 'hold', 'guiyang-archer-b': 'hold', 'guiyang-archer-c': 'hold', 'guiyang-cavalry-a': 'aggressive', 'guiyang-cavalry-b': 'aggressive', 'guiyang-cavalry-c': 'aggressive', 'guiyang-guard-a': 'guard', 'guiyang-guard-b': 'guard', 'guiyang-strategist-a': 'hold', 'guiyang-strategist-b': 'hold', 'guiyang-leader-zhao-fan': 'guard', 'wuling-scout-a': 'aggressive', 'wuling-scout-b': 'aggressive', 'wuling-scout-c': 'aggressive', 'wuling-bandit-a': 'aggressive', 'wuling-bandit-b': 'aggressive', 'wuling-bandit-c': 'aggressive', 'wuling-infantry-a': 'guard', 'wuling-infantry-b': 'guard', 'wuling-infantry-c': 'guard', 'wuling-infantry-d': 'guard', 'wuling-archer-a': 'hold', 'wuling-archer-b': 'hold', 'wuling-archer-c': 'hold', 'wuling-cavalry-a': 'aggressive', 'wuling-cavalry-b': 'guard', 'wuling-guard-a': 'guard', 'wuling-guard-b': 'guard', 'wuling-strategist-a': 'hold', 'wuling-leader-jin-xuan': 'guard', 'changsha-scout-a': 'aggressive', 'changsha-scout-b': 'aggressive', 'changsha-infantry-a': 'guard', 'changsha-infantry-b': 'guard', 'changsha-infantry-c': 'guard', 'changsha-infantry-d': 'guard', 'changsha-archer-a': 'hold', 'changsha-archer-b': 'hold', 'changsha-archer-c': 'hold', 'changsha-cavalry-a': 'aggressive', 'changsha-cavalry-b': 'aggressive', 'changsha-guard-a': 'guard', 'changsha-guard-b': 'guard', 'changsha-strategist-a': 'hold', 'changsha-strategist-b': 'hold', 'changsha-veteran-huang-zhong': 'hold', 'changsha-officer-wei-yan': 'aggressive', 'changsha-leader-han-xuan': 'guard', 'yizhou-scout-a': 'aggressive', 'yizhou-scout-b': 'aggressive', 'yizhou-infantry-a': 'guard', 'yizhou-infantry-b': 'guard', 'yizhou-infantry-c': 'guard', 'yizhou-infantry-d': 'guard', 'yizhou-archer-a': 'hold', 'yizhou-archer-b': 'hold', 'yizhou-archer-c': 'hold', 'yizhou-cavalry-a': 'aggressive', 'yizhou-cavalry-b': 'aggressive', 'yizhou-cavalry-c': 'aggressive', 'yizhou-guard-a': 'guard', 'yizhou-guard-b': 'guard', 'yizhou-strategist-a': 'hold', 'yizhou-strategist-b': 'hold', 'yizhou-ambusher-a': 'aggressive', 'yizhou-ambusher-b': 'aggressive', 'yizhou-leader-yang-huai': 'guard', 'fupass-scout-a': 'aggressive', 'fupass-scout-b': 'aggressive', 'fupass-infantry-a': 'guard', 'fupass-infantry-b': 'guard', 'fupass-infantry-c': 'guard', 'fupass-infantry-d': 'guard', 'fupass-archer-a': 'hold', 'fupass-archer-b': 'hold', 'fupass-archer-c': 'hold', 'fupass-cavalry-a': 'aggressive', 'fupass-cavalry-b': 'aggressive', 'fupass-cavalry-c': 'aggressive', 'fupass-guard-a': 'guard', 'fupass-guard-b': 'guard', 'fupass-strategist-a': 'hold', 'fupass-strategist-b': 'hold', 'fupass-ambusher-a': 'aggressive', 'fupass-ambusher-b': 'aggressive', 'fupass-ambusher-c': 'aggressive', 'fupass-leader-gao-pei': 'guard', 'luo-scout-a': 'aggressive', 'luo-scout-b': 'aggressive', 'luo-infantry-a': 'guard', 'luo-infantry-b': 'guard', 'luo-infantry-c': 'guard', 'luo-infantry-d': 'guard', 'luo-archer-a': 'hold', 'luo-archer-b': 'hold', 'luo-archer-c': 'hold', 'luo-cavalry-a': 'aggressive', 'luo-cavalry-b': 'guard', 'luo-cavalry-c': 'guard', 'luo-guard-a': 'guard', 'luo-guard-b': 'guard', 'luo-strategist-a': 'hold', 'luo-strategist-b': 'hold', 'luo-ambusher-a': 'aggressive', 'luo-ambusher-b': 'aggressive', 'luo-officer-wu-yi': 'guard', 'luo-leader-zhang-ren': 'guard', 'luofeng-scout-a': 'aggressive', 'luofeng-scout-b': 'aggressive', 'luofeng-scout-c': 'aggressive', 'luofeng-infantry-a': 'guard', 'luofeng-infantry-b': 'guard', 'luofeng-infantry-c': 'guard', 'luofeng-infantry-d': 'guard', 'luofeng-archer-a': 'hold', 'luofeng-archer-b': 'hold', 'luofeng-archer-c': 'hold', 'luofeng-archer-d': 'hold', 'luofeng-cavalry-a': 'aggressive', 'luofeng-cavalry-b': 'aggressive', 'luofeng-cavalry-c': 'aggressive', 'luofeng-guard-a': 'guard', 'luofeng-guard-b': 'guard', 'luofeng-strategist-a': 'hold', 'luofeng-strategist-b': 'hold', 'luofeng-ambusher-a': 'aggressive', 'luofeng-ambusher-b': 'aggressive', 'luofeng-ambusher-c': 'aggressive', 'luofeng-ambusher-d': 'aggressive', 'luofeng-leader-zhang-ren': 'guard', 'luo-main-scout-a': 'aggressive', 'luo-main-scout-b': 'aggressive', 'luo-main-infantry-a': 'guard', 'luo-main-infantry-b': 'guard', 'luo-main-infantry-c': 'guard', 'luo-main-infantry-d': 'guard', 'luo-main-infantry-e': 'guard', 'luo-main-archer-a': 'hold', 'luo-main-archer-b': 'hold', 'luo-main-archer-c': 'hold', 'luo-main-archer-d': 'hold', 'luo-main-cavalry-a': 'aggressive', 'luo-main-cavalry-b': 'aggressive', 'luo-main-cavalry-c': 'aggressive', 'luo-main-guard-a': 'guard', 'luo-main-guard-b': 'guard', 'luo-main-guard-c': 'guard', 'luo-main-strategist-a': 'hold', 'luo-main-strategist-b': 'hold', 'luo-main-ambusher-a': 'aggressive', 'luo-main-ambusher-b': 'aggressive', 'luo-main-ambusher-c': 'aggressive', 'luo-main-veteran-yan-yan': 'hold', 'luo-main-officer-a': 'guard', 'luo-main-leader-zhang-ren': 'guard', 'chengdu-scout-a': 'aggressive', 'chengdu-scout-b': 'aggressive', 'chengdu-scout-c': 'aggressive', 'chengdu-infantry-a': 'guard', 'chengdu-infantry-b': 'guard', 'chengdu-infantry-c': 'guard', 'chengdu-infantry-d': 'guard', 'chengdu-infantry-e': 'guard', 'chengdu-infantry-f': 'guard', 'chengdu-archer-a': 'hold', 'chengdu-archer-b': 'hold', 'chengdu-archer-c': 'hold', 'chengdu-archer-d': 'hold', 'chengdu-cavalry-a': 'aggressive', 'chengdu-cavalry-b': 'aggressive', 'chengdu-cavalry-c': 'aggressive', 'chengdu-guard-a': 'guard', 'chengdu-guard-b': 'guard', 'chengdu-guard-c': 'guard', 'chengdu-strategist-a': 'hold', 'chengdu-strategist-b': 'hold', 'chengdu-ambusher-a': 'aggressive', 'chengdu-ambusher-b': 'aggressive', 'chengdu-ambusher-c': 'aggressive', 'chengdu-officer-fei-guan': 'guard', 'chengdu-leader-li-yan': 'guard', 'chengdu-final-scout-a': 'aggressive', 'chengdu-final-scout-b': 'aggressive', 'chengdu-final-scout-c': 'aggressive', 'chengdu-final-infantry-a': 'guard', 'chengdu-final-infantry-b': 'guard', 'chengdu-final-infantry-c': 'guard', 'chengdu-final-infantry-d': 'guard', 'chengdu-final-infantry-e': 'guard', 'chengdu-final-infantry-f': 'guard', 'chengdu-final-archer-a': 'hold', 'chengdu-final-archer-b': 'hold', 'chengdu-final-archer-c': 'hold', 'chengdu-final-archer-d': 'hold', 'chengdu-final-cavalry-a': 'aggressive', 'chengdu-final-cavalry-b': 'aggressive', 'chengdu-final-cavalry-c': 'aggressive', 'chengdu-final-guard-a': 'guard', 'chengdu-final-guard-b': 'guard', 'chengdu-final-guard-c': 'guard', 'chengdu-final-strategist-a': 'hold', 'chengdu-final-strategist-b': 'hold', 'chengdu-final-ambusher-a': 'aggressive', 'chengdu-final-ambusher-b': 'aggressive', 'chengdu-final-ambusher-c': 'aggressive', 'chengdu-final-officer-a': 'guard', 'chengdu-final-leader-huang-quan': 'guard', 'jiameng-scout-a': 'aggressive', 'jiameng-scout-b': 'aggressive', 'jiameng-scout-c': 'aggressive', 'jiameng-infantry-a': 'guard', 'jiameng-infantry-b': 'guard', 'jiameng-infantry-c': 'guard', 'jiameng-infantry-d': 'guard', 'jiameng-archer-a': 'hold', 'jiameng-archer-b': 'hold', 'jiameng-archer-c': 'hold', 'jiameng-cavalry-a': 'aggressive', 'jiameng-cavalry-b': 'aggressive', 'jiameng-cavalry-c': 'aggressive', 'jiameng-cavalry-d': 'aggressive', 'jiameng-guard-a': 'guard', 'jiameng-guard-b': 'guard', 'jiameng-strategist-a': 'hold', 'jiameng-strategist-b': 'hold', 'jiameng-ambusher-a': 'aggressive', 'jiameng-ambusher-b': 'aggressive', 'jiameng-officer-pang-de': 'guard', 'jiameng-leader-ma-chao': 'aggressive', 'yangping-scout-a': 'aggressive', 'yangping-scout-b': 'aggressive', 'yangping-scout-c': 'aggressive', 'yangping-infantry-a': 'guard', 'yangping-infantry-b': 'guard', 'yangping-infantry-c': 'guard', 'yangping-infantry-d': 'guard', 'yangping-infantry-e': 'guard', 'yangping-archer-a': 'hold', 'yangping-archer-b': 'hold', 'yangping-archer-c': 'hold', 'yangping-archer-d': 'hold', 'yangping-cavalry-a': 'aggressive', 'yangping-cavalry-b': 'aggressive', 'yangping-cavalry-c': 'aggressive', 'yangping-cavalry-d': 'aggressive', 'yangping-guard-a': 'guard', 'yangping-guard-b': 'guard', 'yangping-strategist-a': 'hold', 'yangping-strategist-b': 'hold', 'yangping-ambusher-a': 'aggressive', 'yangping-ambusher-b': 'aggressive', 'yangping-officer-yan-pu': 'hold', 'yangping-leader-zhang-wei': 'guard', 'dingjun-scout-a': 'aggressive', 'dingjun-scout-b': 'aggressive', 'dingjun-scout-c': 'aggressive', 'dingjun-infantry-a': 'guard', 'dingjun-infantry-b': 'guard', 'dingjun-infantry-c': 'guard', 'dingjun-infantry-d': 'guard', 'dingjun-infantry-e': 'guard', 'dingjun-archer-a': 'hold', 'dingjun-archer-b': 'hold', 'dingjun-archer-c': 'hold', 'dingjun-archer-d': 'hold', 'dingjun-cavalry-a': 'aggressive', 'dingjun-cavalry-b': 'guard', 'dingjun-cavalry-c': 'guard', 'dingjun-cavalry-d': 'guard', 'dingjun-guard-a': 'guard', 'dingjun-guard-b': 'guard', 'dingjun-strategist-a': 'hold', 'dingjun-strategist-b': 'hold', 'dingjun-ambusher-a': 'guard', 'dingjun-ambusher-b': 'guard', 'dingjun-officer-zhang-he': 'guard', 'dingjun-officer-wang-ping': 'guard', 'dingjun-leader-xiahou-yuan': 'guard', 'hanzhong-scout-a': 'aggressive', 'hanzhong-scout-b': 'aggressive', 'hanzhong-scout-c': 'aggressive', 'hanzhong-infantry-a': 'guard', 'hanzhong-infantry-b': 'guard', 'hanzhong-infantry-c': 'guard', 'hanzhong-infantry-d': 'guard', 'hanzhong-infantry-e': 'guard', 'hanzhong-infantry-f': 'guard', 'hanzhong-archer-a': 'hold', 'hanzhong-archer-b': 'hold', 'hanzhong-archer-c': 'hold', 'hanzhong-archer-d': 'hold', 'hanzhong-archer-e': 'hold', 'hanzhong-cavalry-a': 'aggressive', 'hanzhong-cavalry-b': 'aggressive', 'hanzhong-cavalry-c': 'aggressive', 'hanzhong-cavalry-d': 'aggressive', 'hanzhong-cavalry-e': 'aggressive', 'hanzhong-guard-a': 'guard', 'hanzhong-guard-b': 'guard', 'hanzhong-guard-c': 'guard', 'hanzhong-strategist-a': 'hold', 'hanzhong-strategist-b': 'hold', 'hanzhong-strategist-c': 'hold', 'hanzhong-ambusher-a': 'aggressive', 'hanzhong-ambusher-b': 'aggressive', 'hanzhong-officer-zhang-he': 'aggressive', 'hanzhong-officer-xu-huang': 'guard', 'hanzhong-leader-cao-cao': 'guard', 'jing-defense-wu-scout-a': 'aggressive', 'jing-defense-wu-scout-b': 'aggressive', 'jing-defense-wei-scout-a': 'aggressive', 'jing-defense-wei-scout-b': 'aggressive', 'jing-defense-wu-infantry-a': 'guard', 'jing-defense-wu-infantry-b': 'guard', 'jing-defense-wei-infantry-a': 'guard', 'jing-defense-wei-infantry-b': 'guard', 'jing-defense-wei-infantry-c': 'guard', 'jing-defense-wu-archer-a': 'hold', 'jing-defense-wu-archer-b': 'hold', 'jing-defense-wei-archer-a': 'hold', 'jing-defense-wei-archer-b': 'hold', 'jing-defense-wu-cavalry-a': 'aggressive', 'jing-defense-wu-cavalry-b': 'aggressive', 'jing-defense-wei-cavalry-a': 'aggressive', 'jing-defense-wei-cavalry-b': 'aggressive', 'jing-defense-wu-guard-a': 'guard', 'jing-defense-wu-guard-b': 'guard', 'jing-defense-wei-guard-a': 'guard', 'jing-defense-wei-guard-b': 'guard', 'jing-defense-wu-strategist-a': 'hold', 'jing-defense-wu-strategist-b': 'hold', 'jing-defense-wei-strategist-a': 'hold', 'jing-defense-ambusher-a': 'aggressive', 'jing-defense-ambusher-b': 'aggressive', 'jing-defense-ambusher-c': 'aggressive', 'jing-defense-officer-lu-xun': 'hold', 'jing-defense-officer-cao-ren': 'guard', 'jing-defense-leader-lu-meng': 'guard', 'fan-castle-scout-a': 'aggressive', 'fan-castle-scout-b': 'aggressive', 'fan-castle-scout-c': 'aggressive', 'fan-castle-infantry-a': 'guard', 'fan-castle-infantry-b': 'guard', 'fan-castle-infantry-c': 'guard', 'fan-castle-infantry-d': 'guard', 'fan-castle-infantry-e': 'guard', 'fan-castle-archer-a': 'hold', 'fan-castle-archer-b': 'hold', 'fan-castle-archer-c': 'hold', 'fan-castle-archer-d': 'hold', 'fan-castle-archer-e': 'hold', 'fan-castle-cavalry-a': 'aggressive', 'fan-castle-cavalry-b': 'aggressive', 'fan-castle-cavalry-c': 'aggressive', 'fan-castle-cavalry-d': 'aggressive', 'fan-castle-guard-a': 'guard', 'fan-castle-guard-b': 'guard', 'fan-castle-guard-c': 'guard', 'fan-castle-guard-d': 'guard', 'fan-castle-strategist-a': 'hold', 'fan-castle-strategist-b': 'hold', 'fan-castle-strategist-c': 'hold', 'fan-castle-ambusher-a': 'aggressive', 'fan-castle-ambusher-b': 'aggressive', 'fan-castle-ambusher-c': 'aggressive', 'fan-castle-officer-yu-jin': 'guard', 'fan-castle-officer-cao-ren': 'guard', 'fan-castle-leader-pang-de': 'guard', 'han-river-scout-a': 'aggressive', 'han-river-scout-b': 'aggressive', 'han-river-scout-c': 'aggressive', 'han-river-infantry-a': 'guard', 'han-river-infantry-b': 'guard', 'han-river-infantry-c': 'guard', 'han-river-infantry-d': 'guard', 'han-river-infantry-e': 'guard', 'han-river-archer-a': 'hold', 'han-river-archer-b': 'hold', 'han-river-archer-c': 'hold', 'han-river-archer-d': 'hold', 'han-river-archer-e': 'hold', 'han-river-cavalry-a': 'aggressive', 'han-river-cavalry-b': 'aggressive', 'han-river-cavalry-c': 'aggressive', 'han-river-cavalry-d': 'aggressive', 'han-river-guard-a': 'guard', 'han-river-guard-b': 'guard', 'han-river-guard-c': 'guard', 'han-river-guard-d': 'guard', 'han-river-strategist-a': 'hold', 'han-river-strategist-b': 'hold', 'han-river-strategist-c': 'hold', 'han-river-ambusher-a': 'aggressive', 'han-river-ambusher-b': 'aggressive', 'han-river-ambusher-c': 'aggressive', 'han-river-officer-pang-de': 'aggressive', 'han-river-officer-cao-ren': 'guard', 'han-river-leader-yu-jin': 'guard', 'fan-siege-scout-a': 'aggressive', 'fan-siege-scout-b': 'aggressive', 'fan-siege-scout-c': 'aggressive', 'fan-siege-scout-d': 'aggressive', 'fan-siege-infantry-a': 'guard', 'fan-siege-infantry-b': 'guard', 'fan-siege-infantry-c': 'guard', 'fan-siege-infantry-d': 'guard', 'fan-siege-infantry-e': 'guard', 'fan-siege-infantry-f': 'guard', 'fan-siege-archer-a': 'hold', 'fan-siege-archer-b': 'hold', 'fan-siege-archer-c': 'hold', 'fan-siege-archer-d': 'hold', 'fan-siege-archer-e': 'hold', 'fan-siege-archer-f': 'hold', 'fan-siege-cavalry-a': 'aggressive', 'fan-siege-cavalry-b': 'aggressive', 'fan-siege-cavalry-c': 'aggressive', 'fan-siege-cavalry-d': 'aggressive', 'fan-siege-cavalry-e': 'aggressive', 'fan-siege-guard-a': 'guard', 'fan-siege-guard-b': 'guard', 'fan-siege-guard-c': 'guard', 'fan-siege-guard-d': 'guard', 'fan-siege-guard-e': 'guard', 'fan-siege-strategist-a': 'hold', 'fan-siege-strategist-b': 'hold', 'fan-siege-strategist-c': 'hold', 'fan-siege-ambusher-a': 'aggressive', 'fan-siege-ambusher-b': 'aggressive', 'fan-siege-ambusher-c': 'aggressive', 'fan-siege-officer-pang-de': 'aggressive', 'fan-siege-officer-lu-meng': 'guard', 'fan-siege-leader-cao-ren': 'guard', 'jing-rear-scout-a': 'aggressive', 'jing-rear-scout-b': 'aggressive', 'jing-rear-scout-c': 'aggressive', 'jing-rear-scout-d': 'aggressive', 'jing-rear-scout-e': 'aggressive', 'jing-rear-infantry-a': 'guard', 'jing-rear-infantry-b': 'guard', 'jing-rear-infantry-c': 'guard', 'jing-rear-infantry-d': 'guard', 'jing-rear-infantry-e': 'guard', 'jing-rear-infantry-f': 'guard', 'jing-rear-archer-a': 'hold', 'jing-rear-archer-b': 'hold', 'jing-rear-archer-c': 'hold', 'jing-rear-archer-d': 'hold', 'jing-rear-archer-e': 'hold', 'jing-rear-cavalry-a': 'aggressive', 'jing-rear-cavalry-b': 'aggressive', 'jing-rear-cavalry-c': 'aggressive', 'jing-rear-cavalry-d': 'aggressive', 'jing-rear-guard-a': 'guard', 'jing-rear-guard-b': 'guard', 'jing-rear-guard-c': 'guard', 'jing-rear-guard-d': 'guard', 'jing-rear-strategist-a': 'hold', 'jing-rear-strategist-b': 'hold', 'jing-rear-strategist-c': 'hold', 'jing-rear-ambusher-a': 'aggressive', 'jing-rear-ambusher-b': 'aggressive', 'jing-rear-ambusher-c': 'aggressive', 'jing-rear-marine-a': 'aggressive', 'jing-rear-marine-b': 'guard', 'jing-rear-officer-lu-xun': 'hold', 'jing-rear-officer-mi-fang': 'guard', 'jing-rear-leader-lu-meng': 'aggressive', 'jing-collapse-scout-a': 'aggressive', 'jing-collapse-scout-b': 'aggressive', 'jing-collapse-scout-c': 'aggressive', 'jing-collapse-scout-d': 'aggressive', 'jing-collapse-infantry-a': 'guard', 'jing-collapse-infantry-b': 'guard', 'jing-collapse-infantry-c': 'guard', 'jing-collapse-infantry-d': 'guard', 'jing-collapse-infantry-e': 'guard', 'jing-collapse-infantry-f': 'guard', 'jing-collapse-archer-a': 'hold', 'jing-collapse-archer-b': 'hold', 'jing-collapse-archer-c': 'hold', 'jing-collapse-archer-d': 'hold', 'jing-collapse-archer-e': 'hold', 'jing-collapse-cavalry-a': 'aggressive', 'jing-collapse-cavalry-b': 'aggressive', 'jing-collapse-cavalry-c': 'aggressive', 'jing-collapse-cavalry-d': 'aggressive', 'jing-collapse-guard-a': 'guard', 'jing-collapse-guard-b': 'guard', 'jing-collapse-guard-c': 'guard', 'jing-collapse-guard-d': 'guard', 'jing-collapse-strategist-a': 'hold', 'jing-collapse-strategist-b': 'hold', 'jing-collapse-strategist-c': 'hold', 'jing-collapse-ambusher-a': 'aggressive', 'jing-collapse-ambusher-b': 'aggressive', 'jing-collapse-ambusher-c': 'aggressive', 'jing-collapse-ambusher-d': 'aggressive', 'jing-collapse-marine-a': 'aggressive', 'jing-collapse-marine-b': 'aggressive', 'jing-collapse-marine-c': 'guard', 'jing-collapse-officer-lu-xun': 'hold', 'jing-collapse-officer-fu-shiren': 'guard', 'jing-collapse-officer-mi-fang': 'guard', 'jing-collapse-leader-lu-meng': 'aggressive', 'maicheng-wu-scout-a': 'aggressive', 'maicheng-wu-scout-b': 'aggressive', 'maicheng-wu-scout-c': 'aggressive', 'maicheng-wu-scout-d': 'aggressive', 'maicheng-wu-infantry-a': 'guard', 'maicheng-wu-infantry-b': 'guard', 'maicheng-wu-infantry-c': 'guard', 'maicheng-wu-infantry-d': 'guard', 'maicheng-wu-infantry-e': 'guard', 'maicheng-wu-infantry-f': 'guard', 'maicheng-wu-archer-a': 'hold', 'maicheng-wu-archer-b': 'hold', 'maicheng-wu-archer-c': 'hold', 'maicheng-wu-archer-d': 'hold', 'maicheng-wu-archer-e': 'hold', 'maicheng-wu-archer-f': 'hold', 'maicheng-wu-cavalry-a': 'aggressive', 'maicheng-wu-cavalry-b': 'aggressive', 'maicheng-wu-cavalry-c': 'aggressive', 'maicheng-wu-cavalry-d': 'aggressive', 'maicheng-wu-guard-a': 'guard', 'maicheng-wu-guard-b': 'guard', 'maicheng-wu-guard-c': 'guard', 'maicheng-wu-guard-d': 'guard', 'maicheng-wu-ambusher-a': 'aggressive', 'maicheng-wu-ambusher-b': 'aggressive', 'maicheng-wu-ambusher-c': 'aggressive', 'maicheng-wu-ambusher-d': 'aggressive', 'maicheng-wu-ambusher-e': 'aggressive', 'maicheng-wu-marine-a': 'aggressive', 'maicheng-wu-marine-b': 'aggressive', 'maicheng-wu-marine-c': 'guard', 'maicheng-wei-cavalry-a': 'aggressive', 'maicheng-wei-cavalry-b': 'aggressive', 'maicheng-wei-cavalry-c': 'aggressive', 'maicheng-wei-infantry-a': 'guard', 'maicheng-wei-infantry-b': 'guard', 'maicheng-wei-infantry-c': 'guard', 'maicheng-strategist-a': 'hold', 'maicheng-strategist-b': 'hold', 'maicheng-strategist-c': 'hold', 'maicheng-officer-pan-zhang': 'guard', 'maicheng-officer-ma-zhong': 'aggressive', 'maicheng-officer-lu-xun': 'hold', 'maicheng-officer-xu-huang': 'aggressive', 'maicheng-leader-lu-meng': 'aggressive', 'yiling-wu-scout-a': 'aggressive', 'yiling-wu-scout-b': 'aggressive', 'yiling-wu-scout-c': 'aggressive', 'yiling-wu-scout-d': 'aggressive', 'yiling-wu-infantry-a': 'guard', 'yiling-wu-infantry-b': 'guard', 'yiling-wu-infantry-c': 'guard', 'yiling-wu-infantry-d': 'guard', 'yiling-wu-infantry-e': 'guard', 'yiling-wu-infantry-f': 'guard', 'yiling-wu-archer-a': 'hold', 'yiling-wu-archer-b': 'hold', 'yiling-wu-archer-c': 'hold', 'yiling-wu-archer-d': 'hold', 'yiling-wu-archer-e': 'hold', 'yiling-wu-archer-f': 'hold', 'yiling-wu-cavalry-a': 'aggressive', 'yiling-wu-cavalry-b': 'aggressive', 'yiling-wu-cavalry-c': 'aggressive', 'yiling-wu-cavalry-d': 'aggressive', 'yiling-wu-marine-a': 'aggressive', 'yiling-wu-marine-b': 'guard', 'yiling-wu-marine-c': 'guard', 'yiling-wu-ambusher-a': 'aggressive', 'yiling-wu-ambusher-b': 'aggressive', 'yiling-wu-ambusher-c': 'aggressive', 'yiling-wu-ambusher-d': 'aggressive', 'yiling-wu-ambusher-e': 'aggressive', 'yiling-wu-guard-a': 'guard', 'yiling-wu-guard-b': 'guard', 'yiling-wu-guard-c': 'guard', 'yiling-strategist-a': 'hold', 'yiling-strategist-b': 'hold', 'yiling-strategist-c': 'hold', 'yiling-officer-gan-ning-remnant': 'aggressive', 'yiling-officer-zhu-ran': 'guard', 'yiling-officer-pan-zhang': 'aggressive', 'yiling-officer-ma-zhong': 'aggressive', 'yiling-leader-lu-xun': 'hold', 'yiling-fire-scout-a': 'aggressive', 'yiling-fire-scout-b': 'aggressive', 'yiling-fire-scout-c': 'aggressive', 'yiling-fire-scout-d': 'aggressive', 'yiling-fire-infantry-a': 'guard', 'yiling-fire-infantry-b': 'guard', 'yiling-fire-infantry-c': 'guard', 'yiling-fire-infantry-d': 'guard', 'yiling-fire-infantry-e': 'guard', 'yiling-fire-infantry-f': 'guard', 'yiling-fire-archer-a': 'hold', 'yiling-fire-archer-b': 'hold', 'yiling-fire-archer-c': 'hold', 'yiling-fire-archer-d': 'hold', 'yiling-fire-archer-e': 'hold', 'yiling-fire-archer-f': 'hold', 'yiling-fire-cavalry-a': 'aggressive', 'yiling-fire-cavalry-b': 'aggressive', 'yiling-fire-cavalry-c': 'aggressive', 'yiling-fire-cavalry-d': 'aggressive', 'yiling-fire-marine-a': 'aggressive', 'yiling-fire-marine-b': 'guard', 'yiling-fire-marine-c': 'guard', 'yiling-fire-ambusher-a': 'aggressive', 'yiling-fire-ambusher-b': 'aggressive', 'yiling-fire-ambusher-c': 'aggressive', 'yiling-fire-ambusher-d': 'aggressive', 'yiling-fire-ambusher-e': 'aggressive', 'yiling-fire-guard-a': 'guard', 'yiling-fire-guard-b': 'guard', 'yiling-fire-guard-c': 'guard', 'yiling-fire-strategist-a': 'hold', 'yiling-fire-strategist-b': 'hold', 'yiling-fire-strategist-c': 'hold', 'yiling-fire-officer-zhu-ran': 'aggressive', 'yiling-fire-officer-han-dang': 'guard', 'yiling-fire-officer-pan-zhang': 'aggressive', 'yiling-fire-officer-ma-zhong': 'aggressive', 'yiling-fire-leader-lu-xun': 'hold', 'nanzhong-scout-a': 'aggressive', 'nanzhong-scout-b': 'aggressive', 'nanzhong-scout-c': 'aggressive', 'nanzhong-scout-d': 'aggressive', 'nanzhong-infantry-a': 'guard', 'nanzhong-infantry-b': 'guard', 'nanzhong-infantry-c': 'guard', 'nanzhong-infantry-d': 'guard', 'nanzhong-infantry-e': 'guard', 'nanzhong-infantry-f': 'guard', 'nanzhong-archer-a': 'hold', 'nanzhong-archer-b': 'hold', 'nanzhong-archer-c': 'hold', 'nanzhong-archer-d': 'hold', 'nanzhong-archer-e': 'hold', 'nanzhong-archer-f': 'hold', 'nanzhong-beast-rider-a': 'aggressive', 'nanzhong-beast-rider-b': 'aggressive', 'nanzhong-beast-rider-c': 'aggressive', 'nanzhong-beast-rider-d': 'aggressive', 'nanzhong-ambusher-a': 'aggressive', 'nanzhong-ambusher-b': 'aggressive', 'nanzhong-ambusher-c': 'aggressive', 'nanzhong-ambusher-d': 'aggressive', 'nanzhong-ambusher-e': 'aggressive', 'nanzhong-guard-a': 'guard', 'nanzhong-guard-b': 'guard', 'nanzhong-guard-c': 'guard', 'nanzhong-strategist-a': 'hold', 'nanzhong-strategist-b': 'hold', 'nanzhong-strategist-c': 'hold', 'nanzhong-officer-gao-ding': 'guard', 'nanzhong-officer-zhu-bao': 'guard', 'nanzhong-officer-meng-huo-vanguard': 'aggressive', 'nanzhong-leader-yong-kai': 'hold', 'menghuo-scout-a': 'aggressive', 'menghuo-scout-b': 'aggressive', 'menghuo-scout-c': 'aggressive', 'menghuo-scout-d': 'aggressive', 'menghuo-vine-infantry-a': 'guard', 'menghuo-vine-infantry-b': 'guard', 'menghuo-vine-infantry-c': 'guard', 'menghuo-vine-infantry-d': 'guard', 'menghuo-vine-infantry-e': 'guard', 'menghuo-archer-a': 'hold', 'menghuo-archer-b': 'hold', 'menghuo-archer-c': 'hold', 'menghuo-archer-d': 'hold', 'menghuo-archer-e': 'hold', 'menghuo-beast-rider-a': 'aggressive', 'menghuo-beast-rider-b': 'aggressive', 'menghuo-beast-rider-c': 'aggressive', 'menghuo-beast-rider-d': 'aggressive', 'menghuo-ambusher-a': 'aggressive', 'menghuo-ambusher-b': 'aggressive', 'menghuo-ambusher-c': 'aggressive', 'menghuo-ambusher-d': 'aggressive', 'menghuo-ambusher-e': 'aggressive', 'menghuo-guard-a': 'guard', 'menghuo-guard-b': 'guard', 'menghuo-guard-c': 'guard', 'menghuo-shaman-a': 'hold', 'menghuo-shaman-b': 'hold', 'menghuo-shaman-c': 'hold', 'menghuo-officer-ahuinan': 'guard', 'menghuo-officer-dongtuna': 'guard', 'menghuo-officer-mangyachang': 'aggressive', 'menghuo-leader': 'hold', 'menghuo-return-scout-a': 'aggressive', 'menghuo-return-scout-b': 'aggressive', 'menghuo-return-scout-c': 'aggressive', 'menghuo-return-scout-d': 'aggressive', 'menghuo-return-vine-a': 'guard', 'menghuo-return-vine-b': 'guard', 'menghuo-return-vine-c': 'guard', 'menghuo-return-vine-d': 'guard', 'menghuo-return-vine-e': 'guard', 'menghuo-return-archer-a': 'hold', 'menghuo-return-archer-b': 'hold', 'menghuo-return-archer-c': 'hold', 'menghuo-return-archer-d': 'hold', 'menghuo-return-archer-e': 'hold', 'menghuo-return-beast-a': 'aggressive', 'menghuo-return-beast-b': 'aggressive', 'menghuo-return-beast-c': 'aggressive', 'menghuo-return-beast-d': 'aggressive', 'menghuo-return-ambusher-a': 'aggressive', 'menghuo-return-ambusher-b': 'aggressive', 'menghuo-return-ambusher-c': 'aggressive', 'menghuo-return-ambusher-d': 'aggressive', 'menghuo-return-ambusher-e': 'aggressive', 'menghuo-return-guard-a': 'guard', 'menghuo-return-guard-b': 'guard', 'menghuo-return-guard-c': 'guard', 'menghuo-return-shaman-a': 'hold', 'menghuo-return-shaman-b': 'hold', 'menghuo-return-shaman-c': 'hold', 'menghuo-return-officer-meng-you': 'aggressive', 'menghuo-return-officer-duosi': 'guard', 'menghuo-return-officer-mulu': 'aggressive', 'menghuo-second-leader': 'aggressive', 'menghuo-third-scout-a': 'aggressive', 'menghuo-third-scout-b': 'aggressive', 'menghuo-third-scout-c': 'aggressive', 'menghuo-third-scout-d': 'aggressive', 'menghuo-third-vine-a': 'guard', 'menghuo-third-vine-b': 'guard', 'menghuo-third-vine-c': 'guard', 'menghuo-third-vine-d': 'guard', 'menghuo-third-vine-e': 'guard', 'menghuo-third-archer-a': 'hold', 'menghuo-third-archer-b': 'hold', 'menghuo-third-archer-c': 'hold', 'menghuo-third-archer-d': 'hold', 'menghuo-third-archer-e': 'hold', 'menghuo-third-beast-a': 'aggressive', 'menghuo-third-beast-b': 'aggressive', 'menghuo-third-beast-c': 'aggressive', 'menghuo-third-beast-d': 'aggressive', 'menghuo-third-ambusher-a': 'aggressive', 'menghuo-third-ambusher-b': 'aggressive', 'menghuo-third-ambusher-c': 'aggressive', 'menghuo-third-ambusher-d': 'aggressive', 'menghuo-third-ambusher-e': 'aggressive', 'menghuo-third-guard-a': 'guard', 'menghuo-third-guard-b': 'guard', 'menghuo-third-guard-c': 'guard', 'menghuo-third-shaman-a': 'hold', 'menghuo-third-shaman-b': 'hold', 'menghuo-third-shaman-c': 'hold', 'menghuo-third-officer-dailai': 'guard', 'menghuo-third-officer-zhurong-vanguard': 'aggressive', 'menghuo-third-officer-jinhuan': 'aggressive', 'menghuo-third-officer-ahuinan': 'guard', 'menghuo-third-leader': 'aggressive', 'menghuo-fourth-scout-a': 'aggressive', 'menghuo-fourth-scout-b': 'aggressive', 'menghuo-fourth-scout-c': 'aggressive', 'menghuo-fourth-scout-d': 'aggressive', 'menghuo-fourth-scout-e': 'aggressive', 'menghuo-fourth-vine-a': 'guard', 'menghuo-fourth-vine-b': 'guard', 'menghuo-fourth-vine-c': 'guard', 'menghuo-fourth-vine-d': 'guard', 'menghuo-fourth-vine-e': 'guard', 'menghuo-fourth-vine-f': 'guard', 'menghuo-fourth-archer-a': 'hold', 'menghuo-fourth-archer-b': 'hold', 'menghuo-fourth-archer-c': 'hold', 'menghuo-fourth-archer-d': 'hold', 'menghuo-fourth-archer-e': 'hold', 'menghuo-fourth-beast-a': 'aggressive', 'menghuo-fourth-beast-b': 'aggressive', 'menghuo-fourth-beast-c': 'aggressive', 'menghuo-fourth-beast-d': 'aggressive', 'menghuo-fourth-beast-e': 'aggressive', 'menghuo-fourth-ambusher-a': 'aggressive', 'menghuo-fourth-ambusher-b': 'aggressive', 'menghuo-fourth-ambusher-c': 'aggressive', 'menghuo-fourth-ambusher-d': 'aggressive', 'menghuo-fourth-ambusher-e': 'aggressive', 'menghuo-fourth-ambusher-f': 'aggressive', 'menghuo-fourth-guard-a': 'guard', 'menghuo-fourth-guard-b': 'guard', 'menghuo-fourth-guard-c': 'guard', 'menghuo-fourth-guard-d': 'guard', 'menghuo-fourth-shaman-a': 'hold', 'menghuo-fourth-shaman-b': 'hold', 'menghuo-fourth-shaman-c': 'hold', 'menghuo-fourth-shaman-d': 'hold', 'menghuo-fourth-officer-wutugu': 'guard', 'menghuo-fourth-officer-mangyachang': 'aggressive', 'menghuo-fourth-officer-dongtuna': 'guard', 'menghuo-fourth-officer-ahuinan': 'guard', 'menghuo-fourth-leader': 'aggressive', 'menghuo-fifth-scout-a': 'aggressive', 'menghuo-fifth-scout-b': 'aggressive', 'menghuo-fifth-scout-c': 'aggressive', 'menghuo-fifth-scout-d': 'aggressive', 'menghuo-fifth-scout-e': 'aggressive', 'menghuo-fifth-scout-f': 'aggressive', 'menghuo-fifth-vine-a': 'guard', 'menghuo-fifth-vine-b': 'guard', 'menghuo-fifth-vine-c': 'guard', 'menghuo-fifth-vine-d': 'guard', 'menghuo-fifth-vine-e': 'guard', 'menghuo-fifth-vine-f': 'guard', 'menghuo-fifth-archer-a': 'hold', 'menghuo-fifth-archer-b': 'hold', 'menghuo-fifth-archer-c': 'hold', 'menghuo-fifth-archer-d': 'hold', 'menghuo-fifth-archer-e': 'hold', 'menghuo-fifth-beast-a': 'aggressive', 'menghuo-fifth-beast-b': 'aggressive', 'menghuo-fifth-beast-c': 'aggressive', 'menghuo-fifth-beast-d': 'aggressive', 'menghuo-fifth-beast-e': 'aggressive', 'menghuo-fifth-ambusher-a': 'aggressive', 'menghuo-fifth-ambusher-b': 'aggressive', 'menghuo-fifth-ambusher-c': 'aggressive', 'menghuo-fifth-ambusher-d': 'aggressive', 'menghuo-fifth-ambusher-e': 'aggressive', 'menghuo-fifth-ambusher-f': 'aggressive', 'menghuo-fifth-guard-a': 'guard', 'menghuo-fifth-guard-b': 'guard', 'menghuo-fifth-guard-c': 'guard', 'menghuo-fifth-guard-d': 'guard', 'menghuo-fifth-shaman-a': 'hold', 'menghuo-fifth-shaman-b': 'hold', 'menghuo-fifth-shaman-c': 'hold', 'menghuo-fifth-shaman-d': 'hold', 'menghuo-fifth-officer-zhurong': 'aggressive', 'menghuo-fifth-officer-mulu': 'aggressive', 'menghuo-fifth-officer-duosi': 'guard', 'menghuo-fifth-officer-mengyou': 'guard', 'menghuo-fifth-leader': 'aggressive', 'menghuo-sixth-scout-a': 'aggressive', 'menghuo-sixth-scout-b': 'aggressive', 'menghuo-sixth-scout-c': 'aggressive', 'menghuo-sixth-scout-d': 'aggressive', 'menghuo-sixth-scout-e': 'aggressive', 'menghuo-sixth-scout-f': 'aggressive', 'menghuo-sixth-vine-a': 'guard', 'menghuo-sixth-vine-b': 'guard', 'menghuo-sixth-vine-c': 'guard', 'menghuo-sixth-vine-d': 'guard', 'menghuo-sixth-vine-e': 'guard', 'menghuo-sixth-vine-f': 'guard', 'menghuo-sixth-archer-a': 'hold', 'menghuo-sixth-archer-b': 'hold', 'menghuo-sixth-archer-c': 'hold', 'menghuo-sixth-archer-d': 'hold', 'menghuo-sixth-archer-e': 'hold', 'menghuo-sixth-beast-a': 'aggressive', 'menghuo-sixth-beast-b': 'aggressive', 'menghuo-sixth-beast-c': 'aggressive', 'menghuo-sixth-beast-d': 'aggressive', 'menghuo-sixth-beast-e': 'aggressive', 'menghuo-sixth-ambusher-a': 'aggressive', 'menghuo-sixth-ambusher-b': 'aggressive', 'menghuo-sixth-ambusher-c': 'aggressive', 'menghuo-sixth-ambusher-d': 'aggressive', 'menghuo-sixth-ambusher-e': 'aggressive', 'menghuo-sixth-ambusher-f': 'aggressive', 'menghuo-sixth-guard-a': 'guard', 'menghuo-sixth-guard-b': 'guard', 'menghuo-sixth-guard-c': 'guard', 'menghuo-sixth-guard-d': 'guard', 'menghuo-sixth-shaman-a': 'hold', 'menghuo-sixth-shaman-b': 'hold', 'menghuo-sixth-shaman-c': 'hold', 'menghuo-sixth-shaman-d': 'hold', 'menghuo-sixth-officer-zhurong': 'aggressive', 'menghuo-sixth-officer-mulu': 'aggressive', 'menghuo-sixth-officer-duosi': 'guard', 'menghuo-sixth-officer-mengyou': 'guard', 'menghuo-sixth-officer-dailai': 'aggressive', 'menghuo-sixth-leader': 'aggressive', 'menghuo-final-scout-a': 'aggressive', 'menghuo-final-scout-b': 'aggressive', 'menghuo-final-scout-c': 'aggressive', 'menghuo-final-scout-d': 'aggressive', 'menghuo-final-scout-e': 'aggressive', 'menghuo-final-scout-f': 'aggressive', 'menghuo-final-vine-a': 'guard', 'menghuo-final-vine-b': 'guard', 'menghuo-final-vine-c': 'guard', 'menghuo-final-vine-d': 'guard', 'menghuo-final-vine-e': 'guard', 'menghuo-final-vine-f': 'guard', 'menghuo-final-archer-a': 'hold', 'menghuo-final-archer-b': 'hold', 'menghuo-final-archer-c': 'hold', 'menghuo-final-archer-d': 'hold', 'menghuo-final-archer-e': 'hold', 'menghuo-final-beast-a': 'aggressive', 'menghuo-final-beast-b': 'aggressive', 'menghuo-final-beast-c': 'aggressive', 'menghuo-final-beast-d': 'aggressive', 'menghuo-final-beast-e': 'aggressive', 'menghuo-final-ambusher-a': 'aggressive', 'menghuo-final-ambusher-b': 'aggressive', 'menghuo-final-ambusher-c': 'aggressive', 'menghuo-final-ambusher-d': 'aggressive', 'menghuo-final-ambusher-e': 'aggressive', 'menghuo-final-ambusher-f': 'aggressive', 'menghuo-final-guard-a': 'guard', 'menghuo-final-guard-b': 'guard', 'menghuo-final-guard-c': 'guard', 'menghuo-final-guard-d': 'guard', 'menghuo-final-shaman-a': 'hold', 'menghuo-final-shaman-b': 'hold', 'menghuo-final-shaman-c': 'hold', 'menghuo-final-shaman-d': 'hold', 'menghuo-final-officer-zhurong': 'aggressive', 'menghuo-final-officer-mulu': 'aggressive', 'menghuo-final-officer-duosi': 'guard', 'menghuo-final-officer-mengyou': 'guard', 'menghuo-final-officer-dailai': 'aggressive', 'menghuo-final-officer-ahuinan': 'guard', 'menghuo-final-leader': 'aggressive' }; const defaultEnemyAiByClass: Partial> = { archer: 'hold', cavalry: 'aggressive', yellowTurban: 'guard', rebelLeader: 'guard' }; const guardDetectionRange = 5; let leaderUnitId = battleScenario.leaderUnitId; let quickVictoryTurnLimit = battleScenario.quickVictoryTurnLimit; let baseVictoryGold = battleScenario.baseVictoryGold; const commandLabels: Record = { attack: '공격', strategy: '책략', item: '도구', wait: '대기' }; const rosterLabels: Record = { ally: '아군', enemy: '적군' }; const factionLabels: Record = { ally: '아군', enemy: '적군' }; const terrainDisplayLabels: Record = { plain: '평지', road: '길', forest: '숲', hill: '언덕', village: '마을', fort: '요새', camp: '진영', river: '강', cliff: '절벽' }; const mapMenuLabels: Partial> = { endTurn: '턴 종료', threat: '위험 범위', roster: '부대 일람', bond: '공명', situation: '전황', speed: '속도', bgm: 'BGM', close: '닫기' }; const statLabels: Array<{ key: keyof UnitStats; label: string }> = [ { key: 'might', label: '무력' }, { key: 'intelligence', label: '지력' }, { key: 'leadership', label: '통솔력' }, { key: 'agility', label: '민첩성' }, { key: 'luck', label: '운' } ]; function cloneUnitData(unit: UnitData): UnitData { return { ...unit, stats: { ...unit.stats }, equipment: JSON.parse(JSON.stringify(unit.equipment)) as UnitData['equipment'] }; } function cloneBattleBond(bond: BattleBond): BattleBond { return { ...bond, unitIds: [...bond.unitIds] as [string, string] }; } function scenarioReadinessAnchor() { return ( initialBattleUnits.find((unit) => unit.id === leaderUnitId && unit.faction === 'enemy') ?? initialBattleUnits .filter((unit) => unit.faction === 'enemy') .sort((a, b) => b.level - a.level || b.maxHp - a.maxHp || b.attack - a.attack)[0] ); } function applyScenarioAllyReadiness(unit: UnitData) { if (unit.faction !== 'ally') { return unit; } const anchor = scenarioReadinessAnchor(); if (!anchor) { return unit; } const enemyCount = initialBattleUnits.filter((candidate) => candidate.faction === 'enemy').length; const densityFactor = 1 + Math.max(0, enemyCount - 18) * 0.035; const attackDensityFactor = 1 + Math.max(0, enemyCount - 18) * 0.018; const hpRatio = unit.maxHp > 0 ? unit.hp / unit.maxHp : 1; const minimumLevel = Math.max(unit.level, Math.min(99, Math.round(anchor.level * 0.72))); const minimumMaxHp = Math.max(unit.maxHp, Math.round(anchor.maxHp * 1.15 * densityFactor)); const minimumAttack = Math.max(unit.attack, Math.round(anchor.attack * 0.72 * attackDensityFactor)); const equipmentLevelFloor = Math.max(1, Math.min(9, Math.floor(anchor.level / 28) + 1)); const next = cloneUnitData(unit); next.level = minimumLevel; next.maxHp = minimumMaxHp; next.hp = minimumMaxHp > unit.maxHp ? Math.max(unit.hp, Math.ceil(minimumMaxHp * hpRatio)) : Math.min(unit.hp, minimumMaxHp); next.attack = minimumAttack; (Object.keys(next.stats) as Array).forEach((key) => { next.stats[key] = Math.max(next.stats[key], Math.round(anchor.stats[key] * 0.78)); }); equipmentSlots.forEach((slot) => { next.equipment[slot].level = Math.max(next.equipment[slot].level, equipmentLevelFloor); }); return next; } function configureBattleScenario(scenario: BattleScenarioDefinition) { battleScenario = scenario; battleMap = scenario.map; battleUnits = scenario.units.map(cloneUnitData); battleBonds = scenario.bonds.map(cloneBattleBond); battleSaveStorageKey = `heros-web:battle:${scenario.id}`; initialBattleUnits = battleUnits.map(cloneUnitData); initialBattleBonds = battleBonds.map(cloneBattleBond); leaderUnitId = scenario.leaderUnitId; quickVictoryTurnLimit = scenario.quickVictoryTurnLimit; baseVictoryGold = scenario.baseVictoryGold; } export class BattleScene extends Phaser.Scene { private layout!: BattleLayout; private phase: BattlePhase = 'idle'; private turnNumber = 1; private activeFaction: ActiveFaction = 'ally'; private selectedUnit?: UnitData; private targetingAction?: DamageCommand; private selectedUsable?: BattleUsable; private lockedTargetPreview?: LockedTargetPreview; private turnText?: Phaser.GameObjects.Text; private objectiveTrackerText?: Phaser.GameObjects.Text; private objectiveTrackerSubText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; private rosterTab: RosterTab = 'ally'; private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; private commandButtons: CommandButton[] = []; private commandMenuObjects: Phaser.GameObjects.GameObject[] = []; private mapMenuObjects: Array = []; private mapMenuLayout?: MapMenuLayout; private battleSpeed: BattleSpeed = 'normal'; private fastForwardHeld = false; private battleSpeedControlObjects: Phaser.GameObjects.GameObject[] = []; private alertObjects: Phaser.GameObjects.GameObject[] = []; private alertDismissEvent?: Phaser.Time.TimerEvent; private battleEventObjects: Phaser.GameObjects.GameObject[] = []; private turnPromptObjects: Phaser.GameObjects.GameObject[] = []; private turnPromptMode?: TurnPromptMode; private combatCutInObjects: Phaser.GameObjects.GameObject[] = []; private facingIndicatorObjects: Phaser.GameObjects.GameObject[] = []; private facingIndicatorUnitId?: string; private resultObjects: Phaser.GameObjects.GameObject[] = []; private resultGaugeAnimations: ResultGaugeAnimation[] = []; private resultAnimationToken = 0; private resultSettlementText?: Phaser.GameObjects.Text; private saveSlotPanelObjects: Phaser.GameObjects.GameObject[] = []; private unitViews = new Map(); private actedUnitIds = new Set(); private bondStates = new Map(); private itemStocks = new Map>(); private battleBuffs = new Map(); private attackIntents: AttackIntent[] = []; private battleLog: string[] = []; private enemyHomeTiles = new Map(); private battleStats = new Map(); private triggeredBattleEvents = new Set(); private pendingMove?: PendingMove; private battleOutcome?: BattleOutcome; private debugOverlay?: Phaser.GameObjects.Text; private mapBackground?: Phaser.GameObjects.Image; private mapMask?: Phaser.Display.Masks.GeometryMask; private terrainTileViews: TerrainTileView[] = []; private miniMapLayout?: MiniMapLayout; private miniMapObjects: Phaser.GameObjects.GameObject[] = []; private miniMapUnitDots = new Map(); private miniMapViewport?: Phaser.GameObjects.Rectangle; private miniMapVisible = true; private cameraTileX = 0; private cameraTileY = 0; private edgeScrollElapsed = 0; private pointerFeedbackMarker?: Phaser.GameObjects.Rectangle; private pointerFeedbackTile?: { x: number; y: number }; private pointerFeedbackHintBg?: Phaser.GameObjects.Rectangle; private pointerFeedbackHintText?: Phaser.GameObjects.Text; private lastPointerFeedbackKey?: string; private suppressNextLeftClick = false; private approachPathCostCache = new Map>(); private launchSortieUnitIds: string[] = []; private launchSortieFormationAssignments: SortieFormationAssignments = {}; constructor() { super('BattleScene'); } init(data?: BattleSceneData) { configureBattleScenario(getBattleScenario(data?.battleId)); this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(data?.selectedSortieUnitIds); this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(data?.sortieFormationAssignments); } create() { this.hideBattleResult(); const campaign = getCampaignState(); this.resetBattleData(campaign); if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') { markCampaignStep('first-battle'); } const { width, height } = this.scale; this.layout = this.createLayout(width, height); const startUnit = battleUnits.find((unit) => unit.id === 'liu-bei') ?? battleUnits[0]; this.setCameraTilePosition( (startUnit?.x ?? 0) - Math.floor(this.layout.visibleColumns / 2), (startUnit?.y ?? 0) - Math.floor(this.layout.visibleRows / 2), false ); this.bondStates = this.createBondStates(); this.itemStocks = this.createItemStocks(); this.battleBuffs.clear(); this.enemyHomeTiles = this.createEnemyHomeTiles(); this.battleStats = this.createBattleStats(); this.triggeredBattleEvents.clear(); this.battleLog = []; this.actedUnitIds.clear(); this.attackIntents = []; this.approachPathCostCache.clear(); this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; this.selectedUsable = undefined; this.lockedTargetPreview = undefined; this.battleOutcome = undefined; this.phase = 'idle'; this.battleSpeed = this.loadBattleSpeed(); this.fastForwardHeld = false; soundDirector.playMusic('battle-prep'); this.input.mouse?.disableContextMenu(); this.installBattleAudioUnlock(); this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.rightButtonDown()) { this.handleRightClick(pointer); return; } if (pointer.leftButtonDown()) { this.handleLeftClick(pointer); } }); this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => this.updatePointerFeedback(pointer)); this.input.keyboard?.on('keydown-ENTER', () => { this.confirmLockedTargetPreview(); }); this.input.keyboard?.on('keydown-ESC', () => { this.cancelAttackTargeting(); }); this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => { if (this.activeFaction !== 'enemy' || this.fastForwardHeld) { return; } event.preventDefault(); this.fastForwardHeld = true; this.renderBattleSpeedControl(); }); this.input.keyboard?.on('keyup-SPACE', () => { if (!this.fastForwardHeld) { return; } this.fastForwardHeld = false; this.renderBattleSpeedControl(); }); this.input.keyboard?.on('keydown-PERIOD', () => { this.cycleBattleSpeed(); }); this.installDebugHotkeys(); this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0); this.ensureScenarioAssets(() => this.drawBattleScene()); } private installBattleAudioUnlock() { const unlock = () => { soundDirector.start(); soundDirector.resume(); }; this.input.once('pointerdown', unlock); this.input.keyboard?.once('keydown', unlock); } private drawBattleScene() { this.drawMap(); this.drawUnits(); this.drawSidePanel(); this.drawDebugSpritePreview(); this.updateTurnText(); this.time.delayedCall(180, () => this.showOpeningBattleEvent()); this.renderRosterPanel('ally', '행동할 장수를 선택하세요.'); } private ensureScenarioAssets(onReady: () => void) { this.ensureScenarioMapTexture(() => loadBattleUiIcons(this, () => this.ensureScenarioUnitTextures(onReady))); } private ensureScenarioMapTexture(onReady: () => void) { const textureKey = battleScenario.mapTextureKey; if (this.textures.exists(textureKey)) { onReady(); return; } const textureUrl = battleMapAssets[textureKey]; if (!textureUrl) { throw new Error(`Missing battle map asset for ${textureKey}`); } const loadingText = this.add.text(this.scale.width / 2, this.scale.height / 2, '전장 지도를 펼치는 중...', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#f5e6b8', stroke: '#05070a', strokeThickness: 4 }); loadingText.setOrigin(0.5); loadingText.setDepth(90); this.load.once('complete', () => { loadingText.destroy(); onReady(); }); this.load.once('loaderror', () => { loadingText.setText('전장 지도를 불러오지 못했습니다.'); }); this.load.image(textureKey, textureUrl); this.load.start(); } private ensureScenarioUnitTextures(onReady: () => void) { const textureKeys = Array.from(new Set([...battleUnits.map((unit) => this.unitTextureKey(unit)), ...this.debugSpritePreviewKeys()])); const texturesReady = textureKeys.every((key) => this.textures.exists(key) && this.textures.exists(`${key}-actions`)); if (texturesReady) { ensureUnitAnimations(this, textureKeys); onReady(); return; } const loadingText = this.add.text(this.scale.width / 2, this.scale.height / 2 + 34, '부대 깃발을 정렬하는 중...', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#f5e6b8', stroke: '#05070a', strokeThickness: 4 }); loadingText.setOrigin(0.5); loadingText.setDepth(90); const handleLoadError = () => { loadingText.setText('부대 화상을 불러오지 못했습니다.'); }; this.load.once('loaderror', handleLoadError); loadUnitSheets(this, textureKeys, () => { this.load.off('loaderror', handleLoadError); loadingText.destroy(); ensureUnitAnimations(this, textureKeys); onReady(); }); } private debugSpritePreviewKeys() { const key = this.debugSpritePreviewKey(); return key ? [key] : []; } private debugSpritePreviewKey() { if (!this.debugToolsEnabled() || typeof window === 'undefined') { return undefined; } const key = new URLSearchParams(window.location.search).get('debugSpritePreview')?.trim(); return key && hasUnitSheetAsset(key) ? key : undefined; } private resetBattleData(campaign?: CampaignState) { const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); const selected = new Set(effectiveSortieUnitIds); const assignments = this.effectiveSortieFormationAssignments(campaign); const forcedAllyIds = new Set( battleScenario.defeatConditions .filter((condition) => condition.kind === 'unit-defeated') .map((condition) => condition.unitId) ); const hasSelection = selected.size > 0; const nextUnits = initialBattleUnits .filter((unit) => unit.faction === 'enemy' || !hasSelection || selected.has(unit.id) || forcedAllyIds.has(unit.id)) .map((unit) => applyScenarioAllyReadiness(this.applyCampaignUnitProgress(unit, campaign))); const deploymentPlan = createSortieDeploymentPlan( nextUnits.filter((unit) => unit.faction === 'ally'), initialBattleUnits, effectiveSortieUnitIds, assignments, (unit) => this.defaultSortieFormationRole(unit) ); battleUnits.splice(0, battleUnits.length, ...applySortieDeploymentPlan(nextUnits, deploymentPlan)); battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign))); this.applyDebugBattleSetup(); initialBattleUnits = battleUnits.map(cloneUnitData); initialBattleBonds = battleBonds.map(cloneBattleBond); } private applyDebugBattleSetup() { if (this.debugBattleSetupKey() !== 'attack-preview') { return; } const attacker = battleUnits.find((unit) => unit.id === 'guan-yu' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0); const target = battleUnits.find((unit) => unit.id === 'rebel-a' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!attacker || !target) { return; } const targetTile = this.debugAdjacentOpenTile(attacker, target.id); if (!targetTile) { return; } target.x = targetTile.x; target.y = targetTile.y; target.hp = Math.max(1, target.hp); } private debugBattleSetupKey() { if (!this.debugToolsEnabled() || typeof window === 'undefined') { return undefined; } return new URLSearchParams(window.location.search).get('debugBattleSetup')?.trim(); } private debugAdjacentOpenTile(origin: UnitData, movingUnitId: string) { const offsets = [ { x: 1, y: 0 }, { x: 0, y: -1 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 2, y: 0 }, { x: 1, y: -1 }, { x: 1, y: 1 } ]; return offsets .map((offset) => ({ x: origin.x + offset.x, y: origin.y + offset.y })) .find((tile) => this.isInBounds(tile.x, tile.y) && !this.isOccupied(tile.x, tile.y, movingUnitId)); } private normalizeLaunchSortieUnitIds(unitIds?: string[]) { return [...new Set((unitIds ?? []).map((unitId) => unitId.trim()).filter(Boolean))]; } private effectiveSortieUnitIds(campaign?: CampaignState) { return this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds : campaign?.selectedSortieUnitIds ?? []; } private effectiveSortieFormationAssignments(campaign?: CampaignState) { return Object.keys(this.launchSortieFormationAssignments).length > 0 ? this.launchSortieFormationAssignments : campaign?.sortieFormationAssignments ?? {}; } private defaultSortieFormationRole(unit: UnitData): SortieFormationRole { if (unit.classKey === 'cavalry') { return 'flank'; } if (unit.classKey === 'archer' || unit.classKey === 'strategist' || unit.classKey === 'quartermaster') { return 'support'; } if (unit.classKey === 'lord' || unit.classKey === 'infantry' || unit.classKey === 'spearman') { return 'front'; } return 'reserve'; } private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) { const cloned = cloneUnitData(unit); const progress = campaign?.roster.find((candidate) => candidate.id === unit.id); if (!progress || unit.faction !== 'ally') { return cloned; } return { ...cloned, level: progress.level, exp: progress.exp, hp: Math.max(1, Math.min(progress.hp, progress.maxHp)), maxHp: progress.maxHp, equipment: JSON.parse(JSON.stringify(progress.equipment)) as UnitData['equipment'] }; } private applyCampaignBondProgress(bond: BattleBond, campaign?: CampaignState) { const cloned = cloneBattleBond(bond); const progress = campaign?.bonds.find((candidate) => candidate.id === bond.id); if (!progress) { return cloned; } return { ...cloned, level: progress.level, exp: progress.exp }; } private createLayout(width: number, height: number): BattleLayout { const margin = 24; const gap = 20; const panelWidth = Phaser.Math.Clamp(Math.floor(width * 0.27), 320, 360); const mapWidth = width - panelWidth - gap - margin * 2; const mapHeight = height - margin * 2; const visibleRows = Math.min(12, battleMap.height); const rowBasedTileSize = Math.floor((mapHeight - 36) / visibleRows); const visibleColumns = Math.min(battleMap.width, Math.max(12, Math.floor((mapWidth - 16) / rowBasedTileSize))); const tileSize = Math.floor(Math.min((mapHeight - 36) / visibleRows, (mapWidth - 16) / visibleColumns)); const gridWidth = tileSize * visibleColumns; const gridHeight = tileSize * visibleRows; const gridSize = Math.min(gridWidth, gridHeight); const mapX = margin; const mapY = margin; return { mapX, mapY, mapWidth, mapHeight, gridX: mapX + Math.floor((mapWidth - gridWidth) / 2), gridY: mapY + Math.floor((mapHeight - gridHeight) / 2), gridSize, gridWidth, gridHeight, visibleColumns, visibleRows, tileSize, panelX: mapX + mapWidth + gap, panelY: margin, panelWidth, panelHeight: mapHeight }; } private drawMap() { const layout = this.layout; const maskShape = this.add.graphics(); maskShape.fillStyle(0xffffff, 1); maskShape.fillRect(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight); maskShape.setAlpha(0); this.mapMask = maskShape.createGeometryMask(); const mapMask = this.mapMask; const background = this.add.image(0, 0, battleScenario.mapTextureKey); background.setOrigin(0); background.setDepth(0); background.setDisplaySize(battleMap.width * layout.tileSize, battleMap.height * layout.tileSize); background.setMask(mapMask); this.mapBackground = background; this.add.rectangle(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight, 0x06090b, 0.16).setOrigin(0).setDepth(1); this.add .rectangle(layout.mapX, layout.mapY, layout.mapWidth, layout.mapHeight) .setOrigin(0) .setStrokeStyle(2, palette.gold, 0.62) .setDepth(8); this.terrainTileViews = []; battleMap.terrain.forEach((row, y) => { row.forEach((terrain, x) => { const terrainRule = getTerrainRule(terrain); const tile = this.add.rectangle(0, 0, layout.tileSize, layout.tileSize, terrainRule.color, terrainRule.alpha); tile.setOrigin(0); tile.setStrokeStyle(1, terrainRule.passable === false ? 0x111111 : 0x0a1014, terrainRule.passable === false ? 0.82 : 0.5); tile.setDepth(2); tile.setMask(mapMask); this.terrainTileViews.push({ x, y, tile }); }); }); this.add .rectangle(layout.gridX, layout.gridY, layout.gridWidth, layout.gridHeight) .setOrigin(0) .setStrokeStyle(2, 0xf1d489, 0.48) .setDepth(3); this.updateCameraView(); } private drawUnits() { battleUnits.forEach((unit) => { const sizeRatio = this.unitDisplaySizeRatio(unit); const textureBase = this.unitTextureKey(unit); const centerX = this.tileCenterX(unit.x); const centerY = this.tileCenterY(unit.y); const sprite = this.add.sprite(centerX, centerY, textureBase, this.unitFrameIndex('south')); sprite.setName(`unit-${unit.id}`); sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio); sprite.setDepth(8); if (this.mapMask) { sprite.setMask(this.mapMask); } const hitZone = this.add.zone(centerX, centerY, this.layout.tileSize, this.layout.tileSize); hitZone.setName(`unit-hit-${unit.id}`); hitZone.setDepth(10.8); hitZone.setInteractive({ useHandCursor: true }); hitZone.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { this.selectUnit(unit); } }); const label = this.add.text(sprite.x, sprite.y + this.layout.tileSize * 0.58, unit.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: unit.faction === 'ally' ? '#e7edf7' : '#ffebe7', stroke: '#05070a', strokeThickness: 3 }); label.setOrigin(0.5, 0); label.setDepth(10); if (this.mapMask) { label.setMask(this.mapMask); } const view = { sprite, hitZone, label, textureBase, direction: 'south' as UnitDirection, baseX: sprite.x, baseY: sprite.y, baseScaleX: sprite.scaleX, baseScaleY: sprite.scaleY }; this.unitViews.set(unit.id, view); this.syncUnitMotion(unit, view); this.applyUnitLegibilityStyle(unit, view); }); } private unitDisplaySizeRatio(unit: UnitData) { const baseRatio = unit.faction === 'ally' ? 1.32 : 1.24; return unit.id === 'guan-yu' ? baseRatio * 1.03 : baseRatio; } private drawSidePanel() { const { panelX, panelY, panelWidth, panelHeight } = this.layout; this.add.rectangle(panelX, panelY, panelWidth, panelHeight, palette.panel, 0.96).setOrigin(0); this.add.rectangle(panelX, panelY, 3, panelHeight, palette.gold, 0.82).setOrigin(0); this.add.text(panelX + 24, panelY + 24, battleScenario.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '28px', color: '#e8dfca', fontStyle: '700', padding: { top: 4, bottom: 4 } }); this.turnText = this.add.text(panelX + 24, panelY + 76, '1턴 / 아군', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#d8b15f' }); this.objectiveTrackerText = this.add.text(panelX + 24, panelY + 108, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', wordWrap: { width: panelWidth - 48, useAdvancedWrap: true } }); this.objectiveTrackerSubText = this.add.text(panelX + 24, panelY + 126, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9aa3ad', wordWrap: { width: panelWidth - 48, useAdvancedWrap: true } }); this.renderBattleSpeedControl(); this.updateObjectiveTracker(); this.drawMiniMap(); } private renderBattleSpeedControl() { this.battleSpeedControlObjects.forEach((object) => object.destroy()); this.battleSpeedControlObjects = []; const { panelX, panelY, panelWidth } = this.layout; const width = 112; const height = 28; const left = panelX + panelWidth - width - 24; const top = panelY + 74; const isHoldingFastForward = this.activeFaction === 'enemy' && this.fastForwardHeld; const label = isHoldingFastForward ? '가속 중' : battleSpeedLabels[this.battleSpeed]; const accent = isHoldingFastForward ? palette.green : this.battleSpeed === 'normal' ? 0x5a7588 : palette.gold; const button = this.add.rectangle(left, top, width, height, 0x17232e, 0.96); button.setOrigin(0); button.setDepth(16); button.setStrokeStyle(1, accent, 0.86); button.setInteractive({ useHandCursor: true }); const text = this.add.text(left + width / 2, top + height / 2 - 1, `속도 ${label}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#f2e3bf', fontStyle: '700' }); text.setOrigin(0.5); text.setDepth(17); text.setInteractive({ useHandCursor: true }); const onSelect = () => this.cycleBattleSpeed(); button.on('pointerover', () => button.setFillStyle(0x243746, 0.98)); button.on('pointerout', () => button.setFillStyle(0x17232e, 0.96)); button.on('pointerdown', onSelect); text.on('pointerdown', onSelect); this.battleSpeedControlObjects.push(button, text); } private isBattleSpeed(value: string | null): value is BattleSpeed { return battleSpeedOptions.includes(value as BattleSpeed); } private loadBattleSpeed(): BattleSpeed { if (typeof window === 'undefined') { return 'normal'; } try { const saved = window.localStorage.getItem(battleSpeedStorageKey); return this.isBattleSpeed(saved) ? saved : 'normal'; } catch { return 'normal'; } } private saveBattleSpeed() { if (typeof window === 'undefined') { return; } try { window.localStorage.setItem(battleSpeedStorageKey, this.battleSpeed); } catch { // Local storage can fail in private or restricted browser contexts. } } private cycleBattleSpeed(playSound = true) { const currentIndex = battleSpeedOptions.indexOf(this.battleSpeed); this.battleSpeed = battleSpeedOptions[(currentIndex + 1) % battleSpeedOptions.length]; this.saveBattleSpeed(); this.renderBattleSpeedControl(); if (playSound) { soundDirector.playSelect(); } } private battleSpeedFactor() { const heldFastForward = this.activeFaction === 'enemy' && this.fastForwardHeld ? 1.75 : 1; return battleSpeedFactors[this.battleSpeed] * heldFastForward; } private scaledBattleDuration(duration: number, minDuration = 55) { return Math.max(minDuration, Math.round(duration / this.battleSpeedFactor())); } private drawDebugSpritePreview() { const textureKey = this.debugSpritePreviewKey(); if (!textureKey) { return; } const { panelX, panelY, panelWidth, panelHeight } = this.layout; const left = panelX + 24; const miniMapTop = this.miniMapLayout?.y ?? panelY + panelHeight - 156; const top = Math.max(panelY + 212, miniMapTop - 148); const width = panelWidth - 48; const height = 128; const depth = 42; const panel = this.add.rectangle(left, top, width, height, 0x0b1118, 0.96); panel.setOrigin(0); panel.setDepth(depth); panel.setStrokeStyle(1, palette.gold, 0.78); this.add.text(left + 12, top + 10, `Debug sprite preview: ${textureKey}`, { fontFamily: 'Consolas, monospace', fontSize: '12px', color: '#f4dfad' }).setDepth(depth + 1); const idle72 = this.add.sprite(left + 48, top + 78, textureKey, this.unitFrameIndex('south')); idle72.setDisplaySize(72, 72); idle72.setDepth(depth + 1); this.applyBaseSpriteBlend(idle72); const idle50 = this.add.sprite(left + 120, top + 88, textureKey, this.unitFrameIndex('south')); idle50.setDisplaySize(50, 50); idle50.setDepth(depth + 1); this.applyBaseSpriteBlend(idle50); const action = this.add.sprite( left + 196, top + 76, `${textureKey}-actions`, this.unitActionFrameIndex('east', 'attack', this.unitActionImpactFrame('attack')) ); action.setDisplaySize(78, 78); action.setDepth(depth + 1); this.applyActionSpriteBlend(action); [ { label: '72px', x: left + 24 }, { label: '50px', x: left + 104 }, { label: 'action', x: left + 174 } ].forEach(({ label, x }) => { this.add.text(x, top + 112, label, { fontFamily: 'Consolas, monospace', fontSize: '10px', color: '#d4dce6' }).setDepth(depth + 1); }); } update(_time: number, delta: number) { this.updateEdgeScroll(delta); } private drawMiniMap() { const { panelX, panelY, panelWidth, panelHeight } = this.layout; const maxWidth = panelWidth - 48; const maxHeight = 132; const cellSize = Math.floor(Math.min(maxWidth / battleMap.width, maxHeight / battleMap.height)); const width = battleMap.width * cellSize; const height = battleMap.height * cellSize; const x = panelX + Math.floor((panelWidth - width) / 2); const y = panelY + panelHeight - height - 24; this.miniMapLayout = { x, y, width, height, cellSize }; const frame = this.add.rectangle(x - 5, y - 5, width + 10, height + 10, 0x0a1014, 0.92); frame.setOrigin(0); frame.setStrokeStyle(2, palette.gold, 0.72); frame.setDepth(24); this.miniMapObjects.push(frame); const hitArea = this.add.rectangle(x, y, width, height, 0x111820, 0.08); hitArea.setOrigin(0); hitArea.setInteractive({ useHandCursor: true }); hitArea.setDepth(25); hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => this.handleMiniMapPointer(pointer)); this.miniMapObjects.push(hitArea); battleMap.terrain.forEach((row, tileY) => { row.forEach((terrain, tileX) => { const rule = getTerrainRule(terrain); const cell = this.add.rectangle( x + tileX * cellSize, y + tileY * cellSize, Math.max(1, cellSize - 1), Math.max(1, cellSize - 1), rule.color, rule.passable === false ? 0.92 : 0.72 ); cell.setOrigin(0); cell.setDepth(26); cell.setInteractive({ useHandCursor: true }); cell.on('pointerdown', (pointer: Phaser.Input.Pointer) => this.handleMiniMapPointer(pointer)); this.miniMapObjects.push(cell); }); }); battleUnits.forEach((unit) => { const dot = this.add.rectangle(0, 0, Math.max(3, cellSize), Math.max(3, cellSize), unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, 0.96); dot.setOrigin(0.5); dot.setDepth(28); this.miniMapUnitDots.set(unit.id, dot); this.miniMapObjects.push(dot); }); const viewport = this.add.rectangle(0, 0, this.layout.visibleColumns * cellSize, this.layout.visibleRows * cellSize); viewport.setOrigin(0); viewport.setStrokeStyle(2, 0xf7e6a1, 0.95); viewport.setDepth(29); this.miniMapViewport = viewport; this.miniMapObjects.push(viewport); this.updateMiniMap(); } private handleMiniMapPointer(pointer: Phaser.Input.Pointer) { if (!this.miniMapLayout || this.battleOutcome || this.phase === 'animating') { return; } const tileX = Phaser.Math.Clamp( Math.floor((pointer.x - this.miniMapLayout.x) / this.miniMapLayout.cellSize), 0, battleMap.width - 1 ); const tileY = Phaser.Math.Clamp( Math.floor((pointer.y - this.miniMapLayout.y) / this.miniMapLayout.cellSize), 0, battleMap.height - 1 ); this.centerCameraOnTile(tileX, tileY); soundDirector.playSelect(); } private updateEdgeScroll(delta: number) { if ( this.battleOutcome || this.phase === 'animating' || this.phase === 'resolved' || this.commandMenuObjects.length > 0 || this.mapMenuObjects.length > 0 || this.turnPromptObjects.length > 0 || this.combatCutInObjects.length > 0 || this.resultObjects.length > 0 ) { this.edgeScrollElapsed = 0; return; } const pointer = this.input.activePointer; const { gridX, gridY, gridWidth, gridHeight } = this.layout; if (pointer.x < gridX || pointer.y < gridY || pointer.x > gridX + gridWidth || pointer.y > gridY + gridHeight) { this.edgeScrollElapsed = 0; return; } const edge = 34; const directionX = pointer.x < gridX + edge ? -1 : pointer.x > gridX + gridWidth - edge ? 1 : 0; const directionY = pointer.y < gridY + edge ? -1 : pointer.y > gridY + gridHeight - edge ? 1 : 0; if (directionX === 0 && directionY === 0) { this.edgeScrollElapsed = 0; return; } this.edgeScrollElapsed += delta; if (this.edgeScrollElapsed < 120) { return; } this.edgeScrollElapsed = 0; this.setCameraTilePosition(this.cameraTileX + directionX, this.cameraTileY + directionY); } private centerCameraOnTile(x: number, y: number) { this.setCameraTilePosition(x - Math.floor(this.layout.visibleColumns / 2), y - Math.floor(this.layout.visibleRows / 2)); } private centerCameraOnAllyLine() { const focusUnit = battleUnits.find((unit) => unit.id === 'liu-bei' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0); if (focusUnit) { this.centerCameraOnTile(focusUnit.x, focusUnit.y); } } private setCameraTilePosition(x: number, y: number, refresh = true) { const nextX = Phaser.Math.Clamp(Math.round(x), 0, this.maxCameraTileX()); const nextY = Phaser.Math.Clamp(Math.round(y), 0, this.maxCameraTileY()); const changed = nextX !== this.cameraTileX || nextY !== this.cameraTileY; this.cameraTileX = nextX; this.cameraTileY = nextY; if (refresh && changed) { this.updateCameraView(); } } private maxCameraTileX() { return Math.max(0, battleMap.width - this.layout.visibleColumns); } private maxCameraTileY() { return Math.max(0, battleMap.height - this.layout.visibleRows); } private updateCameraView() { const { gridX, gridY, tileSize } = this.layout; const offsetX = this.cameraTileX * tileSize; const offsetY = this.cameraTileY * tileSize; this.mapBackground?.setPosition(gridX - offsetX, gridY - offsetY); this.terrainTileViews.forEach(({ x, y, tile }) => { this.positionTileObject(tile, x, y); }); this.markers.forEach((marker) => { const x = marker.getData('tileX') as number | undefined; const y = marker.getData('tileY') as number | undefined; if (x !== undefined && y !== undefined) { this.positionTileObject(marker, x, y); } }); battleUnits.forEach((unit) => this.positionUnitView(unit)); this.updateMiniMap(); this.refreshFacingIndicator(); this.updatePointerFeedback(this.input.activePointer, true); } private updateMiniMap() { if (!this.miniMapLayout) { return; } if (!this.miniMapVisible) { this.miniMapObjects.forEach((object) => this.setMiniMapObjectVisible(object, false)); return; } this.miniMapObjects.forEach((object) => this.setMiniMapObjectVisible(object, true)); const { x, y, cellSize } = this.miniMapLayout; this.miniMapUnitDots.forEach((dot, unitId) => { const unit = battleUnits.find((candidate) => candidate.id === unitId); if (!unit) { return; } dot.setPosition(x + unit.x * cellSize + cellSize / 2, y + unit.y * cellSize + cellSize / 2); dot.setVisible(unit.hp > 0); dot.setFillStyle(unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, unit.hp > 0 ? 0.96 : 0.18); }); this.miniMapViewport?.setPosition(x + this.cameraTileX * cellSize, y + this.cameraTileY * cellSize); } private setMiniMapVisible(visible: boolean) { this.miniMapVisible = visible; this.updateMiniMap(); } private setMiniMapObjectVisible(object: Phaser.GameObjects.GameObject, visible: boolean) { const visibleObject = object as Phaser.GameObjects.GameObject & { setVisible: (value: boolean) => Phaser.GameObjects.GameObject; input?: { enabled: boolean }; }; visibleObject.setVisible(visible); if (visibleObject.input) { visibleObject.input.enabled = visible; } } private positionTileObject(object: Phaser.GameObjects.Rectangle, x: number, y: number) { const screenX = this.layout.gridX + (x - this.cameraTileX) * this.layout.tileSize; const screenY = this.layout.gridY + (y - this.cameraTileY) * this.layout.tileSize; object.setPosition(screenX, screenY); object.setVisible(this.isTileVisible(x, y)); } private positionUnitView(unit: UnitData) { const view = this.unitViews.get(unit.id); if (!view) { return; } this.setUnitBasePosition(view, this.tileCenterX(unit.x), this.tileCenterY(unit.y)); view.label.setPosition(view.baseX, view.baseY + this.layout.tileSize * 0.52); const visible = unit.hp > 0 && this.isTileVisible(unit.x, unit.y); view.sprite.setVisible(visible); this.setUnitHitZoneEnabled(view, visible); view.label.setVisible(visible); this.syncUnitMotion(unit, view); this.applyUnitLegibilityStyle(unit, view); } private refreshUnitLegibilityStyles() { battleUnits.forEach((unit) => this.applyUnitLegibilityStyle(unit)); } private applyUnitLegibilityStyle(unit: UnitData, view = this.unitViews.get(unit.id)) { if (!view) { return; } if (unit.hp <= 0) { this.applyDefeatedStyle(unit, view); return; } const visible = this.isTileVisible(unit.x, unit.y); view.sprite.postFX.clear(); view.sprite.clearTint(); view.sprite.setAlpha(1); view.sprite.setVisible(visible); this.setUnitHitZoneEnabled(view, visible); view.label.setVisible(visible); view.label.setAlpha(1); view.label.setColor(unit.faction === 'ally' ? '#e7edf7' : '#ffebe7'); view.label.setBackgroundColor(''); } private isTileVisible(x: number, y: number) { return ( x >= this.cameraTileX && y >= this.cameraTileY && x < this.cameraTileX + this.layout.visibleColumns && y < this.cameraTileY + this.layout.visibleRows ); } private updatePointerFeedback(pointer = this.input.activePointer, force = false) { if ( this.battleOutcome || this.phase === 'animating' || this.phase === 'resolved' || this.commandMenuObjects.length > 0 || this.mapMenuObjects.length > 0 || this.turnPromptObjects.length > 0 || this.combatCutInObjects.length > 0 || this.resultObjects.length > 0 ) { this.clearPointerFeedback(); return; } const tile = this.pointerToTile(pointer); if (!tile || !this.isTileVisible(tile.x, tile.y)) { this.clearPointerFeedback(); return; } if (this.phase === 'moving' && this.selectedUnit) { this.updateMovePointerFeedback(this.selectedUnit, tile, force); return; } if (this.phase === 'targeting' && this.selectedUnit && this.targetingAction) { this.updateTargetingPointerFeedback(this.selectedUnit, tile, force); return; } if (this.phase === 'idle') { const unit = this.unitAtTile(tile.x, tile.y); if (unit) { this.updateUnitPointerFeedback(unit, tile, force); return; } } this.clearPointerFeedback(); } private updateUnitPointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) { const key = `unit:${unit.id}:${tile.x},${tile.y}:${this.activeFaction}:${this.actedUnitIds.has(unit.id)}`; if (!force && this.lastPointerFeedbackKey === key) { return; } const acted = this.actedUnitIds.has(unit.id); const selectable = unit.faction === 'ally' && this.activeFaction === 'ally' && !acted; const tone = selectable ? palette.blue : unit.faction === 'enemy' ? 0xd8732c : 0x647485; const text = selectable ? `${unit.name}: 선택 가능 · 클릭하면 이동 범위 표시` : unit.faction === 'enemy' ? `${unit.name}: 적 부대 · 클릭하면 위협 범위 확인` : `${unit.name}: 행동 완료`; this.showPointerFeedback(tile, tone, text, key, 0x1c7ed6, 0.1, 0.8); } private updateMovePointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) { const destination = this.unitAtTile(tile.x, tile.y, unit.id); const canMove = this.canMoveTo(unit, tile.x, tile.y); const key = `move:${unit.id}:${tile.x},${tile.y}:${canMove}:${destination?.id ?? 'empty'}`; if (!force && this.lastPointerFeedbackKey === key) { return; } if (canMove) { const distance = this.movementTileDistance(unit, tile.x, tile.y); const text = distance === 0 ? `${unit.name}: 제자리에서 명령 선택` : `${unit.name}: 이동 가능 · ${distance}칸 · 클릭해 확정`; this.showPointerFeedback(tile, palette.blue, text, key, 0x1c7ed6, 0.12, 0.9); return; } const reason = destination ? `${destination.name}이 있는 칸` : '이동 범위 밖'; this.showPointerFeedback(tile, 0xb64a45, `이동 불가: ${reason}`, key, 0xb64a45, 0.08, 0.75); } private updateTargetingPointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) { const usable = this.selectedUsable; if (usable && usable.effect !== 'damage') { this.updateSupportPointerFeedback(unit, usable, tile, force); return; } this.updateDamagePointerFeedback(unit, tile, force); } private updateDamagePointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) { const action = this.targetingAction; if (!action) { this.clearPointerFeedback(); return; } const usable = this.selectedUsable; const target = this.unitAtTile(tile.x, tile.y); const range = this.actionRange(unit, action, usable); const distance = this.tileDistanceTo(unit.x, unit.y, tile.x, tile.y); const canTarget = target ? this.canUseDamageCommand(unit, target, action, usable) : false; const key = `damage:${unit.id}:${action}:${usable?.id ?? 'attack'}:${tile.x},${tile.y}:${target?.id ?? 'empty'}:${canTarget}`; if (!force && this.lastPointerFeedbackKey === key) { return; } if (target && canTarget) { const preview = this.combatPreview(unit, target, action, usable); const locked = this.isLockedDamageTarget(unit, target, action, usable); const counter = preview.counterAvailable ? '반격 주의' : '반격 없음'; this.showPointerFeedback( tile, this.previewAccentColor(preview), `${target.name}: 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}% · ${counter}`, key, 0xd9503f, 0.16, 1 ); this.renderAttackPreview(preview, locked); return; } const label = this.actionTargetingLabel(action, usable); const reason = target ? this.damageTargetFailureMessage(unit, target, action, usable) : distance > range ? `${label}: 사거리 밖 · ${distance}/${range}칸` : `${label}: 이 칸에는 적 대상이 없습니다`; this.showPointerFeedback(tile, 0xb64a45, this.singleLineHint(reason), key, 0xb64a45, 0.08, 0.72); this.renderCurrentTargetingGuideNotice(this.singleLineHint(reason)); } private updateSupportPointerFeedback(unit: UnitData, usable: BattleUsable, tile: { x: number; y: number }, force: boolean) { const target = this.unitAtTile(tile.x, tile.y); const distance = this.tileDistanceTo(unit.x, unit.y, tile.x, tile.y); const canTarget = target ? this.canUseSupportCommand(unit, target, usable) : false; const key = `support:${unit.id}:${usable.id}:${tile.x},${tile.y}:${target?.id ?? 'empty'}:${canTarget}`; if (!force && this.lastPointerFeedbackKey === key) { return; } if (target && canTarget) { const preview = this.supportPreview(unit, target, usable); const locked = this.isLockedSupportTarget(unit, target, usable); const isHeal = usable.effect === 'heal'; const text = isHeal ? `${target.name}: 회복 +${preview.healAmount} · HP ${target.hp} → ${preview.projectedHp}` : `${target.name}: 강화 · 공격 +${preview.attackBonus} · 명중 +${preview.hitBonus}`; this.showPointerFeedback(tile, isHeal ? 0x59d18c : palette.blue, text, key, isHeal ? 0x45b875 : 0x4f86d9, 0.16, 1); this.renderSupportPreview(preview, locked); return; } const label = this.actionTargetingLabel(usable.command, usable); const reason = target ? this.supportTargetFailureMessage(unit, target, usable) : distance > usable.range ? `${label}: 사거리 밖 · ${distance}/${usable.range}칸` : `${label}: 이 칸에는 ${this.usableTargetLabel(usable)} 대상이 없습니다`; const tone = usable.effect === 'heal' ? 0x80cfa1 : 0x8ea9d9; this.showPointerFeedback(tile, tone, this.singleLineHint(reason), key, tone, 0.08, 0.72); this.renderCurrentTargetingGuideNotice(this.singleLineHint(reason), tone); } private showPointerFeedback( tile: { x: number; y: number }, tone: number, text: string, key: string, fillColor: number, fillAlpha: number, strokeAlpha: number ) { const marker = this.ensurePointerFeedbackMarker(); marker.setData('tileX', tile.x); marker.setData('tileY', tile.y); marker.setFillStyle(fillColor, fillAlpha); marker.setStrokeStyle(2, tone, strokeAlpha); this.pointerFeedbackTile = tile; this.positionTileObject(marker, tile.x, tile.y); marker.setVisible(true); this.renderPointerFeedbackHint(text, tone); this.lastPointerFeedbackKey = key; } private ensurePointerFeedbackMarker() { if (this.pointerFeedbackMarker) { return this.pointerFeedbackMarker; } const marker = this.add.rectangle(0, 0, this.layout.tileSize, this.layout.tileSize, palette.blue, 0.1); marker.setName('pointer-feedback-tile'); marker.setOrigin(0); marker.setDepth(5.08); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(false); this.pointerFeedbackMarker = marker; return marker; } private renderPointerFeedbackHint(text: string, tone: number) { const left = this.layout.panelX + 18; const top = this.sideContentTop() - 38; const width = this.layout.panelWidth - 36; const height = 32; if (!this.pointerFeedbackHintBg) { this.pointerFeedbackHintBg = this.add.rectangle(left, top, width, height, 0x0b1118, 0.96); this.pointerFeedbackHintBg.setOrigin(0); this.pointerFeedbackHintBg.setDepth(28); } if (!this.pointerFeedbackHintText) { this.pointerFeedbackHintText = this.add.text(left + 12, top + 8, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#f2e3bf', fontStyle: '700', fixedWidth: width - 24 }); this.pointerFeedbackHintText.setDepth(29); } this.pointerFeedbackHintBg.setPosition(left, top); this.pointerFeedbackHintBg.setSize(width, height); this.pointerFeedbackHintBg.setStrokeStyle(1, tone, 0.62); this.pointerFeedbackHintBg.setVisible(true); this.pointerFeedbackHintText.setPosition(left + 12, top + 8); this.pointerFeedbackHintText.setText(this.truncateUiText(text, 38)); this.pointerFeedbackHintText.setVisible(true); } private clearPointerFeedback() { this.pointerFeedbackMarker?.setVisible(false); this.pointerFeedbackTile = undefined; this.pointerFeedbackHintBg?.setVisible(false); this.pointerFeedbackHintText?.setVisible(false); this.lastPointerFeedbackKey = undefined; } private unitAtTile(x: number, y: number, exceptUnitId?: string) { return battleUnits.find((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y); } private singleLineHint(text: string) { return text.replace(/\n/g, ' · '); } private selectUnit(unit: UnitData) { this.hideMapMenu(); this.hideSaveSlotPanel(); this.hideTurnEndPrompt(); if (this.battleOutcome) { return; } if (this.phase === 'animating') { return; } if (!this.isTileVisible(unit.x, unit.y)) { this.centerCameraOnTile(unit.x, unit.y); } if (this.phase === 'moving' && this.selectedUnit?.id === unit.id) { this.moveSelectedUnit(unit.x, unit.y); return; } if (this.phase === 'targeting' && this.selectedUnit) { if (unit.hp > 0 && this.targetingAction) { if (this.selectedUsable && this.selectedUsable.effect !== 'damage') { this.chooseSupportTarget(this.selectedUnit, unit, this.selectedUsable); return; } this.chooseDamageTarget(this.selectedUnit, unit, this.targetingAction, this.selectedUsable); return; } if (this.selectedUsable) { this.renderCurrentTargetingGuideNotice(`${this.selectedUsable.name}: 표시된 대상만 선택할 수 있습니다.`); return; } this.renderCurrentTargetingGuideNotice('공격 대상이 아닙니다. 붉게 표시된 적 부대를 선택하세요.'); return; } if (this.phase === 'command' || this.phase === 'targeting') { this.setInfo('먼저 현재 장수의 행동을 결정하세요.\n공격, 책략, 도구, 대기 중 하나를 선택해야 합니다.'); return; } if (unit.hp <= 0) { this.renderRosterPanel(unit.faction, '이미 퇴각한 부대입니다.'); return; } if (unit.faction !== 'ally') { this.selectedUnit = undefined; this.pendingMove = undefined; this.phase = 'idle'; this.hideCommandMenu(); this.refreshUnitLegibilityStyles(); this.showEnemyUnitThreatRange(unit); this.renderUnitDetail(unit, this.enemyDetailMessage(unit)); return; } if (this.activeFaction !== 'ally') { this.renderUnitDetail(unit, '적군 행동 중에는 장수를 움직일 수 없습니다.'); return; } if (this.actedUnitIds.has(unit.id)) { this.selectedUnit = undefined; this.clearMarkers(); this.hideCommandMenu(); this.phase = 'idle'; this.refreshUnitLegibilityStyles(); this.renderUnitDetail(unit, '이미 행동을 마친 장수입니다.'); return; } this.phase = 'moving'; this.selectedUnit = unit; this.pendingMove = undefined; this.clearMarkers(); this.hideCommandMenu(); this.showMoveRange(unit); this.refreshUnitLegibilityStyles(); this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.'); } private showMoveRange(unit: UnitData) { this.reachableTiles(unit).forEach(({ x, y }) => { const marker = this.add.rectangle( this.tileTopLeftX(x), this.tileTopLeftY(y), this.layout.tileSize, this.layout.tileSize, palette.blue, 0.34 ); marker.setData('tileX', x); marker.setData('tileY', y); marker.setOrigin(0); marker.setStrokeStyle(2, palette.blue, 0.84); marker.setDepth(4); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(this.isTileVisible(x, y)); marker.setInteractive({ useHandCursor: true }); marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.48)); marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.34)); marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { this.moveSelectedUnit(x, y, pointer); } }); this.markers.push(marker); }); } private moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) { if (!this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) { return; } const unit = this.selectedUnit; const view = this.unitViews.get(unit.id); if (!view) { return; } const fromX = unit.x; const fromY = unit.y; const stayingInPlace = fromX === x && fromY === y; unit.x = x; unit.y = y; this.pendingMove = { unit, fromX, fromY, toX: x, toY: y }; this.phase = 'command'; this.clearMarkers(); this.updateMiniMap(); soundDirector.playSelect(); const targetX = this.tileCenterX(x); const targetY = this.tileCenterY(y); if (!stayingInPlace) { this.moveUnitView(unit, x, y, view, 260, fromX, fromY); } const commandX = pointer?.x ?? targetX; const commandY = pointer?.y ?? targetY; const isFinalAlly = this.remainingAllyCount() === 1; if (isFinalAlly) { this.hideCommandMenu(); this.renderUnitDetail(unit, '마지막 미행동 장수입니다.\n바로 턴을 종료하거나 명령을 선택할 수 있습니다.'); this.showPostMoveTurnEndPrompt(unit, commandX, commandY); return; } this.showCommandMenu(unit, commandX, commandY); this.renderUnitDetail(unit, '공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동을 취소합니다.'); this.hideTurnEndPrompt(); } private showPostMoveTurnEndPrompt(unit: UnitData, commandX = this.tileCenterX(unit.x), commandY = this.tileCenterY(unit.y)) { this.hideCommandMenu(); this.showTurnEndPrompt(undefined, { mode: 'post-move', title: '마지막 장수 이동 완료', body: '대기로 턴을 끝낼까요?', primaryLabel: '대기 후 턴 종료', secondaryLabel: '명령 선택', primaryAction: () => { if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { this.hideTurnEndPrompt(); return; } this.completeUnitAction(unit, 'wait'); if (this.activeFaction === 'ally' && !this.battleOutcome) { this.endAllyTurn(); } }, secondaryAction: () => { if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { this.hideTurnEndPrompt(); return; } this.suppressNextLeftClick = true; this.hideTurnEndPrompt(); this.showCommandMenu(unit, commandX, commandY); this.renderUnitDetail(unit, '공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동을 취소합니다.'); soundDirector.playSelect(); } }); } private canMoveTo(unit: UnitData, x: number, y: number) { return this.reachableTiles(unit).some((tile) => tile.x === x && tile.y === y); } private reachableTiles(unit: UnitData) { const directions = [ { x: 1, y: 0 }, { x: -1, y: 0 }, { x: 0, y: 1 }, { x: 0, y: -1 } ]; const originKey = this.tileKey(unit.x, unit.y); const costs = new Map([[originKey, 0]]); const queue = [{ x: unit.x, y: unit.y, cost: 0 }]; const reachable = new Map([[originKey, { x: unit.x, y: unit.y, cost: 0 }]]); while (queue.length > 0) { queue.sort((a, b) => a.cost - b.cost); const current = queue.shift(); if (!current) { break; } directions.forEach((direction) => { const nextX = current.x + direction.x; const nextY = current.y + direction.y; if (!this.isInBounds(nextX, nextY) || this.isMovementBlocked(unit, nextX, nextY)) { return; } const terrain = battleMap.terrain[nextY][nextX]; const stepCost = this.movementCost(unit, terrain); if (!Number.isFinite(stepCost)) { return; } const nextCost = current.cost + stepCost; const key = this.tileKey(nextX, nextY); if (nextCost > unit.move || nextCost >= (costs.get(key) ?? Number.POSITIVE_INFINITY)) { return; } costs.set(key, nextCost); const tile = { x: nextX, y: nextY, cost: nextCost }; if (!this.isOccupied(nextX, nextY, unit.id)) { reachable.set(key, tile); } queue.push(tile); }); } return Array.from(reachable.values()); } private movementCost(unit: UnitData, terrain: TerrainType) { const terrainRule = getTerrainRule(terrain); if (terrainRule.passable === false) { return Number.POSITIVE_INFINITY; } const unitClass = getUnitClass(unit.classKey); return unitClass.movementCosts?.[terrain] ?? terrainRule.moveCost; } private isInBounds(x: number, y: number) { return x >= 0 && y >= 0 && x < battleMap.width && y < battleMap.height; } private tileKey(x: number, y: number) { return `${x},${y}`; } private isOccupied(x: number, y: number, exceptUnitId?: string) { return battleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y); } private isMovementBlocked(unit: UnitData, x: number, y: number) { return battleUnits.some((other) => { if (other.id === unit.id || other.hp <= 0 || other.x !== x || other.y !== y) { return false; } return other.faction !== unit.faction; }); } private tileCenterX(x: number) { return this.tileTopLeftX(x) + this.layout.tileSize / 2; } private tileCenterY(y: number) { return this.tileTopLeftY(y) + this.layout.tileSize / 2; } private tileTopLeftX(x: number) { return this.layout.gridX + (x - this.cameraTileX) * this.layout.tileSize; } private tileTopLeftY(y: number) { return this.layout.gridY + (y - this.cameraTileY) * this.layout.tileSize; } private clearMarkers() { this.markers.forEach((marker) => marker.destroy()); this.markers = []; this.lockedTargetPreview = undefined; this.clearPointerFeedback(); } private showEnemyThreatRange() { this.clearMarkers(); const threatTiles = this.enemyThreatTiles(); threatTiles.forEach((tile) => this.renderThreatMarker(tile)); const aggressiveCount = threatTiles.filter((tile) => tile.strongestBehavior === 'aggressive').length; const guardCount = threatTiles.filter((tile) => tile.strongestBehavior === 'guard').length; const holdCount = threatTiles.filter((tile) => tile.strongestBehavior === 'hold').length; this.renderSituationPanel( [ `위험 범위 ${threatTiles.length}칸 표시`, aggressiveCount > 0 ? `추격 ${aggressiveCount}칸` : '', guardCount > 0 ? `경계 ${guardCount}칸` : '', holdCount > 0 ? `고수 ${holdCount}칸` : '' ] .filter(Boolean) .join('\n') ); } private showEnemyUnitThreatRange(enemy: UnitData) { this.clearMarkers(); const behavior = this.enemyBehavior(enemy); const moveTiles = this.reachableTiles(enemy); const threatTiles = this.enemyThreatTilesFor(enemy, moveTiles); moveTiles.forEach((tile) => this.renderEnemyMoveMarker(enemy, tile.x, tile.y)); threatTiles.forEach((tile) => this.renderEnemyUnitThreatMarker(enemy, tile.x, tile.y)); this.renderSituationPanel( [ `${enemy.name} 대응 범위`, `이동 ${moveTiles.length}칸 · 공격 ${threatTiles.length}칸`, `${this.enemyBehaviorLabel(behavior)} 태세 · 이동 ${enemy.move} · 사거리 ${this.attackRange(enemy)}` ].join('\n') ); } private enemyThreatTilesFor(enemy: UnitData, moveTiles = this.reachableTiles(enemy)) { const threats = new Map(); const origins = [{ x: enemy.x, y: enemy.y, cost: 0 }, ...moveTiles]; const range = this.attackRange(enemy); origins.forEach((origin) => { this.tilesWithinDistance(origin.x, origin.y, range).forEach(({ x, y }) => { if (!this.isThreatTargetTile(x, y, enemy.id)) { return; } threats.set(this.tileKey(x, y), { x, y }); }); }); return Array.from(threats.values()).sort((a, b) => a.y - b.y || a.x - b.x); } private renderEnemyMoveMarker(enemy: UnitData, x: number, y: number) { const marker = this.add.rectangle(this.tileTopLeftX(x), this.tileTopLeftY(y), this.layout.tileSize, this.layout.tileSize, palette.blue, 0.16); marker.setData('tileX', x); marker.setData('tileY', y); marker.setData('markerType', 'enemy-move'); marker.setOrigin(0); marker.setStrokeStyle(1, palette.blue, 0.42); marker.setDepth(4.15); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(this.isTileVisible(x, y)); marker.setInteractive({ useHandCursor: true }); marker.on('pointerover', () => { marker.setFillStyle(palette.blue, 0.26); this.renderUnitDetail(enemy, `${this.enemyDetailMessage(enemy)}\n\n이동 가능 칸`); }); marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.16)); marker.on('pointerdown', () => this.renderUnitDetail(enemy, `${this.enemyDetailMessage(enemy)}\n\n이동 가능 칸`)); this.markers.push(marker); } private renderEnemyUnitThreatMarker(enemy: UnitData, x: number, y: number) { const behavior = this.enemyBehavior(enemy); const color = this.threatColor(behavior); const marker = this.add.rectangle(this.tileTopLeftX(x), this.tileTopLeftY(y), this.layout.tileSize, this.layout.tileSize, color, 0.28); marker.setData('tileX', x); marker.setData('tileY', y); marker.setData('markerType', 'threat'); marker.setOrigin(0); marker.setStrokeStyle(1, 0xffd1a1, 0.64); marker.setDepth(4.65); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(this.isTileVisible(x, y)); marker.setInteractive({ useHandCursor: true }); marker.on('pointerover', () => { marker.setFillStyle(color, 0.46); this.renderUnitDetail(enemy, `${this.enemyDetailMessage(enemy)}\n\n이동 후 공격 가능 칸`); }); marker.on('pointerout', () => marker.setFillStyle(color, 0.28)); marker.on('pointerdown', () => this.renderUnitDetail(enemy, `${this.enemyDetailMessage(enemy)}\n\n이동 후 공격 가능 칸`)); this.markers.push(marker); } private enemyThreatTiles() { const threats = new Map(); battleUnits .filter((enemy) => enemy.faction === 'enemy' && enemy.hp > 0) .forEach((enemy) => { const behavior = this.enemyBehavior(enemy); const origins = behavior === 'hold' ? [{ x: enemy.x, y: enemy.y, cost: 0 }] : [{ x: enemy.x, y: enemy.y, cost: 0 }, ...this.reachableTiles(enemy)]; const range = this.attackRange(enemy); origins.forEach((origin) => { this.tilesWithinDistance(origin.x, origin.y, range).forEach(({ x, y }) => { if (!this.isThreatTargetTile(x, y, enemy.id)) { return; } const key = this.tileKey(x, y); const existing = threats.get(key); if (!existing) { threats.set(key, { x, y, enemies: [enemy], strongestBehavior: behavior }); return; } if (!existing.enemies.some((candidate) => candidate.id === enemy.id)) { existing.enemies.push(enemy); } if (this.threatBehaviorRank(behavior) > this.threatBehaviorRank(existing.strongestBehavior)) { existing.strongestBehavior = behavior; } }); }); }); return Array.from(threats.values()).sort((a, b) => a.y - b.y || a.x - b.x); } private renderThreatMarker(tile: ThreatTile) { const color = this.threatColor(tile.strongestBehavior); const alpha = Phaser.Math.Clamp(0.18 + tile.enemies.length * 0.07, 0.2, 0.48); const marker = this.add.rectangle(this.tileTopLeftX(tile.x), this.tileTopLeftY(tile.y), this.layout.tileSize, this.layout.tileSize, color, alpha); marker.setData('tileX', tile.x); marker.setData('tileY', tile.y); marker.setData('markerType', 'threat'); marker.setOrigin(0); marker.setStrokeStyle(1, 0xffd1a1, tile.strongestBehavior === 'aggressive' ? 0.66 : 0.42); marker.setDepth(4.5); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(this.isTileVisible(tile.x, tile.y)); marker.setInteractive({ useHandCursor: true }); marker.on('pointerover', () => { marker.setFillStyle(color, Phaser.Math.Clamp(alpha + 0.16, 0.3, 0.64)); this.renderThreatDetail(tile); }); marker.on('pointerout', () => marker.setFillStyle(color, alpha)); marker.on('pointerdown', () => this.renderThreatDetail(tile)); this.markers.push(marker); } private tilesWithinDistance(originX: number, originY: number, distance: number) { const tiles: Array<{ x: number; y: number }> = []; for (let y = originY - distance; y <= originY + distance; y += 1) { for (let x = originX - distance; x <= originX + distance; x += 1) { if (!this.isInBounds(x, y) || Math.abs(originX - x) + Math.abs(originY - y) > distance) { continue; } tiles.push({ x, y }); } } return tiles; } private isThreatTargetTile(x: number, y: number, enemyId: string) { const terrainRule = getTerrainRule(battleMap.terrain[y][x]); if (terrainRule.passable === false) { return false; } return !battleUnits.some((unit) => unit.id !== enemyId && unit.faction === 'enemy' && unit.hp > 0 && unit.x === x && unit.y === y); } private threatBehaviorRank(behavior: EnemyAiBehavior) { if (behavior === 'aggressive') { return 3; } if (behavior === 'guard') { return 2; } return 1; } private threatColor(behavior: EnemyAiBehavior) { if (behavior === 'aggressive') { return 0xc93b30; } if (behavior === 'guard') { return 0xd8732c; } return 0x9e3e58; } private commandAccentColor(command: BattleCommand) { if (command === 'attack') { return 0xd8b15f; } if (command === 'strategy') { return palette.blue; } if (command === 'item') { return palette.green; } return 0x9fb0bf; } private commandMenuInfo(unit: UnitData, command: BattleCommand) { if (command === 'attack') { const targets = this.damageableTargets(unit, 'attack'); const target = targets[0]; if (!target) { return { available: false, status: '대상 없음', detail: this.truncateUiText(this.damageTargetUnavailableReason(unit, 'attack'), 24) }; } const preview = this.combatPreview(unit, target, 'attack'); return { available: true, status: `대상 ${targets.length}`, detail: `${target.name} · 피해 ${preview.damage} · ${preview.hitRate}% · ${preview.counterAvailable ? '반격 주의' : '반격 없음'}` }; } if (command === 'strategy' || command === 'item') { const usables = this.availableUsables(unit, command); if (usables.length === 0) { return { available: false, status: command === 'strategy' ? '책략 없음' : '도구 없음', detail: command === 'strategy' ? '사용 가능한 책략이 없습니다.' : '보유한 도구가 없습니다.' }; } const forecasts = usables.map((usable) => ({ usable, forecast: this.usableMenuForecast(unit, usable) })); const availableCount = forecasts.filter(({ forecast }) => forecast.available).length; const firstReady = forecasts.find(({ forecast }) => forecast.available) ?? forecasts[0]; return { available: availableCount > 0, status: availableCount > 0 ? `가능 ${availableCount}` : '대상 없음', detail: availableCount > 0 ? `${firstReady.usable.name} · ${firstReady.forecast.valueLabel} ${firstReady.forecast.value}` : `${firstReady.usable.name} · ${this.truncateUiText(firstReady.forecast.detail, 18)}` }; } return { available: true, status: '종료', detail: '이 유닛 행동 종료' }; } private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) { this.hideCommandMenu(); const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait']; const menuWidth = 330; const titleHeight = 54; const buttonHeight = 70; const gap = 8; const padding = 10; const menuHeight = padding * 2 + titleHeight + commands.length * buttonHeight + (commands.length - 1) * gap; const { left, top } = this.commandMenuPosition(pointerX, pointerY, menuWidth, menuHeight); const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97); panel.setOrigin(0); panel.setStrokeStyle(2, palette.gold, 0.76); panel.setDepth(14); this.commandMenuObjects.push(panel); const title = this.add.text(left + 16, top + padding + 3, `${unit.name} 명령`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#d8b15f', fontStyle: '700' }); title.setDepth(15); this.commandMenuObjects.push(title); const subtitle = this.add.text(left + 16, top + padding + 31, `HP ${unit.hp}/${unit.maxHp} · ${getUnitClass(unit.classKey).name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#aeb7c2', fontStyle: '700' }); subtitle.setDepth(15); this.commandMenuObjects.push(subtitle); commands.forEach((command, index) => { const buttonTop = top + padding + titleHeight + index * (buttonHeight + gap); const accent = this.commandAccentColor(command); const info = this.commandMenuInfo(unit, command); const baseFill = info.available ? 0x1a2630 : 0x151c23; const centerY = buttonTop + buttonHeight / 2; const buttonLeft = left + padding; const background = this.add.rectangle(buttonLeft, buttonTop, menuWidth - padding * 2, buttonHeight, baseFill, 0.95); background.setOrigin(0); background.setStrokeStyle(1, accent, info.available ? 0.74 : 0.46); background.setInteractive({ useHandCursor: true }); background.setDepth(15); const iconFrame = this.add.rectangle(left + 46, centerY, 56, 56, 0x0a0f14, 0.93); iconFrame.setStrokeStyle(1, accent, info.available ? 0.72 : 0.44); iconFrame.setDepth(16); this.commandMenuObjects.push(iconFrame); const icon = this.createBattleUiIcon(left + 46, centerY, this.commandIcon(command, unit), 50); icon.setDepth(17); this.commandMenuObjects.push(icon); const text = this.add.text(left + 84, buttonTop + 10, commandLabels[command], { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: info.available ? '#f2e3bf' : '#c8ced6', fontStyle: '700', fixedWidth: 92 }); text.setDepth(16); const detail = this.add.text(left + 84, buttonTop + 40, info.detail, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: info.available ? '#aeb7c2' : '#ffcf9a', fixedWidth: menuWidth - 194 }); detail.setDepth(16); this.commandMenuObjects.push(detail); const badgeLeft = left + menuWidth - 106; const badge = this.add.rectangle(badgeLeft, buttonTop + 19, 88, 32, 0x0b1118, 0.95); badge.setOrigin(0); badge.setDepth(16); badge.setStrokeStyle(1, info.available ? accent : 0xb64a45, info.available ? 0.62 : 0.74); this.commandMenuObjects.push(badge); const badgeText = this.add.text(badgeLeft + 44, buttonTop + 27, info.status, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: info.available ? '#d4dce6' : '#ffcf9a', fontStyle: '700', fixedWidth: 84, align: 'center' }); badgeText.setOrigin(0.5, 0); badgeText.setDepth(17); this.commandMenuObjects.push(badgeText); const chooseCommand = (choicePointer: Phaser.Input.Pointer) => { if (choicePointer.rightButtonDown()) { this.cancelPendingMove(); return; } if (choicePointer.leftButtonDown()) { this.suppressNextLeftClick = true; this.hideTurnEndPrompt(); if (command === 'strategy' || command === 'item') { this.showUsableMenu(unit, command, choicePointer.x, choicePointer.y); return; } if (command !== 'wait') { this.beginDamageTargeting(unit, command); return; } this.completeUnitAction(unit, command); } }; const showDetail = () => { background.setFillStyle(info.available ? 0x283947 : 0x27303a, 0.98); this.renderUnitDetail(unit, `${commandLabels[command]}\n${info.detail}`); }; const clearHover = () => background.setFillStyle(baseFill, 0.95); background.on('pointerover', showDetail); background.on('pointerout', clearHover); background.on('pointerdown', chooseCommand); text.setInteractive({ useHandCursor: true }); text.on('pointerover', showDetail); text.on('pointerout', clearHover); text.on('pointerdown', chooseCommand); this.commandButtons.push({ command, background, text }); }); } private commandMenuPosition(pointerX: number, pointerY: number, width: number, height: number) { const margin = 12; const offset = 18; const minLeft = this.layout.mapX + margin; const maxLeft = this.layout.mapX + this.layout.mapWidth - margin - width; const minTop = this.layout.mapY + margin; const maxTop = this.layout.mapY + this.layout.mapHeight - margin - height; if (maxLeft < minLeft || maxTop < minTop) { const preferredLeft = pointerX + offset + width > this.scale.width - margin ? pointerX - offset - width : pointerX + offset; const preferredTop = pointerY + height > this.scale.height - margin ? this.scale.height - margin - height : pointerY; return { left: Phaser.Math.Clamp(preferredLeft, margin, this.scale.width - margin - width), top: Phaser.Math.Clamp(preferredTop, margin, this.scale.height - margin - height) }; } const preferredLeft = pointerX + offset + width <= maxLeft ? pointerX + offset : pointerX - offset - width; const preferredTop = pointerY - height / 2; return { left: Phaser.Math.Clamp(preferredLeft, minLeft, maxLeft), top: Phaser.Math.Clamp(preferredTop, minTop, maxTop) }; } private usableAccentColor(usable: BattleUsable) { return this.usableVisualStyle(usable).accent; } private usableEffectLabel(usable: BattleUsable) { return this.usableVisualStyle(usable).effectLabel; } private usableChannelLabel(usable: BattleUsable) { return usable.command === 'item' ? '도구' : '책략'; } private usableTargetIcon(usable: BattleUsable): BattleUiIconKey { if (usable.target === 'self') { return 'counter'; } if (usable.target === 'ally') { return 'leadership'; } return 'hit'; } private usablePowerIcon(usable: BattleUsable): BattleUiIconKey { return this.usableVisualStyle(usable).powerIcon; } private usableVisualStyle(usable: BattleUsable) { const id = usable.id.toLowerCase(); if (usable.command === 'item') { if (usable.effect === 'heal') { return { icon: 'heal' as BattleUiIconKey, powerIcon: 'heal' as BattleUiIconKey, accent: palette.gold, effectLabel: '회복', powerLabel: `회복 ${usable.power}+` }; } return { icon: 'accessory' as BattleUiIconKey, powerIcon: 'focus' as BattleUiIconKey, accent: palette.gold, effectLabel: '강화', powerLabel: `${usable.duration ?? 1}턴 강화` }; } if (usable.effect === 'heal') { return { icon: 'heal' as BattleUiIconKey, powerIcon: 'heal' as BattleUiIconKey, accent: palette.green, effectLabel: '회복', powerLabel: `회복 ${usable.power}+` }; } if (usable.effect === 'focus') { return { icon: 'focus' as BattleUiIconKey, powerIcon: 'leadership' as BattleUiIconKey, accent: palette.blue, effectLabel: '격려', powerLabel: `${usable.duration ?? 1}턴 강화` }; } if (id.includes('fire')) { return { icon: 'fire' as BattleUiIconKey, powerIcon: 'fire' as BattleUiIconKey, accent: 0xd8732c, effectLabel: '화염', powerLabel: `화염 ${usable.power}` }; } if (id.includes('roar') || id.includes('shout')) { return { icon: 'confusion' as BattleUiIconKey, powerIcon: 'confusion' as BattleUiIconKey, accent: 0xc96b4a, effectLabel: '위압', powerLabel: `위압 ${usable.power}` }; } return { icon: 'strategy' as BattleUiIconKey, powerIcon: 'strategy' as BattleUiIconKey, accent: 0xb64a45, effectLabel: '피해', powerLabel: `위력 ${usable.power}` }; } private usableTargetLabel(usable: BattleUsable) { if (usable.target === 'self') { return '자신'; } if (usable.target === 'ally') { return '아군'; } return '적군'; } private usablePowerLabel(usable: BattleUsable) { return this.usableVisualStyle(usable).powerLabel; } private usableTargetCount(user: UnitData, usable: BattleUsable) { if (usable.effect === 'damage') { return this.damageableTargets(user, usable.command, usable).length; } return this.supportTargets(user, usable).length; } private usableStockLabel(user: UnitData, usable: BattleUsable) { return usable.command === 'item' ? `보유 ${this.itemStock(user.id, usable.id)}` : '책략'; } private usableDetailText(user: UnitData, usable: BattleUsable) { const targetCount = this.usableTargetCount(user, usable); return [ `${usable.name} / ${this.usableChannelLabel(usable)} / ${this.usableEffectLabel(usable)} / ${this.usableTargetLabel(usable)}`, `${this.usablePowerLabel(usable)} · 사거리 ${usable.range} · 대상 ${targetCount}명`, targetCount > 0 ? usable.description : `사용 불가: ${this.usableUnavailableReason(user, usable)}` ].join('\n'); } private usableUnavailableReason(user: UnitData, usable: BattleUsable) { if (usable.command === 'item' && this.itemStock(user.id, usable.id) <= 0) { return '보유 수량이 없습니다.'; } if (usable.effect === 'damage') { return this.damageTargetUnavailableReason(user, usable.command, usable); } return this.supportTargetUnavailableReason(user, usable); } private rateTone(rate: number) { if (rate >= 80) { return 0x59d18c; } if (rate >= 60) { return palette.gold; } return 0xb64a45; } private usableMenuForecast(user: UnitData, usable: BattleUsable): UsableMenuForecast { const targetCount = this.usableTargetCount(user, usable); const hasStock = usable.command !== 'item' || this.itemStock(user.id, usable.id) > 0; const available = targetCount > 0 && hasStock; const status = !hasStock ? '수량 없음' : targetCount > 0 ? `대상 ${targetCount}` : '대상 없음'; const accent = this.usableAccentColor(usable); if (usable.effect === 'damage') { const target = this.damageableTargets(user, usable.command, usable)[0]; if (target) { const preview = this.combatPreview(user, target, usable.command, usable); const successLabel = usable.command === 'strategy' ? '성공률' : '명중률'; return { available, status, targetCount, valueLabel: '피해', value: `${preview.damage}`, detail: `${target.name} · 피해 ${preview.damage} · ${successLabel} ${preview.hitRate}% · ${preview.counterAvailable ? '반격 주의' : '반격 없음'}`, secondary: `치명 ${preview.criticalRate}% · 지형 ${preview.terrainLabel} · 사거리 ${usable.range}칸`, chips: [ { icon: this.usablePowerIcon(usable), label: '피해', value: `${preview.damage}`, tone: 0xb64a45 }, { icon: usable.command === 'strategy' ? 'success' : 'hit', label: successLabel, value: `${preview.hitRate}%`, tone: this.rateTone(preview.hitRate) }, { icon: 'counter', label: '반격', value: preview.counterAvailable ? '주의' : '없음', tone: preview.counterAvailable ? 0xd8732c : 0x59d18c } ] }; } return { available, status, targetCount, valueLabel: '위력', value: `${usable.power}`, detail: this.usableUnavailableReason(user, usable), secondary: usable.description, chips: [ { icon: this.usablePowerIcon(usable), label: '효과', value: this.usableEffectLabel(usable), tone: accent }, { icon: 'success', label: '성공률', value: '-', tone: 0x647485 }, { icon: 'counter', label: '반격', value: '-', tone: 0x647485 } ] }; } if (usable.effect === 'heal') { const target = this.supportTargets(user, usable)[0]; const amount = target ? this.supportHealAmount(user, target, usable) : usable.power; const projectedHp = target ? Math.min(target.maxHp, target.hp + amount) : undefined; return { available, status, targetCount, valueLabel: '회복', value: `+${amount}`, detail: target ? `${target.name} HP ${target.hp} → ${projectedHp}` : this.usableUnavailableReason(user, usable), secondary: target ? `기본 ${usable.power} + 능력/장비 보정` : usable.description, chips: [ { icon: this.usablePowerIcon(usable), label: '회복량', value: `+${amount}`, tone: 0x59d18c }, { icon: 'success', label: '적용', value: available ? '100%' : '-', tone: available ? 0x59d18c : 0x647485 }, { icon: this.usableTargetIcon(usable), label: '대상', value: available ? `${targetCount}명` : '없음', tone: accent } ] }; } return { available, status, targetCount, valueLabel: '강화', value: `${usable.duration ?? 1}턴`, detail: `공격 +${usable.attackBonus ?? 0} · 명중 +${usable.hitBonus ?? 0} · 치명 +${usable.criticalBonus ?? 0}`, secondary: available ? `${this.usableTargetLabel(usable)} ${targetCount}명에게 ${usable.duration ?? 1}턴 적용` : this.usableUnavailableReason(user, usable), chips: [ { icon: 'focus', label: '강화', value: this.usableBuffText(usable), tone: palette.gold }, { icon: 'leadership', label: '지속', value: `${usable.duration ?? 1}턴`, tone: 0x83d6ff }, { icon: this.usableTargetIcon(usable), label: '대상', value: available ? `${targetCount}명` : '없음', tone: accent } ] }; } private usableRangeText(usable: BattleUsable) { return usable.range === 0 ? '자신' : `${usable.range}칸`; } private usableBuffText(usable: BattleUsable) { if (usable.effect !== 'focus') { return this.usablePowerLabel(usable); } return `공+${usable.attackBonus ?? 0} 명+${usable.hitBonus ?? 0} 치+${usable.criticalBonus ?? 0}`; } private renderUsableMenuInfoChip( x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string, tone: number, available: boolean ) { const chip = this.add.rectangle(x, y, width, 44, 0x0b1118, available ? 0.96 : 0.88); chip.setOrigin(0); chip.setDepth(16); chip.setStrokeStyle(1, tone, available ? 0.64 : 0.38); this.commandMenuObjects.push(chip); const iconImage = this.createBattleUiIcon(x + 22, y + 22, icon, 34); iconImage.setAlpha(available ? 0.98 : 0.72); iconImage.setDepth(17); this.commandMenuObjects.push(iconImage); const labelText = this.add.text(x + 44, y + 6, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fontStyle: '700', fixedWidth: width - 52 }); labelText.setDepth(17); this.commandMenuObjects.push(labelText); const valueText = this.add.text(x + 44, y + 23, this.truncateUiText(value, 10), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: available ? '#f2e3bf' : '#c8ced6', fontStyle: '700', fixedWidth: width - 52 }); valueText.setDepth(17); this.commandMenuObjects.push(valueText); } private renderUsableMenuValueCard( x: number, y: number, width: number, height: number, usable: BattleUsable, forecast: UsableMenuForecast, tone: number ) { const valueBox = this.add.rectangle(x, y, width, height, 0x0b1118, forecast.available ? 0.97 : 0.9); valueBox.setOrigin(0); valueBox.setDepth(16); valueBox.setStrokeStyle(1, forecast.available ? tone : 0x6c7480, forecast.available ? 0.82 : 0.46); this.commandMenuObjects.push(valueBox); const valueIcon = this.createBattleUiIcon(x + 32, y + height / 2, this.usablePowerIcon(usable), 52); valueIcon.setAlpha(forecast.available ? 1 : 0.72); valueIcon.setDepth(17); this.commandMenuObjects.push(valueIcon); const valueLabel = this.add.text(x + 66, y + 13, forecast.valueLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700', fixedWidth: width - 74 }); valueLabel.setDepth(17); this.commandMenuObjects.push(valueLabel); const valueHasKorean = /[가-힣]/.test(forecast.value); const valueText = this.add.text(x + width - 10, y + height - 16, forecast.value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: valueHasKorean || forecast.value.length > 4 ? '19px' : '27px', color: forecast.available ? '#f2e3bf' : '#c8ced6', fontStyle: '700' }); valueText.setOrigin(1, 0.5); valueText.setDepth(17); this.commandMenuObjects.push(valueText); } private hideCommandMenu() { this.commandMenuObjects.forEach((object) => object.destroy()); this.commandMenuObjects = []; this.commandButtons.forEach(({ background, text }) => { background.destroy(); text.destroy(); }); this.commandButtons = []; } private showUsableMenu(unit: UnitData, command: UsableCommand, pointerX: number, pointerY: number) { const usables = this.availableUsables(unit, command); if (usables.length === 0) { this.showUnavailableCommandAlert(command); return; } this.hideCommandMenu(); const forecasts = usables.map((usable) => ({ usable, forecast: this.usableMenuForecast(unit, usable) })); const readyCount = forecasts.filter(({ forecast }) => forecast.available).length; const menuWidth = 540; const titleHeight = 58; const rowHeight = 154; const padding = 10; const menuHeight = padding * 2 + titleHeight + usables.length * rowHeight; const { left, top } = this.commandMenuPosition(pointerX, pointerY, menuWidth, menuHeight); const accentColor = command === 'strategy' ? palette.blue : palette.gold; const titleLabel = `${unit.name} · ${commandLabels[command]}`; const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.98); panel.setOrigin(0); panel.setStrokeStyle(2, accentColor, 0.78); panel.setDepth(14); this.commandMenuObjects.push(panel); const title = this.add.text(left + 16, top + padding + 2, titleLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#d8b15f', fontStyle: '700' }); title.setDepth(15); this.commandMenuObjects.push(title); const subtitle = this.add.text(left + 16, top + padding + 31, `HP ${unit.hp}/${unit.maxHp} · ${getUnitClass(unit.classKey).name} · ${command === 'strategy' ? '책략 선택' : '도구 선택'}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#aeb7c2', fontStyle: '700' }); subtitle.setDepth(15); this.commandMenuObjects.push(subtitle); const countBadge = this.add.rectangle(left + menuWidth - 92, top + padding + 12, 70, 28, 0x0b1118, 0.94); countBadge.setOrigin(0); countBadge.setDepth(16); countBadge.setStrokeStyle(1, readyCount > 0 ? accentColor : 0xb64a45, readyCount > 0 ? 0.64 : 0.76); this.commandMenuObjects.push(countBadge); const countText = this.add.text(left + menuWidth - 57, top + padding + 19, `${readyCount}/${usables.length}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: readyCount > 0 ? '#f2e3bf' : '#ffcf9a', fontStyle: '700' }); countText.setOrigin(0.5, 0); countText.setDepth(17); this.commandMenuObjects.push(countText); forecasts.forEach(({ usable, forecast }, index) => { const rowTop = top + padding + titleHeight + index * rowHeight; const accent = this.usableAccentColor(usable); const rowLeft = left + padding; const rowWidth = menuWidth - padding * 2; const rowRight = rowLeft + rowWidth; const contentLeft = rowLeft + 102; const sideBoxWidth = 132; const sideBoxLeft = rowRight - sideBoxWidth - 10; const contentWidth = sideBoxLeft - contentLeft - 12; const baseFill = forecast.available ? 0x1a2630 : 0x151c23; const bg = this.add.rectangle(rowLeft, rowTop, rowWidth, rowHeight - 8, baseFill, forecast.available ? 0.97 : 0.92); bg.setOrigin(0); bg.setDepth(15); bg.setStrokeStyle(1, forecast.available ? accent : 0x6c7480, forecast.available ? 0.72 : 0.52); bg.setInteractive({ useHandCursor: true }); this.commandMenuObjects.push(bg); const iconFrame = this.add.rectangle(rowLeft + 48, rowTop + 72, 84, 84, 0x0a0f14, forecast.available ? 0.94 : 0.87); iconFrame.setStrokeStyle(1, accent, forecast.available ? 0.72 : 0.42); iconFrame.setDepth(16); this.commandMenuObjects.push(iconFrame); const icon = this.createBattleUiIcon(rowLeft + 48, rowTop + 72, this.usableIcon(usable), 70); icon.setAlpha(forecast.available ? 1 : 0.72); icon.setDepth(17); this.commandMenuObjects.push(icon); const stock = this.usableStockLabel(unit, usable); const name = this.add.text(contentLeft, rowTop + 9, usable.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: forecast.available ? '#f2e3bf' : '#c8ced6', fontStyle: '700', fixedWidth: contentWidth }); name.setDepth(16); this.commandMenuObjects.push(name); const statusTone = forecast.available ? accent : 0xb64a45; const statusBg = this.add.rectangle(rowRight - 98, rowTop + 9, 88, 28, 0x0b1118, 0.95); statusBg.setOrigin(0); statusBg.setDepth(16); statusBg.setStrokeStyle(1, statusTone, forecast.available ? 0.66 : 0.78); this.commandMenuObjects.push(statusBg); const statusText = this.add.text(rowRight - 54, rowTop + 16, forecast.status, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: forecast.available ? '#d4dce6' : '#ffcf9a', fontStyle: '700', fixedWidth: 84, align: 'center' }); statusText.setOrigin(0.5, 0); statusText.setDepth(17); this.commandMenuObjects.push(statusText); const metaText = this.add.text(contentLeft, rowTop + 35, `${this.usableChannelLabel(usable)} · ${this.usableEffectLabel(usable)} · ${this.usableTargetLabel(usable)} ${this.usableRangeText(usable)} · ${stock}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700', fixedWidth: contentWidth }); metaText.setDepth(16); this.commandMenuObjects.push(metaText); const summary = this.add.text(contentLeft, rowTop + 55, this.truncateUiText(forecast.detail, 34), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: forecast.available ? '#d4dce6' : '#ffcf9a', fontStyle: '700', fixedWidth: contentWidth }); summary.setDepth(16); this.commandMenuObjects.push(summary); const secondary = this.add.text(contentLeft, rowTop + 76, this.truncateUiText(forecast.secondary, 38), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: forecast.available ? '#aeb7c2' : '#d8b199', fixedWidth: contentWidth }); secondary.setDepth(16); this.commandMenuObjects.push(secondary); this.renderUsableMenuValueCard(sideBoxLeft, rowTop + 54, sideBoxWidth, 82, usable, forecast, accent); const chipGap = 6; const chipWidth = (contentWidth - chipGap * 2) / 3; forecast.chips.slice(0, 3).forEach((chip, chipIndex) => { this.renderUsableMenuInfoChip( contentLeft + chipIndex * (chipWidth + chipGap), rowTop + 102, chipWidth, chip.icon, chip.label, chip.value, chip.tone, forecast.available ); }); const choose = (choicePointer: Phaser.Input.Pointer) => { if (choicePointer.rightButtonDown()) { this.showCommandMenu(unit, pointerX, pointerY); return; } if (choicePointer.leftButtonDown()) { this.suppressNextLeftClick = true; if (!forecast.available) { showDetail(); return; } this.beginUsableTargeting(unit, usable); } }; const showDetail = () => { bg.setFillStyle(forecast.available ? 0x283947 : 0x27303a, 0.98); this.renderUnitDetail(unit, this.usableDetailText(unit, usable)); }; const clearHover = () => bg.setFillStyle(baseFill, forecast.available ? 0.97 : 0.92); bg.on('pointerover', () => { showDetail(); }); bg.on('pointerout', () => { clearHover(); }); bg.on('pointerdown', choose); name.setInteractive({ useHandCursor: true }); name.on('pointerover', showDetail); name.on('pointerout', clearHover); name.on('pointerdown', choose); }); } private availableUsables(unit: UnitData, command: UsableCommand) { if (command === 'strategy') { return (unitStrategyIds[unit.id] ?? []) .map((id) => usableCatalog[id]) .filter((usable): usable is BattleUsable => Boolean(usable)); } const stocks = this.itemStocks.get(unit.id); if (!stocks) { return []; } return Array.from(stocks.entries()) .filter(([, count]) => count > 0) .map(([id]) => usableCatalog[id]) .filter((usable): usable is BattleUsable => Boolean(usable)); } private itemStock(unitId: string, itemId: string) { return this.itemStocks.get(unitId)?.get(itemId) ?? 0; } private consumeItem(unitId: string, itemId: string) { const stocks = this.itemStocks.get(unitId); const current = stocks?.get(itemId) ?? 0; if (!stocks || current <= 0) { return false; } stocks.set(itemId, current - 1); return true; } private showUnavailableCommandAlert(command: Extract) { const message = command === 'strategy' ? '사용할 수 있는 책략이 없습니다.' : '사용할 수 있는 도구가 없습니다.'; this.showBattleAlert(message); } private showBattleAlert(message: string) { this.hideBattleAlert(); soundDirector.playSelect(); const depth = 90; const width = 430; const height = 158; const left = Math.floor((this.scale.width - width) / 2); const top = Math.floor((this.scale.height - height) / 2); const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.28); shade.setOrigin(0); shade.setDepth(depth); shade.setInteractive(); const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.98); panel.setOrigin(0); panel.setDepth(depth + 1); panel.setStrokeStyle(3, palette.gold, 0.9); const text = this.add.text(left + width / 2, top + 45, message, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f2e3bf', fontStyle: '700', align: 'center' }); text.setOrigin(0.5, 0); text.setDepth(depth + 2); const okButton = this.add.rectangle(left + width / 2, top + 112, 112, 34, 0x253748, 0.96); okButton.setDepth(depth + 2); okButton.setStrokeStyle(1, palette.blue, 0.78); okButton.setInteractive({ useHandCursor: true }); const okText = this.add.text(left + width / 2, top + 112, '확인', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#ffffff', fontStyle: '700' }); okText.setOrigin(0.5); okText.setDepth(depth + 3); okText.setInteractive({ useHandCursor: true }); const close = () => this.hideBattleAlert(); shade.on('pointerdown', close); okButton.on('pointerdown', close); okText.on('pointerdown', close); this.alertDismissEvent = this.time.delayedCall(1400, close); this.alertObjects.push(shade, panel, text, okButton, okText); } private hideBattleAlert() { this.alertDismissEvent?.remove(false); this.alertDismissEvent = undefined; this.alertObjects.forEach((object) => object.destroy()); this.alertObjects = []; } private completeUnitAction(unit: UnitData, command: BattleCommand) { if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { return; } this.finishUnitAction(unit, this.commandResultMessage(unit, command)); } private actionTargetingLabel(action: DamageCommand, usable?: BattleUsable) { return usable?.name ?? commandLabels[action]; } private targetingActionTypeLabel(action: DamageCommand, usable?: BattleUsable) { if (usable) { return this.usableEffectLabel(usable); } if (action === 'attack') { return '무기 공격'; } if (action === 'strategy') { return '책략'; } return '도구'; } private damageTargetCandidates(attacker: UnitData) { return battleUnits.filter((target) => target.faction !== attacker.faction && target.hp > 0); } private supportTargetCandidates(user: UnitData, usable: BattleUsable) { return battleUnits.filter((target) => { if (target.hp <= 0) { return false; } if (usable.target === 'self') { return target.id === user.id; } if (usable.target === 'ally') { return target.faction === user.faction; } return target.faction !== user.faction; }); } private nearestUnitDistance(origin: UnitData, candidates: UnitData[]) { return candidates .map((target) => ({ target, distance: this.tileDistance(origin, target) })) .sort((a, b) => a.distance - b.distance)[0]; } private damageTargetUnavailableReason(attacker: UnitData, action: DamageCommand, usable?: BattleUsable) { const candidates = this.damageTargetCandidates(attacker); if (candidates.length === 0) { return '대상으로 삼을 적군이 없습니다.'; } const range = this.actionRange(attacker, action, usable); const nearest = this.nearestUnitDistance(attacker, candidates); if (nearest && nearest.distance > range) { return `${nearest.target.name}까지 ${nearest.distance}칸 / 사거리 ${range}칸입니다.`; } return '사거리 안에 실행 가능한 적군이 없습니다.'; } private supportTargetUnavailableReason(user: UnitData, usable: BattleUsable) { const candidates = this.supportTargetCandidates(user, usable); if (candidates.length === 0) { return `${this.usableTargetLabel(usable)} 대상이 없습니다.`; } const usableCandidates = usable.effect === 'heal' ? candidates.filter((target) => target.hp < target.maxHp) : candidates; if (usableCandidates.length === 0) { return `${this.usableTargetLabel(usable)}의 HP가 이미 가득합니다.`; } const nearest = this.nearestUnitDistance(user, usableCandidates); if (nearest && nearest.distance > usable.range) { return `${nearest.target.name}까지 ${nearest.distance}칸 / 사거리 ${usable.range}칸입니다.`; } return '현재 조건에서 선택 가능한 대상이 없습니다.'; } private damageTargetFailureMessage(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) { const label = this.actionTargetingLabel(action, usable); if (target.hp <= 0) { return `${label}: 이미 퇴각한 부대입니다.`; } if (attacker.faction === target.faction) { return `${label}: 아군은 공격 대상이 아닙니다. 붉게 표시된 적군을 선택하세요.`; } const range = this.actionRange(attacker, action, usable); const distance = this.tileDistance(attacker, target); if (distance > range) { return `${label}: ${target.name}은 사거리 밖입니다.\n현재 ${distance}칸 / 사거리 ${range}칸.`; } return `${label}: 선택할 수 없는 대상입니다. 붉게 표시된 적군을 선택하세요.`; } private supportTargetFailureMessage(user: UnitData, target: UnitData, usable: BattleUsable) { if (target.hp <= 0) { return `${usable.name}: 이미 퇴각한 부대입니다.`; } if (usable.target === 'self' && target.id !== user.id) { return `${usable.name}: 자신에게만 사용할 수 있습니다.`; } if (usable.target === 'ally' && target.faction !== user.faction) { return `${usable.name}: 아군 대상 책략입니다. 푸르게 표시된 아군을 선택하세요.`; } if (usable.target === 'enemy' && target.faction === user.faction) { return `${usable.name}: 적군 대상 책략입니다. 표시된 적군을 선택하세요.`; } if (usable.effect === 'heal' && target.hp >= target.maxHp) { return `${usable.name}: ${target.name}의 HP가 이미 가득합니다.`; } const distance = this.tileDistance(user, target); if (distance > usable.range) { return `${usable.name}: ${target.name}은 사거리 밖입니다.\n현재 ${distance}칸 / 사거리 ${usable.range}칸.`; } return `${usable.name}: 선택할 수 없는 대상입니다. 표시된 대상을 선택하세요.`; } private targetingNearestMetric(origin: UnitData, candidates: UnitData[], range: number, tone: number): TargetingGuideMetric { const nearest = this.nearestUnitDistance(origin, candidates); if (!nearest) { return { icon: 'move', label: '가장 가까움', value: '없음', tone }; } return { icon: 'move', label: '가장 가까움', value: `${nearest.distance}/${range}`, tone }; } private renderDamageTargetingUnavailable(unit: UnitData, action: DamageCommand, usable?: BattleUsable) { const range = this.actionRange(unit, action, usable); const label = this.actionTargetingLabel(action, usable); const candidates = this.damageTargetCandidates(unit); const accent = usable ? this.usableAccentColor(usable) : palette.gold; this.renderTargetingUnavailableCard({ unit, title: `${label} 대상 없음`, subtitle: `${unit.name} · ${this.targetingActionTypeLabel(action, usable)}`, icon: usable ? this.usableIcon(usable) : this.commandIcon('attack', unit), accent, reason: this.damageTargetUnavailableReason(unit, action, usable), metrics: [ { icon: 'move', label: '사거리', value: `${range}칸`, tone: accent }, { icon: 'advantage', label: '후보', value: `${candidates.length}명`, tone: 0xb64a45 }, this.targetingNearestMetric(unit, candidates, range, accent) ], hint: '다른 명령을 고르거나, 우클릭으로 이동을 취소하세요.' }); } private renderSupportTargetingUnavailable(unit: UnitData, usable: BattleUsable) { const candidates = this.supportTargetCandidates(unit, usable); const accent = this.usableAccentColor(usable); this.renderTargetingUnavailableCard({ unit, title: `${usable.name} 대상 없음`, subtitle: `${this.usableChannelLabel(usable)} · ${this.usableEffectLabel(usable)} · ${this.usableTargetLabel(usable)}`, icon: this.usableIcon(usable), accent, reason: this.supportTargetUnavailableReason(unit, usable), metrics: [ { icon: this.usableTargetIcon(usable), label: '사거리', value: usable.range === 0 ? '자신' : `${usable.range}칸`, tone: accent }, { icon: 'success', label: '후보', value: `${candidates.length}명`, tone: usable.effect === 'heal' ? 0x59d18c : palette.blue }, this.targetingNearestMetric(unit, candidates, usable.range, accent) ], hint: '다른 책략/도구를 고르거나, 우클릭으로 명령 선택으로 돌아가세요.' }); } private renderCurrentTargetingGuideNotice(text: string, tone = 0xb64a45) { if (!this.selectedUnit || !this.targetingAction) { return; } const unit = this.selectedUnit; const usable = this.selectedUsable; const notice: TargetingGuideNotice = { icon: 'counter', text: this.truncateUiText(text.replace(/\n/g, ' · '), 46), tone }; if (usable && usable.effect !== 'damage') { const targets = this.supportTargets(unit, usable); if (targets.length > 0) { this.renderSupportTargetingGuide(unit, usable, targets, notice); return; } this.renderSupportTargetingUnavailable(unit, usable); return; } const targets = this.damageableTargets(unit, this.targetingAction, usable); if (targets.length > 0) { this.renderDamageTargetingGuide(unit, this.targetingAction, targets, usable, notice); return; } this.renderDamageTargetingUnavailable(unit, this.targetingAction, usable); } private renderDamageTargetingGuide( unit: UnitData, action: DamageCommand, targets: UnitData[], usable?: BattleUsable, notice?: TargetingGuideNotice ) { const range = this.actionRange(unit, action, usable); const label = this.actionTargetingLabel(action, usable); const actionIcon = usable ? this.usableIcon(usable) : this.commandIcon('attack', unit); const accent = usable ? this.usableAccentColor(usable) : palette.gold; const candidates = targets.slice(0, 3).map((target): TargetingGuideCandidate => { const preview = this.combatPreview(unit, target, action, usable); const distance = this.tileDistance(unit, target); const successLabel = this.previewSuccessLabel(preview); const counterLabel = preview.counterAvailable ? '반격 주의' : '반격 없음'; return { icon: this.previewDamageIcon(preview), name: target.name, meta: `${getUnitClass(target.classKey).name} · 거리 ${distance}/${range}`, primary: `${preview.damage} 피해`, secondary: `${preview.hitRate}% ${successLabel} · ${counterLabel}`, tone: this.previewAccentColor(preview), metrics: [ { icon: this.previewDamageIcon(preview), label: '피해', value: `${preview.damage}`, tone: 0xb64a45 }, { icon: action === 'strategy' ? 'success' : 'hit', label: successLabel, value: `${preview.hitRate}%`, tone: this.rateTone(preview.hitRate) }, { icon: 'counter', label: '반격', value: preview.counterAvailable ? '주의' : '없음', tone: preview.counterAvailable ? 0xd8732c : 0x59d18c } ] }; }); this.renderTargetingGuideCard({ unit, title: `${label} 대상 선택`, subtitle: `${unit.name} · ${this.targetingActionTypeLabel(action, usable)}`, icon: actionIcon, accent, metrics: [ { icon: 'move', label: '사거리', value: `${range}칸`, tone: accent }, { icon: 'advantage', label: '대상', value: `${targets.length}명`, tone: 0xb64a45 }, { icon: usable ? this.usablePowerIcon(usable) : this.commandIcon('attack', unit), label: usable ? '위력' : '무기', value: usable ? `${usable.power}` : getItem(unit.equipment.weapon.itemId).name, tone: accent } ], candidates, overflowCount: Math.max(0, targets.length - candidates.length), footer: '표시된 적 선택 · 확정 전 예측 확인', notice }); } private renderSupportTargetingGuide( unit: UnitData, usable: BattleUsable, targets: UnitData[], notice?: TargetingGuideNotice ) { const accent = this.usableAccentColor(usable); const candidates = targets.slice(0, 3).map((target): TargetingGuideCandidate => { const preview = this.supportPreview(unit, target, usable); const distance = this.tileDistance(unit, target); const isHeal = usable.effect === 'heal'; return { icon: this.usablePowerIcon(usable), name: target.name, meta: `${getUnitClass(target.classKey).name} · 거리 ${distance}/${usable.range}`, primary: isHeal ? `+${preview.healAmount} 회복` : `공+${preview.attackBonus}`, secondary: isHeal ? `HP ${target.hp} → ${preview.projectedHp}` : `명+${preview.hitBonus} · 치+${preview.criticalBonus} · ${preview.duration}턴`, tone: isHeal ? 0x59d18c : accent, metrics: isHeal ? [ { icon: this.usablePowerIcon(usable), label: '회복', value: `+${preview.healAmount}`, tone: 0x59d18c }, { icon: 'hp', label: 'HP', value: `${preview.projectedHp}`, tone: 0x59d18c }, { icon: 'success', label: '적용', value: '100%', tone: 0x59d18c } ] : [ { icon: 'focus', label: '공격', value: `+${preview.attackBonus}`, tone: palette.gold }, { icon: 'hit', label: '명중', value: `+${preview.hitBonus}`, tone: palette.blue }, { icon: 'critical', label: '치명', value: `+${preview.criticalBonus}`, tone: 0xd8732c } ] }; }); this.renderTargetingGuideCard({ unit, title: `${usable.name} 대상 선택`, subtitle: `${this.usableChannelLabel(usable)} · ${this.usableEffectLabel(usable)} · ${this.usableTargetLabel(usable)}`, icon: this.usableIcon(usable), accent, metrics: [ { icon: this.usableTargetIcon(usable), label: '사거리', value: usable.range === 0 ? '자신' : `${usable.range}칸`, tone: accent }, { icon: 'success', label: '대상', value: `${targets.length}명`, tone: usable.effect === 'heal' ? 0x59d18c : palette.blue }, { icon: this.usablePowerIcon(usable), label: this.usableEffectLabel(usable), value: usable.effect === 'heal' ? `+${usable.power}` : `${usable.duration ?? 1}턴`, tone: usable.effect === 'heal' ? 0x59d18c : accent } ], candidates, overflowCount: Math.max(0, targets.length - candidates.length), footer: '표시된 대상 선택 · 확정 전 예측 확인', notice }); } private renderTargetingUnavailableCard(options: { unit: UnitData; title: string; subtitle: string; icon: BattleUiIconKey; accent: number; reason: string; metrics: TargetingGuideMetric[]; hint: string; }) { this.clearSidePanelContent(); this.setMiniMapVisible(false); const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = this.sideContentTop(); const height = 492; const bg = this.trackSideObject(this.add.rectangle(left, top, width, height, 0x101820, 0.98)); bg.setOrigin(0); bg.setStrokeStyle(2, options.accent, 0.78); const iconFrame = this.trackSideObject(this.add.rectangle(left + 46, top + 46, 84, 84, 0x0a0f14, 0.94)); iconFrame.setStrokeStyle(1, options.accent, 0.76); this.trackSideIcon(left + 46, top + 46, options.icon, 70); this.trackSideObject(this.add.text(left + 98, top + 12, options.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#f4dfad', fontStyle: '700', fixedWidth: width - 108 })); this.trackSideObject(this.add.text(left + 98, top + 40, options.subtitle, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700', fixedWidth: width - 108 })); this.trackSideObject(this.add.text(left + 98, top + 61, `사용자 ${options.unit.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#c8d2dd', fixedWidth: width - 108 })); const metricTop = top + 104; const metricGap = 6; const metricWidth = (width - metricGap * 2) / 3; options.metrics.slice(0, 3).forEach((metric, index) => { this.renderTargetingGuideMetric(left + index * (metricWidth + metricGap), metricTop, metricWidth, metric); }); const reasonTop = top + 172; const reasonBg = this.trackSideObject(this.add.rectangle(left, reasonTop, width, 120, 0x121a22, 0.98)); reasonBg.setOrigin(0); reasonBg.setStrokeStyle(1, 0xb64a45, 0.74); const reasonIconFrame = this.trackSideObject(this.add.rectangle(left + 38, reasonTop + 40, 58, 58, 0x0a0f14, 0.94)); reasonIconFrame.setStrokeStyle(1, 0xb64a45, 0.7); this.trackSideIcon(left + 38, reasonTop + 40, 'counter', 52); this.trackSideObject(this.add.text(left + 76, reasonTop + 18, '지금은 대상 없음', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#ffcf9a', fontStyle: '700', fixedWidth: width - 92 })); this.trackSideObject(this.add.text(left + 76, reasonTop + 48, options.reason, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', fontStyle: '700', wordWrap: { width: width - 94, useAdvancedWrap: true }, lineSpacing: 3 })); const hintTop = top + 314; const hintBg = this.trackSideObject(this.add.rectangle(left, hintTop, width, 66, 0x0b1118, 0.96)); hintBg.setOrigin(0); hintBg.setStrokeStyle(1, options.accent, 0.54); this.trackSideIcon(left + 25, hintTop + 33, 'move', 38); this.trackSideObject(this.add.text(left + 52, hintTop + 12, '다음 선택', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#9fb0bf', fontStyle: '700' })); this.trackSideObject(this.add.text(left + 52, hintTop + 31, options.hint, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#f2e3bf', fontStyle: '700', fixedWidth: width - 64 })); } private renderTargetingGuideCard(options: { unit: UnitData; title: string; subtitle: string; icon: BattleUiIconKey; accent: number; metrics: TargetingGuideMetric[]; candidates: TargetingGuideCandidate[]; overflowCount: number; footer: string; notice?: TargetingGuideNotice; }) { this.clearSidePanelContent(); this.setMiniMapVisible(false); const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = this.sideContentTop(); const height = 416; const bg = this.trackSideObject(this.add.rectangle(left, top, width, height, 0x101820, 0.98)); bg.setOrigin(0); bg.setStrokeStyle(2, options.accent, 0.78); const iconFrame = this.trackSideObject(this.add.rectangle(left + 40, top + 42, 72, 72, 0x0a0f14, 0.94)); iconFrame.setStrokeStyle(1, options.accent, 0.76); this.trackSideIcon(left + 40, top + 42, options.icon, 64); this.trackSideObject(this.add.text(left + 86, top + 11, options.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#f4dfad', fontStyle: '700', fixedWidth: width - 96 })); this.trackSideObject(this.add.text(left + 86, top + 39, options.subtitle, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700', fixedWidth: width - 96 })); this.trackSideObject(this.add.text(left + 86, top + 57, `사용자 ${options.unit.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#c8d2dd', fixedWidth: width - 96 })); const metricTop = top + 92; const metricGap = 6; const metricWidth = (width - metricGap * 2) / 3; options.metrics.slice(0, 3).forEach((metric, index) => { this.renderTargetingGuideMetric(left + index * (metricWidth + metricGap), metricTop, metricWidth, metric); }); this.trackSideIcon(left + 17, top + 182, 'success', 28); this.trackSideObject(this.add.text(left + 38, top + 170, '대상 후보', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#f2e3bf', fontStyle: '700' })); const rowTop = top + 200; options.candidates.forEach((candidate, index) => { this.renderTargetingCandidateRow(left, rowTop + index * 84, width, candidate); }); const footerTop = top + 454; const footer = this.trackSideObject(this.add.rectangle(left, footerTop, width, 28, 0x0b1118, 0.95)); footer.setOrigin(0); const footerTone = options.notice?.tone ?? options.accent; footer.setStrokeStyle(1, footerTone, options.notice ? 0.72 : 0.52); this.trackSideIcon(left + 18, footerTop + 14, options.notice?.icon ?? 'success', 24); const overflowText = options.overflowCount > 0 ? ` · 외 ${options.overflowCount}명` : ''; const footerText = options.notice?.text ?? `${options.footer}${overflowText}`; this.trackSideObject(this.add.text(left + 38, footerTop + 6, footerText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: options.notice ? '#ffcf9a' : '#d4dce6', fontStyle: '700', fixedWidth: width - 48 })); } private renderTargetingGuideMetric(x: number, y: number, width: number, metric: TargetingGuideMetric) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, 62, 0x16212d, 0.97)); bg.setOrigin(0); bg.setStrokeStyle(1, metric.tone, 0.62); const iconFrame = this.trackSideObject(this.add.rectangle(x + 23, y + 31, 44, 44, 0x0a0f14, 0.9)); iconFrame.setStrokeStyle(1, metric.tone, 0.54); this.trackSideIcon(x + 23, y + 31, metric.icon, 40); this.trackSideObject(this.add.text(x + 49, y + 9, metric.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fontStyle: '700', fixedWidth: width - 56 })); this.trackSideObject(this.add.text(x + 49, y + 31, this.truncateUiText(metric.value, 8), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: metric.value.length > 4 ? '13px' : '16px', color: '#f2e3bf', fontStyle: '700', fixedWidth: width - 56 })); } private renderTargetingCandidateRow(x: number, y: number, width: number, candidate: TargetingGuideCandidate) { const rowHeight = 78; const row = this.trackSideObject(this.add.rectangle(x, y, width, rowHeight, 0x121a22, 0.98)); row.setOrigin(0); row.setStrokeStyle(1, candidate.tone, 0.64); const iconFrame = this.trackSideObject(this.add.rectangle(x + 30, y + 39, 56, 56, 0x0a0f14, 0.92)); iconFrame.setStrokeStyle(1, candidate.tone, 0.58); this.trackSideIcon(x + 30, y + 39, candidate.icon, 50); this.trackSideObject(this.add.text(x + 64, y + 7, candidate.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#f2e3bf', fontStyle: '700', fixedWidth: width - 74 })); this.trackSideObject(this.add.text(x + 64, y + 29, candidate.meta, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fixedWidth: width - 74 })); const metrics = candidate.metrics ?? [ { icon: candidate.icon, label: '결과', value: candidate.primary, tone: candidate.tone }, { icon: 'success', label: '판정', value: candidate.secondary, tone: 0x647485 } ]; const chipGap = 5; const chipLeft = x + 64; const chipTop = y + 49; const chipWidth = (width - 74 - chipGap * 2) / 3; metrics.slice(0, 3).forEach((metric, index) => { const chipX = chipLeft + index * (chipWidth + chipGap); const chip = this.trackSideObject(this.add.rectangle(chipX, chipTop, chipWidth, 22, 0x0b1118, 0.94)); chip.setOrigin(0); chip.setStrokeStyle(1, metric.tone, 0.54); this.trackSideIcon(chipX + 12, chipTop + 11, metric.icon, 19); this.trackSideObject(this.add.text(chipX + 25, chipTop + 5, this.truncateUiText(metric.value, 7), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#f2e3bf', fontStyle: '700', fixedWidth: chipWidth - 30 })); }); } private showTargetingRangeTiles(unit: UnitData, range: number, fillColor: number, strokeColor: number) { this.tilesWithinDistance(unit.x, unit.y, range).forEach(({ x, y }) => { const marker = this.add.rectangle( this.tileTopLeftX(x), this.tileTopLeftY(y), this.layout.tileSize, this.layout.tileSize, fillColor, 0.08 ); marker.setData('tileX', x); marker.setData('tileY', y); marker.setOrigin(0); marker.setStrokeStyle(1, strokeColor, 0.22); marker.setDepth(4.72); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(this.isTileVisible(x, y)); this.markers.push(marker); }); } private showUnavailableTargetMarker(target: UnitData, tone: number) { const marker = this.add.rectangle( this.tileTopLeftX(target.x), this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, 0x0b1118, 0.06 ); marker.setData('tileX', target.x); marker.setData('tileY', target.y); marker.setOrigin(0); marker.setStrokeStyle(1, tone, 0.52); marker.setDepth(5.02); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(this.isTileVisible(target.x, target.y)); this.markers.push(marker); } private showUnavailableDamageTargetMarkers(attacker: UnitData, targets: UnitData[]) { const validIds = new Set(targets.map((target) => target.id)); this.damageTargetCandidates(attacker) .filter((target) => !validIds.has(target.id)) .forEach((target) => this.showUnavailableTargetMarker(target, 0xb76d62)); } private showUnavailableSupportTargetMarkers(user: UnitData, usable: BattleUsable, targets: UnitData[]) { const validIds = new Set(targets.map((target) => target.id)); const tone = usable.effect === 'heal' ? 0x80cfa1 : 0x8ea9d9; this.supportTargetCandidates(user, usable) .filter((target) => !validIds.has(target.id)) .forEach((target) => this.showUnavailableTargetMarker(target, tone)); } private beginDamageTargeting(unit: UnitData, action: DamageCommand, usable?: BattleUsable) { if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { return; } const targets = this.damageableTargets(unit, action, usable); if (targets.length === 0) { this.renderDamageTargetingUnavailable(unit, action, usable); return; } this.hideTurnEndPrompt(); this.phase = 'targeting'; this.targetingAction = action; this.selectedUsable = usable; this.hideCommandMenu(); this.clearMarkers(); this.renderDamageTargetingGuide(unit, action, targets, usable); this.showTargetingRangeTiles(unit, this.actionRange(unit, action, usable), usable ? this.usableAccentColor(usable) : 0xc93b30, 0xffd27a); this.showUnavailableDamageTargetMarkers(unit, targets); this.showDamageTargets(unit, action, targets, usable); soundDirector.playSelect(); } private beginUsableTargeting(unit: UnitData, usable: BattleUsable) { if (usable.command === 'item' && this.itemStock(unit.id, usable.id) <= 0) { this.showUnavailableCommandAlert('item'); return; } if (usable.effect === 'damage') { this.beginDamageTargeting(unit, usable.command, usable); return; } const targets = this.supportTargets(unit, usable); if (targets.length === 0) { this.renderSupportTargetingUnavailable(unit, usable); return; } this.hideTurnEndPrompt(); this.phase = 'targeting'; this.targetingAction = usable.command; this.selectedUsable = usable; this.hideCommandMenu(); this.clearMarkers(); this.renderSupportTargetingGuide(unit, usable, targets); this.showTargetingRangeTiles(unit, usable.range, usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, usable.effect === 'heal' ? 0xb6ffd2 : 0xd9e8ff); this.showUnavailableSupportTargetMarkers(unit, usable, targets); this.showSupportTargets(unit, usable, targets); soundDirector.playSelect(); } private isLockedDamageTarget(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) { const locked = this.lockedTargetPreview; return Boolean( locked && locked.kind === 'damage' && locked.userId === attacker.id && locked.targetId === target.id && locked.action === action && locked.usableId === usable?.id ); } private isLockedSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable) { const locked = this.lockedTargetPreview; return Boolean( locked && locked.kind === 'support' && locked.userId === user.id && locked.targetId === target.id && locked.action === usable.command && locked.usableId === usable.id ); } private clearLockedTargetPreview(refreshMarkers = true) { this.lockedTargetPreview = undefined; if (refreshMarkers) { this.refreshTargetMarkerStyles(); } } private lockDamageTargetPreview( attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable, preview = this.combatPreview(attacker, target, action, usable) ) { this.lockedTargetPreview = { kind: 'damage', userId: attacker.id, targetId: target.id, action, usableId: usable?.id }; this.refreshTargetMarkerStyles(); this.renderAttackPreview(preview, true); soundDirector.playSelect(); } private lockSupportTargetPreview(user: UnitData, target: UnitData, usable: BattleUsable, preview = this.supportPreview(user, target, usable)) { this.lockedTargetPreview = { kind: 'support', userId: user.id, targetId: target.id, action: usable.command, usableId: usable.id }; this.refreshTargetMarkerStyles(); this.renderSupportPreview(preview, true); soundDirector.playSelect(); } private chooseDamageTarget( attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable, preview?: CombatPreview ) { if (!this.canUseDamageCommand(attacker, target, action, usable)) { this.clearLockedTargetPreview(); this.renderCurrentTargetingGuideNotice(this.damageTargetFailureMessage(attacker, target, action, usable)); return; } if (this.isLockedDamageTarget(attacker, target, action, usable)) { void this.tryResolveDamageTarget(attacker, target, action, usable); return; } this.lockDamageTargetPreview(attacker, target, action, usable, preview ?? this.combatPreview(attacker, target, action, usable)); } private chooseSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable, preview?: SupportPreview) { if (!this.canUseSupportCommand(user, target, usable)) { this.clearLockedTargetPreview(); this.renderCurrentTargetingGuideNotice(this.supportTargetFailureMessage(user, target, usable)); return; } if (this.isLockedSupportTarget(user, target, usable)) { void this.tryResolveSupportTarget(user, target, usable); return; } this.lockSupportTargetPreview(user, target, usable, preview ?? this.supportPreview(user, target, usable)); } private confirmLockedTargetPreview() { const locked = this.lockedTargetPreview; if (!locked || this.phase !== 'targeting' || !this.selectedUnit || this.selectedUnit.id !== locked.userId) { return false; } const target = battleUnits.find((unit) => unit.id === locked.targetId); if (!target || target.hp <= 0) { this.clearLockedTargetPreview(); return false; } soundDirector.playSelect(); if (locked.kind === 'support') { if (!this.selectedUsable || this.selectedUsable.id !== locked.usableId) { this.clearLockedTargetPreview(); return false; } void this.tryResolveSupportTarget(this.selectedUnit, target, this.selectedUsable); return true; } const usable = this.selectedUsable?.id === locked.usableId ? this.selectedUsable : undefined; void this.tryResolveDamageTarget(this.selectedUnit, target, locked.action, usable); return true; } private setTargetMarkerData( marker: Phaser.GameObjects.Rectangle, kind: LockedTargetPreview['kind'], userId: string, targetId: string, action: DamageCommand, usableId: string | undefined, baseColor: number, strokeColor: number ) { marker.setData('targetMarkerKind', kind); marker.setData('targetUserId', userId); marker.setData('targetTargetId', targetId); marker.setData('targetAction', action); marker.setData('targetUsableId', usableId); marker.setData('targetBaseColor', baseColor); marker.setData('targetStrokeColor', strokeColor); } private refreshTargetMarkerStyles() { this.markers.forEach((marker) => { const kind = marker.getData('targetMarkerKind') as LockedTargetPreview['kind'] | undefined; if (!kind) { return; } const baseColor = marker.getData('targetBaseColor') as number; const strokeColor = marker.getData('targetStrokeColor') as number; const locked = this.lockedTargetPreview; const isLocked = locked !== undefined && locked.kind === kind && locked.userId === marker.getData('targetUserId') && locked.targetId === marker.getData('targetTargetId') && locked.action === marker.getData('targetAction') && locked.usableId === marker.getData('targetUsableId'); marker.setFillStyle(isLocked ? 0xf2e3bf : baseColor, isLocked ? 0.36 : 0.2); marker.setStrokeStyle(isLocked ? 3 : 2, isLocked ? 0xffffff : strokeColor, isLocked ? 1 : 0.9); }); } private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action), usable?: BattleUsable) { targets.forEach((target) => { const preview = this.combatPreview(attacker, target, action, usable); const baseColor = 0xc93b30; const strokeColor = 0xffd27a; const marker = this.add.rectangle( this.tileTopLeftX(target.x), this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, baseColor, 0.2 ); marker.setData('tileX', target.x); marker.setData('tileY', target.y); this.setTargetMarkerData(marker, 'damage', attacker.id, target.id, action, usable?.id, baseColor, strokeColor); marker.setOrigin(0); marker.setStrokeStyle(2, strokeColor, 0.9); marker.setDepth(5.05); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(this.isTileVisible(target.x, target.y)); const hitArea = this.add.rectangle( this.tileTopLeftX(target.x), this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, 0xffffff, 0.001 ); hitArea.setData('tileX', target.x); hitArea.setData('tileY', target.y); hitArea.setOrigin(0); hitArea.setDepth(11); if (this.mapMask) { hitArea.setMask(this.mapMask); } hitArea.setVisible(this.isTileVisible(target.x, target.y)); hitArea.setInteractive({ useHandCursor: true }); hitArea.on('pointerover', () => { const locked = this.isLockedDamageTarget(attacker, target, action, usable); marker.setFillStyle(locked ? 0xf2e3bf : 0xd9503f, locked ? 0.36 : 0.34); marker.setStrokeStyle(3, locked ? 0xffffff : 0xffe19a, 1); this.renderAttackPreview(preview, locked); }); hitArea.on('pointerout', () => { this.refreshTargetMarkerStyles(); }); hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { this.chooseDamageTarget(attacker, target, action, usable, preview); } }); this.markers.push(marker, hitArea); }); } private showSupportTargets(user: UnitData, usable: BattleUsable, targets = this.supportTargets(user, usable)) { targets.forEach((target) => { const preview = this.supportPreview(user, target, usable); const baseColor = usable.effect === 'heal' ? 0x45b875 : 0x4f86d9; const strokeColor = usable.effect === 'heal' ? 0xb6ffd2 : 0xd9e8ff; const marker = this.add.rectangle( this.tileTopLeftX(target.x), this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, baseColor, 0.2 ); marker.setData('tileX', target.x); marker.setData('tileY', target.y); this.setTargetMarkerData(marker, 'support', user.id, target.id, usable.command, usable.id, baseColor, strokeColor); marker.setOrigin(0); marker.setStrokeStyle(2, strokeColor, 0.9); marker.setDepth(5.05); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(this.isTileVisible(target.x, target.y)); const hitArea = this.add.rectangle( this.tileTopLeftX(target.x), this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, 0xffffff, 0.001 ); hitArea.setData('tileX', target.x); hitArea.setData('tileY', target.y); hitArea.setOrigin(0); hitArea.setDepth(11); if (this.mapMask) { hitArea.setMask(this.mapMask); } hitArea.setVisible(this.isTileVisible(target.x, target.y)); hitArea.setInteractive({ useHandCursor: true }); hitArea.on('pointerover', () => { const locked = this.isLockedSupportTarget(user, target, usable); marker.setFillStyle(locked ? 0xf2e3bf : baseColor, locked ? 0.36 : 0.34); marker.setStrokeStyle(3, locked ? 0xffffff : usable.effect === 'heal' ? 0xd9ffe8 : 0xf2f7ff, 1); this.renderSupportPreview(preview, locked); }); hitArea.on('pointerout', () => { this.refreshTargetMarkerStyles(); }); hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { this.chooseSupportTarget(user, target, usable, preview); } }); this.markers.push(marker, hitArea); }); } private async tryResolveDamageTarget(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) { if (this.phase !== 'targeting' || this.selectedUnit?.id !== attacker.id) { return; } if (!this.canUseDamageCommand(attacker, target, action, usable)) { this.clearLockedTargetPreview(); this.renderCurrentTargetingGuideNotice(this.damageTargetFailureMessage(attacker, target, action, usable)); return; } if (usable?.command === 'item' && !this.consumeItem(attacker.id, usable.id)) { this.clearLockedTargetPreview(); this.showUnavailableCommandAlert('item'); return; } this.triggerBattleEvent('first-engagement', '첫 교전', [ `${attacker.name}이 ${target.name}을 공격합니다.`, '공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.' ]); this.phase = 'animating'; const result = this.resolveCombatAction(attacker, target, action, false, usable); this.clearMarkers(); await this.showBondMapEffect(result); await this.playCombatCutIn(result); result.counter = this.resolveCounterAttack(result); if (result.counter) { await this.delay(180); await this.playCombatCutIn(result.counter); } this.finishUnitAction(attacker, this.formatCombatResult(result)); } private async tryResolveSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable) { if (this.phase !== 'targeting' || this.selectedUnit?.id !== user.id) { return; } if (!this.canUseSupportCommand(user, target, usable)) { this.clearLockedTargetPreview(); this.renderCurrentTargetingGuideNotice(this.supportTargetFailureMessage(user, target, usable)); return; } if (usable.command === 'item' && !this.consumeItem(user.id, usable.id)) { this.clearLockedTargetPreview(); this.showUnavailableCommandAlert('item'); return; } this.phase = 'animating'; const result = this.resolveSupportAction(user, target, usable); this.clearMarkers(); await this.playSupportCutIn(result); this.finishUnitAction(user, this.formatSupportResult(result)); } private renderAttackPreview(preview: CombatPreview, locked = false) { this.clearSidePanelContent(); this.setMiniMapVisible(false); this.renderCombatPreviewCard(preview, locked); } private supportPreview(user: UnitData, target: UnitData, usable: BattleUsable): SupportPreview { const healAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable) : 0; return { usable, user, target, healAmount, projectedHp: Math.min(target.maxHp, target.hp + healAmount), attackBonus: usable.attackBonus ?? 0, hitBonus: usable.hitBonus ?? 0, criticalBonus: usable.criticalBonus ?? 0, duration: usable.duration ?? 1 }; } private renderSupportPreview(preview: SupportPreview, locked = false) { this.clearSidePanelContent(); this.setMiniMapVisible(false); this.renderSupportPreviewCard(preview, locked); } 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 battleBuffShortText(buff: BattleBuffState) { const parts = [ buff.attackBonus > 0 ? `공+${buff.attackBonus}` : '', buff.hitBonus > 0 ? `명+${buff.hitBonus}` : '', buff.criticalBonus > 0 ? `치+${buff.criticalBonus}` : '' ].filter(Boolean); return `${parts.join(' ') || '유지'} · ${buff.turns}턴`; } private previewModifierSummaries(preview: CombatPreview): PreviewModifierSummary[] { const summaries: PreviewModifierSummary[] = []; const buff = this.battleBuffs.get(preview.attacker.id); const equipmentText = this.equipmentPreviewBonusText(preview); const terrainParts = [ preview.terrainDefenseBonus > 0 ? `${preview.action === 'strategy' ? '저항' : '방'}+${preview.terrainDefenseBonus}` : '', preview.terrainHitPenalty > 0 ? `${preview.action === 'strategy' ? '성공' : '명'}-${preview.terrainHitPenalty}` : '' ].filter(Boolean); if (preview.action === 'strategy') { summaries.push({ icon: 'intelligence', label: '지력', value: `${preview.attacker.stats.intelligence} vs ${preview.defender.stats.intelligence}`, tone: 0x83d6ff }); if (terrainParts.length > 0) { summaries.push({ icon: 'terrain', label: preview.terrainLabel, value: terrainParts.join(' / '), tone: 0x59d18c }); } } if (buff) { summaries.push({ icon: 'focus', label: buff.label, value: this.battleBuffShortText(buff), tone: palette.gold }); } if (preview.bondDamageBonus > 0 && preview.bondLabel) { summaries.push({ icon: 'leadership', label: '공명', value: `피해 +${preview.bondDamageBonus}%`, tone: 0x83d6ff }); } if (equipmentText !== '보정 없음') { summaries.push({ icon: 'mastery', label: '장비', value: equipmentText, tone: 0xd8b15f }); } if (preview.action !== 'strategy' && terrainParts.length > 0) { summaries.push({ icon: 'terrain', label: preview.terrainLabel, value: terrainParts.join(' / '), tone: 0x59d18c }); } return summaries; } private supportPreviewModifierSummaries(preview: SupportPreview): PreviewModifierSummary[] { if (preview.usable.effect === 'heal') { const baseBonus = preview.healAmount - preview.usable.power; return [ { icon: this.usablePowerIcon(preview.usable), label: this.usableChannelLabel(preview.usable), value: `회복 +${preview.healAmount}`, tone: 0x59d18c }, { icon: preview.usable.command === 'strategy' ? 'intelligence' : 'accessory', label: baseBonus > 0 ? '보정' : '기본', value: baseBonus > 0 ? `+${baseBonus}` : '추가 없음', tone: baseBonus > 0 ? palette.gold : 0x647485 } ]; } return [ { icon: 'focus', label: preview.usable.name, value: `공+${preview.attackBonus} 명+${preview.hitBonus}`, tone: palette.gold }, { icon: 'critical', label: '치명', value: `+${preview.criticalBonus}`, tone: 0xd8732c }, { icon: 'leadership', label: '지속', value: `${preview.duration}턴`, tone: 0x83d6ff } ]; } private truncateUiText(text: string, maxLength: number) { return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 3))}...` : text; } private previewActionLabel(preview: CombatPreview) { return preview.usable?.name ?? commandLabels[preview.action]; } private previewAccentColor(preview: CombatPreview) { if (preview.usable) { return this.usableAccentColor(preview.usable); } if (preview.action === 'strategy') { return palette.blue; } if (preview.action === 'item') { return palette.green; } return palette.gold; } private previewSuccessLabel(preview: CombatPreview) { return preview.action === 'strategy' ? '성공' : '명중'; } private previewDamageIcon(preview: CombatPreview): BattleUiIconKey { return preview.usable ? this.usablePowerIcon(preview.usable) : 'attack'; } private previewActionTypeLabel(preview: CombatPreview) { if (preview.usable) { return this.usableEffectLabel(preview.usable); } if (preview.action === 'attack') { return '무기 공격'; } if (preview.action === 'strategy') { return '책략'; } return '도구'; } private renderCombatPreviewSummaryCard(preview: CombatPreview, locked = false) { const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const height = locked ? 464 : 430; const y = this.sideContentTop(); const accent = this.previewAccentColor(preview); const distance = this.tileDistance(preview.attacker, preview.defender); const range = this.actionRange(preview.attacker, preview.action, preview.usable); const attackerWeapon = getItem(preview.attacker.equipment.weapon.itemId); const defenderArmor = getItem(preview.defender.equipment.armor.itemId); const isStrategy = preview.action === 'strategy'; const sourceIcon = preview.usable ? this.usableIcon(preview.usable) : this.itemIcon(attackerWeapon, 'weapon'); const sourceName = preview.usable?.name ?? attackerWeapon.name; const sourceLabel = preview.usable ? this.usableChannelLabel(preview.usable) : isStrategy ? '책략 장비' : '공격 장비'; const defenseLabel = isStrategy ? '대상 방어' : '대상 방어구'; const counterTone = preview.counterAvailable ? 0xb64a45 : 0x59d18c; const bg = this.trackSideObject(this.add.rectangle(left, y, width, height, 0x121a22, 1)); bg.setOrigin(0); bg.setStrokeStyle(2, accent, locked ? 0.95 : 0.78); const actionFrame = this.trackSideObject(this.add.rectangle(left + 46, y + 46, 84, 84, 0x0a0f14, 0.96)); actionFrame.setStrokeStyle(1, accent, 0.82); this.trackSideIcon(left + 46, y + 46, this.actionIcon(preview), 70); this.trackSideObject(this.add.text(left + 98, y + 10, `${preview.attacker.name} → ${preview.defender.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#f4dfad', fontStyle: '700', fixedWidth: width - 108 })); this.trackSideObject(this.add.text(left + 98, y + 39, `${this.previewActionLabel(preview)} · ${this.previewActionTypeLabel(preview)}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', fontStyle: '700', fixedWidth: width - 108 })); this.trackSideObject(this.add.text(left + 98, y + 61, `거리 ${distance}/${range} · ${locked ? '선택됨' : '대상 후보'}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: locked ? '#f2e3bf' : '#9fb0bf', fixedWidth: width - 108 })); const metricGap = 6; const metricWidth = (width - 20 - metricGap * 2) / 3; const metricTop = y + 96; this.renderPreviewDecisionCard(left + 10, metricTop, metricWidth, this.previewDamageIcon(preview), isStrategy ? '책략 피해' : '예상 피해', `${preview.damage}`, 0xb64a45); this.renderPreviewDecisionCard( left + 10 + metricWidth + metricGap, metricTop, metricWidth, isStrategy ? 'success' : 'hit', isStrategy ? '성공률' : this.previewSuccessLabel(preview), `${preview.hitRate}%`, accent ); this.renderPreviewDecisionCard( left + 10 + (metricWidth + metricGap) * 2, metricTop, metricWidth, 'counter', '반격', preview.counterAvailable ? '주의' : '없음', counterTone ); const chipGap = 8; const chipWidth = (width - 20 - chipGap) / 2; this.renderPreviewEquipmentChip(left + 10, y + 214, chipWidth, sourceIcon, sourceLabel, sourceName); this.renderPreviewEquipmentChip(left + 10 + chipWidth + chipGap, y + 214, chipWidth, this.itemIcon(defenderArmor, 'armor'), defenseLabel, defenderArmor.name); this.renderPreviewForecastRow(preview, left + 10, y + 272, width - 20, accent); this.renderPreviewModifierCards(left + 10, y + 312, width - 20, this.previewModifierSummaries(preview), accent); const badgeY = y + 382; const maxBadgeRight = left + width - 10; let badgeX = left + 10; if (isStrategy) { badgeX = this.renderPreviewBadge(badgeX, badgeY, 'success', `성공 ${preview.hitRate}%`, maxBadgeRight); } badgeX = this.renderPreviewBadge(badgeX, badgeY, 'critical', `치명 ${preview.criticalRate}%`, maxBadgeRight); badgeX = this.renderPreviewBadge(badgeX, badgeY, 'advantage', preview.matchupLabel, maxBadgeRight); let detailBadgeX = this.renderPreviewBadge(badgeX, badgeY, 'terrain', preview.terrainLabel, maxBadgeRight); if (preview.bondDamageBonus > 0 && preview.bondLabel) { detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight); } if (preview.equipmentEffectLabels.length > 0) { this.renderPreviewBadge(detailBadgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight); } if (locked) { this.renderPreviewConfirmBar(left + 10, y + 426, width - 20, accent, `${preview.defender.name} 선택됨`); } } private renderSupportPreviewSummaryCard(preview: SupportPreview, locked = false) { const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const height = locked ? 464 : 430; const y = this.sideContentTop(); const accent = this.usableAccentColor(preview.usable); const isHeal = preview.usable.effect === 'heal'; const distance = this.tileDistance(preview.user, preview.target); const bg = this.trackSideObject(this.add.rectangle(left, y, width, height, 0x121a22, 1)); bg.setOrigin(0); bg.setStrokeStyle(2, accent, locked ? 0.95 : 0.78); const actionFrame = this.trackSideObject(this.add.rectangle(left + 46, y + 46, 84, 84, 0x0a0f14, 0.96)); actionFrame.setStrokeStyle(1, accent, 0.82); this.trackSideIcon(left + 46, y + 46, this.usableIcon(preview.usable), 70); this.trackSideObject(this.add.text(left + 98, y + 10, `${preview.user.name} → ${preview.target.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#f4dfad', fontStyle: '700', fixedWidth: width - 108 })); this.trackSideObject(this.add.text(left + 98, y + 39, `${preview.usable.name} · ${this.usableEffectLabel(preview.usable)}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', fontStyle: '700', fixedWidth: width - 108 })); this.trackSideObject(this.add.text(left + 98, y + 61, `거리 ${distance}/${preview.usable.range} · ${locked ? '선택됨' : '대상 후보'}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: locked ? '#f2e3bf' : '#9fb0bf', fixedWidth: width - 108 })); const metricGap = 6; const metricWidth = (width - 20 - metricGap * 2) / 3; const metricTop = y + 96; if (isHeal) { this.renderPreviewDecisionCard(left + 10, metricTop, metricWidth, this.usablePowerIcon(preview.usable), '회복', `+${preview.healAmount}`, 0x59d18c); this.renderPreviewDecisionCard(left + 10 + metricWidth + metricGap, metricTop, metricWidth, 'success', '적용', '100%', accent); this.renderPreviewDecisionCard(left + 10 + (metricWidth + metricGap) * 2, metricTop, metricWidth, 'hp', '병력', `${preview.projectedHp}`, 0x59d18c); } else { this.renderPreviewDecisionCard(left + 10, metricTop, metricWidth, this.usablePowerIcon(preview.usable), '강화', `+${preview.attackBonus}`, palette.gold); this.renderPreviewDecisionCard(left + 10 + metricWidth + metricGap, metricTop, metricWidth, 'hit', '명중', `+${preview.hitBonus}`, accent); this.renderPreviewDecisionCard(left + 10 + (metricWidth + metricGap) * 2, metricTop, metricWidth, 'leadership', '지속', `${preview.duration}턴`, 0x83d6ff); } const chipGap = 8; const chipWidth = (width - 20 - chipGap) / 2; this.renderPreviewEquipmentChip( left + 10, y + 214, chipWidth, this.usableIcon(preview.usable), this.usableChannelLabel(preview.usable), preview.usable.name ); this.renderPreviewEquipmentChip( left + 10 + chipWidth + chipGap, y + 214, chipWidth, this.usableTargetIcon(preview.usable), this.usableTargetLabel(preview.usable), preview.target.name ); this.renderSupportForecastRow(preview, left + 10, y + 272, width - 20, accent); this.renderPreviewModifierCards(left + 10, y + 312, width - 20, this.supportPreviewModifierSummaries(preview), accent); const badgeY = y + 382; const maxBadgeRight = left + width - 10; let badgeX = left + 10; badgeX = this.renderPreviewBadge(badgeX, badgeY, this.usableIcon(preview.usable), this.usableEffectLabel(preview.usable), maxBadgeRight); badgeX = this.renderPreviewBadge(badgeX, badgeY, this.usableTargetIcon(preview.usable), `${this.usableTargetLabel(preview.usable)} ${preview.usable.range}칸`, maxBadgeRight); let detailBadgeX = this.renderPreviewBadge(badgeX, badgeY, preview.usable.command === 'item' ? 'accessory' : 'strategy', this.usableChannelLabel(preview.usable), maxBadgeRight); if (!isHeal) { detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'critical', `치명 +${preview.criticalBonus}`, maxBadgeRight); } if (locked) { this.renderPreviewConfirmBar(left + 10, y + 426, width - 20, accent, `${preview.target.name} 선택됨`); } } private renderPreviewDecisionCard( x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string, tone: number ) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, 104, 0x16212d, 0.98)); bg.setOrigin(0); bg.setStrokeStyle(1, tone, 0.82); const iconFrame = this.trackSideObject(this.add.rectangle(x + width / 2, y + 34, 60, 60, 0x0a0f14, 0.94)); iconFrame.setStrokeStyle(1, tone, 0.66); this.trackSideIcon(x + width / 2, y + 34, icon, 54); const labelText = this.trackSideObject(this.add.text(x + width / 2, y + 66, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700', fixedWidth: width - 8, align: 'center' })); labelText.setOrigin(0.5, 0); const valueHasKorean = /[가-힣]/.test(value); const valueText = this.trackSideObject(this.add.text(x + width / 2, y + 82, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: valueHasKorean || value.length > 4 ? '18px' : '26px', color: '#f2e3bf', fontStyle: '700', fixedWidth: width - 8, align: 'center' })); valueText.setOrigin(0.5, 0); } private renderCombatPreviewCard(preview: CombatPreview, locked = false) { return this.renderCombatPreviewSummaryCard(preview, locked); } private renderSupportPreviewCard(preview: SupportPreview, locked = false) { return this.renderSupportPreviewSummaryCard(preview, locked); } private renderPreviewModifierCards( x: number, y: number, width: number, summaries: PreviewModifierSummary[], accent: number ) { const visible = summaries.slice(0, 3); if (visible.length === 0) { const row = this.trackSideObject(this.add.rectangle(x, y, width, 58, 0x0b1118, 0.94)); row.setOrigin(0); row.setStrokeStyle(1, accent, 0.44); this.trackSideIcon(x + 24, y + 29, 'success', 34); this.trackSideObject(this.add.text(x + 50, y + 10, '보정 출처', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700' })); this.trackSideObject(this.add.text(x + 50, y + 30, '추가 보정 없음', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d4dce6', fontStyle: '700' })); return; } const gap = 6; const cardWidth = (width - gap * (visible.length - 1)) / visible.length; visible.forEach((summary, index) => { const cardX = x + index * (cardWidth + gap); const card = this.trackSideObject(this.add.rectangle(cardX, y, cardWidth, 58, 0x0b1118, 0.96)); card.setOrigin(0); card.setStrokeStyle(1, summary.tone, 0.62); const iconFrame = this.trackSideObject(this.add.rectangle(cardX + 21, y + 29, 38, 38, 0x16212d, 0.92)); iconFrame.setStrokeStyle(1, summary.tone, 0.5); this.trackSideIcon(cardX + 21, y + 29, summary.icon, 34); this.trackSideObject(this.add.text(cardX + 44, y + 7, summary.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fontStyle: '700', fixedWidth: cardWidth - 52 })); this.trackSideObject(this.add.text(cardX + 44, y + 24, this.truncateUiText(summary.value, 14), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#f2e3bf', fontStyle: '700', fixedWidth: cardWidth - 52 })); }); if (summaries.length > visible.length) { const extraText = this.trackSideObject(this.add.text(x + width - 8, y + 4, `+${summaries.length - visible.length}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#f4dfad', fontStyle: '700' })); extraText.setOrigin(1, 0); } } private renderPreviewConfirmBar(x: number, y: number, width: number, accent: number, label: string) { const row = this.trackSideObject(this.add.rectangle(x, y, width, 28, 0x0b1118, 0.97)); row.setOrigin(0); row.setStrokeStyle(1, accent, 0.7); this.trackSideObject(this.add.text(x + 10, y + 7, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#f2e3bf', fontStyle: '700', fixedWidth: width - 158 })); this.renderPreviewActionButton(x + width - 148, y + 4, 70, 20, '실행', accent, () => { this.confirmLockedTargetPreview(); }); this.renderPreviewActionButton(x + width - 72, y + 4, 62, 20, '취소', 0x647485, () => { this.cancelAttackTargeting(); }); } private renderPreviewActionButton( x: number, y: number, width: number, height: number, label: string, tone: number, onClick: () => void ) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x16212d, 0.98)); bg.setOrigin(0); bg.setStrokeStyle(1, tone, 0.82); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => { bg.setFillStyle(tone, 0.95); }); bg.on('pointerout', () => { bg.setFillStyle(0x16212d, 0.98); }); bg.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { onClick(); } }); const text = this.trackSideObject(this.add.text(x + width / 2, y + height / 2, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#f5efe2', fontStyle: '700' })); text.setOrigin(0.5); } private renderPreviewEquipmentChip( x: number, y: number, width: number, icon: BattleUiIconKey, roleLabel: string, label: string ) { const chip = this.trackSideObject(this.add.rectangle(x, y, width, 52, 0x0b1118, 0.98)); chip.setOrigin(0); chip.setStrokeStyle(1, 0x647485, 0.62); const iconFrame = this.trackSideObject(this.add.rectangle(x + 29, y + 26, 50, 50, 0x16212d, 0.94)); iconFrame.setStrokeStyle(1, 0x53606c, 0.55); this.trackSideIcon(x + 29, y + 26, icon, 46); this.trackSideObject(this.add.text(x + 62, y + 6, roleLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fontStyle: '700' })); this.trackSideObject(this.add.text(x + 62, y + 24, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', fontStyle: '700', fixedWidth: width - 70 })); } private renderPreviewParticipantBox( x: number, y: number, width: number, unit: UnitData, roleLabel: string, tone: number ) { const unitClass = getUnitClass(unit.classKey); const bg = this.trackSideObject(this.add.rectangle(x, y, width, 56, 0x16212d, 0.96)); bg.setOrigin(0); bg.setStrokeStyle(1, tone, 0.6); const classFrame = this.trackSideObject(this.add.rectangle(x + 21, y + 28, 38, 38, 0x0a0f14, 0.9)); classFrame.setStrokeStyle(1, tone, 0.56); this.trackSideIcon(x + 21, y + 28, this.unitMoveIcon(unit), 34); this.trackSideObject(this.add.text(x + 44, y + 5, `${roleLabel} · ${unitClass.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fontStyle: '700', fixedWidth: width - 52 })); this.trackSideObject(this.add.text(x + 44, y + 19, unit.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#f2e3bf', fontStyle: '700', fixedWidth: width - 52 })); this.trackSideObject(this.add.text(x + 44, y + 39, `HP ${unit.hp}/${unit.maxHp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#c8d2dd', fontStyle: '700', fixedWidth: width - 52 })); } private renderPreviewMetric( x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string, tone = 0x647485 ) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, 62, 0x16212d, 0.94)); bg.setOrigin(0); bg.setStrokeStyle(1, tone, 0.62); this.trackSideIcon(x + 21, y + 31, icon, 40); this.trackSideObject(this.add.text(x + 43, y + 8, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700' })); const valueHasKorean = /[가-힣]/.test(value); const valueFontSize = valueHasKorean || value.length > 4 ? '20px' : '24px'; const valueText = this.trackSideObject(this.add.text(x + width - 8, y + 40, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: valueFontSize, color: '#f2e3bf', fontStyle: '700' })); valueText.setOrigin(1, 0.5); } private renderPreviewOutcomeCard( x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string, tone = 0x647485 ) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, 58, 0x16212d, 0.96)); bg.setOrigin(0); bg.setStrokeStyle(1, tone, 0.68); const iconFrame = this.trackSideObject(this.add.rectangle(x + 24, y + 29, 42, 42, 0x0a0f14, 0.9)); iconFrame.setStrokeStyle(1, tone, 0.54); this.trackSideIcon(x + 24, y + 29, icon, 38); this.trackSideObject(this.add.text(x + 52, y + 8, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700', fixedWidth: width - 60 })); const valueHasKorean = /[가-힣]/.test(value); const valueFontSize = valueHasKorean || value.length > 4 ? '21px' : '25px'; const valueText = this.trackSideObject(this.add.text(x + width - 10, y + 38, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: valueFontSize, color: '#f2e3bf', fontStyle: '700' })); valueText.setOrigin(1, 0.5); } private renderPreviewForecastRow(preview: CombatPreview, x: number, y: number, width: number, accent: number) { const projectedHp = Math.max(0, preview.defender.hp - preview.damage); const label = preview.action === 'strategy' ? '성공 시 HP' : '명중 시 HP'; const row = this.trackSideObject(this.add.rectangle(x, y, width, 30, 0x0b1118, 0.94)); row.setOrigin(0); row.setStrokeStyle(1, accent, 0.48); this.trackSideIcon(x + 16, y + 15, 'hp', 24); this.trackSideObject(this.add.text(x + 36, y + 8, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700' })); this.drawGauge(x + 112, y + 11, width - 190, 10, projectedHp / preview.defender.maxHp, projectedHp <= 0 ? 0xb64a45 : 0x59d18c); const hpText = this.trackSideObject(this.add.text(x + width - 8, y + 8, `${preview.defender.hp} → ${projectedHp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: projectedHp <= 0 ? '#ff8f5f' : '#f2e3bf', fontStyle: '700' })); hpText.setOrigin(1, 0); } private renderSupportForecastRow(preview: SupportPreview, x: number, y: number, width: number, accent: number) { const row = this.trackSideObject(this.add.rectangle(x, y, width, 30, 0x0b1118, 0.94)); row.setOrigin(0); row.setStrokeStyle(1, accent, 0.48); const isHeal = preview.usable.effect === 'heal'; this.trackSideIcon(x + 16, y + 15, this.usablePowerIcon(preview.usable), 24); this.trackSideObject(this.add.text(x + 36, y + 8, isHeal ? '적용 후 HP' : '강화 결과', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700' })); if (isHeal) { this.drawGauge(x + 112, y + 11, width - 190, 10, preview.projectedHp / preview.target.maxHp, 0x59d18c); const hpText = this.trackSideObject(this.add.text(x + width - 8, y + 8, `${preview.target.hp} → ${preview.projectedHp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#f2e3bf', fontStyle: '700' })); hpText.setOrigin(1, 0); return; } const buffText = this.trackSideObject(this.add.text( x + width - 8, y + 8, `공+${preview.attackBonus} / 명+${preview.hitBonus} / 치+${preview.criticalBonus} / ${preview.duration}턴`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#f2e3bf', fontStyle: '700' } )); buffText.setOrigin(1, 0); } private renderPreviewBadge(x: number, y: number, icon: BattleUiIconKey, label: string, maxRight?: number) { const width = Phaser.Math.Clamp(label.length * 7 + 48, 76, 146); if (maxRight && x + width > maxRight) { return x; } const badge = this.trackSideObject(this.add.rectangle(x, y, width, 28, 0x0b1118, 0.95)); badge.setOrigin(0); badge.setStrokeStyle(1, 0x53606c, 0.58); this.trackSideIcon(x + 16, y + 14, icon, 23); this.trackSideObject(this.add.text(x + 34, y + 6, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#d4dce6', fontStyle: '700' })); return x + width + 6; } private finishUnitAction(unit: UnitData, message: string) { this.actedUnitIds.add(unit.id); this.phase = 'idle'; this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; this.selectedUsable = undefined; this.lockedTargetPreview = undefined; this.hideCommandMenu(); this.hideTurnEndPrompt(); this.applyActedStyle(unit); soundDirector.playSelect(); this.pushBattleLog(message); this.checkBattleEvents(); this.updateObjectiveTracker(); if (this.resolveBattleOutcomeIfNeeded()) { return; } const remaining = this.remainingAllyCount(); const turnHint = remaining > 0 ? `${remaining}명의 아군이 아직 행동할 수 있습니다.` : '모든 아군이 행동했습니다. 턴 종료 여부를 선택하세요.'; const completionMessage = `${message}\n${turnHint}`; if (remaining > 0 && this.focusNextActionableAlly(unit, completionMessage)) { return; } this.renderRosterPanel('ally', completionMessage); if (remaining === 0) { this.renderSituationPanel(completionMessage); this.showTurnEndPrompt(message); } } private actionableAllies() { return battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)); } private firstActionableAlly() { return this.actionableAllies()[0]; } private nextActionableAllyAfter(completedUnit: UnitData) { const allies = battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0); const startIndex = allies.findIndex((unit) => unit.id === completedUnit.id); const start = startIndex >= 0 ? startIndex : 0; for (let offset = 1; offset <= allies.length; offset += 1) { const candidate = allies[(start + offset) % allies.length]; if (!this.actedUnitIds.has(candidate.id)) { return candidate; } } return undefined; } private focusActionableAlly(unit: UnitData, message?: string) { if (this.activeFaction !== 'ally' || this.battleOutcome || unit.hp <= 0 || this.actedUnitIds.has(unit.id)) { return false; } this.centerCameraOnTile(unit.x, unit.y); this.selectUnit(unit); if (this.phase === 'moving' && this.selectedUnit?.id === unit.id && message) { this.renderUnitDetail(unit, message); } return this.phase === 'moving' && this.selectedUnit?.id === unit.id; } private focusNextActionableAlly(completedUnit: UnitData, completionMessage: string) { const nextUnit = this.nextActionableAllyAfter(completedUnit); if (!nextUnit) { return false; } const summary = completionMessage.split('\n')[0] || `${completedUnit.name} 행동 완료`; this.renderSituationPanel(`${summary}\n다음 행동: ${nextUnit.name}`); return this.focusActionableAlly(nextUnit, `${summary}\n다음 행동: ${nextUnit.name}\n이동할 파란 칸을 선택하세요.`); } private resolveBattleOutcomeIfNeeded() { if (this.battleOutcome) { return true; } const defeatTriggered = battleScenario.defeatConditions.some((condition) => { return condition.kind === 'unit-defeated' && !this.isUnitAlive(condition.unitId); }); if (defeatTriggered) { this.completeBattle('defeat'); return true; } const leader = battleUnits.find((unit) => unit.id === leaderUnitId); if (leader && leader.hp <= 0) { this.completeBattle('victory'); return true; } const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!enemiesAlive) { this.completeBattle('victory'); return true; } return false; } private completeBattle(outcome: BattleOutcome) { if (this.battleOutcome) { return; } this.battleOutcome = outcome; this.phase = 'resolved'; this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; this.selectedUsable = undefined; this.clearMarkers(); this.hideCommandMenu(); this.hideMapMenu(); this.hideTurnEndPrompt(); this.hideBattleAlert(); this.hideBattleEventBanner(); this.refreshUnitLegibilityStyles(); this.updateObjectiveTracker(); const message = outcome === 'victory' ? `승리! ${battleScenario.victoryConditionLabel}에 성공했습니다.` : `패배... ${battleScenario.defeatConditionLabel} 조건이 발생했습니다.`; this.pushBattleLog(message); this.renderSituationPanel(message); soundDirector.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', { volume: outcome === 'victory' ? 0.34 : 0.26, stopAfterMs: 1200 }); this.publishFirstBattleReport(outcome); this.showBattleResult(outcome); } private showBattleResult(outcome: BattleOutcome) { this.hideBattleResult(); this.resultAnimationToken += 1; this.resultGaugeAnimations = []; const animationToken = this.resultAnimationToken; const depth = 120; const panelWidth = 1040; const panelHeight = 660; const left = Math.floor((this.scale.width - panelWidth) / 2); const top = Math.floor((this.scale.height - panelHeight) / 2); const allies = battleUnits.filter((unit) => unit.faction === 'ally'); const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length; const aliveAllies = allies.filter((unit) => unit.hp > 0).length; const objectives = this.resultObjectives(outcome); const shade = this.trackResultObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68)); shade.setOrigin(0); shade.setDepth(depth); shade.setInteractive(); const panel = this.trackResultObject(this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.98)); panel.setOrigin(0); panel.setDepth(depth + 1); panel.setStrokeStyle(3, outcome === 'victory' ? palette.gold : 0xb86b55, 0.94); const title = this.trackResultObject(this.add.text(left + panelWidth / 2, top + 26, outcome === 'victory' ? '전투 승리' : '전투 패배', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '34px', color: outcome === 'victory' ? '#f4dfad' : '#ffb6a6', fontStyle: '700', stroke: '#05070a', strokeThickness: 5 })); title.setOrigin(0.5, 0); title.setDepth(depth + 2); const subtitleText = outcome === 'victory' ? '탁현 의용군은 첫 싸움에서 황건적을 몰아내고 마을을 지켜냈습니다.' : '유비가 전장을 이탈했습니다. 진형을 가다듬고 다시 도전해야 합니다.'; const subtitle = this.trackResultObject(this.add.text(left + panelWidth / 2, top + 72, subtitleText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d4dce6', align: 'center' })); subtitle.setOrigin(0.5, 0); subtitle.setDepth(depth + 2); const metricTop = top + 112; const metricWidth = 226; const metricGap = 16; this.renderResultMetric('소요 턴', `${this.turnNumber}턴`, left + 44, metricTop, metricWidth, depth + 2); this.renderResultMetric('생존 아군', `${aliveAllies} / ${allies.length}`, left + 44 + (metricWidth + metricGap), metricTop, metricWidth, depth + 2); this.renderResultMetric('격파 적군', `${defeatedEnemies} / ${totalEnemies}`, left + 44 + (metricWidth + metricGap) * 2, metricTop, metricWidth, depth + 2); this.renderResultMetric('전투 보상', outcome === 'victory' ? `군자금 ${this.resultGold(outcome)}` : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2); this.renderResultObjectivePanel(objectives, left + 44, top + 178, 456, depth + 2); this.renderResultRewardPanel(outcome, objectives, left + 526, top + 178, 470, depth + 2); this.renderResultBondStrip(left + 44, top + 318, panelWidth - 88, depth + 2); const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 354, '장수 성장', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f2e3bf', fontStyle: '700' })); unitTitle.setDepth(depth + 2); allies.forEach((unit, index) => { this.renderResultUnitRow(unit, left + 44, top + 386 + index * 70, panelWidth - 88, depth + 2); }); this.resultSettlementText = this.trackResultObject(this.add.text(left + 44, top + 604, '성장 정산 대기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d8b15f', fontStyle: '700' })); this.resultSettlementText.setDepth(depth + 2); if (outcome === 'victory') { this.addResultButton('군영으로', left + panelWidth - 422, top + 612, 132, () => { soundDirector.playSelect(); void startLazyScene(this, 'CampScene'); }, depth + 3); } this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { soundDirector.playSelect(); this.scene.restart({ battleId: battleScenario.id }); }, depth + 3); this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { soundDirector.playSelect(); this.scene.start('TitleScene'); }, depth + 3); void this.playResultSettlementAnimation(animationToken); } private hideBattleResult() { this.resultAnimationToken += 1; this.resultObjects.forEach((object) => object.destroy()); this.resultObjects = []; this.resultGaugeAnimations = []; this.resultSettlementText = undefined; } private resultObjectives(outcome: BattleOutcome): BattleObjectiveResult[] { return this.objectiveStates(outcome).map((objective) => ({ id: objective.id, label: objective.label, achieved: objective.achieved, status: objective.status, detail: objective.detail, rewardGold: objective.rewardGold })); } private objectiveStates(outcome?: BattleOutcome): BattleObjectiveState[] { return battleScenario.objectives.map((objective) => this.objectiveState(objective, outcome)); } private objectiveState(objective: BattleObjectiveDefinition, outcome?: BattleOutcome): BattleObjectiveState { if (objective.kind === 'defeat-leader' || objective.kind === 'pacify-leader') { const targetId = objective.unitId ?? leaderUnitId; const target = battleUnits.find((unit) => unit.id === targetId); const defeated = this.isUnitDefeated(targetId); const doneText = objective.kind === 'pacify-leader' ? '생포' : '격파'; const achieved = outcome ? outcome === 'victory' && defeated : defeated; return { id: objective.id, label: objective.label, achieved, status: achieved ? 'done' : outcome === 'defeat' ? 'failed' : 'active', detail: defeated ? `${this.unitName(targetId)} ${doneText}` : `${this.unitName(targetId)} 병력 ${target?.hp ?? 0}/${target?.maxHp ?? 0}`, rewardGold: objective.rewardGold }; } if (objective.kind === 'keep-unit-alive') { const targetId = objective.unitId ?? 'liu-bei'; const alive = this.isUnitAlive(targetId); return { id: objective.id, label: objective.label, achieved: alive, status: alive ? (outcome === 'victory' ? 'done' : 'active') : 'failed', detail: alive ? `${this.unitName(targetId)} 생존` : `${this.unitName(targetId)} 퇴각`, rewardGold: objective.rewardGold }; } if (objective.kind === 'secure-terrain') { const terrain = (objective.terrain ?? 'village') as TerrainType; const secured = terrain === 'village' ? !this.hasEnemyThreateningTerrain(terrain, 2) : !this.hasEnemyOnTerrain(terrain); const terrainLabel = terrain === 'village' ? '마을 주변' : terrain === 'camp' ? '군영/수용소' : terrain === 'fort' ? '요새' : '목표 지형'; const achieved = outcome ? outcome === 'victory' && secured : secured; return { id: objective.id, label: objective.label, achieved, status: achieved ? 'done' : outcome === 'defeat' ? 'failed' : 'active', detail: secured ? `${terrainLabel} 안전` : `${terrainLabel} 교전 중`, rewardGold: objective.rewardGold }; } const maxTurn = objective.maxTurn ?? quickVictoryTurnLimit; const inTime = this.turnNumber <= maxTurn; const achieved = outcome ? outcome === 'victory' && inTime : inTime; return { id: objective.id, label: objective.label, achieved, status: achieved ? (outcome === 'victory' ? 'done' : 'active') : 'failed', detail: inTime ? (outcome === 'victory' ? `${this.turnNumber}/${maxTurn}턴 달성` : `${this.turnNumber}/${maxTurn}턴 진행 중`) : `${this.turnNumber}/${maxTurn}턴 초과`, rewardGold: objective.rewardGold }; } private resultGold(outcome: BattleOutcome) { if (outcome !== 'victory') { return 0; } return baseVictoryGold + this.resultObjectives(outcome).reduce((total, objective) => total + (objective.achieved ? objective.rewardGold : 0), 0); } private resultMvp() { return battleUnits .filter((unit) => unit.faction === 'ally') .map((unit) => { const stats = this.statsFor(unit.id); const score = stats.damageDealt + stats.defeats * 18 + Math.floor(stats.support * 0.7) + Math.floor(stats.damageTaken * 0.25) + (unit.hp > 0 ? 8 : 0); return { unit, stats, score }; }) .sort((a, b) => b.score - a.score || b.stats.defeats - a.stats.defeats || b.stats.damageDealt - a.stats.damageDealt)[0]; } private publishFirstBattleReport(outcome: BattleOutcome) { const objectives = this.resultObjectives(outcome); const mvp = this.resultMvp(); const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length; setFirstBattleReport({ battleId: battleScenario.id, battleTitle: battleScenario.title, outcome, turnNumber: this.turnNumber, rewardGold: this.resultGold(outcome), defeatedEnemies, totalEnemies, objectives: objectives.map((objective) => ({ ...objective })), units: battleUnits.map(cloneUnitData), bonds: Array.from(this.bondStates.values()).map((bond) => ({ ...bond, unitIds: [...bond.unitIds] as [string, string] })), mvp: mvp ? { unitId: mvp.unit.id, name: mvp.unit.name, damageDealt: mvp.stats.damageDealt, defeats: mvp.stats.defeats } : undefined, itemRewards: outcome === 'victory' ? [...battleScenario.itemRewards] : [], completedCampDialogues: [], completedCampVisits: [], createdAt: new Date().toISOString() }); } private renderResultObjectivePanel(objectives: BattleObjectiveResult[], x: number, y: number, width: number, depth: number) { const height = 132; const bg = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x101820, 0.9)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, palette.blue, 0.56); const title = this.trackResultObject(this.add.text(x + 14, y + 10, '목표 정산', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#f2e3bf', fontStyle: '700' })); title.setDepth(depth + 1); objectives.forEach((objective, index) => { const rowY = y + 42 + index * 21; const mark = objective.achieved ? '완료' : objective.status === 'failed' ? '미획득' : '보너스'; const color = objective.achieved ? '#a8ffd0' : '#aeb7c2'; const line = this.trackResultObject(this.add.text(x + 16, rowY, `${mark} ${objective.label} · ${objective.detail}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color, wordWrap: { width: width - 98, useAdvancedWrap: true } })); line.setDepth(depth + 1); const reward = this.trackResultObject(this.add.text(x + width - 16, rowY, objective.achieved ? `+${objective.rewardGold}` : '-', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: objective.achieved ? '#d8b15f' : '#6f7a84', fontStyle: '700' })); reward.setOrigin(1, 0); reward.setDepth(depth + 1); }); } private renderResultRewardPanel(outcome: BattleOutcome, objectives: BattleObjectiveResult[], x: number, y: number, width: number, depth: number) { const height = 132; const bg = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x101820, 0.9)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, palette.gold, 0.56); const title = this.trackResultObject(this.add.text(x + 14, y + 10, '보상 정산', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#f2e3bf', fontStyle: '700' })); title.setDepth(depth + 1); const mvp = this.resultMvp(); const bondSummary = Array.from(this.bondStates.values()) .filter((bond) => bond.battleExp > 0) .map((bond) => `${this.unitName(bond.unitIds[0])}·${this.unitName(bond.unitIds[1])} +${bond.battleExp}`) .join(' '); const achievedCount = objectives.filter((objective) => objective.achieved).length; const lines = [ outcome === 'victory' ? `군자금 ${this.resultGold(outcome)} 목표 보상 ${achievedCount}/${objectives.length}` : '패배: 보상 없음', mvp ? `MVP ${mvp.unit.name}: 피해 ${mvp.stats.damageDealt}, 격파 ${mvp.stats.defeats}` : 'MVP 없음', bondSummary ? `공명 성장 ${bondSummary}` : '공명 성장 없음', outcome === 'victory' ? `전리품: ${battleScenario.itemRewards.join(', ')}` : '재도전하면 목표 보상을 다시 노릴 수 있습니다.' ]; lines.forEach((line, index) => { const text = this.trackResultObject(this.add.text(x + 16, y + 42 + index * 21, line, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: index === 0 ? '#d8b15f' : '#d4dce6', fontStyle: index === 0 ? '700' : '400', wordWrap: { width: width - 32, useAdvancedWrap: true } })); text.setDepth(depth + 1); }); } private renderResultBondStrip(x: number, y: number, width: number, depth: number) { const bonds = Array.from(this.bondStates.values()); const bg = this.trackResultObject(this.add.rectangle(x, y, width, 30, 0x101820, 0.9)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, palette.gold, 0.46); const title = this.trackResultObject(this.add.text(x + 12, y + 7, '공명 성장', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#f2e3bf', fontStyle: '700' })); title.setDepth(depth + 1); if (bonds.length === 0) { const empty = this.trackResultObject(this.add.text(x + 110, y + 7, '공명 없음', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#9fb0bf' })); empty.setDepth(depth + 1); return; } const startX = x + 104; const columnWidth = Math.floor((width - 118) / bonds.length); bonds.forEach((bond, index) => { const initial = initialBattleBonds.find((candidate) => candidate.id === bond.id); const previousLevel = initial?.level ?? bond.level; const previousExp = initial?.exp ?? bond.exp; const gained = initial ? Math.max(0, this.characterProgressScore(bond.level, bond.exp) - this.characterProgressScore(initial.level, initial.exp)) : bond.battleExp; const colX = startX + index * columnWidth; const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId)); const label = this.trackResultObject(this.add.text(colX, y + 6, `${first}·${second}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#d4dce6', fontStyle: '700' })); label.setDepth(depth + 1); const amount = this.trackResultObject(this.add.text(colX + 86, y + 6, gained > 0 ? `+${gained}` : '+0', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: gained > 0 ? '#d8b15f' : '#6f7a84', fontStyle: '700' })); amount.setDepth(depth + 1); const fill = this.renderResultProgressGauge(colX + 122, y + 11, 82, 7, previousExp / 100, 0x83d6ff, depth + 1); const valueText = this.trackResultObject(this.add.text(colX + 210, y + 4, `${previousExp}/100`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#f0e4c8', fontStyle: '700' })); valueText.setOrigin(1, 0); valueText.setDepth(depth + 1); const levelText = this.trackResultObject(this.add.text(colX + columnWidth - 8, y + 6, `Lv ${previousLevel}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#f4dfad', fontStyle: '700' })); levelText.setOrigin(1, 0); levelText.setDepth(depth + 1); const eventText = this.trackResultObject(this.add.text(colX + 164, y + 2, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#fff2b8', fontStyle: '700', stroke: '#05070a', strokeThickness: 3 })); eventText.setOrigin(0.5); eventText.setDepth(depth + 4); eventText.setAlpha(0); if (gained > 0 || bond.level > previousLevel) { this.queueResultGaugeAnimation({ fill, valueText, levelText, eventText, icon: 'leadership', levelPrefix: 'Lv ', previousLevel, previousExp, previousNext: 100, level: bond.level, exp: bond.exp, next: 100, leveled: bond.level > previousLevel, flashArea: { x: colX - 6, y: y + 3, width: Math.max(220, columnWidth - 12), height: 24, depth: depth + 3 } }); } }); } private renderResultMetric(label: string, value: string, x: number, y: number, width: number, depth: number) { const bg = this.trackResultObject(this.add.rectangle(x, y, width, 48, 0x16212d, 0.94)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, 0x53606c, 0.6); const labelText = this.trackResultObject(this.add.text(x + 12, y + 8, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#9fb0bf' })); labelText.setDepth(depth + 1); const valueText = this.trackResultObject(this.add.text(x + width - 12, y + 22, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#f2e3bf', fontStyle: '700' })); valueText.setOrigin(1, 0); valueText.setDepth(depth + 1); } private renderResultUnitRow(unit: UnitData, x: number, y: number, width: number, depth: number) { const alive = unit.hp > 0; const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id); const bg = this.trackResultObject(this.add.rectangle(x, y, width, 68, 0x101820, alive ? 0.94 : 0.66)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, alive ? palette.blue : 0x646464, alive ? 0.62 : 0.5); const name = this.trackResultObject(this.add.text(x + 14, y + 8, `${unit.name} Lv ${unit.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: alive ? '#e9f0f8' : '#9a9a9a', fontStyle: '700' })); name.setDepth(depth + 1); const classText = this.trackResultObject(this.add.text(x + 150, y + 10, getUnitClass(unit.classKey).name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: alive ? '#9fb0bf' : '#777777' })); classText.setDepth(depth + 1); const hpText = this.trackResultObject(this.add.text(x + 270, y + 9, `HP ${unit.hp}/${unit.maxHp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: alive ? '#f0e4c8' : '#8c8c8c', fontStyle: '700' })); hpText.setDepth(depth + 1); const previousLevel = initial?.level ?? unit.level; const previousExp = initial?.exp ?? unit.exp; const characterGained = initial ? Math.max(0, this.characterProgressScore(unit.level, unit.exp) - this.characterProgressScore(initial.level, initial.exp)) : 0; const expText = this.trackResultObject(this.add.text(x + 392, y + 9, this.characterResultText(unit), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d8b15f', fontStyle: '700' })); expText.setDepth(depth + 1); const characterFill = this.renderResultProgressGauge(x + 392, y + 36, 158, 8, previousExp / 100, palette.gold, depth + 1); const characterValueText = this.trackResultObject(this.add.text(x + 564, y + 28, `${previousExp} / 100`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#f0e4c8', fontStyle: '700' })); characterValueText.setOrigin(1, 0); characterValueText.setDepth(depth + 1); const characterLevelText = this.trackResultObject(this.add.text(x + 570, y + 9, `Lv ${previousLevel}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#f4dfad', fontStyle: '700' })); characterLevelText.setDepth(depth + 1); const characterEventText = this.trackResultObject(this.add.text(x + 476, y + 31, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#fff2b8', fontStyle: '700', stroke: '#05070a', strokeThickness: 3 })); characterEventText.setOrigin(0.5); characterEventText.setDepth(depth + 4); characterEventText.setAlpha(0); if (characterGained > 0 || unit.level > previousLevel) { const statGains = this.characterLevelUpStatGains(unit, unit.level - previousLevel); this.queueResultGaugeAnimation({ fill: characterFill, valueText: characterValueText, levelText: characterLevelText, eventText: characterEventText, unit, icon: statGains[0]?.icon ?? 'hp', levelPrefix: 'Lv ', previousLevel, previousExp, previousNext: 100, level: unit.level, exp: unit.exp, next: 100, leveled: unit.level > previousLevel, statGains, flashArea: { x, y, width, height: 68, depth: depth + 3 } }); } equipmentSlots.forEach((slot, index) => { const state = unit.equipment[slot]; const initialState = initial?.equipment[slot]; const previousEquipmentLevel = initialState?.level ?? state.level; const previousEquipmentExp = initialState?.exp ?? state.exp; const previousNext = equipmentExpToNext(previousEquipmentLevel); const next = equipmentExpToNext(state.level); const gained = initialState ? Math.max(0, this.equipmentProgressScore(state.level, state.exp) - this.equipmentProgressScore(initialState.level, initialState.exp)) : 0; const color = slot === 'weapon' ? 0xffc45f : slot === 'armor' ? 0x77b7ff : 0xb7df7a; const slotX = x + 632 + index * 96; const label = this.trackResultObject(this.add.text(slotX, y + 9, `${equipmentSlotLabels[slot]} Lv${previousEquipmentLevel}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: alive ? '#9fb0bf' : '#777777', fontStyle: '700' })); label.setDepth(depth + 1); const valueText = this.trackResultObject(this.add.text(slotX + 38, y + 22, `${previousEquipmentExp}/${previousNext}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: alive ? '#f0e4c8' : '#7e858d', fontStyle: '700' })); valueText.setOrigin(0.5, 0); valueText.setDepth(depth + 1); const fill = this.renderResultProgressGauge(slotX, y + 38, 76, 7, previousEquipmentExp / previousNext, color, depth + 1); const eventText = this.trackResultObject(this.add.text(slotX + 38, y + 31, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#fff2b8', fontStyle: '700', stroke: '#05070a', strokeThickness: 3 })); eventText.setOrigin(0.5); eventText.setDepth(depth + 4); eventText.setAlpha(0); if (gained > 0 || state.level > previousEquipmentLevel) { this.queueResultGaugeAnimation({ fill, valueText, levelText: label, eventText, icon: slot === 'weapon' ? 'mastery' : slot === 'armor' ? 'armor' : 'accessory', levelPrefix: `${equipmentSlotLabels[slot]} Lv`, previousLevel: previousEquipmentLevel, previousExp: previousEquipmentExp, previousNext, level: state.level, exp: state.exp, next, leveled: state.level > previousEquipmentLevel, flashArea: { x: slotX - 6, y: y + 5, width: 88, height: 44, depth: depth + 3 } }); } }); const equipment = this.trackResultObject(this.add.text(x + 14, y + 48, this.equipmentResultText(unit), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: alive ? '#c8d2dd' : '#7e858d', wordWrap: { width: width - 28, useAdvancedWrap: true } })); equipment.setDepth(depth + 1); } private renderResultProgressGauge(x: number, y: number, width: number, height: number, ratio: number, color: number, depth: number) { const track = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x05090e, 0.9)); track.setOrigin(0); track.setDepth(depth); track.setStrokeStyle(1, 0x40515e, 0.5); const fill = this.trackResultObject(this.add.rectangle(x + 1, y + 1, Math.max(2, width - 2), Math.max(2, height - 2), color, 0.96)); fill.setOrigin(0); fill.setDepth(depth + 1); fill.setScale(Phaser.Math.Clamp(ratio, 0, 1), 1); return fill; } private characterResultText(unit: UnitData) { const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id); const gained = initial ? Math.max(0, this.characterProgressScore(unit.level, unit.exp) - this.characterProgressScore(initial.level, initial.exp)) : 0; const levelDelta = initial ? unit.level - initial.level : 0; const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : ''; return `경험 +${gained} (${unit.exp}/100)${levelText}`; } private equipmentResultText(unit: UnitData) { const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id); return equipmentSlots .map((slot) => { const state = unit.equipment[slot]; const item = getItem(state.itemId); const initialState = initial?.equipment[slot]; const gained = initialState ? Math.max(0, this.equipmentProgressScore(state.level, state.exp) - this.equipmentProgressScore(initialState.level, initialState.exp)) : 0; const levelDelta = initialState ? state.level - initialState.level : 0; const levelText = levelDelta > 0 ? `(+${levelDelta})` : ''; return `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}${levelText} +${gained}`; }) .join(' '); } private characterProgressScore(level: number, exp: number) { return (level - 1) * 100 + exp; } private equipmentProgressScore(level: number, exp: number) { let score = exp; for (let currentLevel = 1; currentLevel < level; currentLevel += 1) { score += equipmentExpToNext(currentLevel); } return score; } private renderResultBondRow(bond: BondState, x: number, y: number, width: number, depth: number) { const bg = this.trackResultObject(this.add.rectangle(x, y, width, 58, 0x101820, 0.88)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, bond.battleExp > 0 ? palette.gold : 0x53606c, bond.battleExp > 0 ? 0.72 : 0.5); const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId)); const title = this.trackResultObject(this.add.text(x + 12, y + 8, `${first} · ${second}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#e8dfca', fontStyle: '700' })); title.setDepth(depth + 1); const value = this.trackResultObject(this.add.text(x + 12, y + 31, this.bondResultText(bond), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: bond.battleExp > 0 ? '#d8b15f' : '#9fb0bf' })); value.setDepth(depth + 1); } private bondResultText(bond: BondState) { const initial = initialBattleBonds.find((candidate) => candidate.id === bond.id); const gained = initial ? Math.max(0, this.characterProgressScore(bond.level, bond.exp) - this.characterProgressScore(initial.level, initial.exp)) : bond.battleExp; const levelDelta = initial ? bond.level - initial.level : 0; const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : ''; return `공명 ${bond.level} 경험 +${gained}${levelText} 전투 +${bond.battleExp}`; } private queueResultGaugeAnimation(animation: ResultGaugeAnimation) { this.resultGaugeAnimations.push(animation); } private async playResultSettlementAnimation(token: number) { await this.delay(360); if (token !== this.resultAnimationToken) { return; } if (this.resultGaugeAnimations.length === 0) { this.resultSettlementText?.setText('성장 변화 없음'); return; } for (let index = 0; index < this.resultGaugeAnimations.length; index += 1) { if (token !== this.resultAnimationToken) { return; } this.resultSettlementText?.setText(`성장 정산 ${index + 1} / ${this.resultGaugeAnimations.length}`); await this.animateResultGauge(this.resultGaugeAnimations[index], 280); await this.delay(44); } if (token === this.resultAnimationToken) { this.resultSettlementText?.setText('성장 정산 완료'); } } private async animateResultGauge(animation: ResultGaugeAnimation, duration: number) { if (animation.leveled) { await this.animateGrowthGauge( animation.fill, animation.valueText, animation.previousExp / animation.previousNext, 1, animation.previousExp, animation.previousNext, animation.previousNext, false, duration ); animation.levelText.setText(`${animation.levelPrefix}${animation.level}`); animation.fill.setScale(0, 1); animation.valueText.setText(`0 / ${animation.next}`); this.showGrowthEventText(animation.eventText, this.levelUpEventLabel(animation)); this.flashResultGauge(animation); soundDirector.playLevelUp(); await this.playResultLevelUpCelebration(animation); await this.animateGrowthGauge(animation.fill, animation.valueText, 0, animation.exp / animation.next, 0, animation.exp, animation.next, false, duration); return; } await this.animateGrowthGauge( animation.fill, animation.valueText, animation.previousExp / animation.previousNext, animation.exp / animation.next, animation.previousExp, animation.exp, animation.next, false, duration ); animation.levelText.setText(`${animation.levelPrefix}${animation.level}`); } private flashResultGauge(animation: ResultGaugeAnimation) { const { x, y, width, height, depth } = animation.flashArea; const flash = this.trackResultObject(this.add.rectangle(x + width / 2, y + height / 2, width, height, 0xffdf7b, 0.16)); flash.setDepth(depth); this.tweens.add({ targets: flash, alpha: 0, x: flash.x + 10, duration: 520, ease: 'Sine.easeOut' }); this.flashTextColor(animation.levelText, '#fff2b8'); this.flashTextColor(animation.valueText, '#fff2b8'); } private async playResultLevelUpCelebration(animation: ResultGaugeAnimation) { const { x, y, width, height, depth } = animation.flashArea; const spriteX = Math.min(x + width - 52, animation.valueText.x + 40); const spriteY = y + height / 2 + 2; this.createResultLevelUpBurst(spriteX, spriteY, depth + 4); const banner = this.trackResultObject(this.add.text(spriteX, spriteY - 44, 'LEVEL UP', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#fff2b8', fontStyle: '700', stroke: '#2b1606', strokeThickness: 3 })); banner.setOrigin(0.5); banner.setDepth(depth + 7); let sprite: Phaser.GameObjects.Sprite | undefined; if (animation.unit) { sprite = this.trackResultObject( this.add.sprite(spriteX, spriteY + 2, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit))) ); sprite.setDisplaySize(70, 70); sprite.setDepth(depth + 6); } await this.playResultLevelUpSpotlight(animation); if (sprite && animation.unit) { await this.playUnitCelebrationFrames(sprite, animation.unit); } } private levelUpEventLabel(animation: ResultGaugeAnimation) { const stats = this.statGainLine(animation.statGains); return stats ? `Lv ${animation.level} 달성! ${stats}` : `Lv ${animation.level} 달성!`; } private async playResultLevelUpSpotlight(animation: ResultGaugeAnimation) { const stats = this.statGainLine(animation.statGains); const depth = animation.flashArea.depth + 12; const container = this.trackResultObject(this.add.container(this.scale.width / 2, 146)); container.setDepth(depth); container.setAlpha(0); container.setScale(0.9); const width = 520; const height = 128; const visualX = -width / 2 + 60; const contentX = -width / 2 + 122; const bg = this.add.rectangle(0, 0, width, height, 0x211624, 0.98); bg.setStrokeStyle(3, palette.gold, 0.96); const visualFrame = this.add.rectangle(visualX, -2, 66, 66, 0x0b1118, 0.92); visualFrame.setStrokeStyle(1, palette.gold, 0.72); const burst = this.add.star(visualX, -10, 8, 14, 42, 0xffdf7b, 0.78); const title = this.add.text(contentX, -48, animation.unit ? `${animation.unit.name} LEVEL UP` : 'LEVEL UP', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '27px', color: '#fff2b8', fontStyle: '700', stroke: '#2b1606', strokeThickness: 5 }); const levelText = this.add.text(contentX, -12, `${animation.levelPrefix}${animation.previousLevel} → ${animation.levelPrefix}${animation.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#f4dfad', fontStyle: '700' }); const statText = this.add.text(contentX, 14, stats ? '능력 상승' : '숙련 효과 강화', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: stats ? '#a8ffd0' : '#d8b15f', fontStyle: '700', stroke: '#05070a', strokeThickness: 3 }); container.add([bg, visualFrame, burst, title, levelText, statText]); if (animation.statGains) { this.addStatGainChipsToContainer(container, animation.statGains, contentX, 46, width - 132); } if (animation.unit) { const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit))); sprite.setDisplaySize(82, 82); container.add(sprite); void this.playUnitActionFrames(sprite, animation.unit, 'south', this.celebrationPose(animation.unit), 105, 2); } else { const icon = this.createBattleUiIcon(visualX, -2, animation.icon ?? 'mastery', 54); container.add(icon); } this.tweens.add({ targets: burst, angle: 240, scale: 1.34, alpha: 0.16, duration: 760, ease: 'Sine.easeOut' }); this.tweens.add({ targets: container, alpha: 1, scale: 1.04, duration: 180, ease: 'Back.easeOut' }); await this.delay(760); this.tweens.add({ targets: container, alpha: 0, y: container.y - 18, duration: 240, ease: 'Sine.easeIn', onComplete: () => container.destroy() }); await this.delay(260); } private addStatGainChipsToContainer( container: Phaser.GameObjects.Container, gains: LevelUpStatGain[], x: number, y: number, maxWidth: number ) { const visibleGains = gains.filter((gain) => gain.amount > 0).slice(0, 5); let cursorX = x; visibleGains.forEach((gain) => { const chipWidth = Phaser.Math.Clamp(gain.label.length * 11 + 62, 86, 116); if (cursorX + chipWidth > x + maxWidth) { return; } const bg = this.add.rectangle(cursorX, y, chipWidth, 34, 0x0b1118, 0.94); bg.setOrigin(0); bg.setStrokeStyle(1, 0x53606c, 0.62); const icon = this.createBattleUiIcon(cursorX + 18, y + 17, gain.icon, 28); const text = this.add.text(cursorX + 36, y + 7, `+${gain.amount}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#a8ffd0', fontStyle: '700' }); const label = this.add.text(cursorX + 65, y + 9, gain.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#d4dce6' }); container.add([bg, icon, text, label]); cursorX += chipWidth + 6; }); } private createResultLevelUpBurst(x: number, y: number, depth: number) { const ring = this.trackResultObject(this.add.circle(x, y + 2, 33)); ring.setStrokeStyle(3, 0xffdf7b, 0.84); ring.setDepth(depth); const halo = this.trackResultObject(this.add.circle(x, y + 4, 22, 0xffdf7b, 0.12)); halo.setDepth(depth - 1); const weaponFlash = this.trackResultObject(this.add.star(x + 22, y - 28, 5, 4, 15, 0xfff2b0, 0.92)); weaponFlash.setDepth(depth + 2); this.tweens.add({ targets: ring, angle: 180, alpha: 0, duration: 680, ease: 'Sine.easeOut' }); this.tweens.add({ targets: halo, alpha: 0.02, duration: 680, ease: 'Sine.easeOut' }); this.tweens.add({ targets: weaponFlash, y: weaponFlash.y - 12, angle: 180, alpha: 0, duration: 620, ease: 'Sine.easeOut' }); } private flashTextColor(text: Phaser.GameObjects.Text, color: string, duration = 360) { const previousColor = typeof text.style.color === 'string' ? text.style.color : '#f0e4c8'; text.setColor(color); this.time.delayedCall(duration, () => { if (text.active) { text.setColor(previousColor); } }); } private playUnitCelebrationFrames(sprite: Phaser.GameObjects.Sprite, unit: UnitData) { const baseY = sprite.y; const baseX = sprite.x; const baseAngle = sprite.angle; const pose = this.celebrationPose(unit); const frameCount = unitActionFrameCounts[pose]; const frames = Array.from({ length: frameCount * 2 }, (_, index) => index % frameCount); return new Promise((resolve) => { frames.forEach((frame, index) => { this.time.delayedCall(index * 105, () => { if (!sprite.active) { return; } this.setUnitActionFrame(sprite, unit, 'south', pose, frame); sprite.setX(baseX + (index % 2 === 0 ? 0 : 1)); sprite.setY(baseY + levelUpCelebrationStepOffsets[index % levelUpCelebrationStepOffsets.length]); sprite.setAngle(baseAngle); }); }); this.time.delayedCall(frames.length * 105 + 80, () => { if (sprite.active) { sprite.setX(baseX); sprite.setY(baseY); sprite.setAngle(baseAngle); this.setUnitActionFrame(sprite, unit, 'south', pose, 0); } resolve(); }); }); } private addResultButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) { const bg = this.trackResultObject(this.add.rectangle(x, y, width, 34, 0x1a2630, 0.96)); bg.setDepth(depth); bg.setStrokeStyle(1, label === '다시 하기' ? palette.blue : palette.gold, 0.78); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96)); bg.on('pointerdown', action); const text = this.trackResultObject(this.add.text(x, y, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f2e3bf', fontStyle: '700' })); text.setOrigin(0.5); text.setDepth(depth + 1); text.setInteractive({ useHandCursor: true }); text.on('pointerdown', action); } private trackResultObject(object: T) { this.resultObjects.push(object); return object; } private attackableTargets(attacker: UnitData) { return this.damageableTargets(attacker, 'attack'); } private damageableTargets(attacker: UnitData, action: DamageCommand, usable?: BattleUsable) { return battleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action, usable)); } private canAttack(attacker: UnitData, target: UnitData) { return this.canUseDamageCommand(attacker, target, 'attack'); } private canUseDamageCommand(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) { if (attacker.faction === target.faction || target.hp <= 0) { return false; } return this.tileDistance(attacker, target) <= this.actionRange(attacker, action, usable); } private attackRange(unit: UnitData) { return attackRangeByClass[unit.classKey] ?? 1; } private actionRange(unit: UnitData, action: DamageCommand, usable?: BattleUsable) { if (usable) { return usable.range; } if (action === 'attack') { return this.attackRange(unit); } return unit.classKey === 'archer' ? 3 : 2; } private supportTargets(user: UnitData, usable: BattleUsable) { return battleUnits.filter((target) => this.canUseSupportCommand(user, target, usable)); } private canUseSupportCommand(user: UnitData, target: UnitData, usable: BattleUsable) { if (target.hp <= 0) { return false; } if (usable.target === 'self' && target.id !== user.id) { return false; } if (usable.target === 'ally' && target.faction !== user.faction) { return false; } if (usable.target === 'enemy' && target.faction === user.faction) { return false; } if (usable.effect === 'heal' && target.hp >= target.maxHp) { return false; } return this.tileDistance(user, target) <= usable.range; } private combatPreview(attacker: UnitData, defender: UnitData, action: DamageCommand, usable?: BattleUsable, isCounter = false): CombatPreview { const terrain = battleMap.terrain[defender.y][defender.x]; const terrainRule = getTerrainRule(terrain); const attackPower = this.actionPower(attacker, action, usable); const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus); const bondBonus = this.activeBondCombatBonus(attacker, defender, action, isCounter); const equipmentEffect = this.combatEquipmentEffect(attacker, defender, action, usable, bondBonus); const totalDamageBonus = bondBonus.damageBonus + equipmentEffect.damageBonus - equipmentEffect.damageReduction; const baseDamage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + totalDamageBonus / 100)), 3, defender.maxHp); const damage = isCounter ? Phaser.Math.Clamp(Math.round(baseDamage * 0.45), 1, defender.maxHp) : baseDamage; 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( 88 + Math.floor((attacker.stats.agility - defender.stats.agility) / 3) + Math.floor((attacker.stats.luck - defender.stats.luck) / 8) - terrainHitPenalty + (usable?.accuracyBonus ?? 0) + equipmentEffect.hitBonus + this.hitBuff(attacker), 45, 98 ); const criticalRate = Phaser.Math.Clamp( 5 + Math.floor((attacker.stats.luck - defender.stats.luck) / 7) + 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 ); return { action, usable, attacker, defender, damage, bondDamageBonus: bondBonus.damageBonus, bondLabel: bondBonus.label, bondPartnerIds: bondBonus.partnerIds, equipmentDamageBonus: equipmentEffect.damageBonus, equipmentDamageReduction: equipmentEffect.damageReduction, equipmentHitBonus: equipmentEffect.hitBonus, equipmentCriticalBonus: equipmentEffect.criticalBonus, equipmentEffectLabels: equipmentEffect.labels, hitRate, criticalRate, terrainDefenseBonus: terrainRule.defenseBonus, terrainHitPenalty, counterAvailable: this.canCounterAttack(defender, attacker, action), terrainLabel: terrainRule.label, matchupLabel: this.combatMatchupLabel(attacker, defender, action) }; } private resolveAttack(attacker: UnitData, defender: UnitData): CombatResult { return this.resolveCombatAction(attacker, defender, 'attack'); } private statsFor(unitId: string) { const existing = this.battleStats.get(unitId); if (existing) { return existing; } const created = this.emptyBattleStats(); this.battleStats.set(unitId, created); return created; } private recordCombatStats(attacker: UnitData, defender: UnitData, damage: number, defeated: boolean) { const attackerStats = this.statsFor(attacker.id); attackerStats.actions += 1; attackerStats.damageDealt += damage; if (defeated) { attackerStats.defeats += 1; } const defenderStats = this.statsFor(defender.id); defenderStats.damageTaken += damage; } private recordSupportStats(user: UnitData, amount: number) { const stats = this.statsFor(user.id); stats.actions += 1; stats.support += amount; } private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false, usable?: BattleUsable): CombatResult { const preview = this.combatPreview(attacker, defender, action, usable, isCounter); const previousDefenderHp = defender.hp; const hit = this.rollPercent(preview.hitRate); 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', 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); defender.hp = Math.max(0, defender.hp - damage); this.recordCombatStats(attacker, defender, damage, defender.hp <= 0 && previousDefenderHp > 0); this.faceUnitToward(attacker, defender); if (hit) { this.flashDamage(defender, damage, critical); } else { this.flashMiss(defender); } if (defender.hp <= 0) { this.applyDefeatedStyle(defender); } else { this.syncUnitMotion(defender); } this.updateMiniMap(); return { ...preview, expectedDamage: preview.damage, damage, previousDefenderHp, hit, critical, isCounter, defeated: defender.hp <= 0, characterGrowth, attackerGrowth, defenderGrowth: armorGrowth, bondExp, bondGrowth }; } private resolveCounterAttack(result: CombatResult) { if (result.defender.hp <= 0) { return undefined; } if (!this.canCounterAttack(result.defender, result.attacker, result.action)) { return undefined; } return this.resolveCombatAction(result.defender, result.attacker, 'attack', true); } private canCounterAttack(defender: UnitData, attacker: UnitData, incomingAction: DamageCommand) { if (incomingAction !== 'attack' || defender.hp <= 0 || attacker.hp <= 0) { return false; } const distance = this.tileDistance(defender, attacker); if (defender.classKey === 'archer' && distance <= 1) { return false; } return this.canAttack(defender, attacker); } private rollPercent(rate: number) { return Phaser.Math.Between(1, 100) <= Phaser.Math.Clamp(rate, 0, 100); } private resolveSupportAction(user: UnitData, target: UnitData, usable: BattleUsable): SupportResult { const previousTargetHp = target.hp; const healAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable) : 0; let buff: BattleBuffState | undefined; if (healAmount > 0) { target.hp = Math.min(target.maxHp, target.hp + healAmount); this.syncUnitMotion(target); this.flashSupport(target, `+${healAmount}`, '#a8ffd0'); this.updateMiniMap(); } if (usable.effect === 'focus') { buff = { unitId: target.id, label: usable.name, turns: usable.duration ?? 1, attackBonus: usable.attackBonus ?? 0, hitBonus: usable.hitBonus ?? 0, criticalBonus: usable.criticalBonus ?? 0 }; this.battleBuffs.set(target.id, buff); this.flashSupport(target, usable.name, '#ffdf7b'); } const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', this.supportEquipmentExpGain(user, usable)); const characterGrowth = this.awardCharacterExp(user, usable.effect === 'heal' ? 8 : 6); this.recordSupportStats(user, healAmount); return { usable, user, target, previousTargetHp, healAmount, buff, characterGrowth, equipmentGrowth }; } 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); 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; const flatAmount = usable.power + bonus + equipmentBonus; const percentFloor = Math.ceil(target.maxHp * (usable.command === 'item' ? 0.24 : 0.18)); return Math.min(target.maxHp - target.hp, Math.max(1, flatAmount, percentFloor)); } private combatEquipmentEffect( attacker: UnitData, defender: UnitData, action: DamageCommand, usable?: BattleUsable, bondBonus: BondCombatBonus = this.activeBondCombatBonus(attacker, defender, action) ) { 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); 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) { if (usable?.effect === 'damage') { const statBonus = usable.command === 'strategy' ? Math.floor(unit.stats.intelligence / 5) : Math.floor(unit.stats.luck / 8); const equipmentBonus = usable.command === 'strategy' ? this.equipmentStrategyBonus(unit) : Math.floor(this.equipmentAttackBonus(unit) / 2); return usable.power + statBonus + equipmentBonus + this.attackBuff(unit); } if (action === 'strategy') { return 10 + Math.floor(unit.stats.intelligence / 5) + this.equipmentStrategyBonus(unit) + this.attackBuff(unit); } if (action === 'item') { return 9 + Math.floor(unit.stats.luck / 8) + Math.floor(this.equipmentAttackBonus(unit) / 2) + this.attackBuff(unit); } return unit.attack + this.equipmentAttackBonus(unit) + Math.floor(unit.stats.might / 12) + this.attackBuff(unit); } private actionDefense(unit: UnitData, action: DamageCommand, terrainDefenseBonus: number) { if (action === 'strategy') { return Math.floor(unit.stats.intelligence / 10) + Math.floor(terrainDefenseBonus / 4); } if (action === 'item') { return Math.floor(unit.stats.leadership / 22) + Math.floor(terrainDefenseBonus / 5); } return this.equipmentDefenseBonus(unit) + Math.floor(unit.stats.leadership / 18) + Math.floor(terrainDefenseBonus / 3); } private combatMatchupLabel(attacker: UnitData, defender: UnitData, action: DamageCommand) { if (action === 'strategy') { const gap = attacker.stats.intelligence - defender.stats.intelligence; return gap >= 10 ? '책략 우위' : gap <= -10 ? '책략 불리' : '책략 균형'; } if (action === 'item') { const gap = attacker.stats.luck - defender.stats.luck; return gap >= 12 ? '도구 우위' : gap <= -12 ? '도구 불리' : '도구 균형'; } if (this.hasClassAdvantage(attacker, defender)) { return '상성 우위'; } if (this.hasClassAdvantage(defender, attacker)) { return '상성 불리'; } return '상성 보통'; } private hasClassAdvantage(attacker: UnitData, defender: UnitData) { const distance = this.tileDistance(attacker, defender); if (attacker.classKey === 'spearman' && defender.classKey === 'cavalry') { return true; } if (attacker.classKey === 'cavalry' && (defender.classKey === 'archer' || defender.classKey === 'strategist' || defender.classKey === 'quartermaster')) { return true; } if (attacker.classKey === 'archer' && distance > 1 && (defender.classKey === 'infantry' || defender.classKey === 'spearman' || defender.classKey === 'yellowTurban')) { return true; } if ((attacker.classKey === 'strategist' || attacker.classKey === 'quartermaster') && defender.stats.intelligence + 12 <= attacker.stats.intelligence) { return true; } return false; } private attackerEquipmentExpGain(unit: UnitData, action: DamageCommand) { if (action === 'attack') { return this.weaponExpGain(unit); } return 5 + (getItem(unit.equipment.accessory.itemId).rank === 'treasure' ? 2 : 0); } private characterExpGain(result: CombatPreview, damage: number, hit: boolean, critical: boolean) { if (!hit) { return 2; } const levelGap = result.defender.level - result.attacker.level; const actionBonus = result.action === 'attack' ? 10 : 8; const defeatBonus = damage >= result.defender.hp ? 12 : 0; const criticalBonus = critical ? 2 : 0; return Phaser.Math.Clamp(actionBonus + levelGap * 2 + defeatBonus + criticalBonus, 4, 30); } private formatCombatResult(result: CombatResult) { const headline = this.combatResultHeadline(result); const resolution = this.combatResolutionLine(result); const summaryBondBonus = result.bondDamageBonus > 0 && result.bondLabel ? `\n공명 효과 ${result.bondLabel}: 피해 +${result.bondDamageBonus}%` : ''; const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : ''; const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; return `${headline}\n${resolution}${summaryCounter}${summaryBondBonus}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`; } private combatActionName(result: CombatResult) { return result.isCounter ? '반격' : result.usable?.name ?? commandLabels[result.action]; } private combatDamageValue(result: CombatResult) { return result.hit ? result.damage : 0; } private combatResultHeadline(result: CombatResult) { const damage = this.combatDamageValue(result); const outcome = !result.hit ? result.action === 'strategy' ? '책략 실패' : '회피됨' : result.critical ? `치명 ${damage} 피해` : `${damage} 피해`; const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}→${result.defender.hp}` : ''; const defeated = result.defeated ? ' · 퇴각' : ''; return `${result.attacker.name} ${this.combatActionName(result)} → ${result.defender.name}: ${outcome}${hpLine}${defeated}`; } private combatResolutionLine(result: CombatResult) { const successLabel = result.action === 'strategy' ? '성공' : '명중'; const actual = this.combatDamageValue(result); const verdict = result.hit ? `${successLabel} ${result.hitRate}%` : `${successLabel} 실패(${result.hitRate}%)`; const critical = result.critical ? '치명 발생' : `치명 ${result.criticalRate}%`; return `판정: ${verdict} · ${critical} · 예상 ${result.expectedDamage}/실제 ${actual}`; } private formatCombatOutcome(result: CombatResult) { if (!result.hit) { return `${result.defender.name}이 공격을 회피했습니다.`; } const summaryCritical = result.critical ? '치명타! ' : ''; return `${summaryCritical}${result.defender.name}에게 ${result.damage} 피해`; } private formatCounterLine(result: CombatResult) { const counterOutcome = result.hit ? `${result.critical ? '치명 ' : ''}${result.damage} 피해` : '회피됨'; const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}→${result.defender.hp}` : ''; const defeated = result.defeated ? ' · 퇴각' : ''; return `반격: ${result.attacker.name} → ${result.defender.name}: ${counterOutcome}${hpLine}${defeated}`; } private combatGrowthLines(result: CombatResult) { const summaryVerdict = result.hit ? `${result.critical ? '치명타 / ' : ''}명중 ${result.hitRate}%` : `회피 / 명중 ${result.hitRate}%`; const summaryLines = [ summaryVerdict, this.formatCharacterGrowth(result.characterGrowth), this.formatEquipmentGrowth(result.attackerGrowth), this.formatEquipmentGrowth(result.defenderGrowth) ]; if (result.bondExp > 0) { summaryLines.push(`공명 경험치 +${result.bondExp}`); } if (result.equipmentEffectLabels.length > 0) { summaryLines.push(`장비 효과: ${result.equipmentEffectLabels.join(', ')}`); } return summaryLines; } private formatSupportResult(result: SupportResult) { const supportEffect = result.usable.effect === 'heal' ? `${result.target.name} 병력 +${result.healAmount} (${result.target.hp}/${result.target.maxHp})` : `${result.target.name} 강화: 공격 +${result.buff?.attackBonus ?? 0} / 명중 +${result.buff?.hitBonus ?? 0} / 치명 +${result.buff?.criticalBonus ?? 0}`; return `${result.user.name} ${result.usable.name}\n${supportEffect}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`; const effect = result.usable.effect === 'heal' ? `${result.target.name} 병력 +${result.healAmount} (${result.target.hp}/${result.target.maxHp})` : `${result.target.name} 강화: 공격 +${result.buff?.attackBonus ?? 0} / 명중 +${result.buff?.hitBonus ?? 0} / 치명 +${result.buff?.criticalBonus ?? 0}`; return `${result.user.name} ${result.usable.name}\n${effect}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`; } private commandResultMessage(unit: UnitData, command: BattleCommand) { if (command === 'attack') { return this.recordAttackIntent(unit); } if (command === 'strategy') { return `${unit.name} 책략 명령 완료\n책략 목록과 MP는 다음 전투 확장 때 붙이겠습니다.`; } if (command === 'item') { return `${unit.name} 도구 명령 완료\n도구 가방은 이후 회복 아이템과 함께 열겠습니다.`; } return `${unit.name} 대기 명령 완료`; } private remainingAllyCount() { return battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length; } private createBondStates() { return new Map( battleBonds.map((bond) => [ bond.id, { ...bond, battleExp: 0 } ]) ); } private createItemStocks() { return new Map( Object.entries(initialItemStocks).map(([unitId, stocks]) => [ unitId, new Map(Object.entries(stocks)) ]) ); } private createEnemyHomeTiles() { return new Map( battleUnits .filter((unit) => unit.faction === 'enemy') .map((unit) => [unit.id, { x: unit.x, y: unit.y }] as const) ); } private createBattleStats() { return new Map(battleUnits.map((unit) => [unit.id, this.emptyBattleStats()] as const)); } private emptyBattleStats(): UnitBattleStats { return { damageDealt: 0, damageTaken: 0, defeats: 0, actions: 0, support: 0 }; } private showOpeningBattleEvent() { this.triggerBattleEvent('opening', '전투 목표', battleScenario.openingObjectiveLines); } private triggerBattleEvent(key: string, title: string, lines: string[]) { if (this.triggeredBattleEvents.has(key) || this.battleOutcome) { return; } this.triggeredBattleEvents.add(key); const message = `${title}\n${lines.join('\n')}`; this.pushBattleLog(message); this.renderSituationPanel(message); this.showBattleEventBanner(title, lines); } private showBattleEventBanner(title: string, lines: string[]) { this.hideBattleEventBanner(); const width = 520; const height = 106; const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2); const top = this.layout.gridY + 18; const depth = 45; const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.94); panel.setOrigin(0); panel.setDepth(depth); panel.setStrokeStyle(2, palette.gold, 0.86); this.battleEventObjects.push(panel); const titleText = this.add.text(left + 20, top + 14, title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f4dfad', fontStyle: '700', stroke: '#05070a', strokeThickness: 3 }); titleText.setDepth(depth + 1); this.battleEventObjects.push(titleText); const body = this.add.text(left + 20, top + 45, lines.join('\n'), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d4dce6', lineSpacing: 4, wordWrap: { width: width - 40, useAdvancedWrap: true } }); body.setDepth(depth + 1); this.battleEventObjects.push(body); const eventObjects = [...this.battleEventObjects]; this.tweens.add({ targets: eventObjects, alpha: 0, delay: 2600, duration: 420, ease: 'Sine.easeIn', onComplete: () => { eventObjects.forEach((object) => object.destroy()); this.battleEventObjects = this.battleEventObjects.filter((object) => !eventObjects.includes(object)); } }); } private hideBattleEventBanner() { this.battleEventObjects.forEach((object) => object.destroy()); this.battleEventObjects = []; } private checkBattleEvents() { const leader = battleUnits.find((unit) => unit.id === leaderUnitId); if (leader && leader.hp > 0 && leader.hp <= Math.ceil(leader.maxHp / 2)) { this.triggerBattleEvent('leader-wavering', '두령 동요', [`${leader.name}의 기세가 꺾였습니다.`, '두령을 몰아붙이면 황건적의 전열이 무너집니다.']); } const endangeredAlly = this.endangeredAlly(); if (endangeredAlly) { this.triggerBattleEvent('ally-danger', '아군 위기', [ `${endangeredAlly.name}의 병력이 크게 줄었습니다.`, '유비가 퇴각하면 패배합니다. 마을과 성채 지형을 이용해 전열을 다시 잡으세요.' ]); } if (this.isAnyAllyNearTerrain('village', 2)) { this.triggerBattleEvent('village-approach', '마을 어귀', [ '마을 주변에 황건적이 몰려 있습니다.', '마을의 적을 몰아내면 승리 보상이 추가됩니다.' ]); } if (!this.hasEnemyThreateningVillage()) { this.triggerBattleEvent('village-secured', '마을 확보', ['마을 주변의 황건적을 몰아냈습니다.', '승리 시 마을 확보 보상이 추가됩니다.']); } if (this.areUnitsDefeated(['rebel-a', 'rebel-b', 'rebel-c'])) { this.triggerBattleEvent('vanguard-broken', '선봉 격파', [ '남쪽 길목의 황건 선봉이 무너졌습니다.', '이제 중앙 마을과 기병의 움직임을 경계하세요.' ]); } if (this.isEnemyClassNearAlly('cavalry', 6)) { this.triggerBattleEvent('cavalry-approach', '기병 접근', [ '황건 기병이 빠르게 압박해옵니다.', '위험 범위를 켜고 숲이나 마을 쪽으로 전선을 잡으세요.' ]); } if (this.turnNumber === 2 && this.activeFaction === 'ally') { this.triggerBattleEvent('enemy-posture', '적 전술 변화', ['기병은 계속 접근하고, 궁병은 자리를 지키며 사격합니다.', '숲과 마을 지형을 이용해 피해를 줄이세요.']); } } private hasEnemyThreateningVillage() { return this.hasEnemyThreateningTerrain('village', 2); } private hasEnemyThreateningTerrain(terrain: TerrainType, distance: number) { return battleUnits.some((unit) => { return unit.faction === 'enemy' && unit.hp > 0 && this.isUnitNearTerrain(unit, terrain, distance); }); } private hasEnemyOnTerrain(terrain: TerrainType) { return battleUnits.some((unit) => { return unit.faction === 'enemy' && unit.hp > 0 && battleMap.terrain[unit.y][unit.x] === terrain; }); } private isAnyAllyNearTerrain(terrain: TerrainType, distance: number) { return battleUnits.some((unit) => { return unit.faction === 'ally' && unit.hp > 0 && this.isUnitNearTerrain(unit, terrain, distance); }); } private isUnitNearTerrain(unit: UnitData, terrain: TerrainType, distance: number) { return battleMap.terrain.some((row, y) => { return row.some((tile, x) => tile === terrain && this.tileDistanceTo(unit.x, unit.y, x, y) <= distance); }); } private areUnitsDefeated(unitIds: string[]) { return unitIds.every((unitId) => this.isUnitDefeated(unitId)); } private isEnemyClassNearAlly(classKey: UnitClassKey, distance: number) { return battleUnits.some((enemy) => { if (enemy.faction !== 'enemy' || enemy.hp <= 0 || enemy.classKey !== classKey) { return false; } return battleUnits.some((ally) => { return ally.faction === 'ally' && ally.hp > 0 && this.tileDistance(enemy, ally) <= distance; }); }); } private endangeredAlly() { return battleUnits.find((unit) => { return unit.faction === 'ally' && unit.hp > 0 && unit.hp <= Math.ceil(unit.maxHp * 0.35); }); } private tileDistanceTo(x1: number, y1: number, x2: number, y2: number) { return Math.abs(x1 - x2) + Math.abs(y1 - y2); } private isUnitAlive(unitId: string) { const unit = battleUnits.find((candidate) => candidate.id === unitId); return Boolean(unit && unit.hp > 0); } private isUnitDefeated(unitId: string) { return !this.isUnitAlive(unitId); } private handleRightClick(pointer: Phaser.Input.Pointer) { if (this.battleOutcome) { return; } if (this.phase === 'animating') { return; } if (this.cancelAttackTargeting()) { return; } if (this.cancelPendingMove()) { return; } if (this.isPointerOnEmptyMapTile(pointer)) { this.showMapMenu(pointer.x, pointer.y); return; } this.hideMapMenu(); } private handleLeftClick(pointer: Phaser.Input.Pointer) { if (this.battleOutcome) { return; } if (this.suppressNextLeftClick) { this.suppressNextLeftClick = false; return; } const mapMenuAction = this.mapMenuActionAt(pointer.x, pointer.y); if (mapMenuAction) { this.runMapMenuAction(mapMenuAction); return; } if (this.phase === 'targeting' && this.handleTargetingEmptyTileClick(pointer)) { return; } if (this.phase !== 'idle' || !this.isPointerOnEmptyMapTile(pointer)) { return; } const tile = this.pointerToTile(pointer); if (!tile) { return; } this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; this.selectedUsable = undefined; this.clearMarkers(); this.hideCommandMenu(); this.hideTurnEndPrompt(); this.hideMapMenu(); this.refreshUnitLegibilityStyles(); this.renderTerrainDetail(tile.x, tile.y); } private handleTargetingEmptyTileClick(pointer: Phaser.Input.Pointer) { if (!this.selectedUnit || !this.targetingAction || !this.isPointerOnEmptyMapTile(pointer)) { return false; } const tile = this.pointerToTile(pointer); if (!tile) { return false; } this.clearLockedTargetPreview(); const unit = this.selectedUnit; const usable = this.selectedUsable; const range = this.actionRange(unit, this.targetingAction, usable); const distance = this.tileDistanceTo(unit.x, unit.y, tile.x, tile.y); const label = this.actionTargetingLabel(this.targetingAction, usable); const targetLabel = usable && usable.effect !== 'damage' ? this.usableTargetLabel(usable) : '적군'; const markedLabel = usable && usable.effect !== 'damage' ? '밝게 표시된 대상' : '붉게 표시된 적군'; if (distance > range) { this.renderCurrentTargetingGuideNotice(`${label}: 사거리 밖입니다. 선택한 칸 ${distance}/${range}칸 · ${markedLabel}을 선택하세요.`); return true; } this.renderCurrentTargetingGuideNotice(`${label}: 이 칸에는 ${targetLabel} 대상이 없습니다. ${markedLabel}을 선택하세요.`); return true; } private isPointerOnEmptyMapTile(pointer: Phaser.Input.Pointer) { const tile = this.pointerToTile(pointer); if (!tile) { return false; } return !this.isOccupied(tile.x, tile.y); } private pointerToTile(pointer: Phaser.Input.Pointer) { const { gridX, gridY, gridWidth, gridHeight, tileSize } = this.layout; if (pointer.x < gridX || pointer.y < gridY || pointer.x >= gridX + gridWidth || pointer.y >= gridY + gridHeight) { return undefined; } const x = this.cameraTileX + Math.floor((pointer.x - gridX) / tileSize); const y = this.cameraTileY + Math.floor((pointer.y - gridY) / tileSize); return this.isInBounds(x, y) ? { x, y } : undefined; } private showMapMenu(pointerX: number, pointerY: number) { if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') { return; } this.hideMapMenu(); this.hideTurnEndPrompt(); this.clearMarkers(); this.selectedUnit = undefined; this.pendingMove = undefined; this.phase = 'idle'; this.refreshUnitLegibilityStyles(); const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'bgm', 'close']; const menuWidth = 148; const buttonHeight = 34; const padding = 8; const menuHeight = padding * 2 + actions.length * buttonHeight; const left = this.layout.mapX + 84; const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16); this.mapMenuLayout = { left, top, width: menuWidth, buttonHeight, padding, actions }; const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97); panel.setOrigin(0); panel.setDepth(20); panel.setStrokeStyle(2, palette.gold, 0.86); this.mapMenuObjects.push(panel); actions.forEach((action, index) => { const y = top + padding + index * buttonHeight; const disabled = this.isMapMenuActionDisabled(action); const bg = this.add.rectangle(left + 8, y, menuWidth - 16, buttonHeight - 4, disabled ? 0x121820 : 0x1a2630, disabled ? 0.72 : 0.94); bg.setOrigin(0); bg.setDepth(21); bg.setStrokeStyle(1, action === 'endTurn' ? 0xd8b15f : 0x5a7588, disabled ? 0.32 : 0.68); this.mapMenuObjects.push(bg); const text = this.add.text(left + menuWidth / 2, y + buttonHeight / 2 - 2, this.mapMenuLabel(action), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: disabled ? '#737b84' : '#f2e3bf', fontStyle: '700' }); text.setOrigin(0.5); text.setDepth(22); this.mapMenuObjects.push(text); if (!disabled) { const run = () => { this.suppressNextLeftClick = true; this.runMapMenuAction(action); }; bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94)); bg.on('pointerdown', run); text.setInteractive({ useHandCursor: true }); text.on('pointerdown', run); } }); } private runMapMenuAction(action: MapMenuAction) { this.hideMapMenu(); soundDirector.playSelect(); if (action === 'endTurn') { this.endAllyTurn(); return; } if (action === 'threat') { this.showEnemyThreatRange(); return; } if (action === 'save') { this.showSaveSlotPanel('save'); return; } if (action === 'load') { this.showSaveSlotPanel('load'); return; } if (action === 'roster') { this.renderRosterPanel('ally', '부대 일람입니다.'); return; } if (action === 'bond') { this.renderBondPanel(); return; } if (action === 'situation') { this.renderSituationPanel(); return; } if (action === 'speed') { this.cycleBattleSpeed(false); return; } if (action === 'bgm') { soundDirector.setMuted(!soundDirector.isMuted()); this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`); } } private hideMapMenu() { this.mapMenuObjects.forEach((object) => object.destroy()); this.mapMenuObjects = []; this.mapMenuLayout = undefined; } private mapMenuActionAt(x: number, y: number) { if (!this.mapMenuLayout) { return undefined; } const { left, top, width, buttonHeight, padding, actions } = this.mapMenuLayout; if (x < left || x > left + width || y < top + padding || y > top + padding + actions.length * buttonHeight) { return undefined; } const index = Math.floor((y - top - padding) / buttonHeight); const action = actions[index]; return action && !this.isMapMenuActionDisabled(action) ? action : undefined; } private isMapMenuActionDisabled(action: MapMenuAction) { return ( (action === 'endTurn' && this.activeFaction !== 'ally') || (action === 'threat' && this.activeFaction !== 'ally') || ((action === 'save' || action === 'load') && this.activeFaction !== 'ally') || (action === 'load' && !this.hasBattleSave()) ); } private mapMenuLabel(action: MapMenuAction) { if (action === 'save') { return '저장'; } if (action === 'load') { return '불러오기'; } if (action === 'speed') { return `속도 ${battleSpeedLabels[this.battleSpeed]}`; } return mapMenuLabels[action] ?? action; } private hasBattleSave() { return Array.from({ length: campaignSaveSlotCount }, (_, index) => index + 1).some((slot) => Boolean(this.readBattleSaveState(slot))); } private showSaveSlotPanel(mode: SaveSlotMode) { this.hideSaveSlotPanel(); const depth = 70; const width = 520; const height = 312; const left = this.layout.mapX + 118; const top = this.layout.mapY + 82; const campaignSlots = listCampaignSaveSlots(); const panel = this.add.rectangle(left, top, width, height, 0x0f1720, 0.98); panel.setOrigin(0); panel.setDepth(depth); panel.setStrokeStyle(2, mode === 'save' ? palette.gold : palette.blue, 0.9); panel.setInteractive(); this.saveSlotPanelObjects.push(panel); const title = this.add.text(left + 22, top + 18, mode === 'save' ? '전투 저장 슬롯' : '전투 불러오기 슬롯', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#f2e3bf', fontStyle: '700' }); title.setDepth(depth + 1); this.saveSlotPanelObjects.push(title); const guide = this.add.text( left + 22, top + 50, mode === 'save' ? '현재 전투와 캠페인 진행 상태를 함께 저장합니다.' : '저장된 전투 상태와 캠페인 슬롯을 함께 불러옵니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#aeb7c2' } ); guide.setDepth(depth + 1); this.saveSlotPanelObjects.push(guide); for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { const rowTop = top + 82 + (slot - 1) * 62; const battleSave = this.readBattleSaveState(slot); const campaignSlot = campaignSlots.find((candidate) => candidate.slot === slot); const disabled = mode === 'load' && !battleSave; const row = this.add.rectangle(left + 18, rowTop, width - 36, 50, disabled ? 0x121820 : 0x172230, disabled ? 0.76 : 0.94); row.setOrigin(0); row.setDepth(depth + 1); row.setStrokeStyle(1, disabled ? 0x53606c : slot === getCampaignState().activeSaveSlot ? palette.gold : palette.blue, disabled ? 0.34 : 0.72); this.saveSlotPanelObjects.push(row); const titleText = this.add.text(left + 34, rowTop + 8, `슬롯 ${slot}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: disabled ? '#78818a' : '#f2e3bf', fontStyle: '700' }); titleText.setDepth(depth + 2); this.saveSlotPanelObjects.push(titleText); const battleSummary = battleSave ? `${this.formatSavedAt(battleSave.savedAt)} · ${battleSave.turnNumber}턴 · ${factionLabels[battleSave.activeFaction]}` : '전투 저장 없음'; const campaignSummary = campaignSlot?.exists ? `군자금 ${campaignSlot.gold ?? 0} · ${campaignSlot.battleTitle ?? '진행 중'}` : '캠페인 저장 없음'; const detail = this.add.text(left + 104, rowTop + 9, `${battleSummary}\n${campaignSummary}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: disabled ? '#6d7580' : '#d4dce6', lineSpacing: 3 }); detail.setDepth(depth + 2); this.saveSlotPanelObjects.push(detail); if (!disabled) { const run = () => { soundDirector.playSelect(); if (mode === 'save') { this.saveBattleState(slot); } else { this.loadBattleState(slot); } this.hideSaveSlotPanel(); }; row.setInteractive({ useHandCursor: true }); row.on('pointerover', () => row.setFillStyle(0x263647, 0.98)); row.on('pointerout', () => row.setFillStyle(0x172230, 0.94)); row.on('pointerdown', run); titleText.setInteractive({ useHandCursor: true }); titleText.on('pointerdown', run); detail.setInteractive({ useHandCursor: true }); detail.on('pointerdown', run); } } const close = this.add.text(left + width - 52, top + 20, '닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d8b15f', fontStyle: '700' }); close.setDepth(depth + 2); close.setInteractive({ useHandCursor: true }); close.on('pointerdown', () => { soundDirector.playSelect(); this.hideSaveSlotPanel(); }); this.saveSlotPanelObjects.push(close); } private hideSaveSlotPanel() { this.saveSlotPanelObjects.forEach((object) => object.destroy()); this.saveSlotPanelObjects = []; } private battleSaveStorageKeyForSlot(slot: number) { return `${battleSaveStorageKey}:slot-${Phaser.Math.Clamp(Math.floor(slot), 1, campaignSaveSlotCount)}`; } private readBattleSaveState(slot: number) { const normalizedSlot = Phaser.Math.Clamp(Math.floor(slot), 1, campaignSaveSlotCount); const raw = window.localStorage.getItem(this.battleSaveStorageKeyForSlot(normalizedSlot)) ?? (normalizedSlot === 1 ? window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey) : undefined); if (!raw) { return undefined; } try { const state = JSON.parse(raw) as BattleSaveState; if (!state || state.version !== 1 || !Array.isArray(state.units)) { return undefined; } if (state.battleId && state.battleId !== battleScenario.id) { return undefined; } return state; } catch { return undefined; } } private saveBattleState(slot = 1) { try { const state = this.createBattleSaveState(); window.localStorage.setItem(this.battleSaveStorageKeyForSlot(slot), JSON.stringify(state)); if (slot === 1) { window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state)); window.localStorage.removeItem(legacyBattleSaveStorageKey); } saveCampaignState(getCampaignState(), slot); this.renderSituationPanel(`슬롯 ${slot}에 전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { this.renderSituationPanel('전투 상태를 저장하지 못했습니다.'); } } private loadBattleState(slot = 1) { const state = this.readBattleSaveState(slot); if (!state) { this.renderSituationPanel('불러올 저장 데이터가 없습니다.'); return; } try { loadCampaignState(slot); this.applyBattleSaveState(state); this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { this.renderSituationPanel('저장 데이터를 불러오지 못했습니다.'); } } private createBattleSaveState(): BattleSaveState { return { version: 1, battleId: battleScenario.id, campaignStep: getCampaignState().step, savedAt: new Date().toISOString(), turnNumber: this.turnNumber, activeFaction: this.activeFaction, rosterTab: this.rosterTab, actedUnitIds: Array.from(this.actedUnitIds), attackIntents: this.attackIntents.map((intent) => ({ ...intent })), battleLog: [...this.battleLog], units: battleUnits.map((unit) => ({ id: unit.id, level: unit.level, exp: unit.exp, hp: unit.hp, maxHp: unit.maxHp, attack: unit.attack, move: unit.move, x: unit.x, y: unit.y, direction: this.unitViews.get(unit.id)?.direction, equipment: this.cloneEquipment(unit.equipment) })), bonds: Array.from(this.bondStates.values()).map((bond) => ({ ...bond, unitIds: [...bond.unitIds] as [string, string] })), itemStocks: this.serializeItemStocks(), battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })), battleStats: this.serializeBattleStats(), triggeredBattleEvents: Array.from(this.triggeredBattleEvents) }; } private applyBattleSaveState(state: BattleSaveState) { this.clearMarkers(); this.hideCommandMenu(); this.hideTurnEndPrompt(); this.hideCombatCutIn(); this.hideBattleResult(); this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; this.selectedUsable = undefined; this.lockedTargetPreview = undefined; this.battleOutcome = undefined; this.phase = 'idle'; this.turnNumber = Math.max(1, state.turnNumber || 1); this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally'; this.rosterTab = state.rosterTab === 'enemy' ? 'enemy' : 'ally'; this.actedUnitIds = new Set(state.actedUnitIds ?? []); this.attackIntents = (state.attackIntents ?? []).map((intent) => ({ ...intent })); this.battleLog = [...(state.battleLog ?? [])]; this.bondStates = new Map( (state.bonds ?? []).map((bond) => [ bond.id, { ...bond, unitIds: [...bond.unitIds] as [string, string], battleExp: bond.battleExp ?? 0 } ]) ); this.itemStocks = this.deserializeItemStocks(state.itemStocks); this.battleBuffs = new Map((state.battleBuffs ?? []).map((buff) => [buff.unitId, { ...buff }])); this.battleStats = this.deserializeBattleStats(state.battleStats); this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []); state.units.forEach((savedUnit) => { const unit = battleUnits.find((candidate) => candidate.id === savedUnit.id); if (!unit) { return; } unit.level = savedUnit.level; unit.exp = savedUnit.exp; unit.hp = Phaser.Math.Clamp(savedUnit.hp, 0, savedUnit.maxHp); unit.maxHp = savedUnit.maxHp; unit.attack = savedUnit.attack; unit.move = savedUnit.move; unit.x = Phaser.Math.Clamp(savedUnit.x, 0, battleMap.width - 1); unit.y = Phaser.Math.Clamp(savedUnit.y, 0, battleMap.height - 1); unit.equipment = this.cloneEquipment(savedUnit.equipment); this.restoreUnitView(unit, savedUnit.direction); }); const cameraFocus = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? battleUnits[0]; if (cameraFocus) { this.centerCameraOnTile(cameraFocus.x, cameraFocus.y); } else { this.updateCameraView(); } this.resetActedStyles(); this.updateTurnText(); this.updateObjectiveTracker(); this.renderRosterPanel(this.rosterTab, '저장된 전투 상태입니다.'); } private restoreUnitView(unit: UnitData, direction: UnitDirection = 'south') { const view = this.unitViews.get(unit.id); if (!view) { return; } this.tweens.killTweensOf([view.sprite, view.label]); view.direction = direction; this.setUnitBasePosition(view, this.tileCenterX(unit.x), this.tileCenterY(unit.y)); this.resetUnitSpritePose(view); view.label.setPosition(view.baseX, view.baseY + this.layout.tileSize * 0.52); this.applyUnitLegibilityStyle(unit, view); } private cloneEquipment(equipment: UnitData['equipment']) { return JSON.parse(JSON.stringify(equipment)) as UnitData['equipment']; } private serializeItemStocks() { return Object.fromEntries( Array.from(this.itemStocks.entries()).map(([unitId, stocks]) => [unitId, Object.fromEntries(stocks.entries())]) ); } private deserializeItemStocks(savedStocks?: Record>) { if (!savedStocks) { return this.createItemStocks(); } return new Map( Object.entries(savedStocks).map(([unitId, stocks]) => [ unitId, new Map(Object.entries(stocks).map(([itemId, count]) => [itemId, Math.max(0, count)])) ]) ); } private serializeBattleStats() { return Object.fromEntries(Array.from(this.battleStats.entries()).map(([unitId, stats]) => [unitId, { ...stats }])); } private deserializeBattleStats(savedStats?: Record) { const stats = this.createBattleStats(); if (!savedStats) { return stats; } Object.entries(savedStats).forEach(([unitId, value]) => { stats.set(unitId, { damageDealt: value.damageDealt ?? 0, damageTaken: value.damageTaken ?? 0, defeats: value.defeats ?? 0, actions: value.actions ?? 0, support: value.support ?? 0 }); }); return stats; } private formatSavedAt(savedAt: string) { return new Date(savedAt).toLocaleString('ko-KR'); } private endAllyTurn() { if (this.activeFaction !== 'ally') { this.renderSituationPanel('이미 적군 행동 중입니다.'); return; } if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) { return; } if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') { this.setInfo('현재 장수의 행동을 먼저 결정하세요.'); return; } this.selectedUnit = undefined; this.pendingMove = undefined; this.phase = 'idle'; this.clearMarkers(); this.hideCommandMenu(); this.hideTurnEndPrompt(); this.refreshUnitLegibilityStyles(); this.activeFaction = 'enemy'; this.updateTurnText(); this.renderBattleSpeedControl(); this.updateObjectiveTracker(); const recoveryMessage = this.applyTerrainRecovery('enemy'); this.renderSituationPanel(['아군 턴을 종료했습니다.', recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n')); void this.runEnemyTurn(); } private async runEnemyTurn() { if (this.battleOutcome) { return; } const enemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); const messages: string[] = []; for (const enemy of enemies) { if (this.battleOutcome) { return; } this.renderSituationPanel([...messages.slice(-2), `적 행동: ${enemy.name}`].filter(Boolean).join('\n')); const message = await this.executeEnemyAction(enemy); if (message) { messages.push(message); this.pushBattleLog(message); this.updateObjectiveTracker(); this.renderSituationPanel(messages.slice(-3).join('\n')); } if (this.resolveBattleOutcomeIfNeeded()) { return; } await this.delay(260); } if (this.resolveBattleOutcomeIfNeeded()) { return; } this.time.delayedCall(this.scaledBattleDuration(520, 120), () => this.beginNextAllyTurn()); } private async executeEnemyAction(enemy: UnitData) { this.centerCameraOnTile(enemy.x, enemy.y); await this.delay(120); const behavior = this.enemyBehavior(enemy); let target = this.chooseEnemyTarget(enemy, behavior); if (!target) { this.renderSituationPanel(`적 행동: ${enemy.name}\n대기`); return `${enemy.name}: 대기`; } if (!this.canAttack(enemy, target) && behavior !== 'hold') { const destination = this.chooseApproachTile(enemy, target); if (destination && (destination.x !== enemy.x || destination.y !== enemy.y)) { const fromX = enemy.x; const fromY = enemy.y; enemy.x = destination.x; enemy.y = destination.y; this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name} 쪽으로 이동`); this.centerCameraOnTile(destination.x, destination.y); await this.moveUnitViewAsync(enemy, destination.x, destination.y, this.unitViews.get(enemy.id), 260, fromX, fromY); } } target = this.chooseTargetInRange(enemy) ?? target; if (this.canAttack(enemy, target)) { this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name} 공격`); this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2)); const result = this.resolveAttack(enemy, target); await this.playCombatCutIn(result); result.counter = this.resolveCounterAttack(result); if (result.counter) { await this.delay(180); await this.playCombatCutIn(result.counter); } return this.formatEnemyCombatResult(result, behavior); } return `${enemy.name}: ${this.enemyBehaviorLabel(behavior)} 태세로 접근`; } private chooseEnemyTarget(enemy: UnitData, behavior: EnemyAiBehavior) { if (behavior === 'hold') { return this.chooseTargetInRange(enemy); } const nearest = this.nearestAliveUnit(enemy, 'ally'); if (!nearest) { return undefined; } if (behavior === 'guard') { const home = this.enemyHomeTiles.get(enemy.id) ?? { x: enemy.x, y: enemy.y }; const distanceFromHome = Math.abs(nearest.x - home.x) + Math.abs(nearest.y - home.y); if (distanceFromHome > guardDetectionRange && !this.canAttack(enemy, nearest)) { return undefined; } } return nearest; } private chooseTargetInRange(enemy: UnitData) { return battleUnits .filter((unit) => unit.faction === 'ally' && unit.hp > 0 && this.canAttack(enemy, unit)) .sort((a, b) => a.hp - b.hp || this.tileDistance(enemy, a) - this.tileDistance(enemy, b))[0]; } private chooseApproachTile(enemy: UnitData, target: UnitData) { const candidates = [{ x: enemy.x, y: enemy.y, cost: 0 }, ...this.reachableTiles(enemy)]; const pathCosts = this.cachedPathCostsToAttackRange(enemy, target); return candidates .filter((tile) => !this.isOccupied(tile.x, tile.y, enemy.id)) .sort((a, b) => { const aPathCost = pathCosts.get(this.tileKey(a.x, a.y)) ?? Number.POSITIVE_INFINITY; const bPathCost = pathCosts.get(this.tileKey(b.x, b.y)) ?? Number.POSITIVE_INFINITY; const aHasPath = Number.isFinite(aPathCost); const bHasPath = Number.isFinite(bPathCost); if (aHasPath !== bHasPath) { return aHasPath ? -1 : 1; } const aDistance = aHasPath ? aPathCost : Math.abs(a.x - target.x) + Math.abs(a.y - target.y); const bDistance = bHasPath ? bPathCost : Math.abs(b.x - target.x) + Math.abs(b.y - target.y); const aTerrain = getTerrainRule(battleMap.terrain[a.y][a.x]).defenseBonus; const bTerrain = getTerrainRule(battleMap.terrain[b.y][b.x]).defenseBonus; return aDistance - bDistance || bTerrain - aTerrain || a.cost - b.cost; })[0]; } private cachedPathCostsToAttackRange(unit: UnitData, target: UnitData) { const key = this.approachPathCacheKey(unit, target); const cached = this.approachPathCostCache.get(key); if (cached) { return cached; } if (this.approachPathCostCache.size > 180) { this.approachPathCostCache.clear(); } const costs = this.pathCostsToAttackRange(unit, target); this.approachPathCostCache.set(key, costs); return costs; } private approachPathCacheKey(unit: UnitData, target: UnitData) { const blockers = battleUnits .filter((candidate) => candidate.id !== unit.id && candidate.hp > 0) .map((candidate) => `${candidate.id}:${candidate.x},${candidate.y}`) .join('|'); return `${unit.id}:${unit.classKey}:${unit.faction}:${target.id}:${target.x},${target.y}:${blockers}`; } private pathCostsToAttackRange(unit: UnitData, target: UnitData) { const range = this.attackRange(unit); const directions = [ { x: 1, y: 0 }, { x: -1, y: 0 }, { x: 0, y: 1 }, { x: 0, y: -1 } ]; const costs = new Map(); const queue: Array<{ x: number; y: number; cost: number }> = []; this.tilesWithinDistance(target.x, target.y, range).forEach((tile) => { if (this.isMovementBlocked(unit, tile.x, tile.y) || this.isOccupied(tile.x, tile.y, unit.id)) { return; } const stepCost = this.movementCost(unit, battleMap.terrain[tile.y][tile.x]); if (!Number.isFinite(stepCost)) { return; } const key = this.tileKey(tile.x, tile.y); costs.set(key, 0); queue.push({ ...tile, cost: 0 }); }); while (queue.length > 0) { queue.sort((a, b) => a.cost - b.cost); const current = queue.shift(); if (!current) { break; } for (const direction of directions) { const nextX = current.x + direction.x; const nextY = current.y + direction.y; if (!this.isInBounds(nextX, nextY) || this.isMovementBlocked(unit, nextX, nextY)) { continue; } const stepCost = this.movementCost(unit, battleMap.terrain[current.y][current.x]); if (!Number.isFinite(stepCost)) { continue; } const nextCost = current.cost + stepCost; const key = this.tileKey(nextX, nextY); if (nextCost >= (costs.get(key) ?? Number.POSITIVE_INFINITY)) { continue; } costs.set(key, nextCost); queue.push({ x: nextX, y: nextY, cost: nextCost }); } } return costs; } private nearestAliveUnit(unit: UnitData, faction: UnitData['faction']) { return battleUnits .filter((candidate) => candidate.faction === faction && candidate.hp > 0) .sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0]; } private enemyBehavior(enemy: UnitData): EnemyAiBehavior { const lateNorthernPrefixes = [ 'northern-fourth', 'northern-fifth', 'northern-sixth', 'northern-seventh', 'northern-eighth', 'northern-ninth', 'northern-tenth', 'northern-eleventh', 'northern-twelfth' ]; if (lateNorthernPrefixes.some((prefix) => enemy.id.startsWith(prefix))) { if ( enemy.id.includes('-scout') || enemy.id.includes('-cavalry') || enemy.id === 'northern-eighth-officer-zhang-he' || enemy.id === 'northern-ninth-officer-zhang-he' || enemy.id === 'northern-tenth-officer-zhang-he' || enemy.id === 'northern-eleventh-officer-zhang-he' || enemy.id === 'northern-twelfth-officer-zhang-he' ) { return 'aggressive'; } if (enemy.id.includes('-archer') || enemy.id.includes('-strategist') || enemy.id.includes('-leader-')) { return 'hold'; } return 'guard'; } if ( enemy.id.startsWith('northern-first-scout') || enemy.id.startsWith('northern-first-cavalry') || enemy.id === 'northern-first-officer-zhang-he' ) { return 'aggressive'; } if ( enemy.id.startsWith('northern-second-scout') || enemy.id.startsWith('northern-second-cavalry') || enemy.id.startsWith('northern-third-scout') || enemy.id.startsWith('northern-third-cavalry') ) { return enemy.id.endsWith('-a') || enemy.id.endsWith('-b') || enemy.id.endsWith('-c') ? 'aggressive' : 'guard'; } if (enemy.id === 'northern-second-officer-jiang-wei') { return 'guard'; } if (enemy.id === 'northern-third-leader-zhang-he') { return 'guard'; } if ( enemy.id.startsWith('northern-first-archer') || enemy.id.startsWith('northern-second-archer') || enemy.id.startsWith('northern-third-archer') || enemy.id.startsWith('northern-first-strategist') || enemy.id.startsWith('northern-second-strategist') || enemy.id.startsWith('northern-third-strategist') || enemy.id === 'northern-first-leader-cao-zhen' || enemy.id === 'northern-second-leader-guo-huai' || enemy.id === 'northern-third-officer-jiang-wei' ) { return 'hold'; } if ( enemy.id.startsWith('northern-first-infantry') || enemy.id.startsWith('northern-second-infantry') || enemy.id.startsWith('northern-third-infantry') || enemy.id.startsWith('northern-first-ridge-guard') || enemy.id.startsWith('northern-second-jieting-guard') || enemy.id.startsWith('northern-third-ridge-guard') || enemy.id.startsWith('northern-first-officer') || enemy.id.startsWith('northern-second-officer') || enemy.id.startsWith('northern-third-officer') ) { return 'guard'; } return enemyAiByUnitId[enemy.id] ?? defaultEnemyAiByClass[enemy.classKey] ?? 'guard'; } private enemyBehaviorLabel(behavior: EnemyAiBehavior) { if (behavior === 'aggressive') { return '추격'; } if (behavior === 'hold') { return '고수'; } return '경계'; } private enemyDetailMessage(enemy: UnitData) { const behavior = this.enemyBehavior(enemy); const nearest = this.nearestAliveUnit(enemy, 'ally'); const distance = nearest ? Math.abs(enemy.x - nearest.x) + Math.abs(enemy.y - nearest.y) : undefined; const distanceText = nearest ? `\n가장 가까운 아군: ${nearest.name} ${distance}칸` : ''; return [ `${this.enemyBehaviorLabel(behavior)} 태세 · 이동 ${enemy.move} · 사거리 ${this.attackRange(enemy)}`, this.enemyBehaviorDescription(behavior), `공격력 ${this.actionPower(enemy, 'attack')} · 방어 ${this.equipmentDefenseBonus(enemy)} · 명중 보정 ${this.hitBuff(enemy)}`, distanceText.trim() ] .filter(Boolean) .join('\n'); } private enemyBehaviorDescription(behavior: EnemyAiBehavior) { if (behavior === 'aggressive') { return '계속 접근하여 공격 대상을 만들려 합니다.'; } if (behavior === 'hold') { return '자리를 지키며 사거리 안의 대상만 노립니다.'; } return `초기 위치 주변 ${guardDetectionRange}칸 안에 들어온 아군을 추격합니다.`; } private formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) { const outcome = result.hit ? `${result.critical ? '치명타 ' : ''}${result.damage} 피해` : '회피됨'; const defeated = result.defeated ? ` / ${result.defender.name} 퇴각` : ` / ${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`; const counter = result.counter ? ` / ${this.formatCounterLine(result.counter)}` : ''; return `${result.attacker.name} ${this.enemyBehaviorLabel(behavior)} 공격: ${result.defender.name} ${outcome}${defeated}${counter}`; } private async playCombatCutIn(result: CombatResult) { this.hideCombatCutIn(); const panelWidth = 940; const panelHeight = 560; const left = Math.floor((this.scale.width - panelWidth) / 2); const top = Math.floor((this.scale.height - panelHeight) / 2); const depth = 70; const attackerX = left + 190; const defenderX = left + panelWidth - 190; const groundY = top + 246; const dim = this.trackCombatObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.62)); dim.setOrigin(0); dim.setDepth(depth); const panel = this.trackCombatObject(this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.98)); panel.setOrigin(0); panel.setDepth(depth + 1); panel.setStrokeStyle(3, palette.gold, 0.9); const stage = this.trackCombatObject(this.add.graphics()); stage.setDepth(depth + 2); stage.fillStyle(0x8ec9e6, 1); stage.fillRect(left + 18, top + 18, panelWidth - 36, 64); stage.fillStyle(0x3f6f44, 1); stage.fillRect(left + 18, top + 82, panelWidth - 36, 186); stage.fillStyle(0x6f8c43, 0.92); stage.fillRect(left + 18, top + 158, panelWidth - 36, 110); stage.lineStyle(1, 0x2f4f2f, 0.45); for (let x = left + 40; x < left + panelWidth - 40; x += 42) { stage.beginPath(); stage.moveTo(x, top + 163); stage.lineTo(x - 16, top + 264); stage.strokePath(); } stage.fillStyle(0x23472c, 1); stage.fillRect(left + 18, top + 82, panelWidth - 36, 18); const titleText = result.isCounter ? '반격' : result.usable?.name ?? commandLabels[result.action]; const displayTitleText = result.isCounter ? '반격' : titleText; const title = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 24, displayTitleText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', color: '#f4dfad', fontStyle: '700', stroke: '#05070a', strokeThickness: 4 })); title.setOrigin(0.5, 0); title.setDepth(depth + 8); this.renderCombatActionRibbon(result, left + panelWidth / 2 - 220, top + 54, 440, depth + 9); const attackerDirection = 'east'; const defenderDirection = 'west'; const attackerSprite = this.trackCombatObject( this.add.sprite( attackerX, groundY, this.unitActionTexture(result.attacker), this.unitActionFrameIndex(attackerDirection, this.actionPoseForCommand(result.action)) ) ); attackerSprite.setDepth(depth + 6); this.applyCombatSpriteDisplaySize(attackerSprite, result.attacker); this.applyActionSpriteBlend(attackerSprite); const defenderSprite = this.trackCombatObject( this.add.sprite(defenderX, groundY, this.unitTextureKey(result.defender), this.unitFrameIndex(defenderDirection)) ); defenderSprite.setDepth(depth + 6); this.applyCombatSpriteDisplaySize(defenderSprite, result.defender); const attackerStatus = this.renderCombatStatusPanel(result.attacker, left + 26, top + 286, 392, result.characterGrowth); const defenderStatus = this.renderCombatStatusPanel(result.defender, left + panelWidth - 418, top + 286, 392); defenderStatus.hpFill.setScale(result.previousDefenderHp / result.defender.maxHp, 1); defenderStatus.hpText.setText(`${result.previousDefenderHp} / ${result.defender.maxHp}`); attackerStatus.expFill.setScale(result.characterGrowth.previousExp / result.characterGrowth.next, 1); attackerStatus.expText.setText(`${result.characterGrowth.previousExp} / ${result.characterGrowth.next}`); const growthEntries = this.combatGrowthEntries(result); if (result.bondDamageBonus > 0 && result.bondLabel) { await this.showBondCombatEffect(result, left + panelWidth / 2, top + 140, attackerX, groundY - 70, depth + 15); } await this.delay(180); await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10, defenderDirection); if (result.hit) { this.showCombatImpact(result, defenderX, groundY - 46, depth + 14); } else { this.showCombatMiss(defenderX, groundY - 70, depth + 14); } const outcomeCard = this.renderCombatOutcomeCard(result, left + panelWidth / 2 - 226, top + 184, 452, depth + 16); await this.animateCombatGauge(defenderStatus.hpFill, result.previousDefenderHp / result.defender.maxHp, result.defender.hp / result.defender.maxHp, 420); defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`); await this.animateGrowthGauge( attackerStatus.expFill, attackerStatus.expText, result.characterGrowth.previousExp / result.characterGrowth.next, result.characterGrowth.exp / result.characterGrowth.next, result.characterGrowth.previousExp, result.characterGrowth.exp, result.characterGrowth.next, result.characterGrowth.leveled, 680 ); if (result.characterGrowth.leveled) { attackerStatus.nameText.setText(`${result.attacker.name} Lv ${result.characterGrowth.level}`); } await this.dismissCombatOutcomeCard(outcomeCard); await this.playGrowthRewardPanel({ x: left + 86, y: top + 410, width: panelWidth - 172, title: '성장 결과', entries: growthEntries, celebrant: this.growthCelebrant(result), depth: depth + 18 }); await this.delay(360); this.hideCombatCutIn(); } private renderCombatOutcomeSummaryCard(result: CombatResult, x: number, y: number, width: number, depth: number) { const height = 96; const icon: BattleUiIconKey = result.defeated ? 'critical' : !result.hit ? 'move' : result.critical ? 'critical' : result.action === 'strategy' ? 'success' : 'hit'; const title = this.combatOutcomeTitle(result); const tone = this.combatOutcomeTone(result); const container = this.trackCombatObject(this.add.container(x, y)); container.setDepth(depth); container.setAlpha(0); container.setScale(0.96); const bg = this.add.rectangle(0, 0, width, height, 0x0b121a, 0.98); bg.setOrigin(0); bg.setStrokeStyle(2, tone, 0.88); const iconFrame = this.add.rectangle(42, height / 2, 64, 64, 0x101820, 0.94); iconFrame.setStrokeStyle(1, tone, 0.72); const iconImage = this.createBattleUiIcon(42, height / 2, icon, 58); const titleText = this.add.text(84, 9, title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '23px', color: result.hit ? '#fff2b8' : '#d9f1ff', fontStyle: '700', fixedWidth: 150 }); const detail = result.defeated ? `${result.defender.name} 퇴각` : result.hit ? `${result.defender.name} HP ${result.previousDefenderHp} → ${result.defender.hp}` : `${result.defender.name} HP 유지 ${result.defender.hp}/${result.defender.maxHp}`; const detailText = this.add.text(86, 39, detail, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: result.defeated ? '#ffb6a6' : '#c8d2dd', fontStyle: '700', fixedWidth: 190 }); const actualDamage = this.combatDamageValue(result); const value = result.hit ? `-${actualDamage}` : '0'; const valueText = this.add.text(width - 18, 9, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '31px', color: result.hit ? '#ffdf7b' : '#9fb0bf', fontStyle: '700' }); valueText.setOrigin(1, 0); const valueLabel = this.add.text(width - 18, 45, '실제 피해', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700' }); valueLabel.setOrigin(1, 0); const expectedText = this.add.text(width - 18, 61, `예상 ${result.expectedDamage}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#c8d2dd', fontStyle: '700' }); expectedText.setOrigin(1, 0); container.add([bg, iconFrame, iconImage, titleText, detailText, valueText, valueLabel, expectedText]); const badgeY = 78; this.addCombatOutcomeChip(container, 86, badgeY, result.action === 'strategy' ? 'success' : 'hit', this.combatJudgementChipLabel(result), tone); this.addCombatOutcomeChip(container, 154, badgeY, 'critical', result.critical ? '치명' : `${result.criticalRate}%`, 0xd8732c); this.addCombatOutcomeChip(container, 222, badgeY, 'advantage', result.matchupLabel, palette.blue, 86); const extraChip = this.combatOutcomeExtraChip(result); if (extraChip) { this.addCombatOutcomeChip(container, width - 114, badgeY, extraChip.icon, extraChip.label, extraChip.tone, 96); } this.tweens.add({ targets: container, alpha: 1, scale: 1, y: y - 4, duration: 160, ease: 'Sine.easeOut' }); return container; } private combatOutcomeTitle(result: CombatResult) { if (result.defeated) { return '퇴각'; } if (!result.hit) { return result.action === 'strategy' ? '책략 실패' : '회피'; } if (result.critical) { return '치명타'; } return result.action === 'strategy' ? '책략 성공' : '명중'; } private combatOutcomeTone(result: CombatResult) { return result.defeated || result.critical ? 0xd8732c : result.hit ? this.previewAccentColor(result) : 0x83d6ff; } private combatJudgementChipLabel(result: CombatResult) { if (!result.hit) { return result.action === 'strategy' ? '실패' : '회피'; } return result.action === 'strategy' ? `${result.hitRate}%` : `${result.hitRate}%`; } private combatCounterWillTrigger(result: CombatResult) { return !result.isCounter && result.counterAvailable && result.defender.hp > 0 && result.attacker.hp > 0; } private combatOutcomeExtraChip(result: CombatResult): { icon: BattleUiIconKey; label: string; tone: number } | undefined { if (result.defeated) { return { icon: 'counter', label: '퇴각', tone: 0xd8732c }; } if (this.combatCounterWillTrigger(result)) { return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 }; } if (result.bondDamageBonus > 0) { return { icon: 'leadership', label: `공명 +${result.bondDamageBonus}%`, tone: palette.gold }; } return undefined; } private addCombatOutcomeChip( container: Phaser.GameObjects.Container, x: number, y: number, icon: BattleUiIconKey, label: string, tone: number, width = 62 ) { const bg = this.add.rectangle(x, y, width, 22, 0x101820, 0.94); bg.setOrigin(0, 0.5); bg.setStrokeStyle(1, tone, 0.54); const chipIcon = this.createBattleUiIcon(x + 13, y, icon, 19); const text = this.add.text(x + 27, y - 6, this.truncateUiText(label, width > 70 ? 8 : 5), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#f2e3bf', fontStyle: '700', fixedWidth: width - 30 }); container.add([bg, chipIcon, text]); } private renderCombatOutcomeCard(result: CombatResult, x: number, y: number, width: number, depth: number) { return this.renderCombatOutcomeSummaryCard(result, x, y, width, depth); const height = 66; const isStrategy = result.action === 'strategy'; const icon: BattleUiIconKey = result.defeated ? 'critical' : !result.hit ? 'move' : result.critical ? 'critical' : isStrategy ? 'success' : 'hit'; const title = result.defeated ? '퇴각' : !result.hit ? isStrategy ? '책략 실패' : '회피' : result.critical ? '치명타' : isStrategy ? '책략 성공' : '명중'; const tone = result.defeated || result.critical ? 0xd8732c : result.hit ? this.previewAccentColor(result) : 0x83d6ff; const container = this.trackCombatObject(this.add.container(x, y)); container.setDepth(depth); container.setAlpha(0); container.setScale(0.96); const bg = this.add.rectangle(0, 0, width, height, 0x0b121a, 0.97); bg.setOrigin(0); bg.setStrokeStyle(2, tone, 0.84); const iconFrame = this.add.rectangle(34, height / 2, 52, 52, 0x101820, 0.94); iconFrame.setStrokeStyle(1, tone, 0.68); const iconImage = this.createBattleUiIcon(34, height / 2, icon, 48); const titleText = this.add.text(70, 9, title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: result.hit ? '#fff2b8' : '#d9f1ff', fontStyle: '700', fixedWidth: 148 }); const detail = result.defeated ? `${result.defender.name} 퇴각` : `${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`; const detailText = this.add.text(72, 39, detail, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: result.defeated ? '#ffb6a6' : '#9fb0bf', fontStyle: '700', fixedWidth: 190 }); const value = result.hit ? `피해 ${result.damage}` : '피해 0'; const valueText = this.add.text(width - 16, 15, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', color: result.hit ? '#ffdf7b' : '#9fb0bf', fontStyle: '700' }); valueText.setOrigin(1, 0); container.add([bg, iconFrame, iconImage, titleText, detailText, valueText]); if (result.defeated) { const badge = this.add.rectangle(width - 82, 42, 66, 22, 0x101820, 0.94); badge.setOrigin(0); badge.setStrokeStyle(1, 0xd8732c, 0.72); const badgeIcon = this.createBattleUiIcon(width - 69, 53, 'counter', 19); const badgeText = this.add.text(width - 54, 47, '퇴각', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#f2e3bf', fontStyle: '700', fixedWidth: 31 }); container.add([badge, badgeIcon, badgeText]); } this.tweens.add({ targets: container, alpha: 1, scale: 1, y: y - 4, duration: 160, ease: 'Sine.easeOut' }); return container; } private async dismissCombatOutcomeCard(card: Phaser.GameObjects.Container) { if (!card.active) { return; } this.tweens.add({ targets: card, alpha: 0, y: card.y - 10, duration: 150, ease: 'Sine.easeIn', onComplete: () => card.destroy() }); await this.delay(170); } private renderCombatActionSummaryRibbon(result: CombatResult, x: number, y: number, width: number, depth: number) { const accent = this.previewAccentColor(result); const bg = this.trackCombatObject(this.add.rectangle(x, y, width, 64, 0x0b1118, 0.94)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, accent, 0.72); const actionFrame = this.trackCombatObject(this.add.rectangle(x + 25, y + 23, 42, 42, 0x101820, 0.9)); actionFrame.setDepth(depth + 1); actionFrame.setStrokeStyle(1, accent, 0.68); this.trackCombatIcon(x + 25, y + 23, this.actionIcon(result), 37, depth + 2); const attackerWeapon = getItem(result.attacker.equipment.weapon.itemId); const defenderArmor = getItem(result.defender.equipment.armor.itemId); const dealtDamage = result.hit ? result.damage : 0; this.renderCombatRibbonEquipment(x + 120, y + 8, this.itemIcon(attackerWeapon, 'weapon'), attackerWeapon.name, '공격 장비', depth + 1); this.renderCombatRibbonEquipment(x + 250, y + 8, this.itemIcon(defenderArmor, 'armor'), defenderArmor.name, '방어 장비', depth + 1); const actionLabel = this.trackCombatObject(this.add.text(x + 46, y + 9, result.usable?.name ?? commandLabels[result.action], { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#f4dfad', fontStyle: '700', fixedWidth: 72 })); actionLabel.setDepth(depth + 1); const damageText = this.trackCombatObject(this.add.text(x + width - 14, y + 8, `피해 ${dealtDamage}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: result.hit ? '#ffcf91' : '#9fb0bf', fontStyle: '700' })); damageText.setOrigin(1, 0); damageText.setDepth(depth + 1); const outcomeIcon = result.action === 'strategy' ? 'success' : result.critical ? 'critical' : result.hit ? 'hit' : 'move'; const outcomeLabel = result.action === 'strategy' ? (result.hit ? '성공' : '실패') : result.hit ? (result.critical ? '치명' : '명중') : '회피'; this.renderCombatRibbonBadge(x + 46, y + 38, this.actionIcon(result), this.previewActionTypeLabel(result), depth + 1, 78); this.renderCombatRibbonBadge(x + 130, y + 38, outcomeIcon, outcomeLabel, depth + 1, 62); this.renderCombatRibbonBadge(x + 198, y + 38, 'advantage', result.matchupLabel, depth + 1, 76); if (result.defeated) { this.renderCombatRibbonBadge(x + 280, y + 38, 'counter', '퇴각', depth + 1, 58); } else if (this.combatCounterWillTrigger(result) || result.counter) { this.renderCombatRibbonBadge(x + 280, y + 38, 'counter', '반격', depth + 1, 58); } } private renderCombatRibbonEquipment( x: number, y: number, icon: BattleUiIconKey, itemName: string, role: string, depth: number ) { const frame = this.trackCombatObject(this.add.rectangle(x + 14, y + 14, 34, 34, 0x16212d, 0.9)); frame.setDepth(depth); frame.setStrokeStyle(1, 0x647485, 0.58); this.trackCombatIcon(x + 14, y + 14, icon, 30, depth + 1); const label = this.trackCombatObject(this.add.text(x + 34, y - 1, this.truncateUiText(itemName, 7), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#d4dce6', fixedWidth: 84 })); label.setDepth(depth); const roleText = this.trackCombatObject(this.add.text(x + 34, y + 17, role, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fixedWidth: 84 })); roleText.setDepth(depth); } private renderCombatActionRibbon(result: CombatResult, x: number, y: number, width: number, depth: number) { this.renderCombatActionSummaryRibbon(result, x, y, width, depth); return; const accent = this.previewAccentColor(result); const bg = this.trackCombatObject(this.add.rectangle(x, y, width, 64, 0x0b1118, 0.92)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, accent, 0.66); const actionFrame = this.trackCombatObject(this.add.rectangle(x + 25, y + 23, 42, 42, 0x101820, 0.9)); actionFrame.setDepth(depth + 1); actionFrame.setStrokeStyle(1, accent, 0.68); this.trackCombatIcon(x + 25, y + 23, this.actionIcon(result), 37, depth + 2); const attackerWeapon = getItem(result.attacker.equipment.weapon.itemId); const defenderArmor = getItem(result.defender.equipment.armor.itemId); const weaponFrame = this.trackCombatObject(this.add.rectangle(x + 134, y + 22, 34, 34, 0x16212d, 0.9)); weaponFrame.setDepth(depth + 1); weaponFrame.setStrokeStyle(1, 0x647485, 0.58); this.trackCombatIcon(x + 134, y + 22, this.itemIcon(attackerWeapon, 'weapon'), 30, depth + 2); const armorFrame = this.trackCombatObject(this.add.rectangle(x + 244, y + 22, 34, 34, 0x16212d, 0.9)); armorFrame.setDepth(depth + 1); armorFrame.setStrokeStyle(1, 0x647485, 0.58); this.trackCombatIcon(x + 244, y + 22, this.itemIcon(defenderArmor, 'armor'), 30, depth + 2); const actionLabel = this.trackCombatObject(this.add.text(x + 46, y + 9, result.usable?.name ?? commandLabels[result.action], { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#f4dfad', fontStyle: '700', fixedWidth: 74 })); actionLabel.setDepth(depth + 1); const weaponLabel = this.trackCombatObject(this.add.text(x + 154, y + 7, attackerWeapon.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#d4dce6', fixedWidth: 82 })); weaponLabel.setDepth(depth + 1); const weaponRole = this.trackCombatObject(this.add.text(x + 154, y + 25, '공격 장비', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fixedWidth: 82 })); weaponRole.setDepth(depth + 1); const armorLabel = this.trackCombatObject(this.add.text(x + 264, y + 7, defenderArmor.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#d4dce6', fixedWidth: 74 })); armorLabel.setDepth(depth + 1); const armorRole = this.trackCombatObject(this.add.text(x + 264, y + 25, '방어 장비', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fixedWidth: 74 })); armorRole.setDepth(depth + 1); const dealtDamage = result.hit ? result.damage : 0; const damageText = this.trackCombatObject(this.add.text(x + width - 14, y + 10, `피해 ${dealtDamage}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: result.hit ? '#ffcf91' : '#9fb0bf', fontStyle: '700' })); damageText.setOrigin(1, 0); damageText.setDepth(depth + 1); const outcomeIcon = result.action === 'strategy' ? 'success' : result.critical ? 'critical' : 'hit'; const outcomeLabel = result.action === 'strategy' ? (result.hit ? '성공' : '실패') : result.hit ? (result.critical ? '치명' : '명중') : '회피'; this.renderCombatRibbonBadge(x + 46, y + 38, this.actionIcon(result), this.previewActionTypeLabel(result), depth + 1, 78); this.renderCombatRibbonBadge(x + 130, y + 38, outcomeIcon, outcomeLabel, depth + 1, 62); this.renderCombatRibbonBadge(x + 198, y + 38, 'advantage', result.matchupLabel, depth + 1, 76); if (result.counter) { this.renderCombatRibbonBadge(x + 280, y + 38, 'counter', '반격', depth + 1, 58); } } private renderCombatRibbonBadge(x: number, y: number, icon: BattleUiIconKey, label: string, depth: number, width = 52) { const badge = this.trackCombatObject(this.add.rectangle(x, y, width, 22, 0x101820, 0.94)); badge.setOrigin(0); badge.setDepth(depth); badge.setStrokeStyle(1, 0x53606c, 0.48); this.trackCombatIcon(x + 13, y + 11, icon, 19, depth + 1); const text = this.trackCombatObject(this.add.text(x + 28, y + 5, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#f2e3bf', fontStyle: '700', fixedWidth: width - 33 })); text.setDepth(depth + 1); } private applyCombatSpriteDisplaySize(sprite: Phaser.GameObjects.Sprite, unit: UnitData, compact = false) { const size = this.combatSpriteDisplaySize(unit, compact); sprite.setDisplaySize(size, size); } private combatSpriteDisplaySize(unit: UnitData, compact = false) { const baseSizeByClass: Record = { lord: 128, infantry: 124, cavalry: 136, spearman: 126, archer: 120, strategist: 116, quartermaster: 116, bandit: 124, yellowTurban: 124, rebelLeader: 128 }; const size = baseSizeByClass[unit.classKey] ?? 124; return Math.round(size * (compact ? 0.9 : 1)); } private async playSupportCutIn(result: SupportResult) { this.hideCombatCutIn(); const panelWidth = 760; const panelHeight = 500; const left = Math.floor((this.scale.width - panelWidth) / 2); const top = Math.floor((this.scale.height - panelHeight) / 2); const depth = 70; const dim = this.trackCombatObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.58)); dim.setOrigin(0); dim.setDepth(depth); const panel = this.trackCombatObject(this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.98)); panel.setOrigin(0); panel.setDepth(depth + 1); panel.setStrokeStyle(3, result.usable.command === 'strategy' ? palette.blue : palette.gold, 0.9); const title = this.trackCombatObject(this.add.text(left + panelWidth / 2, top + 24, result.usable.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '26px', color: '#f4dfad', fontStyle: '700', stroke: '#05070a', strokeThickness: 4 })); title.setOrigin(0.5, 0); title.setDepth(depth + 2); this.renderSupportActionRibbon(result, left + panelWidth / 2 - 220, top + 58, 440, depth + 9); const supportPose: UnitActionPose = result.usable.command === 'strategy' ? 'strategy' : 'item'; const userSprite = this.trackCombatObject( this.add.sprite(left + 160, top + 156, this.unitActionTexture(result.user), this.unitActionFrameIndex('east', supportPose)) ); userSprite.setDepth(depth + 3); this.applyCombatSpriteDisplaySize(userSprite, result.user, true); this.applyActionSpriteBlend(userSprite); void this.playUnitActionFrames(userSprite, result.user, 'east', supportPose, 100, 2); const targetSprite = this.trackCombatObject(this.add.sprite(left + panelWidth - 160, top + 156, this.unitTextureKey(result.target), this.unitFrameIndex('west'))); targetSprite.setDepth(depth + 3); this.applyCombatSpriteDisplaySize(targetSprite, result.target, true); const effectColor = result.usable.effect === 'heal' ? 0x59d18c : 0xd8b15f; const effect = this.trackCombatObject(this.add.circle(left + panelWidth / 2, top + 156, 30, effectColor, 0.76)); effect.setDepth(depth + 4); this.tweens.add({ targets: effect, scale: 1.45, alpha: 0.18, duration: 520, yoyo: true }); soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', { volume: result.usable.command === 'strategy' ? 0.38 : 0.28, stopAfterMs: 900 }); const targetStatus = this.renderCombatStatusPanel(result.target, left + panelWidth / 2 - 196, top + 202, 392); targetStatus.hpFill.setScale(result.previousTargetHp / result.target.maxHp, 1); targetStatus.hpText.setText(`${result.previousTargetHp} / ${result.target.maxHp}`); const growthEntries = this.supportGrowthEntries(result); await this.delay(250); const outcomeCard = this.renderSupportOutcomeCard(result, left + panelWidth / 2 - 216, top + 108, 432, depth + 5); await this.animateCombatGauge(targetStatus.hpFill, result.previousTargetHp / result.target.maxHp, result.target.hp / result.target.maxHp, 480); targetStatus.hpText.setText(`${result.target.hp} / ${result.target.maxHp}`); await this.dismissCombatOutcomeCard(outcomeCard); await this.playGrowthRewardPanel({ x: left + 64, y: top + 322, width: panelWidth - 128, title: '성장 결과', entries: growthEntries, celebrant: result.user, depth: depth + 8 }); await this.delay(360); this.hideCombatCutIn(); } private renderSupportOutcomeSummaryCard(result: SupportResult, x: number, y: number, width: number, depth: number) { const height = 84; const heal = result.usable.effect === 'heal'; const tone = heal ? 0x59d18c : 0xd8b15f; const container = this.trackCombatObject(this.add.container(x, y)); container.setDepth(depth); container.setAlpha(0); container.setScale(0.96); const bg = this.add.rectangle(0, 0, width, height, 0x0b121a, 0.98); bg.setOrigin(0); bg.setStrokeStyle(2, tone, 0.88); const iconFrame = this.add.rectangle(42, height / 2, 64, 64, 0x101820, 0.94); iconFrame.setStrokeStyle(1, tone, 0.72); const iconImage = this.createBattleUiIcon(42, height / 2, this.usablePowerIcon(result.usable), 58); const titleText = this.add.text(84, 10, heal ? '병력 회복' : '강화 적용', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '23px', color: heal ? '#a8ffd0' : '#fff2b8', fontStyle: '700', fixedWidth: 156 }); const detail = heal ? `${result.target.name} HP ${result.previousTargetHp} → ${result.target.hp}` : `${result.target.name} 공+${result.buff?.attackBonus ?? 0} 명+${result.buff?.hitBonus ?? 0} 치+${result.buff?.criticalBonus ?? 0}`; const detailText = this.add.text(86, 42, detail, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', fontStyle: '700', fixedWidth: 218 }); const value = heal ? `+${result.healAmount}` : `${result.buff?.turns ?? result.usable.duration ?? 1}`; const valueText = this.add.text(width - 18, 9, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '31px', color: heal ? '#a8ffd0' : '#ffdf7b', fontStyle: '700' }); valueText.setOrigin(1, 0); const valueLabel = this.add.text(width - 18, 47, heal ? '회복' : '턴', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700' }); valueLabel.setOrigin(1, 0); container.add([bg, iconFrame, iconImage, titleText, detailText, valueText, valueLabel]); const badgeY = 64; this.addCombatOutcomeChip(container, 86, badgeY, this.usableIcon(result.usable), this.usableEffectLabel(result.usable), tone, 78); this.addCombatOutcomeChip(container, 170, badgeY, 'success', '적용', 0x59d18c, 62); this.addCombatOutcomeChip(container, 238, badgeY, this.usableTargetIcon(result.usable), `${this.usableTargetLabel(result.usable)} ${result.usable.range}칸`, palette.blue, 92); this.tweens.add({ targets: container, alpha: 1, scale: 1, y: y - 4, duration: 160, ease: 'Sine.easeOut' }); return container; } private renderSupportOutcomeCard(result: SupportResult, x: number, y: number, width: number, depth: number) { return this.renderSupportOutcomeSummaryCard(result, x, y, width, depth); const height = 66; const heal = result.usable.effect === 'heal'; const tone = heal ? 0x59d18c : 0xd8b15f; const container = this.trackCombatObject(this.add.container(x, y)); container.setDepth(depth); container.setAlpha(0); container.setScale(0.96); const bg = this.add.rectangle(0, 0, width, height, 0x0b121a, 0.97); bg.setOrigin(0); bg.setStrokeStyle(2, tone, 0.84); const iconFrame = this.add.rectangle(34, height / 2, 52, 52, 0x101820, 0.94); iconFrame.setStrokeStyle(1, tone, 0.68); const iconImage = this.createBattleUiIcon(34, height / 2, this.usablePowerIcon(result.usable), 48); const title = heal ? '병력 회복' : '강화 적용'; const titleText = this.add.text(70, 9, title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: heal ? '#a8ffd0' : '#fff2b8', fontStyle: '700', fixedWidth: 148 }); const detail = heal ? `${result.target.name} HP ${result.target.hp}/${result.target.maxHp}` : `공+${result.buff?.attackBonus ?? 0} 명+${result.buff?.hitBonus ?? 0} 치+${result.buff?.criticalBonus ?? 0}`; const detailText = this.add.text(72, 39, detail, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', fontStyle: '700', fixedWidth: 200 }); const value = heal ? `+${result.healAmount}` : `${result.buff?.turns ?? result.usable.duration ?? 1}턴`; const valueText = this.add.text(width - 16, 15, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', color: heal ? '#a8ffd0' : '#ffdf7b', fontStyle: '700' }); valueText.setOrigin(1, 0); container.add([bg, iconFrame, iconImage, titleText, detailText, valueText]); this.tweens.add({ targets: container, alpha: 1, scale: 1, y: y - 4, duration: 160, ease: 'Sine.easeOut' }); return container; } private renderSupportActionSummaryRibbon(result: SupportResult, x: number, y: number, width: number, depth: number) { const accent = this.usableAccentColor(result.usable); const bg = this.trackCombatObject(this.add.rectangle(x, y, width, 64, 0x0b1118, 0.94)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, accent, 0.72); const actionFrame = this.trackCombatObject(this.add.rectangle(x + 25, y + 23, 42, 42, 0x101820, 0.9)); actionFrame.setDepth(depth + 1); actionFrame.setStrokeStyle(1, accent, 0.68); this.trackCombatIcon(x + 25, y + 23, this.usableIcon(result.usable), 37, depth + 2); const sourceItem = getItem(result.usable.command === 'strategy' ? result.user.equipment.weapon.itemId : result.user.equipment.accessory.itemId); const sourceSlot: EquipmentSlot = result.usable.command === 'strategy' ? 'weapon' : 'accessory'; this.renderCombatRibbonEquipment( x + 120, y + 8, this.itemIcon(sourceItem, sourceSlot), sourceItem.name, result.usable.command === 'strategy' ? '책략 도구' : '소모 도구', depth + 1 ); this.renderCombatRibbonEquipment(x + 254, y + 8, this.usablePowerIcon(result.usable), result.target.name, this.usableTargetLabel(result.usable), depth + 1); const actionLabel = this.trackCombatObject(this.add.text(x + 46, y + 9, result.usable.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#f4dfad', fontStyle: '700', fixedWidth: 72 })); actionLabel.setDepth(depth + 1); const resultLabel = result.usable.effect === 'heal' ? `회복 +${result.healAmount}` : `강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴`; const resultText = this.trackCombatObject(this.add.text(x + width - 14, y + 8, resultLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: result.usable.effect === 'heal' ? '#a8ffd0' : '#ffdf7b', fontStyle: '700' })); resultText.setOrigin(1, 0); resultText.setDepth(depth + 1); this.renderCombatRibbonBadge(x + 46, y + 38, this.usableIcon(result.usable), this.usableEffectLabel(result.usable), depth + 1, 68); this.renderCombatRibbonBadge(x + 120, y + 38, 'success', '적용', depth + 1, 58); this.renderCombatRibbonBadge(x + 184, y + 38, this.usableTargetIcon(result.usable), `${this.usableTargetLabel(result.usable)} ${result.usable.range}칸`, depth + 1, 78); } private renderSupportActionRibbon(result: SupportResult, x: number, y: number, width: number, depth: number) { this.renderSupportActionSummaryRibbon(result, x, y, width, depth); return; const accent = this.usableAccentColor(result.usable); const bg = this.trackCombatObject(this.add.rectangle(x, y, width, 64, 0x0b1118, 0.92)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, accent, 0.66); const actionFrame = this.trackCombatObject(this.add.rectangle(x + 25, y + 23, 42, 42, 0x101820, 0.9)); actionFrame.setDepth(depth + 1); actionFrame.setStrokeStyle(1, accent, 0.68); this.trackCombatIcon(x + 25, y + 23, this.usableIcon(result.usable), 37, depth + 2); const sourceItem = getItem(result.usable.command === 'strategy' ? result.user.equipment.weapon.itemId : result.user.equipment.accessory.itemId); const sourceSlot: EquipmentSlot = result.usable.command === 'strategy' ? 'weapon' : 'accessory'; const sourceFrame = this.trackCombatObject(this.add.rectangle(x + 136, y + 22, 34, 34, 0x16212d, 0.9)); sourceFrame.setDepth(depth + 1); sourceFrame.setStrokeStyle(1, 0x647485, 0.58); this.trackCombatIcon(x + 136, y + 22, this.itemIcon(sourceItem, sourceSlot), 30, depth + 2); const targetFrame = this.trackCombatObject(this.add.rectangle(x + 256, y + 22, 34, 34, 0x16212d, 0.9)); targetFrame.setDepth(depth + 1); targetFrame.setStrokeStyle(1, 0x647485, 0.58); this.trackCombatIcon(x + 256, y + 22, this.usablePowerIcon(result.usable), 30, depth + 2); const actionLabel = this.trackCombatObject(this.add.text(x + 46, y + 9, result.usable.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#f4dfad', fontStyle: '700', fixedWidth: 74 })); actionLabel.setDepth(depth + 1); const sourceLabel = this.trackCombatObject(this.add.text(x + 156, y + 7, sourceItem.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#d4dce6', fixedWidth: 90 })); sourceLabel.setDepth(depth + 1); const sourceRole = this.trackCombatObject(this.add.text(x + 156, y + 25, result.usable.command === 'strategy' ? '책략 도구' : '소모 도구', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fixedWidth: 90 })); sourceRole.setDepth(depth + 1); const targetLabel = this.trackCombatObject(this.add.text(x + 276, y + 7, result.target.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#d4dce6', fixedWidth: 74 })); targetLabel.setDepth(depth + 1); const targetRole = this.trackCombatObject(this.add.text(x + 276, y + 25, this.usableTargetLabel(result.usable), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fixedWidth: 74 })); targetRole.setDepth(depth + 1); const resultLabel = result.usable.effect === 'heal' ? `회복 +${result.healAmount}` : `강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴`; const resultText = this.trackCombatObject(this.add.text(x + width - 14, y + 10, resultLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: result.usable.effect === 'heal' ? '#a8ffd0' : '#ffdf7b', fontStyle: '700' })); resultText.setOrigin(1, 0); resultText.setDepth(depth + 1); this.renderCombatRibbonBadge(x + 46, y + 38, this.usableIcon(result.usable), this.usableEffectLabel(result.usable), depth + 1, 68); this.renderCombatRibbonBadge(x + 120, y + 38, 'success', '적용', depth + 1, 58); this.renderCombatRibbonBadge(x + 184, y + 38, this.usableTargetIcon(result.usable), `${this.usableTargetLabel(result.usable)} ${result.usable.range}칸`, depth + 1, 78); } private renderCombatStatusPanel(unit: UnitData, x: number, y: number, width: number, growth?: CharacterGrowthResult) { const panel = this.trackCombatObject(this.add.rectangle(x, y, width, 116, 0x101820, 0.96)); panel.setOrigin(0); panel.setDepth(84); panel.setStrokeStyle(2, unit.faction === 'ally' ? palette.blue : 0xb86b55, 0.78); const textureKey = this.combatPortraitKey(unit); if (textureKey && this.textures.exists(textureKey)) { const portrait = this.trackCombatObject(this.add.image(x + 38, y + 58, textureKey)); portrait.setDepth(85); portrait.setDisplaySize(58, 80); } else { const unitIcon = this.trackCombatObject(this.add.sprite(x + 38, y + 58, this.unitTextureKey(unit), this.unitFrameIndex('south'))); unitIcon.setDepth(85); unitIcon.setDisplaySize(64, 64); } const nameText = this.trackCombatObject(this.add.text(x + 78, y + 14, `${unit.name} Lv ${growth?.previousLevel ?? unit.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#f2e3bf', fontStyle: '700' })); nameText.setDepth(85); this.trackCombatIcon(x + 88, y + 54, 'hp', 18, 85); const hpLabel = this.trackCombatObject(this.add.text(x + 104, y + 45, '병력', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#9fb0bf', fontStyle: '700' })); hpLabel.setDepth(85); hpLabel.setText('병력'); const hpTrack = this.trackCombatObject(this.add.rectangle(x + 148, y + 54, width - 232, 9, 0x0a0f14, 0.9)); hpTrack.setOrigin(0, 0.5); hpTrack.setDepth(85); const hpFill = this.trackCombatObject(this.add.rectangle(x + 148, y + 54, width - 232, 7, 0x59d18c, 0.96)); hpFill.setOrigin(0, 0.5); hpFill.setDepth(86); hpFill.setScale(unit.hp / unit.maxHp, 1); const hpText = this.trackCombatObject(this.add.text(x + width - 18, y + 43, `${unit.hp} / ${unit.maxHp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#f0e4c8', fontStyle: '700' })); hpText.setOrigin(1, 0); hpText.setDepth(85); this.trackCombatIcon(x + 88, y + 84, 'mastery', 18, 85); const expLabel = this.trackCombatObject(this.add.text(x + 104, y + 75, '경험', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#9fb0bf', fontStyle: '700' })); expLabel.setDepth(85); expLabel.setText('경험'); const expTrack = this.trackCombatObject(this.add.rectangle(x + 148, y + 84, width - 232, 9, 0x0a0f14, 0.9)); expTrack.setOrigin(0, 0.5); expTrack.setDepth(85); const expFill = this.trackCombatObject(this.add.rectangle(x + 148, y + 84, width - 232, 7, 0xd8b15f, 0.96)); expFill.setOrigin(0, 0.5); expFill.setDepth(86); expFill.setScale((growth?.previousExp ?? unit.exp) / 100, 1); const expText = this.trackCombatObject(this.add.text(x + width - 18, y + 73, `${growth?.previousExp ?? unit.exp} / 100`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#f0e4c8', fontStyle: '700' })); expText.setOrigin(1, 0); expText.setDepth(85); return { hpFill, hpText, expFill, expText, nameText }; } private combatGrowthEntries(result: CombatResult): GrowthGaugeEntry[] { const entries: GrowthGaugeEntry[] = []; if (result.attacker.faction === 'ally') { entries.push(this.characterGrowthEntry(result.attacker, result.characterGrowth)); entries.push(this.equipmentGrowthEntry(result.attacker, result.attackerGrowth)); } if (result.defender.faction === 'ally') { entries.push(this.equipmentGrowthEntry(result.defender, result.defenderGrowth)); } result.bondGrowth.forEach((growth) => entries.push(this.bondGrowthEntry(growth))); return entries; } private supportGrowthEntries(result: SupportResult): GrowthGaugeEntry[] { return [this.characterGrowthEntry(result.user, result.characterGrowth), this.equipmentGrowthEntry(result.user, result.equipmentGrowth)]; } private characterGrowthEntry(unit: UnitData, growth: CharacterGrowthResult): GrowthGaugeEntry { return { kind: 'character', label: '장수 경험치', owner: unit.name, amountLabel: `+${growth.amount}`, previousLevel: growth.previousLevel, previousExp: growth.previousExp, previousNext: 100, level: growth.level, exp: growth.exp, next: growth.next, leveled: growth.leveled, color: 0xd8b15f, statGains: growth.statGains }; return { kind: 'character', label: '장수 경험치', owner: unit.name, amountLabel: `+${growth.amount}`, previousLevel: growth.previousLevel, previousExp: growth.previousExp, previousNext: 100, level: growth.level, exp: growth.exp, next: growth.next, leveled: growth.leveled, color: 0xd8b15f, statGains: growth.statGains }; } private equipmentGrowthEntry(unit: UnitData, growth: EquipmentGrowthResult): GrowthGaugeEntry { return { kind: 'equipment', slot: growth.slot, label: `${equipmentSlotLabels[growth.slot]} ${growth.itemName}`, owner: unit.name, amountLabel: `+${growth.amount}`, previousLevel: growth.previousLevel, previousExp: growth.previousExp, previousNext: equipmentExpToNext(growth.previousLevel), level: growth.level, exp: growth.exp, next: growth.next, leveled: growth.leveled, color: growth.slot === 'weapon' ? 0xffc45f : growth.slot === 'armor' ? 0x77b7ff : 0xb7df7a }; } private bondGrowthEntry(growth: BondGrowthResult): GrowthGaugeEntry { return { kind: 'bond', label: '공명 경험치', owner: growth.label, amountLabel: `+${growth.amount}`, previousLevel: growth.previousLevel, previousExp: growth.previousExp, previousNext: 100, level: growth.level, exp: growth.exp, next: growth.next, leveled: growth.leveled, color: 0x83d6ff }; return { kind: 'bond', label: '공명 경험치', owner: growth.label, amountLabel: `+${growth.amount}`, previousLevel: growth.previousLevel, previousExp: growth.previousExp, previousNext: 100, level: growth.level, exp: growth.exp, next: growth.next, leveled: growth.leveled, color: 0x83d6ff }; } private growthCelebrant(result: CombatResult) { if (result.attacker.faction === 'ally') { return result.attacker; } if (result.defender.faction === 'ally') { return result.defender; } return undefined; } private statGainLine(gains?: LevelUpStatGain[]) { const visibleGains = gains?.filter((gain) => gain.amount > 0) ?? []; return visibleGains.length > 0 ? visibleGains.map((gain) => `${gain.label} +${gain.amount}`).join(' ') : undefined; } private async playGrowthRewardPanel(config: { x: number; y: number; width: number; title: string; entries: GrowthGaugeEntry[]; celebrant?: UnitData; depth: number; }) { const entries = config.entries.filter((entry) => entry.amountLabel !== '+0' || entry.leveled); if (entries.length === 0) { await this.delay(280); return; } const rowHeight = 25; const height = 48 + entries.length * rowHeight; const panel = this.trackCombatObject(this.add.rectangle(config.x, config.y, config.width, height, 0x0b121a, 0.96)); panel.setOrigin(0); panel.setDepth(config.depth); panel.setStrokeStyle(2, palette.gold, 0.82); const displayTitle = config.title === '성장 결과' ? config.title : '성장 결과'; const title = this.trackCombatObject(this.add.text(config.x + 16, config.y + 12, displayTitle, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f4dfad', fontStyle: '700' })); title.setDepth(config.depth + 1); const levelEvent = entries.some((entry) => entry.leveled); const rowWidth = config.width - (levelEvent && config.celebrant ? 126 : 36); const views = entries.map((entry, index) => this.renderGrowthGaugeRow(entry, config.x + 18, config.y + 42 + index * rowHeight, rowWidth, config.depth + 1)); const spotlightEntry = entries.find((entry) => entry.kind === 'character' && entry.leveled) ?? entries.find((entry) => entry.leveled); let celebration: Phaser.GameObjects.Sprite | undefined; if (levelEvent && config.celebrant) { celebration = this.createGrowthCelebrationSprite(config.celebrant, config.x + config.width - 46, config.y + 24, config.depth + 5); } const spotlight = spotlightEntry ? this.playCombatLevelUpSpotlight(spotlightEntry, config.celebrant, config.x + config.width / 2, config.y - 88, config.width, config.depth + 8) : undefined; for (let index = 0; index < entries.length; index += 1) { await this.animateGrowthEntry(views[index], entries[index], 540 + index * 70); if (entries[index].leveled) { this.flashGrowthLevelUp(views[index], config.x, config.y, config.width, height, config.depth + 4); } } if (celebration && config.celebrant) { await this.playGrowthCelebration(celebration, config.celebrant); } if (spotlight) { await spotlight; } } private async playCombatLevelUpSpotlight(entry: GrowthGaugeEntry, celebrant: UnitData | undefined, x: number, y: number, width: number, depth: number) { const panelWidth = Math.min(540, width - 34); const panelHeight = 132; const visualX = -panelWidth / 2 + 60; const contentX = celebrant ? -panelWidth / 2 + 122 : -panelWidth / 2 + 102; const container = this.trackCombatObject(this.add.container(x, y)); container.setDepth(depth); container.setAlpha(0); container.setScale(0.9); const bg = this.add.rectangle(0, 0, panelWidth, panelHeight, 0x211624, 0.97); bg.setStrokeStyle(3, palette.gold, 0.96); const visualFrame = this.add.rectangle(visualX, 0, 66, 66, 0x0b1118, 0.92); visualFrame.setStrokeStyle(1, palette.gold, 0.72); const glow = this.add.circle(visualX, 0, 42, 0xffdf7b, 0.12); const title = this.add.text(celebrant ? contentX : 0, -48, `${entry.owner} LEVEL UP`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '25px', color: '#fff2b8', fontStyle: '700', stroke: '#2b1606', strokeThickness: 5 }); title.setOrigin(celebrant ? 0 : 0.5, 0); const levelText = this.add.text(celebrant ? contentX : 0, -12, `Lv ${entry.previousLevel} → Lv ${entry.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#f4dfad', fontStyle: '700' }); levelText.setOrigin(celebrant ? 0 : 0.5, 0); levelText.setText(`Lv ${entry.previousLevel} → Lv ${entry.level}`); const statText = this.add.text(celebrant ? contentX : 0, 16, entry.statGains ? '능력 상승' : `${entry.label} 효과 강화`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#a8ffd0', fontStyle: '700', stroke: '#05070a', strokeThickness: 3 }); statText.setOrigin(celebrant ? 0 : 0.5, 0); statText.setText(entry.statGains ? '능력 상승' : `${entry.label} 강화`); container.add([bg, visualFrame, glow, title, levelText, statText]); if (entry.statGains) { this.addStatGainChipsToContainer(container, entry.statGains, celebrant ? contentX : -panelWidth / 2 + 44, 50, panelWidth - 132); } if (celebrant) { const sprite = this.add.sprite(visualX, 8, this.unitActionTexture(celebrant), this.unitActionFrameIndex('south', this.celebrationPose(celebrant))); sprite.setDisplaySize(82, 82); const burst = this.add.star(visualX, -12, 8, 12, 34, 0xffdf7b, 0.72); container.add([burst, sprite]); this.tweens.add({ targets: burst, angle: 220, scale: 1.32, alpha: 0.18, duration: 760, ease: 'Sine.easeOut' }); void this.playUnitActionFrames(sprite, celebrant, 'south', this.celebrationPose(celebrant), 105, 2); } else { const icon = this.createBattleUiIcon(visualX, 0, this.growthEntryIcon(entry), 54); container.add(icon); } this.tweens.add({ targets: container, alpha: 1, scale: 1.04, duration: 180, ease: 'Back.easeOut' }); await this.delay(740); this.tweens.add({ targets: container, alpha: 0, y: y - 16, duration: 240, ease: 'Sine.easeIn', onComplete: () => container.destroy() }); await this.delay(260); } private renderGrowthGaugeRow(entry: GrowthGaugeEntry, x: number, y: number, width: number, depth: number): GrowthGaugeView { this.trackCombatIcon(x + 10, y + 8, this.growthEntryIcon(entry), 20, depth); const label = this.trackCombatObject(this.add.text(x + 28, y - 3, `${entry.owner} ${entry.label}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', fontStyle: '700' })); label.setDepth(depth); const amount = this.trackCombatObject(this.add.text(x + 214, y - 3, entry.amountLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#ffdf7b', fontStyle: '700' })); amount.setDepth(depth); const barX = x + 270; const barWidth = Math.max(130, width - 440); const track = this.trackCombatObject(this.add.rectangle(barX, y + 8, barWidth, 9, 0x05090e, 0.92)); track.setOrigin(0, 0.5); track.setDepth(depth); const fill = this.trackCombatObject(this.add.rectangle(barX, y + 8, barWidth, 7, entry.color, 0.98)); fill.setOrigin(0, 0.5); fill.setDepth(depth + 1); fill.setScale(entry.previousExp / entry.previousNext, 1); const valueText = this.trackCombatObject(this.add.text(barX + barWidth + 10, y - 3, `${entry.previousExp}/${entry.previousNext}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#f0e4c8', fontStyle: '700' })); valueText.setDepth(depth); const levelText = this.trackCombatObject(this.add.text(x + width - 6, y - 3, `Lv ${entry.previousLevel}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#f4dfad', fontStyle: '700' })); levelText.setOrigin(1, 0); levelText.setDepth(depth); const eventText = this.trackCombatObject(this.add.text(barX + Math.floor(barWidth / 2), y - 12, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#fff2b8', fontStyle: '700', stroke: '#05070a', strokeThickness: 3 })); eventText.setOrigin(0.5); eventText.setDepth(depth + 3); return { fill, valueText, levelText, eventText }; } private async animateGrowthEntry(view: GrowthGaugeView, entry: GrowthGaugeEntry, duration: number) { if (entry.leveled) { await this.animateGrowthGauge(view.fill, view.valueText, entry.previousExp / entry.previousNext, 1, entry.previousExp, entry.previousNext, entry.previousNext, false, duration); view.levelText.setText(`Lv ${entry.level}`); view.fill.setScale(0, 1); view.valueText.setText(`0/${entry.next}`); this.showGrowthEventText(view.eventText, this.growthLevelUpEventLabel(entry)); soundDirector.playLevelUp(); await this.delay(260); await this.animateGrowthGauge(view.fill, view.valueText, 0, entry.exp / entry.next, 0, entry.exp, entry.next, false, Math.max(260, Math.floor(duration * 0.48))); return; } await this.animateGrowthGauge(view.fill, view.valueText, entry.previousExp / entry.previousNext, entry.exp / entry.next, entry.previousExp, entry.exp, entry.next, false, duration); view.levelText.setText(`Lv ${entry.level}`); } private growthLevelUpEventLabel(entry: GrowthGaugeEntry) { const stats = this.statGainLine(entry.statGains); return stats ? `Lv ${entry.level} 달성! ${stats}` : `Lv ${entry.level} 달성!`; } private growthEntryIcon(entry: GrowthGaugeEntry): BattleUiIconKey { if (entry.kind === 'character') { return entry.statGains?.[0]?.icon ?? 'hp'; } if (entry.kind === 'bond') { return 'leadership'; } if (entry.slot === 'armor') { return 'armor'; } if (entry.slot === 'accessory') { return 'accessory'; } return 'sword'; } private showGrowthEventText(text: Phaser.GameObjects.Text, label: string) { text.setText(label); text.setAlpha(1); text.setScale(1); this.tweens.add({ targets: text, y: text.y - 12, alpha: 0, duration: 780, ease: 'Sine.easeOut' }); } private flashGrowthLevelUp(view: GrowthGaugeView, x: number, y: number, width: number, height: number, depth: number) { const flash = this.trackCombatObject(this.add.rectangle(x + width / 2, y + height / 2, width - 10, height - 10, 0xffdf7b, 0.14)); flash.setDepth(depth); this.tweens.add({ targets: flash, alpha: 0, x: flash.x + 10, duration: 520, ease: 'Sine.easeOut', onComplete: () => flash.destroy() }); this.flashTextColor(view.levelText, '#fff2b8'); this.flashTextColor(view.valueText, '#fff2b8'); } private createGrowthCelebrationSprite(unit: UnitData, x: number, y: number, depth: number) { const sprite = this.trackCombatObject( this.add.sprite(x, y, this.unitActionTexture(unit), this.unitActionFrameIndex('south', this.celebrationPose(unit))) ); sprite.setDisplaySize(82, 82); this.applyActionSpriteBlend(sprite); sprite.setDepth(depth); return sprite; } private celebrationPose(unit: UnitData): UnitActionPose { return 'celebrate'; } private playGrowthCelebration(sprite: Phaser.GameObjects.Sprite, unit: UnitData) { return this.playUnitCelebrationFrames(sprite, unit); } private combatPortraitKey(unit: UnitData) { if (unit.id === 'liu-bei') { return 'portrait-liu-bei'; } if (unit.id === 'guan-yu') { return 'portrait-guan-yu'; } if (unit.id === 'zhang-fei') { return 'portrait-zhang-fei'; } if (unit.id === 'zhuge-liang') { return 'portrait-zhuge-liang'; } if (unit.id === 'zhao-yun') { return 'portrait-zhao-yun'; } if (unit.id === 'jiang-wei') { return 'portrait-jiang-wei'; } if (unit.id.includes('sima-yi') || unit.name === '사마의') { return 'portrait-sima-yi'; } return undefined; } private async playCombatActionMotion( result: CombatResult, attackerSprite: Phaser.GameObjects.Sprite, defenderSprite: Phaser.GameObjects.Sprite, attackerX: number, defenderX: number, groundY: number, depth: number, defenderDirection: UnitDirection ) { const direction = attackerX <= defenderX ? 1 : -1; if (result.action === 'attack') { if (this.isRangedAttack(result)) { await this.playRangedAttackMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth, defenderDirection, direction); return; } await this.playMeleeAttackMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth, defenderDirection, direction); return; } await this.playSpecialActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth, defenderDirection, direction); } private isRangedAttack(result: Pick) { return result.action === 'attack' && (result.attacker.classKey === 'archer' || this.attackRange(result.attacker) > 1); } private async playMeleeAttackMotion( result: CombatResult, attackerSprite: Phaser.GameObjects.Sprite, defenderSprite: Phaser.GameObjects.Sprite, attackerX: number, defenderX: number, groundY: number, depth: number, defenderDirection: UnitDirection, direction: number ) { const mounted = result.attacker.classKey === 'cavalry'; const windupX = attackerX - direction * 28; const contactX = defenderX - direction * 126; const baseScaleX = attackerSprite.scaleX; const baseScaleY = attackerSprite.scaleY; soundDirector.playMeleeRush(mounted); void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', 'attack', mounted ? 72 : 82); this.createDustPuff(attackerX - direction * 18, groundY + 48, depth - 1, mounted ? 1.08 : 0.82); this.tweens.add({ targets: attackerSprite, x: windupX, y: groundY + 5, scaleX: baseScaleX * 0.96, scaleY: baseScaleY, duration: this.scaledBattleDuration(110), ease: 'Sine.easeIn' }); await this.delay(115); this.createDashTrail(attackerX - direction * 6, groundY - 30, contactX - direction * 18, groundY - 40, direction, depth); this.createDustPuff(attackerX + direction * 18, groundY + 48, depth - 1, mounted ? 1.2 : 0.92); this.tweens.add({ targets: attackerSprite, x: contactX, y: groundY - (mounted ? 4 : 2), scaleX: baseScaleX * 1.05, scaleY: baseScaleY, duration: this.scaledBattleDuration(mounted ? 170 : 205), ease: 'Cubic.easeIn' }); await this.delay(mounted ? 170 : 205); soundDirector.playMeleeSwing(result.hit, result.critical); this.setUnitActionFrame(attackerSprite, result.attacker, 'east', 'attack', this.unitActionImpactFrame('attack')); this.createSlashArc(defenderX - direction * 68, groundY - 48, direction, depth + 2, result.critical); if (result.hit) { void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70); this.cameras.main.shake(result.critical ? 130 : 90, result.critical ? 0.006 : 0.0035); this.shakeCombatSprite(defenderSprite, defenderX, groundY, direction, result.critical); await this.delay(result.critical ? 130 : 92); } else { this.tweens.add({ targets: defenderSprite, x: defenderX + direction * 36, y: groundY - 16, duration: this.scaledBattleDuration(120), yoyo: true, ease: 'Sine.easeOut' }); await this.delay(126); } this.tweens.add({ targets: attackerSprite, x: attackerX, y: groundY, scaleX: baseScaleX, scaleY: baseScaleY, duration: this.scaledBattleDuration(180), ease: 'Sine.easeOut' }); await this.delay(190); } private async playRangedAttackMotion( result: CombatResult, attackerSprite: Phaser.GameObjects.Sprite, defenderSprite: Phaser.GameObjects.Sprite, attackerX: number, defenderX: number, groundY: number, depth: number, defenderDirection: UnitDirection, direction: number ) { void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', 'attack', 88); this.createBowDrawEffect(attackerX + direction * 44, groundY - 52, direction, depth + 2); this.tweens.add({ targets: attackerSprite, x: attackerX - direction * 12, y: groundY + 2, duration: this.scaledBattleDuration(150), yoyo: true, ease: 'Sine.easeInOut' }); await this.delay(150); soundDirector.playArrowShot(); const arrow = this.createArrowProjectile(attackerX + direction * 74, groundY - 62, direction, depth + 4); const targetX = defenderX - direction * (result.hit ? 70 : 18); const targetY = result.hit ? groundY - 62 : groundY - 104; this.tweens.add({ targets: arrow, x: targetX, y: targetY, angle: result.hit ? -4 * direction : -24 * direction, duration: this.scaledBattleDuration(result.hit ? 330 : 380, 90), ease: result.hit ? 'Cubic.easeIn' : 'Sine.easeOut' }); await this.delay(result.hit ? 332 : 382); if (result.hit) { void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70); this.createArrowImpact(defenderX - direction * 58, groundY - 60, direction, depth + 5, result.critical); this.shakeCombatSprite(defenderSprite, defenderX, groundY, direction, result.critical); arrow.destroy(); await this.delay(result.critical ? 115 : 82); return; } soundDirector.playArrowImpact(false, false); this.tweens.add({ targets: arrow, x: arrow.x + direction * 58, y: arrow.y + 42, alpha: 0, angle: arrow.angle + direction * 36, duration: this.scaledBattleDuration(180), ease: 'Sine.easeIn' }); await this.delay(190); arrow.destroy(); } private async playSpecialActionMotion( result: CombatResult, attackerSprite: Phaser.GameObjects.Sprite, defenderSprite: Phaser.GameObjects.Sprite, attackerX: number, defenderX: number, groundY: number, depth: number, defenderDirection: UnitDirection, direction: number ) { if (result.action === 'item') { soundDirector.playItemLaunch(); } else { soundDirector.playStrategyPulse(); } void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', this.actionPoseForCommand(result.action), 92); const baseScaleX = attackerSprite.scaleX; const baseScaleY = attackerSprite.scaleY; this.createCasterPulse(attackerX + direction * 48, groundY - 50, depth + 2, result.action === 'item' ? 0xd8b15f : 0x6cc5ff); this.tweens.add({ targets: attackerSprite, scaleX: baseScaleX * 1.08, scaleY: baseScaleY * 1.08, y: groundY - 4, duration: this.scaledBattleDuration(150), yoyo: true, ease: 'Sine.easeInOut' }); await this.delay(155); const projectile = result.action === 'item' ? this.createCombatCart(attackerX + direction * 78, groundY - 10, depth) : this.createStrategyEffect(attackerX + direction * 78, groundY - 48, depth); projectile.setDepth(depth); this.combatCutInObjects.push(projectile); this.tweens.add({ targets: projectile, x: defenderX - direction * (result.hit ? 72 : 12), y: result.hit ? projectile.y : projectile.y - 24, duration: this.scaledBattleDuration(430, 100), ease: 'Cubic.easeIn' }); await this.delay(450); if (result.hit) { void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70); this.createSpecialImpact(defenderX - direction * 68, result.action === 'item' ? groundY - 20 : groundY - 56, depth + 4, result.action); this.shakeCombatSprite(defenderSprite, defenderX, groundY, direction, result.critical); } projectile.destroy(); await this.delay(110); } private createDustPuff(x: number, y: number, depth: number, scale = 1) { const puff = this.trackCombatObject(this.add.container(x, y)); puff.setDepth(depth); const left = this.add.circle(-12, 0, 9 * scale, 0xcdb48c, 0.34); const center = this.add.circle(0, -4, 13 * scale, 0xd9c49a, 0.28); const right = this.add.circle(12, 1, 8 * scale, 0xb99b72, 0.3); puff.add([left, center, right]); this.tweens.add({ targets: puff, y: y - 8, scale: 1.4, alpha: 0, duration: 360, ease: 'Sine.easeOut' }); return puff; } private createDashTrail(startX: number, startY: number, endX: number, endY: number, direction: number, depth: number) { const trail = this.trackCombatObject(this.add.graphics()); trail.setDepth(depth); trail.lineStyle(9, 0xffdf7b, 0.22); trail.lineBetween(startX - direction * 16, startY + 8, endX - direction * 20, endY + 6); trail.lineStyle(5, 0xf7f0d0, 0.36); trail.lineBetween(startX, startY - 3, endX, endY - 8); trail.lineStyle(3, 0x84c8ff, 0.18); trail.lineBetween(startX - direction * 8, startY + 20, endX - direction * 12, endY + 16); this.tweens.add({ targets: trail, alpha: 0, x: -direction * 18, duration: 260, ease: 'Sine.easeOut', onComplete: () => trail.destroy() }); return trail; } private createSlashArc(x: number, y: number, direction: number, depth: number, critical: boolean) { const slash = this.trackCombatObject(this.add.graphics()); slash.setDepth(depth); slash.lineStyle(critical ? 12 : 9, critical ? 0xff8f5f : 0xfff0b8, critical ? 0.94 : 0.86); slash.beginPath(); slash.moveTo(x - direction * 42, y - 34); slash.lineTo(x + direction * 8, y - 4); slash.lineTo(x + direction * 46, y + 30); slash.strokePath(); slash.lineStyle(critical ? 5 : 4, 0xffffff, 0.68); slash.beginPath(); slash.moveTo(x - direction * 32, y - 26); slash.lineTo(x + direction * 36, y + 24); slash.strokePath(); const spark = this.trackCombatObject(this.add.star(x + direction * 14, y + 2, 7, 7, critical ? 34 : 25, critical ? 0xff6f4f : 0xffdf7b, 0.86)); spark.setDepth(depth + 1); this.tweens.add({ targets: slash, alpha: 0, scale: 1.24, duration: 230, ease: 'Sine.easeOut', onComplete: () => slash.destroy() }); this.tweens.add({ targets: spark, alpha: 0, scale: critical ? 1.8 : 1.45, angle: 160 * direction, duration: 280, ease: 'Sine.easeOut' }); } private shakeCombatSprite(sprite: Phaser.GameObjects.Sprite, baseX: number, baseY: number, direction: number, critical: boolean) { this.tweens.add({ targets: sprite, x: baseX + direction * (critical ? 24 : 15), y: baseY - (critical ? 8 : 4), duration: this.scaledBattleDuration(critical ? 46 : 54, 34), yoyo: true, repeat: critical ? 2 : 1, ease: 'Sine.easeInOut' }); } private createBowDrawEffect(x: number, y: number, direction: number, depth: number) { const draw = this.trackCombatObject(this.add.graphics()); draw.setDepth(depth); draw.lineStyle(4, 0xd8b15f, 0.86); draw.beginPath(); draw.arc(x, y, 32, Phaser.Math.DegToRad(-62), Phaser.Math.DegToRad(62), false); draw.strokePath(); draw.lineStyle(2, 0xf4e7c2, 0.74); draw.lineBetween(x + direction * 9, y - 28, x - direction * 23, y); draw.lineBetween(x - direction * 23, y, x + direction * 9, y + 28); const notch = this.trackCombatObject(this.add.circle(x - direction * 25, y, 5, 0xffdf7b, 0.82)); notch.setDepth(depth + 1); this.tweens.add({ targets: [draw, notch], alpha: 0, x: -direction * 10, duration: 260, ease: 'Sine.easeOut' }); } private createArrowProjectile(x: number, y: number, direction: number, depth: number) { const arrow = this.trackCombatObject(this.add.container(x, y)); arrow.setDepth(depth); const shaft = this.add.rectangle(0, 0, 64, 4, 0xf2d7a1, 1); shaft.setStrokeStyle(1, 0x6f5130, 0.8); const head = this.add.triangle(direction * 39, 0, direction * 25, -8, direction * 45, 0, direction * 25, 8, 0xd8dfe6, 1); head.setStrokeStyle(1, 0x45515c, 0.9); const featherTop = this.add.triangle(-direction * 31, -4, -direction * 45, -11, -direction * 39, -1, -direction * 27, 3, 0xb84f3f, 0.96); const featherBottom = this.add.triangle(-direction * 31, 4, -direction * 45, 11, -direction * 39, 1, -direction * 27, -3, 0xb84f3f, 0.96); const glint = this.add.rectangle(direction * 10, -5, 32, 2, 0xffffff, 0.48); arrow.add([shaft, head, featherTop, featherBottom, glint]); arrow.setAngle(-5 * direction); return arrow; } private createArrowImpact(x: number, y: number, direction: number, depth: number, critical: boolean) { const burst = this.trackCombatObject(this.add.graphics()); burst.setDepth(depth); burst.lineStyle(critical ? 5 : 4, critical ? 0xff8f5f : 0xffdf7b, 0.88); for (let index = 0; index < 7; index += 1) { const angle = -70 + index * 22; const radians = Phaser.Math.DegToRad(angle); const length = critical ? 38 : 28; burst.lineBetween(x, y, x - direction * Math.cos(radians) * length, y + Math.sin(radians) * length); } const stuck = this.trackCombatObject(this.add.rectangle(x - direction * 10, y + 2, 42, 4, 0xf2d7a1, 0.92)); stuck.setDepth(depth + 1); stuck.setAngle(-12 * direction); this.tweens.add({ targets: burst, alpha: 0, scale: critical ? 1.5 : 1.25, duration: 260, ease: 'Sine.easeOut', onComplete: () => burst.destroy() }); this.tweens.add({ targets: stuck, alpha: 0, x: stuck.x - direction * 8, duration: 360, ease: 'Sine.easeOut' }); } private createCasterPulse(x: number, y: number, depth: number, color: number) { const ring = this.trackCombatObject(this.add.circle(x, y, 28)); ring.setDepth(depth); ring.setStrokeStyle(3, color, 0.82); const core = this.trackCombatObject(this.add.circle(x, y, 12, color, 0.28)); core.setDepth(depth - 1); this.tweens.add({ targets: [ring, core], scale: 1.85, alpha: 0, duration: 420, ease: 'Sine.easeOut' }); } private createSpecialImpact(x: number, y: number, depth: number, action: DamageCommand) { const color = action === 'item' ? 0xffdf7b : 0x6cc5ff; const impact = this.trackCombatObject(this.add.container(x, y)); impact.setDepth(depth); const ring = this.add.circle(0, 0, action === 'item' ? 30 : 38); ring.setStrokeStyle(action === 'item' ? 4 : 3, color, 0.9); const core = this.add.star(0, 0, action === 'item' ? 7 : 6, 9, action === 'item' ? 34 : 28, color, 0.78); const flash = this.add.circle(0, 0, 12, 0xffffff, 0.5); impact.add([ring, core, flash]); this.tweens.add({ targets: impact, scale: action === 'item' ? 1.38 : 1.65, alpha: 0, angle: action === 'item' ? 45 : 180, duration: 340, ease: 'Sine.easeOut' }); } private createCombatCart(x: number, y: number, depth: number) { const container = this.add.container(x, y); container.setDepth(depth); const body = this.add.rectangle(0, 0, 58, 28, 0x7c5432, 1); body.setStrokeStyle(2, 0x2a160c, 1); const cover = this.add.rectangle(0, -9, 46, 10, 0xa57a45, 1); const leftWheel = this.add.circle(-18, 15, 7, 0x1b1511, 1); const rightWheel = this.add.circle(18, 15, 7, 0x1b1511, 1); const blade = this.add.triangle(35, -4, 0, -10, 22, 0, 0, 10, 0xd8dfe6, 1); blade.setStrokeStyle(1, 0x4d5960, 1); const spark = this.add.star(34, -18, 5, 4, 12, 0xffdf7b, 0.72); container.add([body, cover, leftWheel, rightWheel, blade, spark]); this.tweens.add({ targets: [leftWheel, rightWheel], angle: 720, duration: 430 }); this.tweens.add({ targets: spark, angle: 180, alpha: 0.2, duration: 430, yoyo: true }); return container; } private createStrategyEffect(x: number, y: number, depth: number) { const container = this.add.container(x, y); container.setDepth(depth); const core = this.add.circle(0, 0, 14, 0x6cc5ff, 0.9); const ring = this.add.circle(0, 0, 24); ring.setStrokeStyle(3, 0xd8b15f, 0.9); const spark = this.add.star(0, 0, 5, 7, 18, 0xf7e6a1, 0.9); const outer = this.add.circle(0, 0, 38); outer.setStrokeStyle(2, 0x6cc5ff, 0.58); container.add([outer, ring, core, spark]); this.tweens.add({ targets: outer, scale: 1.28, alpha: 0.18, duration: 430 }); this.tweens.add({ targets: ring, angle: 180, duration: 430 }); this.tweens.add({ targets: spark, angle: -240, duration: 430 }); return container; } private showBondMapEffect(result: CombatResult) { if (result.bondDamageBonus <= 0 || result.bondPartnerIds.length === 0) { return Promise.resolve(); } const attackerView = this.unitViews.get(result.attacker.id); if (!attackerView || !this.isTileVisible(result.attacker.x, result.attacker.y)) { return Promise.resolve(); } const partnerUnits = result.bondPartnerIds .map((partnerId) => battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0)) .filter((unit): unit is UnitData => Boolean(unit)); const visiblePartners = partnerUnits.filter((unit) => this.isTileVisible(unit.x, unit.y) && this.unitViews.has(unit.id)); if (visiblePartners.length === 0) { return Promise.resolve(); } soundDirector.playEffect('bond-resonance', { volume: 0.22, rate: 1.04, stopAfterMs: 620 }); const objects: Phaser.GameObjects.GameObject[] = []; const line = this.add.graphics(); line.setDepth(34); line.lineStyle(4, 0xffdf7b, 0.78); visiblePartners.forEach((partner) => { const partnerView = this.unitViews.get(partner.id); if (!partnerView) { return; } line.beginPath(); line.moveTo(attackerView.sprite.x, attackerView.sprite.y - this.layout.tileSize * 0.28); line.lineTo(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.28); line.strokePath(); objects.push(this.createBondBadge(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.86, `${partner.name} ♥ 공명`, 35)); }); objects.push(line); objects.push(this.createBondBadge(attackerView.sprite.x, attackerView.sprite.y - this.layout.tileSize * 0.96, '연계 발동', 36)); this.tweens.add({ targets: objects, alpha: 0, y: '-=12', duration: 620, ease: 'Sine.easeOut' }); return this.delay(660).then(() => objects.forEach((object) => object.destroy())); } private createBondBadge(x: number, y: number, label: string, depth: number) { const container = this.add.container(x, y); container.setDepth(depth); const width = Math.max(84, label.length * 13 + 22); const bg = this.add.rectangle(0, 0, width, 30, 0x2a1820, 0.92); bg.setStrokeStyle(2, 0xffdf7b, 0.92); const glow = this.add.circle(-width / 2 + 17, 0, 10, 0xff6f91, 0.82); const text = this.add.text(5, 0, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#fff2b8', fontStyle: '700', stroke: '#30100d', strokeThickness: 3 }); text.setOrigin(0.5); container.add([bg, glow, text]); this.tweens.add({ targets: glow, scale: 1.35, alpha: 0.28, yoyo: true, repeat: 1, duration: 180 }); return container; } private showBondCombatEffect(result: CombatResult, textX: number, textY: number, auraX: number, auraY: number, depth: number) { if (!result.bondLabel || result.bondDamageBonus <= 0) { return Promise.resolve(); } soundDirector.playEffect('bond-resonance', { volume: 0.24, rate: 1, stopAfterMs: 760 }); const aura = this.trackCombatObject(this.add.circle(auraX, auraY, 42)); aura.setStrokeStyle(4, 0xffdf7b, 0.88); aura.setDepth(depth); const inner = this.trackCombatObject(this.add.circle(auraX, auraY, 24, 0xffdf7b, 0.18)); inner.setDepth(depth - 1); const spark = this.trackCombatObject(this.add.star(auraX, auraY, 6, 8, 24, 0xfff0b8, 0.86)); spark.setDepth(depth + 1); const text = this.trackCombatObject(this.add.text(textX, textY, `공명 효과 ${result.bondLabel}\n피해 +${result.bondDamageBonus}%`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#fff2b8', fontStyle: '700', align: 'center', stroke: '#2b1606', strokeThickness: 4, lineSpacing: 2 })); text.setOrigin(0.5); text.setText(`공명 효과 ${result.bondLabel}\n피해 +${result.bondDamageBonus}%`); text.setDepth(depth + 2); this.tweens.add({ targets: aura, scale: 1.7, alpha: 0, duration: 560, ease: 'Sine.easeOut' }); this.tweens.add({ targets: inner, scale: 1.35, alpha: 0.04, duration: 560, ease: 'Sine.easeOut' }); this.tweens.add({ targets: spark, angle: 180, scale: 1.22, duration: 560, ease: 'Sine.easeInOut' }); this.tweens.add({ targets: text, y: textY - 8, scale: 1.05, duration: 180, yoyo: true, ease: 'Sine.easeOut' }); return this.delay(580); } private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) { if (this.isRangedAttack(result)) { soundDirector.playArrowImpact(true, result.critical); } else { soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 }); } const impact = this.trackCombatObject( this.add.star(x, y, 8, 12, result.critical ? 54 : 42, result.critical ? 0xff8f5f : result.action === 'strategy' ? 0x6cc5ff : 0xffdf7b, 0.92) ); impact.setDepth(depth); this.tweens.add({ targets: impact, scale: result.critical ? 1.7 : 1.45, alpha: 0, duration: this.scaledBattleDuration(320, 80), ease: 'Sine.easeOut', onComplete: () => impact.destroy() }); } private showCombatMiss(x: number, y: number, depth: number) { const miss = this.trackCombatObject(this.add.text(x, y, '회피', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', color: '#d9f1ff', fontStyle: '700', stroke: '#071623', strokeThickness: 4 })); miss.setOrigin(0.5); miss.setText('회피'); miss.setDepth(depth); this.tweens.add({ targets: miss, y: y - 20, alpha: 0, duration: this.scaledBattleDuration(460, 90), ease: 'Sine.easeOut', onComplete: () => miss.destroy() }); } private animateCombatGauge(target: Phaser.GameObjects.Rectangle, fromRatio: number, toRatio: number, duration: number) { const scaledDuration = this.scaledBattleDuration(duration, 90); target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1); return new Promise((resolve) => { this.tweens.add({ targets: target, scaleX: Phaser.Math.Clamp(toRatio, 0, 1), duration: scaledDuration, ease: 'Sine.easeInOut', onComplete: () => resolve() }); }); } private animateGrowthGauge( target: Phaser.GameObjects.Rectangle, text: Phaser.GameObjects.Text, fromRatio: number, toRatio: number, fromValue: number, toValue: number, next: number, leveled: boolean, duration: number ) { const scaledDuration = this.scaledBattleDuration(duration, 120); target.setScale(Phaser.Math.Clamp(fromRatio, 0, 1), 1); text.setText(`${fromValue} / ${next}`); if (leveled) { soundDirector.playGrowthTick(); return new Promise((resolve) => { const firstTracker = { ratio: Phaser.Math.Clamp(fromRatio, 0, 1), value: fromValue }; this.tweens.add({ targets: firstTracker, ratio: 1, value: next, duration: Math.floor(scaledDuration * 0.62), ease: 'Sine.easeInOut', onUpdate: () => { target.setScale(firstTracker.ratio, 1); text.setText(`${Math.round(firstTracker.value)} / ${next}`); }, onComplete: () => { soundDirector.playLevelUp(); target.setScale(0, 1); text.setText(`0 / ${next}`); const secondTracker = { ratio: 0, value: 0 }; this.tweens.add({ targets: secondTracker, ratio: Phaser.Math.Clamp(toRatio, 0, 1), value: toValue, duration: Math.floor(scaledDuration * 0.46), ease: 'Sine.easeInOut', onUpdate: () => { target.setScale(secondTracker.ratio, 1); text.setText(`${Math.round(secondTracker.value)} / ${next}`); }, onComplete: () => { target.setScale(Phaser.Math.Clamp(toRatio, 0, 1), 1); text.setText(`${toValue} / ${next}`); this.flashTextColor(text, '#fff2b8'); resolve(); } }); } }); }); } if (toValue !== fromValue) { soundDirector.playGrowthTick(); } return new Promise((resolve) => { const tracker = { ratio: Phaser.Math.Clamp(fromRatio, 0, 1), value: fromValue }; this.tweens.add({ targets: tracker, ratio: Phaser.Math.Clamp(toRatio, 0, 1), value: toValue, duration: scaledDuration, ease: 'Sine.easeInOut', onUpdate: () => { target.setScale(tracker.ratio, 1); text.setText(`${Math.round(tracker.value)} / ${next}`); }, onComplete: () => { target.setScale(Phaser.Math.Clamp(toRatio, 0, 1), 1); text.setText(`${toValue} / ${next}`); if (leveled) { this.flashTextColor(text, '#fff2b8'); } resolve(); } }); }); } private trackCombatObject(object: T) { this.combatCutInObjects.push(object); return object; } private hideCombatCutIn() { this.combatCutInObjects.forEach((object) => { if (object.active) { object.destroy(); } }); this.combatCutInObjects = []; } private delay(ms: number) { return new Promise((resolve) => { this.time.delayedCall(this.scaledBattleDuration(ms), () => resolve()); }); } private moveUnitViewAsync( unit: UnitData, x: number, y: number, view = this.unitViews.get(unit.id), duration = 260, fromX = unit.x, fromY = unit.y ) { return new Promise((resolve) => { if (!view) { resolve(); return; } const startX = this.tileCenterX(fromX); const startY = this.tileCenterY(fromY); const targetX = this.tileCenterX(x); const targetY = this.tileCenterY(y); const direction = this.directionFromDelta(x - fromX, y - fromY); const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y); const movementDuration = this.movementDuration(duration, movementDistance); this.tweens.killTweensOf([view.sprite, view.hitZone, view.label]); this.setUnitBasePosition(view, startX, startY); this.resetUnitSpritePose(view); view.label.setPosition(startX, startY + this.layout.tileSize * 0.52); this.playMovementSound(unit, movementDuration, movementDistance); this.playUnitWalk(view, direction); this.tweens.add({ targets: [view.sprite, view.hitZone], x: targetX, y: targetY, duration: movementDuration, ease: 'Sine.easeInOut', onComplete: () => { this.setUnitBasePosition(view, targetX, targetY); this.syncUnitMotion(unit, view, direction); this.updateMiniMap(); this.refreshFacingIndicator(unit.id); resolve(); } }); this.tweens.add({ targets: view.label, x: targetX, y: targetY + this.layout.tileSize * 0.52, duration: movementDuration, ease: 'Sine.easeInOut' }); }); } private beginNextAllyTurn() { if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) { return; } this.turnNumber += 1; const expiredBuffMessage = this.tickBattleBuffs(); this.activeFaction = 'ally'; this.fastForwardHeld = false; this.actedUnitIds.clear(); this.attackIntents = []; const recoveryMessage = this.applyTerrainRecovery('ally'); this.resetActedStyles(); this.updateTurnText(); this.renderBattleSpeedControl(); this.updateObjectiveTracker(); this.checkBattleEvents(); this.centerCameraOnAllyLine(); const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.'].filter(Boolean).join('\n'); this.renderRosterPanel('ally', turnMessage); const firstUnit = this.firstActionableAlly(); if (firstUnit) { this.renderSituationPanel([`${this.turnNumber}턴 아군 차례입니다.`, expiredBuffMessage, recoveryMessage, `다음 행동: ${firstUnit.name}`].filter(Boolean).join('\n')); this.focusActionableAlly( firstUnit, [`${this.turnNumber}턴 아군 차례입니다.`, expiredBuffMessage, recoveryMessage, '이동할 파란 칸을 선택하세요.'].filter(Boolean).join('\n') ); } } private applyTerrainRecovery(faction: ActiveFaction) { const messages: string[] = []; battleUnits .filter((unit) => unit.faction === faction && unit.hp > 0) .forEach((unit) => { const terrain = battleMap.terrain[unit.y][unit.x]; const terrainRule = getTerrainRule(terrain); 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; } if (hpGain > 0) { unit.hp += hpGain; } if (moraleBonus > 0) { this.applyTerrainMorale(unit, moraleBonus); } const parts = [ hpGain > 0 ? `병력 +${hpGain}${itemRecovery.label ? ` (${itemRecovery.label})` : ''}` : '', moraleBonus > 0 ? `사기 +${moraleBonus}` : '' ].filter(Boolean); messages.push(`${unit.name} ${terrainRule.label}: ${parts.join(' / ')}`); this.showTerrainRecoveryPopup(unit, parts.join(' ')); }); if (messages.length === 0) { return undefined; } soundDirector.playEffect('exp-gain', { volume: 0.18, stopAfterMs: 520 }); const message = `거점 효과\n${messages.slice(0, 3).join('\n')}${messages.length > 3 ? `\n외 ${messages.length - 3}명` : ''}`; this.pushBattleLog(message); 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, { unitId: unit.id, label: existing ? `${existing.label} / 거점 사기` : '거점 사기', turns: Math.max(existing?.turns ?? 0, 1), attackBonus: existing?.attackBonus ?? 0, hitBonus: Math.max(existing?.hitBonus ?? 0, moraleBonus * 2), criticalBonus: Math.max(existing?.criticalBonus ?? 0, moraleBonus) }); } private showTerrainRecoveryPopup(unit: UnitData, label: string) { const view = this.unitViews.get(unit.id); if (!view || !this.isTileVisible(unit.x, unit.y)) { return; } const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.56, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#a8ffd0', fontStyle: '700', stroke: '#082416', strokeThickness: 4 }); popup.setOrigin(0.5); popup.setDepth(31); if (this.mapMask) { popup.setMask(this.mapMask); } this.tweens.add({ targets: popup, y: popup.y - 22, alpha: 0, duration: 900, ease: 'Sine.easeOut', onComplete: () => popup.destroy() }); } private updateTurnText() { this.turnText?.setText(`${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`); this.updateObjectiveTracker(); } private resetActedStyles() { this.unitViews.forEach((view, unitId) => { const unit = battleUnits.find((candidate) => candidate.id === unitId); if (unit && unit.hp <= 0) { this.applyDefeatedStyle(unit); return; } view.sprite.postFX.clear(); view.sprite.clearTint(); view.sprite.setAlpha(1); if (unit) { this.syncUnitMotion(unit, view); this.applyUnitLegibilityStyle(unit, view); } else { this.stopUnitWalk(view, view.direction); } view.label.setAlpha(1); view.label.setColor(unit?.faction === 'ally' ? '#e7edf7' : '#ffebe7'); view.label.setBackgroundColor(''); }); } private recordBondAttackIntent(unit: UnitData, target: UnitData) { const partners = this.activeBondCombatBonus(unit, target, 'attack').partnerIds; const growthResults: BondGrowthResult[] = []; partners.forEach((partnerId) => { const bond = this.findBond(unit.id, partnerId); if (!bond) { return; } const previousLevel = bond.level; const previousExp = bond.exp; let leveled = false; bond.battleExp += 4; bond.exp += 4; while (bond.exp >= 100) { bond.exp -= 100; bond.level = Math.min(100, bond.level + 1); leveled = true; } const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId)); growthResults.push({ label: `${first}·${second}`, amount: 4, previousLevel, previousExp, level: bond.level, exp: bond.exp, next: 100, leveled }); }); this.attackIntents = this.attackIntents.filter((intent) => intent.attackerId !== unit.id); this.attackIntents.push({ attackerId: unit.id, targetId: target.id }); return growthResults; } private recordAttackIntent(unit: UnitData) { const target = this.findNearestEnemy(unit); const weaponGrowth = this.awardEquipmentExp(unit, 'weapon', this.weaponExpGain(unit)); if (!target) { return `${unit.name} 공격 명령 완료\n공격 가능한 적이 없습니다.\n${this.formatEquipmentGrowth(weaponGrowth)}`; } const bondBonus = this.activeBondCombatBonus(unit, target, 'attack'); const partners = bondBonus.partnerIds; let gained = 0; partners.forEach((partnerId) => { const bond = this.findBond(unit.id, partnerId); if (!bond) { return; } bond.battleExp += 4; bond.exp += 4; while (bond.exp >= 100) { bond.exp -= 100; bond.level = Math.min(100, bond.level + 1); } gained += 4; }); this.attackIntents = this.attackIntents.filter((intent) => intent.attackerId !== unit.id); this.attackIntents.push({ attackerId: unit.id, targetId: target.id }); const chain = bondBonus.chainRate > 0 ? `\n공명 보너스: 피해 +${bondBonus.damageBonus}% / 연계 ${bondBonus.chainRate}%` : ''; const exp = gained > 0 ? `\n같은 목표를 노린 공명 경험치 +${gained}` : '\n같은 목표를 노린 장수가 생기면 공명 경험치가 쌓입니다.'; return `${unit.name} 공격 명령 완료\n예상 목표: ${target.name}\n${this.formatEquipmentGrowth(weaponGrowth)}${chain}${exp}`; } private resolveEnemyPressure() { const defender = this.findEnemyPressureTarget(); if (!defender) { return '황건적은 움직일 틈을 찾지 못했습니다.'; } const armorGrowth = this.awardEquipmentExp(defender, 'armor', 6); return `황건적의 견제 공격을 ${defender.name}이 받아냈습니다.\n${this.formatEquipmentGrowth(armorGrowth)}`; } private findEnemyPressureTarget() { const allies = battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0); if (allies.length === 0) { return undefined; } return [...allies].sort((a, b) => this.nearestEnemyDistance(a) - this.nearestEnemyDistance(b))[0]; } private nearestEnemyDistance(unit: UnitData) { const distances = battleUnits .filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0) .map((enemy) => this.tileDistance(unit, enemy)); return distances.length > 0 ? Math.min(...distances) : Number.POSITIVE_INFINITY; } private weaponExpGain(unit: UnitData) { const weapon = getItem(unit.equipment.weapon.itemId); const bonus = this.strongestBondBonus(unit.id); 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 { const state = unit.equipment[slot]; const item = getItem(state.itemId); const previousLevel = state.level; const previousExp = state.exp; let exp = state.exp + amount; let leveled = false; while (state.level < maxEquipmentLevel && exp >= equipmentExpToNext(state.level)) { exp -= equipmentExpToNext(state.level); state.level += 1; leveled = true; } const next = equipmentExpToNext(state.level); state.exp = state.level >= maxEquipmentLevel ? Math.min(exp, next) : exp; return { slot, itemName: item.name, amount, previousLevel, previousExp, level: state.level, exp: state.exp, next, leveled }; } private awardCharacterExp(unit: UnitData, amount: number): CharacterGrowthResult { const previousLevel = unit.level; const previousExp = unit.exp; let exp = unit.exp + amount; let leveled = false; let levelUps = 0; while (unit.level < maxCharacterLevel && exp >= 100) { exp -= 100; unit.level += 1; leveled = true; levelUps += 1; } const statGains = this.applyCharacterLevelUpStats(unit, levelUps); unit.exp = unit.level >= maxCharacterLevel ? Math.min(exp, 100) : exp; return { amount, previousLevel, previousExp, level: unit.level, exp: unit.exp, next: 100, leveled, levelUps, statGains }; } private applyCharacterLevelUpStats(unit: UnitData, levelUps: number) { const gains = this.characterLevelUpStatGains(unit, levelUps); gains.forEach((gain) => { switch (gain.icon) { case 'hp': unit.maxHp += gain.amount; unit.hp = Math.min(unit.maxHp, unit.hp + gain.amount); return; case 'attack': unit.attack += gain.amount; return; case 'might': unit.stats.might = Math.min(255, unit.stats.might + gain.amount); return; case 'intelligence': unit.stats.intelligence = Math.min(255, unit.stats.intelligence + gain.amount); return; case 'leadership': unit.stats.leadership = Math.min(255, unit.stats.leadership + gain.amount); return; case 'move': unit.stats.agility = Math.min(255, unit.stats.agility + gain.amount); return; default: return; } }); return gains; } private characterLevelUpStatGains(unit: UnitData, levelUps: number): LevelUpStatGain[] { if (levelUps <= 0) { return []; } const profile = this.levelUpStatProfile(unit.classKey); return [ { label: '병력', amount: levelUps * 2, icon: 'hp' }, { label: '공격', amount: levelUps, icon: 'attack' }, ...profile.map((icon) => ({ label: this.levelUpStatLabel(icon), amount: levelUps, icon })) ]; } private levelUpStatProfile(classKey: UnitClassKey): BattleUiIconKey[] { switch (classKey) { case 'strategist': case 'quartermaster': return ['intelligence', 'leadership']; case 'archer': return ['might', 'intelligence']; case 'cavalry': return ['might', 'leadership', 'move']; case 'lord': case 'rebelLeader': case 'yellowTurban': return ['might', 'intelligence', 'leadership']; default: return ['might', 'leadership']; } } private levelUpStatLabel(icon: BattleUiIconKey) { switch (icon) { case 'might': return '무력'; case 'intelligence': return '지력'; case 'leadership': return '통솔'; case 'move': return '민첩'; default: return '능력'; } } private formatEquipmentGrowth(result: EquipmentGrowthResult) { const levelUp = result.leveled ? ` / Lv ${result.level} 달성` : ''; return `${equipmentSlotLabels[result.slot]} ${result.itemName} 경험치 +${result.amount} (${result.exp}/${result.next})${levelUp}`; } private formatCharacterGrowth(result: CharacterGrowthResult) { const levelUp = result.leveled ? ` / Lv ${result.level} 달성` : ''; const statGains = this.statGainLine(result.statGains); return `장수 경험치 +${result.amount} (${result.exp}/${result.next})${levelUp}${statGains ? ` / ${statGains}` : ''}`; } private findNearestEnemy(unit: UnitData) { const enemies = battleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0); return enemies.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0]; } private tileDistance(a: UnitData, b: UnitData) { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); } private findBond(unitId: string, partnerId: string) { return Array.from(this.bondStates.values()).find((bond) => { return bond.unitIds.includes(unitId) && bond.unitIds.includes(partnerId); }); } private activeBondCombatBonus(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false): BondCombatBonus { if (isCounter || action !== 'attack' || attacker.faction !== 'ally') { return { damageBonus: 0, chainRate: 0, partnerIds: [] }; } const candidates = Array.from(this.bondStates.values()) .map((bond) => { if (!bond.unitIds.includes(attacker.id)) { return undefined; } const partnerId = bond.unitIds.find((unitId) => unitId !== attacker.id); const partner = partnerId ? battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0) : undefined; if (!partner || partner.faction !== attacker.faction || !this.canBondPartnerJoinAttack(attacker, partner, defender)) { return undefined; } return { bond, partner }; }) .filter((candidate): candidate is { bond: BondState; partner: UnitData } => Boolean(candidate)) .sort((a, b) => b.bond.level - a.bond.level); const strongest = candidates[0]; if (!strongest) { return { damageBonus: 0, chainRate: 0, partnerIds: [] }; } const [first, second] = strongest.bond.unitIds.map((id) => this.unitName(id)); return { damageBonus: Math.floor(strongest.bond.level / 20) * 3, chainRate: strongest.bond.level >= 70 ? 18 : strongest.bond.level >= 50 ? 8 : 0, label: `${strongest.bond.title}: ${first}·${second}`, partnerIds: [strongest.partner.id] }; } private canBondPartnerJoinAttack(attacker: UnitData, partner: UnitData, defender: UnitData) { if (this.tileDistance(attacker, partner) <= 1) { return true; } return this.canUseDamageCommand(partner, defender, 'attack'); } private strongestBondBonus(unitId: string) { const related = Array.from(this.bondStates.values()).filter((bond) => bond.unitIds.includes(unitId)); const strongest = related.sort((a, b) => b.level - a.level)[0]; if (!strongest) { return { damageBonus: 0, chainRate: 0 }; } const [first, second] = strongest.unitIds.map((id) => this.unitName(id)); return { damageBonus: Math.floor(strongest.level / 20) * 3, chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0, label: `${strongest.title}: ${first}·${second}` }; } private attackBuff(unit: UnitData) { return this.battleBuffs.get(unit.id)?.attackBonus ?? 0; } private hitBuff(unit: UnitData) { return this.battleBuffs.get(unit.id)?.hitBonus ?? 0; } private criticalBuff(unit: UnitData) { return this.battleBuffs.get(unit.id)?.criticalBonus ?? 0; } private tickBattleBuffs() { const expired: string[] = []; this.battleBuffs.forEach((buff, unitId) => { buff.turns -= 1; if (buff.turns <= 0) { this.battleBuffs.delete(unitId); expired.push(`${this.unitName(unitId)} ${buff.label}`); } }); if (expired.length === 0) { return undefined; } const message = `상태효과 만료\n${expired.slice(0, 3).join('\n')}${expired.length > 3 ? `\n외 ${expired.length - 3}명` : ''}`; this.pushBattleLog(message); return message; } private cancelAttackTargeting() { if (this.phase !== 'targeting' || !this.selectedUnit) { return false; } const unit = this.selectedUnit; const view = this.unitViews.get(unit.id); this.phase = 'command'; this.targetingAction = undefined; this.selectedUsable = undefined; this.clearMarkers(); soundDirector.playSelect(); this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y)); this.renderUnitDetail(unit, '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.'); return true; } private cancelPendingMove() { if (this.phase !== 'command' || !this.pendingMove) { return false; } const { unit, fromX, fromY, toX, toY } = this.pendingMove; const view = this.unitViews.get(unit.id); unit.x = fromX; unit.y = fromY; this.phase = 'moving'; this.selectedUnit = unit; this.pendingMove = undefined; this.hideCommandMenu(); this.hideTurnEndPrompt(); this.updateMiniMap(); this.refreshUnitLegibilityStyles(); soundDirector.playSelect(); this.moveUnitView(unit, fromX, fromY, view, 180, toX, toY); this.clearMarkers(); this.showMoveRange(unit); this.renderUnitDetail( unit, `이동을 취소했습니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.` ); return true; } private moveUnitView( unit: UnitData, x: number, y: number, view = this.unitViews.get(unit.id), duration = 260, fromX = unit.x, fromY = unit.y ) { if (!view) { return; } const startX = this.tileCenterX(fromX); const startY = this.tileCenterY(fromY); const targetX = this.tileCenterX(x); const targetY = this.tileCenterY(y); const direction = this.directionFromDelta(x - fromX, y - fromY); const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y); const movementDuration = this.movementDuration(duration, movementDistance); this.tweens.killTweensOf([view.sprite, view.hitZone, view.label]); this.setUnitBasePosition(view, startX, startY); this.resetUnitSpritePose(view); view.label.setPosition(startX, startY + this.layout.tileSize * 0.52); this.playMovementSound(unit, movementDuration, movementDistance); this.playUnitWalk(view, direction); this.tweens.add({ targets: [view.sprite, view.hitZone], x: targetX, y: targetY, duration: movementDuration, ease: 'Sine.easeInOut', onComplete: () => { this.setUnitBasePosition(view, targetX, targetY); this.syncUnitMotion(unit, view, direction); this.updateMiniMap(); this.refreshFacingIndicator(unit.id); } }); this.tweens.add({ targets: view.label, x: targetX, y: targetY + this.layout.tileSize * 0.52, duration: movementDuration, ease: 'Sine.easeInOut' }); } private movementTileDistance(unit: UnitData, x: number, y: number) { return this.movementTileDistanceFrom(unit.x, unit.y, x, y); } private movementTileDistanceFrom(fromX: number, fromY: number, x: number, y: number) { return Math.max(1, Math.abs(x - fromX) + Math.abs(y - fromY)); } private movementDuration(baseDuration: number, tileDistance: number) { const duration = tileDistance <= 1 ? baseDuration : Math.max(baseDuration, Math.min(760, 150 + tileDistance * 105)); if (tileDistance <= 1) { return this.scaledBattleDuration(duration, 82); } return this.scaledBattleDuration(duration, 110); } private playMovementSound(unit: UnitData, duration: number, tileDistance = 1) { const isMounted = this.isMountedUnit(unit); const pulseCount = isMounted ? Math.max(3, Math.min(10, tileDistance * 2 + 1)) : Math.max(2, Math.min(8, tileDistance + 1)); const interval = Math.max(isMounted ? 72 : 108, duration / Math.max(1, pulseCount - 0.25)); for (let index = 0; index < pulseCount; index += 1) { this.time.delayedCall(Math.round(index * interval), () => { soundDirector.playMovementStep(isMounted, index, { volume: isMounted ? 0.24 : 0.18, rate: isMounted ? 0.98 + (index % 2) * 0.12 : 0.9 + (index % 2) * 0.12, stopAfterMs: Math.round(Math.min(isMounted ? 210 : 260, interval + 80)) }); }); } } private directionFromDelta(deltaX: number, deltaY: number): UnitDirection { if (Math.abs(deltaX) > Math.abs(deltaY)) { return deltaX >= 0 ? 'east' : 'west'; } return deltaY >= 0 ? 'south' : 'north'; } private currentUnitDirection(unit: UnitData): UnitDirection { return this.unitViews.get(unit.id)?.direction ?? 'south'; } private unitDirectionLabel(direction: UnitDirection) { const labels: Record = { south: '아래', east: '오른쪽', north: '위', west: '왼쪽' }; return labels[direction]; } private directionSymbol(direction: UnitDirection) { const symbols: Record = { south: '▼', east: '▶', north: '▲', west: '◀' }; return symbols[direction]; } private directionVector(direction: UnitDirection) { const vectors: Record = { south: { x: 0, y: 1 }, east: { x: 1, y: 0 }, north: { x: 0, y: -1 }, west: { x: -1, y: 0 } }; return vectors[direction]; } private refreshFacingIndicator(unitId = this.facingIndicatorUnitId) { if (!unitId) { return; } const unit = battleUnits.find((candidate) => candidate.id === unitId); if (!unit) { this.clearFacingIndicator(); return; } this.showFacingIndicator(unit); } private showFacingIndicator(unit: UnitData) { this.clearFacingIndicator(); this.facingIndicatorUnitId = unit.id; } private clearFacingIndicator() { this.facingIndicatorObjects.forEach((object) => object.destroy()); this.facingIndicatorObjects = []; this.facingIndicatorUnitId = undefined; } private playUnitWalk(view: UnitView, direction: UnitDirection) { view.direction = direction; this.stopUnitIdle(view); view.sprite.setFlipX(false); const animationKey = `${view.textureBase}-walk-${direction}`; if (!this.tryPlayUnitAnimation(view, animationKey)) { this.setUnitDirectionFrame(view, direction, Math.floor(unitBaseFramesPerDirection / 2)); } } private playUnitIdle(unit: UnitData, view: UnitView, direction: UnitDirection) { view.direction = direction; this.stopUnitIdle(view); view.sprite.setFlipX(false); const animationKey = `${view.textureBase}-idle-${direction}`; if (!this.tryPlayUnitAnimation(view, animationKey)) { this.setUnitDirectionFrame(view, direction); } this.startUnitIdleMotion(unit, view, direction); } private tryPlayUnitAnimation(view: UnitView, animationKey: string) { if (!view.sprite.active || !view.sprite.scene || !this.anims.exists(animationKey)) { return false; } try { view.sprite.play(animationKey, true); return true; } catch { return false; } } private stopUnitWalk(view: UnitView, direction: UnitDirection) { view.direction = direction; this.stopUnitIdle(view); this.tryStopUnitAnimation(view); this.setUnitDirectionFrame(view, direction); } private tryStopUnitAnimation(view: UnitView) { try { view.sprite.stop(); } catch { // Some hot scene swaps can leave Phaser's animation state half-created. } } private syncUnitMotion(unit: UnitData, view = this.unitViews.get(unit.id), direction = view?.direction) { if (!view || !direction) { return; } view.direction = direction; if (this.actedUnitIds.has(unit.id) || unit.hp <= 0) { this.stopUnitWalk(view, direction); return; } this.playUnitIdle(unit, view, direction); } private setUnitDirectionFrame(view: UnitView, direction: UnitDirection, frame = 0) { if (!view.sprite.active || !view.sprite.scene || !this.textures.exists(view.textureBase)) { return; } try { view.sprite.setTexture(view.textureBase, this.unitFrameIndex(direction, frame)); view.sprite.setFlipX(false); this.applyBaseSpriteBlend(view.sprite); } catch { // Some hot scene swaps can leave Phaser's sprite texture state half-created. } } private setUnitBasePosition(view: UnitView, x: number, y: number) { view.baseX = x; view.baseY = y; view.sprite.setPosition(x, y); view.hitZone.setPosition(x, y); view.hitZone.setSize(this.layout.tileSize, this.layout.tileSize); } private setUnitHitZoneEnabled(view: UnitView, enabled: boolean) { view.hitZone.setVisible(enabled); if (view.hitZone.input) { view.hitZone.input.enabled = enabled; } } private resetUnitSpritePose(view: UnitView) { view.sprite.setPosition(view.baseX, view.baseY); view.sprite.setScale(view.baseScaleX, view.baseScaleY); view.sprite.setAngle(0); } private stopUnitIdle(view: UnitView) { view.idleTween?.stop(); view.idleTween = undefined; this.tweens.killTweensOf(view.sprite); this.resetUnitSpritePose(view); } private startUnitIdleMotion(unit: UnitData, view: UnitView, direction: UnitDirection) { const mounted = this.isMountedUnit(unit); const seed = this.hashString(unit.id) % 160; const bob = mounted ? 2.35 : 1.9; const duration = mounted ? 660 + seed * 0.32 : 900 + seed * 0.5; view.idleTween = this.tweens.add({ targets: view.sprite, y: view.baseY - bob, duration, ease: 'Sine.easeInOut', yoyo: true, repeat: -1, delay: seed }); } private isMountedUnit(unit: UnitData) { return unit.classKey === 'cavalry'; } private unitFrameIndex(direction: UnitDirection, frame = 0) { return unitFrameRows[direction] * unitBaseFramesPerDirection + frame; } private unitTextureKey(unit: UnitData) { const signatureTexture = this.signatureUnitTextureKey(unit); if (signatureTexture) { return signatureTexture; } const earlyRebelTexture = this.earlyRebelTextureKey(unit); if (earlyRebelTexture) { return earlyRebelTexture; } const factionTexture = this.unitFactionTextureKey(unit); if (factionTexture) { return factionTexture; } if ( unit.id.startsWith('northern-first-cavalry') || unit.id.startsWith('northern-second-cavalry') || unit.id.startsWith('northern-third-cavalry') ) { return this.resolveUnitTextureVariant('unit-rebel-cavalry', unit.id); } if ( unit.id.startsWith('northern-first-archer') || unit.id.startsWith('northern-second-archer') || unit.id.startsWith('northern-third-archer') || unit.id.startsWith('northern-first-strategist') || unit.id.startsWith('northern-second-strategist') || unit.id.startsWith('northern-third-strategist') ) { return this.resolveUnitTextureVariant('unit-rebel-archer', unit.id); } if ( unit.id.startsWith('northern-first-officer') || unit.id.startsWith('northern-second-officer') || unit.id.startsWith('northern-third-officer') || unit.id === 'northern-first-leader-cao-zhen' || unit.id === 'northern-second-leader-guo-huai' || unit.id === 'northern-third-leader-zhang-he' ) { return this.resolveUnitTextureVariant('unit-rebel-leader', unit.id); } return this.resolveUnitTextureVariant(unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel', unit.id); } private earlyRebelTextureKey(unit: UnitData) { if (unit.faction !== 'enemy' || !earlyRebelTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { return undefined; } return unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel'; } private signatureUnitTextureKey(unit: UnitData) { if (unit.id.includes('sima-yi') || unit.name === '사마의') { return 'unit-sima-yi'; } if (signatureUnitTextureIds.has(unit.id)) { return unitTexture[unit.id]; } return undefined; } private unitFactionTextureKey(unit: UnitData) { if (unit.faction === 'ally') { if (coreAllyTextureIds.has(unit.id)) { return undefined; } return this.unitTextureForClass('shu', unit.classKey, unit.id); } if (earlyRebelTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { return undefined; } if (nanmanTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { return this.unitTextureForClass('nanman', unit.classKey, unit.id); } if (wuTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { return this.unitTextureForClass('wu', unit.classKey, unit.id); } return this.unitTextureForClass('wei', unit.classKey, unit.id); } private unitTextureForClass(style: 'shu' | 'wei' | 'wu' | 'nanman', classKey: UnitClassKey, seed: string) { let baseKey: string; if (style === 'shu') { if (classKey === 'spearman') { baseKey = 'unit-shu-spearman'; } else if (classKey === 'archer') { baseKey = 'unit-shu-archer'; } else if (classKey === 'cavalry') { baseKey = 'unit-shu-cavalry'; } else if (classKey === 'strategist' || classKey === 'quartermaster') { baseKey = 'unit-shu-strategist'; } else if (classKey === 'rebelLeader') { baseKey = 'unit-shu-officer'; } else { baseKey = 'unit-shu-infantry'; } } else if (style === 'nanman') { if (classKey === 'archer' || classKey === 'strategist' || classKey === 'quartermaster') { baseKey = 'unit-nanman-shaman'; } else if (classKey === 'rebelLeader' || classKey === 'cavalry') { baseKey = 'unit-nanman-officer'; } else { baseKey = 'unit-nanman-infantry'; } } else if (classKey === 'archer') { baseKey = `unit-${style}-archer`; } else if (classKey === 'cavalry') { baseKey = `unit-${style}-cavalry`; } else if (classKey === 'strategist' || classKey === 'quartermaster') { baseKey = `unit-${style}-strategist`; } else if (classKey === 'rebelLeader') { baseKey = `unit-${style}-officer`; } else { baseKey = `unit-${style}-infantry`; } return this.resolveUnitTextureVariant(baseKey, seed); } private resolveUnitTextureVariant(baseKey: string, seed: string) { const variantKeys = this.unitTextureVariantKeys(baseKey); if (variantKeys.length <= 1) { return baseKey; } return variantKeys[this.hashString(seed) % variantKeys.length]; } private unitTextureVariantKeys(baseKey: string) { return unitAssetTextureVariantKeys(baseKey); } private hashString(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) >>> 0; } return hash; } private unitActionTexture(unit: UnitData) { const base = this.unitTextureKey(unit); const actionKey = `${base}-actions`; return this.textures.exists(actionKey) ? actionKey : base; } private unitActionFrameIndex(direction: UnitDirection, pose: UnitActionPose, frame = 0) { const frameCount = unitActionFrameCounts[pose]; const safeFrame = Phaser.Math.Clamp(frame, 0, frameCount - 1); return unitFrameRows[direction] * unitActionFramesPerDirection + unitActionColumnOffsets[pose] + safeFrame; } private unitActionImpactFrame(pose: UnitActionPose) { return Math.max(0, Math.floor(unitActionFrameCounts[pose] * 0.55)); } private setUnitActionFrame(sprite: Phaser.GameObjects.Sprite, unit: UnitData, direction: UnitDirection, pose: UnitActionPose, frame = 0) { sprite.setTexture(this.unitActionTexture(unit), this.unitActionFrameIndex(direction, pose, frame)); this.applyActionSpriteBlend(sprite); } private applyActionSpriteBlend(sprite: Phaser.GameObjects.Sprite) { sprite.setBlendMode(Phaser.BlendModes.NORMAL); } private applyBaseSpriteBlend(sprite: Phaser.GameObjects.Sprite) { sprite.setBlendMode(Phaser.BlendModes.NORMAL); } private playUnitActionFrames( sprite: Phaser.GameObjects.Sprite, unit: UnitData, direction: UnitDirection, pose: UnitActionPose, frameDuration = 86, loops = 1 ) { const frameCount = unitActionFrameCounts[pose]; const totalFrames = frameCount * Math.max(1, loops); const scaledFrameDuration = this.scaledBattleDuration(frameDuration, 42); return new Promise((resolve) => { for (let index = 0; index < totalFrames; index += 1) { this.time.delayedCall(index * scaledFrameDuration, () => { if (sprite.active) { this.setUnitActionFrame(sprite, unit, direction, pose, index % frameCount); } }); } this.time.delayedCall(totalFrames * scaledFrameDuration, () => resolve()); }); } private actionPoseForCommand(action: DamageCommand): UnitActionPose { if (action === 'strategy') { return 'strategy'; } if (action === 'item') { return 'item'; } return 'attack'; } private applyActedStyle(unit: UnitData) { const view = this.unitViews.get(unit.id); if (!view) { return; } if (unit.hp <= 0) { this.applyDefeatedStyle(unit); return; } view.sprite.postFX.clear(); view.sprite.clearTint(); this.syncUnitMotion(unit, view); this.applyUnitLegibilityStyle(unit, view); } private applyDefeatedStyle(unit: UnitData, view = this.unitViews.get(unit.id)) { if (!view) { return; } view.sprite.stop(); view.sprite.disableInteractive(); view.sprite.postFX.clear(); view.sprite.clearTint(); view.sprite.setAlpha(0); view.sprite.setVisible(false); this.setUnitHitZoneEnabled(view, false); view.label.setAlpha(0); view.label.setVisible(false); view.label.setColor('#b6b6b6'); view.label.setBackgroundColor(''); } private faceUnitToward(unit: UnitData, target: UnitData) { const view = this.unitViews.get(unit.id); if (!view) { return; } const direction = this.directionFromDelta(target.x - unit.x, target.y - unit.y); this.syncUnitMotion(unit, view, direction); } private flashDamage(unit: UnitData, damage: number, critical = false) { const view = this.unitViews.get(unit.id); if (!view) { return; } const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, critical ? `치명 -${damage}` : `-${damage}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: critical ? '22px' : '20px', color: critical ? '#ff8f5f' : '#ffdf7b', stroke: '#220909', strokeThickness: 4, fontStyle: '700' }); popup.setOrigin(0.5); popup.setDepth(30); view.sprite.setTintFill(0xffe0a3); this.time.delayedCall(120, () => { if (unit.hp <= 0) { this.applyDefeatedStyle(unit); } else { view.sprite.clearTint(); this.syncUnitMotion(unit, view); this.applyUnitLegibilityStyle(unit, view); } }); this.tweens.add({ targets: popup, y: popup.y - 28, alpha: 0, duration: 520, ease: 'Sine.easeOut', onComplete: () => popup.destroy() }); } private flashMiss(unit: UnitData) { const view = this.unitViews.get(unit.id); if (!view) { return; } const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, '회피', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#d9f1ff', stroke: '#071623', strokeThickness: 4, fontStyle: '700' }); popup.setOrigin(0.5); popup.setDepth(30); this.tweens.add({ targets: [view.sprite, view.label], x: `+=${unit.faction === 'ally' ? -8 : 8}`, duration: 90, yoyo: true, ease: 'Sine.easeOut' }); this.tweens.add({ targets: popup, y: popup.y - 24, alpha: 0, duration: 500, ease: 'Sine.easeOut', onComplete: () => popup.destroy() }); } private flashSupport(unit: UnitData, label: string, color: string) { const view = this.unitViews.get(unit.id); if (!view) { return; } const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color, stroke: '#071623', strokeThickness: 4, fontStyle: '700' }); popup.setOrigin(0.5); popup.setDepth(30); this.tweens.add({ targets: view.sprite, scale: view.sprite.scale * 1.08, duration: 120, yoyo: true, ease: 'Sine.easeOut' }); this.tweens.add({ targets: popup, y: popup.y - 24, alpha: 0, duration: 560, ease: 'Sine.easeOut', onComplete: () => popup.destroy() }); } private showTurnEndPrompt(message?: string, options: TurnEndPromptOptions = {}) { this.hideTurnEndPrompt(); this.turnPromptMode = options.mode ?? 'turn-end'; const width = 388; const height = 162; const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2; const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2; const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.96); panel.setOrigin(0); panel.setDepth(40); panel.setStrokeStyle(2, palette.gold, 0.9); this.turnPromptObjects.push(panel); const title = this.add.text(left + width / 2, top + 26, options.title ?? '모든 아군의 행동이 종료되었습니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#f2e3bf', fontStyle: '700' }); title.setOrigin(0.5); title.setDepth(41); this.turnPromptObjects.push(title); const body = this.add.text(left + width / 2, top + 58, options.body ?? '턴을 종료하시겠습니까?', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d4dce6', align: 'center', wordWrap: { width: width - 44, useAdvancedWrap: true } }); body.setOrigin(0.5); body.setDepth(41); this.turnPromptObjects.push(body); const primaryLabel = options.primaryLabel ?? '턴 종료'; const secondaryLabel = options.secondaryLabel ?? '전장 확인'; const primaryAction = options.primaryAction ?? (() => this.endAllyTurn()); const secondaryAction = options.secondaryAction ?? (() => this.hideTurnEndPrompt()); const buttonWidth = 136; const buttons = [ { label: primaryLabel, x: left + 119, action: primaryAction, primary: true }, { label: secondaryLabel, x: left + 269, action: secondaryAction, primary: false } ]; buttons.forEach(({ label, x, action, primary }) => { const bg = this.add.rectangle(x, top + 116, buttonWidth, 36, 0x1a2630, 0.95); bg.setDepth(41); bg.setStrokeStyle(1, primary ? palette.gold : palette.blue, 0.72); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.95)); bg.on('pointerdown', action); this.turnPromptObjects.push(bg); const text = this.add.text(x, top + 116, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f2e3bf', fontStyle: '700' }); text.setOrigin(0.5); text.setDepth(42); text.setInteractive({ useHandCursor: true }); text.on('pointerdown', action); this.turnPromptObjects.push(text); }); void message; } private hideTurnEndPrompt() { this.turnPromptObjects.forEach((object) => object.destroy()); this.turnPromptObjects = []; this.turnPromptMode = undefined; } private pushBattleLog(message: string) { const firstLine = this.compactBattleLogEntry(message); if (!firstLine) { return; } this.battleLog = [`${this.turnNumber}T ${firstLine}`, ...this.battleLog].slice(0, 10); } private compactBattleLogEntry(message: string) { const firstLine = message .split('\n') .map((line) => line.trim()) .find(Boolean); if (!firstLine) { return ''; } const normalized = firstLine.replace(/\s+/g, ' '); return this.truncateBattleLogText(normalized, 42); } private truncateBattleLogText(text: string, maxLength: number) { return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 3))}...` : text; } private battleLogDisplayParts(entry: string) { const match = /^(\d+T)\s+(.+)$/.exec(entry); return { turn: match?.[1] ?? '', text: match?.[2] ?? entry }; } private battleLogVisual(entry: string): { icon: BattleUiIconKey; tone: number } { const { text } = this.battleLogDisplayParts(entry); if (text.includes('퇴각') || text.includes('격파') || text.includes('전투 불능')) { return { icon: 'critical', tone: 0xd8732c }; } if (text.includes('치명')) { return { icon: 'critical', tone: 0xd8732c }; } if (text.includes('반격')) { return { icon: 'counter', tone: 0xb64a45 }; } if (text.includes('책략') || text.includes('화계') || text.includes('교란') || text.includes('고무')) { return { icon: 'strategy', tone: palette.blue }; } if (text.includes('회복') || text.includes('병력') || text.includes('거점') || text.includes('상태효과')) { return { icon: 'heal', tone: 0x59d18c }; } if (text.includes('이동') || text.includes('접근')) { return { icon: 'move', tone: 0x83d6ff }; } if (text.includes('대기') || text.includes('턴') || text.includes('차례')) { return { icon: 'leadership', tone: palette.gold }; } if (text.includes('공격') || text.includes('피해') || text.includes('→')) { return { icon: 'sword', tone: 0xd8b15f }; } return { icon: 'success', tone: 0x9fb0bf }; } private sideContentTop() { return this.layout.panelY + 152; } private sideContentBottom(padding = 16) { const panelBottom = this.layout.panelY + this.layout.panelHeight - padding; return this.miniMapVisible && this.miniMapLayout ? this.miniMapLayout.y - padding : panelBottom; } private renderRecentActionLogPanel(x: number, y: number, width: number, height = 118) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0b1118, 0.96)); bg.setOrigin(0); bg.setDepth(30); bg.setStrokeStyle(1, 0x647485, 0.68); const title = this.trackSideObject(this.add.text(x + 12, y + 8, '최근 행동', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#f2e3bf', fontStyle: '700' })); title.setDepth(31); const countText = this.trackSideObject(this.add.text(x + width - 12, y + 9, `${this.battleLog.length}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700' })); countText.setOrigin(1, 0); countText.setDepth(31); const entries = this.battleLog.slice(0, 4); if (entries.length === 0) { const emptyText = this.trackSideObject(this.add.text(x + 12, y + 45, '아직 기록된 행동이 없습니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#9fb0bf' })); emptyText.setDepth(31); return; } entries.forEach((entry, index) => { const rowY = y + 32 + index * 20; const { turn, text } = this.battleLogDisplayParts(entry); const visual = this.battleLogVisual(entry); const row = this.trackSideObject(this.add.rectangle(x + 8, rowY, width - 16, 18, index === 0 ? 0x17232e : 0x101820, index === 0 ? 0.98 : 0.86)); row.setOrigin(0); row.setDepth(31); row.setStrokeStyle(1, visual.tone, index === 0 ? 0.62 : 0.28); const iconFrame = this.trackSideObject(this.add.rectangle(x + 21, rowY + 9, 18, 18, 0x0a0f14, 0.96)); iconFrame.setDepth(32); iconFrame.setStrokeStyle(1, visual.tone, 0.5); const icon = this.trackSideIcon(x + 21, rowY + 9, visual.icon, 17); icon.setDepth(33); const turnText = this.trackSideObject(this.add.text(x + width - 12, rowY + 3, turn, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fontStyle: '700' })); turnText.setOrigin(1, 0); turnText.setDepth(32); const eventText = this.trackSideObject(this.add.text(x + 36, rowY + 2, this.truncateBattleLogText(text, 32), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: index === 0 ? '#e8dfca' : '#c8d2dd', fontStyle: index === 0 ? '700' : '400', fixedWidth: width - 84 })); eventText.setDepth(32); }); } private renderBondPanel(message?: string) { this.clearSidePanelContent(); const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = this.sideContentTop(); this.trackSideObject(this.add.text(left, top, '공명 관계', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#f2e3bf', fontStyle: '700' })); this.trackSideObject(this.add.text(left, top + 34, '같은 적을 노리거나 전투 사이 이벤트로 상승합니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#9fb0bf' })); Array.from(this.bondStates.values()).forEach((bond, index) => { const y = top + 70 + index * 104; const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId)); const bg = this.trackSideObject(this.add.rectangle(left, y, width, 92, 0x101820, 0.9)); bg.setOrigin(0); bg.setStrokeStyle(1, bond.level >= 70 ? palette.gold : palette.blue, 0.66); this.trackSideObject(this.add.text(left + 12, y + 8, `${first} · ${second}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#e8dfca', fontStyle: '700' })); const levelText = this.trackSideObject(this.add.text(left + width - 12, y + 8, `${bond.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#f2e3bf', fontStyle: '700' })); levelText.setOrigin(1, 0); this.trackSideObject(this.add.text(left + 12, y + 33, bond.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d8b15f' })); this.drawGauge(left + 86, y + 39, width - 148, 8, bond.exp / 100, bond.level >= 70 ? 0xd8b15f : 0x58aee0); const bonus = this.bondBonus(bond); this.trackSideObject(this.add.text(left + 12, y + 59, `피해 +${bonus.damageBonus}% 연계 ${bonus.chainRate}% 전투 +${bond.battleExp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d4dce6' })); }); this.renderPanelMessage(message ?? '공격 명령에서 같은 예상 목표를 노리면 공명 경험치가 쌓입니다.', left, top + 396, width); } private renderSituationPanel(message?: string) { this.clearSidePanelContent(); this.setMiniMapVisible(true); const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = this.sideContentTop(); const actedCount = battleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length; const allyCount = battleUnits.filter((unit) => unit.faction === 'ally').length; const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; const objectiveStates = this.objectiveStates(this.battleOutcome); const logHeight = 118; const logTop = Math.max(top + 236, this.sideContentBottom(10) - logHeight); const messageTop = top + 168; const messageHeight = Math.max(52, Math.min(72, logTop - messageTop - 12)); this.trackSideObject(this.add.text(left, top, '전황', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', color: '#f2e3bf', fontStyle: '700' })); this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + 46, width); this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + 84, width); this.renderSituationLine('남은 적', `${enemyCount}`, left, top + 122, width); if (message) { this.renderPanelMessage(this.compactSituationMessage(message), left, messageTop, width, messageHeight); this.renderRecentActionLogPanel(left, logTop, width, logHeight); return; } this.trackSideObject(this.add.text(left, messageTop, '목표 현황', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#f2e3bf', fontStyle: '700' })); const objectiveRows = Math.max(1, Math.floor((logTop - (messageTop + 26) - 8) / 28)); objectiveStates.slice(0, objectiveRows).forEach((objective, index) => { this.renderObjectiveStateRow(objective, left, messageTop + 26 + index * 28, width); }); this.renderRecentActionLogPanel(left, logTop, width, logHeight); } private compactSituationMessage(message: string) { return message .split('\n') .slice(0, 2) .map((line) => this.truncateBattleLogText(line, 26)) .join('\n'); } private updateObjectiveTracker() { if (!this.objectiveTrackerText || !this.objectiveTrackerSubText) { return; } const leader = battleUnits.find((unit) => unit.id === leaderUnitId); const leaderProgress = leader && leader.hp > 0 ? `${leader.hp}/${leader.maxHp}` : '완료'; const quickObjective = this.objectiveStates(this.battleOutcome).find((objective) => objective.id === 'quick'); const quickText = quickObjective ? ` · 보너스: ${quickObjective.label} ${this.objectiveStatusText(quickObjective)}` : ''; const victoryText = this.battleOutcome === 'victory' ? `승리 목표 완료: ${battleScenario.victoryConditionLabel}` : `승리 목표: ${battleScenario.victoryConditionLabel} · ${this.unitName(leaderUnitId)} ${leaderProgress}`; const defeatText = this.battleOutcome === 'defeat' ? `패배 조건 발생: ${battleScenario.defeatConditionLabel}` : `패배 조건: ${battleScenario.defeatConditionLabel}`; this.objectiveTrackerText.setText(victoryText); this.objectiveTrackerSubText.setText(`${defeatText}${quickText}`); } private renderObjectiveStateRow(objective: BattleObjectiveState, x: number, y: number, width: number) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, 25, 0x101820, 0.86)); bg.setOrigin(0); bg.setStrokeStyle(1, this.objectiveStatusStroke(objective), 0.5); this.trackSideObject(this.add.text(x + 10, y + 5, this.objectiveStatusText(objective), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: this.objectiveStatusColor(objective), fontStyle: '700' })); this.trackSideObject(this.add.text(x + 54, y + 4, objective.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', fontStyle: '700' })); const detail = this.trackSideObject(this.add.text(x + width - 10, y + 4, objective.detail, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf' })); detail.setOrigin(1, 0); } private objectiveStatusText(objective: BattleObjectiveState) { if (objective.status === 'done') { return '완료'; } if (objective.status === 'failed') { return '실패'; } if (objective.id === 'liu-bei') { return '유지'; } if (objective.id === 'quick') { return '가능'; } return '진행'; } private objectiveStatusColor(objective: BattleObjectiveState) { if (objective.status === 'done') { return '#a8ffd0'; } if (objective.status === 'failed') { return '#ffb6a6'; } return '#f4dfad'; } private objectiveStatusStroke(objective: BattleObjectiveState) { if (objective.status === 'done') { return 0x59d18c; } if (objective.status === 'failed') { return 0xb86b55; } return palette.gold; } private renderThreatDetail(tile: ThreatTile) { this.clearSidePanelContent(); const terrain = battleMap.terrain[tile.y][tile.x]; const terrainRule = getTerrainRule(terrain); const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = this.sideContentTop(); const strongest = [...tile.enemies].sort((a, b) => this.actionPower(b, 'attack') - this.actionPower(a, 'attack'))[0]; this.trackSideObject(this.add.text(left, top, '위험 범위', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', color: '#f2e3bf', fontStyle: '700' })); this.renderSituationLine('좌표', `${tile.x + 1}, ${tile.y + 1}`, left, top + 48, width); this.renderSituationLine('지형', `${terrainRule.label} / 방어 ${this.formatSignedValue(terrainRule.defenseBonus)}`, left, top + 86, width); this.renderSituationLine('위협 수', `${tile.enemies.length}`, left, top + 124, width); this.renderSituationLine('최대 태세', this.enemyBehaviorLabel(tile.strongestBehavior), left, top + 162, width); this.trackSideObject(this.add.text(left, top + 206, '위협 부대', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#f2e3bf', fontStyle: '700' })); tile.enemies.slice(0, 4).forEach((enemy, index) => { const rowY = top + 232 + index * 28; const unitClass = getUnitClass(enemy.classKey); const bg = this.trackSideObject(this.add.rectangle(left, rowY, width, 24, 0x101820, 0.86)); bg.setOrigin(0); bg.setStrokeStyle(1, this.threatColor(this.enemyBehavior(enemy)), 0.52); this.trackSideObject(this.add.text(left + 10, rowY + 5, `${enemy.name} · ${unitClass.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#d4dce6', fontStyle: '700' })); const value = this.trackSideObject(this.add.text(left + width - 10, rowY + 5, `${this.enemyBehaviorLabel(this.enemyBehavior(enemy))} / 공 ${this.actionPower(enemy, 'attack')} / 사 ${this.attackRange(enemy)}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#f4dfad' })); value.setOrigin(1, 0); }); const message = strongest ? `${strongest.name} 최대 위협. 방어 보정 지형을 활용하세요.` : '현재 이 칸을 위협하는 적이 없습니다.'; this.renderPanelMessage(message, left, top + 348, width, 46); } private renderTerrainDetail(x: number, y: number) { this.clearSidePanelContent(); const terrain = battleMap.terrain[y][x]; const terrainRule = getTerrainRule(terrain); const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = this.sideContentTop(); const attackDefense = Math.floor(terrainRule.defenseBonus / 3); const strategyDefense = Math.floor(terrainRule.defenseBonus / 4); const itemDefense = Math.floor(terrainRule.defenseBonus / 5); this.trackSideObject(this.add.text(left, top, '지형 정보', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', color: '#f2e3bf', fontStyle: '700' })); this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + 48, width); this.renderSituationLine('지형', terrainDisplayLabels[terrain], left, top + 92, width); this.renderSituationLine('이동 비용', terrainRule.passable === false ? '진입 불가' : `${terrainRule.moveCost}`, left, top + 136, width); this.renderSituationLine('방어 보정', this.formatSignedValue(terrainRule.defenseBonus), left, top + 180, width); this.renderSituationLine('공격 방어', this.formatSignedValue(attackDefense), left, top + 224, width); this.renderSituationLine('책략 저항', this.formatSignedValue(strategyDefense), left, top + 268, width); this.renderSituationLine('도구 피해', this.formatSignedValue(itemDefense), left, top + 312, width); const recovery = terrainRule.recoveryHp || terrainRule.moraleBonus ? `\n턴 시작 시 병력 +${terrainRule.recoveryHp ?? 0}, 사기 +${terrainRule.moraleBonus ?? 0} 효과를 받습니다.` : ''; const message = terrainRule.passable === false ? '이 지형은 이동할 수 없습니다. 길목과 다리, 완만한 평지를 찾아 우회해야 합니다.' : terrainRule.defenseBonus > 0 ? '이 지형에 있는 부대는 방어 보정으로 받는 피해가 줄어듭니다.' : '이 지형은 기본 방어 보정이 없습니다.'; this.renderPanelMessage(`${message}${recovery}`, left, top + 370, width, 76); } private formatSignedValue(value: number) { if (value > 0) { return `+${value}`; } return `${value}`; } private renderSituationLine(label: string, value: string, x: number, y: number, width: number) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, 34, 0x101820, 0.86)); bg.setOrigin(0); bg.setStrokeStyle(1, 0x53606c, 0.46); this.trackSideObject(this.add.text(x + 12, y + 8, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#9fb0bf' })); const valueText = this.trackSideObject(this.add.text(x + width - 12, y + 7, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f2e3bf', fontStyle: '700' })); valueText.setOrigin(1, 0); } private bondBonus(bond: BondState) { return { damageBonus: Math.floor(bond.level / 20) * 3, chainRate: bond.level >= 70 ? 18 : bond.level >= 50 ? 8 : 0 }; } private unitName(unitId: string) { const battleName = battleUnits.find((unit) => unit.id === unitId)?.name; if (battleName) { return battleName; } const canonicalName = canonicalAllyNames[unitId]; if (canonicalName) { return canonicalName; } return getCampaignState().roster.find((unit) => unit.id === unitId)?.name ?? unitId; } private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) { this.rosterTab = tab; this.clearSidePanelContent(); this.setMiniMapVisible(true); const { panelX, panelY, panelWidth, panelHeight } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = this.sideContentTop(); const tabGap = 8; const tabWidth = (width - tabGap) / 2; (['ally', 'enemy'] as RosterTab[]).forEach((targetTab, index) => { const active = targetTab === tab; const x = left + index * (tabWidth + tabGap); const tabBg = this.trackSideObject(this.add.rectangle(x, top, tabWidth, 38, active ? 0x25384a : 0x141e29, active ? 0.98 : 0.86)); tabBg.setOrigin(0); tabBg.setStrokeStyle(1, active ? palette.gold : 0x53606c, active ? 0.92 : 0.62); tabBg.setInteractive({ useHandCursor: true }); tabBg.on('pointerdown', () => this.renderRosterPanel(targetTab)); const tabText = this.trackSideObject(this.add.text(x + tabWidth / 2, top + 19, rosterLabels[targetTab], { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: active ? '#f4dfad' : '#aeb7c2', fontStyle: '700' })); tabText.setOrigin(0.5); }); const units = battleUnits.filter((unit) => unit.faction === tab); const listTop = top + 54; units.forEach((unit, index) => { this.renderRosterRow(unit, left, listTop + index * 50, width); }); const logHeight = 118; const logTop = this.sideContentBottom(10) - logHeight; const listBottom = listTop + units.length * 50; const canShowLog = listBottom + 12 < logTop; if (message) { const messageHeight = canShowLog ? 70 : 104; const messageBottomLimit = canShowLog ? logTop - 10 : panelY + panelHeight - 124; const messageTop = Math.min(messageBottomLimit - messageHeight, listBottom + 14); this.renderPanelMessage(message, left, messageTop, width, messageHeight); } if (canShowLog) { this.renderRecentActionLogPanel(left, logTop, width, logHeight); } } private renderRosterRow(unit: UnitData, x: number, y: number, width: number) { const acted = this.actedUnitIds.has(unit.id); const active = this.selectedUnit?.id === unit.id; const unitClass = getUnitClass(unit.classKey); const rowBg = this.trackSideObject(this.add.rectangle(x, y, width, 42, active ? 0x2f4050 : 0x101820, acted ? 0.82 : 0.94)); rowBg.setOrigin(0); rowBg.setStrokeStyle(1, active ? palette.gold : unit.faction === 'ally' ? palette.blue : 0xb86b55, active ? 0.88 : 0.52); rowBg.setInteractive({ useHandCursor: true }); rowBg.on('pointerdown', () => this.selectUnit(unit)); const nameColor = acted ? '#a8a8a8' : unit.faction === 'ally' ? '#e9f0f8' : '#ffe0d8'; this.trackSideObject(this.add.text(x + 12, y + 7, unit.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: nameColor, fontStyle: '700' })); this.trackSideObject(this.add.text(x + 88, y + 9, unitClass.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: acted ? '#b7bec8' : '#9fb0bf' })); const hpText = this.trackSideObject(this.add.text(x + width - 12, y + 7, `${unit.hp}/${unit.maxHp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: acted ? '#d8cfb5' : '#f0e4c8', fontStyle: '700' })); hpText.setOrigin(1, 0); this.drawGauge(x + 88, y + 29, width - 100, 6, unit.hp / unit.maxHp, acted ? 0x9a9a9a : 0x59d18c); if (acted) { const actedText = this.trackSideObject(this.add.text(x + 12, y + 27, '행동완료', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#bdbdbd' })); actedText.setAlpha(0.95); } } private renderUnitDetail(unit: UnitData, message?: string) { this.clearSidePanelContent(); this.setMiniMapVisible(false); const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; const top = this.sideContentTop(); const acted = this.actedUnitIds.has(unit.id); const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55; const unitClass = getUnitClass(unit.classKey); const terrain = battleMap.terrain[unit.y][unit.x]; const terrainRule = getTerrainRule(terrain); const terrainRating = unitClass.terrainRatings[terrain]; const direction = this.currentUnitDirection(unit); const header = this.trackSideObject(this.add.rectangle(left, top, width, 64, 0x101820, 0.96)); header.setOrigin(0); header.setStrokeStyle(1, factionColor, 0.76); const classIcon = this.trackSideIcon(left + 28, top + 32, this.unitMoveIcon(unit), 42); classIcon.setAlpha(acted ? 0.86 : 0.96); const headerTextX = left + 58; this.trackSideObject(this.add.text(headerTextX, top + 7, `${rosterLabels[unit.faction]} / ${unitClass.family}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: unit.faction === 'ally' ? '#9fd0ff' : '#ffb2a4', fontStyle: '700' })); this.trackSideObject(this.add.text(headerTextX, top + 27, `${unit.name} ${unitClass.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: acted ? '#bdbdbd' : '#f2e3bf', fontStyle: '700', fixedWidth: width - 154 })); const levelBadge = this.trackSideObject(this.add.rectangle(left + width - 84, top + 8, 70, 26, 0x1b2834, 0.94)); levelBadge.setOrigin(0); levelBadge.setStrokeStyle(1, factionColor, 0.6); const levelText = this.trackSideObject(this.add.text(left + width - 49, top + 21, `Lv ${unit.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: acted ? '#d8cfb5' : '#f4dfad', fontStyle: '700' })); levelText.setOrigin(0.5); if (this.phase !== 'command') { const backBg = this.trackSideObject(this.add.rectangle(left + width - 72, top + 38, 58, 22, 0x223142, 0.95)); backBg.setOrigin(0); backBg.setStrokeStyle(1, palette.gold, 0.72); backBg.setInteractive({ useHandCursor: true }); backBg.on('pointerdown', () => this.returnToRosterPanel(unit.faction)); const backText = this.trackSideObject(this.add.text(left + width - 43, top + 49, '목록', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#f4dfad', fontStyle: '700' })); backText.setOrigin(0.5); } else { const actionText = this.trackSideObject(this.add.text(left + width - 49, top + 48, acted ? '행동완료' : `EXP ${unit.exp}/100`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: acted ? '#bdbdbd' : '#c8d2dd', fontStyle: '700' })); actionText.setOrigin(0.5); } const hpPanel = this.trackSideObject(this.add.rectangle(left, top + 72, width, 34, 0x101820, 0.92)); hpPanel.setOrigin(0); hpPanel.setStrokeStyle(1, 0x40515e, 0.58); this.trackSideIcon(left + 18, top + 89, 'hp', 29); this.trackSideObject(this.add.text(left + 40, top + 79, '병력', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d4dce6', fontStyle: '700' })); const hpValue = this.trackSideObject(this.add.text(left + width - 12, top + 79, `${unit.hp} / ${unit.maxHp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#f0e4c8', fontStyle: '700' })); hpValue.setOrigin(1, 0); this.drawGauge(left + 104, top + 93, width - 168, 10, unit.hp / unit.maxHp, 0x59d18c); const attackBonus = this.equipmentAttackBonus(unit); const summaryGap = 6; const summaryBoxWidth = (width - summaryGap * 2) / 3; this.renderCompactValueBox(left, top + 114, summaryBoxWidth, 'attack', '공격', `${unit.attack + attackBonus}`); this.renderCompactValueBox(left + summaryBoxWidth + summaryGap, top + 114, summaryBoxWidth, 'defense', '방어+', `${this.equipmentDefenseBonus(unit)}`); this.renderCompactValueBox( left + (summaryBoxWidth + summaryGap) * 2, top + 114, summaryBoxWidth, this.unitMoveIcon(unit), '방향', `${this.directionSymbol(direction)} ${this.unitDirectionLabel(direction)}` ); const effects = this.unitEffectSummaries(unit); const hasEffects = effects.length > 0; const statTop = top + (hasEffects ? 154 : 164); const statGap = hasEffects ? 21 : 24; statLabels.forEach((stat, index) => { this.renderStatRow(stat.key, stat.label, unit.stats[stat.key], left, statTop + index * statGap, width); }); const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} · ${terrainRule.label} ${terrainRating}% · 이동 ${unit.move}${acted ? ' · 행동완료' : ''}`; const statusTop = top + (hasEffects ? 264 : 288); const statusPanel = this.trackSideObject(this.add.rectangle(left, statusTop, width, 32, 0x101820, 0.9)); statusPanel.setOrigin(0); statusPanel.setStrokeStyle(1, 0x40515e, 0.5); this.trackSideIcon(left + 17, statusTop + 16, acted ? 'counter' : this.unitMoveIcon(unit), 25); this.trackSideObject(this.add.text(left + 36, statusTop + 6, positionText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: acted ? '#bdbdbd' : '#9fb0bf', wordWrap: { width: width - 48, useAdvancedWrap: true }, lineSpacing: 2 })); const effectTop = top + (hasEffects ? 302 : 326); const equipmentTop = hasEffects ? top + 350 : effectTop; if (hasEffects) { this.renderUnitEffectCards(effects, left, effectTop, width); } this.renderEquipmentSummary(unit, left, equipmentTop, width, hasEffects); if (message) { this.renderPanelMessage(message, left, equipmentTop + 156, width, 42); } this.showFacingIndicator(unit); } private unitEffectSummaries(unit: UnitData): UnitEffectSummary[] { const effects: UnitEffectSummary[] = []; const buff = this.battleBuffs.get(unit.id); const terrainRule = getTerrainRule(battleMap.terrain[unit.y][unit.x]); const turnStartParts = [ terrainRule.recoveryHp ? `병력 +${terrainRule.recoveryHp}` : '', terrainRule.moraleBonus ? `사기 +${terrainRule.moraleBonus}` : '' ].filter(Boolean); if (buff) { effects.push({ icon: 'focus', label: buff.label, value: this.battleBuffShortText(buff), tone: palette.gold, turns: buff.turns }); } if (turnStartParts.length > 0) { effects.push({ icon: 'terrain', label: `${terrainRule.label} 턴 시작`, value: turnStartParts.join(' / '), tone: 0x59d18c }); } return effects; } private renderUnitEffectCards(effects: UnitEffectSummary[], x: number, y: number, width: number) { const visible = effects.slice(0, 2); const gap = 6; const cardWidth = (width - gap) / 2; visible.forEach((effect, index) => { const cardX = x + index * (cardWidth + gap); const bg = this.trackSideObject(this.add.rectangle(cardX, y, cardWidth, 42, 0x101820, 0.97)); bg.setOrigin(0); bg.setStrokeStyle(1, effect.tone, 0.62); const iconFrame = this.trackSideObject(this.add.rectangle(cardX + 20, y + 21, 32, 32, 0x0a0f14, 0.92)); iconFrame.setStrokeStyle(1, effect.tone, 0.56); this.trackSideIcon(cardX + 20, y + 21, effect.icon, 30); this.trackSideObject(this.add.text(cardX + 42, y + 4, effect.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', fontStyle: '700', fixedWidth: cardWidth - 52 })); this.trackSideObject(this.add.text(cardX + 42, y + 20, this.truncateUiText(effect.value, 15), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#f2e3bf', fontStyle: '700', fixedWidth: cardWidth - 52 })); if (effect.turns) { const badge = this.trackSideObject(this.add.rectangle(cardX + cardWidth - 18, y + 14, 26, 18, 0x211624, 0.96)); badge.setStrokeStyle(1, effect.tone, 0.78); const turnText = this.trackSideObject(this.add.text(cardX + cardWidth - 18, y + 14, `${effect.turns}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#fff2b8', fontStyle: '700' })); turnText.setOrigin(0.5); } }); if (effects.length > visible.length) { const extraText = this.trackSideObject(this.add.text(x + width - 8, y + 4, `+${effects.length - visible.length}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#f4dfad', fontStyle: '700' })); extraText.setOrigin(1, 0); } } private renderEquipmentSummary(unit: UnitData, x: number, y: number, width: number, compact = false) { this.trackSideIcon(x + 15, y + 12, 'mastery', compact ? 25 : 30); this.trackSideObject(this.add.text(x + 32, y, '장비 / 숙련도', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: compact ? '14px' : '17px', color: '#f2e3bf', fontStyle: '700' })); equipmentSlots.forEach((slot, index) => { const rowTop = y + (compact ? 20 : 28) + index * (compact ? 30 : 40); this.renderEquipmentRow(unit, slot, x, rowTop, width, compact); }); } private renderEquipmentRow(unit: UnitData, slot: EquipmentSlot, x: number, y: number, width: number, compact = false) { const state = unit.equipment[slot]; const item = getItem(state.itemId); const next = equipmentExpToNext(state.level); const isTreasure = item.rank === 'treasure'; const rowHeight = compact ? 28 : 38; const bg = this.trackSideObject(this.add.rectangle(x, y, width, rowHeight, isTreasure ? 0x1f2430 : 0x101820, isTreasure ? 0.98 : 0.95)); bg.setOrigin(0); bg.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.66 : 0.5); const iconSize = compact ? 26 : 36; const iconFrame = this.trackSideObject(this.add.rectangle(x + (compact ? 19 : 21), y + rowHeight / 2, iconSize, iconSize, 0x0a0f14, 0.92)); iconFrame.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.78 : 0.54); this.trackSideIcon(x + (compact ? 19 : 21), y + rowHeight / 2, this.itemIcon(item, slot), compact ? 24 : 34); this.trackSideObject(this.add.text(x + 40, y + (compact ? 1 : 3), equipmentSlotLabels[slot], { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: compact ? '10px' : '11px', color: '#9fb0bf', fontStyle: '700' })); const bonusText = this.itemBonusText(item); this.trackSideObject(this.add.text(x + 78, y + (compact ? 1 : 3), bonusText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: compact ? '10px' : '11px', color: '#aeb7c2', fixedWidth: width - 170 })); this.trackSideObject(this.add.text(x + 40, y + (compact ? 12 : 17), item.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: compact ? '12px' : '15px', color: isTreasure ? '#f4dfad' : '#d4dce6', fontStyle: '700', fixedWidth: width - 150 })); const levelText = this.trackSideObject(this.add.text(x + width - 10, y + (compact ? 2 : 5), `Lv ${state.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: compact ? '12px' : '15px', color: isTreasure ? '#f2e3bf' : '#d4dce6', fontStyle: '700' })); levelText.setOrigin(1, 0); this.trackSideIcon(x + width - 76, y + (compact ? 20 : 27), 'mastery', compact ? 16 : 20); const expText = this.trackSideObject(this.add.text(x + width - 10, y + (compact ? 17 : 24), `${state.exp}/${next}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: compact ? '10px' : '11px', color: '#c8d2dd', fontStyle: '700' })); expText.setOrigin(1, 0); this.drawGauge(x + 40, y + (compact ? 24 : 32), width - 124, compact ? 4 : 5, state.exp / next, isTreasure ? 0xd8b15f : 0x58aee0); } private createBattleUiIcon(x: number, y: number, icon: BattleUiIconKey, size = 22) { const image = this.add.image(x, y, battleUiIconTextureKey, battleUiIconFrames[icon]); image.setDisplaySize(size, size); return image; } private trackSideIcon(x: number, y: number, icon: BattleUiIconKey, size = 22) { return this.trackSideObject(this.createBattleUiIcon(x, y, icon, size)); } private trackCombatIcon(x: number, y: number, icon: BattleUiIconKey, size = 22, depth = 85) { const image = this.trackCombatObject(this.createBattleUiIcon(x, y, icon, size)); image.setDepth(depth); return image; } private itemIcon(item: ItemDefinition, slot: EquipmentSlot = item.slot): BattleUiIconKey { if (slot === 'armor') { return 'armor'; } if (slot === 'accessory') { return item.id.includes('quiver') ? 'bow' : 'accessory'; } if (item.id.includes('bow') || item.id.includes('quiver')) { return 'bow'; } if (item.id.includes('spear') || item.id.includes('glaive') || item.id.includes('halberd') || item.id.includes('piercer')) { return 'spear'; } if (item.id.includes('axe')) { return 'axe'; } if (item.id.includes('fan') || item.id.includes('manual') || item.id.includes('scroll') || (item.strategyBonus ?? 0) > (item.attackBonus ?? 0)) { return 'strategy'; } return 'sword'; } private unitMoveIcon(unit: UnitData): BattleUiIconKey { return unit.classKey === 'cavalry' ? 'horse' : 'move'; } private statIcon(key: keyof UnitStats): BattleUiIconKey { switch (key) { case 'might': return 'might'; case 'intelligence': return 'intelligence'; case 'leadership': return 'leadership'; case 'agility': return 'move'; case 'luck': return 'luck'; default: return 'might'; } } private commandIcon(command: BattleCommand, unit: UnitData): BattleUiIconKey { switch (command) { case 'attack': return this.itemIcon(getItem(unit.equipment.weapon.itemId), 'weapon'); case 'strategy': return 'strategy'; case 'item': return 'accessory'; case 'wait': return this.unitMoveIcon(unit); default: return 'sword'; } } private usableIcon(usable: BattleUsable): BattleUiIconKey { return this.usableVisualStyle(usable).icon; } private actionIcon(preview: CombatPreview): BattleUiIconKey { if (preview.action === 'attack') { return this.itemIcon(getItem(preview.attacker.equipment.weapon.itemId), 'weapon'); } if (preview.action === 'strategy') { return preview.usable ? this.usableIcon(preview.usable) : 'strategy'; } return preview.usable ? this.itemIcon(getItem(preview.attacker.equipment.accessory.itemId), 'accessory') : 'accessory'; } private itemBonusText(item: ReturnType) { const parts = [ item.attackBonus ? `공격+${item.attackBonus}` : '', item.defenseBonus ? `방어+${item.defenseBonus}` : '', item.strategyBonus ? `책략+${item.strategyBonus}` : '' ].filter(Boolean); return parts.length > 0 ? parts.join(' ') : '-'; } private equipmentAttackBonus(unit: UnitData) { return equipmentSlots.reduce((total, slot) => { const item = getItem(unit.equipment[slot].itemId); const levelBonus = slot === 'weapon' ? unit.equipment[slot].level - 1 : 0; return total + (item.attackBonus ?? 0) + levelBonus; }, 0); } private equipmentStrategyBonus(unit: UnitData) { return equipmentSlots.reduce((total, slot) => { const item = getItem(unit.equipment[slot].itemId); const levelBonus = slot === 'accessory' ? unit.equipment[slot].level - 1 : 0; return total + (item.strategyBonus ?? 0) + levelBonus; }, 0); } private equipmentDefenseBonus(unit: UnitData) { return equipmentSlots.reduce((total, slot) => { const item = getItem(unit.equipment[slot].itemId); const levelBonus = slot === 'armor' ? unit.equipment[slot].level - 1 : 0; return total + (item.defenseBonus ?? 0) + levelBonus; }, 0); } private renderCompactValueBox(x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, 42, 0x16212d, 0.97)); bg.setOrigin(0); bg.setStrokeStyle(1, 0x647485, 0.72); const iconFrame = this.trackSideObject(this.add.rectangle(x + 17, y + 21, 30, 30, 0x0a0f14, 0.86)); iconFrame.setStrokeStyle(1, 0x53606c, 0.52); this.trackSideIcon(x + 17, y + 21, icon, 28); this.trackSideObject(this.add.text(x + 36, y + 5, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#9fb0bf', fixedWidth: Math.max(36, width - 42) })); const valueText = this.trackSideObject(this.add.text(x + width - 7, y + 29, value, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#f2e3bf', fontStyle: '700' })); valueText.setOrigin(1, 0.5); } private renderStatRow(key: keyof UnitStats, label: string, value: number, x: number, y: number, width: number) { const rowBg = this.trackSideObject(this.add.rectangle(x, y - 1, width, 22, 0x101820, 0.5)); rowBg.setOrigin(0); this.trackSideIcon(x + 14, y + 10, this.statIcon(key), 23); this.trackSideObject(this.add.text(x + 34, y + 1, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d4dce6', fontStyle: '700' })); this.drawGauge(x + 104, y + 7, width - 158, 8, value / 100, value >= 80 ? 0xd8b15f : 0x58aee0); const valueText = this.trackSideObject(this.add.text(x + width - 8, y - 1, `${value}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f2e3bf', fontStyle: '700' })); valueText.setOrigin(1, 0); } private drawGauge(x: number, y: number, width: number, height: number, ratio: number, color: number) { const track = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0a0f14, 0.94)); track.setOrigin(0); track.setStrokeStyle(1, 0x40515e, 0.58); const fillWidth = Math.max(2, Math.floor(width * Phaser.Math.Clamp(ratio, 0, 1))); const fill = this.trackSideObject(this.add.rectangle(x + 1, y + 1, fillWidth - 2, Math.max(2, height - 2), color, 0.96)); fill.setOrigin(0); } private renderPanelMessage(text: string, x: number, y: number, width: number, height = 74) { const safeY = Math.max(this.sideContentTop(), Math.min(y, this.sideContentBottom() - height)); const compact = height < 60; const maxLines = Math.max(1, Math.floor((height - 16) / (compact ? 17 : 22))); const bg = this.trackSideObject(this.add.rectangle(x, safeY, width, height, 0x101820, 0.94)); bg.setOrigin(0); bg.setStrokeStyle(1, 0x647485, 0.66); const messageText = this.trackSideObject(this.add.text(x + 12, safeY + 10, text, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: compact ? '13px' : '15px', color: '#d4dce6', wordWrap: { width: width - 24, useAdvancedWrap: true }, lineSpacing: compact ? 2 : 4, maxLines })); bg.setDepth(32); messageText.setDepth(33); } private returnToRosterPanel(tab: RosterTab) { this.selectedUnit = undefined; this.pendingMove = undefined; this.phase = 'idle'; this.clearMarkers(); this.hideCommandMenu(); this.refreshUnitLegibilityStyles(); this.renderRosterPanel(tab); } private clearSidePanelContent() { this.sidePanelObjects.forEach((object) => object.destroy()); this.sidePanelObjects = []; this.clearFacingIndicator(); } private trackSideObject(object: T) { this.sidePanelObjects.push(object); return object; } private setInfo(text: string) { if (this.selectedUnit) { this.renderUnitDetail(this.selectedUnit, text); return; } this.renderRosterPanel(this.rosterTab, text); } getDebugState() { const campaign = getCampaignState(); const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); const sortieFormationAssignments = this.effectiveSortieFormationAssignments(campaign); const deployedAllyIds = battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id); return { scene: this.scene.key, battleId: battleScenario.id, battleTitle: battleScenario.title, victoryConditionLabel: battleScenario.victoryConditionLabel, defeatConditionLabel: battleScenario.defeatConditionLabel, selectedSortieUnitIds: effectiveSortieUnitIds, sortieFormationAssignments: { ...sortieFormationAssignments }, deployedAllyIds, deployedAllyPositions: battleUnits .filter((unit) => unit.faction === 'ally') .map((unit) => ({ id: unit.id, name: unit.name, x: unit.x, y: unit.y, formationRole: sortieFormationAssignments[unit.id] ?? this.defaultSortieFormationRole(unit) })), turnNumber: this.turnNumber, activeFaction: this.activeFaction, phase: this.phase, battleOutcome: this.battleOutcome ?? null, mapTextureKey: battleScenario.mapTextureKey, mapTextureReady: this.textures.exists(battleScenario.mapTextureKey), mapBackgroundReady: this.mapBackground?.texture.key === battleScenario.mapTextureKey, resultVisible: this.resultObjects.length > 0, turnPromptVisible: this.turnPromptObjects.length > 0, turnPromptMode: this.turnPromptMode ?? null, markerCount: this.markers.length, threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length, enemyMoveMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'enemy-move').length, camera: { x: this.cameraTileX, y: this.cameraTileY, visibleColumns: this.layout.visibleColumns, visibleRows: this.layout.visibleRows, mapWidth: battleMap.width, mapHeight: battleMap.height }, selectedUnitId: this.selectedUnit?.id ?? null, pendingMove: this.pendingMove ? { unitId: this.pendingMove.unit.id, from: { x: this.pendingMove.fromX, y: this.pendingMove.fromY }, to: { x: this.pendingMove.toX, y: this.pendingMove.toY } } : null, actedUnitIds: Array.from(this.actedUnitIds), attackIntents: this.attackIntents.map((intent) => ({ ...intent })), battleLog: [...this.battleLog], objectives: this.objectiveStates(this.battleOutcome).map((objective) => ({ ...objective })), battleStats: this.serializeBattleStats(), triggeredBattleEvents: Array.from(this.triggeredBattleEvents), selectedUsable: this.selectedUsable?.id ?? null, audio: soundDirector.getDebugState(), itemStocks: this.serializeItemStocks(), battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })), treasureEffectPreviews: this.debugTreasureEffectPreviews(), combatMechanicsProbe: this.debugCombatMechanicsProbe(), units: battleUnits.map((unit) => ({ id: unit.id, name: unit.name, faction: unit.faction, classKey: unit.classKey, textureBase: this.unitViews.get(unit.id)?.textureBase ?? this.unitTextureKey(unit), actionTexture: this.unitActionTexture(unit), combatPortraitKey: this.combatPortraitKey(unit) ?? null, ai: unit.faction === 'enemy' ? this.enemyBehavior(unit) : null, attackRange: this.attackRange(unit), level: unit.level, exp: unit.exp, hp: unit.hp, maxHp: unit.maxHp, x: unit.x, y: unit.y, direction: this.unitViews.get(unit.id)?.direction ?? null, acted: this.actedUnitIds.has(unit.id), animating: this.unitViews.get(unit.id)?.sprite.anims?.isPlaying ?? false, animationKey: this.unitViews.get(unit.id)?.sprite.anims?.currentAnim?.key ?? null })), bonds: Array.from(this.bondStates.values()).map((bond) => ({ id: bond.id, unitIds: bond.unitIds, level: bond.level, exp: bond.exp, battleExp: bond.battleExp })) }; } debugUnitById(unitId: string) { if (!import.meta.env.DEV) { return undefined; } return battleUnits.find((unit) => unit.id === unitId); } private debugCombatMechanicsProbe() { const bond = Array.from(this.bondStates.values()).find((candidate) => { const units = candidate.unitIds.map((unitId) => battleUnits.find((unit) => unit.id === unitId && unit.hp > 0)); return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && Math.floor(candidate.level / 20) * 3 > 0; }); const attacker = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[0] && unit.hp > 0) : undefined; const partner = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[1] && unit.hp > 0) : undefined; const enemy = battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!bond || !attacker || !partner || !enemy) { return null; } const direction = partner.x <= battleMap.width - 3 ? 1 : -1; const nearX = partner.x + direction; const edgeX = partner.x + direction * 2; if (!this.isInBounds(nearX, partner.y) || !this.isInBounds(edgeX, partner.y)) { return null; } const farAttackerX = Math.abs(battleMap.width - 2 - partner.x) > 2 ? battleMap.width - 2 : 1; const farDefenderX = farAttackerX > 0 ? farAttackerX - 1 : farAttackerX + 1; if (!this.isInBounds(farAttackerX, partner.y) || !this.isInBounds(farDefenderX, partner.y)) { return null; } const adjacentPreview = this.combatPreview({ ...attacker, x: nearX, y: partner.y }, { ...enemy, x: edgeX, y: partner.y }, 'attack'); const sameTargetPreview = this.combatPreview({ ...attacker, x: edgeX, y: partner.y }, { ...enemy, x: nearX, y: partner.y }, 'attack'); const farPreview = this.combatPreview({ ...attacker, x: farAttackerX, y: partner.y }, { ...enemy, x: farDefenderX, y: partner.y }, 'attack'); const normalIncomingPreview = this.combatPreview(enemy, attacker, 'attack'); const counterIncomingPreview = this.combatPreview(enemy, attacker, 'attack', undefined, true); return { bondId: bond.id, attackerId: attacker.id, partnerId: partner.id, enemyId: enemy.id, adjacentBondDamageBonus: adjacentPreview.bondDamageBonus, adjacentPartnerIds: adjacentPreview.bondPartnerIds, sameTargetBondDamageBonus: sameTargetPreview.bondDamageBonus, sameTargetPartnerIds: sameTargetPreview.bondPartnerIds, farBondDamageBonus: farPreview.bondDamageBonus, farPartnerIds: farPreview.bondPartnerIds, normalDamage: normalIncomingPreview.damage, counterDamage: counterIncomingPreview.damage }; } 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 (!this.debugToolsEnabled() || this.battleOutcome) { return; } if (outcome === 'victory') { battleUnits .filter((unit) => unit.faction === 'enemy') .forEach((unit) => { unit.hp = 0; this.applyDefeatedStyle(unit); }); this.updateMiniMap(); } else { const liuBei = battleUnits.find((unit) => unit.id === 'liu-bei'); if (liuBei) { liuBei.hp = 0; this.applyDefeatedStyle(liuBei); } this.updateMiniMap(); } this.completeBattle(outcome); } toggleDebugOverlay() { if (this.debugOverlay) { this.debugOverlay.destroy(); this.debugOverlay = undefined; return; } this.debugOverlay = this.add.text(12, 12, '', { fontFamily: 'Consolas, monospace', fontSize: '13px', color: '#dff7ff', backgroundColor: 'rgba(0, 0, 0, 0.72)', padding: { x: 8, y: 6 } }); this.debugOverlay.setDepth(1000); this.updateDebugOverlay(); } private installDebugHotkeys() { if (!this.debugToolsEnabled()) { return; } this.input.keyboard?.on('keydown-F9', () => this.toggleDebugOverlay()); this.input.keyboard?.on('keydown-F10', () => { console.info('[Battle Debug]', this.getDebugState()); this.updateDebugOverlay(); }); this.input.on('pointermove', () => this.updateDebugOverlay()); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { this.debugOverlay?.destroy(); this.debugOverlay = undefined; }); } private debugToolsEnabled() { if (import.meta.env.DEV) { return true; } if (typeof window === 'undefined') { return false; } return new URLSearchParams(window.location.search).has('debug'); } private updateDebugOverlay() { if (!this.debugOverlay) { return; } const pointerTile = this.pointerToTile(this.input.activePointer); const selected = this.selectedUnit ? `${this.selectedUnit.name} (${this.selectedUnit.x},${this.selectedUnit.y})` : 'none'; const pending = this.pendingMove ? `${this.pendingMove.unit.name}: (${this.pendingMove.fromX},${this.pendingMove.fromY}) -> (${this.pendingMove.toX},${this.pendingMove.toY})` : 'none'; this.debugOverlay.setText([ 'Heros Web Debug', `scene=${this.scene.key}`, `turn=${this.turnNumber} faction=${this.activeFaction} phase=${this.phase}`, `selected=${selected}`, `pendingMove=${pending}`, `acted=${Array.from(this.actedUnitIds).join(',') || 'none'}`, `pointerTile=${pointerTile ? `${pointerTile.x},${pointerTile.y}` : 'outside'}`, 'F9 toggle overlay / F10 log state' ]); } }