Add popup commands and move cancel

This commit is contained in:
2026-06-22 01:33:57 +09:00
parent 34c23def39
commit e9dc21cb4b
2 changed files with 136 additions and 31 deletions

View File

@@ -16,7 +16,7 @@ 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 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, movement range preview, click-to-move unit movement, post-move command selection, and grayscale acted-unit feedback
- First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, and grayscale acted-unit feedback
- Flow verification script from title to first battle
## Next Steps

View File

@@ -49,6 +49,14 @@ type CommandButton = {
text: Phaser.GameObjects.Text;
};
type PendingMove = {
unit: UnitData;
fromX: number;
fromY: number;
toX: number;
toY: number;
};
const commandLabels: Record<BattleCommand, string> = {
attack: '공격',
strategy: '책략',
@@ -64,8 +72,10 @@ export class BattleScene extends Phaser.Scene {
private turnText?: Phaser.GameObjects.Text;
private markers: Phaser.GameObjects.Rectangle[] = [];
private commandButtons: CommandButton[] = [];
private commandMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private unitViews = new Map<string, UnitView>();
private actedUnitIds = new Set<string>();
private pendingMove?: PendingMove;
constructor() {
super('BattleScene');
@@ -75,6 +85,12 @@ export class BattleScene extends Phaser.Scene {
const { width, height } = this.scale;
this.layout = this.createLayout(width, height);
soundDirector.playMusic('battle-prep');
this.input.mouse?.disableContextMenu();
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (pointer.rightButtonDown()) {
this.cancelPendingMove();
}
});
this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0);
this.drawMap();
@@ -245,6 +261,7 @@ export class BattleScene extends Phaser.Scene {
this.phase = 'moving';
this.selectedUnit = unit;
this.pendingMove = undefined;
this.clearMarkers();
this.hideCommandMenu();
this.showMoveRange(unit);
@@ -272,13 +289,17 @@ export class BattleScene extends Phaser.Scene {
marker.setInteractive({ useHandCursor: true });
marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.36));
marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.24));
marker.on('pointerdown', () => this.moveSelectedUnit(x, y));
marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (pointer.leftButtonDown()) {
this.moveSelectedUnit(x, y, pointer);
}
});
this.markers.push(marker);
}
}
}
private moveSelectedUnit(x: number, y: number) {
private moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) {
if (!this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) {
return;
}
@@ -289,30 +310,20 @@ export class BattleScene extends Phaser.Scene {
return;
}
const fromX = unit.x;
const fromY = unit.y;
unit.x = x;
unit.y = y;
this.pendingMove = { unit, fromX, fromY, toX: x, toY: y };
this.phase = 'command';
this.clearMarkers();
soundDirector.playSelect();
const targetX = this.tileCenterX(x);
const targetY = this.tileCenterY(y);
this.tweens.add({
targets: view.sprite,
x: targetX,
y: targetY,
duration: 260,
ease: 'Sine.easeInOut'
});
this.tweens.add({
targets: view.label,
x: targetX,
y: targetY + this.layout.tileSize * 0.36,
duration: 260,
ease: 'Sine.easeInOut'
});
this.showCommandMenu(unit);
this.setInfo(`${unit.name} 이동 완료\n위치: ${x + 1}, ${y + 1}\n이제 공격, 책략, 도구, 대기 중 하나를 선택하세요.`);
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우클릭하면 이동을 취소합니다.`);
}
private canMoveTo(unit: UnitData, x: number, y: number) {
@@ -358,33 +369,61 @@ export class BattleScene extends Phaser.Scene {
this.markers = [];
}
private showCommandMenu(unit: UnitData) {
private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) {
this.hideCommandMenu();
const { panelX, panelY, panelWidth } = this.layout;
const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait'];
const buttonWidth = panelWidth - 48;
const buttonHeight = 44;
const startY = panelY + 354;
const menuWidth = 136;
const titleHeight = 31;
const buttonHeight = 34;
const gap = 5;
const padding = 8;
const menuHeight = padding * 2 + titleHeight + commands.length * buttonHeight + (commands.length - 1) * gap;
const { left, top } = this.commandMenuPosition(pointerX, pointerY, menuWidth, menuHeight);
const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97);
panel.setOrigin(0);
panel.setStrokeStyle(2, palette.gold, 0.76);
panel.setDepth(14);
this.commandMenuObjects.push(panel);
const title = this.add.text(left + menuWidth / 2, top + padding + 2, '명령', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
color: '#d8b15f',
fontStyle: '700'
});
title.setOrigin(0.5, 0);
title.setDepth(15);
this.commandMenuObjects.push(title);
commands.forEach((command, index) => {
const centerX = panelX + 24 + buttonWidth / 2;
const centerY = startY + index * 56;
const background = this.add.rectangle(centerX, centerY, buttonWidth, buttonHeight, 0x1a2630, 0.94);
const buttonTop = top + padding + titleHeight + index * (buttonHeight + gap);
const centerX = left + menuWidth / 2;
const centerY = buttonTop + buttonHeight / 2;
const background = this.add.rectangle(centerX, centerY, menuWidth - padding * 2, buttonHeight, 0x1a2630, 0.94);
background.setStrokeStyle(1, command === 'wait' ? palette.gold : palette.blue, 0.7);
background.setInteractive({ useHandCursor: true });
background.setDepth(12);
background.setDepth(15);
const text = this.add.text(centerX, centerY, commandLabels[command], {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
fontSize: '18px',
color: '#f2e3bf',
fontStyle: '700'
});
text.setOrigin(0.5);
text.setDepth(13);
text.setDepth(16);
const chooseCommand = () => this.completeUnitAction(unit, command);
const chooseCommand = (choicePointer: Phaser.Input.Pointer) => {
if (choicePointer.rightButtonDown()) {
this.cancelPendingMove();
return;
}
if (choicePointer.leftButtonDown()) {
this.completeUnitAction(unit, command);
}
};
background.on('pointerover', () => background.setFillStyle(0x283947, 0.98));
background.on('pointerout', () => background.setFillStyle(0x1a2630, 0.94));
background.on('pointerdown', chooseCommand);
@@ -395,7 +434,21 @@ export class BattleScene extends Phaser.Scene {
});
}
private commandMenuPosition(pointerX: number, pointerY: number, width: number, height: number) {
const margin = 14;
const offset = 18;
const preferredLeft = pointerX + offset + width > this.scale.width - margin ? pointerX - offset - width : pointerX + offset;
const preferredTop = pointerY + height > this.scale.height - margin ? this.scale.height - margin - height : pointerY;
return {
left: Phaser.Math.Clamp(preferredLeft, margin, this.scale.width - margin - width),
top: Phaser.Math.Clamp(preferredTop, margin, this.scale.height - margin - height)
};
}
private hideCommandMenu() {
this.commandMenuObjects.forEach((object) => object.destroy());
this.commandMenuObjects = [];
this.commandButtons.forEach(({ background, text }) => {
background.destroy();
text.destroy();
@@ -411,6 +464,7 @@ export class BattleScene extends Phaser.Scene {
this.actedUnitIds.add(unit.id);
this.phase = 'idle';
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.hideCommandMenu();
this.applyActedStyle(unit);
soundDirector.playSelect();
@@ -436,6 +490,57 @@ export class BattleScene extends Phaser.Scene {
return firstBattleUnits.filter((unit) => unit.faction === 'ally' && !this.actedUnitIds.has(unit.id)).length;
}
private cancelPendingMove() {
if (this.phase !== 'command' || !this.pendingMove) {
return;
}
const { unit, fromX, fromY, toX, toY } = this.pendingMove;
const view = this.unitViews.get(unit.id);
unit.x = fromX;
unit.y = fromY;
this.phase = 'moving';
this.selectedUnit = unit;
this.pendingMove = undefined;
this.hideCommandMenu();
soundDirector.playSelect();
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다시 이동할 파란 칸을 선택하세요.`);
}
private moveUnitView(
unit: UnitData,
x: number,
y: number,
view = this.unitViews.get(unit.id),
duration = 260
) {
if (!view) {
return;
}
const targetX = this.tileCenterX(x);
const targetY = this.tileCenterY(y);
this.tweens.killTweensOf([view.sprite, view.label]);
this.tweens.add({
targets: view.sprite,
x: targetX,
y: targetY,
duration,
ease: 'Sine.easeInOut'
});
this.tweens.add({
targets: view.label,
x: targetX,
y: targetY + this.layout.tileSize * 0.36,
duration,
ease: 'Sine.easeInOut'
});
}
private applyActedStyle(unit: UnitData) {
const view = this.unitViews.get(unit.id);
if (!view) {