feat: improve first-session combat and visual assets

This commit is contained in:
2026-07-18 22:11:50 +09:00
parent 4a2a0dfb19
commit f29956b90e
82 changed files with 3640 additions and 839 deletions

View File

@@ -37,11 +37,20 @@ export const battleUiIconFrames = {
focus: 23,
fire: 24,
shout: 25,
confusion: 25,
failure: 26,
bean: 27,
salve: 28,
wine: 29
wine: 29,
confusion: 30,
benevolentCommand: 31,
azureDragonStrike: 32,
changbanRoar: 33,
singleRiderRescue: 34,
eastWindFire: 35,
hundredPacePierce: 36,
westernCavalryCharge: 37,
qilinStratagem: 38,
burn: 39
} as const;
export type BattleUiIconKey = keyof typeof battleUiIconFrames;
@@ -54,7 +63,17 @@ export const battleUiIconV2Sources = {
fire: { frame: battleUiIconFrames.fire, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/fire-v2.png' },
bean: { frame: battleUiIconFrames.bean, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/bean-v2.png' },
salve: { frame: battleUiIconFrames.salve, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/salve-v2.png' },
wine: { frame: battleUiIconFrames.wine, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png' }
wine: { frame: battleUiIconFrames.wine, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png' },
confusion: { frame: battleUiIconFrames.confusion, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/confusion-v2.png' },
benevolentCommand: { frame: battleUiIconFrames.benevolentCommand, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/benevolent-command-v2.png' },
azureDragonStrike: { frame: battleUiIconFrames.azureDragonStrike, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/azure-dragon-strike-v2.png' },
changbanRoar: { frame: battleUiIconFrames.changbanRoar, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/changban-roar-v2.png' },
singleRiderRescue: { frame: battleUiIconFrames.singleRiderRescue, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/single-rider-rescue-v2.png' },
eastWindFire: { frame: battleUiIconFrames.eastWindFire, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/east-wind-fire-v2.png' },
hundredPacePierce: { frame: battleUiIconFrames.hundredPacePierce, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/hundred-pace-pierce-v2.png' },
westernCavalryCharge: { frame: battleUiIconFrames.westernCavalryCharge, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/western-cavalry-charge-v2.png' },
qilinStratagem: { frame: battleUiIconFrames.qilinStratagem, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/qilin-stratagem-v2.png' },
burn: { frame: battleUiIconFrames.burn, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/burn-v2.png' }
} as const satisfies Partial<Record<BattleUiIconKey, { frame: number; source: string }>>;
export const battleUiIconManifest = {
@@ -68,7 +87,7 @@ export const battleUiIconManifest = {
microFrameHeight: battleUiIconMicroFrameSize,
microMaxDisplaySize: battleUiIconMicroMaxDisplaySize,
columns: 5,
rows: 6,
rows: 8,
frames: battleUiIconFrames,
v2Sources: battleUiIconV2Sources
} as const;

View File

@@ -97,7 +97,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
},
benevolentCommand: {
id: 'benevolentCommand',
iconKey: 'focus',
iconKey: 'benevolentCommand',
command: 'strategy',
name: '인덕의 호령',
target: 'ally',
@@ -114,7 +114,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
},
azureDragonStrike: {
id: 'azureDragonStrike',
iconKey: 'strategy',
iconKey: 'azureDragonStrike',
command: 'strategy',
name: '청룡일섬',
target: 'enemy',
@@ -129,7 +129,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
},
changbanRoar: {
id: 'changbanRoar',
iconKey: 'confusion',
iconKey: 'changbanRoar',
command: 'strategy',
name: '장판교 대갈',
target: 'enemy',
@@ -147,7 +147,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
},
singleRiderRescue: {
id: 'singleRiderRescue',
iconKey: 'heal',
iconKey: 'singleRiderRescue',
command: 'strategy',
name: '단기구원',
target: 'ally',
@@ -160,7 +160,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
},
eastWindFire: {
id: 'eastWindFire',
iconKey: 'fire',
iconKey: 'eastWindFire',
command: 'strategy',
name: '동남풍 화계',
target: 'enemy',
@@ -178,7 +178,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
},
hundredPacePierce: {
id: 'hundredPacePierce',
iconKey: 'strategy',
iconKey: 'hundredPacePierce',
command: 'strategy',
name: '백보천양',
target: 'enemy',
@@ -193,7 +193,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
},
westernCavalryCharge: {
id: 'westernCavalryCharge',
iconKey: 'confusion',
iconKey: 'westernCavalryCharge',
command: 'strategy',
name: '서량철기',
target: 'enemy',
@@ -211,7 +211,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
},
qilinStratagem: {
id: 'qilinStratagem',
iconKey: 'confusion',
iconKey: 'qilinStratagem',
command: 'strategy',
name: '기린지계',
target: 'enemy',

View File

@@ -530,10 +530,10 @@ export const firstBattleScenario: BattleScenarioDefinition = {
supplies: ['콩 1', '탁주 1'],
equipment: ['연습검 1'],
reputation: ['의용군 명성 +1'],
recruits: ['guan-yu', 'zhang-fei'],
recruits: ['guan-yu', 'zhang-fei', 'jian-yong'],
unlockBattleId: 'second-battle-yellow-turban-pursuit',
unlockLabel: '황건 잔당 추격 개방',
note: '도원 형제의 의용군이 정식으로 출전 가능한 부대로 정리됩니다.'
note: '간옹이 합류합니다. 다음 전투부터 유비·관우·장비·간옹 4명 중 최대 3명을 골라 편성하며, 추천 편성은 세 형제입니다.'
},
victoryPages: firstBattleVictoryPages,
nextCampScene: 'CampScene'
@@ -1181,10 +1181,10 @@ export const seventhBattleScenario: BattleScenarioDefinition = {
supplies: ['콩 2', '상처약 2', '탁주 1'],
equipment: ['병법서 1'],
reputation: ['서주 민심 +1'],
recruits: ['jian-yong', 'mi-zhu'],
recruits: ['mi-zhu'],
unlockBattleId: 'eighth-battle-xiaopei-supply-road',
unlockLabel: '소패 보급로 방위전 개방',
note: '서주 구원으로 간옹과 미축이 합류합니다. 다음 전투부터 책사/보급관 선택지가 출전 준비에 표시됩니다.'
note: '서주 구원으로 미축이 합류합니다. 탁현부터 함께한 간옹의 책략과 미축의 보급을 조합하는 선택지가 출전 준비에 추가됩니다.'
},
victoryPages: seventhBattleVictoryPages,
nextCampScene: 'CampScene'
@@ -1197,8 +1197,8 @@ export const eighthBattleScenario: BattleScenarioDefinition = {
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'여포군의 척후와 습격병이 소패 보급로의 창고를 노리고 있습니다. 고순을 격파하면 서주의 첫 방위선을 지킬 수 있습니다.',
'간옹은 병법서와 책략으로 길목 압박을 늦추고, 미축은 창고 주변 보급과 회복을 맡기 좋습니다. 마을과 창고 지형을 중심으로 전열을 잡으십시오.',
'35턴 이내 승리하면 서주 보급망과 새 합류 무장의 명성이 크게 오릅니다.'
'탁현부터 함께한 간옹은 병법서와 책략으로 길목 압박을 늦추고, 새로 합류한 미축은 창고 주변 보급과 회복을 맡기 좋습니다. 마을과 창고 지형을 중심으로 전열을 잡으십시오.',
'35턴 이내 승리하면 서주 보급망과 미축의 군영 명성이 크게 오릅니다.'
],
tacticalGuide: {
summary: '소패 보급로 교두보를 먼저 안정시키고, 관우가 길목을 버티는 동안 간옹과 미축이 후방에서 회복과 책략 보조를 이어갑니다.',
@@ -1243,7 +1243,7 @@ export const eighthBattleScenario: BattleScenarioDefinition = {
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 18, label: '남쪽 압박' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 17, label: '후방 지휘' }
],
note: '소패 보급로는 새로 합류한 간옹과 미축을 시험하기 좋은 전장입니다. 관우가 길목을 막고, 간옹은 책략, 미축은 보급을 맡는 전열이 기본입니다.'
note: '소패 보급로는 기존 책사 간옹과 새 보급관 미축의 조합을 시험하기 좋은 전장입니다. 관우가 길목을 막고, 간옹은 책략, 미축은 보급을 맡는 전열이 기본입니다.'
},
map: eighthBattleMap,
units: eighthBattleUnits,

View File

@@ -364,15 +364,16 @@ const firstBattleStoryOverrides: Record<string, Partial<StoryPage>> = {
'first-victory-next': {
chapter: '다음 길',
speaker: '전황',
text: '세 형제의 이름은 탁현 일대에 퍼지기 시작했다. 이제 황건 잔당의 퇴로를 끊어야 한다.',
text: '탁현에서부터 유비를 도와 온 간옹이 의용군의 군정과 책략을 맡기로 했다. 다음 전투부터 유비·관우·장비·간옹 네 명 중 최대 세 명을 골라 전장에 맞는 편성을 꾸릴 수 있다.',
cutscene: {
kind: 'operation',
title: '다음 전투 해금',
subtitle: '황건 잔당 추격으로 이어집니다.',
title: '간옹 합류 · 첫 편성 선택',
subtitle: '4명 중 최대 3명을 골라 황건 잔당 추격에 나섭니다.',
actors: [
{ unitId: 'liu-bei', x: 0.25, y: 0.54, size: 108, direction: 'east', label: '유비' },
{ unitId: 'guan-yu', x: 0.34, y: 0.53, size: 112, direction: 'east', label: '관우' },
{ unitId: 'zhang-fei', x: 0.43, y: 0.54, size: 112, direction: 'east', label: '장비' }
{ unitId: 'liu-bei', x: 0.22, y: 0.54, size: 108, direction: 'east', label: '유비' },
{ unitId: 'guan-yu', x: 0.32, y: 0.53, size: 112, direction: 'east', label: '관우' },
{ unitId: 'zhang-fei', x: 0.42, y: 0.54, size: 112, direction: 'east', label: '장비' },
{ unitId: 'jian-yong', x: 0.52, y: 0.55, size: 106, direction: 'west', label: '간옹' }
],
markers: [
{ x: 0.24, y: 0.68, label: '탁현', side: 'ally' },
@@ -381,10 +382,11 @@ const firstBattleStoryOverrides: Record<string, Partial<StoryPage>> = {
],
briefing: {
title: '정비 후 출전',
lines: ['보급품 배정', '장비 교체', '출전 무장 선택']
lines: ['신규 무장 간옹 확인', '4명 중 최대 3명 편성', '추천 편성은 유비·관우·장비']
},
rewards: [
{ label: '군영에서 보상 확인', tone: 'reward' },
{ label: '신규 무장: 간옹', tone: 'reward' },
{ label: '편성: 4명 중 최대 3명', tone: 'bond' },
{ label: '다음: 황건 잔당 추격', tone: 'next' }
]
}
@@ -841,7 +843,7 @@ export const seventhBattleVictoryPages: StoryPage[] = [
bgm: 'militia-theme',
chapter: '서주의 문',
background: 'story-xuzhou',
text: '조조군 선봉이 물러서자 서주 성문 앞 피난민들의 숨통이 트였다. 도겸은 유비의 군기를 맞이하며 깊이 고개를 숙였다.'
text: '조조군 선봉이 물러서자 서주 성문 앞 피난민들의 숨통이 트였다. 도겸은 유비의 군기를 맞이하며 깊이 고개를 숙였고, 미축은 보급 장부를 들고 유비군에 합류했다.'
},
{
id: 'seventh-tao-qian-entrusts',
@@ -860,7 +862,7 @@ export const eighthBattleIntroPages: StoryPage[] = [
bgm: 'militia-theme',
chapter: '서주의 새 군영',
background: 'story-xuzhou',
text: '도겸은 병든 몸을 일으켜 서주의 장부와 군량 창고를 유비에게 넘겼다. 간옹과 미축은 백성의 청원과 보급로를 정리하며 새 군영의 기초를 세웠다.'
text: '도겸은 병든 몸을 일으켜 서주의 장부와 군량 창고를 유비에게 넘겼다. 오래 함께해 온 간옹은 백성의 청원을 정리하고, 새로 합류한 미축은 보급로와 창고를 맡아 새 군영의 기초를 세웠다.'
},
{
id: 'eighth-lu-bu-shadow',
@@ -885,7 +887,7 @@ export const eighthBattleIntroPages: StoryPage[] = [
chapter: '첫 방위전',
background: 'story-xuzhou',
speaker: '간옹',
text: '현덕, 이제 함께 갈 장수를 고르는 일도 전술이 되었네. 칼이 필요한 곳과 이 필요한 곳, 그리고 말보다 장부가 필요한 곳을 구분해야 하네.'
text: '현덕, 탁현에서 시작한 편성 선택이 이제 더 넓어졌네. 칼이 필요한 곳과 책략이 필요한 곳, 그리고 말보다 장부가 필요한 곳을 구분해 미축까지 알맞게 배치해야 하네.'
}
];
@@ -5144,7 +5146,7 @@ export const firstBattleUnits: UnitData[] = [
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 5,
y: 13
y: 14
},
{
id: 'rebel-b',
@@ -5358,15 +5360,44 @@ export const firstBattleUnits: UnitData[] = [
}
];
// 간옹은 첫 승리 직후 합류하므로 2전 데이터가 평가되기 전에 공용 템플릿을 선언한다.
// 후반 서주 합류 배열도 이 템플릿을 재사용해 같은 무장이 두 번 정의되지 않게 한다.
export const jianYongRecruitUnit: UnitData = {
id: 'jian-yong',
name: '간옹',
faction: 'ally',
className: '책사',
classKey: 'strategist',
level: 4,
exp: 12,
hp: 26,
maxHp: 26,
attack: 7,
move: 4,
stats: { might: 42, intelligence: 78, leadership: 66, agility: 64, luck: 72 },
equipment: {
weapon: { itemId: 'training-sword', level: 1, exp: 8 },
armor: { itemId: 'cloth-armor', level: 1, exp: 6 },
accessory: { itemId: 'war-manual', level: 1, exp: 0 }
},
x: 1,
y: 17
};
const earlyCampaignAllies: UnitData[] = [
...firstBattleUnits.filter((unit) => unit.faction === 'ally'),
jianYongRecruitUnit
];
const secondBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 15 },
'guan-yu': { x: 3, y: 15 },
'zhang-fei': { x: 2, y: 16 }
'zhang-fei': { x: 2, y: 16 },
'jian-yong': { x: 1, y: 16 }
};
export const secondBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, secondBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'pursuit-raider-a',
@@ -5604,12 +5635,12 @@ export const secondBattleUnits: UnitData[] = [
const thirdBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 16 },
'guan-yu': { x: 3, y: 16 },
'zhang-fei': { x: 2, y: 17 }
'zhang-fei': { x: 2, y: 17 },
'jian-yong': { x: 1, y: 17 }
};
export const thirdBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, thirdBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'guangzong-scout-a',
@@ -5868,12 +5899,12 @@ export const thirdBattleUnits: UnitData[] = [
const fourthBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 16 },
'guan-yu': { x: 3, y: 16 },
'zhang-fei': { x: 2, y: 17 }
'zhang-fei': { x: 2, y: 17 },
'jian-yong': { x: 1, y: 17 }
};
export const fourthBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, fourthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'guangzong-main-vanguard-a',
@@ -6132,12 +6163,12 @@ export const fourthBattleUnits: UnitData[] = [
const fifthBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 16 },
'guan-yu': { x: 3, y: 16 },
'zhang-fei': { x: 2, y: 17 }
'zhang-fei': { x: 2, y: 17 },
'jian-yong': { x: 1, y: 17 }
};
export const fifthBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, fifthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'sishui-vanguard-a',
@@ -6375,12 +6406,12 @@ export const fifthBattleUnits: UnitData[] = [
const sixthBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 15 },
'guan-yu': { x: 3, y: 15 },
'zhang-fei': { x: 2, y: 16 }
'zhang-fei': { x: 2, y: 16 },
'jian-yong': { x: 1, y: 16 }
};
export const sixthBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, sixthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'jieqiao-skirmisher-a',
@@ -6639,12 +6670,12 @@ export const sixthBattleUnits: UnitData[] = [
const seventhBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 15 },
'guan-yu': { x: 3, y: 15 },
'zhang-fei': { x: 2, y: 16 }
'zhang-fei': { x: 2, y: 16 },
'jian-yong': { x: 1, y: 16 }
};
export const seventhBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
...earlyCampaignAllies
.map((unit) => placeScenarioUnit(unit, seventhBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'xuzhou-raider-a',
@@ -6901,27 +6932,7 @@ export const seventhBattleUnits: UnitData[] = [
];
export const xuzhouRecruitUnits: UnitData[] = [
{
id: 'jian-yong',
name: '간옹',
faction: 'ally',
className: '책사',
classKey: 'strategist',
level: 4,
exp: 12,
hp: 26,
maxHp: 26,
attack: 7,
move: 4,
stats: { might: 42, intelligence: 78, leadership: 66, agility: 64, luck: 72 },
equipment: {
weapon: { itemId: 'training-sword', level: 1, exp: 8 },
armor: { itemId: 'cloth-armor', level: 1, exp: 6 },
accessory: { itemId: 'war-manual', level: 1, exp: 0 }
},
x: 3,
y: 17
},
jianYongRecruitUnit,
{
id: 'mi-zhu',
name: '미축',
@@ -19141,15 +19152,17 @@ export const firstBattleBonds: BattleBond[] = [
}
];
export const jianYongRecruitBond: BattleBond = {
id: 'liu-bei__jian-yong',
unitIds: ['liu-bei', 'jian-yong'],
title: '오랜 벗',
level: 48,
exp: 0,
description: '간옹은 유비가 흔들릴 때 현실적인 말로 길을 정리해 주는 오랜 벗이다.'
};
export const xuzhouRecruitBonds: BattleBond[] = [
{
id: 'liu-bei__jian-yong',
unitIds: ['liu-bei', 'jian-yong'],
title: '오랜 벗',
level: 48,
exp: 0,
description: '간옹은 유비가 흔들릴 때 현실적인 말로 길을 정리해 주는 오랜 벗이다.'
},
jianYongRecruitBond,
{
id: 'liu-bei__mi-zhu',
unitIds: ['liu-bei', 'mi-zhu'],
@@ -19781,12 +19794,12 @@ export const jingRearCrisisBonds: BattleBond[] = [
}
];
export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const fifthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const sixthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const secondBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
export const thirdBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
export const fourthBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
export const fifthBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
export const sixthBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
export const seventhBattleBonds: BattleBond[] = [...firstBattleBonds, jianYongRecruitBond].map(cloneBattleBondForScenario);
export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario);
export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleBondForScenario);
export const tenthBattleBonds: BattleBond[] = ninthBattleBonds.map(cloneBattleBondForScenario);

View File

@@ -26,6 +26,35 @@ const storyAssetAliases: Record<string, string> = {
'title-taoyuan': 'story-taoyuan-oath-title'
};
// A handful of later background variants use a scene name that is related to,
// but not prefixed by, the background alias referenced by scenario data. Keep
// those relationships explicit so page planning can still discover them
// without making the runtime depend on file-system order.
const storyAssetCandidateAliases: Record<string, readonly string[]> = {
'story-chengdu-surrender': ['story-chengdu-outer-gate-surrender'],
'story-yiling-fire-attack': ['story-yiling-fire-forest-corridor']
};
// Hashing provides a stable default. These sparse assignments cover single-page
// families and hash collisions so every purpose-built 33-134 scene variant is
// actually shown somewhere in the campaign; the static verifier guards that
// coverage as story pages evolve.
const storyBackgroundPageAssignments: Record<string, string> = {
'yellow-turban-chaos': 'story-chaos-market-panic',
'militia-rises': 'story-militia-training-yard',
'fifth-liu-bei-choice': 'story-anti-dong-hulao-pass',
'thirty-third-chengdu-encircled': 'story-chengdu-surrender-court',
'thirty-third-final-plan': 'story-chengdu-outer-gate-surrender',
'thirty-third-sortie': 'story-chengdu-surrender-hall',
'forty-sixth-camps-stretch': 'story-yiling-fire-attack-river-ashes',
'forty-sixth-lu-xun-fire': 'story-yiling-fire-attack-riverbank',
'forty-sixth-liu-bei-orders': 'story-yiling-fire-forest-corridor',
'forty-seventh-southern-unrest': 'story-nanzhong-forest-council',
'fifty-sixth-tianshui-rumor': 'story-tianshui-front-market-command',
'sixty-second-renewed-qishan-council': 'story-qishan-renewed-frozen-scout-line',
'sixty-third-zhanghe-rear-raid': 'story-lucheng-pursuit-ravine-signal'
};
function storyKeyFromPath(path: string) {
const fileName = path.split('/').pop() ?? path;
const slug = fileName.replace(/\.png$/i, '').replace(/^\d+-/, '');
@@ -47,9 +76,34 @@ Object.entries(storyAssetAliases).forEach(([alias, target]) => {
export function storyBackgroundKeysFor(baseKey: string) {
const prefix = `${baseKey}-`;
const keys = Object.keys(storyBackgroundAssets)
const prefixedKeys = Object.keys(storyBackgroundAssets)
.filter((key) => key === baseKey || key.startsWith(prefix))
.sort();
const candidateAliases = storyAssetCandidateAliases[baseKey] ?? [];
const keys = [...prefixedKeys, ...candidateAliases].filter((key) => Boolean(storyBackgroundAssets[key]));
return Array.from(new Set(keys));
}
export function storyBackgroundKeyForPage(baseKey: string, pageId: string) {
const keys = storyBackgroundKeysFor(baseKey);
if (keys.length === 0) {
return baseKey;
}
const assignedKey = storyBackgroundPageAssignments[pageId];
if (assignedKey && keys.includes(assignedKey)) {
return assignedKey;
}
return keys[stableStoryHash(pageId) % keys.length];
}
function stableStoryHash(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}

View File

@@ -75,6 +75,14 @@ const storyCutsceneActorProfiles: Record<string, StoryCutsceneActorProfile> = {
portraitKey: 'zhangFei',
color: 0xd46a4c
},
'jian-yong': {
unitId: 'jian-yong',
name: '간옹',
roleLabel: '군정·책략 보좌',
unitTextureKey: 'unit-shu-strategist',
portraitKey: 'jian-yong',
color: 0x7ea6b8
},
'rebel-leader': {
unitId: 'rebel-leader',
name: '황건 두령',

View File

@@ -21,6 +21,20 @@ type UnitSheetAsset = {
actionUrl: string;
};
export type UnitActionSheetLoadResult = {
readyKeys: string[];
missingKeys: string[];
failedKeys: string[];
timedOutKeys: string[];
timedOut: boolean;
};
export type UnitActionSheetLoadOptions = {
watchdogMs?: number;
};
const defaultUnitActionSheetWatchdogMs = 15_000;
const unitSheetModules = import.meta.glob('../../assets/images/units/unit-*.png', {
eager: true,
import: 'default'
@@ -116,38 +130,100 @@ export function loadUnitBaseSheets(scene: Phaser.Scene, keys: Iterable<string>,
export function loadUnitSheets(scene: Phaser.Scene, keys: Iterable<string>, onReady: () => void) {
const uniqueKeys = Array.from(new Set(keys));
if (unitSheetsReady(scene, uniqueKeys)) {
onReady();
loadUnitBaseSheets(scene, uniqueKeys, () => {
loadUnitActionSheets(scene, uniqueKeys, () => onReady());
});
}
export function unitActionSheetsReady(scene: Phaser.Scene, keys: Iterable<string>) {
return Array.from(keys).every((key) => scene.textures.exists(`${key}-actions`));
}
export function loadUnitActionSheets(
scene: Phaser.Scene,
keys: Iterable<string>,
onComplete: (result: UnitActionSheetLoadResult) => void,
options: UnitActionSheetLoadOptions = {}
) {
const uniqueKeys = Array.from(new Set(keys));
if (unitActionSheetsReady(scene, uniqueKeys)) {
onComplete({ readyKeys: uniqueKeys, missingKeys: [], failedKeys: [], timedOutKeys: [], timedOut: false });
return;
}
const pendingLoads: Array<{ key: string; url: string }> = [];
uniqueKeys.forEach((key) => {
const sheet = unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key);
if (!scene.textures.exists(sheet.key) && !pendingUnitTextureKeys.has(sheet.key)) {
pendingUnitTextureKeys.add(sheet.key);
pendingLoads.push({ key: sheet.key, url: sheet.url });
}
if (!scene.textures.exists(sheet.actionKey) && !pendingUnitTextureKeys.has(sheet.actionKey)) {
pendingUnitTextureKeys.add(sheet.actionKey);
pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl });
}
});
if (pendingLoads.length <= 0) {
waitForUnitSheets(scene, uniqueKeys, onReady);
return;
}
const actionKeys = new Set(uniqueKeys.map((key) => `${key}-actions`));
const failedActionKeys = new Set<string>();
const watchdogMs = normalizeUnitSheetWatchdogMs(options.watchdogMs);
let settled = false;
let pollTimer: ReturnType<typeof setInterval> | undefined;
let watchdogTimer: ReturnType<typeof setTimeout> | undefined;
const clearPendingLoads = () => {
pendingLoads.forEach(({ key }) => pendingUnitTextureKeys.delete(key));
};
scene.load.once('complete', () => {
const cleanup = () => {
scene.load.off('complete', handleComplete);
scene.load.off('loaderror', handleLoadError);
scene.events.off('shutdown', handleShutdown);
clearPendingLoads();
waitForUnitSheets(scene, uniqueKeys, onReady);
if (pollTimer !== undefined) {
clearInterval(pollTimer);
pollTimer = undefined;
}
if (watchdogTimer !== undefined) {
clearTimeout(watchdogTimer);
watchdogTimer = undefined;
}
};
const settle = (notify = true, timedOut = false) => {
if (settled) {
return;
}
settled = true;
cleanup();
if (notify) {
finishUnitActionSheetLoad(scene, uniqueKeys, failedActionKeys, timedOut, onComplete);
}
};
const allRequestsSettled = () => uniqueKeys.every((key) => {
const actionKey = `${key}-actions`;
return scene.textures.exists(actionKey) || failedActionKeys.has(actionKey);
});
scene.load.once('loaderror', clearPendingLoads);
const handleComplete = () => settle();
const handleLoadError = (file: Phaser.Loader.File) => {
const key = String(file.key);
if (!actionKeys.has(key)) {
return;
}
failedActionKeys.add(key);
if (allRequestsSettled()) {
settle();
}
};
const handleShutdown = () => settle(false);
scene.events.once('shutdown', handleShutdown);
scene.load.on('complete', handleComplete);
scene.load.on('loaderror', handleLoadError);
pollTimer = setInterval(() => {
if (unitActionSheetsReady(scene, uniqueKeys)) {
settle();
}
}, 50);
watchdogTimer = setTimeout(() => settle(true, true), watchdogMs);
if (pendingLoads.length <= 0) {
return;
}
pendingLoads.forEach(({ key, url }) => {
scene.load.spritesheet(key, url, {
frameWidth: unitSheetFrameSize,
@@ -157,8 +233,10 @@ export function loadUnitSheets(scene: Phaser.Scene, keys: Iterable<string>, onRe
scene.load.start();
}
function unitSheetsReady(scene: Phaser.Scene, keys: Iterable<string>) {
return Array.from(keys).every((key) => scene.textures.exists(key) && scene.textures.exists(`${key}-actions`));
function normalizeUnitSheetWatchdogMs(value: number | undefined) {
return Number.isFinite(value)
? Math.max(250, Math.min(defaultUnitActionSheetWatchdogMs, Math.round(value!)))
: defaultUnitActionSheetWatchdogMs;
}
function waitForUnitBaseSheetsAttempt(scene: Phaser.Scene, keys: string[], onReady: () => void, attempts = 0) {
@@ -177,18 +255,29 @@ function waitForUnitBaseSheetsAttempt(scene: Phaser.Scene, keys: string[], onRea
scene.time.delayedCall(50, () => waitForUnitBaseSheetsAttempt(scene, keys, onReady, attempts + 1));
}
function waitForUnitSheets(scene: Phaser.Scene, keys: string[], onReady: () => void, attempts = 0) {
if (unitSheetsReady(scene, keys)) {
onReady();
return;
function finishUnitActionSheetLoad(
scene: Phaser.Scene,
keys: string[],
failedActionKeys: ReadonlySet<string>,
timedOut: boolean,
onComplete: (result: UnitActionSheetLoadResult) => void
) {
const missingKeys = keys.filter((key) => !scene.textures.exists(`${key}-actions`));
const failedKeys = missingKeys.filter((key) => failedActionKeys.has(`${key}-actions`) || !timedOut);
const timedOutKeys = timedOut ? missingKeys.filter((key) => !failedKeys.includes(key)) : [];
if (failedKeys.length > 0) {
console.warn(`Failed to load unit action sheets; base-sheet fallback will be used: ${failedKeys.join(', ')}`);
}
if (attempts > 200) {
const missing = keys.filter((key) => !scene.textures.exists(key) || !scene.textures.exists(`${key}-actions`));
throw new Error(`Timed out waiting for unit sheet textures: ${missing.join(', ')}`);
if (timedOutKeys.length > 0) {
console.info(`Unit action sheet loading exceeded the startup budget; base-sheet fallback is active while loading continues: ${timedOutKeys.join(', ')}`);
}
scene.time.delayedCall(50, () => waitForUnitSheets(scene, keys, onReady, attempts + 1));
onComplete({
readyKeys: keys.filter((key) => !missingKeys.includes(key)),
missingKeys,
failedKeys,
timedOutKeys,
timedOut: timedOutKeys.length > 0
});
}
export function ensureUnitAnimations(scene: Phaser.Scene, keys: Iterable<string>) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,16 @@
import Phaser from 'phaser';
import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png';
const equipmentIcon128Modules = import.meta.glob('../../assets/images/ui/equipment-icons/128/*.png', {
eager: true,
import: 'default'
}) as Record<string, string>;
const equipmentIcon32Modules = import.meta.glob('../../assets/images/ui/equipment-icons/32/*.png', {
eager: true,
import: 'default'
}) as Record<string, string>;
const itemIconSourceSize = 28;
const itemIconTextureSize = 64;
@@ -11,6 +21,8 @@ export class BootScene extends Phaser.Scene {
preload() {
this.load.image('title-taoyuan', titleBackgroundUrl);
this.preloadEquipmentIcons(equipmentIcon128Modules, '');
this.preloadEquipmentIcons(equipmentIcon32Modules, '-micro');
}
create() {
@@ -29,6 +41,15 @@ export class BootScene extends Phaser.Scene {
graphics.destroy();
}
private preloadEquipmentIcons(modules: Record<string, string>, suffix: string) {
Object.entries(modules).forEach(([path, url]) => {
const itemId = path.match(/\/([^/]+)\.png$/)?.[1];
if (itemId) {
this.load.image(`item-${itemId}${suffix}`, url);
}
});
}
private createEquipmentIconTextures() {
this.createItemIcon('item-training-sword', (graphics) => this.drawTrainingSwordIcon(graphics));
this.createItemIcon('item-twin-oath-blades', (graphics) => {

View File

@@ -108,6 +108,8 @@ import {
hanzhongMainRecruitUnits,
hanzhongScoutRecruitBonds,
hanzhongScoutRecruitUnits,
jianYongRecruitBond,
jianYongRecruitUnit,
jiangWeiRecruitBonds,
jiangWeiRecruitUnits,
mengHuoFourthCaptureBonds,
@@ -272,6 +274,20 @@ type CampActionButtonView = {
hovered: boolean;
};
type EquipmentSwapRequest = {
unitId: string;
slot: EquipmentSlot;
candidateItemId: string;
returnToSortie: boolean;
};
type EquipmentInventoryRowView = {
itemId: string;
canEquip: boolean;
background: Phaser.GameObjects.Rectangle;
actionButton: Phaser.GameObjects.Rectangle;
};
type CampRosterBounds = {
x: number;
y: number;
@@ -305,7 +321,7 @@ type SortiePortraitRosterLayout = {
visibleUnitIds: string[];
previousButtonBounds: CampRosterBounds;
nextButtonBounds: CampRosterBounds;
cardBounds: Array<CampRosterBounds & { unitId: string }>;
cardBounds: Array<CampRosterBounds & { unitId: string; toggleButtonBounds: CampRosterBounds }>;
previousEnabled: boolean;
nextEnabled: boolean;
};
@@ -11261,6 +11277,12 @@ export class CampScene extends Phaser.Scene {
private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryNextButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryVisibleItemIds: string[] = [];
private equipmentInventoryRowViews: EquipmentInventoryRowView[] = [];
private equipmentSwapConfirmObjects: Phaser.GameObjects.GameObject[] = [];
private pendingEquipmentSwap?: EquipmentSwapRequest;
private equipmentSwapConfirmPanel?: Phaser.GameObjects.Rectangle;
private equipmentSwapConfirmButton?: Phaser.GameObjects.Rectangle;
private equipmentSwapCancelButton?: Phaser.GameObjects.Rectangle;
private suppliesPanelBackground?: Phaser.GameObjects.Rectangle;
private suppliesEquipmentBoxes: Phaser.GameObjects.Rectangle[] = [];
private suppliesTrophyBoxes: Phaser.GameObjects.Rectangle[] = [];
@@ -11292,6 +11314,8 @@ export class CampScene extends Phaser.Scene {
private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState;
private tabButtons: CampTabButtonView[] = [];
private sortiePrimaryActionButton?: CampActionButtonView;
private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle;
private sortieNextActionGuideText?: Phaser.GameObjects.Text;
private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = [];
private sortieFormationAssignments: SortieFormationAssignments = {};
@@ -11327,6 +11351,7 @@ export class CampScene extends Phaser.Scene {
private sortieRosterScroll = 0;
private sortiePortraitRosterPage = 0;
private sortiePortraitRosterLayout?: SortiePortraitRosterLayout;
private sortieRosterToggleBounds: Array<CampRosterBounds & { unitId: string }> = [];
private sortiePlanFeedback = '';
private sortiePrepStep: SortiePrepStep = 'briefing';
private terrainCountCache = new Map<BattleScenarioId, SortieTerrainCounts>();
@@ -11361,6 +11386,7 @@ export class CampScene extends Phaser.Scene {
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
this.sortiePortraitRosterLayout = undefined;
this.sortieRosterToggleBounds = [];
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieSwapUndoState = undefined;
this.sortieFormationPanelMode = 'roster';
@@ -11388,6 +11414,11 @@ export class CampScene extends Phaser.Scene {
this.victoryRewardObjects = [];
this.victoryRewardCards = [];
this.victoryRewardActions = [];
this.equipmentSwapConfirmObjects = [];
this.pendingEquipmentSwap = undefined;
this.equipmentSwapConfirmPanel = undefined;
this.equipmentSwapConfirmButton = undefined;
this.equipmentSwapCancelButton = undefined;
this.reportFormationReviewObjects = [];
this.reportFormationReviewVisible = false;
this.reportFormationReviewFeedback = '';
@@ -11405,6 +11436,8 @@ export class CampScene extends Phaser.Scene {
this.reportFormationHistoryUndoState = undefined;
this.tabButtons = [];
this.sortiePrimaryActionButton = undefined;
this.sortieNextActionGuideBackground = undefined;
this.sortieNextActionGuideText = undefined;
this.visitedTabs = new Set(['status']);
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
@@ -11652,6 +11685,19 @@ export class CampScene extends Phaser.Scene {
return;
}
if (
battleId === campBattleIds.first ||
battleId === campBattleIds.second ||
battleId === campBattleIds.third ||
battleId === campBattleIds.fourth ||
battleId === campBattleIds.fifth ||
battleId === campBattleIds.sixth
) {
this.campaign = ensureCampaignRosterUnits([jianYongRecruitUnit], [jianYongRecruitBond]);
this.report = this.campaign.firstBattleReport ?? this.report;
return;
}
if (
battleId === campBattleIds.fortieth ||
battleId === campBattleIds.fortyFirst ||
@@ -13150,6 +13196,12 @@ export class CampScene extends Phaser.Scene {
return;
}
if (this.equipmentSwapConfirmObjects.length > 0) {
soundDirector.playSelect();
this.hideEquipmentSwapComparison();
return;
}
if (this.saveSlotConfirmObjects.length > 0) {
soundDirector.playSelect();
this.hideCampSaveConfirm();
@@ -13191,6 +13243,7 @@ export class CampScene extends Phaser.Scene {
if (
this.sortieObjects.length === 0 ||
this.victoryRewardObjects.length > 0 ||
this.equipmentSwapConfirmObjects.length > 0 ||
this.saveSlotObjects.length > 0 ||
this.saveSlotConfirmObjects.length > 0 ||
!this.usesStagedSortiePrep()
@@ -13426,6 +13479,17 @@ export class CampScene extends Phaser.Scene {
this.renderFirstSortieLoadoutStep(contentX, contentY, contentWidth, contentHeight, depth + 2, checklist);
}
const stepIndex = firstSortiePrepSteps.findIndex((step) => step.id === this.sortiePrepStep);
const nextStep = firstSortiePrepSteps[stepIndex + 1]?.id;
this.renderSortieNextActionGuide(
x + 324,
y + height - 66,
548,
28,
nextStep,
depth + 3
);
this.addSortieButton('저장', x + 82, y + height - 42, 96, () => {
soundDirector.playSelect();
this.showCampSaveSlotPanel();
@@ -13435,12 +13499,10 @@ export class CampScene extends Phaser.Scene {
this.hideSortiePrep();
}, depth + 3);
const stepIndex = firstSortiePrepSteps.findIndex((step) => step.id === this.sortiePrepStep);
if (stepIndex > 0) {
const previousStep = firstSortiePrepSteps[stepIndex - 1].id;
this.addSortieButton('이전', x + width - 250, y + height - 42, 104, () => this.setSortiePrepStep(previousStep), depth + 3);
}
const nextStep = firstSortiePrepSteps[stepIndex + 1]?.id;
const primaryActionLabel = nextStep ? (nextStep === 'formation' ? '편성하기' : '장비·보급') : '출진';
this.addSortieButton(primaryActionLabel, x + width - 134, y + height - 42, 128, () => {
if (nextStep) {
@@ -13768,7 +13830,19 @@ export class CampScene extends Phaser.Scene {
const rowIndex = Math.floor(index / 2);
const cardX = x + 18 + column * (cardWidth + columnGap);
const cardY = cardTop + rowIndex * (cardHeight + rowGap);
cardBounds.push({ unitId: unit.id, x: cardX, y: cardY, width: cardWidth, height: cardHeight });
cardBounds.push({
unitId: unit.id,
x: cardX,
y: cardY,
width: cardWidth,
height: cardHeight,
toggleButtonBounds: {
x: cardX + cardWidth - 94,
y: cardY + cardHeight - 32,
width: 84,
height: 24
}
});
const selected = this.isSortieSelected(unit.id);
const focused = this.sortieFocusedUnitId === unit.id;
const pinnedSwapCandidate = this.sortiePinnedSwapCandidateUnitId === unit.id;
@@ -13926,9 +14000,10 @@ export class CampScene extends Phaser.Scene {
visibleUnitIds: visibleUnits.map((unit) => unit.id),
previousButtonBounds: this.scaleCampUiBounds(previousButtonBounds),
nextButtonBounds: this.scaleCampUiBounds(nextButtonBounds),
cardBounds: cardBounds.map(({ unitId, ...bounds }) => ({
cardBounds: cardBounds.map(({ unitId, toggleButtonBounds, ...bounds }) => ({
unitId,
...this.scaleCampUiBounds(bounds)
...this.scaleCampUiBounds(bounds),
toggleButtonBounds: this.scaleCampUiBounds(toggleButtonBounds)
})),
previousEnabled,
nextEnabled
@@ -14988,13 +15063,21 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(this.add.text(x + 190, y + 19, '초상을 고른 뒤 역할을 바꾸십시오.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1);
this.renderFirstSortieInlineButton('추천 편성', x + width - 134, y + 8, 116, 38, this.canApplyRecommendedSortiePlan(), false, () => this.applyRecommendedSortiePlan(), depth + 1);
const units = this.sortieRosterDisplayUnits(this.sortieRosterUnits()).slice(0, 3);
const gap = 12;
const cardWidth = Math.floor((width - 36 - gap * 2) / 3);
const cardY = y + 54;
const cardHeight = height - 70;
this.sortieRosterToggleBounds = [];
const units = this.sortieRosterDisplayUnits(this.sortieRosterUnits());
const compactGrid = units.length > 3;
const columns = compactGrid ? 2 : 3;
const rows = Math.max(1, Math.ceil(units.length / columns));
const columnGap = 12;
const rowGap = compactGrid ? 10 : 0;
const cardWidth = Math.floor((width - 36 - columnGap * (columns - 1)) / columns);
const cardTop = y + 54;
const cardHeight = Math.floor((height - 70 - rowGap * (rows - 1)) / rows);
units.forEach((unit, index) => {
const cardX = x + 18 + index * (cardWidth + gap);
const column = index % columns;
const rowIndex = Math.floor(index / columns);
const cardX = x + 18 + column * (cardWidth + columnGap);
const cardY = cardTop + rowIndex * (cardHeight + rowGap);
const selected = this.isSortieSelected(unit.id);
const focused = this.sortieFocusedUnitId === unit.id;
const required = this.isRequiredSortieUnit(unit.id);
@@ -15013,35 +15096,55 @@ export class CampScene extends Phaser.Scene {
this.showSortiePrep();
});
this.renderFirstSortiePortrait(cardX + cardWidth / 2, cardY + 88, 132, unit, depth + 2, selected ? 1 : 0.5);
this.trackSortie(this.add.text(cardX + 14, cardY + 162, `${unit.name} Lv${unit.level}`, this.textStyle(20, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 190, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(cardX + 14, cardY + 211, cardWidth - 28, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
const recommendationText = recommendation
? this.conciseSortieText(recommendation.reason)
: `${this.sortieFormationRoleLabel(role)} 역할로 전장에 합류합니다.`;
this.trackSortie(
this.add.text(cardX + 14, cardY + 230, recommendationText, {
...this.textStyle(12, recommendation ? '#ffdf7b' : '#9fb0bf', recommendation ? true : false),
wordWrap: { width: cardWidth - 28, useAdvancedWrap: true },
lineSpacing: 2
})
).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 294, this.compactText(this.sortieStrategyLine(unit.id), 24), this.textStyle(10, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2);
if (compactGrid) {
this.renderFirstSortiePortrait(cardX + 50, cardY + 55, 72, unit, depth + 2, selected ? 1 : 0.5);
this.trackSortie(this.add.text(cardX + 96, cardY + 10, `${unit.name} Lv${unit.level}`, this.textStyle(16, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 96, cardY + 36, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(10, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(cardX + 96, cardY + 56, cardWidth - 110, 6, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
this.trackSortie(this.add.text(cardX + 96, cardY + 68, this.compactText(recommendationText, 28), this.textStyle(9, recommendation ? '#ffdf7b' : '#9fb0bf', Boolean(recommendation)))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 98, this.compactText(this.sortieBondLine(unit), 36), this.textStyle(9, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 113, this.compactText(this.sortieStrategyLine(unit.id), 34), this.textStyle(9, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2);
} else {
this.renderFirstSortiePortrait(cardX + cardWidth / 2, cardY + 88, 132, unit, depth + 2, selected ? 1 : 0.5);
this.trackSortie(this.add.text(cardX + 14, cardY + 162, `${unit.name} Lv${unit.level}`, this.textStyle(20, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 190, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(cardX + 14, cardY + 211, cardWidth - 28, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
this.trackSortie(
this.add.text(cardX + 14, cardY + 230, recommendationText, {
...this.textStyle(12, recommendation ? '#ffdf7b' : '#9fb0bf', Boolean(recommendation)),
wordWrap: { width: cardWidth - 28, useAdvancedWrap: true },
lineSpacing: 2
})
).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 294, this.compactText(this.sortieStrategyLine(unit.id), 24), this.textStyle(10, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2);
}
this.renderFirstSortieInlineButton(
required ? '필수 출전' : selected ? '출전 확정' : '대기',
const toggleButton = this.renderFirstSortieInlineButton(
required ? '필수 출전' : selected ? '출전 해제' : '출전 등록',
cardX + cardWidth - 90,
cardY + 12,
cardY + 10,
78,
30,
false,
!required,
selected,
() => undefined,
() => {
this.sortieFocusedUnitId = unit.id;
this.toggleSortieUnit(unit.id);
},
depth + 3
);
const toggleBounds = toggleButton.getBounds();
this.sortieRosterToggleBounds.push({
unitId: unit.id,
x: toggleBounds.x,
y: toggleBounds.y,
width: toggleBounds.width,
height: toggleBounds.height
});
const roles = sortieFormationSlotDefinitions.filter((definition) => definition.role !== 'reserve');
const roleGap = 6;
@@ -15050,9 +15153,9 @@ export class CampScene extends Phaser.Scene {
this.renderFirstSortieInlineButton(
definition.label,
cardX + 14 + roleIndex * (roleButtonWidth + roleGap),
cardY + cardHeight - 52,
cardY + cardHeight - (compactGrid ? 38 : 52),
roleButtonWidth,
40,
compactGrid ? 28 : 40,
selected,
role === definition.role,
() => this.assignFirstSortieRole(unit.id, definition.role),
@@ -15400,6 +15503,7 @@ export class CampScene extends Phaser.Scene {
text.on('pointerout', () => setHovered(false));
text.on('pointerdown', action);
}
return bg;
}
private renderSortieHeaderSummary(x: number, y: number, width: number, height: number, depth: number) {
@@ -15512,6 +15616,7 @@ export class CampScene extends Phaser.Scene {
depth: number,
interaction: 'toggle' | 'focus' = 'toggle'
) {
this.sortieRosterToggleBounds = [];
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setDepth(depth);
@@ -15567,6 +15672,16 @@ export class CampScene extends Phaser.Scene {
row.setDepth(depth + 1);
row.setStrokeStyle(1, disabled ? 0x53606c : selected ? palette.green : recommendedReserve ? palette.gold : 0x53606c, disabled ? 0.22 : selected ? 0.7 : recommendedReserve ? 0.54 : 0.36);
row.setInteractive({ useHandCursor: true });
const rowBounds = row.getBounds();
if (interaction === 'toggle') {
this.sortieRosterToggleBounds.push({
unitId: unit.id,
x: rowBounds.x,
y: rowBounds.y,
width: rowBounds.width,
height: rowBounds.height
});
}
row.on('wheel', handleRosterWheel);
row.on('pointerover', () => {
this.sortieFocusedUnitId = unit.id;
@@ -16168,7 +16283,7 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(this.add.text(x + 174, rowY + 2, this.compactText(this.itemBonusText(item), 14), this.textStyle(10, '#9fb0bf', true))).setDepth(depth + 1);
this.drawSortieBar(x + 46, rowY + 15, 104, 5, state.exp / equipmentExpToNext(state.level), slot === 'weapon' ? palette.gold : slot === 'armor' ? palette.blue : palette.green, depth + 1);
const canSwap = this.equipmentInventoryEntries(slot).some((entry) => entry.item.id !== item.id);
this.renderSortiePanelButton(canSwap ? '교' : '없음', x + width - 58, rowY, 40, 17, canSwap, false, () => this.swapUnitEquipment(unit.id, slot, undefined, true), depth + 1);
this.renderSortiePanelButton(canSwap ? '교' : '없음', x + width - 58, rowY, 40, 17, canSwap, false, () => this.swapUnitEquipment(unit.id, slot, undefined, true), depth + 1);
});
this.renderSortieSupplyAssignment(unit, x + 18, y + 236, width - 36, depth + 1);
@@ -16304,7 +16419,7 @@ export class CampScene extends Phaser.Scene {
const iconFrame = this.trackSortie(this.add.rectangle(x + index * spacing, y, frameSize, frameSize, selected && item.rank === 'treasure' ? 0x2c2630 : 0x0a1017, 0.92));
iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, selected ? 0.62 : 0.34);
iconFrame.setDepth(depth);
const icon = this.trackSortie(this.add.image(x + index * spacing, y, `item-${item.id}`));
const icon = this.trackSortie(this.add.image(x + index * spacing, y, `item-${item.id}-micro`));
icon.setDisplaySize(this.campUiLength(iconSize), this.campUiLength(iconSize));
icon.setAlpha(selected ? 1 : 0.52);
icon.setDepth(depth + 1);
@@ -19284,6 +19399,34 @@ export class CampScene extends Phaser.Scene {
return view;
}
private renderSortieNextActionGuide(
x: number,
y: number,
width: number,
height: number,
nextStep: SortiePrepStep | undefined,
depth: number
) {
const actionLabel = nextStep === 'formation'
? '무장 편성으로 이동'
: nextStep === 'loadout'
? '장비·보급 점검으로 이동'
: '필수 조건을 확인하고 출진';
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.94));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.58);
const label = this.trackSortie(
this.add.text(x + 14, y + 6, `다음 행동 · ${actionLabel}`, this.textStyle(11, '#ffdf7b', true))
);
label.setDepth(depth + 1);
const shortcut = this.trackSortie(this.add.text(x + width - 14, y + 6, 'Enter', this.textStyle(10, '#9fb0bf', true)));
shortcut.setOrigin(1, 0);
shortcut.setDepth(depth + 1);
this.sortieNextActionGuideBackground = bg;
this.sortieNextActionGuideText = label;
}
private startVictoryStory() {
const flow = this.currentSortieFlow();
if (flow.nextBattleId && !this.hasCurrentSortieOrderSelection()) {
@@ -19396,7 +19539,10 @@ export class CampScene extends Phaser.Scene {
this.sortieObjects.forEach((object) => object.destroy());
this.sortieObjects = [];
this.sortiePrimaryActionButton = undefined;
this.sortieNextActionGuideBackground = undefined;
this.sortieNextActionGuideText = undefined;
this.sortiePortraitRosterLayout = undefined;
this.sortieRosterToggleBounds = [];
this.sortieHoveredUnitId = undefined;
if (clearPinnedSwap) {
this.sortiePinnedSwapCandidateUnitId = undefined;
@@ -21749,11 +21895,12 @@ export class CampScene extends Phaser.Scene {
this.equipmentPanelBackground = bg;
this.equipmentInventoryPreviousButton = undefined;
this.equipmentInventoryNextButton = undefined;
this.equipmentInventoryRowViews = [];
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.58);
this.track(this.add.text(x + 24, y + 22, '장비 관리', this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(x + 24, y + 58, '선택 장수의 무기·방어구·보조구 효과와 성장도를 비교하고 바로 교체합니다.', this.textStyle(14, '#d4dce6')));
this.track(this.add.text(x + 24, y + 58, '후보를 고르면 현재 장비와 수치를 비교합니다. 교체 확정 전에는 장부에 반영되지 않습니다.', this.textStyle(14, '#d4dce6')));
this.renderEquipmentUnitSummary(unit, x + 24, y + 86, width - 48, 48);
@@ -21770,14 +21917,14 @@ export class CampScene extends Phaser.Scene {
this.track(this.add.text(
x + 24,
y + 266,
`보유 장비 ${inventoryEntries.length}${inventoryPageCount > 1 ? ` · ${this.equipmentInventoryPage + 1}/${inventoryPageCount}` : ''}`,
`보유 장비 전체 ${inventoryEntries.length}${inventoryPageCount > 1 ? ` · ${this.equipmentInventoryPage + 1}/${inventoryPageCount}` : ''}`,
this.textStyle(18, '#f2e3bf', true)
));
if (inventoryPageCount > 1) {
this.equipmentInventoryPreviousButton = this.renderEquipmentInventoryPageButton(
x + 424,
x + 400,
y + 278,
'',
'이전',
this.equipmentInventoryPage > 0,
() => {
this.equipmentInventoryPage -= 1;
@@ -21787,7 +21934,7 @@ export class CampScene extends Phaser.Scene {
this.equipmentInventoryNextButton = this.renderEquipmentInventoryPageButton(
x + 462,
y + 278,
'',
'다음',
this.equipmentInventoryPage < inventoryPageCount - 1,
() => {
this.equipmentInventoryPage += 1;
@@ -21820,9 +21967,9 @@ export class CampScene extends Phaser.Scene {
enabled: boolean,
action: () => void
) {
const bg = this.track(this.add.rectangle(x, y, 30, 24, enabled ? 0x1a2630 : 0x111820, enabled ? 0.96 : 0.68));
const bg = this.track(this.add.rectangle(x, y, 56, 26, enabled ? 0x1a2630 : 0x111820, enabled ? 0.96 : 0.68));
bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.72 : 0.34);
const text = this.track(this.add.text(x, y - 1, label, this.textStyle(18, enabled ? '#f2e3bf' : '#6f7882', true)));
const text = this.track(this.add.text(x, y - 1, label, this.textStyle(11, enabled ? '#f2e3bf' : '#6f7882', true)));
text.setOrigin(0.5);
if (!enabled) {
return bg;
@@ -21876,7 +22023,7 @@ export class CampScene extends Phaser.Scene {
const iconFrame = this.track(this.add.rectangle(x + 20, y + height / 2, 28, 28, 0x0a1017, 0.92));
iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54);
const icon = this.track(this.add.image(x + 20, y + height / 2, `item-${entry.item.id}`));
const icon = this.track(this.add.image(x + 20, y + height / 2, `item-${entry.item.id}-micro`));
icon.setDisplaySize(this.campUiLength(22), this.campUiLength(22));
icon.setAlpha(canEquip ? 1 : 0.52);
@@ -21887,7 +22034,7 @@ export class CampScene extends Phaser.Scene {
const button = this.track(this.add.rectangle(x + width - 34, y + height / 2, 52, 24, canEquip ? 0x1a2630 : 0x121922, canEquip ? 0.96 : 0.62));
button.setStrokeStyle(1, canEquip ? palette.gold : 0x53606c, canEquip ? 0.68 : 0.3);
const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, canEquip ? '교' : '착용', this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true)));
const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, canEquip ? '교' : '착용', this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true)));
buttonText.setOrigin(0.5, 0);
if (canEquip) {
@@ -21901,6 +22048,12 @@ export class CampScene extends Phaser.Scene {
buttonText.setInteractive({ useHandCursor: true });
buttonText.on('pointerdown', action);
}
this.equipmentInventoryRowViews.push({
itemId: entry.item.id,
canEquip,
background: bg,
actionButton: button
});
}
private renderEquipmentEffectSummary(unit: UnitData, x: number, y: number, width: number, height: number) {
@@ -21912,7 +22065,7 @@ export class CampScene extends Phaser.Scene {
const equipped = equipmentSlots.map((slot) => ({ slot, state: unit.equipment[slot], item: getItem(unit.equipment[slot].itemId) }));
equipped.forEach((entry, index) => {
const rowY = y + 42 + index * 30;
const icon = this.track(this.add.image(x + 20, rowY + 8, `item-${entry.item.id}`));
const icon = this.track(this.add.image(x + 20, rowY + 8, `item-${entry.item.id}-micro`));
icon.setDisplaySize(this.campUiLength(20), this.campUiLength(20));
this.track(this.add.text(x + 36, rowY, `${equipmentSlotLabels[entry.slot]} Lv${entry.state.level}`, this.textStyle(11, '#d8b15f', true)));
this.track(this.add.text(x + 108, rowY, this.compactText(this.itemBonusText(entry.item), 18), this.textStyle(11, '#d4dce6', true)));
@@ -22058,7 +22211,7 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : palette.blue, canEquip ? 0.58 : 0.28);
const iconFrame = this.track(this.add.rectangle(x + 18, y + 18, 28, 28, 0x0a1017, 0.92));
iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54);
const icon = this.track(this.add.image(x + 18, y + 18, `item-${entry.item.id}`));
const icon = this.track(this.add.image(x + 18, y + 18, `item-${entry.item.id}-micro`));
icon.setDisplaySize(this.campUiLength(21), this.campUiLength(21));
this.track(this.add.text(x + 38, y + 7, this.compactText(`${entry.item.name} x${entry.amount}`, 8), this.textStyle(11, '#f2e3bf', true)));
this.track(this.add.text(x + 38, y + 23, this.compactText(`${equipmentSlotLabels[entry.item.slot]} · ${this.itemBonusText(entry.item)}`, 11), this.textStyle(9, '#9fb0bf', true)));
@@ -22253,7 +22406,7 @@ export class CampScene extends Phaser.Scene {
const canSwap = alternatives.length > 0;
const button = this.track(this.add.rectangle(x + width - 36, y + 16, 54, 22, canSwap ? 0x1a2630 : 0x121922, canSwap ? 0.96 : 0.66));
button.setStrokeStyle(1, canSwap ? palette.gold : 0x53606c, canSwap ? 0.7 : 0.3);
const buttonText = this.track(this.add.text(x + width - 36, y + 16, canSwap ? '교' : '없음', this.textStyle(10, canSwap ? '#f2e3bf' : '#7f8994', true)));
const buttonText = this.track(this.add.text(x + width - 36, y + 16, canSwap ? '교' : '없음', this.textStyle(10, canSwap ? '#f2e3bf' : '#7f8994', true)));
buttonText.setOrigin(0.5);
if (canSwap) {
const action = () => this.swapUnitEquipment(unit.id, slot);
@@ -22821,12 +22974,219 @@ export class CampScene extends Phaser.Scene {
return;
}
soundDirector.playSelect();
this.showEquipmentSwapComparison(
{
unitId,
slot,
candidateItemId: nextItem.id,
returnToSortie
},
target,
currentItem,
nextItem
);
}
private showEquipmentSwapComparison(
request: EquipmentSwapRequest,
target: UnitData,
currentItem: ItemDefinition,
candidateItem: ItemDefinition
) {
this.hideEquipmentSwapComparison();
this.pendingEquipmentSwap = request;
const depth = 92;
const width = 636;
const height = 304;
const x = Math.floor((campLegacyCanvasWidth - width) / 2);
const y = Math.floor((campLegacyCanvasHeight - height) / 2);
const shade = this.trackEquipmentSwapConfirm(
this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.76)
);
shade.setOrigin(0);
shade.setDepth(depth);
shade.setInteractive();
const panel = this.trackEquipmentSwapConfirm(this.add.rectangle(x, y, width, height, 0x0f1720, 0.99));
panel.setOrigin(0);
panel.setDepth(depth + 1);
panel.setStrokeStyle(3, palette.gold, 0.94);
panel.setInteractive();
this.equipmentSwapConfirmPanel = panel;
const title = this.trackEquipmentSwapConfirm(
this.add.text(x + 26, y + 20, '장비 교체 비교', this.textStyle(24, '#f2e3bf', true))
);
title.setDepth(depth + 2);
const guide = this.trackEquipmentSwapConfirm(
this.add.text(
x + 26,
y + 53,
`${target.name} · ${equipmentSlotLabels[request.slot]} · 아래 수치를 확인한 뒤 확정하십시오.`,
this.textStyle(13, '#c8d2dd')
)
);
guide.setDepth(depth + 2);
const cardWidth = 280;
const cardHeight = 116;
const cardY = y + 82;
this.renderEquipmentSwapComparisonCard(
'현재 장비',
currentItem,
target.equipment[request.slot].level,
x + 26,
cardY,
cardWidth,
cardHeight,
depth + 2,
false
);
this.renderEquipmentSwapComparisonCard(
'교체 후보',
candidateItem,
target.equipment[request.slot].level,
x + width - 26 - cardWidth,
cardY,
cardWidth,
cardHeight,
depth + 2,
true
);
const delta = this.trackEquipmentSwapConfirm(
this.add.rectangle(x + 26, y + 212, width - 52, 34, 0x172a22, 0.92)
);
delta.setOrigin(0);
delta.setDepth(depth + 2);
delta.setStrokeStyle(1, palette.green, 0.54);
const deltaText = this.trackEquipmentSwapConfirm(
this.add.text(
x + 42,
y + 220,
`교체 변화 · ${this.equipmentDeltaText(candidateItem, currentItem)} · 성장도 Lv${target.equipment[request.slot].level} 유지`,
this.textStyle(12, '#a8ffd0', true)
)
);
deltaText.setDepth(depth + 3);
const cancel = this.trackEquipmentSwapConfirm(this.add.rectangle(x + width - 212, y + 274, 116, 36, 0x1a2630, 0.98));
cancel.setDepth(depth + 2);
cancel.setStrokeStyle(1, palette.blue, 0.82);
const cancelText = this.trackEquipmentSwapConfirm(
this.add.text(x + width - 212, y + 274, '취소', this.textStyle(14, '#d4dce6', true))
);
cancelText.setOrigin(0.5);
cancelText.setDepth(depth + 3);
this.equipmentSwapCancelButton = cancel;
const confirm = this.trackEquipmentSwapConfirm(this.add.rectangle(x + width - 82, y + 274, 128, 36, 0x4a371d, 0.99));
confirm.setDepth(depth + 2);
confirm.setStrokeStyle(2, palette.gold, 0.96);
const confirmText = this.trackEquipmentSwapConfirm(
this.add.text(x + width - 82, y + 274, '교체 확정', this.textStyle(14, '#fff2b8', true))
);
confirmText.setOrigin(0.5);
confirmText.setDepth(depth + 3);
this.equipmentSwapConfirmButton = confirm;
const setCancelHovered = (hovered: boolean) => cancel.setFillStyle(hovered ? 0x283947 : 0x1a2630, 0.99);
const setConfirmHovered = (hovered: boolean) => confirm.setFillStyle(hovered ? 0x654c25 : 0x4a371d, 0.99);
[cancel, cancelText].forEach((object) => {
object.setInteractive({ useHandCursor: true });
object.on('pointerover', () => setCancelHovered(true));
object.on('pointerout', () => setCancelHovered(false));
object.on('pointerdown', () => {
soundDirector.playSelect();
this.hideEquipmentSwapComparison();
});
});
[confirm, confirmText].forEach((object) => {
object.setInteractive({ useHandCursor: true });
object.on('pointerover', () => setConfirmHovered(true));
object.on('pointerout', () => setConfirmHovered(false));
object.on('pointerdown', () => this.confirmEquipmentSwap());
});
}
private renderEquipmentSwapComparisonCard(
eyebrow: string,
item: ItemDefinition,
level: number,
x: number,
y: number,
width: number,
height: number,
depth: number,
candidate: boolean
) {
const bg = this.trackEquipmentSwapConfirm(
this.add.rectangle(x, y, width, height, candidate ? 0x1d2b22 : 0x151f2a, 0.96)
);
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(candidate ? 2 : 1, candidate ? palette.gold : palette.blue, candidate ? 0.84 : 0.48);
const iconFrame = this.trackEquipmentSwapConfirm(this.add.rectangle(x + 34, y + 62, 54, 54, 0x090f16, 0.96));
iconFrame.setDepth(depth + 1);
iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, 0.7);
const icon = this.trackEquipmentSwapConfirm(this.add.image(x + 34, y + 62, `item-${item.id}`));
icon.setDisplaySize(this.campUiLength(40), this.campUiLength(40));
icon.setDepth(depth + 2);
const eyebrowText = this.trackEquipmentSwapConfirm(
this.add.text(x + 14, y + 10, eyebrow, this.textStyle(11, candidate ? '#ffdf7b' : '#9fb0bf', true))
);
eyebrowText.setDepth(depth + 1);
const name = this.trackEquipmentSwapConfirm(
this.add.text(x + 66, y + 32, `${item.name} · Lv${level}`, this.textStyle(17, '#f2e3bf', true))
);
name.setDepth(depth + 1);
const bonus = this.trackEquipmentSwapConfirm(
this.add.text(x + 66, y + 60, this.itemBonusText(item), this.textStyle(12, candidate ? '#a8ffd0' : '#d4dce6', true))
);
bonus.setDepth(depth + 1);
const effect = this.trackEquipmentSwapConfirm(
this.add.text(x + 66, y + 84, this.compactText(item.effects[0] ?? item.description, 28), this.textStyle(10, '#9fb0bf'))
);
effect.setDepth(depth + 1);
}
private confirmEquipmentSwap() {
const request = this.pendingEquipmentSwap;
if (!request) {
this.hideEquipmentSwapComparison();
return;
}
this.hideEquipmentSwapComparison();
this.applyUnitEquipmentSwap(request);
}
private applyUnitEquipmentSwap(request: EquipmentSwapRequest) {
const campaign = this.campaign ?? getCampaignState();
const target = campaign.roster.find((unit) => unit.id === request.unitId);
if (!target) {
this.showCampNotice('장비를 바꿀 장수 장부를 찾지 못했습니다.');
return;
}
const currentState = target.equipment[request.slot];
const currentItem = getItem(currentState.itemId);
const nextItem = getItem(request.candidateItemId);
if (nextItem.slot !== request.slot || nextItem.id === currentItem.id) {
this.showCampNotice('장비 상태가 달라졌습니다. 후보를 다시 비교하십시오.');
return;
}
if ((campaign.inventory[nextItem.name] ?? 0) <= 0) {
this.showCampNotice(`${nextItem.name} 보유량이 없습니다.`);
return;
}
campaign.inventory[nextItem.name] -= 1;
if (campaign.inventory[nextItem.name] <= 0) {
delete campaign.inventory[nextItem.name];
}
campaign.inventory[currentItem.name] = (campaign.inventory[currentItem.name] ?? 0) + 1;
target.equipment[slot] = {
target.equipment[request.slot] = {
itemId: nextItem.id,
level: currentState.level,
exp: currentState.exp
@@ -22836,14 +23196,23 @@ export class CampScene extends Phaser.Scene {
this.campaign = saveCampaignState(campaign);
this.report = this.campaign.firstBattleReport ?? this.report;
soundDirector.playSelect();
this.showCampNotice(`${target.name} ${equipmentSlotLabels[slot]} 교체 · ${currentItem.name}${nextItem.name} (${this.itemBonusText(nextItem)})`);
if (returnToSortie || this.sortieObjects.length > 0) {
this.showCampNotice(`${target.name} ${equipmentSlotLabels[request.slot]} 교체 · ${currentItem.name}${nextItem.name} (${this.itemBonusText(nextItem)})`);
if (request.returnToSortie || this.sortieObjects.length > 0) {
this.showSortiePrep();
} else {
this.render();
}
}
private hideEquipmentSwapComparison() {
this.equipmentSwapConfirmObjects.forEach((object) => object.destroy());
this.equipmentSwapConfirmObjects = [];
this.pendingEquipmentSwap = undefined;
this.equipmentSwapConfirmPanel = undefined;
this.equipmentSwapConfirmButton = undefined;
this.equipmentSwapCancelButton = undefined;
}
private nextEquipmentItemForSlot(slot: EquipmentSlot, currentItemId: string) {
return this.equipmentInventoryEntries(slot).find((entry) => entry.item.id !== currentItemId)?.item;
}
@@ -22944,6 +23313,12 @@ export class CampScene extends Phaser.Scene {
return object;
}
private trackEquipmentSwapConfirm<T extends Phaser.GameObjects.GameObject>(object: T) {
this.scaleLegacyCampUi(object);
this.equipmentSwapConfirmObjects.push(object);
return object;
}
private track<T extends Phaser.GameObjects.GameObject>(object: T) {
this.scaleLegacyCampUi(object);
this.contentObjects.push(object);
@@ -22965,6 +23340,7 @@ export class CampScene extends Phaser.Scene {
this.equipmentInventoryPreviousButton = undefined;
this.equipmentInventoryNextButton = undefined;
this.equipmentInventoryVisibleItemIds = [];
this.equipmentInventoryRowViews = [];
this.suppliesPanelBackground = undefined;
this.suppliesEquipmentBoxes = [];
this.suppliesTrophyBoxes = [];
@@ -23089,6 +23465,15 @@ export class CampScene extends Phaser.Scene {
);
const equipmentInventory = this.equipmentInventoryEntries();
const equipmentInventoryPageCount = Math.max(1, Math.ceil(equipmentInventory.length / equipmentInventoryPageSize));
const equipmentSwapTarget = this.pendingEquipmentSwap
? this.campaign?.roster.find((unit) => unit.id === this.pendingEquipmentSwap?.unitId)
: undefined;
const equipmentSwapCurrentItem = equipmentSwapTarget && this.pendingEquipmentSwap
? getItem(equipmentSwapTarget.equipment[this.pendingEquipmentSwap.slot].itemId)
: undefined;
const equipmentSwapCandidateItem = this.pendingEquipmentSwap
? getItem(this.pendingEquipmentSwap.candidateItemId)
: undefined;
return {
scene: this.scene.key,
victoryRewardAcknowledgement: {
@@ -23107,6 +23492,24 @@ export class CampScene extends Phaser.Scene {
interactive: Boolean(action.button.input?.enabled)
}))
},
equipmentSwapComparison: this.pendingEquipmentSwap && equipmentSwapTarget && equipmentSwapCurrentItem && equipmentSwapCandidateItem
? {
visible: Boolean(this.equipmentSwapConfirmPanel?.active),
unitId: equipmentSwapTarget.id,
unitName: equipmentSwapTarget.name,
slot: this.pendingEquipmentSwap.slot,
currentItemId: equipmentSwapCurrentItem.id,
currentItemName: equipmentSwapCurrentItem.name,
candidateItemId: equipmentSwapCandidateItem.id,
candidateItemName: equipmentSwapCandidateItem.name,
deltaText: this.equipmentDeltaText(equipmentSwapCandidateItem, equipmentSwapCurrentItem),
panelBounds: this.sortieObjectBoundsDebug(this.equipmentSwapConfirmPanel),
confirmButtonBounds: this.sortieObjectBoundsDebug(this.equipmentSwapConfirmButton),
cancelButtonBounds: this.sortieObjectBoundsDebug(this.equipmentSwapCancelButton),
confirmInteractive: Boolean(this.equipmentSwapConfirmButton?.input?.enabled),
cancelInteractive: Boolean(this.equipmentSwapCancelButton?.input?.enabled)
}
: null,
activeTab: this.activeTab,
campTabs: this.tabButtons.map((button) => ({
id: button.tab,
@@ -23501,6 +23904,12 @@ export class CampScene extends Phaser.Scene {
lineWidth: this.sortiePrimaryActionButton.background.lineWidth
}
: null,
sortieNextActionGuide: this.sortieNextActionGuideBackground?.active
? {
label: this.sortieNextActionGuideText?.text ?? '',
bounds: this.sortieObjectBoundsDebug(this.sortieNextActionGuideBackground)
}
: null,
sortiePortraitRosterView: {
enabled: stagedSortiePrep && !this.isFirstSortiePrepSlice(),
total: portraitRosterUnits.length,
@@ -23521,8 +23930,12 @@ export class CampScene extends Phaser.Scene {
nextButtonBounds: this.sortiePortraitRosterLayout
? { ...this.sortiePortraitRosterLayout.nextButtonBounds }
: null,
cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map((bounds) => ({ ...bounds })) ?? []
cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map(({ toggleButtonBounds, ...bounds }) => ({
...bounds,
toggleButtonBounds: { ...toggleButtonBounds }
})) ?? []
},
sortieRosterToggleBounds: this.sortieRosterToggleBounds.map((bounds) => ({ ...bounds })),
sortiePortraitRoster: portraitRosterUnits.map((unit) => {
const availability = this.sortieUnitAvailability(unit);
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
@@ -23605,6 +24018,7 @@ export class CampScene extends Phaser.Scene {
selectedUnitId: this.selectedUnitId,
selectedDialogueId: this.selectedDialogueId,
selectedVisitId: this.selectedVisitId,
sortieSelectionLimit: this.sortieMaxUnits(),
selectedSortieUnitIds: [...this.selectedSortieUnitIds],
sortieFormationAssignments: { ...this.sortieFormationAssignments },
reserveTrainingAssignments: { ...reserveTrainingAssignments },
@@ -23825,7 +24239,14 @@ export class CampScene extends Phaser.Scene {
previousButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryPreviousButton),
nextButtonBounds: this.sortieObjectBoundsDebug(this.equipmentInventoryNextButton),
previousEnabled: Boolean(this.equipmentInventoryPreviousButton?.input?.enabled),
nextEnabled: Boolean(this.equipmentInventoryNextButton?.input?.enabled)
nextEnabled: Boolean(this.equipmentInventoryNextButton?.input?.enabled),
rows: this.equipmentInventoryRowViews.map((row) => ({
itemId: row.itemId,
canEquip: row.canEquip,
bounds: this.sortieObjectBoundsDebug(row.background),
actionButtonBounds: this.sortieObjectBoundsDebug(row.actionButton),
interactive: Boolean(row.background.input?.enabled && row.actionButton.input?.enabled)
}))
}
},
equipmentInventory: equipmentInventory.map((entry) => ({
@@ -23987,7 +24408,11 @@ export class CampScene extends Phaser.Scene {
id: unit.id,
name: unit.name,
hp: unit.hp,
maxHp: unit.maxHp
maxHp: unit.maxHp,
equipment: equipmentSlots.reduce((equipment, slot) => {
equipment[slot] = { ...unit.equipment[slot] };
return equipment;
}, {} as UnitData['equipment'])
})),
completedCampDialogues: this.campaign.completedCampDialogues,
completedCampVisits: this.campaign.completedCampVisits

View File

@@ -8,7 +8,7 @@ import {
type BattleUiIconKey
} from '../data/battleUiIcons';
import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets';
import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets';
import { storyBackgroundAssets, storyBackgroundKeyForPage } from '../data/storyAssets';
import {
ensureUnitAnimations,
loadUnitBaseSheets,
@@ -701,12 +701,8 @@ export class StoryScene extends Phaser.Scene {
}
private pageBackgroundAssetKey(page: StoryPage) {
const keys = storyBackgroundKeysFor(page.background).filter((key) => Boolean(storyBackgroundAssets[key]));
if (keys.length <= 1) {
return keys[0] ?? page.background;
}
return keys[this.hashString(page.id) % keys.length];
const key = storyBackgroundKeyForPage(page.background, page.id);
return storyBackgroundAssets[key] ? key : page.background;
}
private applyBackground(page: StoryPage) {

View File

@@ -1,7 +1,13 @@
import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems';
import { unitClasses } from '../data/battleRules';
import { battleScenarios, type BattleScenarioId } from '../data/battles';
import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario';
import {
campaignRecruitUnits,
jianYongRecruitBond,
jianYongRecruitUnit,
type BattleBond,
type UnitData
} from '../data/scenario';
import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment';
import { coreSortieResonanceMinLevel } from '../data/sortieSynergy';
import {
@@ -438,6 +444,10 @@ export type CampaignSortieOrderHistory = Partial<
Record<BattleScenarioId, Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>
>;
export const campaignTutorialIds = ['first-battle-basic-controls'] as const;
export type CampaignTutorialId = (typeof campaignTutorialIds)[number];
const campaignTutorialIdSet = new Set<string>(campaignTutorialIds);
export type CampaignState = {
version: 1;
updatedAt: string;
@@ -458,6 +468,7 @@ export type CampaignState = {
claimedSortieOrderRewardIds: string[];
reserveTrainingFocus: CampaignReserveTrainingFocusId;
reserveTrainingAssignments: CampaignReserveTrainingAssignments;
completedTutorialIds: CampaignTutorialId[];
completedCampDialogues: string[];
completedCampVisits: string[];
acknowledgedVictoryRewardBattleIds: string[];
@@ -663,6 +674,7 @@ export function createInitialCampaignState(): CampaignState {
claimedSortieOrderRewardIds: [],
reserveTrainingFocus: defaultCampaignReserveTrainingFocusId,
reserveTrainingAssignments: {},
completedTutorialIds: [],
completedCampDialogues: [],
completedCampVisits: [],
acknowledgedVictoryRewardBattleIds: [],
@@ -749,6 +761,20 @@ export function markCampaignStep(step: CampaignStep) {
return cloneCampaignState(state);
}
export function hasCompletedCampaignTutorial(id: CampaignTutorialId, state = ensureCampaignState()) {
return state.completedTutorialIds.includes(id);
}
export function completeCampaignTutorial(id: CampaignTutorialId) {
const state = ensureCampaignState();
if (!state.completedTutorialIds.includes(id)) {
state.completedTutorialIds.push(id);
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
}
return cloneCampaignState(state);
}
export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orderId?: CampaignSortiePresetId) {
const state = ensureCampaignState();
if (!(battleId in battleScenarios)) {
@@ -957,6 +983,7 @@ export function setFirstBattleReport(report: FirstBattleReport) {
.filter((unit): unit is UnitData => Boolean(unit));
state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]);
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
backfillEarlyJianYongRecruit(state);
const reserveTraining = previousSettlement || reportClone.outcome !== 'victory'
? previousSettlement?.reserveTraining ?? []
: applyReserveTrainingProgress(
@@ -1207,6 +1234,89 @@ export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[]
return cloneCampaignState(state);
}
const earlyJianYongRecruitBattleId: BattleScenarioId = 'first-battle-zhuo-commandery';
/**
* 첫 승리 보상에 간옹이 추가되기 전에 만들어진 저장도 동일한 4인 편성 풀을 갖게 한다.
* 반복 호출해도 roster, reward, report, bond에 중복 항목을 만들지 않는다.
*/
function backfillEarlyJianYongRecruit(state: CampaignState) {
const firstSettlement = state.battleHistory[earlyJianYongRecruitBattleId];
const firstReport = state.firstBattleReport?.battleId === earlyJianYongRecruitBattleId
? state.firstBattleReport
: undefined;
const firstVictoryRecorded = firstSettlement?.outcome === 'victory' || firstReport?.outcome === 'victory';
if (!firstVictoryRecorded) {
return;
}
if (!state.roster.some((unit) => unit.id === jianYongRecruitUnit.id)) {
state.roster.push(cloneUnit(jianYongRecruitUnit));
}
if (!state.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
// 전역 인연 진행도는 전투 보고서의 96개 스냅샷 제한 대상이 아니다.
// 후반 저장을 마이그레이션할 때 기존 인연을 지우지 않고 누락 항목만 추가한다.
state.bonds.push(createCampBondSnapshot(jianYongRecruitBond));
}
if (firstReport?.outcome === 'victory') {
if (!firstReport.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
appendRequiredLimitedMigrationEntry(firstReport.bonds, createCampBondSnapshot(jianYongRecruitBond), maxCampaignBattleBondEntries);
}
firstReport.campaignRewards = backfillEarlyJianYongReward(firstReport.campaignRewards);
}
if (firstSettlement?.outcome === 'victory') {
if (!firstSettlement.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
appendRequiredLimitedMigrationEntry(firstSettlement.bonds, {
id: jianYongRecruitBond.id,
title: jianYongRecruitBond.title,
level: jianYongRecruitBond.level,
exp: jianYongRecruitBond.exp,
battleExp: 0
}, maxCampaignBattleBondEntries);
}
firstSettlement.campaignRewards = backfillEarlyJianYongReward(firstSettlement.campaignRewards);
}
}
function backfillEarlyJianYongReward(existing?: CampaignRewardSnapshot): CampaignRewardSnapshot {
const rewardDefinition = battleScenarios[earlyJianYongRecruitBattleId].campaignReward;
const snapshot = cloneCampaignRewardSnapshot(existing, earlyJianYongRecruitBattleId) ?? {
supplies: [...(rewardDefinition?.supplies ?? [])],
equipment: [...(rewardDefinition?.equipment ?? [])],
reputation: [...(rewardDefinition?.reputation ?? [])],
recruits: (rewardDefinition?.recruits ?? []).map((unitId) => ({
unitId,
name: campaignRecruitUnitById.get(unitId)?.name ?? battleScenarios[earlyJianYongRecruitBattleId].units.find((unit) => unit.id === unitId)?.name ?? unitId
})),
unlocks: rewardDefinition?.unlockBattleId
? [{
battleId: rewardDefinition.unlockBattleId,
title: rewardDefinition.unlockLabel ?? battleScenarios[rewardDefinition.unlockBattleId].title
}]
: [],
...(rewardDefinition?.note ? { note: rewardDefinition.note } : {})
};
if (!snapshot.recruits.some((recruit) => recruit.unitId === jianYongRecruitUnit.id)) {
appendRequiredLimitedMigrationEntry(
snapshot.recruits,
{ unitId: jianYongRecruitUnit.id, name: jianYongRecruitUnit.name },
maxCampaignStringListEntries
);
}
return snapshot;
}
function appendRequiredLimitedMigrationEntry<T>(entries: T[], entry: T, limit: number) {
if (entries.length >= limit) {
entries.splice(limit - 1);
}
entries.push(entry);
}
function ensureCampaignState() {
if (!campaignState) {
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
@@ -1232,9 +1342,14 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
.filter((bond): bond is CampBondSnapshot => Boolean(bond));
normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues);
normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits);
normalized.completedTutorialIds = uniqueStrings(normalized.completedTutorialIds)
.filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id));
normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds)
.filter((battleId) => battleId in battleScenarios);
normalized.inventory = normalizeInventory(normalized.inventory);
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
backfillEarlyJianYongRecruit(normalized);
const rosterUnitIds = normalizedRosterUnitIds(normalized.roster);
const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined;
if (sortieUnitFilter) {
@@ -1279,7 +1394,6 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
);
normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus);
normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter);
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
if (sortieUnitFilter) {
normalized.battleHistory = filterBattleHistoryRosterReferences(
normalized.battleHistory,
@@ -1288,7 +1402,6 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
);
}
normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory);
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
if (normalized.firstBattleReport && sortieUnitFilter) {
normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter);
}