fix: paginate sortie portrait roster

This commit is contained in:
2026-07-12 03:54:53 +09:00
parent ea60aa1de6
commit a7925985c7
2 changed files with 466 additions and 103 deletions

View File

@@ -240,6 +240,19 @@ type CampRosterLayout = {
nextEnabled: boolean;
};
type SortiePortraitRosterLayout = {
page: number;
pageCount: number;
pageSize: number;
totalCount: number;
visibleUnitIds: string[];
previousButtonBounds: CampRosterBounds;
nextButtonBounds: CampRosterBounds;
cardBounds: Array<CampRosterBounds & { unitId: string }>;
previousEnabled: boolean;
nextEnabled: boolean;
};
type CampSupplyDefinition = {
label: string;
usableId: string;
@@ -475,6 +488,7 @@ type SortieConfigurationSnapshot = {
focusedUnitId: string;
planFeedback: string;
rosterScroll: number;
portraitRosterPage: number;
};
type SortieSwapProjection = {
@@ -907,6 +921,7 @@ const sortiePresetDefinitions: {
];
const reportFormationHistoryPageSize = 7;
const sortiePortraitRosterPageSize = 6;
const defaultSortieRule: SortieRuleDefinition = {
maxUnits: maxSortieUnits,
@@ -11091,6 +11106,8 @@ export class CampScene extends Phaser.Scene {
private sortieCoreResonanceToggleButton?: Phaser.GameObjects.Rectangle;
private sortieCoreResonanceToggleLabel?: Phaser.GameObjects.Text;
private sortieRosterScroll = 0;
private sortiePortraitRosterPage = 0;
private sortiePortraitRosterLayout?: SortiePortraitRosterLayout;
private sortiePlanFeedback = '';
private sortiePrepStep: SortiePrepStep = 'briefing';
private terrainCountCache = new Map<BattleScenarioId, SortieTerrainCounts>();
@@ -11118,6 +11135,9 @@ export class CampScene extends Phaser.Scene {
this.sortieCoreResonanceSelectorOpen = false;
this.sortieCoreResonanceToggleButton = undefined;
this.sortieCoreResonanceToggleLabel = undefined;
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
this.sortiePortraitRosterLayout = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieSwapUndoState = undefined;
this.sortieFormationPanelMode = 'roster';
@@ -12717,6 +12737,7 @@ export class CampScene extends Phaser.Scene {
const usesStagedPrep = this.usesStagedSortiePrep(flow);
if (usesStagedPrep && !wasVisible) {
this.sortiePrepStep = 'briefing';
this.sortiePortraitRosterPage = 0;
}
const checklist = this.sortieChecklist();
const prepTitle = isBattleSortie ? '출진 준비' : '군영 의정';
@@ -12856,6 +12877,7 @@ export class CampScene extends Phaser.Scene {
this.sortieFormationPanelMode = 'order';
}
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
soundDirector.playSelect();
this.showSortiePrep();
}
@@ -13052,31 +13074,35 @@ export class CampScene extends Phaser.Scene {
const rosterUnits = this.sortieRosterUnits();
const displayUnits = this.sortieRosterDisplayUnits(rosterUnits);
const visibleCount = 8;
const maxScroll = Math.max(0, displayUnits.length - visibleCount);
this.sortieRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, maxScroll);
const visibleUnits = displayUnits.slice(this.sortieRosterScroll, this.sortieRosterScroll + visibleCount);
const pageCount = Math.max(1, Math.ceil(displayUnits.length / sortiePortraitRosterPageSize));
this.sortiePortraitRosterPage = Phaser.Math.Clamp(this.sortiePortraitRosterPage, 0, pageCount - 1);
const pageStart = this.sortiePortraitRosterPage * sortiePortraitRosterPageSize;
const visibleUnits = displayUnits.slice(pageStart, pageStart + sortiePortraitRosterPageSize);
const selectedCount = this.selectedSortieUnitIds.length;
const maxUnits = this.sortieMaxUnits();
const rangeEnd = this.sortieRosterScroll + visibleUnits.length;
const rangeEnd = pageStart + visibleUnits.length;
const previousEnabled = this.sortiePortraitRosterPage > 0;
const nextEnabled = this.sortiePortraitRosterPage < pageCount - 1;
const previousButtonBounds = { x: x + width - 100, y: y + 9, width: 40, height: 30 };
const nextButtonBounds = { x: x + width - 52, y: y + 9, width: 40, height: 30 };
this.trackSortie(this.add.text(x + 18, y + 13, `무장 선택 · 출전 ${selectedCount}/${maxUnits}`, this.textStyle(19, '#f2e3bf', true))).setDepth(depth + 1);
const rangeText = this.trackSortie(
this.add.text(
x + width - 116,
y + 18,
`${displayUnits.length > 0 ? this.sortieRosterScroll + 1 : 0}-${rangeEnd}/${displayUnits.length}`,
`${displayUnits.length > 0 ? pageStart + 1 : 0}-${rangeEnd}/${displayUnits.length} · ${this.sortiePortraitRosterPage + 1}/${pageCount}`,
this.textStyle(11, '#9fb0bf', true)
)
);
rangeText.setOrigin(1, 0);
rangeText.setDepth(depth + 1);
this.renderFirstSortieInlineButton('', x + width - 100, y + 9, 40, 30, this.sortieRosterScroll > 0, false, () => {
this.renderFirstSortieInlineButton('', previousButtonBounds.x, previousButtonBounds.y, previousButtonBounds.width, previousButtonBounds.height, previousEnabled, false, () => {
soundDirector.playSelect();
this.updateSortieRosterScroll(this.sortieRosterScroll - visibleCount, maxScroll);
this.setSortiePortraitRosterPage(this.sortiePortraitRosterPage - 1, pageCount);
}, depth + 1);
this.renderFirstSortieInlineButton('', x + width - 52, y + 9, 40, 30, this.sortieRosterScroll < maxScroll, false, () => {
this.renderFirstSortieInlineButton('', nextButtonBounds.x, nextButtonBounds.y, nextButtonBounds.width, nextButtonBounds.height, nextEnabled, false, () => {
soundDirector.playSelect();
this.updateSortieRosterScroll(this.sortieRosterScroll + visibleCount, maxScroll);
this.setSortiePortraitRosterPage(this.sortiePortraitRosterPage + 1, pageCount);
}, depth + 1);
const handleRosterWheel = (
@@ -13088,26 +13114,28 @@ export class CampScene extends Phaser.Scene {
) => {
event?.stopPropagation();
const direction = Math.sign(deltaY);
if (direction === 0 || maxScroll <= 0) {
if (direction === 0 || pageCount <= 1) {
return;
}
this.updateSortieRosterScroll(this.sortieRosterScroll + direction * 2, maxScroll);
this.setSortiePortraitRosterPage(this.sortiePortraitRosterPage + direction, pageCount);
};
if (maxScroll > 0) {
if (pageCount > 1) {
bg.setInteractive({ useHandCursor: false });
bg.on('wheel', handleRosterWheel);
}
const columnGap = 10;
const rowGap = 8;
const rowGap = 10;
const cardWidth = Math.floor((width - 36 - columnGap) / 2);
const cardTop = y + 50;
const cardHeight = Math.floor((height - 64 - rowGap * 3) / 4);
const cardHeight = Math.floor((height - 64 - rowGap * 2) / 3);
const cardBounds: SortiePortraitRosterLayout['cardBounds'] = [];
visibleUnits.forEach((unit, index) => {
const column = index % 2;
const rowIndex = Math.floor(index / 2);
const cardX = x + 18 + column * (cardWidth + columnGap);
const cardY = cardTop + rowIndex * (cardHeight + rowGap);
cardBounds.push({ unitId: unit.id, x: cardX, y: cardY, width: cardWidth, height: cardHeight });
const selected = this.isSortieSelected(unit.id);
const focused = this.sortieFocusedUnitId === unit.id;
const pinnedSwapCandidate = this.sortiePinnedSwapCandidateUnitId === unit.id;
@@ -13159,9 +13187,9 @@ export class CampScene extends Phaser.Scene {
accent.setOrigin(0);
accent.setDepth(depth + 2);
const portraitView = this.renderFirstSortiePortrait(
cardX + 39,
cardX + 45,
cardY + cardHeight / 2,
56,
70,
unit,
depth + 2,
availability.available ? 1 : 0.42,
@@ -13169,7 +13197,7 @@ export class CampScene extends Phaser.Scene {
accentColor
);
if (pinnedSwapCandidate && portraitView.image) {
portraitView.image.setDisplaySize(60, 60);
portraitView.image.setDisplaySize(74, 74);
}
card.on('pointerover', () => {
this.sortieHoveredUnitId = unit.id;
@@ -13179,7 +13207,7 @@ export class CampScene extends Phaser.Scene {
accent.setFillStyle(palette.gold, 0.98);
portraitView.frame.setStrokeStyle(2, palette.gold, 0.98);
if (availability.available && portraitView.image) {
portraitView.image.setDisplaySize(60, 60);
portraitView.image.setDisplaySize(74, 74);
}
});
card.on('pointerout', () => {
@@ -13192,22 +13220,22 @@ export class CampScene extends Phaser.Scene {
card.setStrokeStyle(2, palette.gold, 0.98);
accent.setFillStyle(palette.gold, 0.98);
portraitView.frame.setStrokeStyle(2, palette.gold, 0.98);
portraitView.image?.setDisplaySize(60, 60);
portraitView.image?.setDisplaySize(74, 74);
return;
}
card.setFillStyle(fill, !availability.available ? 0.62 : 0.96);
card.setStrokeStyle(restingStrokeWidth, availability.available ? accentColor : 0x53606c, restingStrokeAlpha);
accent.setFillStyle(accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34);
portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42);
portraitView.image?.setDisplaySize(56, 56);
portraitView.image?.setDisplaySize(70, 70);
});
const statusLabel = required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기';
const statusColor = required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf';
const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 8, statusLabel, this.textStyle(9, statusColor, true)));
const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 9, statusLabel, this.textStyle(10, statusColor, true)));
status.setOrigin(1, 0);
status.setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 75, cardY + 8, `${unit.name} Lv${unit.level}`, this.textStyle(14, availability.available ? '#f2e3bf' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 75, cardY + 28, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(9, availability.available ? '#c8d2dd' : '#68727e', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 92, cardY + 9, `${unit.name} Lv${unit.level}`, this.textStyle(15, availability.available ? '#f2e3bf' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 92, cardY + 33, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(11, availability.available ? '#c8d2dd' : '#68727e', true))).setDepth(depth + 2);
const previewColor = preview.mode === 'remove'
? '#ff9d7d'
: preview.mode === 'add' || preview.mode === 'current'
@@ -13217,20 +13245,20 @@ export class CampScene extends Phaser.Scene {
: '#87919c';
this.trackSortie(
this.add.text(
cardX + 75,
cardY + 45,
this.compactText(`${this.sortieFormationRoleLabel(role)} · ${preview.compactLabel}`, 28),
this.textStyle(9, availability.available ? previewColor : '#68727e', selected || Boolean(recommendation))
cardX + 92,
cardY + 55,
this.compactText(`${this.sortieFormationRoleLabel(role)} · ${preview.compactLabel}`, 24),
this.textStyle(10, availability.available ? previewColor : '#68727e', selected || Boolean(recommendation))
)
).setDepth(depth + 2);
const canToggle = availability.available && !(selected && required);
this.renderFirstSortieInlineButton(
required ? '필수 출전' : selected ? '출전 해제' : pinnedSwapCandidate ? '후보 고정' : canPinSwap ? '교체 비교' : '출전 등록',
cardX + cardWidth - 80,
cardY + cardHeight - 25,
70,
20,
cardX + cardWidth - 94,
cardY + cardHeight - 32,
84,
24,
canToggle,
selected || pinnedSwapCandidate,
() => {
@@ -13244,6 +13272,19 @@ export class CampScene extends Phaser.Scene {
depth + 3
);
});
this.sortiePortraitRosterLayout = {
page: this.sortiePortraitRosterPage,
pageCount,
pageSize: sortiePortraitRosterPageSize,
totalCount: displayUnits.length,
visibleUnitIds: visibleUnits.map((unit) => unit.id),
previousButtonBounds,
nextButtonBounds,
cardBounds,
previousEnabled,
nextEnabled
};
}
private renderCampaignSortieOrderBrowser(x: number, y: number, width: number, height: number, depth: number) {
@@ -14705,6 +14746,15 @@ export class CampScene extends Phaser.Scene {
this.showSortiePrep();
}
private setSortiePortraitRosterPage(nextPage: number, pageCount: number) {
const clamped = Phaser.Math.Clamp(Math.round(nextPage), 0, Math.max(0, pageCount - 1));
if (clamped === this.sortiePortraitRosterPage) {
return;
}
this.sortiePortraitRosterPage = clamped;
this.showSortiePrep();
}
private sortieRosterViewState(totalUnits: number, height = 286) {
const denseRoster = totalUnits > 12;
const rowGap = denseRoster ? 22 : totalUnits > 7 ? 30 : totalUnits > 5 ? 38 : totalUnits > 4 ? 43 : 48;
@@ -15959,6 +16009,7 @@ export class CampScene extends Phaser.Scene {
? this.sortieFocusedUnitId
: projection.selectedUnitIds[0];
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
const definition = this.sortiePresetDefinition(presetId);
this.sortiePlanFeedback = `${definition.label} 편성책 적용 완료 · 장비·보급 재점검`;
this.persistSortieSelection();
@@ -15989,6 +16040,7 @@ export class CampScene extends Phaser.Scene {
this.sortieFocusedUnitId = undo.before.focusedUnitId;
this.sortiePlanFeedback = undo.before.planFeedback;
this.sortieRosterScroll = undo.before.rosterScroll;
this.sortiePortraitRosterPage = undo.before.portraitRosterPage;
this.sortieHoveredUnitId = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
const definition = this.sortiePresetDefinition(undo.presetId);
@@ -16077,7 +16129,8 @@ export class CampScene extends Phaser.Scene {
itemAssignments: this.cloneSortieItemAssignments(this.sortieItemAssignments),
focusedUnitId: this.sortieFocusedUnitId,
planFeedback: this.sortiePlanFeedback,
rosterScroll: this.sortieRosterScroll
rosterScroll: this.sortieRosterScroll,
portraitRosterPage: this.sortiePortraitRosterPage
};
}
@@ -16146,6 +16199,7 @@ export class CampScene extends Phaser.Scene {
this.sortieHoveredUnitId = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
this.sortiePlanFeedback = `${projection.outgoingUnit.name}${projection.incomingUnit.name} 교체 완료 · 장비·보급 재점검`;
this.persistSortieSelection();
this.sortieSwapUndoState = {
@@ -16178,6 +16232,7 @@ export class CampScene extends Phaser.Scene {
this.sortieFocusedUnitId = undo.before.focusedUnitId;
this.sortiePlanFeedback = undo.before.planFeedback;
this.sortieRosterScroll = undo.before.rosterScroll;
this.sortiePortraitRosterPage = undo.before.portraitRosterPage;
this.sortieHoveredUnitId = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.persistSortieSelection();
@@ -17414,6 +17469,7 @@ export class CampScene extends Phaser.Scene {
private hideSortiePrep(clearPinnedSwap = true) {
this.sortieObjects.forEach((object) => object.destroy());
this.sortieObjects = [];
this.sortiePortraitRosterLayout = undefined;
this.sortieHoveredUnitId = undefined;
if (clearPinnedSwap) {
this.sortiePinnedSwapCandidateUnitId = undefined;
@@ -18991,6 +19047,7 @@ export class CampScene extends Phaser.Scene {
? this.sortieFocusedUnitId
: projection.selectedUnitIds[0];
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
this.sortiePlanFeedback = `${bestRecord.battleTitle} 최고 편성 적용 · 장비·보급 재점검`;
this.persistSortieSelection();
this.reportFormationHistoryUndoState = {
@@ -19025,6 +19082,7 @@ export class CampScene extends Phaser.Scene {
this.sortieFocusedUnitId = undo.before.focusedUnitId;
this.sortiePlanFeedback = undo.before.planFeedback;
this.sortieRosterScroll = undo.before.rosterScroll;
this.sortiePortraitRosterPage = undo.before.portraitRosterPage;
this.persistSortieSelection();
this.reportFormationHistoryFeedback = '최고 편성 적용을 되돌리고 이전 명단·역할·보급으로 복원했습니다.';
soundDirector.playSelect();
@@ -20408,6 +20466,7 @@ export class CampScene extends Phaser.Scene {
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(Array.from(selected));
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
const selectionLabel = hasBattle ? '출전' : '동행';
const synergyResultLine = synergyPreview.headline.replace(/^(합류|해제) 시 /, '');
this.sortiePlanFeedback = `${unit.name} ${selected.has(unitId) ? `${selectionLabel} 추가` : `${selectionLabel} 해제`} · ${synergyResultLine}`;
@@ -20714,9 +20773,14 @@ export class CampScene extends Phaser.Scene {
const sortieStepIndex = firstSortiePrepSteps.findIndex((step) => step.id === this.sortiePrepStep);
const nextSortiePrepStep = stagedSortiePrep ? firstSortiePrepSteps[sortieStepIndex + 1]?.id ?? null : null;
const portraitRosterUnits = this.sortieRosterDisplayUnits(this.sortieRosterUnits());
const portraitRosterVisibleCount = 8;
const portraitRosterMaxScroll = Math.max(0, portraitRosterUnits.length - portraitRosterVisibleCount);
const portraitRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, portraitRosterMaxScroll);
const portraitRosterPageCount = Math.max(1, Math.ceil(portraitRosterUnits.length / sortiePortraitRosterPageSize));
const portraitRosterPage = Phaser.Math.Clamp(this.sortiePortraitRosterPage, 0, portraitRosterPageCount - 1);
const portraitRosterScroll = portraitRosterPage * sortiePortraitRosterPageSize;
const portraitRosterVisibleUnits = portraitRosterUnits.slice(
portraitRosterScroll,
portraitRosterScroll + sortiePortraitRosterPageSize
);
const portraitRosterMaxScroll = Math.max(0, (portraitRosterPageCount - 1) * sortiePortraitRosterPageSize);
const sortieComparisonPreview = this.sortieFormationComparisonPreview();
const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview);
const sortiePursuit = this.sortiePursuitPairs();
@@ -21058,11 +21122,24 @@ export class CampScene extends Phaser.Scene {
sortiePortraitRosterView: {
enabled: stagedSortiePrep && !this.isFirstSortiePrepSlice(),
total: portraitRosterUnits.length,
visibleCount: Math.min(portraitRosterVisibleCount, portraitRosterUnits.length),
visibleCount: portraitRosterVisibleUnits.length,
start: portraitRosterUnits.length > 0 ? portraitRosterScroll + 1 : 0,
end: portraitRosterScroll + Math.min(portraitRosterVisibleCount, portraitRosterUnits.length - portraitRosterScroll),
end: portraitRosterScroll + portraitRosterVisibleUnits.length,
maxScroll: portraitRosterMaxScroll,
scroll: portraitRosterScroll
scroll: portraitRosterScroll,
page: portraitRosterPage,
pageCount: portraitRosterPageCount,
pageSize: sortiePortraitRosterPageSize,
visibleUnitIds: portraitRosterVisibleUnits.map((unit) => unit.id),
previousEnabled: portraitRosterPage > 0,
nextEnabled: portraitRosterPage < portraitRosterPageCount - 1,
previousButtonBounds: this.sortiePortraitRosterLayout
? { ...this.sortiePortraitRosterLayout.previousButtonBounds }
: null,
nextButtonBounds: this.sortiePortraitRosterLayout
? { ...this.sortiePortraitRosterLayout.nextButtonBounds }
: null,
cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map((bounds) => ({ ...bounds })) ?? []
},
sortiePortraitRoster: portraitRosterUnits.map((unit) => {
const portraitKey = campaignPortraitKeysByUnitId[unit.id];