Add Guangzong camp battle and camp events
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Phaser from 'phaser';
|
||||
import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png';
|
||||
import fourthBattleMapUrl from '../../assets/images/battle/fourth-battle-map.svg';
|
||||
import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg';
|
||||
import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg';
|
||||
import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png';
|
||||
@@ -64,6 +65,7 @@ export class BootScene extends Phaser.Scene {
|
||||
this.load.image('battle-map-first', firstBattleMapUrl);
|
||||
this.load.image('battle-map-second', secondBattleMapUrl);
|
||||
this.load.image('battle-map-third', thirdBattleMapUrl);
|
||||
this.load.image('battle-map-fourth', fourthBattleMapUrl);
|
||||
this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
|
||||
this.load.image('portrait-guan-yu', guanYuPortraitUrl);
|
||||
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);
|
||||
|
||||
@@ -25,6 +25,14 @@ type CampDialogue = {
|
||||
bondId: string;
|
||||
rewardExp: number;
|
||||
lines: string[];
|
||||
choices: CampDialogueChoice[];
|
||||
};
|
||||
|
||||
type CampDialogueChoice = {
|
||||
id: string;
|
||||
label: string;
|
||||
response: string;
|
||||
rewardExp: number;
|
||||
};
|
||||
|
||||
type CampTabButtonView = {
|
||||
@@ -41,6 +49,13 @@ type CampSupplyDefinition = {
|
||||
bondExp: number;
|
||||
};
|
||||
|
||||
type MerchantItemDefinition = {
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
type SortieChecklistItem = {
|
||||
label: string;
|
||||
complete: boolean;
|
||||
@@ -73,11 +88,39 @@ const campSupplies: CampSupplyDefinition[] = [
|
||||
description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.',
|
||||
healHp: 8,
|
||||
bondExp: 2
|
||||
},
|
||||
{
|
||||
label: '상처약',
|
||||
title: '상처약',
|
||||
description: '선택 장수의 병력을 22 회복합니다.',
|
||||
healHp: 22,
|
||||
bondExp: 0
|
||||
}
|
||||
];
|
||||
|
||||
const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, supply]));
|
||||
|
||||
const merchantItems: MerchantItemDefinition[] = [
|
||||
{
|
||||
label: '콩',
|
||||
title: '콩',
|
||||
description: '값싸고 가벼운 회복 도구입니다.',
|
||||
price: 40
|
||||
},
|
||||
{
|
||||
label: '탁주',
|
||||
title: '탁주',
|
||||
description: '병력 회복과 작은 공명 상승에 유용합니다.',
|
||||
price: 70
|
||||
},
|
||||
{
|
||||
label: '상처약',
|
||||
title: '상처약',
|
||||
description: '큰 부상을 빠르게 회복합니다.',
|
||||
price: 110
|
||||
}
|
||||
];
|
||||
|
||||
const campDialogues: CampDialogue[] = [
|
||||
{
|
||||
id: 'liu-guan-after-first-battle',
|
||||
@@ -89,6 +132,20 @@ const campDialogues: CampDialogue[] = [
|
||||
'유비: 운장, 오늘 그대가 앞을 막아 주지 않았다면 백성들을 지키지 못했을 것이오.',
|
||||
'관우: 형님의 뜻이 분명했기에 칼을 들 수 있었습니다. 다음 싸움에서도 길을 열겠습니다.',
|
||||
'유비: 우리의 맹세가 전장에서 더 단단해지는구려.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'trust-vanguard',
|
||||
label: '앞길을 맡긴다',
|
||||
response: '관우는 말없이 고개를 숙였다. 다음 전장에서도 형님의 길을 열겠다는 결의가 깊어졌다.',
|
||||
rewardExp: 6
|
||||
},
|
||||
{
|
||||
id: 'protect-people',
|
||||
label: '백성 보호를 당부한다',
|
||||
response: '관우는 칼끝보다 백성의 숨결을 먼저 살피겠다고 답했다. 두 사람의 뜻이 한층 가까워졌다.',
|
||||
rewardExp: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -101,6 +158,20 @@ const campDialogues: CampDialogue[] = [
|
||||
'장비: 형님, 다음엔 제가 먼저 뛰쳐나가 두령 놈을 단번에 꺾겠습니다!',
|
||||
'유비: 익덕의 용맹은 든든하나, 백성이 뒤에 있음을 잊지 말아야 하오.',
|
||||
'장비: 하하! 형님 말이라면 참아 보겠습니다. 대신 싸울 때는 크게 치겠습니다.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'temper-courage',
|
||||
label: '용맹을 다독인다',
|
||||
response: '장비는 답답하다는 듯 웃었지만, 형님의 말에 맞춰 창을 거두는 법도 익히겠다고 했다.',
|
||||
rewardExp: 5
|
||||
},
|
||||
{
|
||||
id: 'lead-charge',
|
||||
label: '선봉을 약속한다',
|
||||
response: '장비의 눈빛이 밝아졌다. 믿고 맡긴다는 말은 그에게 무엇보다 큰 격려였다.',
|
||||
rewardExp: 7
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -113,6 +184,20 @@ const campDialogues: CampDialogue[] = [
|
||||
'관우: 익덕, 적이 흩어질 때 너무 깊이 들어가면 형님이 걱정하신다.',
|
||||
'장비: 운장 형님이 옆을 받쳐 준다면 걱정할 일이 없지 않습니까?',
|
||||
'관우: 좋다. 다음 전장에서는 서로의 빈틈을 먼저 살피자.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'cover-flanks',
|
||||
label: '서로의 측면을 맡긴다',
|
||||
response: '칼과 창이 같은 호흡으로 움직일 길을 찾았다. 두 맹장의 공명이 단단해졌다.',
|
||||
rewardExp: 6
|
||||
},
|
||||
{
|
||||
id: 'compete-boldly',
|
||||
label: '누가 먼저 베는지 겨룬다',
|
||||
response: '장비가 크게 웃고 관우도 미소를 감추지 못했다. 경쟁심마저 전장의 힘이 되었다.',
|
||||
rewardExp: 4
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -613,7 +698,7 @@ export class CampScene extends Phaser.Scene {
|
||||
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 + 56, '대화를 보면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));
|
||||
this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));
|
||||
|
||||
campDialogues.forEach((dialogue, index) => {
|
||||
const completed = this.completedCampDialogues().includes(dialogue.id);
|
||||
@@ -629,7 +714,8 @@ export class CampScene extends Phaser.Scene {
|
||||
this.render();
|
||||
});
|
||||
this.track(this.add.text(x + 38, rowY + 8, dialogue.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
|
||||
this.track(this.add.text(x + 38, rowY + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}`, this.textStyle(12, '#9fb0bf')));
|
||||
const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp));
|
||||
this.track(this.add.text(x + 38, rowY + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(12, '#9fb0bf')));
|
||||
});
|
||||
|
||||
this.renderSelectedDialogue(x + 372, y + 96, 416, 300);
|
||||
@@ -651,19 +737,29 @@ export class CampScene extends Phaser.Scene {
|
||||
})
|
||||
);
|
||||
|
||||
const buttonLabel = completed ? '대화 완료' : `대화 보기 공명 +${dialogue.rewardExp}`;
|
||||
const button = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, completed ? 0x17231d : 0x1a2630, 0.96));
|
||||
button.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.76);
|
||||
if (!completed) {
|
||||
if (completed) {
|
||||
const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96));
|
||||
done.setStrokeStyle(1, palette.green, 0.76);
|
||||
const text = this.track(this.add.text(x + width / 2, y + height - 34, '대화 완료', this.textStyle(15, '#a8ffd0', true)));
|
||||
text.setOrigin(0.5);
|
||||
return;
|
||||
}
|
||||
|
||||
dialogue.choices.forEach((choice, index) => {
|
||||
const choiceY = y + height - 80 + index * 42;
|
||||
const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96));
|
||||
button.setStrokeStyle(1, palette.gold, 0.76);
|
||||
button.setInteractive({ useHandCursor: true });
|
||||
button.on('pointerdown', () => this.completeDialogue(dialogue));
|
||||
}
|
||||
const text = this.track(this.add.text(x + width / 2, y + height - 34, buttonLabel, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
|
||||
text.setOrigin(0.5);
|
||||
if (!completed) {
|
||||
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
|
||||
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
|
||||
button.on('pointerdown', () => this.completeDialogue(dialogue, choice));
|
||||
|
||||
const label = `${choice.label} 공명 +${dialogue.rewardExp + choice.rewardExp}`;
|
||||
const text = this.track(this.add.text(x + width / 2, choiceY, label, this.textStyle(14, '#f2e3bf', true)));
|
||||
text.setOrigin(0.5);
|
||||
text.setInteractive({ useHandCursor: true });
|
||||
text.on('pointerdown', () => this.completeDialogue(dialogue));
|
||||
}
|
||||
text.on('pointerdown', () => this.completeDialogue(dialogue, choice));
|
||||
});
|
||||
}
|
||||
|
||||
private renderBondList(x: number, y: number, width: number) {
|
||||
@@ -695,30 +791,70 @@ export class CampScene extends Phaser.Scene {
|
||||
this.renderSupplyUseRow(supply, x + 390, y + 94 + index * 74, 390);
|
||||
});
|
||||
|
||||
this.track(this.add.text(x + 24, y + 202, '전리품과 명성', this.textStyle(19, '#f2e3bf', true)));
|
||||
this.renderMerchantPanel(x + 24, y + 202, 342);
|
||||
|
||||
this.track(this.add.text(x + 390, y + 330, '전리품과 명성', this.textStyle(19, '#f2e3bf', true)));
|
||||
const trophies = this.nonConsumableInventoryLabels();
|
||||
if (trophies.length === 0) {
|
||||
this.track(this.add.text(x + 24, y + 236, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf')));
|
||||
this.track(this.add.text(x + 390, y + 364, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf')));
|
||||
} else {
|
||||
trophies.forEach((reward, index) => {
|
||||
this.renderSupplyBox(reward, x + 24 + index * 154, y + 232);
|
||||
this.renderSupplyBox(reward, x + 390 + index * 128, y + 360);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.track(this.add.text(x + 24, y + 306, '부대 장비 요약', this.textStyle(19, '#f2e3bf', true)));
|
||||
this.currentUnits()
|
||||
.filter((unit) => unit.faction === 'ally')
|
||||
.forEach((unit, index) => {
|
||||
const rowY = y + 342 + index * 30;
|
||||
this.track(this.add.text(x + 24, rowY, unit.name, this.textStyle(14, '#f2e3bf', true)));
|
||||
equipmentSlots.forEach((slot, slotIndex) => {
|
||||
const state = unit.equipment[slot];
|
||||
const item = getItem(state.itemId);
|
||||
const slotX = x + 104 + slotIndex * 220;
|
||||
this.track(this.add.text(slotX, rowY - 2, `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}`, this.textStyle(11, '#d4dce6')));
|
||||
this.drawBar(slotX, rowY + 17, 176, 6, state.exp / equipmentExpToNext(state.level), item.rank === 'treasure' ? palette.gold : palette.blue);
|
||||
});
|
||||
});
|
||||
private renderMerchantPanel(x: number, y: number, width: number) {
|
||||
const campaign = this.campaign ?? getCampaignState();
|
||||
const bg = this.track(this.add.rectangle(x, y, width, 210, 0x0d141c, 0.92));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, palette.gold, 0.48);
|
||||
this.track(this.add.text(x + 16, y + 10, `군영 상인 · 군자금 ${campaign.gold}`, this.textStyle(17, '#f2e3bf', true)));
|
||||
|
||||
merchantItems.forEach((item, index) => {
|
||||
this.renderMerchantItem(item, x + 16, y + 44 + index * 52, width - 32);
|
||||
});
|
||||
}
|
||||
|
||||
private renderMerchantItem(item: MerchantItemDefinition, x: number, y: number, width: number) {
|
||||
const campaign = this.campaign ?? getCampaignState();
|
||||
const canBuy = campaign.gold >= item.price;
|
||||
const bg = this.track(this.add.rectangle(x, y, width, 40, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.58 : 0.36);
|
||||
this.track(this.add.text(x + 10, y + 6, item.title, this.textStyle(13, canBuy ? '#f2e3bf' : '#7f8994', true)));
|
||||
this.track(this.add.text(x + 10, y + 23, `${item.price}금`, this.textStyle(11, canBuy ? '#d8b15f' : '#7f8994', true)));
|
||||
|
||||
const button = this.track(this.add.rectangle(x + width - 42, y + 20, 60, 26, canBuy ? 0x1a2630 : 0x121922, canBuy ? 0.96 : 0.72));
|
||||
button.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.72 : 0.4);
|
||||
const text = this.track(this.add.text(x + width - 42, y + 20, '구매', this.textStyle(12, canBuy ? '#f2e3bf' : '#7f8994', true)));
|
||||
text.setOrigin(0.5);
|
||||
|
||||
if (canBuy) {
|
||||
const action = () => this.buyMerchantItem(item);
|
||||
button.setInteractive({ useHandCursor: true });
|
||||
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
|
||||
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
|
||||
button.on('pointerdown', action);
|
||||
text.setInteractive({ useHandCursor: true });
|
||||
text.on('pointerdown', action);
|
||||
}
|
||||
}
|
||||
|
||||
private buyMerchantItem(item: MerchantItemDefinition) {
|
||||
const campaign = this.campaign ?? getCampaignState();
|
||||
if (campaign.gold < item.price) {
|
||||
this.showCampNotice('군자금이 부족합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
campaign.gold -= item.price;
|
||||
campaign.inventory[item.label] = (campaign.inventory[item.label] ?? 0) + 1;
|
||||
this.campaign = saveCampaignState(campaign);
|
||||
this.report = this.campaign.firstBattleReport ?? this.report;
|
||||
soundDirector.playSelect();
|
||||
this.showCampNotice(`${item.title} 구입 · 군자금 -${item.price}`);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private renderSupplyBox(label: string, x: number, y: number) {
|
||||
@@ -932,8 +1068,9 @@ export class CampScene extends Phaser.Scene {
|
||||
return bonuses.join(' / ') || '특수 보정 없음';
|
||||
}
|
||||
|
||||
private completeDialogue(dialogue: CampDialogue) {
|
||||
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, dialogue.rewardExp);
|
||||
private completeDialogue(dialogue: CampDialogue, choice: CampDialogueChoice) {
|
||||
const rewardExp = dialogue.rewardExp + choice.rewardExp;
|
||||
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp);
|
||||
if (updated) {
|
||||
this.report = updated;
|
||||
this.campaign = getCampaignState();
|
||||
@@ -941,27 +1078,32 @@ export class CampScene extends Phaser.Scene {
|
||||
this.report.completedCampDialogues.push(dialogue.id);
|
||||
const bond = this.report.bonds.find((candidate) => candidate.id === dialogue.bondId);
|
||||
if (bond) {
|
||||
bond.battleExp += dialogue.rewardExp;
|
||||
bond.exp += dialogue.rewardExp;
|
||||
bond.battleExp += rewardExp;
|
||||
bond.exp += rewardExp;
|
||||
}
|
||||
}
|
||||
|
||||
soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 });
|
||||
this.showDialogueReward(dialogue);
|
||||
this.showDialogueReward(dialogue, choice, rewardExp);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private showDialogueReward(dialogue: CampDialogue) {
|
||||
this.showCampNotice(`${dialogue.title} 완료 · 공명 +${dialogue.rewardExp}`);
|
||||
private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) {
|
||||
this.showCampNotice(`${choice.label} · 공명 +${rewardExp}`);
|
||||
this.time.delayedCall(120, () => this.showCampNotice(choice.response));
|
||||
}
|
||||
|
||||
private showCampNotice(message: string) {
|
||||
this.dialogueObjects.forEach((object) => object.destroy());
|
||||
this.dialogueObjects = [];
|
||||
const box = this.add.rectangle(this.scale.width / 2, 104, 440, 52, 0x101820, 0.96);
|
||||
const box = this.add.rectangle(this.scale.width / 2, 104, 720, 58, 0x101820, 0.96);
|
||||
box.setStrokeStyle(2, palette.gold, 0.84);
|
||||
box.setDepth(30);
|
||||
const text = this.add.text(this.scale.width / 2, 104, message, this.textStyle(17, '#f2e3bf', true));
|
||||
const text = this.add.text(this.scale.width / 2, 104, message, {
|
||||
...this.textStyle(16, '#f2e3bf', true),
|
||||
align: 'center',
|
||||
wordWrap: { width: 670, useAdvancedWrap: true }
|
||||
});
|
||||
text.setOrigin(0.5);
|
||||
text.setDepth(31);
|
||||
this.dialogueObjects.push(box, text);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Phaser from 'phaser';
|
||||
import { soundDirector } from '../audio/SoundDirector';
|
||||
import { firstBattleVictoryPages } from '../data/scenario';
|
||||
import { secondBattleScenario, thirdBattleScenario } from '../data/battles';
|
||||
import { fourthBattleScenario, secondBattleScenario, thirdBattleScenario } from '../data/battles';
|
||||
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
|
||||
import { palette } from '../ui/palette';
|
||||
|
||||
@@ -299,7 +299,7 @@ export class TitleScene extends Phaser.Scene {
|
||||
soundDirector.playSelect();
|
||||
|
||||
const campaign = loadCampaignState();
|
||||
if (campaign.step === 'first-camp' || campaign.step === 'second-camp' || campaign.step === 'third-camp') {
|
||||
if (campaign.step === 'first-camp' || campaign.step === 'second-camp' || campaign.step === 'third-camp' || campaign.step === 'fourth-camp') {
|
||||
this.scene.start('CampScene');
|
||||
return;
|
||||
}
|
||||
@@ -319,6 +319,11 @@ export class TitleScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
if (campaign.step === 'fourth-battle') {
|
||||
this.scene.start('BattleScene', { battleId: fourthBattleScenario.id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (campaign.step === 'first-victory-story') {
|
||||
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user