|
|
|
|
@@ -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(),
|
|
|
|
|
|