From b0c6c0ebeadc39a74b1ebac5adeeb8c01f941af4 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 02:27:03 +0900 Subject: [PATCH] Add equipment growth system --- docs/roadmap.md | 9 +- src/game/data/battleItems.ts | 203 +++++++++++++++++++++++++++++++++ src/game/data/scenario.ts | 37 ++++++ src/game/scenes/BattleScene.ts | 193 ++++++++++++++++++++++++++++--- 4 files changed, 424 insertions(+), 18 deletions(-) create mode 100644 src/game/data/battleItems.ts diff --git a/docs/roadmap.md b/docs/roadmap.md index 7975df0..8690cad 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,15 +16,16 @@ Build a small complete tactical RPG loop: - Cinematic title scene with an original Taoyuan Oath background, subtle motion, menu UI, and first-input audio - Cinematic prologue pages with high-resolution story backgrounds, character portraits, non-black scene transitions, and scenario data - File-based original soft stereo orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes -- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, class and terrain affinity rules, ally/enemy roster tabs, unit detail stat panels, terrain-cost movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, right-click tactical menu, turn ending/reset, resonance bond tracking, and grayscale acted-unit feedback +- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, class and terrain affinity rules, ally/enemy roster tabs, unit detail stat panels, weapon/armor/accessory equipment growth, terrain-cost movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, right-click tactical menu, turn ending/reset, resonance bond tracking, and grayscale acted-unit feedback - Flow verification script from title to first battle ## Next Steps 1. Implement attack range, damage, defeat, victory checks, and real resonance assist attacks -2. Add strategy and item inventories with usable battle effects -3. Add a simple enemy AI turn -4. Add battle result scene and local save/load +2. Apply treasure equipment effects to damage, defense, recovery, and command rules +3. Add strategy and item inventories with usable battle effects +4. Add a simple enemy AI turn +5. Add battle result scene and local save/load ## Content Direction diff --git a/src/game/data/battleItems.ts b/src/game/data/battleItems.ts new file mode 100644 index 0000000..04ffd6d --- /dev/null +++ b/src/game/data/battleItems.ts @@ -0,0 +1,203 @@ +export type EquipmentSlot = 'weapon' | 'armor' | 'accessory'; + +export type EquipmentRank = 'common' | 'treasure'; + +export type EquipmentState = { + itemId: string; + level: number; + exp: number; +}; + +export type EquipmentSet = Record; + +export type ItemDefinition = { + id: string; + name: string; + slot: EquipmentSlot; + rank: EquipmentRank; + description: string; + attackBonus?: number; + defenseBonus?: number; + strategyBonus?: number; + effects: string[]; +}; + +export const equipmentSlotLabels: Record = { + weapon: '무기', + armor: '방어구', + accessory: '보조구' +}; + +export const equipmentSlots: EquipmentSlot[] = ['weapon', 'armor', 'accessory']; + +export const itemCatalog: Record = { + 'training-sword': { + id: 'training-sword', + name: '연습검', + slot: 'weapon', + rank: 'common', + description: '의용군이 가장 쉽게 다룰 수 있는 기본 검.', + attackBonus: 1, + effects: ['공격 경험치를 안정적으로 쌓는다'] + }, + 'twin-oath-blades': { + id: 'twin-oath-blades', + name: '쌍의검', + slot: 'weapon', + rank: 'treasure', + description: '도원에서 맹세한 뜻을 담아 벼린 쌍검.', + attackBonus: 3, + effects: ['공명 관계가 70 이상이면 공격 경험치 +2'] + }, + 'green-dragon-glaive': { + id: 'green-dragon-glaive', + name: '청룡월도', + slot: 'weapon', + rank: 'treasure', + description: '묵직한 월도로 전열을 갈라내는 관우의 보물 무기.', + attackBonus: 5, + effects: ['적보다 무력이 높으면 피해 보너스'] + }, + 'serpent-spear': { + id: 'serpent-spear', + name: '장팔장창', + slot: 'weapon', + rank: 'treasure', + description: '한 번 내지르면 기세까지 꿰뚫는 장비의 긴 창.', + attackBonus: 5, + effects: ['연속 공격 판정 보정'] + }, + 'yellow-turban-saber': { + id: 'yellow-turban-saber', + name: '황건도', + slot: 'weapon', + rank: 'common', + description: '황건 무리가 들고 다니는 거친 도검.', + attackBonus: 1, + effects: ['평지 전투에 무난하다'] + }, + 'short-bow': { + id: 'short-bow', + name: '단궁', + slot: 'weapon', + rank: 'common', + description: '가볍게 쏘기 좋은 짧은 활.', + attackBonus: 2, + effects: ['궁병 기본 사거리 장비'] + }, + 'leader-axe': { + id: 'leader-axe', + name: '두령부', + slot: 'weapon', + rank: 'common', + description: '두령이 위압을 보이기 위해 쓰는 큼직한 도끼.', + attackBonus: 3, + effects: ['HP가 낮은 적을 압박한다'] + }, + 'cloth-armor': { + id: 'cloth-armor', + name: '포의', + slot: 'armor', + rank: 'common', + description: '가벼운 천옷. 민첩함은 해치지 않지만 방어는 낮다.', + defenseBonus: 1, + effects: ['방어 경험치를 빠르게 시작한다'] + }, + 'lamellar-armor': { + id: 'lamellar-armor', + name: '찰갑', + slot: 'armor', + rank: 'common', + description: '작은 철편을 엮어 만든 실전용 갑옷.', + defenseBonus: 3, + effects: ['근접 견제에 강하다'] + }, + 'oath-robe': { + id: 'oath-robe', + name: '도원포', + slot: 'armor', + rank: 'treasure', + description: '맹세의 문양을 수놓은 전투복.', + defenseBonus: 2, + strategyBonus: 1, + effects: ['공명 경험치 획득 시 방어 경험치 +1'] + }, + 'reinforced-lamellar': { + id: 'reinforced-lamellar', + name: '철린갑', + slot: 'armor', + rank: 'treasure', + description: '비늘처럼 맞물린 철편으로 급소를 덮은 보물 갑옷.', + defenseBonus: 5, + effects: ['방어 시 받는 피해 감소'] + }, + 'rebel-vest': { + id: 'rebel-vest', + name: '황건 조끼', + slot: 'armor', + rank: 'common', + description: '두꺼운 천과 가죽을 덧댄 조끼.', + defenseBonus: 1, + effects: ['숲 지형에서 버티기 좋다'] + }, + 'peach-charm': { + id: 'peach-charm', + name: '복숭아 부적', + slot: 'accessory', + rank: 'treasure', + description: '도원결의의 상징을 새긴 작은 부적.', + strategyBonus: 2, + effects: ['턴 시작 시 낮은 병력을 조금 회복할 예정'] + }, + 'war-manual': { + id: 'war-manual', + name: '병법서', + slot: 'accessory', + rank: 'treasure', + description: '진형과 공격 타이밍을 적은 병법서.', + strategyBonus: 2, + effects: ['공명 연계 판정 보정'] + }, + 'bravery-token': { + id: 'bravery-token', + name: '호담패', + slot: 'accessory', + rank: 'treasure', + description: '물러서지 않는 기개를 새긴 패.', + attackBonus: 1, + effects: ['HP가 낮을수록 공격 의지가 오른다'] + }, + 'grain-pouch': { + id: 'grain-pouch', + name: '군량 주머니', + slot: 'accessory', + rank: 'common', + description: '전투 중 병사들의 허기를 달래는 보조 도구.', + effects: ['장기전 회복 아이템과 연동 예정'] + }, + 'yellow-scarf-charm': { + id: 'yellow-scarf-charm', + name: '황건 부적', + slot: 'accessory', + rank: 'common', + description: '황건 무리가 신념의 증표로 지닌 부적.', + effects: ['사기 유지에 도움을 준다'] + }, + 'wind-quiver': { + id: 'wind-quiver', + name: '바람 화살통', + slot: 'accessory', + rank: 'treasure', + description: '가벼운 화살깃과 균형추가 달린 보물 화살통.', + attackBonus: 1, + effects: ['궁병의 명중 보정'] + } +}; + +export function getItem(itemId: string) { + return itemCatalog[itemId] ?? itemCatalog['training-sword']; +} + +export function equipmentExpToNext(level: number) { + return 50 + Math.min(level, 9) * 20; +} diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index fe164b1..9592f47 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1,4 +1,5 @@ import type { TerrainType, UnitClassKey } from './battleRules'; +import type { EquipmentSet } from './battleItems'; export type PortraitKey = 'liuBei' | 'guanYu' | 'zhangFei'; @@ -23,6 +24,7 @@ export type UnitData = { attack: number; move: number; stats: UnitStats; + equipment: EquipmentSet; x: number; y: number; }; @@ -168,6 +170,11 @@ export const firstBattleUnits: UnitData[] = [ attack: 9, move: 4, stats: { might: 76, intelligence: 78, leadership: 84, agility: 70, luck: 88 }, + equipment: { + weapon: { itemId: 'twin-oath-blades', level: 1, exp: 18 }, + armor: { itemId: 'oath-robe', level: 1, exp: 12 }, + accessory: { itemId: 'peach-charm', level: 1, exp: 0 } + }, x: 1, y: 9 }, @@ -182,6 +189,11 @@ export const firstBattleUnits: UnitData[] = [ attack: 10, move: 3, stats: { might: 96, intelligence: 74, leadership: 91, agility: 72, luck: 68 }, + equipment: { + weapon: { itemId: 'green-dragon-glaive', level: 1, exp: 26 }, + armor: { itemId: 'reinforced-lamellar', level: 1, exp: 10 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, x: 2, y: 10 }, @@ -196,6 +208,11 @@ export const firstBattleUnits: UnitData[] = [ attack: 10, move: 3, stats: { might: 95, intelligence: 42, leadership: 83, agility: 67, luck: 61 }, + equipment: { + weapon: { itemId: 'serpent-spear', level: 1, exp: 20 }, + armor: { itemId: 'lamellar-armor', level: 1, exp: 8 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, x: 1, y: 10 }, @@ -210,6 +227,11 @@ export const firstBattleUnits: UnitData[] = [ attack: 6, move: 3, stats: { might: 49, intelligence: 34, leadership: 38, agility: 45, luck: 40 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 6 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 4 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, x: 8, y: 2 }, @@ -224,6 +246,11 @@ export const firstBattleUnits: UnitData[] = [ attack: 6, move: 3, stats: { might: 47, intelligence: 32, leadership: 36, agility: 48, luck: 38 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 4 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 5 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, x: 9, y: 3 }, @@ -238,6 +265,11 @@ export const firstBattleUnits: UnitData[] = [ attack: 7, move: 3, stats: { might: 52, intelligence: 40, leadership: 43, agility: 56, luck: 42 }, + equipment: { + weapon: { itemId: 'short-bow', level: 1, exp: 10 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 6 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, x: 10, y: 2 }, @@ -252,6 +284,11 @@ export const firstBattleUnits: UnitData[] = [ attack: 9, move: 3, stats: { might: 64, intelligence: 46, leadership: 61, agility: 52, luck: 50 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 1, exp: 18 }, + armor: { itemId: 'lamellar-armor', level: 1, exp: 12 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, x: 10, y: 4 } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 920da7c..c01704c 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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 = { @@ -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 = { 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) { + 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 })); }