feat: unify victory reward experience

This commit is contained in:
2026-07-18 14:33:29 +09:00
parent 1df324ecd2
commit 134a509938
6 changed files with 1385 additions and 129 deletions

View File

@@ -1,5 +1,12 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { equipmentItemIdForInventoryLabel } from '../data/battleItems';
import {
battleUiIconFrames,
battleUiIconMicroTextureKey,
battleUiIconTextureKey,
type BattleUiIconKey
} from '../data/battleUiIcons';
import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets';
import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets';
import {
@@ -17,6 +24,7 @@ import {
type StoryCutsceneReward
} from '../data/storyCutscenes';
import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario';
import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState';
import { palette } from '../ui/palette';
import { startGameScene } from './lazyScenes';
@@ -79,6 +87,40 @@ type CutsceneStageBounds = {
type CutsceneBoardBounds = CutsceneStageBounds;
type StoryRewardCategory = 'gold' | 'supply' | 'equipment' | 'reputation' | 'recruit' | 'unlock' | 'fallback';
type StoryRewardSource = 'campaign' | 'fallback' | 'none';
type StoryRewardIcon =
| { kind: 'item'; textureKey: string; fallbackMark: string }
| { kind: 'battle-ui'; iconKey: BattleUiIconKey; fallbackMark: string }
| { kind: 'mark'; fallbackMark: string };
type StoryRewardDisplayItem = {
id: string;
category: StoryRewardCategory;
eyebrow: string;
label: string;
tone: StoryCutsceneReward['tone'];
icon: StoryRewardIcon;
};
type StoryRewardDisplayModel = {
pageId: string | null;
source: StoryRewardSource;
battleId: string | null;
rewardGold: number | null;
items: StoryRewardDisplayItem[];
};
const firstBattleVictoryPageIds = new Set([
'first-victory-village',
'first-victory-liu-bei',
'first-victory-guan-yu',
'first-victory-zhang-fei',
'first-victory-next'
]);
const firstBattleId = 'first-battle-zhuo-commandery';
export class StoryScene extends Phaser.Scene {
private pageIndex = 0;
private pages: StoryPage[] = prologuePages;
@@ -103,6 +145,7 @@ export class StoryScene extends Phaser.Scene {
private activeStoryLoadErrorHandler?: (file: Phaser.Loader.File) => void;
private storyAssetLoadGeneration = 0;
private storyAssetFailures = new Set<string>();
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
constructor() {
super('StoryScene');
@@ -120,6 +163,7 @@ export class StoryScene extends Phaser.Scene {
this.activeStoryLoadErrorHandler = undefined;
this.storyAssetFailures.clear();
this.storyAssetLoadGeneration += 1;
this.rewardDisplay = this.emptyRewardDisplay();
}
getDebugState() {
@@ -160,6 +204,10 @@ export class StoryScene extends Phaser.Scene {
nextScene: this.nextScene,
cutsceneKind: page?.cutscene?.kind ?? null,
cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? [],
rewardDisplay: {
...this.rewardDisplay,
items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } }))
},
assetStreaming: {
currentPageReady: this.readyStoryPageIndexes.has(this.pageIndex),
nextPageIndex: this.pageIndex + 1 < this.pages.length ? this.pageIndex + 1 : null,
@@ -581,7 +629,7 @@ export class StoryScene extends Phaser.Scene {
private applyPage(page: StoryPage) {
soundDirector.playMusic(page.bgm);
this.applyBackground(page);
this.renderCutscene(page.cutscene);
this.renderCutscene(page);
this.chapterText?.setText(page.chapter);
const textureKey = this.pagePortraitTextureKey(page);
@@ -692,10 +740,13 @@ export class StoryScene extends Phaser.Scene {
});
}
private renderCutscene(cutscene?: StoryCutscene) {
private renderCutscene(page: StoryPage) {
this.cutsceneLayer?.list.forEach((child) => this.tweens.killTweensOf(child));
this.cutsceneLayer?.destroy();
this.cutsceneLayer = undefined;
this.rewardDisplay = this.rewardDisplayForPage(page);
const cutscene = page.cutscene;
if (!cutscene) {
return;
@@ -708,7 +759,7 @@ export class StoryScene extends Phaser.Scene {
this.renderCutsceneStage(layer, cutscene);
const boardBounds = this.renderTacticalBoard(layer, cutscene);
this.renderCutsceneActorCards(layer, cutscene, boardBounds);
this.renderCutsceneRewards(layer, cutscene.rewards ?? []);
this.renderCutsceneRewards(layer, this.rewardDisplay);
}
private renderCutsceneStage(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) {
@@ -1205,48 +1256,264 @@ export class StoryScene extends Phaser.Scene {
}
}
private renderCutsceneRewards(layer: Phaser.GameObjects.Container, rewards: StoryCutsceneReward[]) {
if (rewards.length === 0) {
private renderCutsceneRewards(layer: Phaser.GameObjects.Container, model: StoryRewardDisplayModel) {
if (model.items.length === 0) {
return;
}
const stage = this.cutsceneStageBounds();
const maxItems = Math.min(4, rewards.length);
const visibleItems = model.items.slice(0, 4);
const maxItems = visibleItems.length;
const stripX = stage.x + ui(28);
const stripY = stage.y + stage.height - ui(44);
const stripY = stage.y + stage.height - ui(62);
const stripW = stage.width - ui(56);
const itemW = Math.floor(stripW / maxItems);
const gap = ui(8);
const itemW = (stripW - gap * (maxItems - 1)) / maxItems;
const itemH = ui(54);
const bg = this.add.graphics();
bg.fillStyle(cutsceneUiColors.veil, 0.22);
bg.fillRoundedRect(stripX + ui(4), stripY + ui(5), stripW, ui(34), ui(6));
bg.fillStyle(cutsceneUiColors.lacquer, 0.72);
bg.fillRoundedRect(stripX, stripY, stripW, ui(34), ui(6));
bg.fillStyle(cutsceneUiColors.veil, 0.42);
bg.fillRoundedRect(stripX - ui(8), stripY - ui(7), stripW + ui(16), itemH + ui(14), ui(9));
bg.lineStyle(ui(1), palette.gold, 0.18);
bg.lineBetween(stripX + ui(12), stripY + ui(6), stripX + stripW - ui(12), stripY + ui(6));
bg.lineBetween(stripX + ui(12), stripY + ui(28), stripX + stripW - ui(12), stripY + ui(28));
bg.strokeRoundedRect(stripX - ui(8), stripY - ui(7), stripW + ui(16), itemH + ui(14), ui(9));
layer.add(bg);
rewards.slice(0, maxItems).forEach((reward, index) => {
visibleItems.forEach((reward, index) => {
const toneColor = reward.tone === 'next' ? palette.blue : reward.tone === 'bond' ? palette.green : palette.gold;
const x = stripX + index * itemW;
const card = this.add.container(stripX + index * (itemW + gap), stripY + ui(6));
const item = this.add.graphics();
item.fillStyle(toneColor, 0.16);
item.fillCircle(x + ui(17), stripY + ui(17), ui(5));
item.lineStyle(ui(1), toneColor, 0.22);
if (index > 0) {
item.lineBetween(x, stripY + ui(9), x, stripY + ui(25));
}
const text = this.add.text(x + ui(30), stripY + ui(8), reward.label, {
item.fillStyle(cutsceneUiColors.veil, 0.5);
item.fillRoundedRect(ui(3), ui(4), itemW, itemH, ui(7));
item.fillStyle(cutsceneUiColors.lacquer, 0.94);
item.fillRoundedRect(0, 0, itemW, itemH, ui(7));
item.fillStyle(toneColor, 0.12);
item.fillRoundedRect(ui(5), ui(5), itemW - ui(10), itemH - ui(10), ui(5));
item.fillStyle(toneColor, 0.2);
item.fillRoundedRect(ui(7), ui(7), ui(42), itemH - ui(14), ui(5));
item.lineStyle(ui(1), toneColor, 0.42);
item.strokeRoundedRect(0, 0, itemW, itemH, ui(7));
item.lineStyle(ui(1), 0xf0d08a, 0.12);
item.lineBetween(ui(58), ui(29), itemW - ui(12), ui(29));
const icon = this.renderRewardIcon(reward.icon, ui(28), itemH / 2, ui(31), toneColor);
const eyebrow = this.add.text(ui(60), ui(8), reward.eyebrow, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(13),
fontSize: uiPx(11),
color: reward.tone === 'next' ? '#9fc3e8' : reward.tone === 'bond' ? '#a9ddb9' : '#d8b15f',
fontStyle: '700'
});
const text = this.add.text(ui(60), ui(31), reward.label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(14),
color: '#f2e3bf',
fontStyle: '700',
wordWrap: { width: itemW - ui(40), useAdvancedWrap: true }
wordWrap: { width: itemW - ui(74), useAdvancedWrap: true }
});
text.setOrigin(0, 0.5);
card.add([item, icon, eyebrow, text]);
card.setAlpha(0);
layer.add(card);
this.tweens.add({
targets: card,
y: stripY,
alpha: 1,
duration: 220,
delay: 60 + index * 70,
ease: 'Cubic.easeOut'
});
layer.add([item, text]);
});
}
private rewardDisplayForPage(page: StoryPage): StoryRewardDisplayModel {
const fallback = this.fallbackRewardDisplay(page);
if (!firstBattleVictoryPageIds.has(page.id)) {
return fallback;
}
const report = getFirstBattleReport();
if (!report || report.battleId !== firstBattleId || report.outcome !== 'victory') {
return fallback;
}
const rewards = report.campaignRewards;
const presentation = campaignVictoryRewardPresentation(report);
const categorizedRewardItems = new Set([
...(rewards?.supplies ?? []),
...(rewards?.equipment ?? []),
...(rewards?.reputation ?? [])
]);
const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item));
const supplyItems = rewards
? [
...rewards.supplies,
...legacyExtraItems,
...(presentation.sortieOrderBonus?.rewardItems ?? [])
]
: [...presentation.itemRewards];
if (!rewards && page.id !== 'first-victory-village') {
return fallback;
}
let items: StoryRewardDisplayItem[] = [];
if (page.id === 'first-victory-village') {
items = [this.goldRewardItem(presentation.rewardGold), ...supplyItems.map((label, index) => this.supplyRewardItem(label, index))];
} else if (page.id === 'first-victory-liu-bei' && rewards) {
items = [
...rewards.equipment.map((label, index) => this.equipmentRewardItem(label, index)),
...rewards.reputation.map((label, index) => this.reputationRewardItem(label, index))
];
} else if (page.id === 'first-victory-guan-yu' && rewards) {
items = rewards.recruits
.filter((recruit) => recruit.unitId === 'guan-yu')
.map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name));
} else if (page.id === 'first-victory-zhang-fei' && rewards) {
items = rewards.recruits
.filter((recruit) => recruit.unitId !== 'guan-yu')
.map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name));
} else if (page.id === 'first-victory-next' && rewards) {
items = rewards.unlocks.map((unlock) => this.unlockRewardItem(unlock.battleId, unlock.title));
}
return {
pageId: page.id,
source: 'campaign',
battleId: report.battleId,
rewardGold: presentation.rewardGold,
items
};
}
private fallbackRewardDisplay(page: StoryPage): StoryRewardDisplayModel {
const rewards = page.cutscene?.rewards ?? [];
return {
pageId: page.id,
source: rewards.length > 0 ? 'fallback' : 'none',
battleId: null,
rewardGold: null,
items: rewards.map((reward, index) => ({
id: `fallback-${index}`,
category: 'fallback',
eyebrow: reward.tone === 'next' ? '다음 목표' : reward.tone === 'bond' ? '전과' : '획득 정보',
label: reward.label,
tone: reward.tone,
icon: { kind: 'mark', fallbackMark: reward.tone === 'next' ? '路' : reward.tone === 'bond' ? '功' : '賞' }
}))
};
}
private emptyRewardDisplay(): StoryRewardDisplayModel {
return { pageId: null, source: 'none', battleId: null, rewardGold: null, items: [] };
}
private goldRewardItem(amount: number): StoryRewardDisplayItem {
return {
id: 'gold',
category: 'gold',
eyebrow: '군자금',
label: `+${Math.max(0, amount).toLocaleString('ko-KR')}`,
tone: 'reward',
icon: { kind: 'mark', fallbackMark: '金' }
};
}
private supplyRewardItem(label: string, index: number): StoryRewardDisplayItem {
const iconKey: BattleUiIconKey = /탁주/.test(label) ? 'wine' : /상처약/.test(label) ? 'salve' : 'bean';
return {
id: `supply-${index}-${label}`,
category: 'supply',
eyebrow: '보급품',
label,
tone: 'reward',
icon: { kind: 'battle-ui', iconKey, fallbackMark: /탁주/.test(label) ? '酒' : /상처약/.test(label) ? '藥' : '糧' }
};
}
private equipmentRewardItem(label: string, index: number): StoryRewardDisplayItem {
const inventoryLabel = label.replace(/\s+(?:[x×]\s*)?\d+\s*$/u, '').trim();
const itemId = equipmentItemIdForInventoryLabel(inventoryLabel);
return {
id: `equipment-${index}-${label}`,
category: 'equipment',
eyebrow: '장비 획득',
label,
tone: 'reward',
icon: itemId
? { kind: 'item', textureKey: `item-${itemId}`, fallbackMark: '具' }
: { kind: 'battle-ui', iconKey: 'sword', fallbackMark: '具' }
};
}
private reputationRewardItem(label: string, index: number): StoryRewardDisplayItem {
return {
id: `reputation-${index}-${label}`,
category: 'reputation',
eyebrow: '명성 상승',
label,
tone: 'bond',
icon: { kind: 'mark', fallbackMark: '名' }
};
}
private recruitRewardItem(unitId: string, name: string): StoryRewardDisplayItem {
return {
id: `recruit-${unitId}`,
category: 'recruit',
eyebrow: '신규 합류',
label: `${name} 출전 가능`,
tone: 'bond',
icon: { kind: 'mark', fallbackMark: '將' }
};
}
private unlockRewardItem(battleId: string, title: string): StoryRewardDisplayItem {
return {
id: `unlock-${battleId}`,
category: 'unlock',
eyebrow: '전장 해금',
label: title,
tone: 'next',
icon: { kind: 'battle-ui', iconKey: 'success', fallbackMark: '開' }
};
}
private renderRewardIcon(icon: StoryRewardIcon, x: number, y: number, size: number, toneColor: number) {
if (icon.kind === 'item' && this.textures.exists(icon.textureKey)) {
const image = this.add.image(x, y, icon.textureKey);
image.setDisplaySize(size, size);
return image;
}
if (icon.kind === 'battle-ui') {
const textureKey = this.textures.exists(battleUiIconTextureKey)
? battleUiIconTextureKey
: this.textures.exists(battleUiIconMicroTextureKey)
? battleUiIconMicroTextureKey
: undefined;
if (textureKey) {
const image = this.add.image(x, y, textureKey, battleUiIconFrames[icon.iconKey]);
image.setDisplaySize(size, size);
return image;
}
}
const fallback = this.add.container(x, y);
const seal = this.add.graphics();
seal.fillStyle(cutsceneUiColors.ink, 0.9);
seal.fillCircle(0, 0, size * 0.48);
seal.lineStyle(ui(1), toneColor, 0.72);
seal.strokeCircle(0, 0, size * 0.46);
seal.lineStyle(ui(1), 0xf0d08a, 0.16);
seal.strokeCircle(0, 0, size * 0.35);
const mark = this.add.text(0, 0, icon.fallbackMark, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: uiPx(18),
color: '#f2e3bf',
fontStyle: '700'
});
mark.setOrigin(0.5);
fallback.add([seal, mark]);
return fallback;
}
private cutsceneStageBounds(): CutsceneStageBounds {
return {
x: ui(58),