feat: surface campaign officer portraits

This commit is contained in:
2026-07-10 19:40:07 +09:00
parent fc34eca8cf
commit 5bc2852882
27 changed files with 388 additions and 38 deletions

View File

@@ -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}-`;

View File

@@ -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),