feat: paginate large camp rosters

This commit is contained in:
2026-07-12 03:12:50 +09:00
parent 1155a11b22
commit ea60aa1de6
2 changed files with 586 additions and 19 deletions

View File

@@ -215,6 +215,31 @@ type CampTabButtonView = {
text: Phaser.GameObjects.Text;
};
type CampRosterBounds = {
x: number;
y: number;
width: number;
height: number;
};
type CampRosterLayout = {
page: number;
pageCount: number;
rowsPerPage: number;
totalCount: number;
visibleUnitIds: string[];
listBounds: CampRosterBounds;
navigationBounds: {
container: CampRosterBounds;
previous: CampRosterBounds;
label: CampRosterBounds;
next: CampRosterBounds;
} | null;
rowBounds: Array<CampRosterBounds & { unitId: string }>;
previousEnabled: boolean;
nextEnabled: boolean;
};
type CampSupplyDefinition = {
label: string;
usableId: string;
@@ -11015,6 +11040,8 @@ export class CampScene extends Phaser.Scene {
private campaign?: CampaignState;
private report?: FirstBattleReport;
private selectedUnitId = 'liu-bei';
private campRosterPage = 0;
private campRosterLayout?: CampRosterLayout;
private activeTab: CampTab = 'status';
private selectedDialogueId = campDialogues[0].id;
private selectedVisitId = campVisits[0].id;
@@ -11081,6 +11108,8 @@ export class CampScene extends Phaser.Scene {
create() {
this.contentObjects = [];
this.campRosterPage = 0;
this.campRosterLayout = undefined;
this.dialogueObjects = [];
this.sortieObjects = [];
this.sortieComparisonPanelView = undefined;
@@ -17419,18 +17448,35 @@ export class CampScene extends Phaser.Scene {
private renderUnitColumn() {
const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
const rowsPerPage = 6;
const pageCount = Math.max(1, Math.ceil(allies.length / rowsPerPage));
const paginated = pageCount > 1;
this.campRosterPage = Phaser.Math.Clamp(this.campRosterPage, 0, pageCount - 1);
const visibleAllies = allies.slice(
this.campRosterPage * rowsPerPage,
(this.campRosterPage + 1) * rowsPerPage
);
const x = 42;
const listY = 120;
const width = 318;
const availableHeight = 462;
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;
const rowGap = paginated
? 70
: allies.length > 1
? Math.min(132, Math.floor(availableHeight / allies.length))
: 132;
const cardHeight = paginated ? 64 : Math.min(118, Math.max(48, rowGap - 8));
const compact = paginated || cardHeight < 88;
const navigationY = 542;
const navigationHeight = 28;
const rowBounds: CampRosterLayout['rowBounds'] = [];
this.renderRosterColumnSummary(42, 86, 318, allies);
allies.forEach((unit, index) => {
const x = 42;
const y = 120 + index * rowGap;
visibleAllies.forEach((unit, index) => {
const y = listY + 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));
const bg = this.track(this.add.rectangle(x, y, width, 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);
bg.setInteractive({ useHandCursor: true });
@@ -17439,6 +17485,8 @@ export class CampScene extends Phaser.Scene {
this.selectedUnitId = unit.id;
this.render();
});
const bounds = bg.getBounds();
rowBounds.push({ unitId: unit.id, x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height });
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
const portraitTexture = portraitKey
@@ -17446,27 +17494,120 @@ export class CampScene extends Phaser.Scene {
(texture): texture is string => Boolean(texture && this.textures.exists(texture))
)
: undefined;
const portraitCenterX = paginated ? x + 38 : x + 58;
const portraitCenterY = y + cardHeight / 2;
const portraitSize = compact ? 40 : 84;
const portraitSize = paginated ? 48 : compact ? 40 : 84;
if (portraitTexture && this.textures.exists(portraitTexture)) {
const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitTexture));
const portrait = this.track(this.add.image(portraitCenterX, portraitCenterY, portraitTexture));
portrait.setDisplaySize(portraitSize, portraitSize);
} else {
this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 20 : 40, unit);
this.renderUnitFallbackPortrait(portraitCenterX, portraitCenterY, paginated ? 24 : compact ? 20 : 40, unit);
}
const textX = compact ? x + 102 : x + 116;
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 textX = paginated ? x + 70 : compact ? x + 102 : x + 116;
const nameY = paginated ? y + 6 : y + (compact ? 7 : 17);
const detailY = paginated ? y + 26 : y + (compact ? 27 : 47);
const expY = paginated ? y + 42 : y + (compact ? 40 : 72);
this.track(this.add.text(textX, nameY, `${unit.name} Lv ${unit.level}`, this.textStyle(paginated ? 15 : compact ? 14 : 20, '#f2e3bf', true)));
this.track(this.add.text(textX, detailY, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(paginated || compact ? 10 : 13, '#d4dce6')));
const expLine = training
? `경험 ${unit.exp}/100 · ${training.focusLabel ?? '대기'} +${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);
this.track(
this.add.text(
textX,
expY,
paginated ? this.compactText(expLine, 28) : expLine,
this.textStyle(paginated || compact ? 10 : 13, training ? '#a8ffd0' : '#d8b15f', true)
)
);
this.drawBar(
textX,
paginated ? y + 57 : y + cardHeight - (compact ? 10 : 17),
paginated ? width - 82 : compact ? 142 : 176,
paginated ? 4 : 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)));
const badge = this.track(
this.add.text(
paginated ? x + width - 12 : x + 302,
paginated ? y + 7 : y + (compact ? 7 : 17),
rosterStatus.label,
this.textStyle(paginated || compact ? 10 : 11, badgeColor, true)
)
);
badge.setOrigin(1, 0);
});
const debugBounds = (object: Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text): CampRosterBounds => {
const bounds = object.getBounds();
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
};
const previousEnabled = paginated && this.campRosterPage > 0;
const nextEnabled = paginated && this.campRosterPage < pageCount - 1;
let navigationBounds: CampRosterLayout['navigationBounds'] = null;
if (paginated) {
const navigation = this.track(this.add.rectangle(x, navigationY, width, navigationHeight, 0x0b1118, 0.96));
navigation.setOrigin(0);
navigation.setStrokeStyle(1, 0x647485, 0.62);
const renderNavigationButton = (buttonX: number, label: string, enabled: boolean, page: number) => {
const button = this.track(this.add.rectangle(buttonX, navigationY + 2, 70, 24, enabled ? 0x25384a : 0x141b22, 0.96));
button.setOrigin(0);
button.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.78 : 0.36);
if (enabled) {
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x31475a, 0.98));
button.on('pointerout', () => button.setFillStyle(0x25384a, 0.96));
button.on('pointerdown', () => this.setCampRosterPage(page));
}
const text = this.track(this.add.text(buttonX + 35, navigationY + 14, label, this.textStyle(12, enabled ? '#f4dfad' : '#77828c', true)));
text.setOrigin(0.5);
return button;
};
const previousButton = renderNavigationButton(x + 2, '이전', previousEnabled, this.campRosterPage - 1);
const nextButton = renderNavigationButton(x + width - 72, '다음', nextEnabled, this.campRosterPage + 1);
const pageLabel = this.track(this.add.text(x + width / 2, navigationY + 14, `${this.campRosterPage + 1} / ${pageCount}`, this.textStyle(13, '#d4dce6', true)));
pageLabel.setOrigin(0.5);
navigationBounds = {
container: debugBounds(navigation),
previous: debugBounds(previousButton),
label: debugBounds(pageLabel),
next: debugBounds(nextButton)
};
}
this.campRosterLayout = {
page: this.campRosterPage,
pageCount,
rowsPerPage,
totalCount: allies.length,
visibleUnitIds: visibleAllies.map((unit) => unit.id),
listBounds: {
x,
y: listY,
width,
height: visibleAllies.length > 0 ? (visibleAllies.length - 1) * rowGap + cardHeight : 0
},
navigationBounds,
rowBounds,
previousEnabled,
nextEnabled
};
}
private setCampRosterPage(page: number) {
const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
const pageCount = Math.max(1, Math.ceil(allies.length / 6));
const nextPage = Phaser.Math.Clamp(page, 0, pageCount - 1);
if (nextPage === this.campRosterPage) {
return;
}
soundDirector.playSelect();
this.campRosterPage = nextPage;
this.render();
}
private renderRosterColumnSummary(x: number, y: number, width: number, allies: UnitData[]) {
@@ -20524,6 +20665,7 @@ export class CampScene extends Phaser.Scene {
private clearContent() {
this.contentObjects.forEach((object) => object.destroy());
this.contentObjects = [];
this.campRosterLayout = undefined;
this.reportFormationReviewToggleButton = undefined;
this.reportFormationHistoryToggleButton = undefined;
this.reportSortieOrderRewardBadge = undefined;
@@ -20609,6 +20751,22 @@ export class CampScene extends Phaser.Scene {
return {
scene: this.scene.key,
activeTab: this.activeTab,
campRoster: this.campRosterLayout
? {
...this.campRosterLayout,
visibleUnitIds: [...this.campRosterLayout.visibleUnitIds],
listBounds: { ...this.campRosterLayout.listBounds },
navigationBounds: this.campRosterLayout.navigationBounds
? {
container: { ...this.campRosterLayout.navigationBounds.container },
previous: { ...this.campRosterLayout.navigationBounds.previous },
label: { ...this.campRosterLayout.navigationBounds.label },
next: { ...this.campRosterLayout.navigationBounds.next }
}
: null,
rowBounds: this.campRosterLayout.rowBounds.map((bounds) => ({ ...bounds }))
}
: null,
sortieOperationOrder: {
available: Boolean(sortieScenario),
required: Boolean(sortieScenario && stagedSortiePrep),