feat: add tactical command officer cut-ins

This commit is contained in:
2026-07-11 03:12:06 +09:00
parent 2b9e33f4c0
commit 055f7d95c9
2 changed files with 347 additions and 28 deletions

View File

@@ -1641,6 +1641,14 @@ type IntentCounterplayActionOutcome = {
type TacticalInitiativeRole = BattleSaveTacticalCommandRole;
type TacticalCommandSource = NonNullable<BattleSaveTacticalCommandState['source']>;
type TacticalCommandCutInSnapshot = {
source: TacticalCommandSource;
role: TacticalInitiativeRole;
actorId: string;
label: string;
bounds: SortieOrderHudBounds;
};
type TacticalInitiativeCombatEffect = {
role?: TacticalInitiativeRole;
damageBonus: number;
@@ -3257,6 +3265,11 @@ export class BattleScene extends Phaser.Scene {
private battleSpeedControlObjects: Phaser.GameObjects.GameObject[] = [];
private tacticalInitiativeChipObjects: Phaser.GameObjects.GameObject[] = [];
private tacticalInitiativePanelObjects: Phaser.GameObjects.GameObject[] = [];
private tacticalCommandCutInObjects: Phaser.GameObjects.GameObject[] = [];
private tacticalCommandCutInBounds?: SortieOrderHudBounds;
private tacticalCommandCutInCount = 0;
private tacticalCommandCutInLast?: TacticalCommandCutInSnapshot;
private tacticalCommandCutInToken = 0;
private sortieOrderHudObjects: Phaser.GameObjects.GameObject[] = [];
private sortieOrderHudStampObjects: Phaser.GameObjects.GameObject[] = [];
private sortieOrderHudBounds?: SortieOrderHudBounds;
@@ -3360,6 +3373,10 @@ export class BattleScene extends Phaser.Scene {
create() {
this.hideBattleResult();
this.hideTacticalCommandCutIn();
this.tacticalCommandCutInCount = 0;
this.tacticalCommandCutInLast = undefined;
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.hideTacticalCommandCutIn());
this.resultFormationReviewVisible = false;
this.resultFormationReviewFeedback = '';
this.resultPresetOverwriteConfirmId = undefined;
@@ -3422,6 +3439,9 @@ export class BattleScene extends Phaser.Scene {
});
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => this.updatePointerFeedback(pointer));
this.input.keyboard?.on('keydown-ENTER', () => {
if (this.tacticalCommandCutInObjects.length > 0) {
return;
}
if (this.tacticalInitiativePanelObjects.length > 0) {
return;
}
@@ -3433,6 +3453,9 @@ export class BattleScene extends Phaser.Scene {
this.confirmLockedTargetPreview();
});
this.input.keyboard?.on('keydown-ESC', () => {
if (this.tacticalCommandCutInObjects.length > 0) {
return;
}
if (this.resultSortieOrderVisible) {
soundDirector.playSelect();
this.hideResultSortieOrder();
@@ -3511,7 +3534,7 @@ export class BattleScene extends Phaser.Scene {
this.renderBattleSpeedControl();
});
this.input.keyboard?.on('keydown-PERIOD', () => {
if (this.tacticalInitiativePanelObjects.length > 0) {
if (this.tacticalInitiativePanelObjects.length > 0 || this.tacticalCommandCutInObjects.length > 0) {
return;
}
this.cycleBattleSpeed();
@@ -4797,6 +4820,7 @@ export class BattleScene extends Phaser.Scene {
this.alertObjects.length === 0 &&
this.battleEventObjects.length === 0 &&
this.combatCutInObjects.length === 0 &&
this.tacticalCommandCutInObjects.length === 0 &&
this.resultObjects.length === 0;
const highlighted = ready || pending;
@@ -4839,6 +4863,7 @@ export class BattleScene extends Phaser.Scene {
this.alertObjects.length > 0 ||
this.battleEventObjects.length > 0 ||
this.combatCutInObjects.length > 0 ||
this.tacticalCommandCutInObjects.length > 0 ||
this.resultObjects.length > 0 ||
this.battleOutcome
) {
@@ -5042,7 +5067,7 @@ export class BattleScene extends Phaser.Scene {
if (available) {
const select = () => {
this.suppressNextLeftClick = true;
this.activateTacticalInitiativeCommand(role);
void this.activateTacticalInitiativeCommand(role);
};
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x254451, 1));
@@ -5053,7 +5078,7 @@ export class BattleScene extends Phaser.Scene {
}
}
private activateTacticalInitiativeCommand(role: TacticalInitiativeRole) {
private async activateTacticalInitiativeCommand(role: TacticalInitiativeRole) {
const actor = this.tacticalInitiativeActor(role);
if (!actor || !this.canUseTacticalInitiativeCommand(role)) {
return;
@@ -5063,8 +5088,9 @@ export class BattleScene extends Phaser.Scene {
return;
}
const source = this.tacticalCommandSource() ?? this.tacticalCommandPanelSource();
this.tacticalCommand = {
source: this.tacticalCommandSource() ?? this.tacticalCommandPanelSource(),
source,
role,
actorId: actor.id,
usedTurn: this.turnNumber,
@@ -5088,38 +5114,244 @@ export class BattleScene extends Phaser.Scene {
const healText = healAmount > 0 ? `병력 +${healAmount}` : '';
const statusText = statusesCleared > 0 ? `상태 ${statusesCleared}개 해제` : '';
effectText = `${supportTarget.name} · ${[healText, statusText].filter(Boolean).join(' · ')}`;
this.showMapResultPopup(
supportTarget,
[healAmount > 0 ? `재정비 +${healAmount}` : '재정비', statusesCleared > 0 ? `상태 해제 ${statusesCleared}` : ''],
'#a8ffd0',
'#071623',
18,
34
);
} else {
this.showMapResultPopup(
actor,
[this.tacticalInitiativeCommandLabel(role), role === 'front' ? '적 일반 공격 방호' : '다음 공격 강화'],
'#ffdf7b',
'#2b1606',
18,
34
);
}
const sourceLabel = this.tacticalCommand.source === 'sortie-order' ? '군령 발동' : '전술 주도권';
const sourceLabel = source === 'sortie-order' ? '군령 발동' : '전술 주도권';
const message = `${sourceLabel} · ${this.tacticalInitiativeCommandLabel(role)} · ${actor.name}\n${effectText}`;
const previousPhase: BattlePhase = this.phase === 'moving' ? 'moving' : 'idle';
this.phase = 'animating';
this.hideTacticalInitiativePanel();
soundDirector.playStrategyPulse();
this.pushBattleLog(message);
this.renderTacticalInitiativeChip();
this.renderSortieOrderHud();
this.refreshEnemyIntentForecast();
if (this.phase === 'moving' && this.selectedUnit) {
if (previousPhase === 'moving' && this.selectedUnit) {
this.renderUnitDetail(this.selectedUnit, `${message}\n이동할 파란 칸을 선택하세요.`);
} else {
this.renderSituationPanel(message);
}
try {
const completed = await this.playTacticalCommandCutIn(source, role, actor, effectText);
if (!completed || this.battleOutcome) {
return;
}
if (role === 'support' && supportTarget) {
const healAmount = this.tacticalCommand.effectValue;
const statusesCleared = this.tacticalCommand.statusesCleared;
this.showMapResultPopup(
supportTarget,
[healAmount > 0 ? `재정비 +${healAmount}` : '재정비', statusesCleared > 0 ? `상태 해제 ${statusesCleared}` : ''],
'#a8ffd0',
'#071623',
18,
34
);
} else {
this.showMapResultPopup(
actor,
[this.tacticalInitiativeCommandLabel(role), role === 'front' ? '적 일반 공격 방호' : '다음 공격 강화'],
'#ffdf7b',
'#2b1606',
18,
34
);
}
} finally {
if (!this.battleOutcome && this.phase === 'animating') {
this.phase = previousPhase;
this.refreshEnemyIntentForecast();
this.renderTacticalInitiativeChip();
this.renderSortieOrderHud();
if (previousPhase === 'moving' && this.selectedUnit) {
this.renderUnitDetail(this.selectedUnit, `${message}\n이동할 파란 칸을 선택하세요.`);
}
}
}
}
private async playTacticalCommandCutIn(
source: TacticalCommandSource,
role: TacticalInitiativeRole,
actor: UnitData,
effectText: string
) {
this.hideTacticalCommandCutIn();
const token = this.tacticalCommandCutInToken;
const width = Math.min(900, Math.max(560, this.scale.width - 120));
const height = 206;
const left = Math.floor((this.scale.width - width) / 2);
const top = Math.floor((this.scale.height - height) / 2);
const bounds = { x: left, y: top, width, height };
const definition = sortieRoleEffects[role];
const roleTone = definition?.tone ?? palette.gold;
const sourceTone = source === 'sortie-order' ? palette.gold : palette.blue;
const sourceLabel = source === 'sortie-order' ? '군령 발동' : '전술 주도권';
const commandLabel = this.tacticalInitiativeCommandLabel(role);
const depth = 92;
this.tacticalCommandCutInBounds = bounds;
this.tacticalCommandCutInCount += 1;
this.tacticalCommandCutInLast = {
source,
role,
actorId: actor.id,
label: commandLabel,
bounds: { ...bounds }
};
const dim = this.trackTacticalCommandCutInObject(
this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.7)
);
dim.setOrigin(0);
dim.setDepth(depth);
dim.setInteractive();
const banner = this.trackTacticalCommandCutInObject(this.add.container(left, top));
banner.setDepth(depth + 1);
const shadow = this.add.rectangle(8, 10, width, height, 0x020406, 0.72);
shadow.setOrigin(0);
const panel = this.add.rectangle(0, 0, width, height, palette.panelDark, 0.99);
panel.setOrigin(0);
panel.setStrokeStyle(3, sourceTone, 0.96);
const roleBand = this.add.rectangle(0, 0, 12, height, roleTone, 0.96);
roleBand.setOrigin(0);
const topRule = this.add.rectangle(12, 0, width - 12, 5, sourceTone, 0.82);
topRule.setOrigin(0);
const portraitFrame = this.add.rectangle(92, height / 2, 144, 178, 0x0a0f14, 0.98);
portraitFrame.setStrokeStyle(2, roleTone, 0.86);
const portraitKey = this.combatPortraitKey(actor);
const portrait = portraitKey && this.textures.exists(portraitKey)
? this.add.image(92, height / 2, portraitKey).setDisplaySize(132, 166)
: this.add.sprite(92, height / 2, this.unitActionTexture(actor), this.unitActionFrameIndex('south', 'strategy'))
.setDisplaySize(132, 132);
const iconFrame = this.add.rectangle(202, 42, 54, 54, 0x0a0f14, 0.96);
iconFrame.setStrokeStyle(1, roleTone, 0.82);
const icon = this.createBattleUiIcon(202, 42, definition?.icon ?? 'leadership', 46);
const sourceText = this.add.text(242, 17, sourceLabel, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: source === 'sortie-order' ? '#f4dfad' : '#d9f1ff',
fontStyle: '700',
letterSpacing: 2
});
const commandText = this.add.text(242, 39, commandLabel, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '34px',
color: '#fff2b8',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 4
});
const actorText = this.add.text(178, 92, `${actor.name} · ${definition?.label ?? this.deploymentRoleLabel(role)}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f2e3bf',
fontStyle: '700'
});
const divider = this.add.rectangle(178, 124, width - 204, 1, roleTone, 0.54);
divider.setOrigin(0);
const detailText = this.add.text(178, 139, effectText, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px',
color: '#c8d2dd',
fontStyle: '700',
wordWrap: { width: width - 208, useAdvancedWrap: true },
maxLines: 2,
lineSpacing: 3
});
const roleMark = this.add.text(width - 24, 20, this.deploymentRoleLabel(role), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#9fb0bf',
fontStyle: '700'
});
roleMark.setOrigin(1, 0);
banner.add([
shadow,
panel,
roleBand,
topRule,
portraitFrame,
portrait,
iconFrame,
icon,
sourceText,
commandText,
actorText,
divider,
detailText,
roleMark
]);
const reducedMotion = typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const enterDuration = this.scaledBattleDuration(150, 100);
const exitAt = this.scaledBattleDuration(700, 500);
const totalDuration = this.scaledBattleDuration(880, 650);
if (!reducedMotion) {
banner.setX(left + 28);
banner.setAlpha(0);
banner.setScale(0.985);
this.tweens.add({
targets: banner,
x: left,
alpha: 1,
scale: 1,
duration: enterDuration,
ease: 'Cubic.easeOut'
});
this.time.delayedCall(exitAt, () => {
if (token !== this.tacticalCommandCutInToken || !banner.active) {
return;
}
this.tweens.add({
targets: banner,
x: left - 18,
alpha: 0,
duration: enterDuration,
ease: 'Cubic.easeIn'
});
this.tweens.add({ targets: dim, alpha: 0, duration: enterDuration, ease: 'Sine.easeIn' });
});
} else {
this.time.delayedCall(Math.max(0, totalDuration - 90), () => {
if (token !== this.tacticalCommandCutInToken || !banner.active) {
return;
}
this.tweens.add({ targets: [banner, dim], alpha: 0, duration: 90, ease: 'Sine.easeIn' });
});
}
await new Promise<void>((resolve) => {
this.time.delayedCall(totalDuration, resolve);
});
if (token !== this.tacticalCommandCutInToken) {
return false;
}
this.hideTacticalCommandCutIn();
return true;
}
private trackTacticalCommandCutInObject<T extends Phaser.GameObjects.GameObject>(object: T) {
this.tacticalCommandCutInObjects.push(object);
return object;
}
private hideTacticalCommandCutIn() {
this.tacticalCommandCutInToken += 1;
this.tacticalCommandCutInObjects.forEach((object) => {
this.tweens?.killTweensOf(object);
if (object.active) {
object.destroy();
}
});
this.tacticalCommandCutInObjects = [];
this.tacticalCommandCutInBounds = undefined;
}
private hideTacticalInitiativePanel() {
@@ -5135,6 +5367,9 @@ export class BattleScene extends Phaser.Scene {
}
private handleTacticalInitiativeKey(event: KeyboardEvent) {
if (this.phase === 'animating' || this.tacticalCommandCutInObjects.length > 0) {
return false;
}
if (this.tacticalInitiativePanelObjects.length > 0) {
const role = ({ '1': 'front', '2': 'flank', '3': 'support' } as const)[event.key as '1' | '2' | '3'];
if (!role) {
@@ -5142,7 +5377,7 @@ export class BattleScene extends Phaser.Scene {
}
event.preventDefault();
if (this.canUseTacticalInitiativeCommand(role)) {
this.activateTacticalInitiativeCommand(role);
void this.activateTacticalInitiativeCommand(role);
}
return true;
}
@@ -7792,6 +8027,17 @@ export class BattleScene extends Phaser.Scene {
command: this.tacticalCommand ? { ...this.tacticalCommand } : null,
panelOpen: this.tacticalInitiativePanelObjects.length > 0,
panelBounds: this.tacticalInitiativePanelBounds ? { ...this.tacticalInitiativePanelBounds } : null,
cutIn: {
active: this.tacticalCommandCutInObjects.length > 0,
count: this.tacticalCommandCutInCount,
bounds: this.tacticalCommandCutInBounds ? { ...this.tacticalCommandCutInBounds } : null,
last: this.tacticalCommandCutInLast
? {
...this.tacticalCommandCutInLast,
bounds: { ...this.tacticalCommandCutInLast.bounds }
}
: null
},
cards
};
}
@@ -10668,6 +10914,7 @@ export class BattleScene extends Phaser.Scene {
this.hideMapMenu();
this.hideTurnEndPrompt();
this.hideTacticalInitiativePanel();
this.hideTacticalCommandCutIn();
this.tacticalInitiativeChipObjects.forEach((object) => object.destroy());
this.tacticalInitiativeChipObjects = [];
this.hideBattleAlert();
@@ -14814,6 +15061,7 @@ export class BattleScene extends Phaser.Scene {
this.hideCommandMenu();
this.hideTurnEndPrompt();
this.hideTacticalInitiativePanel();
this.hideTacticalCommandCutIn();
this.hideCombatCutIn();
this.hideBattleResult();
this.resetSortieOrderHudState();