feat: enrich campaign reward and roster visuals

This commit is contained in:
2026-07-19 12:16:30 +09:00
parent 7c6a8174d1
commit f83296db7b
11 changed files with 1164 additions and 205 deletions

View File

@@ -0,0 +1,257 @@
export type SortieHistoryRecruitLike = string | {
unitId?: string;
};
export type SortieHistoryBattleLike = {
battleId?: string;
completedAt?: string;
sortiePerformance?: {
selectedUnitIds?: readonly string[];
};
campaignRewards?: {
recruits?: readonly SortieHistoryRecruitLike[];
};
};
export type SortieBattleHistoryLike =
| Readonly<Record<string, SortieHistoryBattleLike>>
| readonly SortieHistoryBattleLike[];
export type SortieRosterHistoryBadgeKind = 'recent-recruit' | 'first-sortie' | 'long-wait' | 'active';
export type SortieRosterHistoryBadge = {
kind: SortieRosterHistoryBadgeKind;
label: string;
};
export type SortieRosterUnitHistory = {
unitId: string;
sortieCount: number;
firstSortiePending: boolean;
battlesSinceLastSortie: number | null;
recentRecruit: boolean;
battlesSinceRecruit: number | null;
recruitBattleId?: string;
lastSortieBattleId?: string;
badge: SortieRosterHistoryBadge;
summary: string;
};
export type SortieRosterHistoryOptions = {
recentRecruitBattleWindow?: number;
longWaitBattleThreshold?: number;
};
export const defaultRecentRecruitBattleWindow = 1;
export const defaultLongWaitBattleThreshold = 3;
type NormalizedHistoryBattle = {
battleId?: string;
completedAtMs?: number;
selectedUnitIds: string[];
recruitUnitIds: string[];
sourceIndex: number;
};
type UnitHistoryAccumulator = {
sortieCount: number;
lastSortieIndex?: number;
recruitBattleIndex?: number;
};
export function deriveSortieRosterHistory(
unitIds: readonly string[],
battleHistory: SortieBattleHistoryLike,
options: SortieRosterHistoryOptions = {}
): SortieRosterUnitHistory[] {
const normalizedUnitIds = uniqueIds(unitIds);
const battles = normalizeBattleHistory(battleHistory);
const recentRecruitBattleWindow = nonNegativeInteger(
options.recentRecruitBattleWindow,
defaultRecentRecruitBattleWindow
);
const longWaitBattleThreshold = positiveInteger(
options.longWaitBattleThreshold,
defaultLongWaitBattleThreshold
);
const accumulatorByUnitId = new Map<string, UnitHistoryAccumulator>(
normalizedUnitIds.map((unitId) => [unitId, { sortieCount: 0 }])
);
battles.forEach((battle, battleIndex) => {
battle.selectedUnitIds.forEach((unitId) => {
const accumulator = accumulatorByUnitId.get(unitId);
if (!accumulator) {
return;
}
accumulator.sortieCount += 1;
accumulator.lastSortieIndex = battleIndex;
});
battle.recruitUnitIds.forEach((unitId) => {
const accumulator = accumulatorByUnitId.get(unitId);
if (accumulator && accumulator.recruitBattleIndex === undefined) {
accumulator.recruitBattleIndex = battleIndex;
}
});
});
return normalizedUnitIds.map((unitId) => {
const accumulator = accumulatorByUnitId.get(unitId) ?? { sortieCount: 0 };
const firstSortiePending = accumulator.sortieCount === 0;
const battlesSinceLastSortie = accumulator.lastSortieIndex === undefined
? null
: battles.length - accumulator.lastSortieIndex - 1;
const battlesSinceRecruit = accumulator.recruitBattleIndex === undefined
? null
: battles.length - accumulator.recruitBattleIndex - 1;
const recentRecruit = battlesSinceRecruit !== null && battlesSinceRecruit <= recentRecruitBattleWindow;
const badge = createHistoryBadge({
sortieCount: accumulator.sortieCount,
firstSortiePending,
battlesSinceLastSortie,
recentRecruit,
longWaitBattleThreshold
});
return {
unitId,
sortieCount: accumulator.sortieCount,
firstSortiePending,
battlesSinceLastSortie,
recentRecruit,
battlesSinceRecruit,
...(accumulator.recruitBattleIndex === undefined
? {}
: { recruitBattleId: battles[accumulator.recruitBattleIndex]?.battleId }),
...(accumulator.lastSortieIndex === undefined
? {}
: { lastSortieBattleId: battles[accumulator.lastSortieIndex]?.battleId }),
badge,
summary: createHistorySummary({
sortieCount: accumulator.sortieCount,
firstSortiePending,
battlesSinceLastSortie,
recentRecruit,
battlesSinceRecruit
})
};
});
}
export function deriveSortieRosterHistoryByUnitId(
unitIds: readonly string[],
battleHistory: SortieBattleHistoryLike,
options: SortieRosterHistoryOptions = {}
) {
return new Map(
deriveSortieRosterHistory(unitIds, battleHistory, options)
.map((history) => [history.unitId, history] as const)
);
}
function normalizeBattleHistory(battleHistory: SortieBattleHistoryLike): NormalizedHistoryBattle[] {
const entries = Array.isArray(battleHistory)
? battleHistory.map((battle, sourceIndex) => ({ battle, sourceIndex }))
: Object.entries(battleHistory).map(([battleId, battle], sourceIndex) => ({
battle: { ...battle, battleId: normalizedId(battle.battleId) ?? normalizedId(battleId) },
sourceIndex
}));
const normalized = entries.map(({ battle, sourceIndex }) => ({
battleId: normalizedId(battle.battleId),
completedAtMs: validTimestamp(battle.completedAt),
selectedUnitIds: uniqueIds(battle.sortiePerformance?.selectedUnitIds ?? []),
recruitUnitIds: uniqueIds((battle.campaignRewards?.recruits ?? []).map(recruitUnitId)),
sourceIndex
}));
if (normalized.length > 1 && normalized.every((battle) => battle.completedAtMs !== undefined)) {
normalized.sort((left, right) =>
(left.completedAtMs ?? 0) - (right.completedAtMs ?? 0) || left.sourceIndex - right.sourceIndex
);
}
return normalized;
}
function createHistoryBadge(context: {
sortieCount: number;
firstSortiePending: boolean;
battlesSinceLastSortie: number | null;
recentRecruit: boolean;
longWaitBattleThreshold: number;
}): SortieRosterHistoryBadge {
if (context.recentRecruit) {
return { kind: 'recent-recruit', label: 'NEW' };
}
if (context.firstSortiePending) {
return { kind: 'first-sortie', label: '미출전' };
}
if (
context.battlesSinceLastSortie !== null &&
context.battlesSinceLastSortie >= context.longWaitBattleThreshold
) {
return { kind: 'long-wait', label: `대기 ${context.battlesSinceLastSortie}` };
}
return { kind: 'active', label: `출전 ${context.sortieCount}` };
}
function createHistorySummary(context: {
sortieCount: number;
firstSortiePending: boolean;
battlesSinceLastSortie: number | null;
recentRecruit: boolean;
battlesSinceRecruit: number | null;
}) {
if (context.recentRecruit && context.firstSortiePending) {
return '최근 합류 · 첫 출전 대기';
}
if (context.firstSortiePending) {
return context.battlesSinceRecruit === null
? '출전 기록 없음 · 첫 출전 대기'
: `합류 후 ${context.battlesSinceRecruit}전 경과 · 첫 출전 대기`;
}
const lastSortie = context.battlesSinceLastSortie === 0
? '직전 전투 출전'
: `${context.battlesSinceLastSortie}전 전 마지막 출전`;
return `${context.recentRecruit ? '최근 합류 · ' : ''}출전 ${context.sortieCount}회 · ${lastSortie}`;
}
function recruitUnitId(recruit: SortieHistoryRecruitLike) {
return typeof recruit === 'string' ? recruit : recruit?.unitId;
}
function uniqueIds(values: readonly unknown[]) {
const seen = new Set<string>();
return values.flatMap((value) => {
const id = normalizedId(value);
if (!id || seen.has(id)) {
return [];
}
seen.add(id);
return [id];
});
}
function normalizedId(value: unknown) {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized || undefined;
}
function validTimestamp(value: unknown) {
if (typeof value !== 'string') {
return undefined;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function nonNegativeInteger(value: number | undefined, fallback: number) {
return Number.isFinite(value) ? Math.max(0, Math.floor(value ?? fallback)) : fallback;
}
function positiveInteger(value: number | undefined, fallback: number) {
return Number.isFinite(value) ? Math.max(1, Math.floor(value ?? fallback)) : fallback;
}

View File

@@ -100,6 +100,90 @@ export function storyBackgroundKeyForPage(baseKey: string, pageId: string) {
return keys[stableStoryHash(pageId) % keys.length];
}
type StoryBackgroundPageReference = Readonly<{
id: string;
background: string;
}>;
/**
* Select backgrounds in page order while preserving stable per-page defaults.
* The planner minimizes physical-image repeats across the whole sequence
* first, then minimizes deviations from each page's hash-selected preference.
* Explicit assignments remain authoritative.
*/
export function storyBackgroundKeysForPages(pages: readonly StoryBackgroundPageReference[]) {
let plans: StoryBackgroundSequencePlan[] = [];
pages.forEach((page, pageIndex) => {
const options = storyBackgroundOptionsForPage(page);
plans = options.map((key, preferenceRank) => {
if (pageIndex === 0) {
return { keys: [key], repeats: 0, preferenceCost: preferenceRank };
}
let bestPlan: StoryBackgroundSequencePlan | undefined;
plans.forEach((previousPlan) => {
const previousKey = previousPlan.keys[previousPlan.keys.length - 1];
const repeats = previousPlan.repeats + (storyBackgroundUrl(previousKey) === storyBackgroundUrl(key) ? 1 : 0);
const candidatePlan = {
keys: [...previousPlan.keys, key],
repeats,
preferenceCost: previousPlan.preferenceCost + preferenceRank
};
if (!bestPlan || storyBackgroundPlanIsBetter(candidatePlan, bestPlan)) {
bestPlan = candidatePlan;
}
});
return bestPlan ?? { keys: [key], repeats: 0, preferenceCost: preferenceRank };
});
});
return plans.reduce<StoryBackgroundSequencePlan | undefined>(
(bestPlan, candidatePlan) =>
!bestPlan || storyBackgroundPlanIsBetter(candidatePlan, bestPlan) ? candidatePlan : bestPlan,
undefined
)?.keys ?? [];
}
export function storyBackgroundPageHasFixedAssignment(pageId: string) {
return Object.prototype.hasOwnProperty.call(storyBackgroundPageAssignments, pageId);
}
type StoryBackgroundSequencePlan = {
keys: string[];
repeats: number;
preferenceCost: number;
};
function storyBackgroundOptionsForPage(page: StoryBackgroundPageReference) {
const keys = storyBackgroundKeysFor(page.background);
if (keys.length === 0) {
return [page.background];
}
const fixedKey = storyBackgroundPageAssignments[page.id];
if (fixedKey && keys.includes(fixedKey)) {
return [fixedKey];
}
const preferredKey = storyBackgroundKeyForPage(page.background, page.id);
const preferredIndex = Math.max(0, keys.indexOf(preferredKey));
return [
preferredKey,
...keys.slice(preferredIndex + 1),
...keys.slice(0, preferredIndex)
].filter((key, index, options) => options.indexOf(key) === index);
}
function storyBackgroundPlanIsBetter(candidate: StoryBackgroundSequencePlan, current: StoryBackgroundSequencePlan) {
return candidate.repeats < current.repeats ||
(candidate.repeats === current.repeats && candidate.preferenceCost < current.preferenceCost);
}
function storyBackgroundUrl(key: string) {
return storyBackgroundAssets[key] ?? key;
}
function stableStoryHash(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {

View File

@@ -51,6 +51,10 @@ import {
type SortieSynergyComparison,
type SortieSynergySnapshot
} from '../data/sortieSynergy';
import {
deriveSortieRosterHistoryByUnitId,
type SortieRosterUnitHistory
} from '../data/sortieRosterHistory';
import {
evaluateSortiePerformanceReview,
sortiePerformanceGradeForScore,
@@ -255,11 +259,22 @@ type VictoryRewardCardId = 'gold' | 'equipment' | 'supplies' | 'reputation' | 'r
type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close';
type VictoryRewardIcon =
| { kind: 'mark'; mark: string }
| { kind: 'battle-ui'; iconKey: BattleUiIconKey }
| { kind: 'item'; itemId: string }
| { kind: 'portrait'; unitId: string };
type VictoryRewardCardView = {
id: VictoryRewardCardId;
label: string;
items: string[];
background: Phaser.GameObjects.Rectangle;
iconKind: VictoryRewardIcon['kind'];
iconKey: string;
iconReady: boolean;
isNew: boolean;
actionId?: VictoryRewardActionId;
};
type VictoryRewardActionView = {
@@ -386,6 +401,7 @@ type SortieFocusedUnitSummary = SortieUnitTacticalSummary & {
reserveTrainingBondPreviewLine: string;
reserveTrainingBondPartnerCount: number;
reserveTrainingPlanLine: string;
serviceHistory: SortieRosterUnitHistory;
terrainScore: number;
terrainGrade: string;
equipment: {
@@ -13045,7 +13061,21 @@ export class CampScene extends Phaser.Scene {
);
subtitle.setDepth(depth + 2);
const cardDefinitions: Array<{ id: VictoryRewardCardId; label: string; items: string[] }> = [
const equipmentReward = rewards?.equipment[0];
const equipmentItem = equipmentReward ? this.itemForRewardLabel(equipmentReward) : undefined;
const supplyReward = supplyItems[0];
const supply = supplyReward ? this.supplyForRewardLabel(supplyReward) : undefined;
const recruit = rewards?.recruits[0];
const cardDefinitions: Array<{
id: VictoryRewardCardId;
label: string;
items: string[];
accent: number;
icon: VictoryRewardIcon;
actionId?: VictoryRewardActionId;
actionHint: string;
isNew?: boolean;
}> = [
{
id: 'gold',
label: '군자금',
@@ -13054,13 +13084,58 @@ export class CampScene extends Phaser.Scene {
...(presentation.sortieOrderBonus
? [`${presentation.sortieOrderBonus.label} 첫 공훈 +${presentation.sortieOrderBonus.rewardGold} 포함`]
: [])
]
],
accent: palette.gold,
icon: { kind: 'mark', mark: '金' },
actionHint: '승전 자산에 반영 완료'
},
{ id: 'equipment', label: '장비', items: [...(rewards?.equipment ?? [])] },
{ id: 'supplies', label: '보급', items: supplyItems },
{ id: 'reputation', label: '명성·기록', items: [...(rewards?.reputation ?? [])] },
{ id: 'recruits', label: '합류 무장', items: rewards?.recruits.map((recruit) => recruit.name) ?? [] },
{ id: 'unlocks', label: '새 전장', items: rewards?.unlocks.map((unlock) => unlock.title) ?? [] }
{
id: 'equipment',
label: '장비',
items: [...(rewards?.equipment ?? [])],
accent: equipmentItem?.rank === 'treasure' ? palette.gold : palette.blue,
icon: equipmentItem ? { kind: 'item', itemId: equipmentItem.id } : { kind: 'battle-ui', iconKey: 'sword' },
actionId: 'equipment',
actionHint: '눌러서 장비 비교',
isNew: Boolean(equipmentReward)
},
{
id: 'supplies',
label: '보급',
items: supplyItems,
accent: palette.green,
icon: { kind: 'battle-ui', iconKey: supply?.usableId ?? 'bean' },
actionId: 'supplies',
actionHint: '눌러서 보급 확인'
},
{
id: 'reputation',
label: '명성·기록',
items: [...(rewards?.reputation ?? [])],
accent: 0xc887dc,
icon: { kind: 'mark', mark: '名' },
actionHint: '전황 기록에 반영 완료'
},
{
id: 'recruits',
label: '합류 무장',
items: rewards?.recruits.map((rewardRecruit) => rewardRecruit.name) ?? [],
accent: palette.gold,
icon: recruit ? { kind: 'portrait', unitId: recruit.unitId } : { kind: 'battle-ui', iconKey: 'leadership' },
actionId: 'sortie',
actionHint: '눌러서 편성에 배치',
isNew: Boolean(recruit)
},
{
id: 'unlocks',
label: '새 전장',
items: rewards?.unlocks.map((unlock) => unlock.title) ?? [],
accent: 0xe78a55,
icon: { kind: 'battle-ui', iconKey: 'terrain' },
actionId: 'sortie',
actionHint: '눌러서 다음 전장 준비',
isNew: Boolean(rewards?.unlocks.length)
}
];
const cardWidth = 252;
const cardHeight = 104;
@@ -13069,28 +13144,75 @@ export class CampScene extends Phaser.Scene {
const row = Math.floor(index / 3);
const cardX = panelX + 34 + column * 268;
const cardY = panelY + 128 + row * 120;
const background = this.trackVictoryReward(
this.add.rectangle(cardX, cardY, cardWidth, cardHeight, definition.id === 'gold' ? 0x2d2619 : 0x141f2a, 0.98)
);
const hasReward = definition.items.length > 0;
const actionable = hasReward && Boolean(definition.actionId);
const restingFill = definition.id === 'gold' ? 0x2d2619 : hasReward ? 0x17232e : 0x111820;
const background = this.trackVictoryReward(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, restingFill, 0.98));
background.setOrigin(0);
background.setDepth(depth + 2);
background.setStrokeStyle(1, definition.id === 'gold' ? palette.gold : palette.blue, definition.items.length > 0 ? 0.78 : 0.38);
background.setStrokeStyle(hasReward ? 2 : 1, definition.accent, hasReward ? 0.78 : 0.3);
const iconPlate = this.trackVictoryReward(this.add.rectangle(cardX + 12, cardY + 14, 62, 62, 0x080d13, hasReward ? 0.96 : 0.64));
iconPlate.setOrigin(0);
iconPlate.setDepth(depth + 3);
iconPlate.setStrokeStyle(1, definition.accent, hasReward ? 0.72 : 0.26);
const iconState = this.renderVictoryRewardIcon(definition.icon, cardX + 43, cardY + 45, 50, depth + 4, hasReward ? 1 : 0.42);
const label = this.trackVictoryReward(
this.add.text(cardX + 16, cardY + 13, definition.label, this.textStyle(13, definition.id === 'gold' ? '#ffdf7b' : '#9fc8e8', true))
this.add.text(cardX + 86, cardY + 11, definition.label, this.textStyle(12, hasReward ? '#9fc8e8' : '#77818c', true))
);
label.setDepth(depth + 3);
if (definition.isNew && hasReward) {
const newBadge = this.trackVictoryReward(this.add.rectangle(cardX + cardWidth - 30, cardY + 16, 44, 19, 0x7b271f, 0.98));
newBadge.setDepth(depth + 4);
newBadge.setStrokeStyle(1, 0xffb08e, 0.9);
const newLabel = this.trackVictoryReward(this.add.text(cardX + cardWidth - 30, cardY + 16, 'NEW', this.textStyle(9, '#fff0d8', true)));
newLabel.setOrigin(0.5);
newLabel.setDepth(depth + 5);
}
const itemText = definition.items.length > 0 ? definition.items.join(' · ') : '변동 없음';
const value = this.trackVictoryReward(
this.add.text(cardX + 16, cardY + 40, itemText, {
...this.textStyle(definition.id === 'gold' ? 17 : 12, definition.items.length > 0 ? '#f2e3bf' : '#77818c', true),
wordWrap: { width: cardWidth - 32, useAdvancedWrap: true },
lineSpacing: 4
this.add.text(cardX + 86, cardY + 34, itemText, {
...this.textStyle(definition.id === 'gold' ? 16 : 12, hasReward ? '#f2e3bf' : '#77818c', true),
wordWrap: { width: cardWidth - 100, useAdvancedWrap: true },
lineSpacing: 3,
maxLines: 2
})
);
value.setDepth(depth + 3);
this.victoryRewardCards.push({ ...definition, background });
const actionHint = this.trackVictoryReward(
this.add.text(cardX + 86, cardY + cardHeight - 18, hasReward ? definition.actionHint : '이번 전투 변동 없음', this.textStyle(9, actionable ? '#ffdf7b' : '#7f8994', actionable))
);
actionHint.setDepth(depth + 3);
if (actionable && definition.actionId) {
background.setInteractive({ useHandCursor: true });
background.on('pointerover', () => {
background.setFillStyle(definition.id === 'gold' ? 0x3a3020 : 0x223342, 1);
background.setStrokeStyle(2, palette.gold, 0.98);
iconPlate.setStrokeStyle(2, palette.gold, 0.92);
});
background.on('pointerout', () => {
background.setFillStyle(restingFill, 0.98);
background.setStrokeStyle(2, definition.accent, 0.78);
iconPlate.setStrokeStyle(1, definition.accent, 0.72);
});
background.on('pointerdown', () => this.completeVictoryRewardAcknowledgement(definition.actionId!));
}
this.victoryRewardCards.push({
id: definition.id,
label: definition.label,
items: definition.items,
background,
iconKind: definition.icon.kind,
iconKey: iconState.key,
iconReady: iconState.ready,
isNew: Boolean(definition.isNew && hasReward),
...(actionable && definition.actionId ? { actionId: definition.actionId } : {})
});
});
const note = rewards?.note?.trim();
@@ -13150,6 +13272,13 @@ export class CampScene extends Phaser.Scene {
private completeVictoryRewardAcknowledgement(action: VictoryRewardActionId) {
const report = this.pendingVictoryRewardReport;
const rewardedRecruitId = report?.campaignRewards?.recruits[0]?.unitId;
if (action === 'sortie' && rewardedRecruitId && this.sortieAllies().some((unit) => unit.id === rewardedRecruitId)) {
this.focusVictoryRewardRecruit(rewardedRecruitId);
}
if (action === 'equipment' && report) {
this.focusVictoryRewardEquipment(report);
}
if (report) {
this.campaign = acknowledgeCampaignVictoryReward(report.battleId);
if (this.campaign.acknowledgedVictoryRewardBattleIds.includes(report.battleId)) {
@@ -13191,6 +13320,108 @@ export class CampScene extends Phaser.Scene {
this.victoryRewardActions = [];
}
private rewardInventoryLabel(label: string) {
return label.replace(/\s*(?:[+xX×-]?\s*\d+)\s*$/u, '').trim();
}
private itemForRewardLabel(label: string) {
return findItemByName(this.rewardInventoryLabel(label));
}
private supplyForRewardLabel(label: string) {
return campSupplyByLabel.get(this.rewardInventoryLabel(label));
}
private focusVictoryRewardEquipment(report: FirstBattleReport) {
const rewardItemIds = new Set(
(report.campaignRewards?.equipment ?? [])
.map((label) => this.itemForRewardLabel(label)?.id)
.filter((itemId): itemId is string => Boolean(itemId))
);
if (rewardItemIds.size === 0) {
return;
}
const rewardIndex = this.equipmentInventoryEntries().findIndex((entry) => rewardItemIds.has(entry.item.id));
if (rewardIndex >= 0) {
this.equipmentInventoryPage = Math.floor(rewardIndex / equipmentInventoryPageSize);
}
}
private focusVictoryRewardRecruit(unitId: string) {
this.sortieFocusedUnitId = unitId;
const displayUnits = this.sortieRosterDisplayUnits(this.sortieRosterUnits());
const recruitIndex = displayUnits.findIndex((unit) => unit.id === unitId);
if (recruitIndex >= 0) {
this.sortiePortraitRosterPage = Math.floor(recruitIndex / sortiePortraitRosterPageSize);
}
}
private renderVictoryRewardIcon(
icon: VictoryRewardIcon,
x: number,
y: number,
size: number,
depth: number,
alpha: number
): { key: string; ready: boolean } {
if (icon.kind === 'item') {
const textureKey = `item-${icon.itemId}`;
if (this.textures.exists(textureKey)) {
const image = this.trackVictoryReward(this.add.image(x, y, textureKey));
image.setDisplaySize(this.campUiLength(size), this.campUiLength(size));
image.setAlpha(alpha);
image.setDepth(depth);
return { key: textureKey, ready: true };
}
return this.renderVictoryRewardIcon({ kind: 'battle-ui', iconKey: 'sword' }, x, y, size, depth, alpha);
}
if (icon.kind === 'portrait') {
const unit = this.currentUnits().find((candidate) => candidate.id === icon.unitId);
const portraitKey = campaignPortraitKeysByUnitId[icon.unitId];
const textureKey = portraitKey
? [portraitCardTextureKey(portraitKey), portraitTextureKey(portraitKey)].find(
(candidate): candidate is string => Boolean(candidate && this.textures.exists(candidate))
)
: undefined;
if (textureKey) {
const image = this.trackVictoryReward(this.add.image(x, y, textureKey));
image.setDisplaySize(this.campUiLength(size), this.campUiLength(size));
image.setAlpha(alpha);
image.setDepth(depth);
return { key: textureKey, ready: true };
}
return this.renderVictoryRewardMark(unit?.name.slice(0, 1) ?? '將', x, y, size, depth, alpha, `portrait-${icon.unitId}`);
}
if (icon.kind === 'battle-ui') {
const displaySize = this.campUiLength(size);
const textureKey = battleUiIconTextureForSize(displaySize);
if (this.textures.exists(textureKey)) {
const image = this.trackVictoryReward(this.add.image(x, y, textureKey, battleUiIconFrames[icon.iconKey]));
image.setDisplaySize(displaySize, displaySize);
image.setAlpha(alpha);
image.setDepth(depth);
return { key: icon.iconKey, ready: true };
}
return this.renderVictoryRewardMark('賞', x, y, size, depth, alpha, icon.iconKey);
}
return this.renderVictoryRewardMark(icon.mark, x, y, size, depth, alpha, icon.mark);
}
private renderVictoryRewardMark(mark: string, x: number, y: number, size: number, depth: number, alpha: number, key: string) {
const seal = this.trackVictoryReward(this.add.circle(x, y, size / 2, 0x2d2619, 0.94));
seal.setStrokeStyle(2, palette.gold, 0.74);
seal.setAlpha(alpha);
seal.setDepth(depth);
const label = this.trackVictoryReward(this.add.text(x, y, mark, this.textStyle(Math.max(20, Math.round(size * 0.52)), '#ffdf7b', true)));
label.setOrigin(0.5);
label.setAlpha(alpha);
label.setDepth(depth + 1);
return { key, ready: true };
}
private handleEscapeKey() {
if (this.victoryRewardObjects.length > 0) {
this.completeVictoryRewardAcknowledgement('close');
@@ -13777,6 +14008,7 @@ export class CampScene extends Phaser.Scene {
const selectedCount = this.selectedSortieUnitIds.length;
const maxUnits = this.sortieMaxUnits();
const rangeEnd = pageStart + visibleUnits.length;
const serviceHistoryByUnitId = this.sortieRosterHistoryByUnitId();
const previousEnabled = this.sortiePortraitRosterPage > 0;
const nextEnabled = this.sortiePortraitRosterPage < pageCount - 1;
const previousButtonBounds = { x: x + width - 100, y: y + 9, width: 40, height: 30 };
@@ -13852,6 +14084,7 @@ export class CampScene extends Phaser.Scene {
const availability = this.sortieUnitAvailability(unit);
const role = this.sortieFormationRole(unit);
const preview = this.sortieUnitSynergyPreview(unit);
const serviceHistory = serviceHistoryByUnitId.get(unit.id);
const canPinSwap = availability.available && !selected && selectedCount >= maxUnits &&
this.isSortieSelected(this.sortieFocusedUnitId) && !this.isRequiredSortieUnit(this.sortieFocusedUnitId);
const fill = !availability.available
@@ -13937,10 +14170,16 @@ export class CampScene extends Phaser.Scene {
portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42);
portraitView.image?.setDisplaySize(this.campUiLength(70), this.campUiLength(70));
});
const statusLabel = !availability.available
const rosterStatusLabel = !availability.available
? required ? `필수 · ${availability.label}` : availability.label
: required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기';
const statusColor = !availability.available
const serviceStatusLabel = availability.available && serviceHistory?.badge.kind !== 'active'
? serviceHistory?.badge.label
: undefined;
const statusLabel = serviceStatusLabel ? `${serviceStatusLabel} · ${rosterStatusLabel}` : rosterStatusLabel;
const statusColor = serviceStatusLabel && serviceHistory
? this.sortieRosterHistoryTone(serviceHistory).text
: !availability.available
? '#ff9d7d'
: required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf';
const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 9, statusLabel, this.textStyle(10, statusColor, true)));
@@ -15066,6 +15305,7 @@ export class CampScene extends Phaser.Scene {
this.sortieRosterToggleBounds = [];
const units = this.sortieRosterDisplayUnits(this.sortieRosterUnits());
const serviceHistoryByUnitId = this.sortieRosterHistoryByUnitId();
const compactGrid = units.length > 3;
const columns = compactGrid ? 2 : 3;
const rows = Math.max(1, Math.ceil(units.length / columns));
@@ -15084,6 +15324,7 @@ export class CampScene extends Phaser.Scene {
const required = this.isRequiredSortieUnit(unit.id);
const recommendation = this.sortieRecommendation(unit.id);
const role = this.sortieFormationRole(unit);
const serviceHistory = serviceHistoryByUnitId.get(unit.id);
const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, selected ? 0x172a22 : 0x121820, selected ? 0.98 : 0.78));
card.setOrigin(0);
card.setDepth(depth + 1);
@@ -15102,6 +15343,9 @@ export class CampScene extends Phaser.Scene {
: `${this.sortieFormationRoleLabel(role)} 역할로 전장에 합류합니다.`;
if (compactGrid) {
this.renderFirstSortiePortrait(cardX + 50, cardY + 55, 72, unit, depth + 2, selected ? 1 : 0.5);
if (serviceHistory) {
this.renderSortieRosterHistoryBadge(serviceHistory, cardX + 50, cardY + 20, 68, depth + 5);
}
this.trackSortie(this.add.text(cardX + 96, cardY + 10, `${unit.name} Lv${unit.level}`, this.textStyle(16, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 96, cardY + 36, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(10, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(cardX + 96, cardY + 56, cardWidth - 110, 6, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
@@ -15110,6 +15354,9 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(this.add.text(cardX + 14, cardY + 113, this.compactText(this.sortieStrategyLine(unit.id), 34), this.textStyle(9, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2);
} else {
this.renderFirstSortiePortrait(cardX + cardWidth / 2, cardY + 88, 132, unit, depth + 2, selected ? 1 : 0.5);
if (serviceHistory) {
this.renderSortieRosterHistoryBadge(serviceHistory, cardX + cardWidth / 2, cardY + 30, 78, depth + 5);
}
this.trackSortie(this.add.text(cardX + 14, cardY + 162, `${unit.name} Lv${unit.level}`, this.textStyle(20, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 190, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(cardX + 14, cardY + 211, cardWidth - 28, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
@@ -16243,6 +16490,7 @@ export class CampScene extends Phaser.Scene {
const statusColor = status.tone === 'disabled' ? '#77818c' : status.tone === 'selected' ? '#a8ffd0' : status.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf';
const selected = this.isSortieSelected(unit.id);
const synergyPreview = this.sortieUnitSynergyPreview(unit);
const serviceHistory = this.sortieRosterHistoryByUnitId().get(unit.id);
const synergyColor = synergyPreview.mode === 'remove'
? '#ff9d7d'
: synergyPreview.mode === 'add' || synergyPreview.mode === 'current'
@@ -16255,6 +16503,12 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(this.add.text(x + width - 16, y + 16, status.label, this.textStyle(11, statusColor, true))).setOrigin(1, 0).setDepth(depth + 1);
this.renderSortieBattleUiIcon(this.unitClassIconKey(unit), x + 34, y + 58, 42, depth + 1, availability.available ? 1 : 0.5);
this.trackSortie(this.add.text(x + 66, y + 42, `${unit.name} Lv${unit.level}`, this.textStyle(18, availability.available ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 1);
if (serviceHistory) {
const serviceTone = this.sortieRosterHistoryTone(serviceHistory);
this.trackSortie(
this.add.text(x + width - 16, y + 43, this.compactText(serviceHistory.summary, 25), this.textStyle(10, serviceTone.text, true))
).setOrigin(1, 0).setDepth(depth + 1);
}
this.trackSortie(this.add.text(x + 66, y + 68, `${unit.className} · ${getUnitClass(unit.classKey).role}`, this.textStyle(12, availability.available ? '#d4dce6' : '#77818c', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 16, y + 68, `HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, unit.hp < unit.maxHp ? '#ffdf7b' : '#a8ffd0', true))).setOrigin(1, 0).setDepth(depth + 1);
this.drawSortieBar(x + 66, y + 88, width - 92, 7, unit.hp / unit.maxHp, availability.available ? palette.green : 0x53606c, depth + 1);
@@ -18565,6 +18819,7 @@ export class CampScene extends Phaser.Scene {
const terrainScore = this.sortieTerrainScore(unit, scenario);
const assignedReserveTrainingFocus = this.campaign?.reserveTrainingAssignments[unit.id];
const reserveTrainingFocus = this.reserveTrainingFocusDefinitionForUnit(unit.id);
const serviceHistory = this.sortieRosterHistoryByUnitId().get(unit.id) ?? this.emptySortieRosterHistory(unit.id);
return {
id: unit.id,
name: unit.name,
@@ -18581,6 +18836,7 @@ export class CampScene extends Phaser.Scene {
reserveTrainingBondPreviewLine: this.reserveTrainingBondPreviewLine(unit),
reserveTrainingBondPartnerCount: this.reserveTrainingBondPartnerNames(unit).length,
reserveTrainingPlanLine: this.reserveTrainingPlanLine(unit),
serviceHistory,
terrainScore,
terrainGrade: this.sortieTerrainGrade(terrainScore),
statLine: summary.statLine,
@@ -19810,6 +20066,55 @@ export class CampScene extends Phaser.Scene {
return { label: '대기', tone: 'reserve' as const };
}
private sortieRosterHistoryByUnitId(allies: UnitData[] = this.sortieAllies()) {
return deriveSortieRosterHistoryByUnitId(
allies.map((unit) => unit.id),
this.campaign?.battleHistory ?? {}
);
}
private emptySortieRosterHistory(unitId: string): SortieRosterUnitHistory {
return {
unitId,
sortieCount: 0,
firstSortiePending: true,
battlesSinceLastSortie: null,
recentRecruit: false,
battlesSinceRecruit: null,
badge: { kind: 'first-sortie', label: '미출전' },
summary: '출전 기록 없음 · 첫 출전 대기'
};
}
private sortieRosterHistoryTone(history: SortieRosterUnitHistory) {
switch (history.badge.kind) {
case 'recent-recruit':
return { frame: palette.red, text: '#fff0d8' };
case 'first-sortie':
return { frame: palette.gold, text: '#ffdf7b' };
case 'long-wait':
return { frame: 0xe78a55, text: '#ffc29d' };
default:
return { frame: palette.green, text: '#a8ffd0' };
}
}
private renderSortieRosterHistoryBadge(
history: SortieRosterUnitHistory,
x: number,
y: number,
width: number,
depth: number
) {
const tone = this.sortieRosterHistoryTone(history);
const background = this.trackSortie(this.add.rectangle(x, y, width, 18, 0x090e14, 0.96));
background.setStrokeStyle(1, tone.frame, 0.9);
background.setDepth(depth);
this.trackSortie(this.add.text(x, y, history.badge.label, this.textStyle(8, tone.text, true)))
.setOrigin(0.5)
.setDepth(depth + 1);
}
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);
@@ -23430,6 +23735,7 @@ export class CampScene extends Phaser.Scene {
const sortieCoreResonanceCandidates = this.sortieCoreResonanceCandidates();
const sortieCoreResonanceSelection = this.campaign?.sortieResonanceSelection;
const sortieRecommendationPlanResults = this.sortieRecommendationPlans();
const sortieRosterHistoryByUnitId = this.sortieRosterHistoryByUnitId();
const sortieRecommendationPlans = this.sortieRecommendationProjections();
const recommendedPresetId = this.recommendedSortiePresetId();
const sortieFormationPresets = this.sortieFormationPresets();
@@ -23484,6 +23790,12 @@ export class CampScene extends Phaser.Scene {
id: card.id,
label: card.label,
items: [...card.items],
iconKind: card.iconKind,
iconKey: card.iconKey,
iconReady: card.iconReady,
new: card.isNew,
actionId: card.actionId ?? null,
interactive: Boolean(card.background.input?.enabled),
bounds: this.sortieObjectBoundsDebug(card.background)
})),
actions: this.victoryRewardActions.map((action) => ({
@@ -24208,6 +24520,10 @@ export class CampScene extends Phaser.Scene {
reserveTrainingPlanLine: this.reserveTrainingPlanLine(unit),
recommended: Boolean(this.sortieRecommendation(unit.id)),
recommendationReason: this.sortieRecommendation(unit.id)?.reason ?? null,
serviceHistory: (() => {
const history = sortieRosterHistoryByUnitId.get(unit.id) ?? this.emptySortieRosterHistory(unit.id);
return { ...history, badge: { ...history.badge } };
})(),
statLine: summary.statLine,
equipmentLine: summary.equipmentLine,
bondLine: summary.bondLine,

View File

@@ -9,7 +9,11 @@ import {
type BattleUiIconKey
} from '../data/battleUiIcons';
import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets';
import { storyBackgroundAssets, storyBackgroundKeyForPage } from '../data/storyAssets';
import {
storyBackgroundAssets,
storyBackgroundKeyForPage,
storyBackgroundKeysForPages
} from '../data/storyAssets';
import {
ensureUnitAnimations,
loadUnitBaseSheets,
@@ -130,6 +134,7 @@ const firstBattleWarmupUnitTextureKeys = [
export class StoryScene extends Phaser.Scene {
private pageIndex = 0;
private pages: StoryPage[] = prologuePages;
private selectedStoryBackgroundKeys = storyBackgroundKeysForPages(prologuePages);
private nextScene = 'BattleScene';
private nextSceneData?: Record<string, unknown>;
private transitioning = false;
@@ -159,6 +164,7 @@ export class StoryScene extends Phaser.Scene {
init(data?: StorySceneData) {
this.pages = data?.pages?.length ? data.pages : prologuePages;
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
this.nextScene = data?.nextScene ?? 'BattleScene';
this.nextSceneData = data?.nextSceneData;
this.pageIndex = 0;
@@ -745,7 +751,10 @@ export class StoryScene extends Phaser.Scene {
}
private pageBackgroundAssetKey(page: StoryPage) {
const key = storyBackgroundKeyForPage(page.background, page.id);
const pageIndex = this.pages.indexOf(page);
const key = pageIndex >= 0
? this.selectedStoryBackgroundKeys[pageIndex]
: storyBackgroundKeyForPage(page.background, page.id);
return storyBackgroundAssets[key] ? key : page.background;
}