feat: improve first-session combat and visual assets
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '황건 두령',
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user