diff --git a/src/assets/images/portraits/huang-quan.png b/src/assets/images/portraits/huang-quan.png new file mode 100644 index 0000000..fd50ad1 Binary files /dev/null and b/src/assets/images/portraits/huang-quan.png differ diff --git a/src/assets/images/portraits/ma-liang.png b/src/assets/images/portraits/ma-liang.png new file mode 100644 index 0000000..677da01 Binary files /dev/null and b/src/assets/images/portraits/ma-liang.png differ diff --git a/src/assets/images/portraits/sun-qian.png b/src/assets/images/portraits/sun-qian.png new file mode 100644 index 0000000..00b1635 Binary files /dev/null and b/src/assets/images/portraits/sun-qian.png differ diff --git a/src/assets/images/portraits/wang-ping.png b/src/assets/images/portraits/wang-ping.png new file mode 100644 index 0000000..211a169 Binary files /dev/null and b/src/assets/images/portraits/wang-ping.png differ diff --git a/src/assets/images/story/33-yellow-pursuit-refugees.png b/src/assets/images/story/33-yellow-pursuit-refugees.png new file mode 100644 index 0000000..33043ce Binary files /dev/null and b/src/assets/images/story/33-yellow-pursuit-refugees.png differ diff --git a/src/assets/images/story/34-xuzhou-gates-handover.png b/src/assets/images/story/34-xuzhou-gates-handover.png new file mode 100644 index 0000000..bc3739e Binary files /dev/null and b/src/assets/images/story/34-xuzhou-gates-handover.png differ diff --git a/src/assets/images/story/35-wolong-night-strategy.png b/src/assets/images/story/35-wolong-night-strategy.png new file mode 100644 index 0000000..077f2d1 Binary files /dev/null and b/src/assets/images/story/35-wolong-night-strategy.png differ diff --git a/src/assets/images/story/36-red-cliffs-chain-fleet.png b/src/assets/images/story/36-red-cliffs-chain-fleet.png new file mode 100644 index 0000000..cff34ff Binary files /dev/null and b/src/assets/images/story/36-red-cliffs-chain-fleet.png differ diff --git a/src/assets/images/units/unit-rebel-ragged-actions.png b/src/assets/images/units/unit-rebel-ragged-actions.png new file mode 100644 index 0000000..3a529c5 Binary files /dev/null and b/src/assets/images/units/unit-rebel-ragged-actions.png differ diff --git a/src/assets/images/units/unit-rebel-ragged.png b/src/assets/images/units/unit-rebel-ragged.png new file mode 100644 index 0000000..73be787 Binary files /dev/null and b/src/assets/images/units/unit-rebel-ragged.png differ diff --git a/src/assets/images/units/unit-wei-infantry-veteran-actions.png b/src/assets/images/units/unit-wei-infantry-veteran-actions.png new file mode 100644 index 0000000..6bbcdc3 Binary files /dev/null and b/src/assets/images/units/unit-wei-infantry-veteran-actions.png differ diff --git a/src/assets/images/units/unit-wei-infantry-veteran.png b/src/assets/images/units/unit-wei-infantry-veteran.png new file mode 100644 index 0000000..cd9e3ec Binary files /dev/null and b/src/assets/images/units/unit-wei-infantry-veteran.png differ diff --git a/src/game/data/portraitAssets.ts b/src/game/data/portraitAssets.ts new file mode 100644 index 0000000..eeea1cd --- /dev/null +++ b/src/game/data/portraitAssets.ts @@ -0,0 +1,69 @@ +const portraitModules = import.meta.glob('../../assets/images/portraits/*.png', { + eager: true, + import: 'default' +}) as Record; + +const legacyPortraitSlugs: Record = { + liuBei: 'liu-bei', + guanYu: 'guan-yu', + zhangFei: 'zhang-fei', + zhugeLiang: 'zhuge-liang', + zhaoYun: 'zhao-yun', + jiangWei: 'jiang-wei', + simaYi: 'sima-yi' +}; + +const speakerPortraitKeys: Record = { + 유비: 'liuBei', + 관우: 'guanYu', + 장비: 'zhangFei', + 제갈량: 'zhugeLiang', + 조운: 'zhaoYun', + 강유: 'jiangWei', + 사마의: 'simaYi', + 미축: 'mi-zhu', + 간옹: 'jian-yong', + 손건: 'sun-qian', + 마량: 'ma-liang', + 방통: 'pang-tong', + 법정: 'fa-zheng', + 황충: 'huang-zhong', + 위연: 'wei-yan', + 마초: 'ma-chao', + 마대: 'ma-dai', + 왕평: 'wang-ping', + 이엄: 'li-yan', + 오의: 'wu-yi', + 이적: 'yi-ji', + 공지: 'gong-zhi', + 황권: 'huang-quan', + 맹획: 'meng-huo', + 주유: 'zhou-yu', + 조조: 'cao-cao' +}; + +function fileSlugFromPath(path: string) { + const fileName = path.split('/').pop() ?? path; + return fileName.replace(/\.png$/i, ''); +} + +function camelToKebab(value: string) { + return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +} + +export const portraitAssets: Record = Object.fromEntries( + Object.entries(portraitModules).map(([path, url]) => [fileSlugFromPath(path), url]) +); + +export function portraitSlugForKey(key: string) { + return legacyPortraitSlugs[key] ?? camelToKebab(key); +} + +export function portraitTextureKey(key: string) { + const slug = portraitSlugForKey(key); + return portraitAssets[slug] ? `portrait-${slug}` : undefined; +} + +export function portraitKeyForSpeaker(speaker?: string) { + return speaker ? speakerPortraitKeys[speaker] : undefined; +} diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 620cc79..57f36f7 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1,7 +1,7 @@ import type { TerrainType, UnitClassKey } from './battleRules'; import type { EquipmentSet } from './battleItems'; -export type PortraitKey = 'liuBei' | 'guanYu' | 'zhangFei' | 'zhugeLiang' | 'zhaoYun' | 'jiangWei' | 'simaYi'; +export type PortraitKey = string; export type StoryPage = { id: string; diff --git a/src/game/data/storyAssets.ts b/src/game/data/storyAssets.ts index 565a312..414d9df 100644 --- a/src/game/data/storyAssets.ts +++ b/src/game/data/storyAssets.ts @@ -1,68 +1,54 @@ -import storyChaosUrl from '../../assets/images/story/01-yellow-turban-chaos.png'; -import storyLiuBeiUrl from '../../assets/images/story/02-liu-bei-resolve.png'; -import storyThreeHeroesUrl from '../../assets/images/story/03-three-heroes-meet.png'; -import storyMilitiaUrl from '../../assets/images/story/04-volunteer-militia.png'; -import storySortieUrl from '../../assets/images/story/05-first-sortie.png'; -import storyYellowPursuitUrl from '../../assets/images/story/06-yellow-turban-pursuit.png'; -import storyAntiDongPassUrl from '../../assets/images/story/07-anti-dong-pass.png'; -import storyXuzhouHandoverUrl from '../../assets/images/story/08-xuzhou-handover.png'; -import storyWanderingRoadUrl from '../../assets/images/story/09-wandering-road.png'; -import storyWolongCottageUrl from '../../assets/images/story/10-wolong-cottage.png'; -import storyRedCliffsFireUrl from '../../assets/images/story/11-red-cliffs-fire.png'; -import storyYizhouMountainPassUrl from '../../assets/images/story/12-yizhou-mountain-pass.png'; -import storyNorthernExpeditionUrl from '../../assets/images/story/13-northern-expedition.png'; -import storyNanzhongPacificationUrl from '../../assets/images/story/14-nanzhong-pacification.png'; -import storyYilingBaidiAftermathUrl from '../../assets/images/story/15-yiling-baidi-aftermath.png'; -import storyQishanRenewedOffensiveUrl from '../../assets/images/story/16-qishan-renewed-offensive.png'; -import storyTianshuiFrontUrl from '../../assets/images/story/17-tianshui-front.png'; -import storyJietingCrisisUrl from '../../assets/images/story/18-jieting-crisis.png'; -import storyChencangSiegeUrl from '../../assets/images/story/19-chencang-siege.png'; -import storyWuduYinpingUrl from '../../assets/images/story/20-wudu-yinping.png'; -import storyHanzhongRainDefenseUrl from '../../assets/images/story/21-hanzhong-rain-defense.png'; -import storyLuchengPursuitUrl from '../../assets/images/story/22-lucheng-pursuit.png'; -import storyWeishuiCampsUrl from '../../assets/images/story/23-weishui-camps.png'; -import storyWeishuiNorthbankUrl from '../../assets/images/story/24-weishui-northbank.png'; -import storyYizhouLuoRoadUrl from '../../assets/images/story/25-yizhou-luo-road.png'; -import storyChengduSurrenderUrl from '../../assets/images/story/26-chengdu-surrender.png'; -import storyJingzhouCrisisUrl from '../../assets/images/story/27-jingzhou-crisis.png'; -import storyNanzhongCapturesUrl from '../../assets/images/story/28-nanzhong-seven-captures.png'; -import storyFanCastleFloodUrl from '../../assets/images/story/29-fan-castle-flood.png'; -import storyMaichengIsolationUrl from '../../assets/images/story/30-maicheng-isolation.png'; -import storyYilingFireAttackUrl from '../../assets/images/story/31-yiling-fire-attack.png'; -import storyBaidiEntrustmentUrl from '../../assets/images/story/32-baidi-entrustment.png'; +const storyModules = import.meta.glob('../../assets/images/story/*.png', { + eager: true, + import: 'default' +}) as Record; -export const storyBackgroundAssets: Record = { - 'story-chaos': storyChaosUrl, - 'story-liu-bei': storyLiuBeiUrl, - 'story-three-heroes': storyThreeHeroesUrl, - 'story-militia': storyMilitiaUrl, - 'story-sortie': storySortieUrl, - 'story-yellow-pursuit': storyYellowPursuitUrl, - 'story-anti-dong': storyAntiDongPassUrl, - 'story-xuzhou': storyXuzhouHandoverUrl, - 'story-wandering': storyWanderingRoadUrl, - 'story-wolong': storyWolongCottageUrl, - 'story-red-cliffs': storyRedCliffsFireUrl, - 'story-yizhou': storyYizhouMountainPassUrl, - 'story-northern': storyNorthernExpeditionUrl, - 'story-nanzhong': storyNanzhongPacificationUrl, - 'story-yiling-baidi': storyYilingBaidiAftermathUrl, - 'story-qishan-renewed': storyQishanRenewedOffensiveUrl, - 'story-tianshui-front': storyTianshuiFrontUrl, - 'story-jieting-crisis': storyJietingCrisisUrl, - 'story-chencang-siege': storyChencangSiegeUrl, - 'story-wudu-yinping': storyWuduYinpingUrl, - 'story-hanzhong-rain': storyHanzhongRainDefenseUrl, - 'story-lucheng-pursuit': storyLuchengPursuitUrl, - 'story-weishui-camps': storyWeishuiCampsUrl, - 'story-weishui-northbank': storyWeishuiNorthbankUrl, - 'story-yizhou-luo-road': storyYizhouLuoRoadUrl, - 'story-chengdu-surrender': storyChengduSurrenderUrl, - 'story-jingzhou-crisis': storyJingzhouCrisisUrl, - 'story-nanzhong-captures': storyNanzhongCapturesUrl, - 'story-fan-castle-flood': storyFanCastleFloodUrl, - 'story-maicheng-isolation': storyMaichengIsolationUrl, - 'story-yiling-fire-attack': storyYilingFireAttackUrl, - 'story-baidi-entrustment': storyBaidiEntrustmentUrl, - 'story-resolve': storyLiuBeiUrl +const storyAssetAliases: Record = { + 'story-chaos': 'story-yellow-turban-chaos', + 'story-liu-bei': 'story-liu-bei-resolve', + 'story-three-heroes': 'story-three-heroes-meet', + 'story-militia': 'story-volunteer-militia', + 'story-sortie': 'story-first-sortie', + 'story-yellow-pursuit': 'story-yellow-turban-pursuit', + 'story-anti-dong': 'story-anti-dong-pass', + 'story-xuzhou': 'story-xuzhou-handover', + 'story-wandering': 'story-wandering-road', + 'story-wolong': 'story-wolong-cottage', + 'story-red-cliffs': 'story-red-cliffs-fire', + 'story-yizhou': 'story-yizhou-mountain-pass', + 'story-northern': 'story-northern-expedition', + 'story-nanzhong': 'story-nanzhong-pacification', + 'story-yiling-baidi': 'story-yiling-baidi-aftermath', + 'story-qishan-renewed': 'story-qishan-renewed-offensive', + 'story-hanzhong-rain': 'story-hanzhong-rain-defense', + 'story-nanzhong-captures': 'story-nanzhong-seven-captures', + 'story-resolve': 'story-liu-bei-resolve' }; + +function storyKeyFromPath(path: string) { + const fileName = path.split('/').pop() ?? path; + const slug = fileName.replace(/\.png$/i, '').replace(/^\d+-/, ''); + return `story-${slug}`; +} + +const discoveredStoryAssets = Object.fromEntries( + Object.entries(storyModules).map(([path, url]) => [storyKeyFromPath(path), url]) +); + +export const storyBackgroundAssets: Record = { ...discoveredStoryAssets }; + +Object.entries(storyAssetAliases).forEach(([alias, target]) => { + const targetUrl = storyBackgroundAssets[target]; + if (targetUrl) { + storyBackgroundAssets[alias] = targetUrl; + } +}); + +export function storyBackgroundKeysFor(baseKey: string) { + const prefix = `${baseKey}-`; + const keys = Object.keys(storyBackgroundAssets) + .filter((key) => key === baseKey || key.startsWith(prefix)) + .sort(); + + return Array.from(new Set(keys)); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 082f527..f397455 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -8266,7 +8266,7 @@ export class BattleScene extends Phaser.Scene { unit.id.startsWith('northern-second-cavalry') || unit.id.startsWith('northern-third-cavalry') ) { - return 'unit-rebel-cavalry'; + return this.resolveUnitTextureVariant('unit-rebel-cavalry', unit.id); } if ( unit.id.startsWith('northern-first-archer') || @@ -8276,7 +8276,7 @@ export class BattleScene extends Phaser.Scene { unit.id.startsWith('northern-second-strategist') || unit.id.startsWith('northern-third-strategist') ) { - return 'unit-rebel-archer'; + return this.resolveUnitTextureVariant('unit-rebel-archer', unit.id); } if ( unit.id.startsWith('northern-first-officer') || @@ -8286,9 +8286,9 @@ export class BattleScene extends Phaser.Scene { unit.id === 'northern-second-leader-guo-huai' || unit.id === 'northern-third-leader-zhang-he' ) { - return 'unit-rebel-leader'; + return this.resolveUnitTextureVariant('unit-rebel-leader', unit.id); } - return unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel'; + return this.resolveUnitTextureVariant(unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel', unit.id); } private signatureUnitTextureKey(unit: UnitData) { @@ -8306,65 +8306,90 @@ export class BattleScene extends Phaser.Scene { if (coreAllyTextureIds.has(unit.id)) { return undefined; } - return this.unitTextureForClass('shu', unit.classKey); + return this.unitTextureForClass('shu', unit.classKey, unit.id); } if (earlyRebelTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { return undefined; } if (nanmanTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { - return this.unitTextureForClass('nanman', unit.classKey); + return this.unitTextureForClass('nanman', unit.classKey, unit.id); } if (wuTexturePrefixes.some((prefix) => unit.id.startsWith(prefix))) { - return this.unitTextureForClass('wu', unit.classKey); + return this.unitTextureForClass('wu', unit.classKey, unit.id); } - return this.unitTextureForClass('wei', unit.classKey); + return this.unitTextureForClass('wei', unit.classKey, unit.id); } - private unitTextureForClass(style: 'shu' | 'wei' | 'wu' | 'nanman', classKey: UnitClassKey) { + private unitTextureForClass(style: 'shu' | 'wei' | 'wu' | 'nanman', classKey: UnitClassKey, seed: string) { + let baseKey: string; + if (style === 'shu') { if (classKey === 'spearman') { - return 'unit-shu-spearman'; + baseKey = 'unit-shu-spearman'; + } else if (classKey === 'archer') { + baseKey = 'unit-shu-archer'; + } else if (classKey === 'cavalry') { + baseKey = 'unit-shu-cavalry'; + } else if (classKey === 'strategist' || classKey === 'quartermaster') { + baseKey = 'unit-shu-strategist'; + } else if (classKey === 'rebelLeader') { + baseKey = 'unit-shu-officer'; + } else { + baseKey = 'unit-shu-infantry'; } - if (classKey === 'archer') { - return 'unit-shu-archer'; - } - if (classKey === 'cavalry') { - return 'unit-shu-cavalry'; - } - if (classKey === 'strategist' || classKey === 'quartermaster') { - return 'unit-shu-strategist'; - } - if (classKey === 'rebelLeader') { - return 'unit-shu-officer'; - } - return 'unit-shu-infantry'; - } - - if (style === 'nanman') { + } else if (style === 'nanman') { if (classKey === 'archer' || classKey === 'strategist' || classKey === 'quartermaster') { - return 'unit-nanman-shaman'; + baseKey = 'unit-nanman-shaman'; + } else if (classKey === 'rebelLeader' || classKey === 'cavalry') { + baseKey = 'unit-nanman-officer'; + } else { + baseKey = 'unit-nanman-infantry'; } - if (classKey === 'rebelLeader' || classKey === 'cavalry') { - return 'unit-nanman-officer'; - } - return 'unit-nanman-infantry'; + } else if (classKey === 'archer') { + baseKey = `unit-${style}-archer`; + } else if (classKey === 'cavalry') { + baseKey = `unit-${style}-cavalry`; + } else if (classKey === 'strategist' || classKey === 'quartermaster') { + baseKey = `unit-${style}-strategist`; + } else if (classKey === 'rebelLeader') { + baseKey = `unit-${style}-officer`; + } else { + baseKey = `unit-${style}-infantry`; } - if (classKey === 'archer') { - return `unit-${style}-archer`; + return this.resolveUnitTextureVariant(baseKey, seed); + } + + private resolveUnitTextureVariant(baseKey: string, seed: string) { + const variantKeys = this.unitTextureVariantKeys(baseKey); + if (variantKeys.length <= 1) { + return baseKey; } - if (classKey === 'cavalry') { - return `unit-${style}-cavalry`; + + return variantKeys[this.hashString(seed) % variantKeys.length]; + } + + private unitTextureVariantKeys(baseKey: string) { + const prefix = `${baseKey}-`; + return this.textures + .getTextureKeys() + .filter((key) => { + if (key === baseKey) { + return true; + } + return key.startsWith(prefix) && !key.endsWith('-actions') && this.textures.exists(`${key}-actions`); + }) + .sort(); + } + + private hashString(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; } - if (classKey === 'strategist' || classKey === 'quartermaster') { - return `unit-${style}-strategist`; - } - if (classKey === 'rebelLeader') { - return `unit-${style}-officer`; - } - return `unit-${style}-infantry`; + return hash; } private unitActionTexture(unit: UnitData) { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index cc7ca7f..03d96f9 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -1,108 +1,6 @@ import Phaser from 'phaser'; -import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png'; -import jiangWeiPortraitUrl from '../../assets/images/portraits/jiang-wei.png'; -import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png'; -import simaYiPortraitUrl from '../../assets/images/portraits/sima-yi.png'; -import zhaoYunPortraitUrl from '../../assets/images/portraits/zhao-yun.png'; -import zhangFeiPortraitUrl from '../../assets/images/portraits/zhang-fei.png'; -import zhugeLiangPortraitUrl from '../../assets/images/portraits/zhuge-liang.png'; import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png'; -import caoCaoActionSheetUrl from '../../assets/images/units/unit-cao-cao-actions.png'; -import caoCaoUnitSheetUrl from '../../assets/images/units/unit-cao-cao.png'; -import faZhengActionSheetUrl from '../../assets/images/units/unit-fa-zheng-actions.png'; -import faZhengUnitSheetUrl from '../../assets/images/units/unit-fa-zheng.png'; -import gongZhiActionSheetUrl from '../../assets/images/units/unit-gong-zhi-actions.png'; -import gongZhiUnitSheetUrl from '../../assets/images/units/unit-gong-zhi.png'; -import guanYuActionSheetUrl from '../../assets/images/units/unit-guan-yu-actions.png'; -import guanYuUnitSheetUrl from '../../assets/images/units/unit-guan-yu.png'; -import huangQuanActionSheetUrl from '../../assets/images/units/unit-huang-quan-actions.png'; -import huangQuanUnitSheetUrl from '../../assets/images/units/unit-huang-quan.png'; -import huangZhongActionSheetUrl from '../../assets/images/units/unit-huang-zhong-actions.png'; -import huangZhongUnitSheetUrl from '../../assets/images/units/unit-huang-zhong.png'; -import jiangWeiActionSheetUrl from '../../assets/images/units/unit-jiang-wei-actions.png'; -import jiangWeiUnitSheetUrl from '../../assets/images/units/unit-jiang-wei.png'; -import liYanActionSheetUrl from '../../assets/images/units/unit-li-yan-actions.png'; -import liYanUnitSheetUrl from '../../assets/images/units/unit-li-yan.png'; -import liuBeiActionSheetUrl from '../../assets/images/units/unit-liu-bei-actions.png'; -import liuBeiUnitSheetUrl from '../../assets/images/units/unit-liu-bei.png'; -import luBuActionSheetUrl from '../../assets/images/units/unit-lu-bu-actions.png'; -import luBuUnitSheetUrl from '../../assets/images/units/unit-lu-bu.png'; -import luMengActionSheetUrl from '../../assets/images/units/unit-lu-meng-actions.png'; -import luMengUnitSheetUrl from '../../assets/images/units/unit-lu-meng.png'; -import maChaoActionSheetUrl from '../../assets/images/units/unit-ma-chao-actions.png'; -import maChaoUnitSheetUrl from '../../assets/images/units/unit-ma-chao.png'; -import maDaiActionSheetUrl from '../../assets/images/units/unit-ma-dai-actions.png'; -import maDaiUnitSheetUrl from '../../assets/images/units/unit-ma-dai.png'; -import maLiangActionSheetUrl from '../../assets/images/units/unit-ma-liang-actions.png'; -import maLiangUnitSheetUrl from '../../assets/images/units/unit-ma-liang.png'; -import mengHuoActionSheetUrl from '../../assets/images/units/unit-meng-huo-actions.png'; -import mengHuoUnitSheetUrl from '../../assets/images/units/unit-meng-huo.png'; -import pangTongActionSheetUrl from '../../assets/images/units/unit-pang-tong-actions.png'; -import pangTongUnitSheetUrl from '../../assets/images/units/unit-pang-tong.png'; -import rebelArcherActionSheetUrl from '../../assets/images/units/unit-rebel-archer-actions.png'; -import rebelArcherUnitSheetUrl from '../../assets/images/units/unit-rebel-archer.png'; -import rebelCavalryActionSheetUrl from '../../assets/images/units/unit-rebel-cavalry-actions.png'; -import rebelCavalryUnitSheetUrl from '../../assets/images/units/unit-rebel-cavalry.png'; -import rebelLeaderActionSheetUrl from '../../assets/images/units/unit-rebel-leader-actions.png'; -import rebelLeaderUnitSheetUrl from '../../assets/images/units/unit-rebel-leader.png'; -import rebelActionSheetUrl from '../../assets/images/units/unit-rebel-actions.png'; -import rebelUnitSheetUrl from '../../assets/images/units/unit-rebel.png'; -import nanmanInfantryActionSheetUrl from '../../assets/images/units/unit-nanman-infantry-actions.png'; -import nanmanInfantryUnitSheetUrl from '../../assets/images/units/unit-nanman-infantry.png'; -import nanmanOfficerActionSheetUrl from '../../assets/images/units/unit-nanman-officer-actions.png'; -import nanmanOfficerUnitSheetUrl from '../../assets/images/units/unit-nanman-officer.png'; -import nanmanShamanActionSheetUrl from '../../assets/images/units/unit-nanman-shaman-actions.png'; -import nanmanShamanUnitSheetUrl from '../../assets/images/units/unit-nanman-shaman.png'; -import shuArcherActionSheetUrl from '../../assets/images/units/unit-shu-archer-actions.png'; -import shuArcherUnitSheetUrl from '../../assets/images/units/unit-shu-archer.png'; -import shuCavalryActionSheetUrl from '../../assets/images/units/unit-shu-cavalry-actions.png'; -import shuCavalryUnitSheetUrl from '../../assets/images/units/unit-shu-cavalry.png'; -import shuInfantryActionSheetUrl from '../../assets/images/units/unit-shu-infantry-actions.png'; -import shuInfantryUnitSheetUrl from '../../assets/images/units/unit-shu-infantry.png'; -import shuOfficerActionSheetUrl from '../../assets/images/units/unit-shu-officer-actions.png'; -import shuOfficerUnitSheetUrl from '../../assets/images/units/unit-shu-officer.png'; -import shuSpearmanActionSheetUrl from '../../assets/images/units/unit-shu-spearman-actions.png'; -import shuSpearmanUnitSheetUrl from '../../assets/images/units/unit-shu-spearman.png'; -import shuStrategistActionSheetUrl from '../../assets/images/units/unit-shu-strategist-actions.png'; -import shuStrategistUnitSheetUrl from '../../assets/images/units/unit-shu-strategist.png'; -import simaYiActionSheetUrl from '../../assets/images/units/unit-sima-yi-actions.png'; -import simaYiUnitSheetUrl from '../../assets/images/units/unit-sima-yi.png'; -import wangPingActionSheetUrl from '../../assets/images/units/unit-wang-ping-actions.png'; -import wangPingUnitSheetUrl from '../../assets/images/units/unit-wang-ping.png'; -import weiArcherActionSheetUrl from '../../assets/images/units/unit-wei-archer-actions.png'; -import weiArcherUnitSheetUrl from '../../assets/images/units/unit-wei-archer.png'; -import weiCavalryActionSheetUrl from '../../assets/images/units/unit-wei-cavalry-actions.png'; -import weiCavalryUnitSheetUrl from '../../assets/images/units/unit-wei-cavalry.png'; -import weiInfantryActionSheetUrl from '../../assets/images/units/unit-wei-infantry-actions.png'; -import weiInfantryUnitSheetUrl from '../../assets/images/units/unit-wei-infantry.png'; -import weiOfficerActionSheetUrl from '../../assets/images/units/unit-wei-officer-actions.png'; -import weiOfficerUnitSheetUrl from '../../assets/images/units/unit-wei-officer.png'; -import weiStrategistActionSheetUrl from '../../assets/images/units/unit-wei-strategist-actions.png'; -import weiStrategistUnitSheetUrl from '../../assets/images/units/unit-wei-strategist.png'; -import weiYanActionSheetUrl from '../../assets/images/units/unit-wei-yan-actions.png'; -import weiYanUnitSheetUrl from '../../assets/images/units/unit-wei-yan.png'; -import wuArcherActionSheetUrl from '../../assets/images/units/unit-wu-archer-actions.png'; -import wuArcherUnitSheetUrl from '../../assets/images/units/unit-wu-archer.png'; -import wuCavalryActionSheetUrl from '../../assets/images/units/unit-wu-cavalry-actions.png'; -import wuCavalryUnitSheetUrl from '../../assets/images/units/unit-wu-cavalry.png'; -import wuInfantryActionSheetUrl from '../../assets/images/units/unit-wu-infantry-actions.png'; -import wuInfantryUnitSheetUrl from '../../assets/images/units/unit-wu-infantry.png'; -import wuOfficerActionSheetUrl from '../../assets/images/units/unit-wu-officer-actions.png'; -import wuOfficerUnitSheetUrl from '../../assets/images/units/unit-wu-officer.png'; -import wuStrategistActionSheetUrl from '../../assets/images/units/unit-wu-strategist-actions.png'; -import wuStrategistUnitSheetUrl from '../../assets/images/units/unit-wu-strategist.png'; -import wuYiActionSheetUrl from '../../assets/images/units/unit-wu-yi-actions.png'; -import wuYiUnitSheetUrl from '../../assets/images/units/unit-wu-yi.png'; -import yanYanActionSheetUrl from '../../assets/images/units/unit-yan-yan-actions.png'; -import yanYanUnitSheetUrl from '../../assets/images/units/unit-yan-yan.png'; -import yiJiActionSheetUrl from '../../assets/images/units/unit-yi-ji-actions.png'; -import yiJiUnitSheetUrl from '../../assets/images/units/unit-yi-ji.png'; -import zhangFeiActionSheetUrl from '../../assets/images/units/unit-zhang-fei-actions.png'; -import zhangFeiUnitSheetUrl from '../../assets/images/units/unit-zhang-fei.png'; -import zhaoYunActionSheetUrl from '../../assets/images/units/unit-zhao-yun-actions.png'; -import zhaoYunUnitSheetUrl from '../../assets/images/units/unit-zhao-yun.png'; -import zhugeLiangActionSheetUrl from '../../assets/images/units/unit-zhuge-liang-actions.png'; -import zhugeLiangUnitSheetUrl from '../../assets/images/units/unit-zhuge-liang.png'; +import { portraitAssets } from '../data/portraitAssets'; import { storyBackgroundAssets } from '../data/storyAssets'; type UnitDirection = 'south' | 'east' | 'north' | 'west'; @@ -118,56 +16,37 @@ const unitSheetRows: Record = { west: 3 }; -const sourceUnitSheets = [ - { key: 'unit-liu-bei', url: liuBeiUnitSheetUrl, actionKey: 'unit-liu-bei-actions', actionUrl: liuBeiActionSheetUrl }, - { key: 'unit-guan-yu', url: guanYuUnitSheetUrl, actionKey: 'unit-guan-yu-actions', actionUrl: guanYuActionSheetUrl }, - { key: 'unit-zhang-fei', url: zhangFeiUnitSheetUrl, actionKey: 'unit-zhang-fei-actions', actionUrl: zhangFeiActionSheetUrl }, - { key: 'unit-rebel', url: rebelUnitSheetUrl, actionKey: 'unit-rebel-actions', actionUrl: rebelActionSheetUrl }, - { key: 'unit-rebel-archer', url: rebelArcherUnitSheetUrl, actionKey: 'unit-rebel-archer-actions', actionUrl: rebelArcherActionSheetUrl }, - { key: 'unit-rebel-cavalry', url: rebelCavalryUnitSheetUrl, actionKey: 'unit-rebel-cavalry-actions', actionUrl: rebelCavalryActionSheetUrl }, - { key: 'unit-rebel-leader', url: rebelLeaderUnitSheetUrl, actionKey: 'unit-rebel-leader-actions', actionUrl: rebelLeaderActionSheetUrl }, - { key: 'unit-lu-bu', url: luBuUnitSheetUrl, actionKey: 'unit-lu-bu-actions', actionUrl: luBuActionSheetUrl }, - { key: 'unit-cao-cao', url: caoCaoUnitSheetUrl, actionKey: 'unit-cao-cao-actions', actionUrl: caoCaoActionSheetUrl }, - { key: 'unit-lu-meng', url: luMengUnitSheetUrl, actionKey: 'unit-lu-meng-actions', actionUrl: luMengActionSheetUrl }, - { key: 'unit-meng-huo', url: mengHuoUnitSheetUrl, actionKey: 'unit-meng-huo-actions', actionUrl: mengHuoActionSheetUrl }, - { key: 'unit-zhao-yun', url: zhaoYunUnitSheetUrl, actionKey: 'unit-zhao-yun-actions', actionUrl: zhaoYunActionSheetUrl }, - { key: 'unit-zhuge-liang', url: zhugeLiangUnitSheetUrl, actionKey: 'unit-zhuge-liang-actions', actionUrl: zhugeLiangActionSheetUrl }, - { key: 'unit-jiang-wei', url: jiangWeiUnitSheetUrl, actionKey: 'unit-jiang-wei-actions', actionUrl: jiangWeiActionSheetUrl }, - { key: 'unit-sima-yi', url: simaYiUnitSheetUrl, actionKey: 'unit-sima-yi-actions', actionUrl: simaYiActionSheetUrl }, - { key: 'unit-ma-liang', url: maLiangUnitSheetUrl, actionKey: 'unit-ma-liang-actions', actionUrl: maLiangActionSheetUrl }, - { key: 'unit-yi-ji', url: yiJiUnitSheetUrl, actionKey: 'unit-yi-ji-actions', actionUrl: yiJiActionSheetUrl }, - { key: 'unit-gong-zhi', url: gongZhiUnitSheetUrl, actionKey: 'unit-gong-zhi-actions', actionUrl: gongZhiActionSheetUrl }, - { key: 'unit-huang-zhong', url: huangZhongUnitSheetUrl, actionKey: 'unit-huang-zhong-actions', actionUrl: huangZhongActionSheetUrl }, - { key: 'unit-wei-yan', url: weiYanUnitSheetUrl, actionKey: 'unit-wei-yan-actions', actionUrl: weiYanActionSheetUrl }, - { key: 'unit-pang-tong', url: pangTongUnitSheetUrl, actionKey: 'unit-pang-tong-actions', actionUrl: pangTongActionSheetUrl }, - { key: 'unit-fa-zheng', url: faZhengUnitSheetUrl, actionKey: 'unit-fa-zheng-actions', actionUrl: faZhengActionSheetUrl }, - { key: 'unit-wu-yi', url: wuYiUnitSheetUrl, actionKey: 'unit-wu-yi-actions', actionUrl: wuYiActionSheetUrl }, - { key: 'unit-yan-yan', url: yanYanUnitSheetUrl, actionKey: 'unit-yan-yan-actions', actionUrl: yanYanActionSheetUrl }, - { key: 'unit-li-yan', url: liYanUnitSheetUrl, actionKey: 'unit-li-yan-actions', actionUrl: liYanActionSheetUrl }, - { key: 'unit-huang-quan', url: huangQuanUnitSheetUrl, actionKey: 'unit-huang-quan-actions', actionUrl: huangQuanActionSheetUrl }, - { key: 'unit-ma-chao', url: maChaoUnitSheetUrl, actionKey: 'unit-ma-chao-actions', actionUrl: maChaoActionSheetUrl }, - { key: 'unit-ma-dai', url: maDaiUnitSheetUrl, actionKey: 'unit-ma-dai-actions', actionUrl: maDaiActionSheetUrl }, - { key: 'unit-wang-ping', url: wangPingUnitSheetUrl, actionKey: 'unit-wang-ping-actions', actionUrl: wangPingActionSheetUrl }, - { key: 'unit-shu-infantry', url: shuInfantryUnitSheetUrl, actionKey: 'unit-shu-infantry-actions', actionUrl: shuInfantryActionSheetUrl }, - { key: 'unit-shu-spearman', url: shuSpearmanUnitSheetUrl, actionKey: 'unit-shu-spearman-actions', actionUrl: shuSpearmanActionSheetUrl }, - { key: 'unit-shu-archer', url: shuArcherUnitSheetUrl, actionKey: 'unit-shu-archer-actions', actionUrl: shuArcherActionSheetUrl }, - { key: 'unit-shu-cavalry', url: shuCavalryUnitSheetUrl, actionKey: 'unit-shu-cavalry-actions', actionUrl: shuCavalryActionSheetUrl }, - { key: 'unit-shu-strategist', url: shuStrategistUnitSheetUrl, actionKey: 'unit-shu-strategist-actions', actionUrl: shuStrategistActionSheetUrl }, - { key: 'unit-shu-officer', url: shuOfficerUnitSheetUrl, actionKey: 'unit-shu-officer-actions', actionUrl: shuOfficerActionSheetUrl }, - { key: 'unit-wei-infantry', url: weiInfantryUnitSheetUrl, actionKey: 'unit-wei-infantry-actions', actionUrl: weiInfantryActionSheetUrl }, - { key: 'unit-wei-archer', url: weiArcherUnitSheetUrl, actionKey: 'unit-wei-archer-actions', actionUrl: weiArcherActionSheetUrl }, - { key: 'unit-wei-cavalry', url: weiCavalryUnitSheetUrl, actionKey: 'unit-wei-cavalry-actions', actionUrl: weiCavalryActionSheetUrl }, - { key: 'unit-wei-strategist', url: weiStrategistUnitSheetUrl, actionKey: 'unit-wei-strategist-actions', actionUrl: weiStrategistActionSheetUrl }, - { key: 'unit-wei-officer', url: weiOfficerUnitSheetUrl, actionKey: 'unit-wei-officer-actions', actionUrl: weiOfficerActionSheetUrl }, - { key: 'unit-wu-infantry', url: wuInfantryUnitSheetUrl, actionKey: 'unit-wu-infantry-actions', actionUrl: wuInfantryActionSheetUrl }, - { key: 'unit-wu-archer', url: wuArcherUnitSheetUrl, actionKey: 'unit-wu-archer-actions', actionUrl: wuArcherActionSheetUrl }, - { key: 'unit-wu-cavalry', url: wuCavalryUnitSheetUrl, actionKey: 'unit-wu-cavalry-actions', actionUrl: wuCavalryActionSheetUrl }, - { key: 'unit-wu-strategist', url: wuStrategistUnitSheetUrl, actionKey: 'unit-wu-strategist-actions', actionUrl: wuStrategistActionSheetUrl }, - { key: 'unit-wu-officer', url: wuOfficerUnitSheetUrl, actionKey: 'unit-wu-officer-actions', actionUrl: wuOfficerActionSheetUrl }, - { key: 'unit-nanman-infantry', url: nanmanInfantryUnitSheetUrl, actionKey: 'unit-nanman-infantry-actions', actionUrl: nanmanInfantryActionSheetUrl }, - { key: 'unit-nanman-shaman', url: nanmanShamanUnitSheetUrl, actionKey: 'unit-nanman-shaman-actions', actionUrl: nanmanShamanActionSheetUrl }, - { key: 'unit-nanman-officer', url: nanmanOfficerUnitSheetUrl, actionKey: 'unit-nanman-officer-actions', actionUrl: nanmanOfficerActionSheetUrl } -]; +type UnitSheetAsset = { + key: string; + url: string; + actionKey: string; + actionUrl: string; +}; + +const unitSheetModules = import.meta.glob('../../assets/images/units/unit-*.png', { + eager: true, + import: 'default' +}) as Record; + +function textureKeyFromPath(path: string) { + const fileName = path.split('/').pop() ?? path; + return fileName.replace(/\.png$/i, ''); +} + +const unitSheetUrls = Object.fromEntries( + Object.entries(unitSheetModules).map(([path, url]) => [textureKeyFromPath(path), url]) +); + +const sourceUnitSheets: UnitSheetAsset[] = Object.entries(unitSheetUrls) + .filter(([key]) => !key.endsWith('-actions')) + .map(([key, url]) => ({ + key, + url, + actionKey: `${key}-actions`, + actionUrl: unitSheetUrls[`${key}-actions`] + })) + .filter((sheet): sheet is UnitSheetAsset => Boolean(sheet.actionUrl)) + .sort((left, right) => left.key.localeCompare(right.key)); const animatedUnitSheets = sourceUnitSheets.map(({ key }) => ({ key })); @@ -179,13 +58,7 @@ export class BootScene extends Phaser.Scene { preload() { this.load.image('title-taoyuan', titleBackgroundUrl); this.load.image('story-militia', storyBackgroundAssets['story-militia']); - this.load.image('portrait-liu-bei', liuBeiPortraitUrl); - this.load.image('portrait-guan-yu', guanYuPortraitUrl); - this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl); - this.load.image('portrait-zhuge-liang', zhugeLiangPortraitUrl); - this.load.image('portrait-zhao-yun', zhaoYunPortraitUrl); - this.load.image('portrait-jiang-wei', jiangWeiPortraitUrl); - this.load.image('portrait-sima-yi', simaYiPortraitUrl); + Object.entries(portraitAssets).forEach(([key, url]) => this.load.image(`portrait-${key}`, url)); sourceUnitSheets.forEach(({ key, url, actionKey, actionUrl }) => { this.load.spritesheet(key, url, { diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 80b85d6..dbadea0 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -1,29 +1,10 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; -import { storyBackgroundAssets } from '../data/storyAssets'; +import { portraitKeyForSpeaker, portraitTextureKey } from '../data/portraitAssets'; +import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets'; import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario'; import { palette } from '../ui/palette'; -const portraitKeys: Record = { - liuBei: 'portrait-liu-bei', - guanYu: 'portrait-guan-yu', - zhangFei: 'portrait-zhang-fei', - zhugeLiang: 'portrait-zhuge-liang', - zhaoYun: 'portrait-zhao-yun', - jiangWei: 'portrait-jiang-wei', - simaYi: 'portrait-sima-yi' -}; - -const portraitBySpeaker: Record = { - 유비: 'liuBei', - 관우: 'guanYu', - 장비: 'zhangFei', - 제갈량: 'zhugeLiang', - 조운: 'zhaoYun', - 강유: 'jiangWei', - 사마의: 'simaYi' -}; - type AlphaObject = Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text; type StorySceneData = { @@ -69,8 +50,9 @@ export class StoryScene extends Phaser.Scene { pageIndex: this.pageIndex, totalPages: this.pages.length, currentPageId: page?.id, - background: page?.background, - backgroundReady: page ? this.textures.exists(page.background) : false, + requestedBackground: page?.background, + background: page ? this.pageBackgroundKey(page) : undefined, + backgroundReady: page ? this.textures.exists(this.pageBackgroundKey(page)) : false, activeTexture: this.background?.texture.key ?? null, portraitKey: page ? this.pagePortraitKey(page) ?? null : null, portraitTextureKey: this.portrait?.visible ? this.portrait.texture.key : null @@ -93,7 +75,7 @@ export class StoryScene extends Phaser.Scene { this.ensureStoryBackgroundsLoaded(() => { this.loadingText?.destroy(); this.loadingText = undefined; - this.background = this.add.image(width / 2, height / 2, this.resolveBackgroundKey(this.pages[0].background)); + this.background = this.add.image(width / 2, height / 2, this.resolveBackgroundKey(this.pageBackgroundKey(this.pages[0]))); this.drawSceneShade(width, height); this.drawDialogPanel(width, height); this.showPage(0, true); @@ -105,7 +87,8 @@ export class StoryScene extends Phaser.Scene { } private ensureStoryBackgroundsLoaded(onReady: () => void) { - const missingKeys = Array.from(new Set(this.pages.map((page) => page.background))).filter((key) => !this.textures.exists(key)); + const requestedKeys = this.pages.flatMap((page) => storyBackgroundKeysFor(page.background)); + const missingKeys = Array.from(new Set(requestedKeys)).filter((key) => !this.textures.exists(key)); const loadableKeys = missingKeys.filter((key) => storyBackgroundAssets[key]); missingKeys @@ -252,16 +235,17 @@ export class StoryScene extends Phaser.Scene { private applyPage(page: StoryPage) { soundDirector.playMusic(page.bgm); - this.applyBackground(page.background); + this.applyBackground(page); this.chapterText?.setText(page.chapter); const portraitKey = this.pagePortraitKey(page); + const textureKey = portraitKey ? portraitTextureKey(portraitKey) : undefined; - if (portraitKey) { + if (textureKey && this.textures.exists(textureKey)) { this.portraitFrame?.setVisible(true); this.portrait?.setVisible(true); this.portraitDivider?.setVisible(true); - this.portrait?.setTexture(portraitKeys[portraitKey]); + this.portrait?.setTexture(textureKey); this.portrait?.setDisplaySize(128, 128); this.nameText?.setPosition(270, this.scale.height - 210); this.bodyText?.setPosition(270, this.scale.height - 160); @@ -281,16 +265,25 @@ export class StoryScene extends Phaser.Scene { } private pagePortraitKey(page: StoryPage) { - return page.portrait ?? (page.speaker ? portraitBySpeaker[page.speaker] : undefined); + return page.portrait ?? portraitKeyForSpeaker(page.speaker); } - private applyBackground(textureKey: string) { + private pageBackgroundKey(page: StoryPage) { + const keys = storyBackgroundKeysFor(page.background).filter((key) => this.textures.exists(key)); + if (keys.length <= 1) { + return keys[0] ?? page.background; + } + + return keys[this.hashString(page.id) % keys.length]; + } + + private applyBackground(page: StoryPage) { if (!this.background) { return; } const { width, height } = this.scale; - const safeTextureKey = this.resolveBackgroundKey(textureKey); + const safeTextureKey = this.resolveBackgroundKey(this.pageBackgroundKey(page)); const source = this.textures.get(safeTextureKey).getSourceImage(); const imageWidth = source.width; const imageHeight = source.height; @@ -318,6 +311,14 @@ export class StoryScene extends Phaser.Scene { return this.textures.exists(textureKey) ? textureKey : 'story-fallback'; } + private hashString(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; + } + private advance() { if (this.transitioning) { return;