diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 7dc4ff7..df38566 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -390,6 +390,28 @@ try { await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie formation'); + const midFormationPortraitState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const missingMidFormationPortraits = midFormationPortraitState?.sortiePortraitRoster?.filter( + (unit) => !unit.portraitReady || !unit.portraitTextureKey?.startsWith('portrait-card-') + ) ?? []; + assert( + midFormationPortraitState?.sortiePortraitRoster?.length === 23 && missingMidFormationPortraits.length === 0, + `Expected all 23 campaign officers to use loaded lightweight portrait cards: ${JSON.stringify(missingMidFormationPortraits)}` + ); + + await page.mouse.move(530, 260); + await page.waitForFunction(() => Boolean(window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId), undefined, { timeout: 30000 }); + const hoveredPortraitUnitId = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId); + assert( + midFormationPortraitState.sortiePortraitRoster.some( + (unit) => unit.id === hoveredPortraitUnitId && unit.portraitReady && unit.portraitTextureKey?.startsWith('portrait-card-') + ), + `Expected portrait-card hover feedback for a mapped officer: ${JSON.stringify(hoveredPortraitUnitId)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation-hover.png`, fullPage: true }); + await page.mouse.move(40, 710); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId === null, undefined, { timeout: 30000 }); + const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); await page.mouse.click(658, 196); await page.waitForFunction((previousScroll) => { diff --git a/scripts/verify-story-image-asset-data.mjs b/scripts/verify-story-image-asset-data.mjs index 1677842..0fe82a3 100644 --- a/scripts/verify-story-image-asset-data.mjs +++ b/scripts/verify-story-image-asset-data.mjs @@ -4,11 +4,13 @@ import { createServer } from 'vite'; const storyDir = join('src', 'assets', 'images', 'story'); const portraitDir = join('src', 'assets', 'images', 'portraits'); +const portraitCardDir = join(portraitDir, 'cards'); const titleImagePath = join('src', 'assets', 'images', 'taoyuan-oath-title.png'); const storyAssetsSourcePath = join('src', 'game', 'data', 'storyAssets.ts'); const expectedStorySize = { width: 1672, height: 941 }; const expectedPortraitSize = { width: 1254, height: 1254 }; +const expectedPortraitCardSize = { width: 320, height: 320 }; const allowedPngColorTypes = new Map([ [2, 'RGB'], [6, 'RGBA'] @@ -22,7 +24,20 @@ const server = await createServer({ try { const { storyBackgroundAssets } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); - const { portraitAssets } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts'); + const { + campaignPortraitKeysByUnitId, + portraitAssets, + portraitCardAssetEntryForKey, + portraitCardAssets, + portraitCardTextureKey, + portraitTextureKey + } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts'); + const { campaignRecruitUnits, firstBattleUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts'); + const expectedCampaignPortraitUnitIds = new Set( + [...firstBattleUnits, ...campaignRecruitUnits] + .filter((unit) => unit.faction === 'ally') + .map((unit) => unit.id) + ); const errors = []; const storyAliasKeys = parseStoryAliasKeys(readFileSync(storyAssetsSourcePath, 'utf8'), errors); @@ -32,6 +47,17 @@ try { const portraitSlugs = validatePortraitImages(errors, portraitAssets); validatePortraitManifestKeys(errors, portraitAssets, portraitSlugs); + const portraitCardSlugs = validatePortraitCardImages(errors, portraitCardAssets, expectedCampaignPortraitUnitIds); + validatePortraitCardManifestKeys(errors, portraitCardAssets, portraitCardSlugs); + validateCampaignPortraitCoverage(errors, { + expectedCampaignPortraitUnitIds, + campaignPortraitKeysByUnitId, + portraitAssets, + portraitCardAssets, + portraitTextureKey, + portraitCardTextureKey, + portraitCardAssetEntryForKey + }); if (errors.length) { console.error(`Story image asset verification failed with ${errors.length} issue(s):`); @@ -43,6 +69,7 @@ try { } else { console.log( `Verified ${storySourceKeys.size} story backgrounds, ${portraitSlugs.size} portraits, ` + + `${portraitCardSlugs.size} portrait cards, ${expectedCampaignPortraitUnitIds.size} campaign portrait mappings, ` + `${storyAliasKeys.size} story aliases, and the title artwork dimensions.` ); } @@ -115,6 +142,111 @@ function validatePortraitManifestKeys(errors, portraitAssets, slugs) { }); } +function validatePortraitCardImages(errors, portraitCardAssets, expectedCampaignPortraitUnitIds) { + const slugs = new Set(); + const files = assetFiles(portraitCardDir, /\.webp$/i); + if (files.length !== expectedCampaignPortraitUnitIds.size) { + errors.push( + `${portraitCardDir}: expected ${expectedCampaignPortraitUnitIds.size} WebP portrait cards, found ${files.length}` + ); + } + + const unexpectedFiles = assetFiles(portraitCardDir, /./).filter((fileName) => !/\.webp$/i.test(fileName)); + unexpectedFiles.forEach((fileName) => { + errors.push(`${join(portraitCardDir, fileName)}: portrait card assets must use WebP`); + }); + + for (const fileName of files) { + const path = join(portraitCardDir, fileName); + validateWebpFile(errors, path, expectedPortraitCardSize, 'portrait card'); + + const slug = fileName.replace(/\.webp$/i, ''); + slugs.add(slug); + if (!expectedCampaignPortraitUnitIds.has(slug)) { + errors.push(`${path}: unexpected campaign portrait card slug "${slug}"`); + } + if (!portraitCardAssets[slug]) { + errors.push(`${path}: expected portraitCardAssets slug "${slug}"`); + } + validateRuntimeUrl(errors, slug, portraitCardAssets[slug], path); + } + + expectedCampaignPortraitUnitIds.forEach((unitId) => { + if (!slugs.has(unitId)) { + errors.push(`${portraitCardDir}: missing campaign portrait card "${unitId}.webp"`); + } + }); + + return slugs; +} + +function validatePortraitCardManifestKeys(errors, portraitCardAssets, slugs) { + Object.keys(portraitCardAssets) + .sort() + .forEach((slug) => { + if (!slugs.has(slug)) { + errors.push(`portraitCardAssets contains unexpected slug "${slug}"`); + } + }); +} + +function validateCampaignPortraitCoverage( + errors, + { + expectedCampaignPortraitUnitIds, + campaignPortraitKeysByUnitId, + portraitAssets, + portraitCardAssets, + portraitTextureKey, + portraitCardTextureKey, + portraitCardAssetEntryForKey + } +) { + const mappedUnitIds = Object.keys(campaignPortraitKeysByUnitId).sort(); + if (mappedUnitIds.length !== expectedCampaignPortraitUnitIds.size) { + errors.push( + `campaignPortraitKeysByUnitId should contain ${expectedCampaignPortraitUnitIds.size} units, found ${mappedUnitIds.length}` + ); + } + + mappedUnitIds.forEach((unitId) => { + if (!expectedCampaignPortraitUnitIds.has(unitId)) { + errors.push(`campaignPortraitKeysByUnitId contains unexpected unit "${unitId}"`); + } + }); + + expectedCampaignPortraitUnitIds.forEach((unitId) => { + const portraitKey = campaignPortraitKeysByUnitId[unitId]; + if (typeof portraitKey !== 'string' || portraitKey.length === 0) { + errors.push(`campaignPortraitKeysByUnitId is missing a portrait key for "${unitId}"`); + return; + } + if (!portraitAssets[unitId]) { + errors.push(`campaign unit "${unitId}" is missing its base portrait asset`); + } + if (!portraitCardAssets[unitId]) { + errors.push(`campaign unit "${unitId}" is missing its portrait card asset`); + } + + const expectedBaseTextureKey = `portrait-${unitId}`; + const expectedCardTextureKey = `portrait-card-${unitId}`; + if (portraitTextureKey(portraitKey) !== expectedBaseTextureKey) { + errors.push(`campaign unit "${unitId}" does not resolve base texture key "${expectedBaseTextureKey}"`); + } + if (portraitCardTextureKey(portraitKey) !== expectedCardTextureKey) { + errors.push(`campaign unit "${unitId}" does not resolve card texture key "${expectedCardTextureKey}"`); + } + + const cardEntry = portraitCardAssetEntryForKey(portraitKey); + if (cardEntry?.textureKey !== expectedCardTextureKey) { + errors.push(`campaign unit "${unitId}" does not expose card entry texture key "${expectedCardTextureKey}"`); + } + if (cardEntry?.url !== portraitCardAssets[unitId]) { + errors.push(`campaign unit "${unitId}" card entry does not expose its manifest URL`); + } + }); +} + function validatePngFile(errors, path, expectedSize, label) { let bytes; let stats; @@ -155,6 +287,41 @@ function validatePngFile(errors, path, expectedSize, label) { } } +function validateWebpFile(errors, path, expectedSize, label) { + let bytes; + let stats; + try { + bytes = readFileSync(path); + stats = statSync(path); + } catch (error) { + errors.push(`${path}: cannot read ${label}: ${error.message}`); + return; + } + + if (stats.size < 5 * 1024) { + errors.push(`${path}: ${label} is suspiciously small (${stats.size} bytes)`); + } + if ( + bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || + bytes.subarray(8, 12).toString('ascii') !== 'WEBP' + ) { + errors.push(`${path}: ${label} does not have a WebP RIFF header`); + return; + } + + const dimensions = readWebpDimensions(bytes); + if (!dimensions) { + errors.push(`${path}: ${label} does not expose readable WebP dimensions`); + return; + } + if (dimensions.width !== expectedSize.width || dimensions.height !== expectedSize.height) { + errors.push( + `${path}: ${label} should be ${expectedSize.width}x${expectedSize.height}, ` + + `got ${dimensions.width}x${dimensions.height}` + ); + } +} + function validateRuntimeUrl(errors, key, url, path) { assertNonEmptyString(errors, url, `${key} runtime asset URL`); if (typeof url !== 'string' || url.startsWith('data:')) { @@ -168,11 +335,15 @@ function validateRuntimeUrl(errors, key, url, path) { } function imageFiles(dir) { + return assetFiles(dir, /\.png$/i); +} + +function assetFiles(dir, pattern) { return readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name) .sort() - .filter((fileName) => /\.png$/i.test(fileName)); + .filter((fileName) => pattern.test(fileName)); } function storyKeyFromFileName(fileName) { @@ -195,6 +366,58 @@ function parseStoryAliasKeys(source, errors) { return aliases; } +function readWebpDimensions(bytes) { + let offset = 12; + while (offset + 8 <= bytes.length) { + const chunkType = bytes.subarray(offset, offset + 4).toString('ascii'); + const chunkSize = bytes.readUInt32LE(offset + 4); + const dataOffset = offset + 8; + if (dataOffset + chunkSize > bytes.length) { + return undefined; + } + + if (chunkType === 'VP8X' && chunkSize >= 10) { + return { + width: readUInt24LE(bytes, dataOffset + 4) + 1, + height: readUInt24LE(bytes, dataOffset + 7) + 1 + }; + } + + if (chunkType === 'VP8L' && chunkSize >= 5 && bytes[dataOffset] === 0x2f) { + const bits = + bytes[dataOffset + 1] | + (bytes[dataOffset + 2] << 8) | + (bytes[dataOffset + 3] << 16) | + (bytes[dataOffset + 4] << 24); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >>> 14) & 0x3fff) + 1 + }; + } + + if ( + chunkType === 'VP8 ' && + chunkSize >= 10 && + bytes[dataOffset + 3] === 0x9d && + bytes[dataOffset + 4] === 0x01 && + bytes[dataOffset + 5] === 0x2a + ) { + return { + width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff, + height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff + }; + } + + offset = dataOffset + chunkSize + (chunkSize % 2); + } + + return undefined; +} + +function readUInt24LE(bytes, offset) { + return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); +} + function assetUrlToPath(url) { const marker = 'src/assets/images/'; const index = url.indexOf(marker); diff --git a/src/assets/images/portraits/cards/fa-zheng.webp b/src/assets/images/portraits/cards/fa-zheng.webp new file mode 100644 index 0000000..3e21030 Binary files /dev/null and b/src/assets/images/portraits/cards/fa-zheng.webp differ diff --git a/src/assets/images/portraits/cards/gong-zhi.webp b/src/assets/images/portraits/cards/gong-zhi.webp new file mode 100644 index 0000000..0c3256c Binary files /dev/null and b/src/assets/images/portraits/cards/gong-zhi.webp differ diff --git a/src/assets/images/portraits/cards/guan-yu.webp b/src/assets/images/portraits/cards/guan-yu.webp new file mode 100644 index 0000000..90e7879 Binary files /dev/null and b/src/assets/images/portraits/cards/guan-yu.webp differ diff --git a/src/assets/images/portraits/cards/huang-quan.webp b/src/assets/images/portraits/cards/huang-quan.webp new file mode 100644 index 0000000..cf29fdc Binary files /dev/null and b/src/assets/images/portraits/cards/huang-quan.webp differ diff --git a/src/assets/images/portraits/cards/huang-zhong.webp b/src/assets/images/portraits/cards/huang-zhong.webp new file mode 100644 index 0000000..7449256 Binary files /dev/null and b/src/assets/images/portraits/cards/huang-zhong.webp differ diff --git a/src/assets/images/portraits/cards/jian-yong.webp b/src/assets/images/portraits/cards/jian-yong.webp new file mode 100644 index 0000000..c83b005 Binary files /dev/null and b/src/assets/images/portraits/cards/jian-yong.webp differ diff --git a/src/assets/images/portraits/cards/jiang-wei.webp b/src/assets/images/portraits/cards/jiang-wei.webp new file mode 100644 index 0000000..0d3e727 Binary files /dev/null and b/src/assets/images/portraits/cards/jiang-wei.webp differ diff --git a/src/assets/images/portraits/cards/li-yan.webp b/src/assets/images/portraits/cards/li-yan.webp new file mode 100644 index 0000000..77312ea Binary files /dev/null and b/src/assets/images/portraits/cards/li-yan.webp differ diff --git a/src/assets/images/portraits/cards/liu-bei.webp b/src/assets/images/portraits/cards/liu-bei.webp new file mode 100644 index 0000000..19a9fcc Binary files /dev/null and b/src/assets/images/portraits/cards/liu-bei.webp differ diff --git a/src/assets/images/portraits/cards/ma-chao.webp b/src/assets/images/portraits/cards/ma-chao.webp new file mode 100644 index 0000000..a2199cf Binary files /dev/null and b/src/assets/images/portraits/cards/ma-chao.webp differ diff --git a/src/assets/images/portraits/cards/ma-dai.webp b/src/assets/images/portraits/cards/ma-dai.webp new file mode 100644 index 0000000..f137a64 Binary files /dev/null and b/src/assets/images/portraits/cards/ma-dai.webp differ diff --git a/src/assets/images/portraits/cards/ma-liang.webp b/src/assets/images/portraits/cards/ma-liang.webp new file mode 100644 index 0000000..c82bdb4 Binary files /dev/null and b/src/assets/images/portraits/cards/ma-liang.webp differ diff --git a/src/assets/images/portraits/cards/mi-zhu.webp b/src/assets/images/portraits/cards/mi-zhu.webp new file mode 100644 index 0000000..09a8e5e Binary files /dev/null and b/src/assets/images/portraits/cards/mi-zhu.webp differ diff --git a/src/assets/images/portraits/cards/pang-tong.webp b/src/assets/images/portraits/cards/pang-tong.webp new file mode 100644 index 0000000..2899a07 Binary files /dev/null and b/src/assets/images/portraits/cards/pang-tong.webp differ diff --git a/src/assets/images/portraits/cards/sun-qian.webp b/src/assets/images/portraits/cards/sun-qian.webp new file mode 100644 index 0000000..56c0f83 Binary files /dev/null and b/src/assets/images/portraits/cards/sun-qian.webp differ diff --git a/src/assets/images/portraits/cards/wang-ping.webp b/src/assets/images/portraits/cards/wang-ping.webp new file mode 100644 index 0000000..52164a7 Binary files /dev/null and b/src/assets/images/portraits/cards/wang-ping.webp differ diff --git a/src/assets/images/portraits/cards/wei-yan.webp b/src/assets/images/portraits/cards/wei-yan.webp new file mode 100644 index 0000000..87783e5 Binary files /dev/null and b/src/assets/images/portraits/cards/wei-yan.webp differ diff --git a/src/assets/images/portraits/cards/wu-yi.webp b/src/assets/images/portraits/cards/wu-yi.webp new file mode 100644 index 0000000..c8ec741 Binary files /dev/null and b/src/assets/images/portraits/cards/wu-yi.webp differ diff --git a/src/assets/images/portraits/cards/yan-yan.webp b/src/assets/images/portraits/cards/yan-yan.webp new file mode 100644 index 0000000..e029e69 Binary files /dev/null and b/src/assets/images/portraits/cards/yan-yan.webp differ diff --git a/src/assets/images/portraits/cards/yi-ji.webp b/src/assets/images/portraits/cards/yi-ji.webp new file mode 100644 index 0000000..bacd3b5 Binary files /dev/null and b/src/assets/images/portraits/cards/yi-ji.webp differ diff --git a/src/assets/images/portraits/cards/zhang-fei.webp b/src/assets/images/portraits/cards/zhang-fei.webp new file mode 100644 index 0000000..d82aba9 Binary files /dev/null and b/src/assets/images/portraits/cards/zhang-fei.webp differ diff --git a/src/assets/images/portraits/cards/zhao-yun.webp b/src/assets/images/portraits/cards/zhao-yun.webp new file mode 100644 index 0000000..740454f Binary files /dev/null and b/src/assets/images/portraits/cards/zhao-yun.webp differ diff --git a/src/assets/images/portraits/cards/zhuge-liang.webp b/src/assets/images/portraits/cards/zhuge-liang.webp new file mode 100644 index 0000000..70a647d Binary files /dev/null and b/src/assets/images/portraits/cards/zhuge-liang.webp differ diff --git a/src/game/data/portraitAssets.ts b/src/game/data/portraitAssets.ts index eca70e7..45ef2c7 100644 --- a/src/game/data/portraitAssets.ts +++ b/src/game/data/portraitAssets.ts @@ -3,6 +3,11 @@ const portraitModules = import.meta.glob('../../assets/images/portraits/*.png', import: 'default' }) as Record; +const portraitCardModules = import.meta.glob('../../assets/images/portraits/cards/*.webp', { + eager: true, + import: 'default' +}) as Record; + const legacyPortraitSlugs: Record = { liuBei: 'liu-bei', guanYu: 'guan-yu', @@ -13,6 +18,32 @@ const legacyPortraitSlugs: Record = { simaYi: 'sima-yi' }; +export const campaignPortraitKeysByUnitId: Record = { + '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 = { 유비: 'liuBei', 관우: 'guanYu', @@ -45,7 +76,7 @@ const speakerPortraitKeys: Record = { 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 = Object.fromEntries( Object.entries(portraitModules).map(([path, url]) => [fileSlugFromPath(path), url]) ); +export const portraitCardAssets: Record = 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}-`; diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 17a5c8e..d23d393 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -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 = { - '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(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),