Add roster and unit stat panels

This commit is contained in:
2026-06-22 01:46:14 +09:00
parent b996f780f2
commit 83d6497b5e
3 changed files with 383 additions and 29 deletions

View File

@@ -19,10 +19,19 @@ export type UnitData = {
maxHp: number;
attack: number;
move: number;
stats: UnitStats;
x: number;
y: number;
};
export type UnitStats = {
might: number;
intelligence: number;
leadership: number;
agility: number;
luck: number;
};
export type TerrainType = 'plain' | 'forest' | 'hill' | 'fort';
export type BattleMap = {
@@ -138,11 +147,95 @@ export const firstBattleMap: BattleMap = {
};
export const firstBattleUnits: UnitData[] = [
{ id: 'liu-bei', name: '유비', faction: 'ally', className: '의병장', hp: 32, maxHp: 32, attack: 9, move: 4, x: 1, y: 9 },
{ id: 'guan-yu', name: '관우', faction: 'ally', className: '보병', hp: 30, maxHp: 30, attack: 10, move: 3, x: 2, y: 10 },
{ id: 'zhang-fei', name: '비', faction: 'ally', className: '창병', hp: 30, maxHp: 30, attack: 10, move: 3, x: 1, y: 10 },
{ id: 'rebel-a', name: '황건병', faction: 'enemy', className: '도적', hp: 20, maxHp: 20, attack: 6, move: 3, x: 8, y: 2 },
{ id: 'rebel-b', name: '황건병', faction: 'enemy', className: '도적', hp: 20, maxHp: 20, attack: 6, move: 3, x: 9, y: 3 },
{ id: 'rebel-c', name: '황건궁병', faction: 'enemy', className: '궁병', hp: 18, maxHp: 18, attack: 7, move: 3, x: 10, y: 2 },
{ id: 'rebel-leader', name: '두령 한석', faction: 'enemy', className: '두령', hp: 30, maxHp: 30, attack: 9, move: 3, x: 10, y: 4 }
{
id: 'liu-bei',
name: '비',
faction: 'ally',
className: '의병장',
hp: 32,
maxHp: 32,
attack: 9,
move: 4,
stats: { might: 76, intelligence: 78, leadership: 84, agility: 70, luck: 88 },
x: 1,
y: 9
},
{
id: 'guan-yu',
name: '관우',
faction: 'ally',
className: '보병',
hp: 30,
maxHp: 30,
attack: 10,
move: 3,
stats: { might: 96, intelligence: 74, leadership: 91, agility: 72, luck: 68 },
x: 2,
y: 10
},
{
id: 'zhang-fei',
name: '장비',
faction: 'ally',
className: '창병',
hp: 30,
maxHp: 30,
attack: 10,
move: 3,
stats: { might: 95, intelligence: 42, leadership: 83, agility: 67, luck: 61 },
x: 1,
y: 10
},
{
id: 'rebel-a',
name: '황건병',
faction: 'enemy',
className: '도적',
hp: 20,
maxHp: 20,
attack: 6,
move: 3,
stats: { might: 49, intelligence: 34, leadership: 38, agility: 45, luck: 40 },
x: 8,
y: 2
},
{
id: 'rebel-b',
name: '황건병',
faction: 'enemy',
className: '도적',
hp: 20,
maxHp: 20,
attack: 6,
move: 3,
stats: { might: 47, intelligence: 32, leadership: 36, agility: 48, luck: 38 },
x: 9,
y: 3
},
{
id: 'rebel-c',
name: '황건궁병',
faction: 'enemy',
className: '궁병',
hp: 18,
maxHp: 18,
attack: 7,
move: 3,
stats: { might: 52, intelligence: 40, leadership: 43, agility: 56, luck: 42 },
x: 10,
y: 2
},
{
id: 'rebel-leader',
name: '두령 한석',
faction: 'enemy',
className: '두령',
hp: 30,
maxHp: 30,
attack: 9,
move: 3,
stats: { might: 64, intelligence: 46, leadership: 61, agility: 52, luck: 50 },
x: 10,
y: 4
}
];

View File

@@ -1,6 +1,6 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { firstBattleMap, firstBattleUnits, type TerrainType, type UnitData } from '../data/scenario';
import { firstBattleMap, firstBattleUnits, type TerrainType, type UnitData, type UnitStats } from '../data/scenario';
import { palette } from '../ui/palette';
const terrainColor: Record<TerrainType, number> = {
@@ -42,6 +42,7 @@ type UnitView = {
type BattlePhase = 'idle' | 'moving' | 'command';
type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
type RosterTab = 'ally' | 'enemy';
type CommandButton = {
command: BattleCommand;
@@ -64,13 +65,27 @@ const commandLabels: Record<BattleCommand, string> = {
wait: '대기'
};
const rosterLabels: Record<RosterTab, string> = {
ally: '아군',
enemy: '적군'
};
const statLabels: Array<{ key: keyof UnitStats; label: string }> = [
{ key: 'might', label: '무력' },
{ key: 'intelligence', label: '지력' },
{ key: 'leadership', label: '통솔력' },
{ key: 'agility', label: '민첩성' },
{ key: 'luck', label: '운' }
];
export class BattleScene extends Phaser.Scene {
private layout!: BattleLayout;
private phase: BattlePhase = 'idle';
private selectedUnit?: UnitData;
private infoText?: Phaser.GameObjects.Text;
private turnText?: Phaser.GameObjects.Text;
private markers: Phaser.GameObjects.Rectangle[] = [];
private rosterTab: RosterTab = 'ally';
private sidePanelObjects: Phaser.GameObjects.GameObject[] = [];
private commandButtons: CommandButton[] = [];
private commandMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private unitViews = new Map<string, UnitView>();
@@ -96,7 +111,7 @@ export class BattleScene extends Phaser.Scene {
this.drawMap();
this.drawUnits();
this.drawSidePanel();
this.setInfo('아군 차례입니다.\n행동할 장수를 선택한 뒤 파란 칸으로 이동하세요.');
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
}
private createLayout(width: number, height: number): BattleLayout {
@@ -214,25 +229,17 @@ export class BattleScene extends Phaser.Scene {
fontSize: '22px',
color: '#d8b15f'
});
this.infoText = this.add.text(panelX + 24, panelY + 144, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: '#e8dfca',
wordWrap: { width: panelWidth - 48, useAdvancedWrap: true },
lineSpacing: 8
});
this.add.text(panelX + 24, panelY + panelHeight - 166, '승리 조건: 적 전멸', {
this.add.text(panelX + 24, panelY + panelHeight - 104, '승리 조건: 적 전멸', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#9aa3ad'
});
this.add.text(panelX + 24, panelY + panelHeight - 126, '패배 조건: 유비 퇴각', {
this.add.text(panelX + 24, panelY + panelHeight - 72, '패배 조건: 유비 퇴각', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#9aa3ad'
});
this.add.text(panelX + 24, panelY + panelHeight - 86, '이동 후 명령: 공격 / 책략 / 도구 / 대기', {
this.add.text(panelX + 24, panelY + panelHeight - 40, '이동 후 명령: 공격 / 책략 / 도구 / 대기', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#9aa3ad'
@@ -246,7 +253,12 @@ export class BattleScene extends Phaser.Scene {
}
if (unit.faction !== 'ally') {
this.setInfo(`${unit.name}: ${unit.className}\nHP ${unit.hp}/${unit.maxHp}`);
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.phase = 'idle';
this.clearMarkers();
this.hideCommandMenu();
this.renderUnitDetail(unit, '적 부대 정보입니다.');
return;
}
@@ -255,7 +267,7 @@ export class BattleScene extends Phaser.Scene {
this.clearMarkers();
this.hideCommandMenu();
this.phase = 'idle';
this.setInfo(`${unit.name}은 이미 행동을 마쳤습니다.\n흑백으로 표시된 장수는 이번 턴에 다시 움직일 수 없습니다.`);
this.renderUnitDetail(unit, '이미 행동을 마친 장수입니다.');
return;
}
@@ -265,7 +277,7 @@ export class BattleScene extends Phaser.Scene {
this.clearMarkers();
this.hideCommandMenu();
this.showMoveRange(unit);
this.setInfo(`${unit.name} (${unit.className})\nHP ${unit.hp}/${unit.maxHp}\n공격 ${unit.attack} / 이동 ${unit.move}\n이동할 파란 칸을 선택하세요.`);
this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.');
}
private showMoveRange(unit: UnitData) {
@@ -323,7 +335,7 @@ export class BattleScene extends Phaser.Scene {
const targetY = this.tileCenterY(y);
this.moveUnitView(unit, x, y, view);
this.showCommandMenu(unit, pointer?.x ?? targetX, pointer?.y ?? targetY);
this.setInfo(`${unit.name} 이동 완료\n위치: ${x + 1}, ${y + 1}\n공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동을 취소합니다.`);
this.renderUnitDetail(unit, '공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동을 취소합니다.');
}
private canMoveTo(unit: UnitData, x: number, y: number) {
@@ -470,7 +482,7 @@ export class BattleScene extends Phaser.Scene {
soundDirector.playSelect();
const actionMessage = this.commandResultMessage(unit, command);
this.setInfo(`${actionMessage}\n\n${this.remainingAllyCount()}명의 아군이 아직 행동할 수 있습니다.`);
this.renderRosterPanel('ally', `${actionMessage}\n${this.remainingAllyCount()}명의 아군이 아직 행동할 수 있습니다.`);
}
private commandResultMessage(unit: UnitData, command: BattleCommand) {
@@ -508,7 +520,10 @@ export class BattleScene extends Phaser.Scene {
this.moveUnitView(unit, fromX, fromY, view, 180);
this.clearMarkers();
this.showMoveRange(unit);
this.setInfo(`${unit.name} 이동 취소\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.\n다시 이동할 파란 칸을 선택하세요.`);
this.renderUnitDetail(
unit,
`이동을 취소했습니다.\n${toX + 1}, ${toY + 1}에서 원래 위치 ${fromX + 1}, ${fromY + 1}로 되돌렸습니다.`
);
}
private moveUnitView(
@@ -555,7 +570,253 @@ export class BattleScene extends Phaser.Scene {
view.label.setBackgroundColor('rgba(20,20,20,0.66)');
}
private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) {
this.rosterTab = tab;
this.clearSidePanelContent();
const { panelX, panelY, panelWidth, panelHeight } = this.layout;
const left = panelX + 24;
const width = panelWidth - 48;
const top = panelY + 132;
const tabGap = 8;
const tabWidth = (width - tabGap) / 2;
(['ally', 'enemy'] as RosterTab[]).forEach((targetTab, index) => {
const active = targetTab === tab;
const x = left + index * (tabWidth + tabGap);
const tabBg = this.trackSideObject(this.add.rectangle(x, top, tabWidth, 38, active ? 0x25384a : 0x141e29, active ? 0.98 : 0.86));
tabBg.setOrigin(0);
tabBg.setStrokeStyle(1, active ? palette.gold : 0x53606c, active ? 0.92 : 0.62);
tabBg.setInteractive({ useHandCursor: true });
tabBg.on('pointerdown', () => this.renderRosterPanel(targetTab));
const tabText = this.trackSideObject(this.add.text(x + tabWidth / 2, top + 19, rosterLabels[targetTab], {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: active ? '#f4dfad' : '#aeb7c2',
fontStyle: '700'
}));
tabText.setOrigin(0.5);
});
const units = firstBattleUnits.filter((unit) => unit.faction === tab);
const listTop = top + 54;
units.forEach((unit, index) => {
this.renderRosterRow(unit, left, listTop + index * 50, width);
});
if (message) {
const messageTop = Math.min(panelY + panelHeight - 254, listTop + units.length * 50 + 14);
this.renderPanelMessage(message, left, messageTop, width);
}
}
private renderRosterRow(unit: UnitData, x: number, y: number, width: number) {
const acted = this.actedUnitIds.has(unit.id);
const active = this.selectedUnit?.id === unit.id;
const rowBg = this.trackSideObject(this.add.rectangle(x, y, width, 42, active ? 0x2f4050 : 0x101820, acted ? 0.54 : 0.9));
rowBg.setOrigin(0);
rowBg.setStrokeStyle(1, active ? palette.gold : unit.faction === 'ally' ? palette.blue : 0xb86b55, active ? 0.88 : 0.52);
rowBg.setInteractive({ useHandCursor: true });
rowBg.on('pointerdown', () => this.selectUnit(unit));
const nameColor = acted ? '#a8a8a8' : unit.faction === 'ally' ? '#e9f0f8' : '#ffe0d8';
this.trackSideObject(this.add.text(x + 12, y + 7, unit.name, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: nameColor,
fontStyle: '700'
}));
this.trackSideObject(this.add.text(x + 88, y + 9, unit.className, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: acted ? '#8c8c8c' : '#9fb0bf'
}));
const hpText = this.trackSideObject(this.add.text(x + width - 12, y + 7, `${unit.hp}/${unit.maxHp}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
color: acted ? '#9a9a9a' : '#f0e4c8',
fontStyle: '700'
}));
hpText.setOrigin(1, 0);
this.drawGauge(x + 88, y + 29, width - 100, 6, unit.hp / unit.maxHp, acted ? 0x9a9a9a : 0x59d18c);
if (acted) {
const actedText = this.trackSideObject(this.add.text(x + 12, y + 27, '행동완료', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#bdbdbd'
}));
actedText.setAlpha(0.78);
}
}
private renderUnitDetail(unit: UnitData, message?: string) {
this.clearSidePanelContent();
const { panelX, panelY, panelWidth } = this.layout;
const left = panelX + 24;
const width = panelWidth - 48;
const top = panelY + 124;
const acted = this.actedUnitIds.has(unit.id);
const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55;
const header = this.trackSideObject(this.add.rectangle(left, top, width, 60, 0x101820, 0.94));
header.setOrigin(0);
header.setStrokeStyle(1, factionColor, 0.76);
this.trackSideObject(this.add.text(left + 14, top + 9, rosterLabels[unit.faction], {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: unit.faction === 'ally' ? '#9fd0ff' : '#ffb2a4',
fontStyle: '700'
}));
this.trackSideObject(this.add.text(left + 14, top + 29, `${unit.name} ${unit.className}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: acted ? '#bdbdbd' : '#f2e3bf',
fontStyle: '700'
}));
if (this.phase !== 'command') {
const backBg = this.trackSideObject(this.add.rectangle(left + width - 66, top + 10, 52, 28, 0x1b2834, 0.9));
backBg.setOrigin(0);
backBg.setStrokeStyle(1, palette.gold, 0.72);
backBg.setInteractive({ useHandCursor: true });
backBg.on('pointerdown', () => this.returnToRosterPanel(unit.faction));
const backText = this.trackSideObject(this.add.text(left + width - 40, top + 24, '목록', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#f4dfad',
fontStyle: '700'
}));
backText.setOrigin(0.5);
}
this.trackSideObject(this.add.text(left, top + 78, '병력', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d4dce6',
fontStyle: '700'
}));
const hpValue = this.trackSideObject(this.add.text(left + width, top + 76, `${unit.hp} / ${unit.maxHp}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f0e4c8',
fontStyle: '700'
}));
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}`);
this.renderSmallValueBox(left + width / 2 + 6, top + 112, '이동', `${unit.move}`);
const statTop = top + 164;
statLabels.forEach((stat, index) => {
this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 32, width);
});
const positionText = `위치 ${unit.x + 1}, ${unit.y + 1}${acted ? ' / 행동완료' : ''}`;
this.trackSideObject(this.add.text(left, top + 328, positionText, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px',
color: acted ? '#bdbdbd' : '#9fb0bf'
}));
if (message) {
this.renderPanelMessage(message, left, top + 352, width);
}
}
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));
bg.setOrigin(0);
bg.setStrokeStyle(1, 0x53606c, 0.56);
this.trackSideObject(this.add.text(x + 12, y + 9, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#9fb0bf'
}));
const valueText = this.trackSideObject(this.add.text(x + boxWidth - 12, y + 7, value, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f2e3bf',
fontStyle: '700'
}));
valueText.setOrigin(1, 0);
}
private renderStatRow(label: string, value: number, x: number, y: number, width: number) {
this.trackSideObject(this.add.text(x, y, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
color: '#d4dce6',
fontStyle: '700'
}));
this.drawGauge(x + 76, y + 8, width - 130, 9, value / 100, value >= 80 ? 0xd8b15f : 0x58aee0);
const valueText = this.trackSideObject(this.add.text(x + width, y - 1, `${value}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#f2e3bf',
fontStyle: '700'
}));
valueText.setOrigin(1, 0);
}
private drawGauge(x: number, y: number, width: number, height: number, ratio: number, color: number) {
const track = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0a0f14, 0.82));
track.setOrigin(0);
track.setStrokeStyle(1, 0x40515e, 0.58);
const fillWidth = Math.max(2, Math.floor(width * Phaser.Math.Clamp(ratio, 0, 1)));
const fill = this.trackSideObject(this.add.rectangle(x + 1, y + 1, fillWidth - 2, Math.max(2, height - 2), color, 0.96));
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));
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',
color: '#d4dce6',
wordWrap: { width: width - 24, useAdvancedWrap: true },
lineSpacing: 4
}));
}
private returnToRosterPanel(tab: RosterTab) {
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.phase = 'idle';
this.clearMarkers();
this.hideCommandMenu();
this.renderRosterPanel(tab);
}
private clearSidePanelContent() {
this.sidePanelObjects.forEach((object) => object.destroy());
this.sidePanelObjects = [];
}
private trackSideObject<T extends Phaser.GameObjects.GameObject>(object: T) {
this.sidePanelObjects.push(object);
return object;
}
private setInfo(text: string) {
this.infoText?.setText(text);
if (this.selectedUnit) {
this.renderUnitDetail(this.selectedUnit, text);
return;
}
this.renderRosterPanel(this.rosterTab, text);
}
}