feat: surface campaign officer portraits
This commit is contained in:
@@ -3,6 +3,11 @@ const portraitModules = import.meta.glob('../../assets/images/portraits/*.png',
|
||||
import: 'default'
|
||||
}) as Record<string, string>;
|
||||
|
||||
const portraitCardModules = import.meta.glob('../../assets/images/portraits/cards/*.webp', {
|
||||
eager: true,
|
||||
import: 'default'
|
||||
}) as Record<string, string>;
|
||||
|
||||
const legacyPortraitSlugs: Record<string, string> = {
|
||||
liuBei: 'liu-bei',
|
||||
guanYu: 'guan-yu',
|
||||
@@ -13,6 +18,32 @@ const legacyPortraitSlugs: Record<string, string> = {
|
||||
simaYi: 'sima-yi'
|
||||
};
|
||||
|
||||
export const campaignPortraitKeysByUnitId: Record<string, string> = {
|
||||
'liu-bei': 'liuBei',
|
||||
'guan-yu': 'guanYu',
|
||||
'zhang-fei': 'zhangFei',
|
||||
'jian-yong': 'jian-yong',
|
||||
'mi-zhu': 'mi-zhu',
|
||||
'sun-qian': 'sun-qian',
|
||||
'zhao-yun': 'zhaoYun',
|
||||
'zhuge-liang': 'zhugeLiang',
|
||||
'ma-liang': 'ma-liang',
|
||||
'yi-ji': 'yi-ji',
|
||||
'gong-zhi': 'gong-zhi',
|
||||
'huang-zhong': 'huang-zhong',
|
||||
'wei-yan': 'wei-yan',
|
||||
'pang-tong': 'pang-tong',
|
||||
'fa-zheng': 'fa-zheng',
|
||||
'wu-yi': 'wu-yi',
|
||||
'yan-yan': 'yan-yan',
|
||||
'li-yan': 'li-yan',
|
||||
'huang-quan': 'huang-quan',
|
||||
'ma-chao': 'ma-chao',
|
||||
'ma-dai': 'ma-dai',
|
||||
'wang-ping': 'wang-ping',
|
||||
'jiang-wei': 'jiangWei'
|
||||
};
|
||||
|
||||
const speakerPortraitKeys: Record<string, string> = {
|
||||
유비: 'liuBei',
|
||||
관우: 'guanYu',
|
||||
@@ -45,7 +76,7 @@ const speakerPortraitKeys: Record<string, string> = {
|
||||
|
||||
function fileSlugFromPath(path: string) {
|
||||
const fileName = path.split('/').pop() ?? path;
|
||||
return fileName.replace(/\.png$/i, '');
|
||||
return fileName.replace(/\.(?:png|webp)$/i, '');
|
||||
}
|
||||
|
||||
function camelToKebab(value: string) {
|
||||
@@ -56,6 +87,10 @@ export const portraitAssets: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(portraitModules).map(([path, url]) => [fileSlugFromPath(path), url])
|
||||
);
|
||||
|
||||
export const portraitCardAssets: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(portraitCardModules).map(([path, url]) => [fileSlugFromPath(path), url])
|
||||
);
|
||||
|
||||
export type PortraitAssetEntry = {
|
||||
textureKey: string;
|
||||
url: string;
|
||||
@@ -70,6 +105,22 @@ export function portraitTextureKey(key: string) {
|
||||
return portraitAssets[slug] ? `portrait-${slug}` : undefined;
|
||||
}
|
||||
|
||||
export function portraitCardTextureKey(key: string) {
|
||||
const slug = portraitSlugForKey(key);
|
||||
return portraitCardAssets[slug] ? `portrait-card-${slug}` : undefined;
|
||||
}
|
||||
|
||||
export function portraitCardAssetEntryForKey(key: string): PortraitAssetEntry | undefined {
|
||||
const slug = portraitSlugForKey(key);
|
||||
const url = portraitCardAssets[slug];
|
||||
return url
|
||||
? {
|
||||
textureKey: `portrait-card-${slug}`,
|
||||
url
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] {
|
||||
const slug = portraitSlugForKey(key);
|
||||
const prefix = `${slug}-`;
|
||||
|
||||
@@ -15,7 +15,14 @@ import {
|
||||
import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules';
|
||||
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
|
||||
import { getSortieFlow } from '../data/campaignFlow';
|
||||
import { portraitAssetEntriesForKey, portraitTextureKey, type PortraitAssetEntry } from '../data/portraitAssets';
|
||||
import {
|
||||
campaignPortraitKeysByUnitId,
|
||||
portraitAssetEntriesForKey,
|
||||
portraitCardAssetEntryForKey,
|
||||
portraitCardTextureKey,
|
||||
portraitTextureKey,
|
||||
type PortraitAssetEntry
|
||||
} from '../data/portraitAssets';
|
||||
import {
|
||||
createSortieDeploymentPlan,
|
||||
normalizeSortieFormationAssignments,
|
||||
@@ -101,7 +108,6 @@ import {
|
||||
zhugeRecruitBonds,
|
||||
zhugeRecruitUnits,
|
||||
type BattleBond,
|
||||
type PortraitKey,
|
||||
type UnitData
|
||||
} from '../data/scenario';
|
||||
import {
|
||||
@@ -345,15 +351,6 @@ type CampSceneData = {
|
||||
skipIntroStory?: boolean;
|
||||
};
|
||||
|
||||
const portraitByUnitId: Record<string, PortraitKey> = {
|
||||
'liu-bei': 'liuBei',
|
||||
'guan-yu': 'guanYu',
|
||||
'zhang-fei': 'zhangFei',
|
||||
'zhuge-liang': 'zhugeLiang',
|
||||
'zhao-yun': 'zhaoYun',
|
||||
'jiang-wei': 'jiangWei'
|
||||
};
|
||||
|
||||
const campSupplies: CampSupplyDefinition[] = [
|
||||
{
|
||||
label: '콩',
|
||||
@@ -10786,6 +10783,7 @@ export class CampScene extends Phaser.Scene {
|
||||
private sortieFormationAssignments: SortieFormationAssignments = {};
|
||||
private sortieItemAssignments: CampaignSortieItemAssignments = {};
|
||||
private sortieFocusedUnitId = 'liu-bei';
|
||||
private sortieHoveredUnitId?: string;
|
||||
private sortieRosterScroll = 0;
|
||||
private sortiePlanFeedback = '';
|
||||
private sortiePrepStep: SortiePrepStep = 'briefing';
|
||||
@@ -10858,15 +10856,16 @@ export class CampScene extends Phaser.Scene {
|
||||
const useFirstSortieAssets = this.isFirstSortiePrepSlice();
|
||||
const missingPortraits = this.currentUnits()
|
||||
.flatMap((unit): PortraitAssetEntry[] => {
|
||||
const portraitKey = portraitByUnitId[unit.id];
|
||||
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
|
||||
if (!portraitKey) {
|
||||
return [];
|
||||
}
|
||||
const entries = portraitAssetEntriesForKey(portraitKey);
|
||||
const cardPortrait = portraitCardAssetEntryForKey(portraitKey);
|
||||
const periodPortrait = useFirstSortieAssets
|
||||
? entries.find((entry) => entry.textureKey.endsWith('-yellow-turban'))
|
||||
: undefined;
|
||||
return [entries[0], periodPortrait].filter((entry): entry is PortraitAssetEntry => entry !== undefined);
|
||||
return [cardPortrait ?? entries[0], periodPortrait].filter((entry): entry is PortraitAssetEntry => entry !== undefined);
|
||||
})
|
||||
.filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index)
|
||||
.filter((entry) => !this.textures.exists(entry.textureKey));
|
||||
@@ -12730,18 +12729,15 @@ export class CampScene extends Phaser.Scene {
|
||||
const role = this.sortieFormationRole(unit);
|
||||
const preview = this.sortieUnitSynergyPreview(unit);
|
||||
const fill = !availability.available ? 0x11151a : selected ? 0x172a22 : recommendation ? 0x241f17 : 0x151b24;
|
||||
const accentColor = focused ? palette.gold : selected ? palette.green : recommendation ? palette.gold : 0x53606c;
|
||||
const restingStrokeWidth = focused ? 2 : selected ? 1.5 : 1;
|
||||
const restingStrokeAlpha = focused ? 0.96 : selected ? 0.68 : recommendation ? 0.52 : 0.34;
|
||||
const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, fill, !availability.available ? 0.62 : 0.96));
|
||||
card.setOrigin(0);
|
||||
card.setDepth(depth + 1);
|
||||
card.setStrokeStyle(
|
||||
focused ? 2 : 1,
|
||||
focused ? palette.gold : !availability.available ? 0x53606c : selected ? palette.green : recommendation ? palette.gold : 0x53606c,
|
||||
focused ? 0.96 : selected ? 0.64 : recommendation ? 0.48 : 0.34
|
||||
);
|
||||
card.setStrokeStyle(restingStrokeWidth, availability.available ? accentColor : 0x53606c, restingStrokeAlpha);
|
||||
card.setInteractive({ useHandCursor: true });
|
||||
card.on('wheel', handleRosterWheel);
|
||||
card.on('pointerover', () => card.setFillStyle(!availability.available ? 0x15191e : selected ? 0x20362a : 0x1d2731, 0.98));
|
||||
card.on('pointerout', () => card.setFillStyle(fill, !availability.available ? 0.62 : 0.96));
|
||||
card.on('pointerdown', () => {
|
||||
this.sortieFocusedUnitId = unit.id;
|
||||
soundDirector.playSelect();
|
||||
@@ -12751,7 +12747,39 @@ export class CampScene extends Phaser.Scene {
|
||||
this.showSortiePrep();
|
||||
});
|
||||
|
||||
this.renderFirstSortiePortrait(cardX + 39, cardY + cardHeight / 2, 56, unit, depth + 2, availability.available ? 1 : 0.42, false);
|
||||
const accent = this.trackSortie(this.add.rectangle(cardX + 3, cardY + 3, 4, cardHeight - 6, accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34));
|
||||
accent.setOrigin(0);
|
||||
accent.setDepth(depth + 2);
|
||||
const portraitView = this.renderFirstSortiePortrait(
|
||||
cardX + 39,
|
||||
cardY + cardHeight / 2,
|
||||
56,
|
||||
unit,
|
||||
depth + 2,
|
||||
availability.available ? 1 : 0.42,
|
||||
false,
|
||||
accentColor
|
||||
);
|
||||
card.on('pointerover', () => {
|
||||
this.sortieHoveredUnitId = unit.id;
|
||||
card.setFillStyle(!availability.available ? 0x15191e : selected ? 0x20362a : 0x1d2731, 0.98);
|
||||
card.setStrokeStyle(2, palette.gold, 0.98);
|
||||
accent.setFillStyle(palette.gold, 0.98);
|
||||
portraitView.frame.setStrokeStyle(2, palette.gold, 0.98);
|
||||
if (availability.available && portraitView.image) {
|
||||
portraitView.image.setDisplaySize(60, 60);
|
||||
}
|
||||
});
|
||||
card.on('pointerout', () => {
|
||||
if (this.sortieHoveredUnitId === unit.id) {
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
}
|
||||
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);
|
||||
});
|
||||
const statusLabel = required ? '필수' : selected ? '출전' : recommendation ? '추천' : '대기';
|
||||
const statusColor = required || recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf';
|
||||
const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 8, statusLabel, this.textStyle(9, statusColor, true)));
|
||||
@@ -12962,26 +12990,28 @@ export class CampScene extends Phaser.Scene {
|
||||
unit: UnitData,
|
||||
depth: number,
|
||||
alpha = 1,
|
||||
preferPeriodPortrait = true
|
||||
) {
|
||||
preferPeriodPortrait = true,
|
||||
frameColor: number = palette.gold
|
||||
): { frame: Phaser.GameObjects.Rectangle; image?: Phaser.GameObjects.Image } {
|
||||
const frame = this.trackSortie(this.add.rectangle(x, y, size + 8, size + 8, 0x090e14, 0.98));
|
||||
frame.setDepth(depth);
|
||||
frame.setStrokeStyle(2, palette.gold, 0.58);
|
||||
const portraitKey = portraitByUnitId[unit.id];
|
||||
frame.setStrokeStyle(2, frameColor, 0.72);
|
||||
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
|
||||
const periodPortraitTexture = portraitKey && preferPeriodPortrait
|
||||
? portraitAssetEntriesForKey(portraitKey).find((entry) => entry.textureKey.endsWith('-yellow-turban'))?.textureKey
|
||||
: undefined;
|
||||
const portraitTexture = periodPortraitTexture && this.textures.exists(periodPortraitTexture)
|
||||
? periodPortraitTexture
|
||||
: portraitKey
|
||||
? portraitTextureKey(portraitKey)
|
||||
: undefined;
|
||||
const cardPortraitTexture = portraitKey ? portraitCardTextureKey(portraitKey) : undefined;
|
||||
const basePortraitTexture = portraitKey ? portraitTextureKey(portraitKey) : undefined;
|
||||
const portraitTexture = (preferPeriodPortrait
|
||||
? [periodPortraitTexture, cardPortraitTexture, basePortraitTexture]
|
||||
: [cardPortraitTexture, basePortraitTexture]
|
||||
).find((texture): texture is string => Boolean(texture && this.textures.exists(texture)));
|
||||
if (portraitTexture && this.textures.exists(portraitTexture)) {
|
||||
const portrait = this.trackSortie(this.add.image(x, y, portraitTexture));
|
||||
portrait.setDisplaySize(size, size);
|
||||
portrait.setAlpha(alpha);
|
||||
portrait.setDepth(depth + 1);
|
||||
return;
|
||||
return { frame, image: portrait };
|
||||
}
|
||||
const fallback = this.trackSortie(this.add.circle(x, y, size / 2, 0x1f3140, 0.96));
|
||||
fallback.setDepth(depth + 1);
|
||||
@@ -12992,6 +13022,7 @@ export class CampScene extends Phaser.Scene {
|
||||
const className = this.trackSortie(this.add.text(x, y + 30, unit.className, this.textStyle(12, '#d4dce6', true)));
|
||||
className.setOrigin(0.5);
|
||||
className.setDepth(depth + 2);
|
||||
return { frame };
|
||||
}
|
||||
|
||||
private assignFirstSortieRole(unitId: string, role: SortieFormationRole) {
|
||||
@@ -15043,6 +15074,7 @@ export class CampScene extends Phaser.Scene {
|
||||
private hideSortiePrep() {
|
||||
this.sortieObjects.forEach((object) => object.destroy());
|
||||
this.sortieObjects = [];
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
}
|
||||
|
||||
private trackSortie<T extends Phaser.GameObjects.GameObject>(object: T) {
|
||||
@@ -15082,8 +15114,12 @@ export class CampScene extends Phaser.Scene {
|
||||
this.render();
|
||||
});
|
||||
|
||||
const portraitKey = portraitByUnitId[unit.id];
|
||||
const portraitTexture = portraitKey ? portraitTextureKey(portraitKey) : undefined;
|
||||
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
|
||||
const portraitTexture = portraitKey
|
||||
? [portraitCardTextureKey(portraitKey), portraitTextureKey(portraitKey)].find(
|
||||
(texture): texture is string => Boolean(texture && this.textures.exists(texture))
|
||||
)
|
||||
: undefined;
|
||||
const portraitCenterY = y + cardHeight / 2;
|
||||
const portraitSize = compact ? 40 : 84;
|
||||
if (portraitTexture && this.textures.exists(portraitTexture)) {
|
||||
@@ -15460,8 +15496,12 @@ export class CampScene extends Phaser.Scene {
|
||||
bg.setStrokeStyle(1, palette.blue, 0.56);
|
||||
|
||||
const unitClass = getUnitClass(unit.classKey);
|
||||
const portraitKey = portraitByUnitId[unit.id];
|
||||
const portraitTexture = portraitKey ? portraitTextureKey(portraitKey) : undefined;
|
||||
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
|
||||
const portraitTexture = portraitKey
|
||||
? [portraitCardTextureKey(portraitKey), portraitTextureKey(portraitKey)].find(
|
||||
(texture): texture is string => Boolean(texture && this.textures.exists(texture))
|
||||
)
|
||||
: undefined;
|
||||
const portraitFrame = this.track(this.add.rectangle(x + 84, y + 90, 122, 122, 0x0b1219, 0.92));
|
||||
portraitFrame.setStrokeStyle(2, palette.gold, 0.62);
|
||||
if (portraitTexture && this.textures.exists(portraitTexture)) {
|
||||
@@ -16831,6 +16871,15 @@ export class CampScene extends Phaser.Scene {
|
||||
maxScroll: portraitRosterMaxScroll,
|
||||
scroll: portraitRosterScroll
|
||||
},
|
||||
sortiePortraitRoster: portraitRosterUnits.map((unit) => {
|
||||
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
|
||||
const unitPortraitTextureKey = portraitKey ? portraitCardTextureKey(portraitKey) ?? portraitTextureKey(portraitKey) : undefined;
|
||||
return {
|
||||
id: unit.id,
|
||||
portraitTextureKey: unitPortraitTextureKey ?? null,
|
||||
portraitReady: Boolean(unitPortraitTextureKey && this.textures.exists(unitPortraitTextureKey))
|
||||
};
|
||||
}),
|
||||
sortieHasBattle: Boolean(sortieScenario),
|
||||
nextSortieBattleId: sortieScenario?.id ?? null,
|
||||
nextSortieBattleTitle: sortieScenario?.title ?? null,
|
||||
@@ -16844,6 +16893,7 @@ export class CampScene extends Phaser.Scene {
|
||||
sortieItemAssignments: this.cloneSortieItemAssignments(this.sortieItemAssignments),
|
||||
sortieDeploymentPreview: this.sortieDeploymentPreviewDebug(),
|
||||
sortieFocusedUnitId: this.sortieFocusedUnitId,
|
||||
sortieHoveredUnitId: this.sortieHoveredUnitId ?? null,
|
||||
sortieFocusedUnit: this.sortieFocusedUnitSummary(),
|
||||
sortiePlanFeedback: this.sortiePlanFeedback,
|
||||
sortieChecklist,
|
||||
@@ -16860,6 +16910,8 @@ export class CampScene extends Phaser.Scene {
|
||||
const summary = this.sortieUnitTacticalSummary(unit);
|
||||
const reserveTrainingFocus = this.reserveTrainingFocusDefinitionForUnit(unit.id);
|
||||
const availability = this.sortieUnitAvailability(unit);
|
||||
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
|
||||
const unitPortraitTextureKey = portraitKey ? portraitCardTextureKey(portraitKey) ?? portraitTextureKey(portraitKey) : undefined;
|
||||
return {
|
||||
id: unit.id,
|
||||
name: unit.name,
|
||||
@@ -16868,6 +16920,8 @@ export class CampScene extends Phaser.Scene {
|
||||
selected: this.isSortieSelected(unit.id),
|
||||
recruited: !foundingSortieUnitIds.has(unit.id),
|
||||
required: this.isRequiredSortieUnit(unit.id),
|
||||
portraitTextureKey: unitPortraitTextureKey ?? null,
|
||||
portraitReady: Boolean(unitPortraitTextureKey && this.textures.exists(unitPortraitTextureKey)),
|
||||
className: unit.className,
|
||||
classRole: getUnitClass(unit.classKey).role,
|
||||
formationRole: this.sortieFormationRole(unit),
|
||||
|
||||
Reference in New Issue
Block a user