Add equipment growth system
This commit is contained in:
@@ -9,6 +9,13 @@ import {
|
||||
type UnitStats
|
||||
} from '../data/scenario';
|
||||
import { getTerrainRule, getUnitClass, type TerrainType } from '../data/battleRules';
|
||||
import {
|
||||
equipmentExpToNext,
|
||||
equipmentSlotLabels,
|
||||
equipmentSlots,
|
||||
getItem,
|
||||
type EquipmentSlot
|
||||
} from '../data/battleItems';
|
||||
import { palette } from '../ui/palette';
|
||||
|
||||
const unitTexture: Record<string, string> = {
|
||||
@@ -70,6 +77,18 @@ type BondState = BattleBond & {
|
||||
battleExp: number;
|
||||
};
|
||||
|
||||
type EquipmentGrowthResult = {
|
||||
slot: EquipmentSlot;
|
||||
itemName: string;
|
||||
amount: number;
|
||||
level: number;
|
||||
exp: number;
|
||||
next: number;
|
||||
leveled: boolean;
|
||||
};
|
||||
|
||||
const maxEquipmentLevel = 9;
|
||||
|
||||
const commandLabels: Record<BattleCommand, string> = {
|
||||
attack: '공격',
|
||||
strategy: '책략',
|
||||
@@ -724,7 +743,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
this.activeFaction = 'enemy';
|
||||
this.updateTurnText();
|
||||
this.renderSituationPanel('아군 턴을 종료했습니다.\n황건적은 아직 간단 AI 구현 전이라 정비만 하고 넘어갑니다.');
|
||||
const pressureMessage = this.resolveEnemyPressure();
|
||||
this.renderSituationPanel(`아군 턴을 종료했습니다.\n${pressureMessage}`);
|
||||
|
||||
this.time.delayedCall(700, () => this.beginNextAllyTurn());
|
||||
}
|
||||
@@ -757,8 +777,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private recordAttackIntent(unit: UnitData) {
|
||||
const target = this.findNearestEnemy(unit);
|
||||
const weaponGrowth = this.awardEquipmentExp(unit, 'weapon', this.weaponExpGain(unit));
|
||||
if (!target) {
|
||||
return `${unit.name} 공격 명령 완료\n공격 가능한 적이 없습니다.`;
|
||||
return `${unit.name} 공격 명령 완료\n공격 가능한 적이 없습니다.\n${this.formatEquipmentGrowth(weaponGrowth)}`;
|
||||
}
|
||||
|
||||
const partners = this.attackIntents
|
||||
@@ -786,7 +807,70 @@ export class BattleScene extends Phaser.Scene {
|
||||
const bonus = this.strongestBondBonus(unit.id);
|
||||
const chain = bonus.chainRate > 0 ? `\n공명 보너스: 피해 +${bonus.damageBonus}% / 연계 ${bonus.chainRate}%` : '';
|
||||
const exp = gained > 0 ? `\n같은 목표를 노린 공명 경험치 +${gained}` : '\n같은 목표를 노린 장수가 생기면 공명 경험치가 쌓입니다.';
|
||||
return `${unit.name} 공격 명령 완료\n예상 목표: ${target.name}${chain}${exp}`;
|
||||
return `${unit.name} 공격 명령 완료\n예상 목표: ${target.name}\n${this.formatEquipmentGrowth(weaponGrowth)}${chain}${exp}`;
|
||||
}
|
||||
|
||||
private resolveEnemyPressure() {
|
||||
const defender = this.findEnemyPressureTarget();
|
||||
if (!defender) {
|
||||
return '황건적은 움직일 틈을 찾지 못했습니다.';
|
||||
}
|
||||
|
||||
const armorGrowth = this.awardEquipmentExp(defender, 'armor', 6);
|
||||
return `황건적의 견제 공격을 ${defender.name}이 받아냈습니다.\n${this.formatEquipmentGrowth(armorGrowth)}`;
|
||||
}
|
||||
|
||||
private findEnemyPressureTarget() {
|
||||
const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
|
||||
if (allies.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [...allies].sort((a, b) => this.nearestEnemyDistance(a) - this.nearestEnemyDistance(b))[0];
|
||||
}
|
||||
|
||||
private nearestEnemyDistance(unit: UnitData) {
|
||||
const distances = firstBattleUnits
|
||||
.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0)
|
||||
.map((enemy) => this.tileDistance(unit, enemy));
|
||||
return distances.length > 0 ? Math.min(...distances) : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
private weaponExpGain(unit: UnitData) {
|
||||
const weapon = getItem(unit.equipment.weapon.itemId);
|
||||
const bonus = this.strongestBondBonus(unit.id);
|
||||
return 8 + (weapon.rank === 'treasure' ? 2 : 0) + (bonus.chainRate > 0 ? 2 : 0);
|
||||
}
|
||||
|
||||
private awardEquipmentExp(unit: UnitData, slot: EquipmentSlot, amount: number): EquipmentGrowthResult {
|
||||
const state = unit.equipment[slot];
|
||||
const item = getItem(state.itemId);
|
||||
let exp = state.exp + amount;
|
||||
let leveled = false;
|
||||
|
||||
while (state.level < maxEquipmentLevel && exp >= equipmentExpToNext(state.level)) {
|
||||
exp -= equipmentExpToNext(state.level);
|
||||
state.level += 1;
|
||||
leveled = true;
|
||||
}
|
||||
|
||||
const next = equipmentExpToNext(state.level);
|
||||
state.exp = state.level >= maxEquipmentLevel ? Math.min(exp, next) : exp;
|
||||
|
||||
return {
|
||||
slot,
|
||||
itemName: item.name,
|
||||
amount,
|
||||
level: state.level,
|
||||
exp: state.exp,
|
||||
next,
|
||||
leveled
|
||||
};
|
||||
}
|
||||
|
||||
private formatEquipmentGrowth(result: EquipmentGrowthResult) {
|
||||
const levelUp = result.leveled ? ` / Lv ${result.level} 달성` : '';
|
||||
return `${equipmentSlotLabels[result.slot]} ${result.itemName} 경험치 +${result.amount} (${result.exp}/${result.next})${levelUp}`;
|
||||
}
|
||||
|
||||
private findNearestEnemy(unit: UnitData) {
|
||||
@@ -1042,8 +1126,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
if (message) {
|
||||
const messageTop = Math.min(panelY + panelHeight - 254, listTop + units.length * 50 + 14);
|
||||
this.renderPanelMessage(message, left, messageTop, width);
|
||||
const messageHeight = 104;
|
||||
const messageTop = Math.min(panelY + panelHeight - messageHeight - 124, listTop + units.length * 50 + 14);
|
||||
this.renderPanelMessage(message, left, messageTop, width, messageHeight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1153,26 +1238,106 @@ export class BattleScene extends Phaser.Scene {
|
||||
hpValue.setOrigin(1, 0);
|
||||
this.drawGauge(left + 76, top + 84, width - 160, 10, unit.hp / unit.maxHp, 0x59d18c);
|
||||
|
||||
this.renderSmallValueBox(left, top + 112, '공격', `${unit.attack}`);
|
||||
const attackBonus = this.equipmentAttackBonus(unit);
|
||||
this.renderSmallValueBox(left, top + 112, '공격', `${unit.attack + attackBonus}`);
|
||||
this.renderSmallValueBox(left + width / 2 + 6, top + 112, '이동', `${unit.move}`);
|
||||
|
||||
const statTop = top + 164;
|
||||
const statTop = top + 158;
|
||||
statLabels.forEach((stat, index) => {
|
||||
this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 32, width);
|
||||
this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 28, width);
|
||||
});
|
||||
|
||||
const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} / ${terrainRule.label} ${terrainRating}%${acted ? ' / 행동완료' : ''}`;
|
||||
this.trackSideObject(this.add.text(left, top + 328, positionText, {
|
||||
this.trackSideObject(this.add.text(left, top + 306, positionText, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '15px',
|
||||
color: acted ? '#bdbdbd' : '#9fb0bf'
|
||||
}));
|
||||
|
||||
this.renderEquipmentSummary(unit, left, top + 330, width);
|
||||
|
||||
if (message) {
|
||||
this.renderPanelMessage(message, left, top + 352, width);
|
||||
this.renderPanelMessage(message, left, top + 440, width, 50);
|
||||
}
|
||||
}
|
||||
|
||||
private renderEquipmentSummary(unit: UnitData, x: number, y: number, width: number) {
|
||||
this.trackSideObject(this.add.text(x, y, '장비', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '16px',
|
||||
color: '#f2e3bf',
|
||||
fontStyle: '700'
|
||||
}));
|
||||
|
||||
equipmentSlots.forEach((slot, index) => {
|
||||
this.renderEquipmentRow(unit, slot, x, y + 24 + index * 28, width);
|
||||
});
|
||||
}
|
||||
|
||||
private renderEquipmentRow(unit: UnitData, slot: EquipmentSlot, x: number, y: number, width: number) {
|
||||
const state = unit.equipment[slot];
|
||||
const item = getItem(state.itemId);
|
||||
const next = equipmentExpToNext(state.level);
|
||||
const isTreasure = item.rank === 'treasure';
|
||||
const bg = this.trackSideObject(this.add.rectangle(x, y, width, 24, isTreasure ? 0x1f2430 : 0x101820, isTreasure ? 0.94 : 0.86));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.58 : 0.42);
|
||||
|
||||
this.trackSideObject(this.add.text(x + 8, y + 5, equipmentSlotLabels[slot], {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '12px',
|
||||
color: '#9fb0bf',
|
||||
fontStyle: '700'
|
||||
}));
|
||||
this.trackSideObject(this.add.text(x + 54, y + 4, item.name, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '14px',
|
||||
color: isTreasure ? '#f4dfad' : '#d4dce6',
|
||||
fontStyle: isTreasure ? '700' : '400'
|
||||
}));
|
||||
|
||||
const bonusText = this.itemBonusText(item);
|
||||
this.trackSideObject(this.add.text(x + width - 124, y + 6, bonusText, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '11px',
|
||||
color: '#9fb0bf'
|
||||
}));
|
||||
|
||||
const expText = this.trackSideObject(this.add.text(x + width - 54, y + 6, `${state.exp}/${next}`, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '11px',
|
||||
color: '#9fb0bf'
|
||||
}));
|
||||
expText.setOrigin(1, 0);
|
||||
|
||||
const levelText = this.trackSideObject(this.add.text(x + width - 8, y + 3, `Lv ${state.level}`, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '14px',
|
||||
color: isTreasure ? '#f2e3bf' : '#d4dce6',
|
||||
fontStyle: '700'
|
||||
}));
|
||||
levelText.setOrigin(1, 0);
|
||||
|
||||
this.drawGauge(x + 54, y + 19, width - 154, 4, state.exp / next, isTreasure ? 0xd8b15f : 0x58aee0);
|
||||
}
|
||||
|
||||
private itemBonusText(item: ReturnType<typeof getItem>) {
|
||||
const parts = [
|
||||
item.attackBonus ? `공+${item.attackBonus}` : '',
|
||||
item.defenseBonus ? `방+${item.defenseBonus}` : '',
|
||||
item.strategyBonus ? `책+${item.strategyBonus}` : ''
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : '-';
|
||||
}
|
||||
|
||||
private equipmentAttackBonus(unit: UnitData) {
|
||||
return equipmentSlots.reduce((total, slot) => {
|
||||
const item = getItem(unit.equipment[slot].itemId);
|
||||
const levelBonus = slot === 'weapon' ? unit.equipment[slot].level - 1 : 0;
|
||||
return total + (item.attackBonus ?? 0) + levelBonus;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private renderSmallValueBox(x: number, y: number, label: string, value: string) {
|
||||
const boxWidth = (this.layout.panelWidth - 60) / 2;
|
||||
const bg = this.trackSideObject(this.add.rectangle(x, y, boxWidth, 38, 0x16212d, 0.92));
|
||||
@@ -1221,16 +1386,16 @@ export class BattleScene extends Phaser.Scene {
|
||||
fill.setOrigin(0);
|
||||
}
|
||||
|
||||
private renderPanelMessage(text: string, x: number, y: number, width: number) {
|
||||
const bg = this.trackSideObject(this.add.rectangle(x, y, width, 74, 0x101820, 0.72));
|
||||
private renderPanelMessage(text: string, x: number, y: number, width: number, height = 74) {
|
||||
const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x101820, 0.72));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, 0x53606c, 0.42);
|
||||
this.trackSideObject(this.add.text(x + 12, y + 10, text, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '15px',
|
||||
fontSize: height < 60 ? '13px' : '15px',
|
||||
color: '#d4dce6',
|
||||
wordWrap: { width: width - 24, useAdvancedWrap: true },
|
||||
lineSpacing: 4
|
||||
lineSpacing: height < 60 ? 2 : 4
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user