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

@@ -16,12 +16,12 @@ 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, ally/enemy roster tabs, unit detail stat panels, movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, and grayscale acted-unit feedback
- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, ally/enemy roster tabs, unit detail stat panels, 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, and victory checks
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

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();