Add turn menu and resonance bonds

This commit is contained in:
2026-06-22 02:01:16 +09:00
parent 83d6497b5e
commit 6db887e2da
3 changed files with 457 additions and 7 deletions

View File

@@ -32,6 +32,15 @@ export type UnitStats = {
luck: number;
};
export type BattleBond = {
id: string;
unitIds: [string, string];
title: string;
level: number;
exp: number;
description: string;
};
export type TerrainType = 'plain' | 'forest' | 'hill' | 'fort';
export type BattleMap = {
@@ -239,3 +248,30 @@ export const firstBattleUnits: UnitData[] = [
y: 4
}
];
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
unitIds: ['liu-bei', 'guan-yu'],
title: '도원결의',
level: 72,
exp: 20,
description: '같은 적을 노릴 때 공격 피해 보너스와 연계 공격 확률이 오른다.'
},
{
id: 'liu-bei__zhang-fei',
unitIds: ['liu-bei', 'zhang-fei'],
title: '의형제',
level: 68,
exp: 12,
description: '공격 목표가 겹치면 공명 경험치가 빠르게 쌓인다.'
},
{
id: 'guan-yu__zhang-fei',
unitIds: ['guan-yu', 'zhang-fei'],
title: '맹장 공명',
level: 64,
exp: 8,
description: '둘이 같은 전선을 압박하면 연계 공격의 기초 확률이 오른다.'
}
];

View File

@@ -1,6 +1,14 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { firstBattleMap, firstBattleUnits, type TerrainType, type UnitData, type UnitStats } from '../data/scenario';
import {
firstBattleBonds,
firstBattleMap,
firstBattleUnits,
type BattleBond,
type TerrainType,
type UnitData,
type UnitStats
} from '../data/scenario';
import { palette } from '../ui/palette';
const terrainColor: Record<TerrainType, number> = {
@@ -43,6 +51,8 @@ type UnitView = {
type BattlePhase = 'idle' | 'moving' | 'command';
type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
type RosterTab = 'ally' | 'enemy';
type ActiveFaction = 'ally' | 'enemy';
type MapMenuAction = 'endTurn' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close';
type CommandButton = {
command: BattleCommand;
@@ -58,6 +68,15 @@ type PendingMove = {
toY: number;
};
type AttackIntent = {
attackerId: string;
targetId: string;
};
type BondState = BattleBond & {
battleExp: number;
};
const commandLabels: Record<BattleCommand, string> = {
attack: '공격',
strategy: '책략',
@@ -70,6 +89,20 @@ const rosterLabels: Record<RosterTab, string> = {
enemy: '적군'
};
const factionLabels: Record<ActiveFaction, string> = {
ally: '아군',
enemy: '적군'
};
const mapMenuLabels: Record<MapMenuAction, string> = {
endTurn: '턴 종료',
roster: '부대 일람',
bond: '공명',
situation: '전황',
bgm: 'BGM',
close: '닫기'
};
const statLabels: Array<{ key: keyof UnitStats; label: string }> = [
{ key: 'might', label: '무력' },
{ key: 'intelligence', label: '지력' },
@@ -81,6 +114,8 @@ const statLabels: Array<{ key: keyof UnitStats; label: string }> = [
export class BattleScene extends Phaser.Scene {
private layout!: BattleLayout;
private phase: BattlePhase = 'idle';
private turnNumber = 1;
private activeFaction: ActiveFaction = 'ally';
private selectedUnit?: UnitData;
private turnText?: Phaser.GameObjects.Text;
private markers: Phaser.GameObjects.Rectangle[] = [];
@@ -88,8 +123,11 @@ export class BattleScene extends Phaser.Scene {
private sidePanelObjects: Phaser.GameObjects.GameObject[] = [];
private commandButtons: CommandButton[] = [];
private commandMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private unitViews = new Map<string, UnitView>();
private actedUnitIds = new Set<string>();
private bondStates = new Map<string, BondState>();
private attackIntents: AttackIntent[] = [];
private pendingMove?: PendingMove;
constructor() {
@@ -99,11 +137,12 @@ export class BattleScene extends Phaser.Scene {
create() {
const { width, height } = this.scale;
this.layout = this.createLayout(width, height);
this.bondStates = this.createBondStates();
soundDirector.playMusic('battle-prep');
this.input.mouse?.disableContextMenu();
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (pointer.rightButtonDown()) {
this.cancelPendingMove();
this.handleRightClick(pointer);
}
});
@@ -111,6 +150,7 @@ export class BattleScene extends Phaser.Scene {
this.drawMap();
this.drawUnits();
this.drawSidePanel();
this.updateTurnText();
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
}
@@ -247,6 +287,8 @@ export class BattleScene extends Phaser.Scene {
}
private selectUnit(unit: UnitData) {
this.hideMapMenu();
if (this.phase === 'command') {
this.setInfo('먼저 현재 장수의 행동을 결정하세요.\n공격, 책략, 도구, 대기 중 하나를 선택해야 합니다.');
return;
@@ -262,6 +304,11 @@ export class BattleScene extends Phaser.Scene {
return;
}
if (this.activeFaction !== 'ally') {
this.renderUnitDetail(unit, '적군 행동 중에는 장수를 움직일 수 없습니다.');
return;
}
if (this.actedUnitIds.has(unit.id)) {
this.selectedUnit = undefined;
this.clearMarkers();
@@ -482,12 +529,14 @@ export class BattleScene extends Phaser.Scene {
soundDirector.playSelect();
const actionMessage = this.commandResultMessage(unit, command);
this.renderRosterPanel('ally', `${actionMessage}\n${this.remainingAllyCount()}명의 아군이 아직 행동할 수 있습니다.`);
const remaining = this.remainingAllyCount();
const turnHint = remaining > 0 ? `${remaining}명의 아군이 아직 행동할 수 있습니다.` : '모든 아군이 행동했습니다. 빈 맵 우클릭 메뉴에서 턴 종료를 선택하세요.';
this.renderRosterPanel('ally', `${actionMessage}\n${turnHint}`);
}
private commandResultMessage(unit: UnitData, command: BattleCommand) {
if (command === 'attack') {
return `${unit.name} 공격 명령 완료\n다음 구현에서 대상 선택과 피해 처리를 연결할 예정입니다.`;
return this.recordAttackIntent(unit);
}
if (command === 'strategy') {
return `${unit.name} 책략 명령 완료\n책략 목록과 MP는 다음 전투 확장 때 붙이겠습니다.`;
@@ -502,9 +551,253 @@ export class BattleScene extends Phaser.Scene {
return firstBattleUnits.filter((unit) => unit.faction === 'ally' && !this.actedUnitIds.has(unit.id)).length;
}
private createBondStates() {
return new Map(
firstBattleBonds.map((bond) => [
bond.id,
{
...bond,
battleExp: 0
}
])
);
}
private handleRightClick(pointer: Phaser.Input.Pointer) {
if (this.cancelPendingMove()) {
return;
}
if (this.isPointerOnEmptyMapTile(pointer)) {
this.showMapMenu(pointer.x, pointer.y);
return;
}
this.hideMapMenu();
}
private isPointerOnEmptyMapTile(pointer: Phaser.Input.Pointer) {
const tile = this.pointerToTile(pointer);
if (!tile) {
return false;
}
return !this.isOccupied(tile.x, tile.y);
}
private pointerToTile(pointer: Phaser.Input.Pointer) {
const { gridX, gridY, gridSize, tileSize } = this.layout;
if (pointer.x < gridX || pointer.y < gridY || pointer.x >= gridX + gridSize || pointer.y >= gridY + gridSize) {
return undefined;
}
return {
x: Math.floor((pointer.x - gridX) / tileSize),
y: Math.floor((pointer.y - gridY) / tileSize)
};
}
private showMapMenu(pointerX: number, pointerY: number) {
if (this.phase === 'command') {
return;
}
this.hideMapMenu();
this.clearMarkers();
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.phase = 'idle';
const actions: MapMenuAction[] = ['endTurn', 'roster', 'bond', 'situation', 'bgm', 'close'];
const menuWidth = 148;
const buttonHeight = 34;
const padding = 8;
const menuHeight = padding * 2 + actions.length * buttonHeight;
const left = this.layout.mapX + 84;
const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16);
const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97);
panel.setOrigin(0);
panel.setDepth(20);
panel.setStrokeStyle(2, palette.gold, 0.86);
this.mapMenuObjects.push(panel);
actions.forEach((action, index) => {
const y = top + padding + index * buttonHeight;
const disabled = action === 'endTurn' && this.activeFaction !== 'ally';
const bg = this.add.rectangle(left + 8, y, menuWidth - 16, buttonHeight - 4, disabled ? 0x121820 : 0x1a2630, disabled ? 0.72 : 0.94);
bg.setOrigin(0);
bg.setDepth(21);
bg.setStrokeStyle(1, action === 'endTurn' ? 0xd8b15f : 0x5a7588, disabled ? 0.32 : 0.68);
this.mapMenuObjects.push(bg);
const text = this.add.text(left + menuWidth / 2, y + buttonHeight / 2 - 2, mapMenuLabels[action], {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: disabled ? '#737b84' : '#f2e3bf',
fontStyle: '700'
});
text.setOrigin(0.5);
text.setDepth(22);
this.mapMenuObjects.push(text);
if (!disabled) {
const run = () => this.runMapMenuAction(action);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94));
bg.on('pointerdown', run);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', run);
}
});
}
private runMapMenuAction(action: MapMenuAction) {
this.hideMapMenu();
soundDirector.playSelect();
if (action === 'endTurn') {
this.endAllyTurn();
return;
}
if (action === 'roster') {
this.renderRosterPanel('ally', '부대 일람입니다.');
return;
}
if (action === 'bond') {
this.renderBondPanel();
return;
}
if (action === 'situation') {
this.renderSituationPanel();
return;
}
if (action === 'bgm') {
soundDirector.setMuted(!soundDirector.isMuted());
this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`);
}
}
private hideMapMenu() {
this.mapMenuObjects.forEach((object) => object.destroy());
this.mapMenuObjects = [];
}
private endAllyTurn() {
if (this.activeFaction !== 'ally') {
this.renderSituationPanel('이미 적군 행동 중입니다.');
return;
}
if (this.phase === 'command') {
this.setInfo('현재 장수의 행동을 먼저 결정하세요.');
return;
}
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.phase = 'idle';
this.clearMarkers();
this.hideCommandMenu();
this.activeFaction = 'enemy';
this.updateTurnText();
this.renderSituationPanel('아군 턴을 종료했습니다.\n황건적은 아직 간단 AI 구현 전이라 정비만 하고 넘어갑니다.');
this.time.delayedCall(700, () => this.beginNextAllyTurn());
}
private beginNextAllyTurn() {
this.turnNumber += 1;
this.activeFaction = 'ally';
this.actedUnitIds.clear();
this.attackIntents = [];
this.resetActedStyles();
this.updateTurnText();
this.renderRosterPanel('ally', `${this.turnNumber}턴 아군 차례입니다.\n행동할 장수를 선택하세요.`);
}
private updateTurnText() {
this.turnText?.setText(`${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`);
}
private resetActedStyles() {
this.unitViews.forEach((view, unitId) => {
const unit = firstBattleUnits.find((candidate) => candidate.id === unitId);
view.sprite.postFX.clear();
view.sprite.clearTint();
view.sprite.setAlpha(1);
view.label.setAlpha(1);
view.label.setColor(unit?.faction === 'ally' ? '#e7edf7' : '#ffebe7');
view.label.setBackgroundColor('rgba(8,10,14,0.78)');
});
}
private recordAttackIntent(unit: UnitData) {
const target = this.findNearestEnemy(unit);
if (!target) {
return `${unit.name} 공격 명령 완료\n공격 가능한 적이 없습니다.`;
}
const partners = this.attackIntents
.filter((intent) => intent.targetId === target.id && intent.attackerId !== unit.id)
.map((intent) => intent.attackerId);
let gained = 0;
partners.forEach((partnerId) => {
const bond = this.findBond(unit.id, partnerId);
if (!bond) {
return;
}
bond.battleExp += 4;
bond.exp += 4;
while (bond.exp >= 100) {
bond.exp -= 100;
bond.level = Math.min(100, bond.level + 1);
}
gained += 4;
});
this.attackIntents = this.attackIntents.filter((intent) => intent.attackerId !== unit.id);
this.attackIntents.push({ attackerId: unit.id, targetId: target.id });
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}`;
}
private findNearestEnemy(unit: UnitData) {
const enemies = firstBattleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0);
return enemies.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0];
}
private tileDistance(a: UnitData, b: UnitData) {
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}
private findBond(unitId: string, partnerId: string) {
return Array.from(this.bondStates.values()).find((bond) => {
return bond.unitIds.includes(unitId) && bond.unitIds.includes(partnerId);
});
}
private strongestBondBonus(unitId: string) {
const related = Array.from(this.bondStates.values()).filter((bond) => bond.unitIds.includes(unitId));
const strongest = related.sort((a, b) => b.level - a.level)[0];
if (!strongest) {
return { damageBonus: 0, chainRate: 0 };
}
return {
damageBonus: Math.floor(strongest.level / 20) * 3,
chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0
};
}
private cancelPendingMove() {
if (this.phase !== 'command' || !this.pendingMove) {
return;
return false;
}
const { unit, fromX, fromY, toX, toY } = this.pendingMove;
@@ -524,6 +817,7 @@ export class BattleScene extends Phaser.Scene {
unit,
`이동을 취소했습니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.`
);
return true;
}
private moveUnitView(
@@ -570,6 +864,126 @@ export class BattleScene extends Phaser.Scene {
view.label.setBackgroundColor('rgba(20,20,20,0.66)');
}
private renderBondPanel(message?: string) {
this.clearSidePanelContent();
const { panelX, panelY, panelWidth } = this.layout;
const left = panelX + 24;
const width = panelWidth - 48;
const top = panelY + 132;
this.trackSideObject(this.add.text(left, top, '공명 관계', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#f2e3bf',
fontStyle: '700'
}));
this.trackSideObject(this.add.text(left, top + 34, '같은 적을 노리거나 전투 사이 이벤트로 상승합니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#9fb0bf'
}));
Array.from(this.bondStates.values()).forEach((bond, index) => {
const y = top + 70 + index * 104;
const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId));
const bg = this.trackSideObject(this.add.rectangle(left, y, width, 92, 0x101820, 0.9));
bg.setOrigin(0);
bg.setStrokeStyle(1, bond.level >= 70 ? palette.gold : palette.blue, 0.66);
this.trackSideObject(this.add.text(left + 12, y + 8, `${first} · ${second}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#e8dfca',
fontStyle: '700'
}));
const levelText = this.trackSideObject(this.add.text(left + width - 12, y + 8, `${bond.level}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f2e3bf',
fontStyle: '700'
}));
levelText.setOrigin(1, 0);
this.trackSideObject(this.add.text(left + 12, y + 33, bond.title, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#d8b15f'
}));
this.drawGauge(left + 86, y + 39, width - 148, 8, bond.exp / 100, bond.level >= 70 ? 0xd8b15f : 0x58aee0);
const bonus = this.bondBonus(bond);
this.trackSideObject(this.add.text(left + 12, y + 59, `피해 +${bonus.damageBonus}% 연계 ${bonus.chainRate}% 전투 +${bond.battleExp}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#d4dce6'
}));
});
this.renderPanelMessage(message ?? '공격 명령에서 같은 예상 목표를 노리면 공명 경험치가 쌓입니다.', left, top + 396, width);
}
private renderSituationPanel(message?: string) {
this.clearSidePanelContent();
const { panelX, panelY, panelWidth } = this.layout;
const left = panelX + 24;
const width = panelWidth - 48;
const top = panelY + 132;
const actedCount = firstBattleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length;
const allyCount = firstBattleUnits.filter((unit) => unit.faction === 'ally').length;
const enemyCount = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length;
const bestBond = Array.from(this.bondStates.values()).sort((a, b) => b.level - a.level)[0];
this.trackSideObject(this.add.text(left, top, '전황', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px',
color: '#f2e3bf',
fontStyle: '700'
}));
this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + 48, width);
this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + 92, width);
this.renderSituationLine('남은 적', `${enemyCount}`, left, top + 136, width);
this.renderSituationLine('BGM', soundDirector.isMuted() ? '꺼짐' : '켜짐', left, top + 180, width);
this.renderSituationLine(
'최고 공명',
bestBond ? `${this.unitName(bestBond.unitIds[0])}·${this.unitName(bestBond.unitIds[1])} ${bestBond.level}` : '-',
left,
top + 224,
width
);
this.renderPanelMessage(message ?? '빈 맵에서 우클릭하면 전술 메뉴를 다시 열 수 있습니다.', left, top + 286, width);
}
private renderSituationLine(label: string, value: string, x: number, y: number, width: number) {
const bg = this.trackSideObject(this.add.rectangle(x, y, width, 34, 0x101820, 0.86));
bg.setOrigin(0);
bg.setStrokeStyle(1, 0x53606c, 0.46);
this.trackSideObject(this.add.text(x + 12, y + 8, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px',
color: '#9fb0bf'
}));
const valueText = this.trackSideObject(this.add.text(x + width - 12, y + 7, value, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
color: '#f2e3bf',
fontStyle: '700'
}));
valueText.setOrigin(1, 0);
}
private bondBonus(bond: BondState) {
return {
damageBonus: Math.floor(bond.level / 20) * 3,
chainRate: bond.level >= 70 ? 18 : bond.level >= 50 ? 8 : 0
};
}
private unitName(unitId: string) {
return firstBattleUnits.find((unit) => unit.id === unitId)?.name ?? unitId;
}
private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) {
this.rosterTab = tab;
this.clearSidePanelContent();