feat: refine battle feedback and early guidance

This commit is contained in:
2026-07-18 03:16:05 +09:00
parent 470516bfd9
commit af2f5dbf06
4 changed files with 650 additions and 87 deletions

View File

@@ -1261,6 +1261,9 @@ type RosterPanelLayout = {
type DeploymentPanelLayout = {
headerBounds: { x: number; y: number; width: number; height: number };
statusBounds: { x: number; y: number; width: number; height: number };
changeCount: number;
recommended: boolean;
noticeBounds: { x: number; y: number; width: number; height: number };
noticeTextBounds: { x: number; y: number; width: number; height: number };
roleBounds: Array<{ x: number; y: number; width: number; height: number }>;
@@ -3510,6 +3513,17 @@ export class BattleScene extends Phaser.Scene {
private turnPromptMode?: TurnPromptMode;
private turnPromptPrimaryAction?: () => void;
private turnPromptSecondaryAction?: () => void;
private mapResultPopupObjects: Phaser.GameObjects.Text[] = [];
private mapResultPopupPeakCount = 0;
private mapResultPopupLast?: {
unitId: string;
lines: string[];
bounds: HudBounds;
endBounds: HudBounds;
duration: number;
delay: number;
lift: number;
};
private combatCutInRoot?: Phaser.GameObjects.Container;
private combatCutInObjects: Phaser.GameObjects.GameObject[] = [];
private combatCutInStageActive?: CombatCutInStageSnapshot;
@@ -3647,6 +3661,10 @@ export class BattleScene extends Phaser.Scene {
this.hideBattleResult();
this.hideTacticalCommandCutIn();
this.hideCombatCutIn();
this.mapResultPopupObjects.forEach((object) => object.active && object.destroy());
this.mapResultPopupObjects = [];
this.mapResultPopupPeakCount = 0;
this.mapResultPopupLast = undefined;
this.combatCutInStageActive = undefined;
this.combatCutInStageLast = undefined;
this.tacticalCommandCutInCount = 0;
@@ -3655,6 +3673,8 @@ export class BattleScene extends Phaser.Scene {
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.hideTacticalCommandCutIn();
this.hideCombatCutIn();
this.mapResultPopupObjects.forEach((object) => object.active && object.destroy());
this.mapResultPopupObjects = [];
});
this.resultFormationReviewVisible = false;
this.resultFormationReviewFeedback = '';
@@ -7244,6 +7264,15 @@ export class BattleScene extends Phaser.Scene {
return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.';
}
private deploymentChangeCount() {
return this.deploymentSlots()
.filter((slot): slot is DeploymentSlot & { unitId: string } => Boolean(slot.unitId))
.filter((slot) => {
const unit = battleUnits.find((candidate) => candidate.id === slot.unitId && candidate.hp > 0);
return !unit || unit.x !== slot.x || unit.y !== slot.y;
}).length;
}
private deploymentSubtitle() {
if (this.launchSortieRecommendation) {
return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`;
@@ -7648,6 +7677,8 @@ export class BattleScene extends Phaser.Scene {
const noticeHeight = this.battleUiLength(52);
const roleRowHeight = this.battleUiLength(40);
const buttonHeight = this.battleUiLength(30);
const deploymentChangeCount = this.deploymentChangeCount();
const recommendedDeployment = deploymentChangeCount === 0;
const header = this.trackSideObject(this.add.rectangle(left, top, width, headerHeight, 0x101820, 0.96));
header.setOrigin(0);
@@ -7658,6 +7689,26 @@ export class BattleScene extends Phaser.Scene {
color: '#f4dfad',
fontStyle: '700'
}));
const statusWidth = this.battleUiLength(122);
const statusHeight = this.battleUiLength(20);
const statusLeft = left + width - statusWidth - this.battleUiLength(10);
const statusTop = top + this.battleUiLength(8);
const statusTone = recommendedDeployment ? palette.green : palette.gold;
const status = this.trackSideObject(this.add.rectangle(statusLeft, statusTop, statusWidth, statusHeight, recommendedDeployment ? 0x15261f : 0x2b2114, 0.96));
status.setOrigin(0);
status.setStrokeStyle(this.battleUiLength(1), statusTone, recommendedDeployment ? 0.72 : 0.92);
const statusText = this.trackSideObject(this.add.text(
statusLeft + statusWidth / 2,
statusTop + statusHeight / 2,
recommendedDeployment ? '권장 배치 유지' : `변경 ${deploymentChangeCount}명 · 복원 가능`,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(9),
color: recommendedDeployment ? '#a8ffd0' : '#ffdf7b',
fontStyle: '700'
}
));
statusText.setOrigin(0.5);
this.trackSideObject(this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(32), this.deploymentSubtitle(), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(10),
@@ -7738,9 +7789,19 @@ export class BattleScene extends Phaser.Scene {
});
this.renderDeploymentButton(left, startButtonTop, width, '전투 시작', palette.gold, () => this.confirmPreBattleDeployment());
this.renderDeploymentButton(left, restoreButtonTop, width, '추천 배치 복원', 0x647485, () => this.restoreRecommendedDeployment());
this.renderDeploymentButton(
left,
restoreButtonTop,
width,
recommendedDeployment ? '추천 배치 유지 중' : `추천 배치 복원 · ${deploymentChangeCount}`,
recommendedDeployment ? 0x647485 : palette.gold,
() => this.restoreRecommendedDeployment()
);
this.deploymentPanelLayout = {
headerBounds: { x: left, y: top, width, height: headerHeight },
statusBounds: { x: statusLeft, y: statusTop, width: statusWidth, height: statusHeight },
changeCount: deploymentChangeCount,
recommended: recommendedDeployment,
noticeBounds: { x: left, y: noticeTop, width, height: noticeHeight },
noticeTextBounds: this.gameObjectBoundsDebug(noticeText)!,
roleBounds,
@@ -10706,10 +10767,7 @@ export class BattleScene extends Phaser.Scene {
await this.delay(180);
await this.playCombatCutIn(result.counter);
}
this.showCombatMapResult(result);
if (result.counter) {
this.showCombatMapResult(result.counter);
}
await this.showCombatExchangeMapResults(result);
await this.delay(result.counter ? 220 : 160);
this.finishUnitAction(attacker, this.formatCombatResult(result), {
defeatedEnemyIds: result.defeated ? [result.defender.id] : []
@@ -14632,9 +14690,9 @@ export class BattleScene extends Phaser.Scene {
const bondChain = bondChainResolution?.result;
this.faceUnitToward(attacker, defender);
if (hit) {
this.flashDamage(defender, damage, critical, previousDefenderHp, bondChain?.previousDefenderHp);
this.flashDamage(defender, bondChain?.previousDefenderHp ?? defender.hp);
} else {
this.flashMiss(defender, this.missFeedbackLabel(action, usable));
this.flashMiss(defender);
}
if (defender.hp <= 0) {
@@ -14725,7 +14783,7 @@ export class BattleScene extends Phaser.Scene {
if (healAmount > 0) {
target.hp = Math.min(target.maxHp, target.hp + healAmount);
this.syncUnitMotion(target);
this.flashSupport(target, `회복 +${healAmount}`, '#a8ffd0', `HP ${previousTargetHp}${target.hp}`);
this.flashSupport(target);
this.updateMiniMap();
}
@@ -14739,7 +14797,7 @@ export class BattleScene extends Phaser.Scene {
criticalBonus: usable.criticalBonus ?? 0
};
this.battleBuffs.set(target.id, buff);
this.flashSupport(target, `강화 ${usable.duration ?? 1}`, '#ffdf7b', this.supportBuffCompactText(buff));
this.flashSupport(target);
}
const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', this.supportEquipmentExpGain(user, usable));
@@ -15251,6 +15309,9 @@ export class BattleScene extends Phaser.Scene {
...(this.isFirstPursuitRoleEffectBattle()
? [`적 의도 · 검=공격 · 발=추격 · 파훼 ${tacticalInitiativeThreshold}회 → 전술 명령`]
: []),
...(battleScenario.id === 'first-battle-zhuo-commandery'
? ['첫 행동 · 아군 선택 → 파란 이동 칸 → 명령 공격 → 붉은 적 선택']
: []),
`작전 목표 · ${objectiveLines[0]}`
]);
return;
@@ -15387,7 +15448,8 @@ export class BattleScene extends Phaser.Scene {
const doctrineLines = lines.filter((line) => (
line.startsWith('도원 공명') ||
line.startsWith('공명 ') ||
line.startsWith('적 의도')
line.startsWith('적 의도') ||
line.startsWith('첫 행동')
));
if (doctrineLines.length === 0) {
doctrineLines.push('활성 공명 없음 · 역할 효과로 전선을 운용합니다.');
@@ -15499,7 +15561,7 @@ export class BattleScene extends Phaser.Scene {
const doctrineText = this.add.text(left + 18, top + 145 + index * 20, this.truncateBattleLogText(line, 54), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
color: line.startsWith('적 의도') ? '#b9d9ec' : '#d8b15f',
color: line.startsWith('적 의도') ? '#b9d9ec' : line.startsWith('첫 행동') ? '#a8ffd0' : '#d8b15f',
fontStyle: '700',
fixedWidth: width - 36
});
@@ -16810,10 +16872,7 @@ export class BattleScene extends Phaser.Scene {
await this.delay(180);
await this.playCombatCutIn(result.counter);
}
this.showCombatMapResult(result);
if (result.counter) {
this.showCombatMapResult(result.counter);
}
await this.showCombatExchangeMapResults(result);
await this.delay(result.counter ? 220 : 160);
return this.formatEnemyCombatResult(result, plan.behavior);
}
@@ -19126,7 +19185,10 @@ export class BattleScene extends Phaser.Scene {
if (result.hit) {
void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70);
this.cameras.main.shake(result.critical ? 130 : 90, result.critical ? 0.006 : 0.0035);
this.cameras.main.shake(
this.scaledBattleDuration(result.critical ? 130 : 90, 55),
result.critical ? 0.006 : 0.0035
);
this.shakeCombatSprite(defenderSprite, defenderX, groundY, direction, result.critical);
await this.delay(result.critical ? 130 : 92);
} else {
@@ -21815,18 +21877,14 @@ export class BattleScene extends Phaser.Scene {
this.syncUnitMotion(unit, view, direction);
}
private flashDamage(unit: UnitData, damage: number, critical = false, previousHp?: number, displayedHp = unit.hp) {
private flashDamage(unit: UnitData, displayedHp = unit.hp) {
const view = this.unitViews.get(unit.id);
if (!view) {
return;
}
const title = displayedHp <= 0 ? `${critical ? '치명 ' : ''}퇴각 -${damage}` : `${critical ? '치명 ' : '피해 '}-${damage}`;
const detail = previousHp === undefined ? '' : `HP ${previousHp}${displayedHp}`;
this.showMapResultPopup(unit, [title, detail], critical ? '#ff8f5f' : '#ffdf7b', '#220909', critical ? 20 : 18, critical ? 34 : 30);
view.sprite.setTintFill(0xffe0a3);
this.time.delayedCall(120, () => {
this.time.delayedCall(this.scaledBattleDuration(120, 55), () => {
if (displayedHp <= 0) {
this.applyDefeatedStyle(unit);
} else {
@@ -21842,40 +21900,46 @@ export class BattleScene extends Phaser.Scene {
});
}
private flashMiss(unit: UnitData, label = '회피') {
private flashMiss(unit: UnitData) {
const view = this.unitViews.get(unit.id);
if (!view) {
return;
}
this.showMapResultPopup(unit, [label], '#d9f1ff', '#071623', label.length > 4 ? 17 : 19, 26);
this.tweens.add({
targets: view.roleSeal ? [view.sprite, view.label, view.roleSeal] : [view.sprite, view.label],
x: `+=${unit.faction === 'ally' ? -8 : 8}`,
duration: 90,
duration: this.scaledBattleDuration(90, 55),
yoyo: true,
ease: 'Sine.easeOut'
});
}
private flashSupport(unit: UnitData, label: string, color: string, detail = '') {
private flashSupport(unit: UnitData) {
const view = this.unitViews.get(unit.id);
if (!view) {
return;
}
this.showMapResultPopup(unit, [label, detail], color, '#071623', 18, 28);
this.tweens.add({
targets: view.sprite,
scale: view.sprite.scale * 1.08,
duration: 120,
duration: this.scaledBattleDuration(120, 55),
yoyo: true,
ease: 'Sine.easeOut'
});
}
private async showCombatExchangeMapResults(result: CombatResult) {
this.showCombatMapResult(result);
if (!result.counter) {
return;
}
await this.delay(110);
this.showCombatMapResult(result.counter);
}
private showCombatMapResult(result: CombatResult) {
if (!result.hit) {
this.showMapResultPopup(
@@ -21966,17 +22030,13 @@ export class BattleScene extends Phaser.Scene {
}
const visibleLines = lines.filter(Boolean);
const widestLength = Math.max(...visibleLines.map((line) => line.length), 1);
const estimatedWidth = Math.min(this.layout.mapWidth - 20, widestLength * fontSize * 0.78 + 16);
const popupX = Phaser.Math.Clamp(
view.sprite.x,
this.layout.mapX + estimatedWidth / 2 + 6,
this.layout.mapX + this.layout.mapWidth - estimatedWidth / 2 - 6
);
if (visibleLines.length === 0) {
return;
}
const popup = this.add.text(
popupX,
view.sprite.y - this.layout.tileSize * 0.64,
view.sprite.x,
view.sprite.y,
visibleLines.join('\n'),
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -21986,19 +22046,60 @@ export class BattleScene extends Phaser.Scene {
strokeThickness: 4,
fontStyle: '700',
align: 'center',
lineSpacing: -3
lineSpacing: -3,
wordWrap: { width: Math.max(80, this.layout.mapWidth - 24), useAdvancedWrap: true }
}
);
popup.setOrigin(0.5, 1);
popup.setDepth(35);
const mapLeft = this.layout.mapX + 6;
const mapRight = this.layout.mapX + this.layout.mapWidth - 6;
const mapTop = this.layout.mapY + 6;
const mapBottom = this.layout.mapY + this.layout.mapHeight - 6;
const safeLift = Math.min(Math.max(0, lift), Math.max(0, this.layout.mapHeight - popup.height - 12));
const minBottomY = mapTop + popup.height + safeLift;
const popupX = Phaser.Math.Clamp(view.sprite.x, mapLeft + popup.width / 2, mapRight - popup.width / 2);
const requestedBottomY = view.sprite.y - this.layout.tileSize * 0.64;
const popupY = Phaser.Math.Clamp(requestedBottomY, minBottomY, mapBottom);
popup.setPosition(popupX, popupY);
this.mapResultPopupObjects = this.mapResultPopupObjects.filter((object) => object.active);
while (this.mapResultPopupObjects.length >= 4) {
const oldest = this.mapResultPopupObjects.shift();
if (oldest?.active) {
this.tweens.killTweensOf(oldest);
oldest.destroy();
}
}
this.mapResultPopupObjects.push(popup);
this.mapResultPopupPeakCount = Math.max(this.mapResultPopupPeakCount, this.mapResultPopupObjects.length);
const duration = this.scaledBattleDuration(680, 260);
const delay = this.scaledBattleDuration(80, 30);
const bounds = popup.getBounds();
this.mapResultPopupLast = {
unitId: unit.id,
lines: [...visibleLines],
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
endBounds: { x: bounds.x, y: bounds.y - safeLift, width: bounds.width, height: bounds.height },
duration,
delay,
lift: safeLift
};
this.tweens.add({
targets: popup,
y: popup.y - lift,
y: popup.y - safeLift,
alpha: 0,
duration: 680,
delay: 80,
duration,
delay,
ease: 'Sine.easeOut',
onComplete: () => popup.destroy()
onComplete: () => {
this.mapResultPopupObjects = this.mapResultPopupObjects.filter((object) => object !== popup && object.active);
if (popup.active) {
popup.destroy();
}
}
});
}
@@ -24864,7 +24965,10 @@ export class BattleScene extends Phaser.Scene {
};
const deploymentPanel = this.deploymentPanelLayout
? {
changeCount: this.deploymentPanelLayout.changeCount,
recommended: this.deploymentPanelLayout.recommended,
headerBounds: { ...this.deploymentPanelLayout.headerBounds },
statusBounds: { ...this.deploymentPanelLayout.statusBounds },
noticeBounds: { ...this.deploymentPanelLayout.noticeBounds },
noticeTextBounds: { ...this.deploymentPanelLayout.noticeTextBounds },
roleBounds: this.deploymentPanelLayout.roleBounds.map((bounds) => ({ ...bounds })),
@@ -25177,6 +25281,26 @@ export class BattleScene extends Phaser.Scene {
: null,
turnPromptVisible: this.turnPromptObjects.length > 0,
turnPromptMode: this.turnPromptMode ?? null,
mapResultFeedback: {
activeCount: this.mapResultPopupObjects.filter((object) => object.active).length,
peakCount: this.mapResultPopupPeakCount,
maxActive: 4,
speed: this.battleSpeed,
mapBounds: {
x: this.layout.mapX,
y: this.layout.mapY,
width: this.layout.mapWidth,
height: this.layout.mapHeight
},
last: this.mapResultPopupLast
? {
...this.mapResultPopupLast,
lines: [...this.mapResultPopupLast.lines],
bounds: { ...this.mapResultPopupLast.bounds },
endBounds: { ...this.mapResultPopupLast.endBounds }
}
: null
},
pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible),
pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null,
bondChainReadyPreview: this.bondChainReadyDebugState(),

View File

@@ -774,7 +774,7 @@ const campSupplies: CampSupplyDefinition[] = [
label: '탁주',
usableId: 'wine',
title: '탁주',
description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.',
description: '선택 장수의 병력을 8 회복하고 연결된 모든 공명 경험치를 각각 2 올립니다.',
healHp: 8,
bondExp: 2
},
@@ -789,6 +789,7 @@ const campSupplies: CampSupplyDefinition[] = [
];
const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, supply]));
const equipmentInventoryPageSize = 4;
const merchantItems: MerchantItemDefinition[] = [
{
@@ -11234,6 +11235,15 @@ export class CampScene extends Phaser.Scene {
private activeTab: CampTab = 'status';
private selectedDialogueId = campDialogues[0].id;
private selectedVisitId = campVisits[0].id;
private equipmentInventoryPage = 0;
private equipmentPanelBackground?: Phaser.GameObjects.Rectangle;
private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryNextButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryVisibleItemIds: string[] = [];
private suppliesPanelBackground?: Phaser.GameObjects.Rectangle;
private suppliesEquipmentBoxes: Phaser.GameObjects.Rectangle[] = [];
private suppliesTrophyBoxes: Phaser.GameObjects.Rectangle[] = [];
private reportPanelBackground?: Phaser.GameObjects.Rectangle;
private contentObjects: Phaser.GameObjects.GameObject[] = [];
private dialogueObjects: Phaser.GameObjects.GameObject[] = [];
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
@@ -19409,6 +19419,7 @@ export class CampScene extends Phaser.Scene {
const x = 42;
const y = 590;
const bg = this.track(this.add.rectangle(x, y, 1180, 84, 0x101820, 0.86));
this.reportPanelBackground = bg;
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.48);
this.track(this.add.text(x + 18, y + 14, '전투 보고', this.textStyle(18, '#f2e3bf', true)));
@@ -21470,6 +21481,9 @@ export class CampScene extends Phaser.Scene {
const width = 828;
const height = 464;
const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.91));
this.equipmentPanelBackground = bg;
this.equipmentInventoryPreviousButton = undefined;
this.equipmentInventoryNextButton = undefined;
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.58);
@@ -21483,7 +21497,39 @@ export class CampScene extends Phaser.Scene {
});
const inventoryEntries = this.equipmentInventoryEntries();
this.track(this.add.text(x + 24, y + 266, `보유 장비 ${inventoryEntries.length}`, this.textStyle(18, '#f2e3bf', true)));
const inventoryPageCount = Math.max(1, Math.ceil(inventoryEntries.length / equipmentInventoryPageSize));
this.equipmentInventoryPage = Phaser.Math.Clamp(this.equipmentInventoryPage, 0, inventoryPageCount - 1);
const inventoryStart = this.equipmentInventoryPage * equipmentInventoryPageSize;
const visibleInventoryEntries = inventoryEntries.slice(inventoryStart, inventoryStart + equipmentInventoryPageSize);
this.equipmentInventoryVisibleItemIds = visibleInventoryEntries.map((entry) => entry.item.id);
this.track(this.add.text(
x + 24,
y + 266,
`보유 장비 ${inventoryEntries.length}${inventoryPageCount > 1 ? ` · ${this.equipmentInventoryPage + 1}/${inventoryPageCount}` : ''}`,
this.textStyle(18, '#f2e3bf', true)
));
if (inventoryPageCount > 1) {
this.equipmentInventoryPreviousButton = this.renderEquipmentInventoryPageButton(
x + 424,
y + 278,
'',
this.equipmentInventoryPage > 0,
() => {
this.equipmentInventoryPage -= 1;
this.render();
}
);
this.equipmentInventoryNextButton = this.renderEquipmentInventoryPageButton(
x + 462,
y + 278,
'',
this.equipmentInventoryPage < inventoryPageCount - 1,
() => {
this.equipmentInventoryPage += 1;
this.render();
}
);
}
this.track(this.add.text(x + 516, y + 268, '장비 효과 요약', this.textStyle(17, '#f2e3bf', true)));
if (inventoryEntries.length === 0) {
@@ -21493,7 +21539,7 @@ export class CampScene extends Phaser.Scene {
this.track(this.add.text(x + 44, y + 324, '교체 가능한 보유 장비가 없습니다.', this.textStyle(15, '#9fb0bf', true)));
this.track(this.add.text(x + 44, y + 352, '전투 보상이나 군영 상인을 통해 장비를 확보하면 이곳에서 비교할 수 있습니다.', this.textStyle(12, '#77818c')));
} else {
inventoryEntries.slice(0, 4).forEach((entry, index) => {
visibleInventoryEntries.forEach((entry, index) => {
this.renderEquipmentCompareRow(entry, unit, x + 24, y + 298 + index * 40, 468, 36);
});
}
@@ -21502,6 +21548,31 @@ export class CampScene extends Phaser.Scene {
this.renderEquipmentInventorySummary(inventoryEntries, x + 516, y + 432, 280);
}
private renderEquipmentInventoryPageButton(
x: number,
y: number,
label: string,
enabled: boolean,
action: () => void
) {
const bg = this.track(this.add.rectangle(x, y, 30, 24, enabled ? 0x1a2630 : 0x111820, enabled ? 0.96 : 0.68));
bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.72 : 0.34);
const text = this.track(this.add.text(x, y - 1, label, this.textStyle(18, enabled ? '#f2e3bf' : '#6f7882', true)));
text.setOrigin(0.5);
if (!enabled) {
return bg;
}
const setHovered = (hovered: boolean) => bg.setFillStyle(hovered ? 0x654c25 : 0x1a2630, hovered ? 0.99 : 0.96);
[bg, text].forEach((target) => {
target.setInteractive({ useHandCursor: true });
target.on('pointerover', () => setHovered(true));
target.on('pointerout', () => setHovered(false));
target.on('pointerdown', action);
});
return bg;
}
private renderEquipmentUnitSummary(unit: UnitData, x: number, y: number, width: number, height: number) {
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
@@ -21611,7 +21682,10 @@ export class CampScene extends Phaser.Scene {
private renderSuppliesPanel() {
const x = 394;
const y = 120;
const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
const bg = this.track(this.add.rectangle(x, y, 828, 466, 0x101820, 0.9));
this.suppliesPanelBackground = bg;
this.suppliesEquipmentBoxes = [];
this.suppliesTrophyBoxes = [];
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.blue, 0.56);
this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true)));
@@ -21628,23 +21702,23 @@ export class CampScene extends Phaser.Scene {
this.renderMerchantPanel(x + 24, y + 202, 342);
this.track(this.add.text(x + 390, y + 318, '보유 장비', this.textStyle(18, '#f2e3bf', true)));
this.track(this.add.text(x + 390, y + 312, '보유 장비', 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 {
equipmentEntries.slice(0, 3).forEach((entry, index) => {
this.renderEquipmentInventoryBox(entry, x + 390 + index * 128, y + 344);
this.suppliesEquipmentBoxes.push(this.renderEquipmentInventoryBox(entry, x + 390 + index * 128, y + 336));
});
}
this.track(this.add.text(x + 390, y + 410, '전리품과 명성', this.textStyle(17, '#f2e3bf', true)));
this.track(this.add.text(x + 390, y + 394, '전리품과 명성', 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);
this.suppliesTrophyBoxes.push(this.renderSupplyBox(reward, x + 390 + index * 128, y + 418));
});
}
}
@@ -21708,6 +21782,7 @@ export class CampScene extends Phaser.Scene {
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.52);
this.track(this.add.text(x + 10, y + 14, this.compactText(label, 9), this.textStyle(13, '#f2e3bf', true)));
return bg;
}
private renderEquipmentInventoryBox(entry: { label: string; amount: number; item: ItemDefinition }, x: number, y: number) {
@@ -21734,6 +21809,7 @@ export class CampScene extends Phaser.Scene {
actionText.setInteractive({ useHandCursor: true });
actionText.on('pointerdown', action);
}
return bg;
}
private renderSupplyTargetCard(unit: UnitData, x: number, y: number, width: number, height: number) {
@@ -21823,7 +21899,7 @@ export class CampScene extends Phaser.Scene {
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 620 });
const hpText = hpGain > 0 ? `병력 +${hpGain}` : '';
const bondText = bondGain > 0 ? `공명 +${supply.bondExp}` : '';
const bondText = bondGain > 0 ? `연결 공명 총 +${bondGain}` : '';
this.showCampNotice(`${target.name} ${supply.title} 사용 · ${[hpText, bondText].filter(Boolean).join(' / ')}`);
this.render();
}
@@ -22052,7 +22128,8 @@ export class CampScene extends Phaser.Scene {
}
private showCampNotice(message: string) {
this.dialogueObjects.forEach((object) => object.destroy());
this.tweens.killTweensOf(this.dialogueObjects);
this.dialogueObjects.forEach((object) => object.active && object.destroy());
this.dialogueObjects = [];
const depth = this.sortieObjects.length > 0 ? 72 : 30;
const noticeWidth = Math.min(640, Math.max(360, message.length * 13));
@@ -22066,15 +22143,18 @@ export class CampScene extends Phaser.Scene {
}));
text.setOrigin(0.5);
text.setDepth(depth + 1);
this.dialogueObjects.push(box, text);
const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text];
this.dialogueObjects = noticeObjects;
this.tweens.add({
targets: this.dialogueObjects,
targets: noticeObjects,
alpha: 0,
delay: 1300,
duration: 320,
onComplete: () => {
this.dialogueObjects.forEach((object) => object.destroy());
this.dialogueObjects = [];
noticeObjects.forEach((object) => object.active && object.destroy());
if (this.dialogueObjects === noticeObjects) {
this.dialogueObjects = [];
}
}
});
}
@@ -22610,6 +22690,14 @@ export class CampScene extends Phaser.Scene {
this.contentObjects.forEach((object) => object.destroy());
this.contentObjects = [];
this.campRosterLayout = undefined;
this.equipmentPanelBackground = undefined;
this.equipmentInventoryPreviousButton = undefined;
this.equipmentInventoryNextButton = undefined;
this.equipmentInventoryVisibleItemIds = [];
this.suppliesPanelBackground = undefined;
this.suppliesEquipmentBoxes = [];
this.suppliesTrophyBoxes = [];
this.reportPanelBackground = undefined;
this.reportFormationReviewToggleButton = undefined;
this.reportFormationHistoryToggleButton = undefined;
this.reportSortieOrderRewardBadge = undefined;
@@ -22728,6 +22816,8 @@ export class CampScene extends Phaser.Scene {
1,
Math.ceil(reportFormationHistoryRecords.length / reportFormationHistoryPageSize)
);
const equipmentInventory = this.equipmentInventoryEntries();
const equipmentInventoryPageCount = Math.max(1, Math.ceil(equipmentInventory.length / equipmentInventoryPageSize));
return {
scene: this.scene.key,
activeTab: this.activeTab,
@@ -23422,7 +23512,35 @@ export class CampScene extends Phaser.Scene {
strategyLine: summary.strategyLine
};
}),
equipmentInventory: this.equipmentInventoryEntries().map((entry) => ({
secondaryPanels: {
report: {
visible: Boolean(this.reportPanelBackground?.active),
bounds: this.sortieObjectBoundsDebug(this.reportPanelBackground)
},
supplies: {
visible: this.activeTab === 'supplies' && Boolean(this.suppliesPanelBackground?.active),
bounds: this.sortieObjectBoundsDebug(this.suppliesPanelBackground),
equipmentBoxBounds: this.suppliesEquipmentBoxes
.map((box) => this.sortieObjectBoundsDebug(box))
.filter((bounds) => bounds !== null),
trophyBoxBounds: this.suppliesTrophyBoxes
.map((box) => this.sortieObjectBoundsDebug(box))
.filter((bounds) => bounds !== null)
},
equipment: {
visible: this.activeTab === 'equipment' && Boolean(this.equipmentPanelBackground?.active),
bounds: this.sortieObjectBoundsDebug(this.equipmentPanelBackground),
page: this.equipmentInventoryPage,
pageCount: equipmentInventoryPageCount,
pageSize: equipmentInventoryPageSize,
visibleItemIds: [...this.equipmentInventoryVisibleItemIds],
previousButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryPreviousButton),
nextButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryNextButton),
previousEnabled: Boolean(this.equipmentInventoryPreviousButton?.input?.enabled),
nextEnabled: Boolean(this.equipmentInventoryNextButton?.input?.enabled)
}
},
equipmentInventory: equipmentInventory.map((entry) => ({
label: entry.label,
amount: entry.amount,
itemId: entry.item.id,

View File

@@ -123,7 +123,16 @@ export class StoryScene extends Phaser.Scene {
? { x: progressPanelBounds.x, y: progressPanelBounds.y, width: progressPanelBounds.width, height: progressPanelBounds.height }
: null,
isLastPage,
advanceHint: isLastPage ? 'continue' : 'next',
advanceHint:
isLastPage && this.nextScene === 'BattleScene'
? 'battle-deployment'
: isLastPage && this.nextScene === 'CampScene'
? 'camp-return'
: isLastPage
? 'continue'
: 'next',
advanceLabel: this.progressActionLabel(isLastPage),
nextScene: this.nextScene,
cutsceneKind: page?.cutscene?.kind ?? null,
cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? []
};
@@ -312,9 +321,9 @@ export class StoryScene extends Phaser.Scene {
});
this.bodyText.setDepth(dialogDepth + 2);
const progressX = width - ui(174);
const progressX = width - ui(214);
const progressY = panelY + panelH + ui(20);
this.progressBackground = this.add.rectangle(progressX, progressY, ui(300), ui(34), cutsceneUiColors.ink, 0.82);
this.progressBackground = this.add.rectangle(progressX, progressY, ui(380), ui(34), cutsceneUiColors.ink, 0.82);
this.progressBackground.setStrokeStyle(ui(1), palette.gold, 0.42);
this.progressBackground.setDepth(dialogDepth + 1);
@@ -324,7 +333,7 @@ export class StoryScene extends Phaser.Scene {
color: '#d8b15f',
fontStyle: '700',
align: 'center',
fixedWidth: ui(276)
fixedWidth: ui(356)
});
this.progressText.setOrigin(0.5);
this.progressText.setDepth(dialogDepth + 2);
@@ -404,10 +413,23 @@ export class StoryScene extends Phaser.Scene {
this.bodyText?.setText(page.text);
const isLastPage = this.pageIndex >= this.pages.length - 1;
this.progressText?.setText(
`${this.pageIndex + 1} / ${this.pages.length} · 클릭 / 스페이스·엔터 · ${isLastPage ? '계속' : '다음'}`
`${this.pageIndex + 1} / ${this.pages.length} · 클릭 / 스페이스·엔터 · ${this.progressActionLabel(isLastPage)}`
);
}
private progressActionLabel(isLastPage = this.pageIndex >= this.pages.length - 1) {
if (!isLastPage) {
return '다음';
}
if (this.nextScene === 'BattleScene') {
return '전투 배치';
}
if (this.nextScene === 'CampScene') {
return '군영으로';
}
return '계속';
}
private pagePortraitKey(page: StoryPage) {
return page.portrait ?? portraitKeyForSpeaker(page.speaker);
}