14946 lines
549 KiB
TypeScript
14946 lines
549 KiB
TypeScript
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<string, string> = {
|
|
'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<string, string> = {
|
|
'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<Record<UnitClassKey, string>> = {
|
|
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<UnitDirection, number> = {
|
|
south: 0,
|
|
east: 1,
|
|
north: 2,
|
|
west: 3
|
|
};
|
|
|
|
const unitActionFrameCounts: Record<UnitActionPose, number> = {
|
|
attack: 10,
|
|
strategy: 8,
|
|
item: 8,
|
|
hurt: 4,
|
|
celebrate: 6
|
|
};
|
|
const unitActionColumnOffsets: Record<UnitActionPose, number> = {
|
|
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<BattleCommand, 'wait'>;
|
|
type UsableCommand = Extract<BattleCommand, 'strategy' | 'item'>;
|
|
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<BattleSpeed, string> = {
|
|
normal: '보통',
|
|
fast: '빠름',
|
|
'very-fast': '매우 빠름'
|
|
};
|
|
const battleSpeedFactors: Record<BattleSpeed, number> = {
|
|
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<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
|
|
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<string, Record<string, number>>;
|
|
battleBuffs?: BattleBuffState[];
|
|
battleStats?: Record<string, UnitBattleStats>;
|
|
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<string, BattleUsable> = {
|
|
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<string, string[]> = {
|
|
'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<string, Record<string, number>> = {
|
|
'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<Record<UnitClassKey, number>> = {
|
|
archer: 2,
|
|
strategist: 2
|
|
};
|
|
|
|
const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
|
|
'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<Record<UnitClassKey, EnemyAiBehavior>> = {
|
|
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<BattleCommand, string> = {
|
|
attack: '공격',
|
|
strategy: '책략',
|
|
item: '도구',
|
|
wait: '대기'
|
|
};
|
|
|
|
const rosterLabels: Record<RosterTab, string> = {
|
|
ally: '아군',
|
|
enemy: '적군'
|
|
};
|
|
|
|
const factionLabels: Record<ActiveFaction, string> = {
|
|
ally: '아군',
|
|
enemy: '적군'
|
|
};
|
|
|
|
const terrainDisplayLabels: Record<TerrainType, string> = {
|
|
plain: '평지',
|
|
road: '길',
|
|
forest: '숲',
|
|
hill: '언덕',
|
|
village: '마을',
|
|
fort: '요새',
|
|
camp: '진영',
|
|
river: '강',
|
|
cliff: '절벽'
|
|
};
|
|
|
|
const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
|
|
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<keyof UnitStats>).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<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
|
|
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<string, UnitView>();
|
|
private actedUnitIds = new Set<string>();
|
|
private bondStates = new Map<string, BondState>();
|
|
private itemStocks = new Map<string, Map<string, number>>();
|
|
private battleBuffs = new Map<string, BattleBuffState>();
|
|
private attackIntents: AttackIntent[] = [];
|
|
private battleLog: string[] = [];
|
|
private enemyHomeTiles = new Map<string, { x: number; y: number }>();
|
|
private battleStats = new Map<string, UnitBattleStats>();
|
|
private triggeredBattleEvents = new Set<string>();
|
|
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<string, Phaser.GameObjects.Rectangle>();
|
|
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<string, Map<string, number>>();
|
|
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<string, number>([[originKey, 0]]);
|
|
const queue = [{ x: unit.x, y: unit.y, cost: 0 }];
|
|
const reachable = new Map<string, { x: number; y: number; cost: number }>([[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<string, { x: number; y: number }>();
|
|
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<string, ThreatTile>();
|
|
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<BattleCommand, 'strategy' | 'item'>) {
|
|
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<void>((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<T extends Phaser.GameObjects.GameObject>(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<string, Record<string, number>>) {
|
|
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<string, UnitBattleStats>) {
|
|
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<string, number>();
|
|
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<UnitClassKey, number> = {
|
|
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<CombatResult, 'action' | 'attacker'>) {
|
|
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<void>((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<void>((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<void>((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<T extends Phaser.GameObjects.GameObject>(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<void>((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<void>((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<UnitDirection, string> = {
|
|
south: '아래',
|
|
east: '오른쪽',
|
|
north: '위',
|
|
west: '왼쪽'
|
|
};
|
|
return labels[direction];
|
|
}
|
|
|
|
private directionSymbol(direction: UnitDirection) {
|
|
const symbols: Record<UnitDirection, string> = {
|
|
south: '▼',
|
|
east: '▶',
|
|
north: '▲',
|
|
west: '◀'
|
|
};
|
|
return symbols[direction];
|
|
}
|
|
|
|
private directionVector(direction: UnitDirection) {
|
|
const vectors: Record<UnitDirection, { x: number; y: number }> = {
|
|
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<void>((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<typeof getItem>) {
|
|
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<T extends Phaser.GameObjects.GameObject>(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<typeof preview> => 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'
|
|
]);
|
|
}
|
|
}
|