feat: add playable first-battle militia camp

This commit is contained in:
2026-07-26 23:20:47 +09:00
parent 412b8691b5
commit 9beed0ac02
20 changed files with 2477 additions and 178 deletions

View File

@@ -1,7 +1,7 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import {
prologueDeparturePages,
prologueBrotherhoodPages,
prologueVillageNpcDefinitions,
prologueVillageObjectiveDefinitions,
prologueVillageRequiredObjectiveIds,
@@ -75,7 +75,7 @@ type ObjectiveRowView = {
const objectiveTutorialIds: Record<PrologueVillageRequiredObjectiveId, CampaignTutorialId> = {
'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei,
'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu,
'check-supplies': prologueVillageCampaignTutorialIds.checkSupplies
'register-volunteers': prologueVillageCampaignTutorialIds.registerVolunteers
};
export class PrologueVillageScene extends Phaser.Scene {
@@ -150,6 +150,7 @@ export class PrologueVillageScene extends Phaser.Scene {
}
ensureUnitAnimations(this, characterTextureKeys);
this.createActors();
this.restoreNarrativeActorPositions();
this.createDialoguePanel();
this.loadingOverlay?.destroy();
this.loadingOverlay = undefined;
@@ -164,7 +165,7 @@ export class PrologueVillageScene extends Phaser.Scene {
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '장터에서 뜻을 함께할 사람을 찾고 의병소의 준비를 확인하자. 모든 준비가 끝나면 북쪽 복숭아 동산으로 가야겠다.'
text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.'
}
]);
}
@@ -246,7 +247,9 @@ export class PrologueVillageScene extends Phaser.Scene {
completed: objective.id === 'make-oath'
? hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete)
: this.isObjectiveComplete(objective.id),
unlocked: objective.id !== 'make-oath' || this.allRequiredObjectivesComplete()
unlocked: objective.id === 'make-oath'
? this.allRequiredObjectivesComplete()
: this.objectiveUnlocked(objective.id)
})),
completedObjectiveIds: this.completedRequiredObjectiveIds(),
exitUnlocked: this.allRequiredObjectivesComplete(),
@@ -350,8 +353,9 @@ export class PrologueVillageScene extends Phaser.Scene {
private prepareCampaignProgress() {
const campaign = getCampaignState();
this.firstVisit = campaign.step !== 'prologue-town' &&
!campaign.completedTutorialIds.includes(prologueVillageCampaignTutorialIds.entered);
this.firstVisit = !campaign.completedTutorialIds.includes(
prologueVillageCampaignTutorialIds.entered
);
if (campaign.step === 'new' || campaign.step === 'prologue') {
markCampaignStep('prologue-town');
}
@@ -427,7 +431,7 @@ export class PrologueVillageScene extends Phaser.Scene {
height: 236,
wallColor: 0xb99a68,
roofColor: 0x6d2f26,
sign: '장터 주점'
sign: '탁현 관아'
});
this.addBlocker(105, 160, 410, 236);
@@ -438,7 +442,7 @@ export class PrologueVillageScene extends Phaser.Scene {
height: 226,
wallColor: 0xc2a974,
roofColor: 0x31475a,
sign: '동쪽 장터'
sign: '장터 주점'
});
this.addBlocker(990, 148, 390, 226);
@@ -449,7 +453,7 @@ export class PrologueVillageScene extends Phaser.Scene {
height: 215,
wallColor: 0xa98d61,
roofColor: 0x493a32,
sign: '탁현 병소'
sign: '탁현 병소'
});
this.addBlocker(1120, 665, 310, 215);
@@ -542,6 +546,22 @@ export class PrologueVillageScene extends Phaser.Scene {
weapons.lineBetween(1070, 710, 1110, 805);
weapons.lineBetween(1110, 710, 1070, 805);
const notice = this.add.graphics().setDepth(34);
notice.fillStyle(0x4d3826, 1);
notice.fillRect(264, 412, 12, 92);
notice.fillRect(440, 412, 12, 92);
notice.fillRect(250, 402, 216, 18);
notice.fillStyle(0xe3d4a8, 1);
notice.fillRect(286, 427, 144, 62);
notice.lineStyle(2, 0x765e3d, 0.8);
notice.strokeRect(286, 427, 144, 62);
this.add.text(358, 457, '의병 모집', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: '#3c2a1c',
fontStyle: 'bold'
}).setOrigin(0.5).setDepth(35);
this.add.text(780, 244, '복숭아 동산', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '28px',
@@ -588,13 +608,13 @@ export class PrologueVillageScene extends Phaser.Scene {
}
private drawHud() {
this.add.text(48, 27, '탁현 · 황건 봉기 직전', {
this.add.text(48, 27, '탁현 · 의병 모집 격문이 붙은 날', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '34px',
color: '#f4e3b5',
fontStyle: 'bold'
}).setDepth(1500);
this.add.text(460, 36, '유비의 첫걸음', {
this.add.text(650, 36, '유비의 첫걸음', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: '#b4c0cb'
@@ -670,11 +690,11 @@ export class PrologueVillageScene extends Phaser.Scene {
color: '#e3e7eb'
}).setDepth(1502);
this.promptBackground = this.add.rectangle(870, 1018, 540, 58, 0x2a2119, 0.98)
this.promptBackground = this.add.rectangle(1110, 1018, 560, 58, 0x2a2119, 0.98)
.setStrokeStyle(2, palette.gold, 0.9)
.setDepth(1510)
.setVisible(false);
this.promptText = this.add.text(870, 1018, '', {
this.promptText = this.add.text(1110, 1018, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
color: '#f6e3af',
@@ -1019,6 +1039,43 @@ export class PrologueVillageScene extends Phaser.Scene {
this.stopPlayerMovement();
}
private gatherOathParty() {
this.playerDirection = 'north';
this.setPlayerPosition(oathPosition.x, oathPosition.y + 125);
this.setNpcPosition('guan-yu', oathPosition.x - 104, oathPosition.y + 32, 'east');
this.setNpcPosition('zhang-fei', oathPosition.x + 104, oathPosition.y + 32, 'west');
}
private restoreNarrativeActorPositions() {
if (this.isObjectiveComplete('meet-guan-yu')) {
this.setNpcPosition('guan-yu', 850, 655, 'east');
this.setNpcPosition('zhang-fei', 810, 805, 'east');
return;
}
if (this.isObjectiveComplete('meet-zhang-fei')) {
this.setNpcPosition('zhang-fei', 1000, 540, 'east');
}
}
private setNpcPosition(
npcId: string,
x: number,
y: number,
direction: UnitDirection
) {
const view = this.npcViews.get(npcId);
if (!view) {
return;
}
view.sprite.setPosition(x, y).setDepth(110 + y);
view.shadow.setPosition(x, y + 20).setDepth(100 + y);
view.nameplate.setPosition(x, y + 63);
view.marker?.setPosition(x, y - 72).setVisible(false);
if (this.textures.exists(view.definition.textureKey)) {
view.sprite.play(`${view.definition.textureKey}-idle-${direction}`);
}
}
private safeApproachPosition(targetX: number, targetY: number) {
const candidates = [
{ x: targetX, y: targetY + 88 },
@@ -1055,11 +1112,14 @@ export class PrologueVillageScene extends Phaser.Scene {
const completed = view.definition.objectiveId
? this.isObjectiveComplete(view.definition.objectiveId)
: false;
const dialogue = completed && view.definition.repeatDialogue?.length
? view.definition.repeatDialogue
: view.definition.dialogue;
const unlocked = !view.definition.objectiveId || this.objectiveUnlocked(view.definition.objectiveId);
const dialogue = !unlocked && view.definition.lockedDialogue?.length
? view.definition.lockedDialogue
: completed && view.definition.repeatDialogue?.length
? view.definition.repeatDialogue
: view.definition.dialogue;
this.startDialogue(dialogue, () => {
if (view.definition.objectiveId && !completed) {
if (view.definition.objectiveId && unlocked && !completed) {
this.completeObjective(view.definition.objectiveId);
}
}, view.definition.id);
@@ -1081,35 +1141,27 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
this.gatherOathParty();
this.startDialogue([
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '내 장원 뒤 복숭아꽃이 한창이오. 싸움에 앞서 서로의 등을 맡길 뜻부터 하늘과 땅에 고합시다.'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '우리는 성은 다르나 이제 형제가 되어, 위로는 나라에 보답하고 아래로는 백성을 편안하게 하겠습니다.'
text: '좋소. 성은 서로 다르지만 백성을 구하려는 뜻은 하나이니, 오늘부터 형제가 되어 힘을 합칩시다.'
},
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '한마음으로 힘을 합쳐 어려움 속에서도 서로를 저버리지 않겠습니다.'
text: '의를 저버리지 않고 형제를 저버리지 않겠습니다. 어려움이 닥쳐도 함께 맞서겠습니다.'
},
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '좋소! 오늘부터 함께 싸워 이 마을과 백성을 지킵시다!'
},
{
speaker: '전황',
text: '세 사람의 뜻이 하나로 공명했다. 탁현 의용군이 북문 앞에 모이기 시작했다.'
},
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '황건 잔당은 북동쪽 길목에 있습니다. 장비가 측면을 흔들면 제가 정면을 열겠습니다.'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '좋소. 부상자를 지키며 전열을 잇겠습니다. 이제 첫 출전입니다.'
text: '형님들의 뜻을 내 목숨처럼 지키겠소. 결의를 마치면 내 장원 밖 막사에서 의병들을 함께 살핍시다!'
}
], () => this.finishVillage(), 'make-oath');
}
@@ -1178,6 +1230,12 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
if (objectiveId === 'meet-zhang-fei') {
this.setNpcPosition('zhang-fei', 1000, 540, 'east');
} else if (objectiveId === 'meet-guan-yu') {
this.setNpcPosition('guan-yu', 850, 655, 'east');
this.setNpcPosition('zhang-fei', 810, 805, 'east');
}
soundDirector.playObjectiveAchieved();
this.refreshObjectiveHud();
this.refreshNpcMarkers();
@@ -1202,9 +1260,8 @@ export class PrologueVillageScene extends Phaser.Scene {
this.showTransitionOverlay();
void startGameScene(this, 'StoryScene', {
pages: prologueDeparturePages(),
nextScene: 'BattleScene',
nextSceneData: { battleId: 'first-battle-zhuo-commandery' },
pages: prologueBrotherhoodPages(),
nextScene: 'PrologueMilitiaCampScene',
presentationBattleId: 'first-battle-zhuo-commandery',
presentationStage: 'story'
}).catch((error) => {
@@ -1218,13 +1275,13 @@ export class PrologueVillageScene extends Phaser.Scene {
private showTransitionOverlay() {
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.78).setOrigin(0);
const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, '탁현 의용군 · 첫 출전', {
const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, '도원결의 · 세 사람의 맹세', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '44px',
color: '#f3dda2',
fontStyle: 'bold'
}).setOrigin(0.5);
const subtitle = this.add.text(sceneWidth / 2, sceneHeight / 2 + 44, '북문 밖의 황건 잔당을 향해 이동합니다.', {
const subtitle = this.add.text(sceneWidth / 2, sceneHeight / 2 + 44, '맹세를 마친 뒤 의용군 막사에서 첫 출전을 준비합니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px',
color: '#d7dde4'
@@ -1284,7 +1341,9 @@ export class PrologueVillageScene extends Phaser.Scene {
} else {
completed = this.isObjectiveComplete(objective.id);
}
const unlocked = !isOath || this.allRequiredObjectivesComplete();
const unlocked = isOath
? this.allRequiredObjectivesComplete()
: this.objectiveUnlocked(objective.id);
row.status.setText(completed ? '✓ 완료' : unlocked ? '◆ 진행 가능' : '◇ 준비 후 개방');
row.status.setColor(completed ? '#a8d58e' : unlocked ? '#e1bc69' : '#7f8994');
row.label.setColor(completed ? '#b8c4b3' : unlocked ? '#f0e6d2' : '#7f8994');
@@ -1299,7 +1358,7 @@ export class PrologueVillageScene extends Phaser.Scene {
const remaining = prologueVillageRequiredObjectiveIds.length - requiredComplete.length;
this.objectiveSummaryText?.setText(
remaining > 0
? `필수 준비 ${requiredComplete.length} / ${prologueVillageRequiredObjectiveIds.length}\n세 항목은 원하는 순서로 확인할 수 있습니다.`
? `만남의 흐름 ${requiredComplete.length} / ${prologueVillageRequiredObjectiveIds.length}\n◆ 표시된 다음 인물을 찾아 이야기하세요.`
: '모든 준비 완료\n복숭아 동산의 빛나는 표식으로 이동하세요.'
);
if (this.oathMarker) {
@@ -1318,9 +1377,10 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
const completed = this.isObjectiveComplete(definition.objectiveId);
marker.setText(completed ? '✓' : '!');
marker.setColor(completed ? '#e5f2d5' : '#2a2013');
marker.setBackgroundColor(completed ? '#4e7549' : '#e5bd68');
const unlocked = this.objectiveUnlocked(definition.objectiveId);
marker.setText(completed ? '✓' : unlocked ? '!' : '');
marker.setColor(completed ? '#e5f2d5' : unlocked ? '#2a2013' : '#9b8f7d');
marker.setBackgroundColor(completed ? '#4e7549' : unlocked ? '#e5bd68' : '#343b45');
});
}
@@ -1409,6 +1469,14 @@ export class PrologueVillageScene extends Phaser.Scene {
return hasCompletedCampaignTutorial(objectiveTutorialIds[objectiveId]);
}
private objectiveUnlocked(objectiveId: PrologueVillageRequiredObjectiveId | 'make-oath') {
if (objectiveId === 'make-oath') {
return this.allRequiredObjectivesComplete();
}
const objective = prologueVillageObjectiveDefinitions.find((entry) => entry.id === objectiveId);
return !objective?.prerequisiteId || this.isObjectiveComplete(objective.prerequisiteId);
}
private completedRequiredObjectiveIds() {
return prologueVillageRequiredObjectiveIds.filter((objectiveId) => this.isObjectiveComplete(objectiveId));
}