feat: surface pursuit-ready attacks in battle

This commit is contained in:
2026-07-11 13:30:04 +09:00
parent 307040ef00
commit 5b6ffa28f2
2 changed files with 754 additions and 41 deletions

View File

@@ -159,6 +159,391 @@ try {
`Expected pursuit damage and defeat credit to belong to the supporter, cancel counters on defeat, and reset next turn: ${JSON.stringify(firstBattleBondChain.chain)}` `Expected pursuit damage and defeat credit to belong to the supporter, cancel counters on defeat, and reset next turn: ${JSON.stringify(firstBattleBondChain.chain)}`
); );
const firstBattleBondChainReadyUi = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const stateBefore = window.__HEROS_DEBUG__?.battle();
const mechanics = stateBefore?.combatMechanicsProbe;
const attacker = mechanics?.attackerId ? scene?.debugUnitById(mechanics.attackerId) : undefined;
const partner = mechanics?.partnerId ? scene?.debugUnitById(mechanics.partnerId) : undefined;
const target = mechanics?.enemyId ? scene?.debugUnitById(mechanics.enemyId) : undefined;
const mismatchTarget = stateBefore?.units?.find(
(unit) => unit.faction === 'enemy' && unit.hp > 0 && unit.id !== mechanics?.enemyId
);
if (
!scene ||
!attacker ||
!partner ||
!target ||
!mismatchTarget ||
stateBefore?.phase !== 'idle' ||
stateBefore.selectedUnitId !== null ||
stateBefore.commandMenuVisible !== false
) {
return {
ready: false,
reason: 'first battle was not in a clean idle state for the pursuit-ready UI fixture',
stateBefore,
mechanics
};
}
const touchedUnits = [attacker, partner, target];
const original = {
phase: scene.phase,
selectedUnit: scene.selectedUnit,
pendingMove: scene.pendingMove,
targetingAction: scene.targetingAction,
selectedUsable: scene.selectedUsable,
lockedTargetPreview: scene.lockedTargetPreview,
attackIntents: scene.attackIntents.map((intent) => ({ ...intent })),
actedUnitIds: new Set(scene.actedUnitIds),
cameraTileX: scene.cameraTileX,
cameraTileY: scene.cameraTileY,
miniMapVisible: scene.miniMapVisible,
rosterTab: scene.rosterTab,
units: touchedUnits.map((unit) => ({
id: unit.id,
x: unit.x,
y: unit.y,
hp: unit.hp,
maxHp: unit.maxHp
}))
};
const fixtureViewportBounds = { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height };
const fixturePanelBounds = {
x: scene.layout.panelX,
y: scene.layout.panelY,
width: scene.layout.panelWidth,
height: scene.layout.panelHeight
};
const fixtureTargetPosition = { x: 5, y: 15 };
const fixtureTargetTileBounds = () => ({
x: scene.tileTopLeftX(target.x),
y: scene.tileTopLeftY(target.y),
width: scene.layout.tileSize,
height: scene.layout.tileSize
});
const fixturePartnerTileBounds = () => ({
x: scene.tileTopLeftX(partner.x),
y: scene.tileTopLeftY(partner.y),
width: scene.layout.tileSize,
height: scene.layout.tileSize
});
const capture = () => {
const state = scene.getDebugState();
return {
phase: state.phase,
selectedUnitId: state.selectedUnitId,
selectedUsable: state.selectedUsable,
preview: state.bondChainReadyPreview ?? null,
targetTileBounds: fixtureTargetTileBounds(),
partnerTileBounds: fixturePartnerTileBounds(),
viewportBounds: fixtureViewportBounds,
panelBounds: fixturePanelBounds
};
};
const stageTargeting = ({ user, action, usable, intentAttackerId, intentTargetId, actedUnitId }) => {
scene.clearMarkers();
scene.hideCommandMenu();
scene.selectedUnit = user;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.lockedTargetPreview = undefined;
scene.phase = 'command';
scene.attackIntents = [{ attackerId: intentAttackerId, targetId: intentTargetId }];
scene.actedUnitIds = new Set([actedUnitId]);
scene.beginDamageTargeting(user, action, usable);
};
let probes;
try {
attacker.x = 4;
attacker.y = 15;
partner.x = 3;
partner.y = 15;
target.x = fixtureTargetPosition.x;
target.y = fixtureTargetPosition.y;
target.maxHp = Math.max(target.maxHp, 5000);
target.hp = target.maxHp;
scene.centerCameraOnTile(target.x, target.y);
touchedUnits.forEach((unit) => scene.positionUnitView(unit));
stageTargeting({
user: attacker,
action: 'attack',
intentAttackerId: partner.id,
intentTargetId: target.id,
actedUnitId: partner.id
});
const eligibleCombatPreview = scene.combatPreview(attacker, target, 'attack');
scene.renderAttackPreview(eligibleCombatPreview, false);
const eligible = { ...capture(), baseDamage: eligibleCombatPreview.damage, targetHp: target.hp };
const eligibleCamera = { x: scene.cameraTileX, y: scene.cameraTileY };
scene.cameraTileX = scene.maxCameraTileX();
scene.cameraTileY = 0;
scene.updateCameraView();
const offscreen = capture();
scene.cameraTileX = eligibleCamera.x;
scene.cameraTileY = eligibleCamera.y;
scene.updateCameraView();
const returned = capture();
stageTargeting({
user: attacker,
action: 'attack',
intentAttackerId: partner.id,
intentTargetId: mismatchTarget.id,
actedUnitId: partner.id
});
const mismatch = capture();
const damageStrategy = scene.availableUsables(partner, 'strategy').find((usable) => usable.effect === 'damage');
if (!damageStrategy) {
throw new Error(`Expected ${partner.id} to have a damaging strategy for the pursuit-ready UI fixture.`);
}
stageTargeting({
user: partner,
action: damageStrategy.command,
usable: damageStrategy,
intentAttackerId: attacker.id,
intentTargetId: target.id,
actedUnitId: attacker.id
});
const strategy = capture();
partner.x = 3;
partner.y = 15;
scene.positionUnitView(partner);
const lethalBaseDamage = scene.combatPreview(attacker, target, 'attack').damage;
target.hp = Math.max(1, lethalBaseDamage);
stageTargeting({
user: attacker,
action: 'attack',
intentAttackerId: partner.id,
intentTargetId: target.id,
actedUnitId: partner.id
});
const lethal = {
...capture(),
baseDamage: scene.combatPreview(attacker, target, 'attack').damage,
targetHp: target.hp
};
target.hp = target.maxHp;
partner.x = 18;
partner.y = 17;
scene.positionUnitView(partner);
stageTargeting({
user: attacker,
action: 'attack',
intentAttackerId: partner.id,
intentTargetId: target.id,
actedUnitId: partner.id
});
const far = capture();
probes = {
ready: true,
attackerId: attacker.id,
partnerId: partner.id,
partnerName: partner.name,
targetId: target.id,
targetName: target.name,
expectedRate: mechanics.chain?.rate ?? null,
eligible,
offscreen,
returned,
mismatch,
strategy,
lethal,
far
};
} finally {
scene.clearMarkers();
scene.hideCommandMenu();
original.units.forEach((snapshot) => {
const unit = scene.debugUnitById(snapshot.id);
if (!unit) {
return;
}
unit.x = snapshot.x;
unit.y = snapshot.y;
unit.hp = snapshot.hp;
unit.maxHp = snapshot.maxHp;
});
scene.attackIntents = original.attackIntents.map((intent) => ({ ...intent }));
scene.actedUnitIds = new Set(original.actedUnitIds);
scene.selectedUnit = original.selectedUnit;
scene.pendingMove = original.pendingMove;
scene.targetingAction = original.targetingAction;
scene.selectedUsable = original.selectedUsable;
scene.lockedTargetPreview = original.lockedTargetPreview;
scene.phase = original.phase;
scene.cameraTileX = original.cameraTileX;
scene.cameraTileY = original.cameraTileY;
scene.updateCameraView();
scene.refreshUnitLegibilityStyles();
scene.renderRosterPanel(original.rosterTab, '행동할 장수를 선택하세요.');
scene.setMiniMapVisible(original.miniMapVisible);
}
const restored = scene.getDebugState();
return {
...probes,
restored: {
phase: restored.phase,
selectedUnitId: restored.selectedUnitId,
commandMenuVisible: restored.commandMenuVisible,
markerCount: restored.markerCount,
actedUnitIds: [...restored.actedUnitIds].sort(),
attackIntents: restored.attackIntents,
camera: restored.camera,
pursuitReady: restored.bondChainReadyPreview ?? null,
units: restored.units
.filter((unit) => original.units.some((snapshot) => snapshot.id === unit.id))
.map(({ id, x, y, hp, maxHp }) => ({ id, x, y, hp, maxHp }))
.sort((left, right) => left.id.localeCompare(right.id))
},
expectedRestored: {
phase: stateBefore.phase,
selectedUnitId: stateBefore.selectedUnitId,
commandMenuVisible: stateBefore.commandMenuVisible,
markerCount: stateBefore.markerCount,
actedUnitIds: [...stateBefore.actedUnitIds].sort(),
attackIntents: stateBefore.attackIntents,
camera: stateBefore.camera,
units: original.units
.map(({ id, x, y, hp, maxHp }) => ({ id, x, y, hp, maxHp }))
.sort((left, right) => left.id.localeCompare(right.id))
}
};
});
assert(
firstBattleBondChainReadyUi.ready === true && firstBattleBondChainReadyUi.expectedRate === 18,
`Expected a deterministic level-72 pursuit-ready UI fixture: ${JSON.stringify(firstBattleBondChainReadyUi)}`
);
const readyCandidate = firstBattleBondChainReadyUi.eligible?.preview?.candidates?.[0];
const readyHud = firstBattleBondChainReadyUi.eligible?.preview?.hud;
const readyContext = '명중 후 대상 생존 시 · 지원 거리 충족';
assert(
firstBattleBondChainReadyUi.eligible?.phase === 'targeting' &&
firstBattleBondChainReadyUi.eligible.selectedUnitId === firstBattleBondChainReadyUi.attackerId &&
firstBattleBondChainReadyUi.eligible.selectedUsable === null &&
firstBattleBondChainReadyUi.eligible.preview?.visible === true &&
firstBattleBondChainReadyUi.eligible.preview.action === 'attack' &&
firstBattleBondChainReadyUi.eligible.preview.attackerId === firstBattleBondChainReadyUi.attackerId &&
sameJsonValue(firstBattleBondChainReadyUi.eligible.preview.eligibleTargetIds, [firstBattleBondChainReadyUi.targetId]) &&
firstBattleBondChainReadyUi.eligible.preview.candidates?.length === 1 &&
firstBattleBondChainReadyUi.eligible.baseDamage < firstBattleBondChainReadyUi.eligible.targetHp &&
readyCandidate?.targetId === firstBattleBondChainReadyUi.targetId &&
readyCandidate.targetName === firstBattleBondChainReadyUi.targetName &&
readyCandidate.partnerId === firstBattleBondChainReadyUi.partnerId &&
readyCandidate.partnerName === firstBattleBondChainReadyUi.partnerName &&
readyCandidate.rate === 18 &&
readyCandidate.damage > 0 &&
readyCandidate.text === `추격 후보 ${firstBattleBondChainReadyUi.partnerName} 18% · 예상 +${readyCandidate.damage}` &&
readyCandidate.context === readyContext &&
readyCandidate.targetMarkerId === `bond-chain-ready-${firstBattleBondChainReadyUi.targetId}` &&
readyCandidate.partnerMarkerId === `bond-chain-partner-${firstBattleBondChainReadyUi.partnerId}` &&
readyCandidate.targetMarkerVisible === true &&
readyCandidate.labelVisible === true &&
readyCandidate.partnerMarkerVisible === true,
`Expected matching acted intent to expose exactly one 18% pursuit candidate with clear partner, damage, and survival context: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}`
);
const returnedCandidate = firstBattleBondChainReadyUi.returned?.preview?.candidates?.[0];
assert(
firstBattleBondChainReadyUi.offscreen?.preview?.visible === false &&
sameJsonValue(firstBattleBondChainReadyUi.offscreen.preview.eligibleTargetIds, []) &&
sameJsonValue(firstBattleBondChainReadyUi.offscreen.preview.candidates, []) &&
firstBattleBondChainReadyUi.offscreen.preview.hud === null &&
firstBattleBondChainReadyUi.offscreen.preview.focused === null &&
firstBattleBondChainReadyUi.returned?.preview?.visible === true &&
returnedCandidate?.targetId === readyCandidate.targetId &&
returnedCandidate.partnerId === readyCandidate.partnerId &&
returnedCandidate.damage === readyCandidate.damage &&
returnedCandidate.targetMarkerVisible === true &&
returnedCandidate.labelVisible === true &&
returnedCandidate.partnerMarkerVisible === true &&
firstBattleBondChainReadyUi.returned.preview.hud?.targetId === readyCandidate.targetId,
`Expected camera movement to hide offscreen pursuit markers and restore the same visible target, partner, and HUD on return: ${JSON.stringify(firstBattleBondChainReadyUi)}`
);
assert(
isFiniteBounds(readyCandidate?.targetMarkerBounds) &&
isFiniteBounds(readyCandidate?.partnerMarkerBounds) &&
isFiniteBounds(readyCandidate?.labelBounds) &&
isFiniteBounds(readyCandidate?.tileBounds) &&
isFiniteBounds(firstBattleBondChainReadyUi.eligible?.targetTileBounds) &&
isFiniteBounds(firstBattleBondChainReadyUi.eligible?.partnerTileBounds) &&
sameJsonValue(readyCandidate.tileBounds, firstBattleBondChainReadyUi.eligible.targetTileBounds) &&
sameJsonValue(readyCandidate.bounds, readyCandidate.targetMarkerBounds) &&
boundsInside(readyCandidate.targetMarkerBounds, readyCandidate.tileBounds, 2) &&
boundsInside(readyCandidate.labelBounds, readyCandidate.targetMarkerBounds, 3) &&
boundsInside(readyCandidate.partnerMarkerBounds, firstBattleBondChainReadyUi.eligible.partnerTileBounds, 2) &&
boundsInside(readyCandidate.tileBounds, firstBattleBondChainReadyUi.eligible.viewportBounds) &&
boundsInside(firstBattleBondChainReadyUi.eligible.partnerTileBounds, firstBattleBondChainReadyUi.eligible.viewportBounds),
`Expected pursuit target badge, label, and partner marker to remain inside their visible map tiles: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}`
);
assert(
readyHud?.targetId === readyCandidate.targetId &&
readyHud.partnerId === readyCandidate.partnerId &&
readyHud.partnerName === readyCandidate.partnerName &&
readyHud.rate === readyCandidate.rate &&
readyHud.damage === readyCandidate.damage &&
readyHud.locked === false &&
readyHud.text === readyCandidate.text &&
readyHud.context === readyContext &&
isFiniteBounds(readyHud.bounds) &&
isFiniteBounds(readyHud.textBounds) &&
isFiniteBounds(readyHud.contextBounds) &&
boundsInside(readyHud.bounds, firstBattleBondChainReadyUi.eligible.panelBounds) &&
boundsInside(readyHud.bounds, firstBattleBondChainReadyUi.eligible.viewportBounds) &&
boundsInside(readyHud.textBounds, readyHud.bounds, 2) &&
boundsInside(readyHud.contextBounds, readyHud.bounds, 2),
`Expected the hovered target forecast to show the same two-line pursuit HUD entirely inside the side panel: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}`
);
const hiddenReadyStates = [
['mismatched target intent', firstBattleBondChainReadyUi.mismatch, 'attack', firstBattleBondChainReadyUi.attackerId, null],
['strategy action', firstBattleBondChainReadyUi.strategy, 'strategy', firstBattleBondChainReadyUi.partnerId, 'fireTactic'],
['lethal base attack', firstBattleBondChainReadyUi.lethal, 'attack', firstBattleBondChainReadyUi.attackerId, null],
['out-of-support-range partner', firstBattleBondChainReadyUi.far, 'attack', firstBattleBondChainReadyUi.attackerId, null]
];
assert(
firstBattleBondChainReadyUi.lethal?.baseDamage >= firstBattleBondChainReadyUi.lethal?.targetHp,
`Expected the lethal fixture to prove the base attack would defeat the target before pursuit: ${JSON.stringify(firstBattleBondChainReadyUi.lethal)}`
);
hiddenReadyStates.forEach(([context, probe, action, attackerId, selectedUsable]) => {
assert(
probe?.phase === 'targeting' &&
probe.selectedUnitId === attackerId &&
probe.selectedUsable === selectedUsable &&
probe.preview?.visible === false &&
probe.preview.action === action &&
probe.preview.attackerId === attackerId &&
sameJsonValue(probe.preview.eligibleTargetIds, []) &&
sameJsonValue(probe.preview.candidates, []) &&
probe.preview.hud === null &&
sameJsonValue(probe.preview.markers, []) &&
probe.preview.focused === null,
`Expected pursuit-ready UI to remain hidden for ${context} without stale markers or HUD: ${JSON.stringify(probe)}`
);
});
const { pursuitReady: restoredPursuitReady, ...restoredFixtureState } = firstBattleBondChainReadyUi.restored;
assert(
sameJsonValue(restoredFixtureState, firstBattleBondChainReadyUi.expectedRestored) &&
restoredPursuitReady?.visible === false &&
restoredPursuitReady.action === null &&
restoredPursuitReady.attackerId === null &&
sameJsonValue(restoredPursuitReady.eligibleTargetIds, []) &&
sameJsonValue(restoredPursuitReady.candidates, []) &&
restoredPursuitReady.hud === null,
`Expected the pursuit-ready UI fixture to restore battle positions, intents, acted state, camera, selection, markers, and idle HUD before the existing RC flow: ${JSON.stringify(firstBattleBondChainReadyUi)}`
);
const firstBattleOrderHud = await assertLiveSortieOrderHud(page, { const firstBattleOrderHud = await assertLiveSortieOrderHud(page, {
battleId: 'first-battle-zhuo-commandery', battleId: 'first-battle-zhuo-commandery',
orderId: 'elite', orderId: 'elite',

View File

@@ -1599,6 +1599,32 @@ type LockedTargetPreview = {
usableId?: string; usableId?: string;
}; };
type BondChainReadyMarkerView = {
targetId: string;
partnerId: string;
partnerName: string;
rate: number;
damage: number;
text: string;
context: string;
background: Phaser.GameObjects.Rectangle;
label: Phaser.GameObjects.Text;
};
type BondChainReadyFocusedPreview = {
targetId: string;
partnerId: string;
partnerName: string;
rate: number;
damage: number;
locked: boolean;
text: string;
context: string;
background: Phaser.GameObjects.Rectangle;
titleText: Phaser.GameObjects.Text;
contextText: Phaser.GameObjects.Text;
};
type SupportResult = { type SupportResult = {
usable: BattleUsable; usable: BattleUsable;
user: UnitData; user: UnitData;
@@ -3273,6 +3299,9 @@ export class BattleScene extends Phaser.Scene {
private targetingAction?: DamageCommand; private targetingAction?: DamageCommand;
private selectedUsable?: BattleUsable; private selectedUsable?: BattleUsable;
private lockedTargetPreview?: LockedTargetPreview; private lockedTargetPreview?: LockedTargetPreview;
private bondChainReadyMarkerViews: BondChainReadyMarkerView[] = [];
private bondChainReadyPartnerMarkers = new Map<string, Phaser.GameObjects.Rectangle>();
private bondChainReadyFocusedPreview?: BondChainReadyFocusedPreview;
private turnText?: Phaser.GameObjects.Text; private turnText?: Phaser.GameObjects.Text;
private objectiveTrackerText?: Phaser.GameObjects.Text; private objectiveTrackerText?: Phaser.GameObjects.Text;
private objectiveTrackerSubText?: Phaser.GameObjects.Text; private objectiveTrackerSubText?: Phaser.GameObjects.Text;
@@ -5706,6 +5735,7 @@ export class BattleScene extends Phaser.Scene {
} }
}); });
this.positionBondChainReadyMarkers();
this.positionObjectiveMapMarkers(); this.positionObjectiveMapMarkers();
battleUnits.forEach((unit) => this.positionUnitView(unit)); battleUnits.forEach((unit) => this.positionUnitView(unit));
this.positionDeploymentOverlayObjects(); this.positionDeploymentOverlayObjects();
@@ -5843,6 +5873,24 @@ export class BattleScene extends Phaser.Scene {
}); });
} }
private positionBondChainReadyMarkers() {
this.bondChainReadyMarkerViews.forEach((view) => {
[view.background, view.label].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 visible = this.isTileVisible(tileX, tileY);
object.setPosition(this.tileTopLeftX(tileX) + offsetX, this.tileTopLeftY(tileY) + offsetY);
object.setVisible(visible);
});
});
}
private positionUnitView(unit: UnitData) { private positionUnitView(unit: UnitData) {
const view = this.unitViews.get(unit.id); const view = this.unitViews.get(unit.id);
if (!view) { if (!view) {
@@ -6084,13 +6132,17 @@ export class BattleScene extends Phaser.Scene {
if (target && canTarget) { if (target && canTarget) {
const preview = this.combatPreview(unit, target, action, usable); const preview = this.combatPreview(unit, target, action, usable);
const bondChainReady = this.bondChainReadyPreviewData(preview);
const locked = this.isLockedDamageTarget(unit, target, action, usable); const locked = this.isLockedDamageTarget(unit, target, action, usable);
const counter = preview.counterAvailable ? '반격 주의' : '반격 없음'; const counter = preview.counterAvailable ? '반격 주의' : '반격 없음';
const terrain = `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`; const terrain = `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`;
const feedbackText = bondChainReady
? `${bondChainReady.text} · ${target.name} 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}%`
: `${target.name}: 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}% · ${counter} · ${terrain}`;
this.showPointerFeedback( this.showPointerFeedback(
tile, tile,
this.previewAccentColor(preview), this.previewAccentColor(preview),
`${target.name}: 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}% · ${counter} · ${terrain}`, feedbackText,
key, key,
0xd9503f, 0xd9503f,
0.16, 0.16,
@@ -6636,6 +6688,12 @@ export class BattleScene extends Phaser.Scene {
private clearMarkers() { private clearMarkers() {
this.markers.forEach((marker) => marker.destroy()); this.markers.forEach((marker) => marker.destroy());
this.markers = []; this.markers = [];
this.bondChainReadyMarkerViews.forEach((view) => {
view.background.destroy();
view.label.destroy();
});
this.bondChainReadyMarkerViews = [];
this.bondChainReadyPartnerMarkers.clear();
this.lockedTargetPreview = undefined; this.lockedTargetPreview = undefined;
this.clearPointerFeedback(); this.clearPointerFeedback();
} }
@@ -9145,8 +9203,14 @@ export class BattleScene extends Phaser.Scene {
const actionIcon = usable ? this.usableIcon(usable) : this.commandIcon('attack', unit); const actionIcon = usable ? this.usableIcon(usable) : this.commandIcon('attack', unit);
const accent = usable ? this.usableAccentColor(usable) : palette.gold; const accent = usable ? this.usableAccentColor(usable) : palette.gold;
let objectiveWarningNotice: TargetingGuideNotice | undefined; let objectiveWarningNotice: TargetingGuideNotice | undefined;
const candidates = targets.slice(0, 3).map((target): TargetingGuideCandidate => { const orderedTargets = targets
const preview = this.combatPreview(unit, target, action, usable); .map((target) => {
const preview = this.combatPreview(unit, target, action, usable);
return { target, preview, bondChainReady: this.bondChainReadyPreviewData(preview) };
})
.sort((left, right) => Number(Boolean(right.bondChainReady)) - Number(Boolean(left.bondChainReady)));
const hasBondChainReady = orderedTargets.some(({ bondChainReady }) => Boolean(bondChainReady));
const candidates = orderedTargets.slice(0, 3).map(({ target, preview, bondChainReady }): TargetingGuideCandidate => {
const distance = this.tileDistance(unit, target); const distance = this.tileDistance(unit, target);
const successLabel = this.previewSuccessLabel(preview); const successLabel = this.previewSuccessLabel(preview);
const counterLabel = preview.counterAvailable ? '반격 주의' : '반격 없음'; const counterLabel = preview.counterAvailable ? '반격 주의' : '반격 없음';
@@ -9159,15 +9223,19 @@ export class BattleScene extends Phaser.Scene {
return { return {
icon: endsBattleWithPendingBonus ? 'failure' : this.previewDamageIcon(preview), icon: endsBattleWithPendingBonus ? 'failure' : this.previewDamageIcon(preview),
name: target.name, name: target.name,
meta: `${getUnitClass(target.classKey).name} · 거리 ${distance}/${range}`, meta: bondChainReady
? `${getUnitClass(target.classKey).name} · 추격 후보 ${bondChainReady.partnerName} ${bondChainReady.rate}%`
: `${getUnitClass(target.classKey).name} · 거리 ${distance}/${range}`,
primary: `${preview.damage} 피해`, primary: `${preview.damage} 피해`,
secondary: warningText ?? `${preview.hitRate}% ${successLabel} · ${counterLabel}`, secondary: warningText ?? bondChainReady?.text ?? `${preview.hitRate}% ${successLabel} · ${counterLabel}`,
tone: endsBattleWithPendingBonus ? 0xd8732c : this.previewAccentColor(preview), tone: endsBattleWithPendingBonus ? 0xd8732c : bondChainReady ? palette.gold : this.previewAccentColor(preview),
metrics: [ metrics: [
{ icon: this.previewDamageIcon(preview), label: '피해', value: `${preview.damage}`, tone: 0xb64a45 }, { icon: this.previewDamageIcon(preview), label: '피해', value: `${preview.damage}`, tone: 0xb64a45 },
{ icon: action === 'strategy' ? 'success' : 'hit', label: successLabel, value: `${preview.hitRate}%`, tone: this.rateTone(preview.hitRate) }, { icon: action === 'strategy' ? 'success' : 'hit', label: successLabel, value: `${preview.hitRate}%`, tone: this.rateTone(preview.hitRate) },
endsBattleWithPendingBonus endsBattleWithPendingBonus
? { icon: 'failure', label: '보조', value: '미확보', tone: 0xd8732c } ? { icon: 'failure', label: '보조', value: '미확보', tone: 0xd8732c }
: bondChainReady
? { icon: 'leadership', label: '추격', value: `${bondChainReady.rate}% · +${bondChainReady.damage}`, tone: palette.gold }
: { icon: 'counter', label: '반격', value: preview.counterAvailable ? '주의' : '없음', tone: preview.counterAvailable ? 0xd8732c : 0x59d18c } : { icon: 'counter', label: '반격', value: preview.counterAvailable ? '주의' : '없음', tone: preview.counterAvailable ? 0xd8732c : 0x59d18c }
] ]
}; };
@@ -9186,7 +9254,7 @@ export class BattleScene extends Phaser.Scene {
], ],
candidates, candidates,
overflowCount: Math.max(0, targets.length - candidates.length), overflowCount: Math.max(0, targets.length - candidates.length),
footer: '표시된 적 선택 · 확정 전 예측 확인', footer: hasBondChainReady ? '금색 공명 · 명중 후 생존 시 확률 판정' : '표시된 적 선택 · 확정 전 예측 확인',
notice: notice ?? objectiveWarningNotice notice: notice ?? objectiveWarningNotice
}); });
} }
@@ -9487,9 +9555,9 @@ export class BattleScene extends Phaser.Scene {
chip.setOrigin(0); chip.setOrigin(0);
chip.setStrokeStyle(1, metric.tone, 0.54); chip.setStrokeStyle(1, metric.tone, 0.54);
this.trackSideIcon(chipX + 12, chipTop + 11, metric.icon, 19); this.trackSideIcon(chipX + 12, chipTop + 11, metric.icon, 19);
this.trackSideObject(this.add.text(chipX + 25, chipTop + 5, this.truncateUiText(metric.value, 7), { this.trackSideObject(this.add.text(chipX + 25, chipTop + 5, this.truncateUiText(metric.value, 10), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px', fontSize: metric.value.length > 7 ? '9px' : '11px',
color: '#f2e3bf', color: '#f2e3bf',
fontStyle: '700', fontStyle: '700',
fixedWidth: chipWidth - 30 fixedWidth: chipWidth - 30
@@ -9776,6 +9844,150 @@ export class BattleScene extends Phaser.Scene {
marker.setFillStyle(isLocked ? 0xf2e3bf : baseColor, isLocked ? 0.36 : 0.2); marker.setFillStyle(isLocked ? 0xf2e3bf : baseColor, isLocked ? 0.36 : 0.2);
marker.setStrokeStyle(isLocked ? 3 : 2, isLocked ? 0xffffff : strokeColor, isLocked ? 1 : 0.9); marker.setStrokeStyle(isLocked ? 3 : 2, isLocked ? 0xffffff : strokeColor, isLocked ? 1 : 0.9);
}); });
this.refreshBondChainReadyMarkerStyles();
}
private bondChainReadyPreviewData(preview: CombatPreview) {
if (
preview.action !== 'attack' ||
preview.usable ||
preview.attacker.faction !== 'ally' ||
!preview.bondChainPartnerId ||
!preview.bondChainPartnerName ||
preview.bondChainRate <= 0 ||
preview.bondChainDamage <= 0 ||
preview.damage >= preview.defender.hp
) {
return undefined;
}
const partner = battleUnits.find((unit) => unit.id === preview.bondChainPartnerId && unit.hp > 0);
if (!partner || partner.faction !== preview.attacker.faction) {
return undefined;
}
const join = this.bondPartnerJoinState(preview.attacker, partner, preview.defender);
if (!join.canJoin) {
return undefined;
}
return {
targetId: preview.defender.id,
partnerId: partner.id,
partnerName: partner.name,
rate: preview.bondChainRate,
damage: preview.bondChainDamage,
text: `추격 후보 ${partner.name} ${preview.bondChainRate}% · 예상 +${preview.bondChainDamage}`,
context: '명중 후 대상 생존 시 · 지원 거리 충족'
};
}
private renderBondChainReadyPartnerMarker(partnerId: string) {
const existing = this.bondChainReadyPartnerMarkers.get(partnerId);
if (existing?.active) {
return existing;
}
const partner = battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0);
if (!partner) {
return undefined;
}
const marker = this.add.rectangle(
this.tileTopLeftX(partner.x),
this.tileTopLeftY(partner.y),
this.layout.tileSize,
this.layout.tileSize,
palette.gold,
0.035
);
marker.setName(`bond-chain-partner-${partner.id}`);
marker.setData('tileX', partner.x);
marker.setData('tileY', partner.y);
marker.setData('markerType', 'bond-chain-partner');
marker.setData('bondChainPartnerId', partner.id);
marker.setOrigin(0);
marker.setStrokeStyle(2, palette.gold, 0.78);
marker.setDepth(5.12);
if (this.mapMask) {
marker.setMask(this.mapMask);
}
marker.setVisible(this.isTileVisible(partner.x, partner.y));
this.bondChainReadyPartnerMarkers.set(partner.id, marker);
this.markers.push(marker);
return marker;
}
private renderBondChainReadyTargetMarker(preview: CombatPreview) {
const ready = this.bondChainReadyPreviewData(preview);
if (!ready) {
return;
}
const tileSize = this.layout.tileSize;
const width = Phaser.Math.Clamp(Math.round(tileSize * 0.72), 34, 44);
const height = 16;
const offsetX = tileSize - 3;
const offsetY = 3;
const background = this.add.rectangle(
this.tileTopLeftX(preview.defender.x) + offsetX,
this.tileTopLeftY(preview.defender.y) + offsetY,
width,
height,
0x271d0c,
0.97
);
background.setName(`bond-chain-ready-${preview.defender.id}`);
background.setData('tileX', preview.defender.x);
background.setData('tileY', preview.defender.y);
background.setData('tileOffsetX', offsetX);
background.setData('tileOffsetY', offsetY);
background.setData('markerType', 'bond-chain-target');
background.setData('bondChainTargetId', preview.defender.id);
background.setOrigin(1, 0);
background.setStrokeStyle(1, palette.gold, 0.96);
background.setDepth(10.45);
if (this.mapMask) {
background.setMask(this.mapMask);
}
background.setVisible(this.isTileVisible(preview.defender.x, preview.defender.y));
const label = this.add.text(
this.tileTopLeftX(preview.defender.x) + offsetX - width / 2,
this.tileTopLeftY(preview.defender.y) + offsetY + height / 2,
'공명',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '10px',
color: '#ffe79a',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 2
}
);
label.setName(`bond-chain-ready-label-${preview.defender.id}`);
label.setData('tileX', preview.defender.x);
label.setData('tileY', preview.defender.y);
label.setData('tileOffsetX', offsetX - width / 2);
label.setData('tileOffsetY', offsetY + height / 2);
label.setOrigin(0.5);
label.setDepth(10.5);
if (this.mapMask) {
label.setMask(this.mapMask);
}
label.setVisible(this.isTileVisible(preview.defender.x, preview.defender.y));
this.renderBondChainReadyPartnerMarker(ready.partnerId);
this.bondChainReadyMarkerViews.push({ ...ready, background, label });
}
private refreshBondChainReadyMarkerStyles() {
this.bondChainReadyMarkerViews.forEach((view) => {
const locked = this.lockedTargetPreview?.kind === 'damage' && this.lockedTargetPreview.targetId === view.targetId;
view.background.setFillStyle(locked ? 0x5a4317 : 0x271d0c, locked ? 1 : 0.97);
view.background.setStrokeStyle(locked ? 2 : 1, locked ? 0xffffff : palette.gold, 0.98);
view.label.setColor(locked ? '#ffffff' : '#ffe79a');
});
} }
private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action), usable?: BattleUsable) { private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action), usable?: BattleUsable) {
@@ -9834,6 +10046,7 @@ export class BattleScene extends Phaser.Scene {
} }
}); });
this.markers.push(marker, hitArea); this.markers.push(marker, hitArea);
this.renderBondChainReadyTargetMarker(preview);
}); });
} }
@@ -10023,15 +10236,6 @@ export class BattleScene extends Phaser.Scene {
preview.terrainAffinity !== 100 ? `적성${preview.terrainAffinity}%` : '' preview.terrainAffinity !== 100 ? `적성${preview.terrainAffinity}%` : ''
].filter(Boolean); ].filter(Boolean);
if (preview.bondChainPartnerName && preview.bondChainRate > 0 && preview.bondChainDamage > 0) {
summaries.push({
icon: 'leadership',
label: `추격·${preview.bondChainPartnerName}`,
value: `${preview.bondChainRate}% · +${preview.bondChainDamage}`,
tone: palette.gold
});
}
if (preview.action === 'strategy') { if (preview.action === 'strategy') {
summaries.push({ summaries.push({
icon: 'intelligence', icon: 'intelligence',
@@ -10220,6 +10424,53 @@ export class BattleScene extends Phaser.Scene {
return '도구'; return '도구';
} }
private renderBondChainReadyPreviewBanner(
preview: CombatPreview,
x: number,
y: number,
width: number,
locked: boolean
) {
const ready = this.bondChainReadyPreviewData(preview);
if (!ready) {
return false;
}
const background = this.trackSideObject(this.add.rectangle(x, y, width, 46, 0x271d0c, 0.98));
background.setOrigin(0);
background.setStrokeStyle(locked ? 2 : 1, locked ? 0xffffff : palette.gold, 0.9);
this.trackSideIcon(x + 17, y + 23, 'leadership', 28);
const titleText = this.trackSideObject(this.add.text(x + 36, y + 3, ready.text, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
color: '#ffe79a',
fontStyle: '700',
fixedWidth: width - 44
}));
const contextText = this.trackSideObject(this.add.text(
x + 36,
y + 18,
`${ready.context}\n${preview.matchupLabel} · ${preview.terrainLabel} ${this.combatTerrainSummary(preview)} · 치명 ${preview.criticalRate}%`,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '8px',
color: '#d8c693',
fontStyle: '700',
fixedWidth: width - 44,
lineSpacing: 1
}
));
this.bondChainReadyFocusedPreview = {
...ready,
locked,
background,
titleText,
contextText
};
return true;
}
private renderCombatPreviewSummaryCard(preview: CombatPreview, locked = false) { private renderCombatPreviewSummaryCard(preview: CombatPreview, locked = false) {
const { panelX, panelWidth } = this.layout; const { panelX, panelWidth } = this.layout;
const left = panelX + 24; const left = panelX + 24;
@@ -10300,29 +10551,23 @@ export class BattleScene extends Phaser.Scene {
this.renderPreviewForecastRow(preview, left + 10, y + 272, width - 20, accent); this.renderPreviewForecastRow(preview, left + 10, y + 272, width - 20, accent);
this.renderPreviewModifierCards(left + 10, y + 312, width - 20, this.previewModifierSummaries(preview), accent); this.renderPreviewModifierCards(left + 10, y + 312, width - 20, this.previewModifierSummaries(preview), accent);
const badgeY = y + 382; const showsBondChainReady = this.renderBondChainReadyPreviewBanner(preview, left + 10, y + 372, width - 20, locked);
const maxBadgeRight = left + width - 10; if (!showsBondChainReady) {
let badgeX = left + 10; const badgeY = y + 382;
if (isStrategy) { const maxBadgeRight = left + width - 10;
badgeX = this.renderPreviewBadge(badgeX, badgeY, 'success', `성공 ${preview.hitRate}%`, maxBadgeRight); let badgeX = left + 10;
} if (isStrategy) {
badgeX = this.renderPreviewBadge(badgeX, badgeY, 'critical', `치명 ${preview.criticalRate}%`, maxBadgeRight); badgeX = this.renderPreviewBadge(badgeX, badgeY, 'success', `성공 ${preview.hitRate}%`, maxBadgeRight);
badgeX = this.renderPreviewBadge(badgeX, badgeY, 'advantage', preview.matchupLabel, maxBadgeRight); }
let detailBadgeX = this.renderPreviewBadge(badgeX, badgeY, 'terrain', `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`, maxBadgeRight); badgeX = this.renderPreviewBadge(badgeX, badgeY, 'critical', `치명 ${preview.criticalRate}%`, maxBadgeRight);
if (preview.bondDamageBonus > 0 && preview.bondLabel) { badgeX = this.renderPreviewBadge(badgeX, badgeY, 'advantage', preview.matchupLabel, maxBadgeRight);
detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight); let detailBadgeX = this.renderPreviewBadge(badgeX, badgeY, 'terrain', `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`, maxBadgeRight);
} if (preview.bondDamageBonus > 0 && preview.bondLabel) {
if (preview.bondChainPartnerName && preview.bondChainRate > 0) { detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight);
detailBadgeX = this.renderPreviewBadge( }
detailBadgeX, if (preview.equipmentEffectLabels.length > 0) {
badgeY, this.renderPreviewBadge(detailBadgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight);
'leadership', }
`추격 ${preview.bondChainPartnerName} ${preview.bondChainRate}% +${preview.bondChainDamage}`,
maxBadgeRight
);
}
if (preview.equipmentEffectLabels.length > 0) {
this.renderPreviewBadge(detailBadgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight);
} }
if (showsObjectiveWarning) { if (showsObjectiveWarning) {
this.renderPreviewObjectiveWarning(left + 10, y + 408, width - 20, pendingBonusLabels); this.renderPreviewObjectiveWarning(left + 10, y + 408, width - 20, pendingBonusLabels);
@@ -21751,6 +21996,7 @@ export class BattleScene extends Phaser.Scene {
private clearSidePanelContent() { private clearSidePanelContent() {
this.sidePanelObjects.forEach((object) => object.destroy()); this.sidePanelObjects.forEach((object) => object.destroy());
this.sidePanelObjects = []; this.sidePanelObjects = [];
this.bondChainReadyFocusedPreview = undefined;
this.clearFacingIndicator(); this.clearFacingIndicator();
} }
@@ -21776,6 +22022,87 @@ export class BattleScene extends Phaser.Scene {
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
} }
private gameObjectBoundsDebug(object?: Phaser.GameObjects.GameObject) {
if (!object?.active) {
return null;
}
const bounded = object as Phaser.GameObjects.GameObject & { getBounds?: () => Phaser.Geom.Rectangle };
const bounds = bounded.getBounds?.();
return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null;
}
private bondChainReadyDebugState() {
const targetingBasicAttack =
this.phase === 'targeting' &&
this.targetingAction === 'attack' &&
!this.selectedUsable &&
this.selectedUnit?.faction === 'ally';
const candidates = targetingBasicAttack
? this.bondChainReadyMarkerViews
.filter((view) => view.background.active && view.background.visible && view.label.active && view.label.visible)
.map((view) => {
const partnerMarker = this.bondChainReadyPartnerMarkers.get(view.partnerId);
const target = battleUnits.find((unit) => unit.id === view.targetId);
const targetMarkerBounds = this.gameObjectBoundsDebug(view.background);
const partnerMarkerBounds = this.gameObjectBoundsDebug(partnerMarker);
return {
targetId: view.targetId,
targetName: target?.name ?? this.unitName(view.targetId),
partnerId: view.partnerId,
partnerName: view.partnerName,
rate: view.rate,
damage: view.damage,
text: view.text,
context: view.context,
targetMarkerId: view.background.name,
partnerMarkerId: partnerMarker?.name ?? null,
targetMarkerVisible: view.background.visible,
labelVisible: view.label.visible,
partnerMarkerVisible: Boolean(partnerMarker?.active && partnerMarker.visible),
targetMarkerBounds,
partnerMarkerBounds,
labelBounds: this.gameObjectBoundsDebug(view.label),
tileBounds: target
? {
x: this.tileTopLeftX(target.x),
y: this.tileTopLeftY(target.y),
width: this.layout.tileSize,
height: this.layout.tileSize
}
: null,
bounds: targetMarkerBounds
};
})
: [];
const candidateTargetIds = new Set(candidates.map((candidate) => candidate.targetId));
const focused = targetingBasicAttack && this.bondChainReadyFocusedPreview && candidateTargetIds.has(this.bondChainReadyFocusedPreview.targetId)
? {
targetId: this.bondChainReadyFocusedPreview.targetId,
partnerId: this.bondChainReadyFocusedPreview.partnerId,
partnerName: this.bondChainReadyFocusedPreview.partnerName,
rate: this.bondChainReadyFocusedPreview.rate,
damage: this.bondChainReadyFocusedPreview.damage,
locked: this.bondChainReadyFocusedPreview.locked,
text: this.bondChainReadyFocusedPreview.text,
context: this.bondChainReadyFocusedPreview.context,
bounds: this.gameObjectBoundsDebug(this.bondChainReadyFocusedPreview.background),
textBounds: this.gameObjectBoundsDebug(this.bondChainReadyFocusedPreview.titleText),
contextBounds: this.gameObjectBoundsDebug(this.bondChainReadyFocusedPreview.contextText)
}
: null;
const visible = candidates.length > 0;
return {
visible,
action: this.phase === 'targeting' ? this.targetingAction ?? null : null,
attackerId: this.phase === 'targeting' ? this.selectedUnit?.id ?? null : null,
eligibleTargetIds: candidates.map((candidate) => candidate.targetId),
candidates,
hud: focused,
markers: candidates,
focused
};
}
private sortieOrderHudDebugState() { private sortieOrderHudDebugState() {
const snapshot = this.sortieOrderHudSnapshot; const snapshot = this.sortieOrderHudSnapshot;
if (!snapshot) { if (!snapshot) {
@@ -21940,6 +22267,7 @@ export class BattleScene extends Phaser.Scene {
turnPromptMode: this.turnPromptMode ?? null, turnPromptMode: this.turnPromptMode ?? null,
pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible), pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible),
pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null, pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null,
bondChainReadyPreview: this.bondChainReadyDebugState(),
markerCount: this.markers.length, markerCount: this.markers.length,
threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length, threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length,
enemyMoveMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'enemy-move').length, enemyMoveMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'enemy-move').length,