Connect battle rewards to campaign equipment

This commit is contained in:
2026-07-03 23:39:27 +09:00
parent 0c4e358284
commit 42a596bd8b
4 changed files with 419 additions and 56 deletions

View File

@@ -239,10 +239,28 @@ export const itemCatalog: Record<string, ItemDefinition> = {
}
};
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;
}

View File

@@ -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<string, Map<string, number>>();
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<CampaignSortieItemAssignments>((next, [unitId, stocks]) => {
const unitStocks = Object.entries(stocks ?? {}).reduce<Record<string, number>>((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<string, Record<string, number>>) {
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')

View File

@@ -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<string, PortraitKey> = {
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<CampTab>();
private selectedSortieUnitIds: string[] = [];
private sortieFormationAssignments: SortieFormationAssignments = {};
private sortieItemAssignments: CampaignSortieItemAssignments = {};
private sortieFocusedUnitId = 'liu-bei';
private sortieRosterScroll = 0;
private terrainCountCache = new Map<BattleScenarioId, SortieTerrainCounts>();
@@ -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<CampaignSortieItemAssignments>((next, [unitId, stocks]) => {
if (!selected.has(unitId)) {
return next;
}
const unitStocks = Object.entries(stocks ?? {}).reduce<Record<string, number>>((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(),

View File

@@ -287,6 +287,8 @@ export type CampaignBattleSettlement = {
completedAt: string;
};
export type CampaignSortieItemAssignments = Record<string, Record<string, number>>;
export type CampaignState = {
version: 1;
updatedAt: string;
@@ -298,6 +300,7 @@ export type CampaignState = {
inventory: Record<string, number>;
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<CampaignSortieItemAssignments>((next, [unitId, stocks]) => {
const unitStocks = Object.entries(stocks ?? {}).reduce<Record<string, number>>((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