Add officer roster reserve growth

This commit is contained in:
2026-06-23 05:59:14 +09:00
parent 44009b3542
commit 8f083a6ca4
4 changed files with 195 additions and 19 deletions

View File

@@ -3177,13 +3177,16 @@ export class CampScene extends Phaser.Scene {
private renderUnitColumn() {
const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
const availableHeight = 462;
const rowGap = allies.length > 1 ? Math.min(150, Math.floor(availableHeight / allies.length)) : 150;
const cardHeight = Math.min(126, Math.max(70, rowGap - 10));
const compact = cardHeight < 104;
const rowGap = allies.length > 1 ? Math.min(132, Math.floor(availableHeight / allies.length)) : 132;
const cardHeight = Math.min(118, Math.max(48, rowGap - 8));
const compact = cardHeight < 88;
this.renderRosterColumnSummary(42, 86, 318, allies);
allies.forEach((unit, index) => {
const x = 42;
const y = 120 + index * rowGap;
const active = this.selectedUnitId === unit.id;
const rosterStatus = this.unitRosterStatus(unit);
const training = this.latestReserveTraining(unit.id);
const bg = this.track(this.add.rectangle(x, y, 318, cardHeight, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86));
bg.setOrigin(0);
bg.setStrokeStyle(2, active ? palette.gold : palette.blue, active ? 0.9 : 0.46);
@@ -3196,22 +3199,63 @@ export class CampScene extends Phaser.Scene {
const portraitKey = portraitByUnitId[unit.id];
const portraitCenterY = y + cardHeight / 2;
const portraitSize = compact ? 62 : 92;
const portraitSize = compact ? 40 : 84;
if (portraitKey) {
const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitKeys[portraitKey]));
portrait.setDisplaySize(portraitSize, portraitSize);
} else {
this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 31 : 44, unit);
this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 20 : 40, unit);
}
const textX = compact ? x + 102 : x + 116;
this.track(this.add.text(textX, y + (compact ? 13 : 20), `${unit.name} Lv ${unit.level}`, this.textStyle(compact ? 17 : 21, '#f2e3bf', true)));
this.track(this.add.text(textX, y + (compact ? 38 : 51), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 12 : 14, '#d4dce6')));
this.track(this.add.text(textX, y + (compact ? 58 : 78), `경험 ${unit.exp}/100`, this.textStyle(compact ? 12 : 14, '#d8b15f', true)));
this.drawBar(textX, y + cardHeight - 18, compact ? 160 : 176, 8, unit.exp / 100, palette.gold);
this.track(this.add.text(textX, y + (compact ? 7 : 17), `${unit.name} Lv ${unit.level}`, this.textStyle(compact ? 14 : 20, '#f2e3bf', true)));
this.track(this.add.text(textX, y + (compact ? 27 : 47), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 10 : 13, '#d4dce6')));
const expLine = training ? `경험 ${unit.exp}/100 · 대기 +${training.expGained}` : `경험 ${unit.exp}/100`;
this.track(this.add.text(textX, y + (compact ? 40 : 72), expLine, this.textStyle(compact ? 10 : 13, training ? '#a8ffd0' : '#d8b15f', true)));
this.drawBar(textX, y + cardHeight - (compact ? 10 : 17), compact ? 142 : 176, compact ? 5 : 8, unit.exp / 100, training ? palette.green : palette.gold);
const badgeColor = rosterStatus.tone === 'selected' ? '#a8ffd0' : rosterStatus.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf';
const badge = this.track(this.add.text(x + 302, y + (compact ? 7 : 17), rosterStatus.label, this.textStyle(compact ? 10 : 11, badgeColor, true)));
badge.setOrigin(1, 0);
});
}
private renderRosterColumnSummary(x: number, y: number, width: number, allies: UnitData[]) {
const selected = this.selectedSortieUnits();
const reserveCount = Math.max(0, allies.length - selected.length);
const recentTrainingCount = this.latestReserveTrainingAwards().length;
const bg = this.track(this.add.rectangle(x, y, width, 26, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.38);
this.track(this.add.text(x + 12, y + 6, `전군 ${allies.length} · 출전 ${selected.length} · 대기 ${reserveCount}`, this.textStyle(11, '#f2e3bf', true)));
const trainingText = this.track(
this.add.text(
x + width - 12,
y + 6,
recentTrainingCount > 0 ? `최근 훈련 ${recentTrainingCount}` : '훈련 대기',
this.textStyle(11, recentTrainingCount > 0 ? '#a8ffd0' : '#9fb0bf', true)
)
);
trainingText.setOrigin(1, 0);
}
private unitRosterStatus(unit: UnitData) {
const selected = this.isSortieSelected(unit.id);
const recommendation = this.sortieRecommendation(unit.id);
if (requiredSortieUnitIds.has(unit.id)) {
return { label: '필수', tone: 'recommended' as const };
}
if (selected) {
return { label: recommendation ? '추천 출전' : '출전', tone: 'selected' as const };
}
if (recommendation) {
return { label: '추천 대기', tone: 'recommended' as const };
}
if (this.latestReserveTraining(unit.id)) {
return { label: '훈련', tone: 'selected' as const };
}
return { label: '대기', tone: 'reserve' as const };
}
private renderUnitFallbackPortrait(x: number, y: number, radius: number, unit: UnitData) {
const bg = this.track(this.add.circle(x, y, radius, 0x1f3140, 0.96));
bg.setStrokeStyle(2, palette.gold, 0.58);
@@ -3423,6 +3467,36 @@ export class CampScene extends Phaser.Scene {
return new Set(Object.keys(this.campaign?.battleHistory ?? {}));
}
private latestReserveTrainingAwards() {
const latestBattleId = this.campaign?.latestBattleId;
if (!latestBattleId) {
return [];
}
return this.campaign?.battleHistory[latestBattleId]?.reserveTraining ?? [];
}
private latestReserveTraining(unitId: string) {
return this.latestReserveTrainingAwards().find((entry) => entry.unitId === unitId);
}
private rosterCollectionSummary() {
const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
const selectedIds = new Set(this.selectedSortieUnitIds);
const selectedCount = allies.filter((unit) => selectedIds.has(unit.id)).length;
const recruitedCount = allies.filter((unit) => !foundingSortieUnitIds.has(unit.id)).length;
const reserveTrainingAwards = this.latestReserveTrainingAwards();
const averageLevel = allies.length > 0 ? Math.round(allies.reduce((sum, unit) => sum + unit.level, 0) / allies.length) : 0;
return {
total: allies.length,
selectedCount,
reserveCount: Math.max(0, allies.length - selectedCount),
recruitedCount,
averageLevel,
reserveTrainingCount: reserveTrainingAwards.length,
reserveTrainingExp: reserveTrainingAwards.reduce((sum, entry) => sum + entry.expGained, 0)
};
}
private renderStatusPanel() {
const unit = this.selectedUnit();
if (!unit) {
@@ -3472,12 +3546,14 @@ export class CampScene extends Phaser.Scene {
this.renderStatCard(label, `${value}`, statX + (index % 2) * 128, y + 22 + Math.floor(index / 2) * 42, 116);
});
this.track(this.add.text(x + 24, y + 210, '장비', this.textStyle(20, '#f2e3bf', true)));
this.renderSelectedUnitRosterPlan(unit, x + 24, y + 196, 780);
this.track(this.add.text(x + 24, y + 224, '장비', this.textStyle(20, '#f2e3bf', true)));
equipmentSlots.forEach((slot, index) => {
this.renderEquipmentCard(unit, slot, x + 24 + index * 260, y + 244, 244, 102);
this.renderEquipmentCard(unit, slot, x + 24 + index * 260, y + 256, 244, 98);
});
this.renderSelectedUnitBondPanel(unit, x + 24, y + 362, 780);
this.renderSelectedUnitBondPanel(unit, x + 24, y + 366, 780);
}
private renderDialoguePanel() {
@@ -3974,6 +4050,23 @@ export class CampScene extends Phaser.Scene {
});
}
private renderSelectedUnitRosterPlan(unit: UnitData, x: number, y: number, width: number) {
const status = this.unitRosterStatus(unit);
const training = this.latestReserveTraining(unit.id);
const role = this.sortieFormationRole(unit);
const roleLabel = sortieFormationSlotDefinitions.find((slot) => slot.role === role)?.label ?? '예비';
const recommendation = this.sortieRecommendation(unit.id);
const bg = this.track(this.add.rectangle(x, y, width, 22, 0x151f2a, 0.82));
bg.setOrigin(0);
bg.setStrokeStyle(1, training ? palette.green : palette.blue, training ? 0.5 : 0.34);
const planText = recommendation ? `추천: ${recommendation.reason}` : `${roleLabel} 역할로 편성 가능`;
const trainingText = training ? `최근 대기 훈련 경험 +${training.expGained}, 장비 +${training.equipmentExpGained}` : '대기 중에도 전투 후 소량의 훈련 성장이 반영됩니다.';
this.track(this.add.text(x + 12, y + 5, `${status.label} · ${roleLabel}`, this.textStyle(11, status.tone === 'reserve' ? '#9fb0bf' : '#f2e3bf', true)));
this.track(this.add.text(x + 112, y + 5, this.compactText(planText, 48), this.textStyle(11, '#d4dce6')));
const trainingLabel = this.track(this.add.text(x + width - 12, y + 5, this.compactText(trainingText, 28), this.textStyle(11, training ? '#a8ffd0' : '#9fb0bf', true)));
trainingLabel.setOrigin(1, 0);
}
private itemBonusText(item: ReturnType<typeof getItem>) {
const bonuses = [
item.attackBonus ? `공격 +${item.attackBonus}` : '',
@@ -4278,6 +4371,8 @@ export class CampScene extends Phaser.Scene {
};
}),
sortiePlan: this.sortiePlanSummary(),
rosterCollection: this.rosterCollectionSummary(),
reserveTrainingAwards: this.latestReserveTrainingAwards(),
campaignProgress: this.campaignTimelineProgress(),
campBattleId: this.currentCampBattleId(),
campTitle: this.currentCampTitle(),

View File

@@ -1,3 +1,4 @@
import { equipmentExpToNext, equipmentSlots } from '../data/battleItems';
import type { BattleBond, UnitData } from '../data/scenario';
export type BattleObjectiveSnapshot = {
@@ -96,6 +97,15 @@ export type CampaignBondProgressSnapshot = {
battleExp: number;
};
export type CampaignReserveTrainingSnapshot = {
unitId: string;
name: string;
expGained: number;
equipmentExpGained: number;
level: number;
exp: number;
};
export type CampaignBattleSettlement = {
battleId: string;
battleTitle: string;
@@ -105,6 +115,7 @@ export type CampaignBattleSettlement = {
objectives: BattleObjectiveSnapshot[];
units: CampaignUnitProgressSnapshot[];
bonds: CampaignBondProgressSnapshot[];
reserveTraining?: CampaignReserveTrainingSnapshot[];
completedAt: string;
};
@@ -265,12 +276,15 @@ export function setFirstBattleReport(report: FirstBattleReport) {
state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep;
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally'));
const reserveTraining = previousSettlement || reportClone.outcome !== 'victory'
? previousSettlement?.reserveTraining ?? []
: applyReserveTrainingProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally'));
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
state.completedCampDialogues = completedCampDialogues;
state.completedCampVisits = completedCampVisits;
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1);
state.battleHistory[battleId] = createBattleSettlement(reportClone);
state.battleHistory[battleId] = createBattleSettlement(reportClone, reserveTraining);
state.latestBattleId = battleId;
state.updatedAt = new Date().toISOString();
@@ -492,7 +506,7 @@ function advanceBond(bond: CampBondSnapshot, amount: number) {
}
}
function createBattleSettlement(report: FirstBattleReport): CampaignBattleSettlement {
function createBattleSettlement(report: FirstBattleReport, reserveTraining: CampaignReserveTrainingSnapshot[] = []): CampaignBattleSettlement {
return {
battleId: report.battleId,
battleTitle: report.battleTitle,
@@ -518,6 +532,7 @@ function createBattleSettlement(report: FirstBattleReport): CampaignBattleSettle
exp: bond.exp,
battleExp: bond.battleExp
})),
reserveTraining: reserveTraining.map((entry) => ({ ...entry })),
completedAt: report.createdAt
};
}
@@ -540,6 +555,50 @@ function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[
return Array.from(merged.values());
}
function applyReserveTrainingProgress(roster: UnitData[], deployedUnits: UnitData[]): CampaignReserveTrainingSnapshot[] {
const deployedIds = new Set(deployedUnits.map((unit) => unit.id));
return roster
.filter((unit) => unit.faction === 'ally' && !deployedIds.has(unit.id))
.map((unit) => {
const expGained = 6;
const equipmentExpGained = 2;
advanceUnitExp(unit, expGained);
advanceReserveEquipment(unit, equipmentExpGained);
return {
unitId: unit.id,
name: unit.name,
expGained,
equipmentExpGained,
level: unit.level,
exp: unit.exp
};
});
}
function advanceUnitExp(unit: UnitData, amount: number) {
let exp = unit.exp + amount;
while (unit.level < 99 && exp >= 100) {
exp -= 100;
unit.level += 1;
unit.maxHp += 2;
unit.hp = Math.min(unit.maxHp, unit.hp + 2);
unit.attack += 1;
}
unit.exp = unit.level >= 99 ? Math.min(exp, 100) : exp;
}
function advanceReserveEquipment(unit: UnitData, amount: number) {
equipmentSlots.forEach((slot) => {
const state = unit.equipment[slot];
let exp = state.exp + amount;
while (state.level < 9 && exp >= equipmentExpToNext(state.level)) {
exp -= equipmentExpToNext(state.level);
state.level += 1;
}
state.exp = state.level >= 9 ? Math.min(exp, equipmentExpToNext(state.level)) : exp;
});
}
function parseRewardLabel(reward: string) {
const normalized = reward.replace(/\s+/g, ' ').trim();
const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/);