Add Mianzhu Gate sortie chapter

This commit is contained in:
2026-06-23 12:15:04 +09:00
parent 004ec3cfff
commit 9ac621da76
11 changed files with 870 additions and 35 deletions

View File

@@ -47,6 +47,10 @@ import {
thirtyFirstBattleMap,
thirtyFirstBattleUnits,
thirtyFirstBattleVictoryPages,
thirtySecondBattleBonds,
thirtySecondBattleMap,
thirtySecondBattleUnits,
thirtySecondBattleVictoryPages,
thirteenthBattleBonds,
thirteenthBattleMap,
thirteenthBattleUnits,
@@ -160,7 +164,8 @@ export type BattleScenarioId =
| 'twenty-eighth-battle-fu-pass-entry'
| 'twenty-ninth-battle-luo-outer-wall'
| 'thirtieth-battle-luofeng-ambush'
| 'thirty-first-battle-luo-main-gate';
| 'thirty-first-battle-luo-main-gate'
| 'thirty-second-battle-mianzhu-gate';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -1973,6 +1978,69 @@ export const thirtyFirstBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
export const thirtySecondBattleScenario: BattleScenarioDefinition = {
id: 'thirty-second-battle-mianzhu-gate',
title: '면죽관 압박전',
victoryConditionLabel: '이엄 수비선 격파',
defeatConditionLabel: '유비 또는 엄안 퇴각',
openingObjectiveLines: [
'면죽관은 성도 앞 마지막 완충 지대입니다. 관문을 장악하면 유장의 항복 권고가 현실적인 압박으로 바뀝니다.',
'엄안은 익주 장수들의 마음을 흔드는 첫 증거입니다. 엄안을 지키며 이엄에게 유비군의 군율과 예우를 보여 주십시오.',
'이엄을 격파하되 마을과 창고를 안정시키면 성도 항복 권고의 명분이 강해집니다.'
],
map: thirtySecondBattleMap,
units: thirtySecondBattleUnits,
bonds: thirtySecondBattleBonds,
mapTextureKey: 'battle-map-thirty-second',
leaderUnitId: 'chengdu-leader-li-yan',
quickVictoryTurnLimit: 45,
baseVictoryGold: 5040,
objectives: [
{
id: 'leader',
kind: 'defeat-leader',
label: '이엄 수비선 격파',
rewardGold: 3120,
unitId: 'chengdu-leader-li-yan'
},
{
id: 'liu-bei',
kind: 'keep-unit-alive',
label: '유비 생존',
rewardGold: 960,
unitId: 'liu-bei'
},
{
id: 'yan-yan',
kind: 'keep-unit-alive',
label: '엄안 생존',
rewardGold: 1260,
unitId: 'yan-yan'
},
{
id: 'mianzhu-storehouse',
kind: 'secure-terrain',
label: '면죽관 창고 확보',
rewardGold: 1540,
terrain: 'village'
},
{
id: 'quick',
kind: 'quick-victory',
label: '45턴 이내 승리',
rewardGold: 1180,
maxTurn: 45
}
],
defeatConditions: [
{ kind: 'unit-defeated', unitId: 'liu-bei' },
{ kind: 'unit-defeated', unitId: 'yan-yan' }
],
itemRewards: ['콩 18', '상처약 13', '탁주 7', '성도 항복 권고문 +1', '이엄 합류'],
victoryPages: thirtySecondBattleVictoryPages,
nextCampScene: 'CampScene'
};
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = {
@@ -2006,7 +2074,8 @@ export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition>
'twenty-eighth-battle-fu-pass-entry': twentyEighthBattleScenario,
'twenty-ninth-battle-luo-outer-wall': twentyNinthBattleScenario,
'thirtieth-battle-luofeng-ambush': thirtiethBattleScenario,
'thirty-first-battle-luo-main-gate': thirtyFirstBattleScenario
'thirty-first-battle-luo-main-gate': thirtyFirstBattleScenario,
'thirty-second-battle-mianzhu-gate': thirtySecondBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];

View File

@@ -18,6 +18,7 @@ import {
tenthBattleScenario,
thirteenthBattleScenario,
thirtyFirstBattleScenario,
thirtySecondBattleScenario,
thirtiethBattleScenario,
twentyEighthBattleScenario,
twentyNinthBattleScenario,
@@ -69,6 +70,8 @@ import {
thirteenthBattleVictoryPages,
thirtyFirstBattleIntroPages,
thirtyFirstBattleVictoryPages,
thirtySecondBattleIntroPages,
thirtySecondBattleVictoryPages,
thirtiethBattleIntroPages,
thirtiethBattleVictoryPages,
twentyEighthBattleIntroPages,
@@ -431,13 +434,24 @@ const sortieFlows: Record<string, SortieFlow> = {
},
[thirtyFirstBattleScenario.id]: {
afterBattleId: thirtyFirstBattleScenario.id,
eyebrow: '다음 장 준비',
title: '성도 압박 준비',
eyebrow: '다음 장',
title: thirtySecondBattleScenario.title,
description:
'낙성 본성이 열리고 엄안이 합류했습니다. 이제 유비군은 익주의 심부를 흔들며 성도까지 이어지는 길을 준비해야 합니다.',
rewardHint: '다음 장: 성도 압박 준비 중',
pages: thirtyFirstBattleVictoryPages,
unavailableNotice: '성도 압박과 익주 최종 설득은 다음 작업에서 이어집니다. 새로 합류한 엄안을 포함해 성벽과 고지 전투 조합을 준비하십시오.'
'낙성 본성이 열리고 엄안이 합류했습니다. 이제 엄안의 첫 출전과 법정·오의의 길 안내를 엮어 성도 앞 면죽관을 압박해야 합니다.',
rewardHint: `예상 보상: ${thirtySecondBattleScenario.title} 개방 / 이엄 합류`,
nextBattleId: thirtySecondBattleScenario.id,
campaignStep: 'thirty-second-battle',
pages: [...thirtyFirstBattleVictoryPages, ...thirtySecondBattleIntroPages]
},
[thirtySecondBattleScenario.id]: {
afterBattleId: thirtySecondBattleScenario.id,
eyebrow: '다음 장 준비',
title: '성도 항복 권고 준비',
description:
'면죽관이 열리고 이엄이 합류했습니다. 이제 성도 포위와 유장의 항복 권고, 그리고 익주 통치의 첫 선택을 준비해야 합니다.',
rewardHint: '다음 장: 성도 항복 권고 준비 중',
pages: thirtySecondBattleVictoryPages,
unavailableNotice: '성도 항복 권고와 익주 최종 수습은 다음 작업에서 이어집니다. 새로 합류한 이엄을 포함해 보급과 내정형 무장 조합을 준비하십시오.'
}
};

View File

@@ -2014,6 +2014,68 @@ export const thirtyFirstBattleVictoryPages: StoryPage[] = [
}
];
export const thirtySecondBattleIntroPages: StoryPage[] = [
{
id: 'thirty-second-chengdu-pressure',
bgm: 'story-dark',
chapter: '성도 압박의 시작',
background: 'story-three-heroes',
speaker: '법정',
text: '낙성이 열리자 성도는 놀랐으나 아직 문을 닫고 있습니다. 면죽관의 수비장 이엄이 항복과 결전을 저울질하고 있으니, 힘과 명분을 함께 보여 주어야 합니다.'
},
{
id: 'thirty-second-yan-yan-first-counsel',
bgm: 'battle-prep',
chapter: '엄안의 첫 출전',
background: 'story-liu-bei',
speaker: '엄안',
text: '이엄은 무턱대고 물러설 장수가 아닙니다. 그러나 백성을 해치지 않는 군율과 익주 장수를 예우하는 마음을 보이면, 그의 칼끝도 흔들릴 것입니다.'
},
{
id: 'thirty-second-mianzhu-plan',
bgm: 'battle-prep',
chapter: '면죽관 설득',
background: 'story-militia',
speaker: '방통',
text: '정면으로만 밀면 성도는 더 굳게 닫힙니다. 엄안으로 익주 장수의 마음을 열고, 법정과 오의로 길을 짚으며, 황충과 장비가 수비선을 짧게 끊어야 합니다.'
},
{
id: 'thirty-second-sortie',
bgm: 'battle-prep',
chapter: '면죽관 압박전',
background: 'story-sortie',
speaker: '유비',
portrait: 'liuBei',
text: '우리가 얻으려는 것은 성 하나가 아니라 익주의 마음이오. 면죽관을 넘되 백성을 다치게 하지 말고, 이엄에게도 함께 살 길을 보여 주시오.'
}
];
export const thirtySecondBattleVictoryPages: StoryPage[] = [
{
id: 'thirty-second-victory-pass-yields',
bgm: 'militia-theme',
chapter: '면죽관이 열리다',
background: 'story-sortie',
text: '면죽관의 수비선은 무너졌지만 성도 길목의 마을은 불타지 않았습니다. 유비군이 군율을 지키자 익주 병사들은 패배보다 이후의 처우를 먼저 바라보았습니다.'
},
{
id: 'thirty-second-li-yan-joins',
bgm: 'battle-prep',
chapter: '이엄의 선택',
background: 'story-liu-bei',
speaker: '이엄',
text: '칼을 거두는 일이 반드시 부끄러운 것은 아니군요. 백성을 살리고 익주의 길을 보전한다면, 이엄 또한 새 주군의 군율 아래 서겠습니다.'
},
{
id: 'thirty-second-next-chengdu-surrender',
bgm: 'story-dark',
chapter: '성도 항복 권고',
background: 'story-three-heroes',
speaker: '제갈량',
text: '낙성과 면죽이 열렸으니 유장의 선택지는 더욱 좁아졌습니다. 다음 길은 성도 포위와 항복 권고, 그리고 익주를 어떻게 다스릴 것인가에 달렸습니다.'
}
];
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -2345,6 +2407,12 @@ export const thirtyFirstBattleMap: BattleMap = {
terrain: createThirtyFirstBattleTerrain()
};
export const thirtySecondBattleMap: BattleMap = {
width: 64,
height: 46,
terrain: createThirtySecondBattleTerrain()
};
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -4514,6 +4582,30 @@ export const luoCastleProperRecruitUnits: UnitData[] = [
}
];
export const chengduPressureRecruitUnits: UnitData[] = [
{
id: 'li-yan',
name: '이엄',
faction: 'ally',
className: '익주 수비장',
classKey: 'infantry',
level: 20,
exp: 0,
hp: 46,
maxHp: 46,
attack: 15,
move: 4,
stats: { might: 80, intelligence: 78, leadership: 88, agility: 70, luck: 72 },
equipment: {
weapon: { itemId: 'iron-spear', level: 3, exp: 24 },
armor: { itemId: 'reinforced-lamellar', level: 3, exp: 12 },
accessory: { itemId: 'war-manual', level: 1, exp: 0 }
},
x: 11,
y: 28
}
];
const eighthBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 17 },
'guan-yu': { x: 3, y: 17 },
@@ -13037,6 +13129,70 @@ export const thirtyFirstBattleUnits: UnitData[] = [
createThirtyFirstEnemyUnit('luo-main-leader-zhang-ren', '장임', '낙성 본성 주장', 'rebelLeader', 58, 286, 78, 58, 18)
];
const thirtySecondBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 4, y: 40 },
'guan-yu': { x: 5, y: 38 },
'zhang-fei': { x: 4, y: 42 },
'jian-yong': { x: 3, y: 43 },
'mi-zhu': { x: 5, y: 44 },
'sun-qian': { x: 7, y: 43 },
'zhao-yun': { x: 8, y: 39 },
'zhuge-liang': { x: 6, y: 38 },
'ma-liang': { x: 8, y: 44 },
'yi-ji': { x: 9, y: 41 },
'gong-zhi': { x: 10, y: 44 },
'huang-zhong': { x: 7, y: 40 },
'wei-yan': { x: 10, y: 38 },
'pang-tong': { x: 9, y: 39 },
'fa-zheng': { x: 11, y: 41 },
'wu-yi': { x: 12, y: 39 },
'yan-yan': { x: 11, y: 37 }
};
export const thirtySecondBattleUnits: UnitData[] = [
...[
...firstBattleUnits.filter((unit) => unit.faction === 'ally'),
...xuzhouRecruitUnits,
...caoBreakRecruitUnits,
...liuBiaoRecruitUnits,
...zhugeRecruitUnits,
...jingzhouRecruitUnits,
...guiyangRecruitUnits,
...wulingRecruitUnits,
...changshaRecruitUnits,
...yizhouRecruitUnits,
...fuPassRecruitUnits,
...luoCastleRecruitUnits,
...luoCastleProperRecruitUnits
].map((unit) => placeScenarioUnit(unit, thirtySecondBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
createThirtyFirstEnemyUnit('chengdu-scout-a', '성도 정찰병', '남로 정찰병', 'bandit', 56, 164, 60, 18, 39),
createThirtyFirstEnemyUnit('chengdu-scout-b', '성도 정찰병', '수로 정찰병', 'bandit', 56, 164, 60, 24, 36),
createThirtyFirstEnemyUnit('chengdu-scout-c', '성도 정찰병', '산길 정찰병', 'bandit', 57, 172, 62, 31, 35),
createThirtyFirstEnemyUnit('chengdu-infantry-a', '면죽관 보병', '외곽 보병', 'yellowTurban', 57, 236, 74, 29, 33),
createThirtyFirstEnemyUnit('chengdu-infantry-b', '면죽관 보병', '외곽 보병', 'yellowTurban', 57, 236, 74, 34, 31),
createThirtyFirstEnemyUnit('chengdu-infantry-c', '면죽관 보병', '관문 보병', 'yellowTurban', 58, 246, 76, 41, 27),
createThirtyFirstEnemyUnit('chengdu-infantry-d', '면죽관 보병', '관문 보병', 'yellowTurban', 58, 246, 76, 45, 25),
createThirtyFirstEnemyUnit('chengdu-infantry-e', '성도 보병', '내곽 보병', 'yellowTurban', 59, 256, 78, 53, 22),
createThirtyFirstEnemyUnit('chengdu-infantry-f', '성도 보병', '창고 보병', 'yellowTurban', 59, 256, 78, 57, 25),
createThirtyFirstEnemyUnit('chengdu-archer-a', '면죽관 궁병', '숲길 궁병', 'archer', 57, 160, 60, 31, 29),
createThirtyFirstEnemyUnit('chengdu-archer-b', '면죽관 궁병', '성벽 궁병', 'archer', 58, 166, 62, 43, 21),
createThirtyFirstEnemyUnit('chengdu-archer-c', '성도 궁병', '고지 궁병', 'archer', 58, 166, 62, 50, 18),
createThirtyFirstEnemyUnit('chengdu-archer-d', '성도 궁병', '창고 궁병', 'archer', 59, 174, 64, 58, 23),
createThirtyFirstEnemyUnit('chengdu-cavalry-a', '성도 기병', '우익 기병', 'cavalry', 58, 246, 78, 28, 41),
createThirtyFirstEnemyUnit('chengdu-cavalry-b', '성도 기병', '좌익 기병', 'cavalry', 58, 246, 78, 40, 36),
createThirtyFirstEnemyUnit('chengdu-cavalry-c', '성도 기병', '내성 기병', 'cavalry', 59, 258, 80, 55, 30),
createThirtyFirstEnemyUnit('chengdu-guard-a', '이엄 친위대', '관문 친위대', 'yellowTurban', 59, 266, 80, 48, 22),
createThirtyFirstEnemyUnit('chengdu-guard-b', '이엄 친위대', '관문 친위대', 'yellowTurban', 59, 266, 80, 52, 21),
createThirtyFirstEnemyUnit('chengdu-guard-c', '이엄 친위대', '성도 친위대', 'yellowTurban', 60, 276, 82, 58, 19),
createThirtyFirstEnemyUnit('chengdu-strategist-a', '성도 책사', '항복 반대파', 'strategist', 58, 166, 62, 45, 18),
createThirtyFirstEnemyUnit('chengdu-strategist-b', '성도 책사', '창고 책사', 'strategist', 59, 174, 64, 55, 15),
createThirtyFirstEnemyUnit('chengdu-ambusher-a', '성도 복병', '남벽 복병', 'bandit', 57, 174, 64, 35, 39),
createThirtyFirstEnemyUnit('chengdu-ambusher-b', '성도 복병', '동문 복병', 'bandit', 57, 174, 64, 49, 33),
createThirtyFirstEnemyUnit('chengdu-ambusher-c', '성도 복병', '북벽 복병', 'bandit', 58, 182, 66, 59, 11),
createThirtyFirstEnemyUnit('chengdu-officer-fei-guan', '비관', '익주 부장', 'rebelLeader', 60, 286, 84, 55, 20),
createThirtyFirstEnemyUnit('chengdu-leader-li-yan', '이엄', '면죽관 수비장', 'rebelLeader', 61, 306, 86, 60, 18)
];
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -13388,6 +13544,33 @@ export const luoCastleProperRecruitBonds: BattleBond[] = [
}
];
export const chengduPressureRecruitBonds: BattleBond[] = [
{
id: 'liu-bei__li-yan',
unitIds: ['liu-bei', 'li-yan'],
title: '성도 앞의 군율',
level: 30,
exp: 0,
description: '유비의 군율과 이엄의 수비 책임이 맞닿아, 익주 백성을 해치지 않는 항복 명분을 더 단단하게 만듭니다.'
},
{
id: 'zhuge-liang__li-yan',
unitIds: ['zhuge-liang', 'li-yan'],
title: '내정과 책략',
level: 28,
exp: 0,
description: '제갈량의 큰 판과 이엄의 창고·관문 지식이 만나, 전투 이후 익주를 다스릴 준비를 앞당깁니다.'
},
{
id: 'yan-yan__li-yan',
unitIds: ['yan-yan', 'li-yan'],
title: '익주 장수의 결단',
level: 28,
exp: 0,
description: '엄안의 선례와 이엄의 신중함이 서로를 비추며, 익주 장수들이 유비군을 받아들이는 길을 넓힙니다.'
}
];
export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
@@ -13424,6 +13607,9 @@ export const twentyEighthBattleBonds: BattleBond[] = [...twentySeventhBattleBond
export const twentyNinthBattleBonds: BattleBond[] = [...twentyEighthBattleBonds, ...fuPassRecruitBonds].map(cloneBattleBondForScenario);
export const thirtiethBattleBonds: BattleBond[] = [...twentyNinthBattleBonds, ...luoCastleRecruitBonds].map(cloneBattleBondForScenario);
export const thirtyFirstBattleBonds: BattleBond[] = [...thirtiethBattleBonds].map(cloneBattleBondForScenario);
export const thirtySecondBattleBonds: BattleBond[] = [...thirtyFirstBattleBonds, ...luoCastleProperRecruitBonds].map(
cloneBattleBondForScenario
);
function createEighthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 22 }, (_, y) =>
@@ -14685,6 +14871,72 @@ function createThirtyFirstBattleTerrain(): TerrainType[][] {
);
}
function createThirtySecondBattleTerrain(): TerrainType[][] {
return Array.from({ length: 46 }, (_, y) =>
Array.from({ length: 64 }, (_, x): TerrainType => {
if (x <= 4 && y >= 39) {
return 'camp';
}
if (
(x >= 49 && x <= 62 && y >= 12 && y <= 26) ||
(x >= 45 && x <= 54 && y >= 8 && y <= 17) ||
(x >= 54 && x <= 62 && y >= 17 && y <= 30) ||
(x >= 52 && x <= 58 && y >= 27 && y <= 32)
) {
return 'fort';
}
if (
(x >= 18 && x <= 21 && y >= 37 && y <= 39) ||
(x >= 36 && x <= 38 && y >= 31 && y <= 33) ||
(x >= 45 && x <= 47 && y >= 26 && y <= 28) ||
(x >= 55 && x <= 57 && y >= 29 && y <= 31)
) {
return 'village';
}
if (
(y >= 41 && x >= 4 && x <= 19) ||
(x >= 18 && x <= 31 && y >= 37 && y <= 41) ||
(x >= 30 && x <= 43 && y >= 32 && y <= 37) ||
(x >= 42 && x <= 54 && y >= 26 && y <= 32) ||
(x >= 53 && x <= 61 && y >= 20 && y <= 26) ||
(x >= 58 && x <= 62 && y >= 18 && y <= 21)
) {
return 'road';
}
if ((x === 14 || x === 15) && y >= 0 && y <= 35) {
return 'river';
}
if ((x === 35 || x === 36) && y >= 4 && y <= 30) {
return 'river';
}
if ((x === 46 || x === 47) && y >= 32 && y <= 45) {
return 'river';
}
if (
(x >= 4 && x <= 21 && y >= 8 && y <= 30) ||
(x >= 22 && x <= 34 && y >= 14 && y <= 31) ||
(x >= 37 && x <= 50 && y >= 33 && y <= 45) ||
(x >= 49 && x <= 63 && y >= 29 && y <= 44)
) {
return 'forest';
}
if (
(x >= 0 && x <= 12 && y <= 16) ||
(x >= 22 && x <= 34 && y <= 13) ||
(x >= 36 && x <= 52 && y >= 2 && y <= 14) ||
(x >= 55 && x <= 63 && y <= 12) ||
(x >= 26 && x <= 38 && y >= 38 && y <= 45)
) {
return 'hill';
}
if ((x <= 2 && y <= 38) || (x >= 62 && y >= 26) || (x >= 49 && x <= 52 && y <= 8)) {
return 'cliff';
}
return 'plain';
})
);
}
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {
...cloneUnitForScenario(unit),

View File

@@ -38,6 +38,7 @@ const unitTexture: Record<string, string> = {
'fa-zheng': 'unit-liu-bei',
'wu-yi': 'unit-guan-yu',
'yan-yan': 'unit-rebel-archer',
'li-yan': 'unit-guan-yu',
'rebel-a': 'unit-rebel',
'rebel-b': 'unit-rebel',
'rebel-c': 'unit-rebel-archer',
@@ -427,7 +428,33 @@ const unitTexture: Record<string, string> = {
'luo-main-ambusher-c': 'unit-rebel',
'luo-main-veteran-yan-yan': 'unit-rebel-archer',
'luo-main-officer-a': 'unit-rebel-leader',
'luo-main-leader-zhang-ren': 'unit-rebel-leader'
'luo-main-leader-zhang-ren': 'unit-rebel-leader',
'chengdu-scout-a': 'unit-rebel',
'chengdu-scout-b': 'unit-rebel',
'chengdu-scout-c': 'unit-rebel',
'chengdu-infantry-a': 'unit-rebel',
'chengdu-infantry-b': 'unit-rebel',
'chengdu-infantry-c': 'unit-rebel',
'chengdu-infantry-d': 'unit-rebel',
'chengdu-infantry-e': 'unit-rebel',
'chengdu-infantry-f': 'unit-rebel',
'chengdu-archer-a': 'unit-rebel-archer',
'chengdu-archer-b': 'unit-rebel-archer',
'chengdu-archer-c': 'unit-rebel-archer',
'chengdu-archer-d': 'unit-rebel-archer',
'chengdu-cavalry-a': 'unit-rebel-cavalry',
'chengdu-cavalry-b': 'unit-rebel-cavalry',
'chengdu-cavalry-c': 'unit-rebel-cavalry',
'chengdu-guard-a': 'unit-rebel',
'chengdu-guard-b': 'unit-rebel',
'chengdu-guard-c': 'unit-rebel',
'chengdu-strategist-a': 'unit-rebel-archer',
'chengdu-strategist-b': 'unit-rebel-archer',
'chengdu-ambusher-a': 'unit-rebel',
'chengdu-ambusher-b': 'unit-rebel',
'chengdu-ambusher-c': 'unit-rebel',
'chengdu-officer-fei-guan': 'unit-rebel-leader',
'chengdu-leader-li-yan': 'unit-rebel-leader'
};
const unitTextureByClass: Partial<Record<UnitClassKey, string>> = {
@@ -1317,7 +1344,33 @@ const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
'luo-main-ambusher-c': 'aggressive',
'luo-main-veteran-yan-yan': 'hold',
'luo-main-officer-a': 'guard',
'luo-main-leader-zhang-ren': 'guard'
'luo-main-leader-zhang-ren': 'guard',
'chengdu-scout-a': 'aggressive',
'chengdu-scout-b': 'aggressive',
'chengdu-scout-c': 'aggressive',
'chengdu-infantry-a': 'guard',
'chengdu-infantry-b': 'guard',
'chengdu-infantry-c': 'guard',
'chengdu-infantry-d': 'guard',
'chengdu-infantry-e': 'guard',
'chengdu-infantry-f': 'guard',
'chengdu-archer-a': 'hold',
'chengdu-archer-b': 'hold',
'chengdu-archer-c': 'hold',
'chengdu-archer-d': 'hold',
'chengdu-cavalry-a': 'aggressive',
'chengdu-cavalry-b': 'aggressive',
'chengdu-cavalry-c': 'aggressive',
'chengdu-guard-a': 'guard',
'chengdu-guard-b': 'guard',
'chengdu-guard-c': 'guard',
'chengdu-strategist-a': 'hold',
'chengdu-strategist-b': 'hold',
'chengdu-ambusher-a': 'aggressive',
'chengdu-ambusher-b': 'aggressive',
'chengdu-ambusher-c': 'aggressive',
'chengdu-officer-fei-guan': 'guard',
'chengdu-leader-li-yan': 'guard'
};
const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {

View File

@@ -17,6 +17,7 @@ import seventeenthBattleMapUrl from '../../assets/images/battle/seventeenth-batt
import tenthBattleMapUrl from '../../assets/images/battle/tenth-battle-map.svg';
import thirteenthBattleMapUrl from '../../assets/images/battle/thirteenth-battle-map.svg';
import thirtyFirstBattleMapUrl from '../../assets/images/battle/thirty-first-battle-map.svg';
import thirtySecondBattleMapUrl from '../../assets/images/battle/thirty-second-battle-map.svg';
import thirtiethBattleMapUrl from '../../assets/images/battle/thirtieth-battle-map.svg';
import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg';
import twentyEighthBattleMapUrl from '../../assets/images/battle/twenty-eighth-battle-map.svg';
@@ -120,6 +121,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-twenty-ninth', twentyNinthBattleMapUrl);
this.load.image('battle-map-thirtieth', thirtiethBattleMapUrl);
this.load.image('battle-map-thirty-first', thirtyFirstBattleMapUrl);
this.load.image('battle-map-thirty-second', thirtySecondBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);

View File

@@ -7,6 +7,8 @@ import { getSortieFlow } from '../data/campaignFlow';
import {
caoBreakRecruitBonds,
caoBreakRecruitUnits,
chengduPressureRecruitBonds,
chengduPressureRecruitUnits,
changshaRecruitBonds,
changshaRecruitUnits,
firstBattleBonds,
@@ -322,7 +324,8 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [
'twenty-eighth-battle-fu-pass-entry',
'twenty-ninth-battle-luo-outer-wall',
'thirtieth-battle-luofeng-ambush',
'thirty-first-battle-luo-main-gate'
'thirty-first-battle-luo-main-gate',
'thirty-second-battle-mianzhu-gate'
],
nextHints: ['형주 방위', '익주 진입', '방통·법정 합류']
},
@@ -367,7 +370,8 @@ const campBattleIds = {
twentyEighth: 'twenty-eighth-battle-fu-pass-entry',
twentyNinth: 'twenty-ninth-battle-luo-outer-wall',
thirtieth: 'thirtieth-battle-luofeng-ambush',
thirtyFirst: 'thirty-first-battle-luo-main-gate'
thirtyFirst: 'thirty-first-battle-luo-main-gate',
thirtySecond: 'thirty-second-battle-mianzhu-gate'
} as const;
const requiredSortieUnitIds = new Set(['liu-bei']);
@@ -714,6 +718,18 @@ const sortieRulesByBattleId: Partial<Record<BattleScenarioId, SortieRuleDefiniti
{ unitId: 'zhang-fei', reason: '성문 앞 친위대를 짧게 무너뜨리는 돌파력이 필요합니다.' }
],
note: '낙성 본성 공략은 열여섯 명 중 여섯 명만 출전합니다. 설득을 노리면 책사와 오의를, 빠른 돌파를 노리면 장비와 황충 같은 전투 무장을 우선하십시오.'
},
'thirty-second-battle-mianzhu-gate': {
maxUnits: 6,
recommended: [
{ unitId: 'liu-bei', reason: '성도 앞 항복 명분과 패배 조건의 중심입니다.' },
{ unitId: 'yan-yan', reason: '엄안의 첫 출전입니다. 이엄과 익주 장수들을 설득하는 증거가 됩니다.' },
{ unitId: 'fa-zheng', reason: '성도 수비장들의 심리와 면죽관 창고 사정을 읽는 책사입니다.' },
{ unitId: 'wu-yi', reason: '익주 안쪽 길과 관문 배치를 아는 새 합류 장수입니다.' },
{ unitId: 'pang-tong', reason: '무리한 돌파 대신 항복 권고와 퇴로 확보를 동시에 계산합니다.' },
{ unitId: 'huang-zhong', reason: '관문 궁병과 고지 수비를 안정적으로 견제합니다.' }
],
note: '면죽관 압박전은 열일곱 명 중 여섯 명만 출전합니다. 엄안이 새로 후보에 들어오며, 이엄을 설득하려면 익주 출신 장수와 책사를 함께 고르는 편이 좋습니다.'
}
};
@@ -3309,6 +3325,87 @@ const campDialogues: CampDialogue[] = [
rewardExp: 11
}
]
},
{
id: 'liu-li-after-mianzhu',
title: '성도 앞의 군율',
availableAfterBattleIds: [campBattleIds.thirtySecond],
unitIds: ['liu-bei', 'li-yan'],
bondId: 'liu-bei__li-yan',
rewardExp: 36,
lines: [
'이엄: 면죽관을 내주었으나, 성도 앞의 군율까지 무너지면 익주는 오래 앙금을 품을 것입니다.',
'유비: 항복한 장수의 말이라도 백성을 살리는 말이면 곧 군령이오. 그대가 익주 병사의 마음을 잡아 주시오.',
'이엄: 그렇다면 창고와 관문을 맡아, 항복한 병사도 약탈을 두려워하지 않게 하겠습니다.'
],
choices: [
{
id: 'trust-li-yan-discipline',
label: '군율 정비를 맡긴다',
response: '이엄은 면죽관 창고 명부를 다시 정리하고, 항복 병사에게도 같은 배급 규칙을 적용했습니다.',
rewardExp: 14
},
{
id: 'ask-li-yan-chengdu-order',
label: '성도 질서를 묻는다',
response: '이엄은 성도 안쪽 창고, 문지기, 장수들의 이해관계를 짚어 다음 권고문의 근거를 보탰습니다.',
rewardExp: 13
}
]
},
{
id: 'zhuge-li-after-mianzhu',
title: '내정과 책략',
availableAfterBattleIds: [campBattleIds.thirtySecond],
unitIds: ['zhuge-liang', 'li-yan'],
bondId: 'zhuge-liang__li-yan',
rewardExp: 34,
lines: [
'제갈량: 성을 얻는 일보다 얻은 뒤의 질서가 더 어렵습니다. 이엄 장군은 그 무게를 알고 있군요.',
'이엄: 저는 관문을 막는 법은 알지만, 사람 마음을 여는 법은 아직 배워야 합니다.',
'제갈량: 그렇다면 관문의 숫자와 사람의 사정을 함께 보지요. 다음 싸움은 이미 싸움 뒤의 정치와 맞닿아 있습니다.'
],
choices: [
{
id: 'map-chengdu-storehouses',
label: '성도 창고도를 맞춘다',
response: '제갈량의 큰 지도 위에 이엄의 창고 명부가 겹치며, 성도 항복 뒤 혼란을 줄일 길이 보였습니다.',
rewardExp: 13
},
{
id: 'write-surrender-terms',
label: '항복 조건을 다듬는다',
response: '두 사람은 유장에게 보낼 문장에 처벌보다 안정을 먼저 적었습니다.',
rewardExp: 12
}
]
},
{
id: 'yan-li-after-mianzhu',
title: '익주 장수의 결단',
availableAfterBattleIds: [campBattleIds.thirtySecond],
unitIds: ['yan-yan', 'li-yan'],
bondId: 'yan-yan__li-yan',
rewardExp: 34,
lines: [
'엄안: 나도 성문 위에서 끝까지 버티려 했소. 그러나 백성이 살아야 장수의 절개도 남는 법이오.',
'이엄: 노장께서 먼저 길을 여셨기에 저도 검을 내려놓을 수 있었습니다.',
'엄안: 이제는 우리가 익주 장수들에게 말해야 하오. 항복이 부끄러움이 아니라 백성을 살리는 선택이 될 수 있다고.'
],
choices: [
{
id: 'share-yizhou-officer-names',
label: '익주 장수 명단을 맞춘다',
response: '엄안과 이엄은 끝까지 버틸 장수와 설득 가능한 장수를 나누어, 다음 전장의 말길을 열었습니다.',
rewardExp: 13
},
{
id: 'honor-yizhou-soldiers',
label: '항복 병사를 예우한다',
response: '두 장수가 함께 병사들을 달래자, 익주 병사들은 유비군 군영을 조금 덜 두려워하게 되었습니다.',
rewardExp: 12
}
]
}
];
@@ -4298,6 +4395,66 @@ const campVisits: CampVisitDefinition[] = [
itemRewards: ['익주 창고 장부 1']
}
]
},
{
id: 'mianzhu-storehouse-relief',
title: '면죽관 창고 구호',
location: '면죽관 창고',
availableAfterBattleIds: [campBattleIds.thirtySecond],
bondId: 'liu-bei__li-yan',
description: '유비와 이엄이 면죽관 창고를 열어 항복 병사와 백성에게 공평하게 배급할 방법을 정합니다.',
lines: [
'이엄: 창고를 빼앗긴 병사들은 패배보다 굶주림을 더 두려워합니다.',
'유비: 그렇다면 먼저 명부를 세우고, 우리 병사와 항복 병사를 같은 줄에 세웁시다.',
'창고 앞의 긴장된 줄은 천천히 낮아졌고, 면죽관의 문은 전리품이 아니라 구호소처럼 쓰이기 시작했습니다.'
],
choices: [
{
id: 'equal-storehouse-ration',
label: '배급 줄을 하나로 합친다',
response: '유비군과 항복 병사가 같은 줄에서 배급을 받자, 성도까지 보낼 소문이 한층 부드러워졌습니다.',
bondExp: 28,
itemRewards: ['콩 6', '상처약 3']
},
{
id: 'record-mianzhu-ledger',
label: '창고 장부를 남긴다',
response: '이엄은 창고 장부에 손댄 곡식과 남긴 곡식을 모두 적어, 다음 성도 권고의 증거를 만들었습니다.',
bondExp: 26,
gold: 420,
itemRewards: ['면죽관 창고 장부 1']
}
]
},
{
id: 'chengdu-surrender-council',
title: '성도 항복 권고 회의',
location: '면죽관 본막',
availableAfterBattleIds: [campBattleIds.thirtySecond],
bondId: 'zhuge-liang__li-yan',
description: '제갈량과 이엄이 성도에 보낼 항복 조건을 정리하고, 유장이 받아들일 만한 명분을 다듬습니다.',
lines: [
'제갈량: 권고문은 이기는 군대의 칼이 아니라, 성 안의 사람이 붙잡을 난간이어야 합니다.',
'이엄: 유장공이 체면을 잃지 않는 문장이라면, 문을 여는 사람도 배신자가 아니라 중재자가 될 수 있습니다.',
'두 사람은 항복 뒤 관직과 창고, 병사의 처우를 조목조목 적어 다음 장의 실마리를 만들었습니다.'
],
choices: [
{
id: 'soften-surrender-terms',
label: '항복 조건을 부드럽게 쓴다',
response: '권고문은 위협보다 보전과 질서를 앞세웠고, 성도 안쪽으로 들어갈 여지가 넓어졌습니다.',
bondExp: 28,
itemRewards: ['성도 항복 권고문 1']
},
{
id: 'prepare-city-officer-list',
label: '성도 장수 명단을 정리한다',
response: '이엄의 기억과 제갈량의 판단이 겹쳐지며, 다음 설득전에서 먼저 만날 장수들이 정해졌습니다.',
bondExp: 26,
gold: 440,
itemRewards: ['성도 장수 명단 1']
}
]
}
];
@@ -4384,6 +4541,43 @@ export class CampScene extends Phaser.Scene {
return;
}
if (battleId === campBattleIds.thirtySecond) {
this.campaign = ensureCampaignRosterUnits(
[
...xuzhouRecruitUnits,
...caoBreakRecruitUnits,
...liuBiaoRecruitUnits,
...zhugeRecruitUnits,
...jingzhouRecruitUnits,
...guiyangRecruitUnits,
...wulingRecruitUnits,
...changshaRecruitUnits,
...yizhouRecruitUnits,
...fuPassRecruitUnits,
...luoCastleRecruitUnits,
...luoCastleProperRecruitUnits,
...chengduPressureRecruitUnits
],
[
...xuzhouRecruitBonds,
...caoBreakRecruitBonds,
...liuBiaoRecruitBonds,
...zhugeRecruitBonds,
...jingzhouRecruitBonds,
...guiyangRecruitBonds,
...wulingRecruitBonds,
...changshaRecruitBonds,
...yizhouRecruitBonds,
...fuPassRecruitBonds,
...luoCastleRecruitBonds,
...luoCastleProperRecruitBonds,
...chengduPressureRecruitBonds
]
);
this.report = this.campaign.firstBattleReport ?? this.report;
return;
}
if (battleId === campBattleIds.thirtyFirst) {
this.campaign = ensureCampaignRosterUnits(
[
@@ -4660,6 +4854,9 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
if (battleId === campBattleIds.thirtySecond) {
return '면죽관 압박전 후 군영';
}
if (battleId === campBattleIds.thirtyFirst) {
return '낙성 본성 공략 후 군영';
}

View File

@@ -19,6 +19,7 @@ import {
tenthBattleScenario,
thirteenthBattleScenario,
thirtyFirstBattleScenario,
thirtySecondBattleScenario,
thirtiethBattleScenario,
twentyEighthBattleScenario,
twentyFirstBattleScenario,
@@ -361,7 +362,8 @@ export class TitleScene extends Phaser.Scene {
campaign.step === 'twenty-eighth-camp' ||
campaign.step === 'twenty-ninth-camp' ||
campaign.step === 'thirtieth-camp' ||
campaign.step === 'thirty-first-camp'
campaign.step === 'thirty-first-camp' ||
campaign.step === 'thirty-second-camp'
) {
this.scene.start('CampScene');
return;
@@ -522,6 +524,11 @@ export class TitleScene extends Phaser.Scene {
return;
}
if (campaign.step === 'thirty-second-battle') {
this.scene.start('BattleScene', { battleId: thirtySecondBattleScenario.id });
return;
}
if (campaign.step === 'first-victory-story') {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return;

View File

@@ -103,7 +103,9 @@ export type CampaignStep =
| 'thirtieth-battle'
| 'thirtieth-camp'
| 'thirty-first-battle'
| 'thirty-first-camp';
| 'thirty-first-camp'
| 'thirty-second-battle'
| 'thirty-second-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -196,7 +198,8 @@ const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: Campai
'twenty-eighth-battle-fu-pass-entry': { victory: 'twenty-eighth-camp', retry: 'twenty-eighth-battle' },
'twenty-ninth-battle-luo-outer-wall': { victory: 'twenty-ninth-camp', retry: 'twenty-ninth-battle' },
'thirtieth-battle-luofeng-ambush': { victory: 'thirtieth-camp', retry: 'thirtieth-battle' },
'thirty-first-battle-luo-main-gate': { victory: 'thirty-first-camp', retry: 'thirty-first-battle' }
'thirty-first-battle-luo-main-gate': { victory: 'thirty-first-camp', retry: 'thirty-first-battle' },
'thirty-second-battle-mianzhu-gate': { victory: 'thirty-second-camp', retry: 'thirty-second-battle' }
};
export type CampaignSaveSlotSummary = {