feat: surface pursuit-ready attacks in battle
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user