feat: unify victory reward experience
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user