diff --git a/src/game/data/battleItems.ts b/src/game/data/battleItems.ts index 8e623ee..d79df71 100644 --- a/src/game/data/battleItems.ts +++ b/src/game/data/battleItems.ts @@ -239,10 +239,28 @@ export const itemCatalog: Record = { } }; +export const itemCatalogEntries = Object.values(itemCatalog); + export function getItem(itemId: string) { return itemCatalog[itemId] ?? itemCatalog['training-sword']; } +export function findItemByName(name: string) { + return itemCatalogEntries.find((item) => item.name === name); +} + +export function itemInventoryLabel(itemId: string) { + return getItem(itemId).name; +} + +export function equipmentItemIdForInventoryLabel(label: string) { + return findItemByName(label)?.id; +} + +export function isEquipmentInventoryLabel(label: string) { + return Boolean(findItemByName(label)); +} + export function equipmentExpToNext(level: number) { return 50 + Math.min(level, 9) * 20; } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index eb64722..cb29733 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -49,6 +49,7 @@ import { setFirstBattleReport, type CampaignRewardSnapshot, type CampaignState, + type CampaignSortieItemAssignments, type CampaignStep } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -1149,6 +1150,7 @@ type BattleSceneData = { battleId?: string; selectedSortieUnitIds?: string[]; sortieFormationAssignments?: SortieFormationAssignments; + sortieItemAssignments?: CampaignSortieItemAssignments; }; type CommandButton = { @@ -3077,6 +3079,7 @@ export class BattleScene extends Phaser.Scene { private approachPathCostCache = new Map>(); private launchSortieUnitIds: string[] = []; private launchSortieFormationAssignments: SortieFormationAssignments = {}; + private launchSortieItemAssignments: CampaignSortieItemAssignments = {}; constructor() { super('BattleScene'); @@ -3086,6 +3089,7 @@ export class BattleScene extends Phaser.Scene { configureBattleScenario(getBattleScenario(data?.battleId)); this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(data?.selectedSortieUnitIds); this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(data?.sortieFormationAssignments); + this.launchSortieItemAssignments = this.normalizeLaunchSortieItemAssignments(data?.sortieItemAssignments); } create() { @@ -3104,7 +3108,7 @@ export class BattleScene extends Phaser.Scene { false ); this.bondStates = this.createBondStates(); - this.itemStocks = this.createItemStocks(); + this.itemStocks = this.createItemStocks(campaign); this.battleBuffs.clear(); this.battleStatuses.clear(); this.applyDebugStatusUiSetup(); @@ -3378,6 +3382,22 @@ export class BattleScene extends Phaser.Scene { return [...new Set((unitIds ?? []).map((unitId) => unitId.trim()).filter(Boolean))]; } + private normalizeLaunchSortieItemAssignments(assignments?: CampaignSortieItemAssignments) { + return Object.entries(assignments ?? {}).reduce((next, [unitId, stocks]) => { + const unitStocks = Object.entries(stocks ?? {}).reduce>((nextStocks, [itemId, count]) => { + const normalizedCount = Math.floor(Number(count)); + if (itemId && normalizedCount > 0) { + nextStocks[itemId] = normalizedCount; + } + return nextStocks; + }, {}); + if (Object.keys(unitStocks).length > 0) { + next[unitId] = unitStocks; + } + return next; + }, {}); + } + private effectiveSortieUnitIds(campaign?: CampaignState) { return this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds : campaign?.selectedSortieUnitIds ?? []; } @@ -3388,6 +3408,12 @@ export class BattleScene extends Phaser.Scene { : campaign?.sortieFormationAssignments ?? {}; } + private effectiveSortieItemAssignments(campaign?: CampaignState) { + return Object.keys(this.launchSortieItemAssignments).length > 0 + ? this.launchSortieItemAssignments + : campaign?.sortieItemAssignments ?? {}; + } + private defaultSortieFormationRole(unit: UnitData): SortieFormationRole { if (unit.classKey === 'cavalry') { return 'flank'; @@ -9260,12 +9286,19 @@ export class BattleScene extends Phaser.Scene { ); } - private createItemStocks() { + private createItemStocks(campaign?: CampaignState) { + const sortieItemAssignments = this.effectiveSortieItemAssignments(campaign); + const usesSortieAssignments = Object.keys(sortieItemAssignments).length > 0; return new Map( - Object.entries(initialItemStocks).map(([unitId, stocks]) => [ - unitId, - new Map(Object.entries(stocks)) - ]) + battleUnits.map((unit) => { + const stocks = unit.faction === 'ally' && usesSortieAssignments + ? sortieItemAssignments[unit.id] ?? {} + : initialItemStocks[unit.id] ?? {}; + return [ + unit.id, + new Map(Object.entries(stocks).filter(([, count]) => count > 0)) + ] as const; + }) ); } @@ -10036,7 +10069,7 @@ export class BattleScene extends Phaser.Scene { private deserializeItemStocks(savedStocks?: Record>) { if (!savedStocks) { - return this.createItemStocks(); + return this.createItemStocks(getCampaignState()); } return new Map( @@ -15347,6 +15380,7 @@ export class BattleScene extends Phaser.Scene { const campaign = getCampaignState(); const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); const sortieFormationAssignments = this.effectiveSortieFormationAssignments(campaign); + const sortieItemAssignments = this.effectiveSortieItemAssignments(campaign); const deployedAllyIds = battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id); return { @@ -15357,6 +15391,7 @@ export class BattleScene extends Phaser.Scene { defeatConditionLabel: battleScenario.defeatConditionLabel, selectedSortieUnitIds: effectiveSortieUnitIds, sortieFormationAssignments: { ...sortieFormationAssignments }, + sortieItemAssignments, deployedAllyIds, deployedAllyPositions: battleUnits .filter((unit) => unit.faction === 'ally') diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 0a7d7b6..cdbe7d0 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -2,7 +2,15 @@ import Phaser from 'phaser'; import storyMilitiaUrl from '../../assets/images/story/04-volunteer-militia.png'; import { soundDirector } from '../audio/SoundDirector'; import { battleUiIconFrames, battleUiIconTextureKey, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; -import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; +import { + equipmentExpToNext, + equipmentSlotLabels, + equipmentSlots, + findItemByName, + getItem, + type EquipmentSlot, + type ItemDefinition +} from '../data/battleItems'; import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles'; import { getSortieFlow } from '../data/campaignFlow'; @@ -101,6 +109,7 @@ import { type CampaignState, type CampaignReserveTrainingFocusDefinition, type CampaignReserveTrainingFocusId, + type CampaignSortieItemAssignments, type FirstBattleReport } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -156,6 +165,7 @@ type CampTabButtonView = { type CampSupplyDefinition = { label: string; + usableId: string; title: string; description: string; healHp: number; @@ -297,6 +307,7 @@ const portraitByUnitId: Record = { const campSupplies: CampSupplyDefinition[] = [ { label: '콩', + usableId: 'bean', title: '콩', description: '선택 장수의 병력을 12 회복합니다.', healHp: 12, @@ -304,6 +315,7 @@ const campSupplies: CampSupplyDefinition[] = [ }, { label: '탁주', + usableId: 'wine', title: '탁주', description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.', healHp: 8, @@ -311,6 +323,7 @@ const campSupplies: CampSupplyDefinition[] = [ }, { label: '상처약', + usableId: 'salve', title: '상처약', description: '선택 장수의 병력을 22 회복합니다.', healHp: 22, @@ -10028,6 +10041,7 @@ export class CampScene extends Phaser.Scene { private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; private sortieFormationAssignments: SortieFormationAssignments = {}; + private sortieItemAssignments: CampaignSortieItemAssignments = {}; private sortieFocusedUnitId = 'liu-bei'; private sortieRosterScroll = 0; private terrainCountCache = new Map(); @@ -10055,6 +10069,7 @@ export class CampScene extends Phaser.Scene { this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei'; this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.campaign.sortieFormationAssignments); + this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments); this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id; this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id; soundDirector.playMusic('militia-theme'); @@ -11240,6 +11255,7 @@ export class CampScene extends Phaser.Scene { this.report = this.campaign.firstBattleReport ?? this.report; this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.campaign.sortieFormationAssignments); + this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments); this.ensureSortieFocus(); const depth = 40; @@ -11724,19 +11740,23 @@ export class CampScene extends Phaser.Scene { this.trackSortie(this.add.text(x + 18, y + 157, this.compactText(reason, 38), this.textStyle(11, recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : availability.available ? '#c8d2dd' : '#87919c', true))).setDepth(depth + 1); equipmentSlots.forEach((slot, index) => { - const rowY = y + 178 + index * 22; + const rowY = y + 174 + index * 20; const state = unit.equipment[slot]; const item = getItem(state.itemId); const iconX = x + 28; this.renderSortieBattleUiIcon(this.itemIconKeyForSortie(slot), iconX, rowY + 8, 18, depth + 1, availability.available ? 0.96 : 0.5); this.trackSortie(this.add.text(x + 46, rowY + 2, `${equipmentSlotLabels[slot]} · ${item.name} Lv${state.level}`, this.textStyle(11, '#f2e3bf', true))).setDepth(depth + 1); - this.trackSortie(this.add.text(x + 184, rowY + 2, this.compactText(this.itemBonusText(item), 22), this.textStyle(10, '#9fb0bf', true))).setDepth(depth + 1); - this.drawSortieBar(x + 46, rowY + 16, 118, 5, state.exp / equipmentExpToNext(state.level), slot === 'weapon' ? palette.gold : slot === 'armor' ? palette.blue : palette.green, depth + 1); + this.trackSortie(this.add.text(x + 174, rowY + 2, this.compactText(this.itemBonusText(item), 14), this.textStyle(10, '#9fb0bf', true))).setDepth(depth + 1); + this.drawSortieBar(x + 46, rowY + 15, 104, 5, state.exp / equipmentExpToNext(state.level), slot === 'weapon' ? palette.gold : slot === 'armor' ? palette.blue : palette.green, depth + 1); + const canSwap = this.equipmentInventoryEntries(slot).some((entry) => entry.item.id !== item.id); + this.renderSortiePanelButton(canSwap ? '교체' : '없음', x + width - 58, rowY, 40, 17, canSwap, false, () => this.swapUnitEquipment(unit.id, slot, undefined, true), depth + 1); }); + this.renderSortieSupplyAssignment(unit, x + 18, y + 236, width - 36, depth + 1); + const actionLabel = selected ? (this.isRequiredSortieUnit(unit.id) ? '필수 출전' : '출전 해제') : '출전 등록'; - this.renderSortiePanelButton(actionLabel, x + 18, y + height - 34, 108, 24, availability.available, selected, () => this.toggleSortieUnit(unit.id), depth + 1); - this.trackSortie(this.add.text(x + 140, y + height - 29, `${roleLabel} · 역할은 중앙 버튼으로 변경`, this.textStyle(10, selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 1); + this.renderSortiePanelButton(actionLabel, x + 18, y + height - 28, 108, 22, availability.available, selected, () => this.toggleSortieUnit(unit.id), depth + 1); + this.trackSortie(this.add.text(x + 140, y + height - 24, `${roleLabel} · 역할은 중앙 버튼으로 변경`, this.textStyle(10, selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 1); } private renderSortieBattleUiIcon(iconKey: BattleUiIconKey, x: number, y: number, size: number, depth: number, alpha = 1) { @@ -11828,19 +11848,45 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, active ? palette.green : enabled ? palette.gold : 0x53606c, active ? 0.72 : enabled ? 0.62 : 0.32); - bg.setInteractive({ useHandCursor: true }); - bg.on('pointerover', () => { - if (enabled) { - bg.setFillStyle(active ? 0x314738 : 0x283947, 0.98); - } - }); - bg.on('pointerout', () => bg.setFillStyle(active ? 0x263a2d : 0x1a2630, enabled ? 0.96 : 0.58)); - bg.on('pointerdown', action); + if (enabled) { + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setFillStyle(active ? 0x314738 : 0x283947, 0.98)); + bg.on('pointerout', () => bg.setFillStyle(active ? 0x263a2d : 0x1a2630, enabled ? 0.96 : 0.58)); + bg.on('pointerdown', action); + } const text = this.trackSortie(this.add.text(x + width / 2, y + 5, label, this.textStyle(10, enabled ? '#f2e3bf' : '#87919c', true))); text.setOrigin(0.5, 0); text.setDepth(depth + 1); - text.setInteractive({ useHandCursor: true }); - text.on('pointerdown', action); + if (enabled) { + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', action); + } + } + + private renderSortieSupplyAssignment(unit: UnitData, x: number, y: number, width: number, depth: number) { + const selected = this.isSortieSelected(unit.id); + this.trackSortie(this.add.text(x, y + 3, '보급', this.textStyle(10, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth); + const buttonWidth = Math.floor((width - 42) / campSupplies.length); + campSupplies.forEach((supply, index) => { + const buttonX = x + 42 + index * buttonWidth; + const assigned = this.sortieAssignedSupplyCount(unit.id, supply.usableId); + const availableForUnit = this.sortieAvailableSupplyForUnit(unit.id, supply); + const enabled = selected && (assigned > 0 || availableForUnit > 0); + const bg = this.trackSortie(this.add.rectangle(buttonX, y, buttonWidth - 5, 20, assigned > 0 ? 0x263a2d : 0x0e151d, enabled ? 0.94 : 0.58)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, assigned > 0 ? palette.green : enabled ? palette.gold : 0x53606c, assigned > 0 ? 0.78 : enabled ? 0.5 : 0.28); + this.trackSortie( + this.add.text( + buttonX + (buttonWidth - 5) / 2, + y + 5, + `${supply.title} ${assigned}`, + this.textStyle(9, assigned > 0 ? '#a8ffd0' : enabled ? '#c8d2dd' : '#77818c', true) + ) + ).setOrigin(0.5, 0).setDepth(depth + 1); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', () => this.toggleSortieSupplyAssignment(unit.id, supply.usableId)); + }); } private itemIconKeyForSortie(slot: EquipmentSlot): BattleUiIconKey { @@ -12401,12 +12447,14 @@ export class CampScene extends Phaser.Scene { const completedDialogues = this.completedAvailableDialogues().length; const availableVisits = this.availableCampVisits(); const completedVisits = this.completedAvailableVisits().length; - const inventoryLabels = this.inventoryLabels(); const selectedUnits = this.selectedSortieUnits(); const selectedPreview = selectedUnits.slice(0, 3).map((unit) => unit.name).join(', '); const selectedSummary = selectedUnits.length > 0 ? `${selectedUnits.length}명 (${selectedPreview}${selectedUnits.length > 3 ? ' 외' : ''})` : '없음'; - const inventorySummary = - inventoryLabels.length > 0 ? `${inventoryLabels.slice(0, 3).join(', ')}${inventoryLabels.length > 3 ? ' 외' : ''}` : '없음'; + const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0); + const assignedSupplyCount = campSupplies.reduce((total, supply) => total + this.totalAssignedSupplyCount(supply.usableId), 0); + const equipmentCount = this.equipmentInventoryEntries().reduce((total, entry) => total + entry.amount, 0); + const recordCount = this.nonEquipmentInventoryLabels().length; + const inventorySummary = `보급 ${assignedSupplyCount}/${supplyCount} · 장비 ${equipmentCount} · 기록 ${recordCount}`; const reward = this.currentSortieFlow().rewardHint; const sortieNote = this.nextSortieRule().note; const readyLine = [ @@ -12503,6 +12551,7 @@ export class CampScene extends Phaser.Scene { : '필수 무장 없음'; const injured = units.filter((unit) => unit.hp < unit.maxHp); const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0); + const assignedSupplyCount = campSupplies.reduce((total, supply) => total + this.totalAssignedSupplyCount(supply.usableId), 0); const availableDialogues = this.availableCampDialogues(); const completedDialogues = this.completedAvailableDialogues().length; const availableVisits = this.availableCampVisits(); @@ -12532,9 +12581,9 @@ export class CampScene extends Phaser.Scene { detail: recommendedClassLine }, { - label: '소모품 보유', - complete: supplyCount > 0, - detail: supplyCount > 0 ? `보유 ${supplyCount}` : '정비 탭에서 보급 확인' + label: '전투 보급', + complete: assignedSupplyCount > 0 || supplyCount > 0, + detail: supplyCount > 0 ? `배정 ${assignedSupplyCount}/${supplyCount}` : '정비 탭에서 보급 확인' }, { label: '공명 대화', @@ -12622,11 +12671,13 @@ export class CampScene extends Phaser.Scene { markCampaignStep(flow.campaignStep); const selectedSortieUnitIds = [...this.selectedSortieUnitIds]; const sortieFormationAssignments = { ...this.sortieFormationAssignments }; + const sortieItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments); if (this.skipIntroStoryForSortie) { void startLazyScene(this, 'BattleScene', { battleId: flow.nextBattleId, selectedSortieUnitIds, - sortieFormationAssignments + sortieFormationAssignments, + sortieItemAssignments }); return; } @@ -12634,7 +12685,7 @@ export class CampScene extends Phaser.Scene { void startLazyScene(this, 'StoryScene', { pages: flow.pages, nextScene: 'BattleScene', - nextSceneData: { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments } + nextSceneData: { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments, sortieItemAssignments } }); } @@ -13073,11 +13124,11 @@ export class CampScene extends Phaser.Scene { const statX = x + 524; const stats = [ - ['공격', unit.attack], + ['공격', this.statWithBonus(unit.attack, this.equipmentAttackBonus(unit))], ['이동', unit.move], ['무력', unit.stats.might], - ['지력', unit.stats.intelligence], - ['통솔', unit.stats.leadership], + ['지력', this.statWithBonus(unit.stats.intelligence, this.equipmentStrategyBonus(unit))], + ['통솔', this.statWithBonus(unit.stats.leadership, this.equipmentDefenseBonus(unit))], ['민첩', unit.stats.agility], ['운', unit.stats.luck] ] as const; @@ -13310,7 +13361,7 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setStrokeStyle(1, palette.blue, 0.56); this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true))); - this.track(this.add.text(x + 24, y + 58, '소모품은 왼쪽에서 선택한 장수에게 사용됩니다.', this.textStyle(14, '#d4dce6'))); + this.track(this.add.text(x + 24, y + 58, '소모품 사용, 출전 보급, 보유 장비를 한 번에 정리합니다.', this.textStyle(14, '#d4dce6'))); const unit = this.selectedUnit(); if (unit) { @@ -13323,13 +13374,23 @@ export class CampScene extends Phaser.Scene { this.renderMerchantPanel(x + 24, y + 202, 342); - this.track(this.add.text(x + 390, y + 330, '전리품과 명성', this.textStyle(19, '#f2e3bf', true))); - const trophies = this.nonConsumableInventoryLabels(); - if (trophies.length === 0) { - this.track(this.add.text(x + 390, y + 364, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf'))); + this.track(this.add.text(x + 390, y + 318, '보유 장비', this.textStyle(18, '#f2e3bf', true))); + const equipmentEntries = this.equipmentInventoryEntries(); + if (equipmentEntries.length === 0) { + this.track(this.add.text(x + 390, y + 346, '교체 가능한 장비 없음', this.textStyle(12, '#9fb0bf'))); } else { - trophies.forEach((reward, index) => { - this.renderSupplyBox(reward, x + 390 + index * 128, y + 360); + equipmentEntries.slice(0, 3).forEach((entry, index) => { + this.renderEquipmentInventoryBox(entry, x + 390 + index * 128, y + 344); + }); + } + + this.track(this.add.text(x + 390, y + 410, '전리품과 명성', this.textStyle(17, '#f2e3bf', true))); + const trophies = this.nonEquipmentInventoryLabels(); + if (trophies.length === 0) { + this.track(this.add.text(x + 390, y + 438, '장부/명성 전리품 없음', this.textStyle(12, '#9fb0bf'))); + } else { + trophies.slice(0, 3).forEach((reward, index) => { + this.renderSupplyBox(reward, x + 390 + index * 128, y + 436); }); } } @@ -13388,10 +13449,36 @@ export class CampScene extends Phaser.Scene { } private renderSupplyBox(label: string, x: number, y: number) { - const bg = this.track(this.add.rectangle(x, y, 138, 50, 0x151f2a, 0.9)); + const bg = this.track(this.add.rectangle(x, y, 118, 46, 0x151f2a, 0.9)); bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.52); - this.track(this.add.text(x + 14, y + 15, label, this.textStyle(16, '#f2e3bf', true))); + this.track(this.add.text(x + 10, y + 14, this.compactText(label, 9), this.textStyle(13, '#f2e3bf', true))); + } + + private renderEquipmentInventoryBox(entry: { label: string; amount: number; item: ItemDefinition }, x: number, y: number) { + const unit = this.selectedUnit(); + const canEquip = Boolean(unit); + const bg = this.track(this.add.rectangle(x, y, 118, 54, canEquip ? 0x151f2a : 0x111820, canEquip ? 0.92 : 0.72)); + bg.setOrigin(0); + bg.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : palette.blue, canEquip ? 0.58 : 0.28); + const iconFrame = this.track(this.add.rectangle(x + 18, y + 18, 28, 28, 0x0a1017, 0.92)); + iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54); + const icon = this.track(this.add.image(x + 18, y + 18, `item-${entry.item.id}`)); + icon.setDisplaySize(21, 21); + this.track(this.add.text(x + 38, y + 7, this.compactText(`${entry.item.name} x${entry.amount}`, 8), this.textStyle(11, '#f2e3bf', true))); + this.track(this.add.text(x + 38, y + 23, this.compactText(`${equipmentSlotLabels[entry.item.slot]} · ${this.itemBonusText(entry.item)}`, 11), this.textStyle(9, '#9fb0bf', true))); + const actionText = this.track(this.add.text(x + 58, y + 39, canEquip ? '장착' : '장수 선택', this.textStyle(9, canEquip ? '#d8b15f' : '#7f8994', true))); + actionText.setOrigin(0.5, 0); + + if (canEquip) { + const action = () => this.swapUnitEquipment(unit!.id, entry.item.slot, entry.item.id); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); + bg.on('pointerout', () => bg.setFillStyle(0x151f2a, 0.92)); + bg.on('pointerdown', action); + actionText.setInteractive({ useHandCursor: true }); + actionText.on('pointerdown', action); + } } private renderSupplyTargetCard(unit: UnitData, x: number, y: number, width: number, height: number) { @@ -13544,6 +13631,7 @@ export class CampScene extends Phaser.Scene { const item = getItem(state.itemId); const next = equipmentExpToNext(state.level); const isTreasure = item.rank === 'treasure'; + const alternatives = this.equipmentInventoryEntries(slot).filter((entry) => entry.item.id !== item.id); const bg = this.track(this.add.rectangle(x, y, width, height, isTreasure ? 0x1f2430 : 0x151f2a, 0.92)); bg.setOrigin(0); bg.setStrokeStyle(1, isTreasure ? palette.gold : palette.blue, isTreasure ? 0.68 : 0.42); @@ -13559,6 +13647,21 @@ export class CampScene extends Phaser.Scene { valueText.setOrigin(1, 0); this.drawBar(x + 58, y + 57, width - 74, 7, state.exp / next, isTreasure ? palette.gold : palette.blue); + const canSwap = alternatives.length > 0; + const button = this.track(this.add.rectangle(x + width - 36, y + 16, 54, 22, canSwap ? 0x1a2630 : 0x121922, canSwap ? 0.96 : 0.66)); + button.setStrokeStyle(1, canSwap ? palette.gold : 0x53606c, canSwap ? 0.7 : 0.3); + const buttonText = this.track(this.add.text(x + width - 36, y + 16, canSwap ? '교체' : '없음', this.textStyle(10, canSwap ? '#f2e3bf' : '#7f8994', true))); + buttonText.setOrigin(0.5); + if (canSwap) { + const action = () => this.swapUnitEquipment(unit.id, slot); + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(0x283947, 0.98)); + button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96)); + button.on('pointerdown', action); + buttonText.setInteractive({ useHandCursor: true }); + buttonText.on('pointerdown', action); + } + this.track( this.add.text(x + 14, y + 72, `${this.itemBonusText(item)} · ${item.effects[0] ?? item.description}`, { ...this.textStyle(11, '#c8d2dd'), @@ -13775,6 +13878,37 @@ export class CampScene extends Phaser.Scene { return this.sortieAllies().filter((unit) => selected.has(unit.id)); } + private normalizedSortieItemAssignments(assignments?: CampaignSortieItemAssignments) { + const selected = new Set(this.selectedSortieUnitIds); + const validSupplyIds = new Set(campSupplies.map((supply) => supply.usableId)); + const remainingBySupply = new Map(campSupplies.map((supply) => [supply.usableId, this.inventoryAmount(supply.label)])); + + return Object.entries(assignments ?? {}).reduce((next, [unitId, stocks]) => { + if (!selected.has(unitId)) { + return next; + } + + const unitStocks = Object.entries(stocks ?? {}).reduce>((nextStocks, [usableId, amount]) => { + if (!validSupplyIds.has(usableId)) { + return nextStocks; + } + const remaining = remainingBySupply.get(usableId) ?? 0; + const maxPerUnit = this.sortieSupplyMaxPerUnit(usableId); + const normalizedAmount = Math.min(maxPerUnit, remaining, Math.max(0, Math.floor(Number(amount)))); + if (normalizedAmount > 0) { + nextStocks[usableId] = normalizedAmount; + remainingBySupply.set(usableId, remaining - normalizedAmount); + } + return nextStocks; + }, {}); + + if (Object.keys(unitStocks).length > 0) { + next[unitId] = unitStocks; + } + return next; + }, {}); + } + private normalizedSortieUnitIds(candidateIds?: string[]) { const allies = this.sortieAllies(); const allyIds = new Set(allies.map((unit) => unit.id)); @@ -13805,6 +13939,12 @@ export class CampScene extends Phaser.Scene { return normalizeSortieFormationAssignments(assignments, new Set(this.sortieAllies().map((unit) => unit.id))); } + private cloneSortieItemAssignments(assignments: CampaignSortieItemAssignments) { + return Object.fromEntries( + Object.entries(assignments).map(([unitId, stocks]) => [unitId, { ...stocks }]) + ) as CampaignSortieItemAssignments; + } + private isSortieSelected(unitId: string) { return this.selectedSortieUnitIds.includes(unitId); } @@ -13831,6 +13971,9 @@ export class CampScene extends Phaser.Scene { const nextAssignments = { ...this.sortieFormationAssignments }; delete nextAssignments[unitId]; this.sortieFormationAssignments = nextAssignments; + const nextItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments); + delete nextItemAssignments[unitId]; + this.sortieItemAssignments = nextItemAssignments; } else if (selected.size >= this.sortieMaxUnits()) { this.showCampNotice(`이번 전투는 최대 ${this.sortieMaxUnits()}명까지 출전할 수 있습니다.`); return; @@ -13858,11 +14001,68 @@ export class CampScene extends Phaser.Scene { private persistSortieSelection() { const campaign = this.campaign ?? getCampaignState(); this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.sortieFormationAssignments); + this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.sortieItemAssignments); campaign.selectedSortieUnitIds = [...this.selectedSortieUnitIds]; campaign.sortieFormationAssignments = { ...this.sortieFormationAssignments }; + campaign.sortieItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments); this.campaign = saveCampaignState(campaign); } + private sortieSupplyMaxPerUnit(usableId: string) { + return usableId === 'bean' ? 2 : 1; + } + + private sortieAssignedSupplyCount(unitId: string, usableId: string) { + return this.sortieItemAssignments[unitId]?.[usableId] ?? 0; + } + + private totalAssignedSupplyCount(usableId: string) { + return Object.values(this.sortieItemAssignments).reduce((sum, stocks) => sum + (stocks[usableId] ?? 0), 0); + } + + private sortieAvailableSupplyForUnit(unitId: string, supply: CampSupplyDefinition) { + const total = this.inventoryAmount(supply.label); + const assignedElsewhere = this.totalAssignedSupplyCount(supply.usableId) - this.sortieAssignedSupplyCount(unitId, supply.usableId); + return Math.max(0, total - assignedElsewhere); + } + + private toggleSortieSupplyAssignment(unitId: string, usableId: string) { + const unit = this.sortieFocusedUnit(); + const supply = campSupplies.find((candidate) => candidate.usableId === usableId); + if (!supply || !unit || unit.id !== unitId) { + return; + } + if (!this.isSortieSelected(unitId)) { + this.showCampNotice(`${unit.name}는 출전 등록 후 보급을 배정할 수 있습니다.`); + return; + } + + const current = this.sortieAssignedSupplyCount(unitId, usableId); + const maxAssignable = Math.min(this.sortieSupplyMaxPerUnit(usableId), this.sortieAvailableSupplyForUnit(unitId, supply)); + if (current <= 0 && maxAssignable <= 0) { + this.showCampNotice(`${supply.title} 보유량이 부족합니다.`); + return; + } + + const nextCount = current >= maxAssignable ? 0 : current + 1; + const nextAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments); + nextAssignments[unitId] = { ...(nextAssignments[unitId] ?? {}) }; + if (nextCount > 0) { + nextAssignments[unitId][usableId] = nextCount; + } else { + delete nextAssignments[unitId][usableId]; + } + if (Object.keys(nextAssignments[unitId]).length === 0) { + delete nextAssignments[unitId]; + } + + this.sortieItemAssignments = this.normalizedSortieItemAssignments(nextAssignments); + this.persistSortieSelection(); + soundDirector.playSelect(); + this.showCampNotice(nextCount > 0 ? `${unit.name}에게 ${supply.title} ${nextCount}개 배정` : `${unit.name}의 ${supply.title} 배정을 해제했습니다.`); + this.showSortiePrep(); + } + private completedCampDialogues() { if (this.campaign) { return this.campaign.completedCampDialogues; @@ -13886,23 +14086,110 @@ export class CampScene extends Phaser.Scene { return this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1']; } - private nonConsumableInventoryLabels() { - const entries = Object.entries(this.campaign?.inventory ?? {}).filter(([label, amount]) => { - return amount > 0 && !campSupplyByLabel.has(label); - }); - if (entries.length > 0) { - return entries.map(([label, amount]) => `${label} ${amount}`); - } - return this.inventoryLabels().filter((label) => { - const normalized = label.replace(/\s+\d+$/, '').trim(); - return !campSupplyByLabel.has(normalized); - }); - } - private inventoryAmount(label: string) { return this.campaign?.inventory[label] ?? 0; } + private equipmentInventoryEntries(slot?: EquipmentSlot) { + return Object.entries(this.campaign?.inventory ?? {}) + .map(([label, amount]) => ({ label, amount, item: findItemByName(label) })) + .filter((entry): entry is { label: string; amount: number; item: ItemDefinition } => { + const item = entry.item; + return entry.amount > 0 && item !== undefined && (!slot || item.slot === slot); + }) + .sort((a, b) => { + if (a.item.slot !== b.item.slot) { + return equipmentSlots.indexOf(a.item.slot) - equipmentSlots.indexOf(b.item.slot); + } + const bonusDelta = this.itemTotalBonus(b.item) - this.itemTotalBonus(a.item); + if (bonusDelta !== 0) { + return bonusDelta; + } + return a.item.name.localeCompare(b.item.name, 'ko-KR'); + }); + } + + private nonEquipmentInventoryLabels() { + return Object.entries(this.campaign?.inventory ?? {}) + .filter(([label, amount]) => amount > 0 && !campSupplyByLabel.has(label) && !findItemByName(label)) + .map(([label, amount]) => `${label} ${amount}`); + } + + private swapUnitEquipment(unitId: string, slot: EquipmentSlot, itemId?: string, returnToSortie = false) { + const campaign = this.campaign ?? getCampaignState(); + const target = campaign.roster.find((unit) => unit.id === unitId); + if (!target) { + this.showCampNotice('장비를 바꿀 장수 장부를 찾지 못했습니다.'); + return; + } + + const currentState = target.equipment[slot]; + const currentItem = getItem(currentState.itemId); + const nextItem = itemId ? getItem(itemId) : this.nextEquipmentItemForSlot(slot, currentItem.id); + if (!nextItem || nextItem.slot !== slot || nextItem.id === currentItem.id) { + this.showCampNotice(`${equipmentSlotLabels[slot]} 교체 가능한 장비가 없습니다.`); + return; + } + + if ((campaign.inventory[nextItem.name] ?? 0) <= 0) { + this.showCampNotice(`${nextItem.name} 보유량이 없습니다.`); + return; + } + + campaign.inventory[nextItem.name] -= 1; + if (campaign.inventory[nextItem.name] <= 0) { + delete campaign.inventory[nextItem.name]; + } + campaign.inventory[currentItem.name] = (campaign.inventory[currentItem.name] ?? 0) + 1; + target.equipment[slot] = { + itemId: nextItem.id, + level: currentState.level, + exp: currentState.exp + }; + this.syncReportUnitEquipment(campaign, target.id, target.equipment); + + this.campaign = saveCampaignState(campaign); + this.report = this.campaign.firstBattleReport ?? this.report; + soundDirector.playSelect(); + this.showCampNotice(`${target.name} ${equipmentSlotLabels[slot]} 교체 · ${currentItem.name} → ${nextItem.name} (${this.itemBonusText(nextItem)})`); + if (returnToSortie || this.sortieObjects.length > 0) { + this.showSortiePrep(); + } else { + this.render(); + } + } + + private nextEquipmentItemForSlot(slot: EquipmentSlot, currentItemId: string) { + return this.equipmentInventoryEntries(slot).find((entry) => entry.item.id !== currentItemId)?.item; + } + + private syncReportUnitEquipment(campaign: CampaignState, unitId: string, equipment: UnitData['equipment']) { + const reportUnit = campaign.firstBattleReport?.units.find((unit) => unit.id === unitId); + if (reportUnit) { + reportUnit.equipment = JSON.parse(JSON.stringify(equipment)) as UnitData['equipment']; + } + } + + private statWithBonus(base: number, bonus: number) { + return bonus > 0 ? `${base}+${bonus}` : `${base}`; + } + + private itemTotalBonus(item: ItemDefinition) { + return (item.attackBonus ?? 0) + (item.defenseBonus ?? 0) + (item.strategyBonus ?? 0); + } + + private equipmentAttackBonus(unit: UnitData) { + return equipmentSlots.reduce((sum, slot) => sum + (getItem(unit.equipment[slot].itemId).attackBonus ?? 0), 0); + } + + private equipmentStrategyBonus(unit: UnitData) { + return equipmentSlots.reduce((sum, slot) => sum + (getItem(unit.equipment[slot].itemId).strategyBonus ?? 0), 0); + } + + private equipmentDefenseBonus(unit: UnitData) { + return equipmentSlots.reduce((sum, slot) => sum + (getItem(unit.equipment[slot].itemId).defenseBonus ?? 0), 0); + } + private wolongClueCount() { return this.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록'); } @@ -13958,6 +14245,7 @@ export class CampScene extends Phaser.Scene { selectedVisitId: this.selectedVisitId, selectedSortieUnitIds: [...this.selectedSortieUnitIds], sortieFormationAssignments: { ...this.sortieFormationAssignments }, + sortieItemAssignments: this.cloneSortieItemAssignments(this.sortieItemAssignments), sortieDeploymentPreview: this.sortieDeploymentPreviewDebug(), sortieFocusedUnitId: this.sortieFocusedUnitId, sortieFocusedUnit: this.sortieFocusedUnitSummary(), diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index ea181c7..beec0cc 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -287,6 +287,8 @@ export type CampaignBattleSettlement = { completedAt: string; }; +export type CampaignSortieItemAssignments = Record>; + export type CampaignState = { version: 1; updatedAt: string; @@ -298,6 +300,7 @@ export type CampaignState = { inventory: Record; selectedSortieUnitIds: string[]; sortieFormationAssignments: SortieFormationAssignments; + sortieItemAssignments: CampaignSortieItemAssignments; reserveTrainingFocus: CampaignReserveTrainingFocusId; completedCampDialogues: string[]; completedCampVisits: string[]; @@ -401,6 +404,7 @@ export function createInitialCampaignState(): CampaignState { inventory: {}, selectedSortieUnitIds: [], sortieFormationAssignments: {}, + sortieItemAssignments: {}, reserveTrainingFocus: defaultCampaignReserveTrainingFocusId, completedCampDialogues: [], completedCampVisits: [], @@ -690,6 +694,7 @@ function normalizeCampaignState(state: CampaignState): CampaignState { normalized.inventory = { ...(normalized.inventory ?? {}) }; normalized.selectedSortieUnitIds = [...new Set(normalized.selectedSortieUnitIds ?? [])]; normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(normalized.sortieFormationAssignments); + normalized.sortieItemAssignments = normalizeSortieItemAssignments(normalized.sortieItemAssignments); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); normalized.battleHistory = normalized.battleHistory ?? {}; if (normalized.firstBattleReport) { @@ -698,6 +703,23 @@ function normalizeCampaignState(state: CampaignState): CampaignState { return normalized; } +function normalizeSortieItemAssignments(assignments?: CampaignSortieItemAssignments) { + return Object.entries(assignments ?? {}).reduce((next, [unitId, stocks]) => { + const unitStocks = Object.entries(stocks ?? {}).reduce>((nextStocks, [itemId, amount]) => { + const normalizedAmount = Math.floor(Number(amount)); + if (itemId && normalizedAmount > 0) { + nextStocks[itemId] = normalizedAmount; + } + return nextStocks; + }, {}); + + if (Object.keys(unitStocks).length > 0) { + next[unitId] = unitStocks; + } + return next; + }, {}); +} + function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId { return campaignReserveTrainingFocusDefinitions.some((focus) => focus.id === focusId) ? focusId as CampaignReserveTrainingFocusId