Add first battle deployment phase

This commit is contained in:
2026-07-04 02:14:45 +09:00
parent 3352359ba1
commit 59bcd96e61

View File

@@ -1108,7 +1108,7 @@ type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt' | 'celebrate';
const levelUpCelebrationStepOffsets = [0, -7, -2, -9, 0, -6, -3, -8];
type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved';
type BattlePhase = 'deployment' | 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved';
type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
type DamageCommand = Exclude<BattleCommand, 'wait'>;
type UsableCommand = Extract<BattleCommand, 'strategy' | 'item'>;
@@ -1146,6 +1146,17 @@ type TurnEndPromptOptions = {
secondaryAction?: () => void;
};
type DeploymentSlot = {
x: number;
y: number;
role: SortieFormationRole;
label: string;
note: string;
unitId?: string;
icon: BattleUiIconKey;
tone: number;
};
type BattleSceneData = {
battleId?: string;
selectedSortieUnitIds?: string[];
@@ -3026,6 +3037,9 @@ export class BattleScene extends Phaser.Scene {
private sidePanelObjects: Phaser.GameObjects.GameObject[] = [];
private commandButtons: CommandButton[] = [];
private commandMenuObjects: Phaser.GameObjects.GameObject[] = [];
private deploymentObjects: Phaser.GameObjects.GameObject[] = [];
private deploymentSelectedUnitId?: string;
private deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.';
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private mapMenuLayout?: MapMenuLayout;
private battleSpeed: BattleSpeed = 'normal';
@@ -3189,9 +3203,13 @@ export class BattleScene extends Phaser.Scene {
this.drawSidePanel();
this.drawDebugSpritePreview();
this.updateTurnText();
this.time.delayedCall(180, () => this.showOpeningBattleEvent());
if (this.shouldShowPreBattleDeployment()) {
this.showPreBattleDeployment();
} else {
this.time.delayedCall(180, () => this.showOpeningBattleEvent());
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
}
this.scheduleDebugForcedBattleOutcome();
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
}
private ensureScenarioAssets(onReady: () => void) {
@@ -3558,6 +3576,11 @@ export class BattleScene extends Phaser.Scene {
hitZone.setInteractive({ useHandCursor: true });
hitZone.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (pointer.leftButtonDown()) {
if (this.phase === 'deployment') {
this.suppressNextLeftClick = true;
this.handleDeploymentUnitClick(unit);
return;
}
this.selectUnit(unit);
}
});
@@ -3949,6 +3972,7 @@ export class BattleScene extends Phaser.Scene {
});
battleUnits.forEach((unit) => this.positionUnitView(unit));
this.positionDeploymentOverlayObjects();
this.updateMiniMap();
this.refreshFacingIndicator();
this.updatePointerFeedback(this.input.activePointer, true);
@@ -4075,6 +4099,11 @@ export class BattleScene extends Phaser.Scene {
return;
}
if (this.phase === 'deployment') {
this.updateDeploymentPointerFeedback(tile, force);
return;
}
if (this.phase === 'moving' && this.selectedUnit) {
this.updateMovePointerFeedback(this.selectedUnit, tile, force);
return;
@@ -4308,6 +4337,11 @@ export class BattleScene extends Phaser.Scene {
}
private selectUnit(unit: UnitData) {
if (this.phase === 'deployment') {
this.handleDeploymentUnitClick(unit);
return;
}
this.hideMapMenu();
this.hideSaveSlotPanel();
this.hideTurnEndPrompt();
@@ -4608,6 +4642,484 @@ export class BattleScene extends Phaser.Scene {
this.clearPointerFeedback();
}
private shouldShowPreBattleDeployment() {
return battleScenario.id === 'first-battle-zhuo-commandery' && battleUnits.some((unit) => unit.faction === 'ally' && unit.hp > 0);
}
private showPreBattleDeployment() {
this.phase = 'deployment';
this.activeFaction = 'ally';
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.targetingAction = undefined;
this.selectedUsable = undefined;
this.lockedTargetPreview = undefined;
this.deploymentSelectedUnitId = battleUnits.find((unit) => unit.id === 'guan-yu' && unit.hp > 0)?.id;
this.deploymentNotice = '추천 배치는 이미 적용되어 있습니다. 필요하면 장수를 선택해 시작 구역 안에서 위치를 바꾸세요.';
this.clearMarkers();
this.hideCommandMenu();
this.hideMapMenu();
this.hideTurnEndPrompt();
this.hideBattleEventBanner();
this.centerCameraOnTile(2, 16);
this.renderDeploymentMap();
this.renderDeploymentPanel();
this.refreshUnitLegibilityStyles();
this.updatePointerFeedback(this.input.activePointer, true);
}
private firstBattleDeploymentSlots(): DeploymentSlot[] {
const slots: DeploymentSlot[] = [
{
x: 2,
y: 15,
role: 'front',
unitId: 'guan-yu',
label: '정면 돌파',
note: '길목 보병을 가장 먼저 받습니다.',
icon: 'spear',
tone: 0xd8b15f
},
{
x: 1,
y: 16,
role: 'flank',
unitId: 'zhang-fei',
label: '측면 압박',
note: '아래쪽 길로 궁병 엄호를 흔듭니다.',
icon: 'attack',
tone: 0xc76542
},
{
x: 1,
y: 15,
role: 'support',
unitId: 'liu-bei',
label: '후방 지휘',
note: '유비 생존과 회복 여지를 지킵니다.',
icon: 'leadership',
tone: 0x5f95d8
},
{ x: 2, y: 16, role: 'reserve', label: '예비 자리', note: '전열을 좁게 묶습니다.', icon: 'move', tone: 0x7d8a96 },
{ x: 1, y: 17, role: 'reserve', label: '예비 자리', note: '후방을 더 안전하게 둡니다.', icon: 'move', tone: 0x7d8a96 },
{ x: 2, y: 17, role: 'reserve', label: '예비 자리', note: '측면 출발을 늦춥니다.', icon: 'move', tone: 0x7d8a96 }
];
return slots.filter((slot) => {
if (!this.isInBounds(slot.x, slot.y)) {
return false;
}
return getTerrainRule(battleMap.terrain[slot.y][slot.x]).passable !== false;
});
}
private isDeploymentTile(x: number, y: number) {
return this.firstBattleDeploymentSlots().some((slot) => slot.x === x && slot.y === y);
}
private deploymentSlotAt(x: number, y: number) {
return this.firstBattleDeploymentSlots().find((slot) => slot.x === x && slot.y === y);
}
private renderDeploymentMap() {
this.clearDeploymentObjects();
const selected = this.deploymentSelectedUnit();
this.firstBattleDeploymentSlots().forEach((slot) => {
const occupant = this.unitAtTile(slot.x, slot.y);
const recommendedUnit = slot.unitId ? battleUnits.find((unit) => unit.id === slot.unitId) : undefined;
const selectedTile = selected?.x === slot.x && selected?.y === slot.y;
const recommended = Boolean(slot.unitId);
const fillAlpha = selectedTile ? 0.34 : recommended ? 0.2 : 0.12;
const strokeAlpha = selectedTile ? 1 : recommended ? 0.86 : 0.46;
const strokeWidth = selectedTile ? 3 : recommended ? 2 : 1;
const marker = this.trackDeploymentObject(
this.add.rectangle(this.tileTopLeftX(slot.x), this.tileTopLeftY(slot.y), this.layout.tileSize, this.layout.tileSize, slot.tone, fillAlpha)
);
marker.setName(`deployment-slot-${slot.x}-${slot.y}`);
marker.setData('tileX', slot.x);
marker.setData('tileY', slot.y);
marker.setOrigin(0);
marker.setDepth(4.75);
marker.setStrokeStyle(strokeWidth, slot.tone, strokeAlpha);
if (this.mapMask) {
marker.setMask(this.mapMask);
}
marker.setInteractive({ useHandCursor: true });
marker.on('pointerover', () => {
marker.setFillStyle(slot.tone, Math.min(fillAlpha + 0.14, 0.44));
this.deploymentNotice = occupant
? `${occupant.name}: ${slot.label}. 다른 장수를 선택한 뒤 누르면 위치를 교환합니다.`
: `${slot.label}: ${slot.note}`;
this.renderDeploymentPanel();
});
marker.on('pointerout', () => {
marker.setFillStyle(slot.tone, fillAlpha);
});
marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (pointer.leftButtonDown()) {
this.suppressNextLeftClick = true;
this.handleDeploymentTileClick(slot.x, slot.y);
}
});
if (recommendedUnit) {
const label = this.trackDeploymentObject(
this.add.text(this.tileCenterX(slot.x), this.tileTopLeftY(slot.y) + 3, slot.label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '10px',
color: '#fff1c8',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 3
})
);
label.setName(`deployment-label-${slot.x}-${slot.y}`);
label.setData('tileX', slot.x);
label.setData('tileY', slot.y);
label.setData('tileOffsetX', this.layout.tileSize / 2);
label.setData('tileOffsetY', 3);
label.setOrigin(0.5, 0);
label.setDepth(11.2);
if (this.mapMask) {
label.setMask(this.mapMask);
}
}
});
this.positionDeploymentOverlayObjects();
}
private clearDeploymentObjects() {
this.deploymentObjects.forEach((object) => object.destroy());
this.deploymentObjects = [];
}
private trackDeploymentObject<T extends Phaser.GameObjects.GameObject>(object: T) {
this.deploymentObjects.push(object);
return object;
}
private positionDeploymentOverlayObjects() {
this.deploymentObjects.forEach((object) => {
const tileX = object.getData('tileX') as number | undefined;
const tileY = object.getData('tileY') as number | undefined;
if (tileX === undefined || tileY === undefined) {
return;
}
const offsetX = (object.getData('tileOffsetX') as number | undefined) ?? 0;
const offsetY = (object.getData('tileOffsetY') as number | undefined) ?? 0;
const tileObject = object as Phaser.GameObjects.GameObject & {
setPosition: (x: number, y: number) => Phaser.GameObjects.GameObject;
setVisible: (value: boolean) => Phaser.GameObjects.GameObject;
input?: { enabled: boolean };
};
const visible = this.isTileVisible(tileX, tileY);
tileObject.setPosition(this.tileTopLeftX(tileX) + offsetX, this.tileTopLeftY(tileY) + offsetY);
tileObject.setVisible(visible);
if (tileObject.input) {
tileObject.input.enabled = visible;
}
});
}
private renderDeploymentPanel() {
this.clearSidePanelContent();
this.setMiniMapVisible(false);
const { panelX, panelY, panelWidth, panelHeight } = this.layout;
const left = panelX + 24;
const width = panelWidth - 48;
const top = this.sideContentTop();
const selected = this.deploymentSelectedUnit();
const header = this.trackSideObject(this.add.rectangle(left, top, width, 92, 0x101820, 0.96));
header.setOrigin(0);
header.setStrokeStyle(1, palette.gold, 0.78);
this.trackSideObject(this.add.text(left + 16, top + 13, '전열 배치', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px',
color: '#f4dfad',
fontStyle: '700'
}));
this.trackSideObject(this.add.text(left + 16, top + 48, '황건 잔당의 보병 전열을 보고 시작 위치를 정합니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#c8d2dd',
wordWrap: { width: width - 32, useAdvancedWrap: true }
}));
const noticeTop = top + 104;
const notice = this.trackSideObject(this.add.rectangle(left, noticeTop, width, 54, 0x0b1118, 0.94));
notice.setOrigin(0);
notice.setStrokeStyle(1, selected ? palette.blue : 0x647485, selected ? 0.74 : 0.56);
this.trackSideObject(this.add.text(left + 14, noticeTop + 10, selected ? `선택: ${selected.name}` : '장수를 선택하세요.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: selected ? '#f2e3bf' : '#d4dce6',
fontStyle: '700'
}));
this.trackSideObject(this.add.text(left + 14, noticeTop + 30, this.deploymentNotice, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#9fb0bf',
fixedWidth: width - 28
}));
const roleTop = noticeTop + 72;
this.deploymentRoleSummaries().forEach((summary, index) => {
const rowTop = roleTop + index * 72;
const active = selected?.id === summary.unitId;
const row = this.trackSideObject(this.add.rectangle(left, rowTop, width, 62, active ? 0x21364a : 0x101820, active ? 0.98 : 0.92));
row.setOrigin(0);
row.setStrokeStyle(1, active ? summary.tone : 0x53606c, active ? 0.86 : 0.52);
row.setInteractive({ useHandCursor: true });
row.on('pointerdown', () => {
const unit = battleUnits.find((candidate) => candidate.id === summary.unitId && candidate.hp > 0);
if (unit) {
this.handleDeploymentUnitClick(unit);
}
});
const icon = this.trackSideIcon(left + 31, rowTop + 31, summary.icon, 42);
icon.setDepth(34);
this.trackSideObject(this.add.text(left + 58, rowTop + 9, `${summary.name} · ${summary.role}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px',
color: active ? '#f4dfad' : '#e7edf7',
fontStyle: '700'
}));
this.trackSideObject(this.add.text(left + 58, rowTop + 32, summary.text, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#aeb7c2',
fixedWidth: width - 70
}));
});
this.renderDeploymentButton(left, panelY + panelHeight - 76, width, '전투 시작', palette.gold, () => this.confirmPreBattleDeployment());
this.renderDeploymentButton(left, panelY + panelHeight - 116, width, '추천 배치 복원', 0x647485, () => this.restoreRecommendedDeployment());
}
private deploymentRoleSummaries() {
return [
{
unitId: 'guan-yu',
name: this.unitName('guan-yu'),
role: '정면 돌파',
text: '길목의 황건 보병을 먼저 열어 전열을 만듭니다.',
icon: 'spear' as BattleUiIconKey,
tone: 0xd8b15f
},
{
unitId: 'zhang-fei',
name: this.unitName('zhang-fei'),
role: '측면 압박',
text: '아래쪽에서 궁병 엄호와 잔당을 흔듭니다.',
icon: 'attack' as BattleUiIconKey,
tone: 0xc76542
},
{
unitId: 'liu-bei',
name: this.unitName('liu-bei'),
role: '후방 지휘',
text: '유비를 무리시키지 않고 생존과 회복 여지를 남깁니다.',
icon: 'leadership' as BattleUiIconKey,
tone: 0x5f95d8
}
];
}
private renderDeploymentButton(
x: number,
y: number,
width: number,
label: string,
tone: number,
action: () => void
) {
const button = this.trackSideObject(this.add.rectangle(x, y, width, 34, 0x17232e, 0.96));
button.setOrigin(0);
button.setStrokeStyle(1, tone, 0.86);
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x243746, 0.98));
button.on('pointerout', () => button.setFillStyle(0x17232e, 0.96));
button.on('pointerdown', () => {
this.suppressNextLeftClick = true;
action();
});
const text = this.trackSideObject(this.add.text(x + width / 2, y + 17, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px',
color: '#f2e3bf',
fontStyle: '700'
}));
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', () => {
this.suppressNextLeftClick = true;
action();
});
}
private deploymentSelectedUnit() {
return this.deploymentSelectedUnitId
? battleUnits.find((unit) => unit.id === this.deploymentSelectedUnitId && unit.faction === 'ally' && unit.hp > 0)
: undefined;
}
private handleDeploymentUnitClick(unit: UnitData) {
if (this.phase !== 'deployment') {
return;
}
if (unit.faction !== 'ally' || unit.hp <= 0) {
this.deploymentNotice = '전열 배치 중에는 아군 시작 위치만 조정할 수 있습니다.';
this.renderDeploymentPanel();
return;
}
const selected = this.deploymentSelectedUnit();
if (!selected || selected.id === unit.id) {
this.deploymentSelectedUnitId = unit.id;
this.deploymentNotice = `${unit.name}을 선택했습니다. 빈 시작 칸을 누르면 이동하고, 다른 장수를 누르면 위치를 바꿉니다.`;
soundDirector.playSelect();
this.renderDeploymentMap();
this.renderDeploymentPanel();
return;
}
this.swapDeploymentUnits(selected, unit);
}
private handleDeploymentTileClick(x: number, y: number) {
if (this.phase !== 'deployment') {
return;
}
const selected = this.deploymentSelectedUnit();
const occupant = this.unitAtTile(x, y);
if (occupant?.faction === 'ally') {
this.handleDeploymentUnitClick(occupant);
return;
}
if (!selected) {
this.deploymentNotice = '먼저 유비, 관우, 장비 중 한 명을 선택하세요.';
this.renderDeploymentPanel();
return;
}
if (!this.isDeploymentTile(x, y)) {
this.deploymentNotice = '첫 전투에서는 표시된 시작 구역 안에서만 전열을 바꿀 수 있습니다.';
this.renderDeploymentPanel();
return;
}
if (occupant) {
this.deploymentNotice = `${occupant.name}이 있는 칸입니다. 시작 구역 안의 빈 칸을 선택하세요.`;
this.renderDeploymentPanel();
return;
}
const fromX = selected.x;
const fromY = selected.y;
selected.x = x;
selected.y = y;
this.moveUnitView(selected, x, y, this.unitViews.get(selected.id), 150, fromX, fromY);
this.deploymentNotice = `${selected.name}${this.deploymentSlotAt(x, y)?.label ?? '시작 위치'}에 배치했습니다.`;
this.updateMiniMap();
this.renderDeploymentMap();
this.renderDeploymentPanel();
soundDirector.playSelect();
}
private swapDeploymentUnits(selected: UnitData, target: UnitData) {
const selectedFrom = { x: selected.x, y: selected.y };
const targetFrom = { x: target.x, y: target.y };
selected.x = targetFrom.x;
selected.y = targetFrom.y;
target.x = selectedFrom.x;
target.y = selectedFrom.y;
this.moveUnitView(selected, selected.x, selected.y, this.unitViews.get(selected.id), 150, selectedFrom.x, selectedFrom.y);
this.moveUnitView(target, target.x, target.y, this.unitViews.get(target.id), 150, targetFrom.x, targetFrom.y);
this.deploymentNotice = `${selected.name}${target.name}의 위치를 바꿨습니다.`;
this.updateMiniMap();
this.renderDeploymentMap();
this.renderDeploymentPanel();
soundDirector.playSelect();
}
private restoreRecommendedDeployment() {
const recommended = this.firstBattleDeploymentSlots().filter((slot) => slot.unitId);
recommended.forEach((slot) => {
const unit = battleUnits.find((candidate) => candidate.id === slot.unitId && candidate.hp > 0);
if (!unit) {
return;
}
const fromX = unit.x;
const fromY = unit.y;
unit.x = slot.x;
unit.y = slot.y;
this.moveUnitView(unit, slot.x, slot.y, this.unitViews.get(unit.id), 130, fromX, fromY);
});
this.deploymentSelectedUnitId = 'guan-yu';
this.deploymentNotice = '추천 배치로 되돌렸습니다. 관우는 정면, 장비는 측면, 유비는 후방에 섭니다.';
this.updateMiniMap();
this.renderDeploymentMap();
this.renderDeploymentPanel();
soundDirector.playSelect();
}
private confirmPreBattleDeployment() {
if (this.phase !== 'deployment') {
return;
}
this.clearDeploymentObjects();
this.clearPointerFeedback();
this.deploymentSelectedUnitId = undefined;
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.phase = 'idle';
this.refreshUnitLegibilityStyles();
this.renderRosterPanel('ally', '전열 배치가 끝났습니다. 관우로 길목을 열고, 장비로 측면을 압박하세요.');
this.showOpeningBattleEvent();
soundDirector.playSelect();
}
private updateDeploymentPointerFeedback(tile: { x: number; y: number }, force: boolean) {
const selected = this.deploymentSelectedUnit();
const occupant = this.unitAtTile(tile.x, tile.y);
const slot = this.deploymentSlotAt(tile.x, tile.y);
const key = `deployment:${tile.x},${tile.y}:${selected?.id ?? 'none'}:${occupant?.id ?? 'empty'}:${slot?.label ?? 'out'}`;
if (!force && this.lastPointerFeedbackKey === key) {
return;
}
if (occupant?.faction === 'ally') {
const text = selected && selected.id !== occupant.id
? `${occupant.name}: 클릭하면 ${selected.name}과 위치 교환`
: `${occupant.name}: 선택 후 시작 구역 칸으로 이동`;
this.showPointerFeedback(tile, palette.blue, text, key, 0x1c7ed6, 0.12, 0.9);
return;
}
if (slot && selected) {
this.showPointerFeedback(tile, slot.tone, `${slot.label}: ${selected.name} 배치 가능`, key, slot.tone, 0.16, 0.92);
return;
}
if (slot) {
this.showPointerFeedback(tile, slot.tone, `${slot.label}: 먼저 장수를 선택하세요`, key, slot.tone, 0.12, 0.7);
return;
}
this.showPointerFeedback(tile, 0x9b4d45, '표시된 시작 구역 안에서만 배치할 수 있습니다.', key, 0x9b4d45, 0.08, 0.66);
}
private showEnemyThreatRange() {
this.clearMarkers();
const threatTiles = this.enemyThreatTiles();
@@ -9517,6 +10029,15 @@ export class BattleScene extends Phaser.Scene {
return;
}
if (this.phase === 'deployment') {
this.deploymentSelectedUnitId = undefined;
this.deploymentNotice = '선택을 해제했습니다. 배치할 장수를 다시 선택하세요.';
this.renderDeploymentMap();
this.renderDeploymentPanel();
this.clearPointerFeedback();
return;
}
if (this.cancelAttackTargeting()) {
return;
}
@@ -9543,6 +10064,14 @@ export class BattleScene extends Phaser.Scene {
return;
}
if (this.phase === 'deployment') {
const tile = this.pointerToTile(pointer);
if (tile) {
this.handleDeploymentTileClick(tile.x, tile.y);
}
return;
}
const mapMenuAction = this.mapMenuActionAt(pointer.x, pointer.y);
if (mapMenuAction) {
this.runMapMenuAction(mapMenuAction);
@@ -9623,7 +10152,7 @@ export class BattleScene extends Phaser.Scene {
}
private showMapMenu(pointerX: number, pointerY: number) {
if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') {
if (this.phase === 'deployment' || this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') {
return;
}