feat: reveal resonance pursuit outcomes
This commit is contained in:
@@ -544,6 +544,895 @@ try {
|
|||||||
`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)}`
|
`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 firstBattleBondChainJudgement = await page.evaluate(async () => {
|
||||||
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||||
|
const stateBefore = window.__HEROS_DEBUG__?.battle();
|
||||||
|
const mechanics = stateBefore?.combatMechanicsProbe;
|
||||||
|
const attackerId = mechanics?.attackerId;
|
||||||
|
const partnerId = mechanics?.partnerId;
|
||||||
|
const targetId = mechanics?.enemyId;
|
||||||
|
if (
|
||||||
|
!scene ||
|
||||||
|
!attackerId ||
|
||||||
|
!partnerId ||
|
||||||
|
!targetId ||
|
||||||
|
stateBefore?.phase !== 'idle' ||
|
||||||
|
stateBefore.selectedUnitId !== null ||
|
||||||
|
stateBefore.commandMenuVisible !== false
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
reason: 'first battle was not in a clean idle state for the resonance judgement fixture',
|
||||||
|
stateBefore,
|
||||||
|
mechanics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||||
|
const comparableSave = (state) => {
|
||||||
|
const { savedAt: _savedAt, ...comparable } = state;
|
||||||
|
return comparable;
|
||||||
|
};
|
||||||
|
const collectText = (objects) => {
|
||||||
|
const texts = [];
|
||||||
|
const visited = new Set();
|
||||||
|
const visit = (object) => {
|
||||||
|
if (!object || visited.has(object)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visited.add(object);
|
||||||
|
if (object.type === 'Text' && typeof object.text === 'string') {
|
||||||
|
texts.push(object.text);
|
||||||
|
}
|
||||||
|
if (Array.isArray(object.list)) {
|
||||||
|
object.list.forEach(visit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
objects.forEach(visit);
|
||||||
|
return texts;
|
||||||
|
};
|
||||||
|
const sceneObjectsCreatedAfter = (before) => scene.children.list.filter((object) => !before.has(object));
|
||||||
|
const clearCreatedSceneObjects = (objects) => {
|
||||||
|
objects.forEach((object) => {
|
||||||
|
scene.tweens?.killTweensOf(object);
|
||||||
|
if (object?.active) {
|
||||||
|
object.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setDebugForceHit = (enabled) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (enabled) {
|
||||||
|
url.searchParams.set('debugForceHit', '1');
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('debugForceHit');
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
};
|
||||||
|
const setDebugForceBondChain = (value) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (value === null) {
|
||||||
|
url.searchParams.delete('debugForceBondChain');
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('debugForceBondChain', value);
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
};
|
||||||
|
const statsFor = (snapshot, unitId) => clone(snapshot?.[unitId] ?? {});
|
||||||
|
const statsDelta = (before, after) => Object.fromEntries(
|
||||||
|
Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).map((key) => [
|
||||||
|
key,
|
||||||
|
(after[key] ?? 0) - (before[key] ?? 0)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalUrl = window.location.href;
|
||||||
|
const originalSave = clone(scene.createBattleSaveState());
|
||||||
|
const originalCamera = { x: scene.cameraTileX, y: scene.cameraTileY };
|
||||||
|
const originalJudgement = clone(scene.bondChainJudgementLast);
|
||||||
|
const originalPartnerClassKey = scene.debugUnitById(partnerId)?.classKey;
|
||||||
|
const originalBattleSpeed = scene.battleSpeed;
|
||||||
|
const originalFastForwardHeld = scene.fastForwardHeld;
|
||||||
|
const originalRollPercent = scene.rollPercent;
|
||||||
|
const hadOwnRollPercent = Object.prototype.hasOwnProperty.call(scene, 'rollPercent');
|
||||||
|
const originalPhase = scene.phase;
|
||||||
|
const originalSelectedUnit = scene.selectedUnit;
|
||||||
|
const originalPendingMove = scene.pendingMove;
|
||||||
|
const originalTargetingAction = scene.targetingAction;
|
||||||
|
const originalSelectedUsable = scene.selectedUsable;
|
||||||
|
const originalLockedTargetPreview = scene.lockedTargetPreview;
|
||||||
|
|
||||||
|
const restoreFixtureState = () => {
|
||||||
|
scene.hideCombatCutIn();
|
||||||
|
scene.applyBattleSaveState(clone(originalSave));
|
||||||
|
scene.cameraTileX = originalCamera.x;
|
||||||
|
scene.cameraTileY = originalCamera.y;
|
||||||
|
scene.updateCameraView();
|
||||||
|
scene.phase = originalPhase;
|
||||||
|
scene.selectedUnit = originalSelectedUnit;
|
||||||
|
scene.pendingMove = originalPendingMove;
|
||||||
|
scene.targetingAction = originalTargetingAction;
|
||||||
|
scene.selectedUsable = originalSelectedUsable;
|
||||||
|
scene.lockedTargetPreview = originalLockedTargetPreview;
|
||||||
|
scene.bondChainJudgementLast = clone(originalJudgement);
|
||||||
|
const partner = scene.debugUnitById(partnerId);
|
||||||
|
if (partner && originalPartnerClassKey) {
|
||||||
|
partner.classKey = originalPartnerClassKey;
|
||||||
|
}
|
||||||
|
scene.battleSpeed = originalBattleSpeed;
|
||||||
|
scene.fastForwardHeld = originalFastForwardHeld;
|
||||||
|
scene.renderBattleSpeedControl();
|
||||||
|
scene.clearMarkers();
|
||||||
|
scene.hideCommandMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareScenario = (mode, partnerClassKey) => {
|
||||||
|
restoreFixtureState();
|
||||||
|
const attacker = scene.debugUnitById(attackerId);
|
||||||
|
const partner = scene.debugUnitById(partnerId);
|
||||||
|
const target = scene.debugUnitById(targetId);
|
||||||
|
if (!attacker || !partner || !target) {
|
||||||
|
throw new Error(`Missing judgement fixture units: ${JSON.stringify({ attackerId, partnerId, targetId })}`);
|
||||||
|
}
|
||||||
|
if (partnerClassKey) {
|
||||||
|
partner.classKey = partnerClassKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
attacker.x = 4;
|
||||||
|
attacker.y = 15;
|
||||||
|
partner.x = 3;
|
||||||
|
partner.y = 15;
|
||||||
|
target.x = 5;
|
||||||
|
target.y = 15;
|
||||||
|
target.maxHp = Math.max(target.maxHp, 5000);
|
||||||
|
target.hp = target.maxHp;
|
||||||
|
scene.attackIntents = [{ attackerId: partner.id, targetId: target.id }];
|
||||||
|
scene.actedUnitIds = new Set([partner.id]);
|
||||||
|
scene.centerCameraOnTile(target.x, target.y);
|
||||||
|
[attacker, partner, target].forEach((unit) => scene.positionUnitView(unit));
|
||||||
|
|
||||||
|
const preview = scene.combatPreview(attacker, target, 'attack');
|
||||||
|
if (!(preview.damage > 0 && preview.bondChainDamage > 0 && preview.bondChainRate > 0)) {
|
||||||
|
throw new Error(`Expected a live resonance preview in ${mode}: ${JSON.stringify({
|
||||||
|
damage: preview.damage,
|
||||||
|
rate: preview.bondChainRate,
|
||||||
|
chainDamage: preview.bondChainDamage,
|
||||||
|
partnerId: preview.bondChainPartnerId
|
||||||
|
})}`);
|
||||||
|
}
|
||||||
|
target.hp = mode === 'lethal'
|
||||||
|
? preview.damage
|
||||||
|
: preview.damage + preview.bondChainDamage;
|
||||||
|
return { attacker, partner, target, preview };
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureNeutralMapCue = async (result) => {
|
||||||
|
const before = new Set(scene.children.list);
|
||||||
|
const effect = scene.showBondMapEffect(result);
|
||||||
|
const created = sceneObjectsCreatedAfter(before);
|
||||||
|
const texts = collectText(created);
|
||||||
|
await effect;
|
||||||
|
return texts;
|
||||||
|
};
|
||||||
|
const captureMapResult = (result) => {
|
||||||
|
const before = new Set(scene.children.list);
|
||||||
|
scene.showCombatMapResult(result);
|
||||||
|
const created = sceneObjectsCreatedAfter(before);
|
||||||
|
const texts = collectText(created);
|
||||||
|
clearCreatedSceneObjects(created);
|
||||||
|
return texts;
|
||||||
|
};
|
||||||
|
const presentCombatResult = async (result) => {
|
||||||
|
let presented = null;
|
||||||
|
let settled = false;
|
||||||
|
const telemetryStartedAt = performance.now();
|
||||||
|
const telemetry = { gauge: [], impact: [], partnerMotion: [] };
|
||||||
|
const originalAnimateCombatGauge = scene.animateCombatGauge;
|
||||||
|
const originalShowCombatImpact = scene.showCombatImpact;
|
||||||
|
const originalPlayCombatActionMotion = scene.playCombatActionMotion;
|
||||||
|
const methodOwnership = {
|
||||||
|
animateCombatGauge: Object.prototype.hasOwnProperty.call(scene, 'animateCombatGauge'),
|
||||||
|
showCombatImpact: Object.prototype.hasOwnProperty.call(scene, 'showCombatImpact'),
|
||||||
|
playCombatActionMotion: Object.prototype.hasOwnProperty.call(scene, 'playCombatActionMotion')
|
||||||
|
};
|
||||||
|
const elapsed = () => performance.now() - telemetryStartedAt;
|
||||||
|
const isPartnerResult = (candidate) =>
|
||||||
|
Boolean(result.bondChain) && candidate?.attacker?.id === result.bondChain.partnerId;
|
||||||
|
const restoreMethod = (name, original) => {
|
||||||
|
if (methodOwnership[name]) {
|
||||||
|
scene[name] = original;
|
||||||
|
} else {
|
||||||
|
delete scene[name];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scene.animateCombatGauge = function (fill, from, to, duration) {
|
||||||
|
const isChainGauge = Boolean(result.bondChain) &&
|
||||||
|
duration === 300 &&
|
||||||
|
Math.abs(from - result.bondChain.previousDefenderHp / result.defender.maxHp) < 0.0001 &&
|
||||||
|
Math.abs(to - result.defender.hp / result.defender.maxHp) < 0.0001;
|
||||||
|
const event = isChainGauge
|
||||||
|
? { startAt: elapsed(), endAt: null, from, to, duration, startScale: fill.scaleX, endScale: null }
|
||||||
|
: null;
|
||||||
|
if (event) {
|
||||||
|
telemetry.gauge.push(event);
|
||||||
|
}
|
||||||
|
const animation = originalAnimateCombatGauge.call(this, fill, from, to, duration);
|
||||||
|
return animation.then(() => {
|
||||||
|
if (event) {
|
||||||
|
event.endAt = elapsed();
|
||||||
|
event.endScale = fill.scaleX;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
scene.showCombatImpact = function (impactResult, ...args) {
|
||||||
|
if (isPartnerResult(impactResult)) {
|
||||||
|
telemetry.impact.push({ at: elapsed(), damage: impactResult.damage });
|
||||||
|
}
|
||||||
|
return originalShowCombatImpact.call(this, impactResult, ...args);
|
||||||
|
};
|
||||||
|
scene.playCombatActionMotion = async function (motionResult, ...args) {
|
||||||
|
const isPartnerMotion = isPartnerResult(motionResult);
|
||||||
|
const event = isPartnerMotion ? { startAt: elapsed(), endAt: null } : null;
|
||||||
|
if (event) {
|
||||||
|
telemetry.partnerMotion.push(event);
|
||||||
|
}
|
||||||
|
await originalPlayCombatActionMotion.call(this, motionResult, ...args);
|
||||||
|
if (event) {
|
||||||
|
event.endAt = elapsed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playback = scene.playCombatCutIn(result).finally(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
for (let attempt = 0; attempt < 240 && !settled; attempt += 1) {
|
||||||
|
const judgement = scene.getDebugState().bondChainJudgement;
|
||||||
|
if (judgement?.presented && !judgement.completed) {
|
||||||
|
presented = clone(judgement);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 25));
|
||||||
|
}
|
||||||
|
await playback;
|
||||||
|
return {
|
||||||
|
presented,
|
||||||
|
completed: clone(scene.getDebugState().bondChainJudgement),
|
||||||
|
telemetry
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
restoreMethod('animateCombatGauge', originalAnimateCombatGauge);
|
||||||
|
restoreMethod('showCombatImpact', originalShowCombatImpact);
|
||||||
|
restoreMethod('playCombatActionMotion', originalPlayCombatActionMotion);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runScenario = async ({ mode, forcedRoll, present, naturalRoll = false, partnerClassKey, battleSpeed }) => {
|
||||||
|
const { attacker, partner, target, preview } = prepareScenario(mode, partnerClassKey);
|
||||||
|
if (battleSpeed) {
|
||||||
|
scene.battleSpeed = battleSpeed;
|
||||||
|
scene.fastForwardHeld = false;
|
||||||
|
scene.renderBattleSpeedControl();
|
||||||
|
}
|
||||||
|
setDebugForceHit(mode !== 'miss');
|
||||||
|
const rollCalls = [];
|
||||||
|
const chainRollCalls = [];
|
||||||
|
const originalBondChainRollSucceeded = scene.bondChainRollSucceeded;
|
||||||
|
const hadOwnBondChainRollSucceeded = Object.prototype.hasOwnProperty.call(scene, 'bondChainRollSucceeded');
|
||||||
|
scene.rollPercent = (rate) => {
|
||||||
|
rollCalls.push(rate);
|
||||||
|
return naturalRoll && rate === preview.bondChainRate;
|
||||||
|
};
|
||||||
|
scene.bondChainRollSucceeded = function (rate, forcedResult) {
|
||||||
|
const rollCountBefore = rollCalls.length;
|
||||||
|
const succeeded = originalBondChainRollSucceeded.call(this, rate, forcedResult);
|
||||||
|
chainRollCalls.push({
|
||||||
|
rate,
|
||||||
|
forcedResult: forcedResult ?? null,
|
||||||
|
rollPercentCalls: rollCalls.length - rollCountBefore,
|
||||||
|
succeeded
|
||||||
|
});
|
||||||
|
return succeeded;
|
||||||
|
};
|
||||||
|
const statsBefore = clone(scene.serializeBattleStats());
|
||||||
|
try {
|
||||||
|
const result = scene.resolveCombatAction(attacker, target, 'attack', false, undefined, forcedRoll);
|
||||||
|
const resolutionChainRollCalls = clone(chainRollCalls);
|
||||||
|
const statsAfter = clone(scene.serializeBattleStats());
|
||||||
|
const outcomeChip = clone(scene.combatOutcomeExtraChip(result));
|
||||||
|
const mapCueLabel = scene.bondChainMapCueLabel(result);
|
||||||
|
const neutralMapTexts = present ? await captureNeutralMapCue(result) : [];
|
||||||
|
let presentationDurationMs = null;
|
||||||
|
let judgement;
|
||||||
|
if (present) {
|
||||||
|
const presentationStartedAt = performance.now();
|
||||||
|
judgement = await presentCombatResult(result);
|
||||||
|
presentationDurationMs = performance.now() - presentationStartedAt;
|
||||||
|
} else {
|
||||||
|
judgement = {
|
||||||
|
presented: null,
|
||||||
|
completed: clone(scene.getDebugState().bondChainJudgement)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const formatted = scene.formatCombatResult(result);
|
||||||
|
const mapTitle = scene.combatMapDamageTitle(result);
|
||||||
|
const mapResultTexts = present ? captureMapResult(result) : [];
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
attackerId: attacker.id,
|
||||||
|
partnerId: partner.id,
|
||||||
|
partnerName: partner.name,
|
||||||
|
partnerClassKey: partner.classKey,
|
||||||
|
battleSpeed: scene.battleSpeed,
|
||||||
|
presentationDurationMs,
|
||||||
|
targetId: target.id,
|
||||||
|
preview: {
|
||||||
|
damage: preview.damage,
|
||||||
|
chainRate: preview.bondChainRate,
|
||||||
|
chainDamage: preview.bondChainDamage,
|
||||||
|
chainPartnerId: preview.bondChainPartnerId ?? null
|
||||||
|
},
|
||||||
|
initialTargetHp: result.previousDefenderHp,
|
||||||
|
finalTargetHp: result.defender.hp,
|
||||||
|
hit: result.hit,
|
||||||
|
baseDefeated: result.baseDefeated,
|
||||||
|
defeated: result.defeated,
|
||||||
|
damage: result.damage,
|
||||||
|
attempt: clone(result.bondChainAttempt) ?? null,
|
||||||
|
chain: clone(result.bondChain) ?? null,
|
||||||
|
outcomeChip,
|
||||||
|
mapCueLabel,
|
||||||
|
neutralMapTexts,
|
||||||
|
formatted,
|
||||||
|
mapTitle,
|
||||||
|
mapResultTexts,
|
||||||
|
judgement,
|
||||||
|
chainRollCalls: resolutionChainRollCalls,
|
||||||
|
partnerStatsDelta: statsDelta(statsFor(statsBefore, partner.id), statsFor(statsAfter, partner.id)),
|
||||||
|
targetStatsDelta: statsDelta(statsFor(statsBefore, target.id), statsFor(statsAfter, target.id))
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (hadOwnRollPercent) {
|
||||||
|
scene.rollPercent = originalRollPercent;
|
||||||
|
} else {
|
||||||
|
delete scene.rollPercent;
|
||||||
|
}
|
||||||
|
if (hadOwnBondChainRollSucceeded) {
|
||||||
|
scene.bondChainRollSucceeded = originalBondChainRollSucceeded;
|
||||||
|
} else {
|
||||||
|
delete scene.bondChainRollSucceeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runProductionScenario = async (shouldDefeat) => {
|
||||||
|
const { attacker, target, preview } = prepareScenario('success');
|
||||||
|
if (!shouldDefeat) {
|
||||||
|
target.hp += Math.max(20, preview.bondChainDamage);
|
||||||
|
}
|
||||||
|
const targetView = scene.unitViews.get(target.id);
|
||||||
|
const targetMiniMapDot = scene.miniMapUnitDots.get(target.id);
|
||||||
|
if (!targetView) {
|
||||||
|
throw new Error(`Missing production finisher target view: ${target.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewState = () => ({
|
||||||
|
spriteVisible: targetView.sprite.visible,
|
||||||
|
spriteAlpha: targetView.sprite.alpha,
|
||||||
|
labelVisible: targetView.label.visible,
|
||||||
|
labelAlpha: targetView.label.alpha,
|
||||||
|
hitZoneVisible: targetView.hitZone.visible,
|
||||||
|
miniMapDotVisible: targetMiniMapDot?.visible ?? null
|
||||||
|
});
|
||||||
|
const originalForceBondChain = new URL(originalUrl).searchParams.get('debugForceBondChain');
|
||||||
|
const originalResolveCombatAction = scene.resolveCombatAction;
|
||||||
|
const originalPlayCombatCutIn = scene.playCombatCutIn;
|
||||||
|
const originalApplyDefeatedStyle = scene.applyDefeatedStyle;
|
||||||
|
const originalResolveCounterAttack = scene.resolveCounterAttack;
|
||||||
|
const methodOwnership = {
|
||||||
|
resolveCombatAction: Object.prototype.hasOwnProperty.call(scene, 'resolveCombatAction'),
|
||||||
|
playCombatCutIn: Object.prototype.hasOwnProperty.call(scene, 'playCombatCutIn'),
|
||||||
|
applyDefeatedStyle: Object.prototype.hasOwnProperty.call(scene, 'applyDefeatedStyle'),
|
||||||
|
resolveCounterAttack: Object.prototype.hasOwnProperty.call(scene, 'resolveCounterAttack')
|
||||||
|
};
|
||||||
|
let primaryResult = null;
|
||||||
|
let counterResult = null;
|
||||||
|
let visibleAfterResolve = null;
|
||||||
|
let visibleAtCutInStart = null;
|
||||||
|
let visibleAtCutInEnd = null;
|
||||||
|
let primaryCutInStarted = false;
|
||||||
|
let primaryCutInCompleted = false;
|
||||||
|
let counterCalls = 0;
|
||||||
|
let counterProduced = false;
|
||||||
|
const cutInOrder = [];
|
||||||
|
const defeatApplications = [];
|
||||||
|
|
||||||
|
const restoreMethod = (name, original) => {
|
||||||
|
if (methodOwnership[name]) {
|
||||||
|
scene[name] = original;
|
||||||
|
} else {
|
||||||
|
delete scene[name];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setDebugForceHit(true);
|
||||||
|
setDebugForceBondChain('1');
|
||||||
|
scene.rollPercent = () => false;
|
||||||
|
scene.resolveCombatAction = function (...args) {
|
||||||
|
const result = originalResolveCombatAction.apply(this, args);
|
||||||
|
if (args[3] === true) {
|
||||||
|
counterResult = result;
|
||||||
|
} else {
|
||||||
|
primaryResult = result;
|
||||||
|
visibleAfterResolve = viewState();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
scene.playCombatCutIn = async function (result) {
|
||||||
|
const isPrimary = result === primaryResult;
|
||||||
|
cutInOrder.push(isPrimary ? 'primary' : result === counterResult || result.isCounter ? 'counter' : 'other');
|
||||||
|
if (isPrimary) {
|
||||||
|
primaryCutInStarted = true;
|
||||||
|
visibleAtCutInStart = viewState();
|
||||||
|
}
|
||||||
|
await originalPlayCombatCutIn.call(this, result);
|
||||||
|
if (isPrimary) {
|
||||||
|
primaryCutInCompleted = true;
|
||||||
|
visibleAtCutInEnd = viewState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scene.applyDefeatedStyle = function (unit, view) {
|
||||||
|
if (unit.id === target.id) {
|
||||||
|
defeatApplications.push({
|
||||||
|
primaryCutInStarted,
|
||||||
|
primaryCutInCompleted,
|
||||||
|
before: viewState()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return originalApplyDefeatedStyle.call(this, unit, view);
|
||||||
|
};
|
||||||
|
scene.resolveCounterAttack = function (result) {
|
||||||
|
counterCalls += 1;
|
||||||
|
const counter = originalResolveCounterAttack.call(this, result);
|
||||||
|
counterProduced = counterProduced || Boolean(counter);
|
||||||
|
return counter;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
scene.phase = 'targeting';
|
||||||
|
scene.selectedUnit = attacker;
|
||||||
|
await scene.tryResolveDamageTarget(attacker, target, 'attack');
|
||||||
|
return {
|
||||||
|
result: primaryResult
|
||||||
|
? {
|
||||||
|
hit: primaryResult.hit,
|
||||||
|
baseDefeated: primaryResult.baseDefeated,
|
||||||
|
defeated: primaryResult.defeated,
|
||||||
|
chainDefeated: primaryResult.bondChain?.defeated ?? false,
|
||||||
|
counter: Boolean(primaryResult.counter)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
visibleAfterResolve,
|
||||||
|
visibleAtCutInStart,
|
||||||
|
visibleAtCutInEnd,
|
||||||
|
defeatApplications,
|
||||||
|
visibleAfterWrapper: viewState(),
|
||||||
|
counterCalls,
|
||||||
|
counterProduced,
|
||||||
|
cutInOrder
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
restoreMethod('resolveCombatAction', originalResolveCombatAction);
|
||||||
|
restoreMethod('playCombatCutIn', originalPlayCombatCutIn);
|
||||||
|
restoreMethod('applyDefeatedStyle', originalApplyDefeatedStyle);
|
||||||
|
restoreMethod('resolveCounterAttack', originalResolveCounterAttack);
|
||||||
|
if (hadOwnRollPercent) {
|
||||||
|
scene.rollPercent = originalRollPercent;
|
||||||
|
} else {
|
||||||
|
delete scene.rollPercent;
|
||||||
|
}
|
||||||
|
setDebugForceBondChain(originalForceBondChain);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let scenarios;
|
||||||
|
let restored;
|
||||||
|
try {
|
||||||
|
const productionFinisher = await runProductionScenario(true);
|
||||||
|
const productionSurvivor = await runProductionScenario(false);
|
||||||
|
const success = await runScenario({ mode: 'success', forcedRoll: true, present: true });
|
||||||
|
const failure = await runScenario({ mode: 'failure', forcedRoll: false, present: true });
|
||||||
|
const rangedNormal = await runScenario({
|
||||||
|
mode: 'success',
|
||||||
|
forcedRoll: true,
|
||||||
|
present: true,
|
||||||
|
partnerClassKey: 'archer',
|
||||||
|
battleSpeed: 'normal'
|
||||||
|
});
|
||||||
|
const rangedFast = await runScenario({
|
||||||
|
mode: 'success',
|
||||||
|
forcedRoll: true,
|
||||||
|
present: true,
|
||||||
|
partnerClassKey: 'strategist',
|
||||||
|
battleSpeed: 'fast'
|
||||||
|
});
|
||||||
|
const rangedVeryFast = await runScenario({
|
||||||
|
mode: 'success',
|
||||||
|
forcedRoll: true,
|
||||||
|
present: true,
|
||||||
|
partnerClassKey: 'strategist',
|
||||||
|
battleSpeed: 'very-fast'
|
||||||
|
});
|
||||||
|
const natural = await runScenario({ mode: 'success', forcedRoll: undefined, present: false, naturalRoll: true });
|
||||||
|
const naturalFailure = await runScenario({ mode: 'failure', forcedRoll: undefined, present: false });
|
||||||
|
const miss = await runScenario({ mode: 'miss', forcedRoll: true, present: false });
|
||||||
|
const lethal = await runScenario({ mode: 'lethal', forcedRoll: true, present: false });
|
||||||
|
scenarios = {
|
||||||
|
productionFinisher,
|
||||||
|
productionSurvivor,
|
||||||
|
success,
|
||||||
|
failure,
|
||||||
|
rangedNormal,
|
||||||
|
rangedFast,
|
||||||
|
rangedVeryFast,
|
||||||
|
natural,
|
||||||
|
naturalFailure,
|
||||||
|
miss,
|
||||||
|
lethal
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Let the final lethal scenario's map popup and delayed defeat styling settle
|
||||||
|
// before restoring the live battle. Otherwise those callbacks can fire after
|
||||||
|
// restoration and hide the revived fixture target in the following RC flow.
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 900));
|
||||||
|
restoreFixtureState();
|
||||||
|
window.history.replaceState({}, '', originalUrl);
|
||||||
|
const restoredState = scene.getDebugState();
|
||||||
|
restored = {
|
||||||
|
save: comparableSave(clone(scene.createBattleSaveState())),
|
||||||
|
camera: { x: scene.cameraTileX, y: scene.cameraTileY },
|
||||||
|
phase: restoredState.phase,
|
||||||
|
selectedUnitId: restoredState.selectedUnitId,
|
||||||
|
pendingMove: restoredState.pendingMove,
|
||||||
|
selectedUsable: restoredState.selectedUsable,
|
||||||
|
commandMenuVisible: restoredState.commandMenuVisible,
|
||||||
|
markerCount: restoredState.markerCount,
|
||||||
|
judgement: clone(restoredState.bondChainJudgement),
|
||||||
|
partnerClassKey: scene.debugUnitById(partnerId)?.classKey ?? null,
|
||||||
|
battleSpeed: scene.battleSpeed,
|
||||||
|
fastForwardHeld: scene.fastForwardHeld,
|
||||||
|
combatObjectCount: scene.combatCutInObjects.length,
|
||||||
|
debugForceHit: new URLSearchParams(window.location.search).get('debugForceHit')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: true,
|
||||||
|
sceneBounds: { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height },
|
||||||
|
expectedRate: mechanics.chain?.rate ?? null,
|
||||||
|
scenarios,
|
||||||
|
restored,
|
||||||
|
expectedRestored: {
|
||||||
|
save: comparableSave(originalSave),
|
||||||
|
camera: originalCamera,
|
||||||
|
phase: stateBefore.phase,
|
||||||
|
selectedUnitId: stateBefore.selectedUnitId,
|
||||||
|
pendingMove: stateBefore.pendingMove,
|
||||||
|
selectedUsable: stateBefore.selectedUsable,
|
||||||
|
commandMenuVisible: stateBefore.commandMenuVisible,
|
||||||
|
markerCount: stateBefore.markerCount,
|
||||||
|
judgement: originalJudgement ?? null,
|
||||||
|
partnerClassKey: originalPartnerClassKey ?? null,
|
||||||
|
battleSpeed: originalBattleSpeed,
|
||||||
|
fastForwardHeld: originalFastForwardHeld,
|
||||||
|
combatObjectCount: 0,
|
||||||
|
debugForceHit: new URL(originalUrl).searchParams.get('debugForceHit')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(
|
||||||
|
firstBattleBondChainJudgement.ready === true && firstBattleBondChainJudgement.expectedRate === 18,
|
||||||
|
`Expected a deterministic level-72 resonance judgement fixture: ${JSON.stringify(firstBattleBondChainJudgement)}`
|
||||||
|
);
|
||||||
|
const productionFinisher = firstBattleBondChainJudgement.scenarios.productionFinisher;
|
||||||
|
const productionViewVisible = (view) =>
|
||||||
|
view?.spriteVisible === true && view.spriteAlpha > 0.9 && view.labelVisible === true && view.labelAlpha > 0.9;
|
||||||
|
assert(
|
||||||
|
productionFinisher.result?.hit === true &&
|
||||||
|
productionFinisher.result.baseDefeated === false &&
|
||||||
|
productionFinisher.result.defeated === true &&
|
||||||
|
productionFinisher.result.chainDefeated === true &&
|
||||||
|
productionViewVisible(productionFinisher.visibleAfterResolve) &&
|
||||||
|
productionViewVisible(productionFinisher.visibleAtCutInStart) &&
|
||||||
|
productionViewVisible(productionFinisher.visibleAtCutInEnd) &&
|
||||||
|
productionFinisher.visibleAfterResolve.miniMapDotVisible === true &&
|
||||||
|
productionFinisher.visibleAtCutInStart.miniMapDotVisible === true &&
|
||||||
|
productionFinisher.visibleAtCutInEnd.miniMapDotVisible === true &&
|
||||||
|
productionFinisher.defeatApplications.length >= 1 &&
|
||||||
|
productionFinisher.defeatApplications.every((application) =>
|
||||||
|
application.primaryCutInStarted === true && application.primaryCutInCompleted === true
|
||||||
|
) &&
|
||||||
|
productionViewVisible(productionFinisher.defeatApplications[0].before) &&
|
||||||
|
productionFinisher.visibleAfterWrapper.spriteVisible === false &&
|
||||||
|
productionFinisher.visibleAfterWrapper.spriteAlpha === 0 &&
|
||||||
|
productionFinisher.visibleAfterWrapper.labelVisible === false &&
|
||||||
|
productionFinisher.visibleAfterWrapper.miniMapDotVisible === false &&
|
||||||
|
productionFinisher.counterCalls === 1 &&
|
||||||
|
productionFinisher.counterProduced === false &&
|
||||||
|
productionFinisher.result.counter === false &&
|
||||||
|
JSON.stringify(productionFinisher.cutInOrder) === JSON.stringify(['primary']),
|
||||||
|
`Expected the production attack path to keep a pursuit-defeated target visible through the finisher, then hide it and suppress counterattack: ${JSON.stringify(productionFinisher)}`
|
||||||
|
);
|
||||||
|
const productionSurvivor = firstBattleBondChainJudgement.scenarios.productionSurvivor;
|
||||||
|
assert(
|
||||||
|
productionSurvivor.result?.hit === true &&
|
||||||
|
productionSurvivor.result.baseDefeated === false &&
|
||||||
|
productionSurvivor.result.defeated === false &&
|
||||||
|
productionSurvivor.result.chainDefeated === false &&
|
||||||
|
productionSurvivor.result.counter === true &&
|
||||||
|
productionViewVisible(productionSurvivor.visibleAfterResolve) &&
|
||||||
|
productionViewVisible(productionSurvivor.visibleAtCutInStart) &&
|
||||||
|
productionViewVisible(productionSurvivor.visibleAtCutInEnd) &&
|
||||||
|
productionSurvivor.defeatApplications.length === 0 &&
|
||||||
|
productionViewVisible(productionSurvivor.visibleAfterWrapper) &&
|
||||||
|
productionSurvivor.counterCalls === 1 &&
|
||||||
|
productionSurvivor.counterProduced === true &&
|
||||||
|
JSON.stringify(productionSurvivor.cutInOrder) === JSON.stringify(['primary', 'counter']),
|
||||||
|
`Expected the production nonlethal pursuit path to preserve the defender and continue into its counterattack cut-in: ${JSON.stringify(productionSurvivor)}`
|
||||||
|
);
|
||||||
|
const judgementSuccess = firstBattleBondChainJudgement.scenarios.success;
|
||||||
|
const successAttempt = judgementSuccess.attempt;
|
||||||
|
const successChain = judgementSuccess.chain;
|
||||||
|
const successPresented = judgementSuccess.judgement.presented;
|
||||||
|
const successCompleted = judgementSuccess.judgement.completed;
|
||||||
|
const expectedSuccessMotion = judgementSuccess.partnerClassKey === 'archer' ? 'ranged' : 'melee';
|
||||||
|
const neutralSuccessCue = [judgementSuccess.outcomeChip?.label, judgementSuccess.mapCueLabel, ...judgementSuccess.neutralMapTexts]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
assert(
|
||||||
|
judgementSuccess.hit === true &&
|
||||||
|
judgementSuccess.baseDefeated === false &&
|
||||||
|
judgementSuccess.defeated === true &&
|
||||||
|
successAttempt?.succeeded === true &&
|
||||||
|
successAttempt.partnerId === judgementSuccess.partnerId &&
|
||||||
|
successAttempt.partnerName === judgementSuccess.partnerName &&
|
||||||
|
successAttempt.rate === 18 &&
|
||||||
|
successAttempt.expectedDamage === judgementSuccess.preview.chainDamage &&
|
||||||
|
successChain?.partnerId === successAttempt.partnerId &&
|
||||||
|
successChain.partnerName === successAttempt.partnerName &&
|
||||||
|
successChain.rate === successAttempt.rate &&
|
||||||
|
successChain.damage === successAttempt.expectedDamage &&
|
||||||
|
successChain.previousDefenderHp === successChain.damage &&
|
||||||
|
successChain.defeated === true &&
|
||||||
|
judgementSuccess.initialTargetHp === judgementSuccess.damage + successChain.damage &&
|
||||||
|
judgementSuccess.finalTargetHp === 0,
|
||||||
|
`Expected forced pursuit success to preserve the attempt, full supporter damage, and follow-up defeat contract: ${JSON.stringify(judgementSuccess)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
judgementSuccess.partnerStatsDelta.damageDealt === successChain.damage &&
|
||||||
|
judgementSuccess.partnerStatsDelta.defeats === 1 &&
|
||||||
|
judgementSuccess.partnerStatsDelta.actions === 0 &&
|
||||||
|
judgementSuccess.targetStatsDelta.damageTaken === judgementSuccess.damage + successChain.damage,
|
||||||
|
`Expected successful pursuit damage and defeat credit to belong only to the supporter while the defender receives both hits: ${JSON.stringify(judgementSuccess)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
judgementSuccess.outcomeChip?.label === '판정 18%' &&
|
||||||
|
judgementSuccess.mapCueLabel === `공명 판정 · ${judgementSuccess.partnerName} 18%` &&
|
||||||
|
judgementSuccess.neutralMapTexts.some((text) => text.includes(judgementSuccess.mapCueLabel)) &&
|
||||||
|
!neutralSuccessCue.includes('추격') &&
|
||||||
|
!neutralSuccessCue.includes('격파') &&
|
||||||
|
!neutralSuccessCue.includes('호흡 일치') &&
|
||||||
|
!neutralSuccessCue.includes('호흡 불발'),
|
||||||
|
`Expected successful pursuit to remain a neutral percentage judgement in the outcome chip and pre-contact map cue: ${JSON.stringify(judgementSuccess)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
successPresented?.status === 'success' &&
|
||||||
|
successPresented.partnerId === judgementSuccess.partnerId &&
|
||||||
|
successPresented.partnerName === judgementSuccess.partnerName &&
|
||||||
|
successPresented.rate === 18 &&
|
||||||
|
successPresented.expectedDamage === successChain.damage &&
|
||||||
|
successPresented.damage === successChain.damage &&
|
||||||
|
successPresented.defeated === true &&
|
||||||
|
successPresented.presented === true &&
|
||||||
|
successPresented.completed === false &&
|
||||||
|
successPresented.motion === expectedSuccessMotion &&
|
||||||
|
successPresented.title === '공명 판정 18%' &&
|
||||||
|
successPresented.detail === `${judgementSuccess.partnerName} · 호흡 일치` &&
|
||||||
|
isFiniteBounds(successPresented.bounds) &&
|
||||||
|
boundsInside(successPresented.bounds, firstBattleBondChainJudgement.sceneBounds),
|
||||||
|
`Expected the successful judgement to expose the bounded percentage verdict before revealing pursuit contact: ${JSON.stringify(judgementSuccess.judgement)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
successCompleted?.status === 'success' &&
|
||||||
|
successCompleted.presented === true &&
|
||||||
|
successCompleted.completed === true &&
|
||||||
|
successCompleted.motion === expectedSuccessMotion &&
|
||||||
|
successCompleted.damage === successChain.damage &&
|
||||||
|
successCompleted.defeated === true &&
|
||||||
|
successCompleted.title.includes('공명 격파') &&
|
||||||
|
isFiniteBounds(successCompleted.bounds) &&
|
||||||
|
boundsInside(successCompleted.bounds, firstBattleBondChainJudgement.sceneBounds) &&
|
||||||
|
judgementSuccess.formatted.includes(`공명 판정 · ${judgementSuccess.partnerName} 18% · 호흡 일치`) &&
|
||||||
|
judgementSuccess.formatted.includes(`추격 피해 +${successChain.damage} · 공명 격파`) &&
|
||||||
|
judgementSuccess.mapTitle.includes('공명 격파') &&
|
||||||
|
judgementSuccess.mapResultTexts.some((text) => text.includes('공명 격파')),
|
||||||
|
`Expected successful pursuit contact to retain its completed debug snapshot and explicit finisher result feedback: ${JSON.stringify(judgementSuccess)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const rangedScenarios = [
|
||||||
|
firstBattleBondChainJudgement.scenarios.rangedNormal,
|
||||||
|
firstBattleBondChainJudgement.scenarios.rangedFast,
|
||||||
|
firstBattleBondChainJudgement.scenarios.rangedVeryFast
|
||||||
|
];
|
||||||
|
const rangedTelemetryIsSynchronized = (scenario) => {
|
||||||
|
const gauge = scenario.judgement.telemetry?.gauge?.[0];
|
||||||
|
const impact = scenario.judgement.telemetry?.impact?.[0];
|
||||||
|
const motion = scenario.judgement.telemetry?.partnerMotion?.[0];
|
||||||
|
return scenario.judgement.telemetry?.gauge?.length === 1 &&
|
||||||
|
scenario.judgement.telemetry?.impact?.length === 1 &&
|
||||||
|
scenario.judgement.telemetry?.partnerMotion?.length === 1 &&
|
||||||
|
gauge.duration === 300 &&
|
||||||
|
Math.abs(gauge.startScale - gauge.from) < 0.001 &&
|
||||||
|
Math.abs(gauge.endScale - gauge.to) < 0.001 &&
|
||||||
|
gauge.endAt > gauge.startAt &&
|
||||||
|
impact.damage === scenario.chain.damage &&
|
||||||
|
Math.abs(impact.at - gauge.startAt) < 80 &&
|
||||||
|
motion.startAt <= gauge.startAt &&
|
||||||
|
motion.endAt + 80 >= gauge.startAt;
|
||||||
|
};
|
||||||
|
const rangedGaugeDurations = rangedScenarios.map((scenario) => {
|
||||||
|
const gauge = scenario.judgement.telemetry.gauge[0];
|
||||||
|
return gauge.endAt - gauge.startAt;
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
rangedScenarios.map((scenario) => scenario.battleSpeed).join(',') === 'normal,fast,very-fast' &&
|
||||||
|
rangedScenarios[0].partnerClassKey === 'archer' &&
|
||||||
|
rangedScenarios[1].partnerClassKey === 'strategist' &&
|
||||||
|
rangedScenarios[2].partnerClassKey === 'strategist' &&
|
||||||
|
rangedScenarios.every((scenario) =>
|
||||||
|
scenario.attempt?.succeeded === true &&
|
||||||
|
scenario.judgement.presented?.status === 'success' &&
|
||||||
|
scenario.judgement.presented.motion === 'ranged' &&
|
||||||
|
scenario.judgement.presented.title === '공명 판정 18%' &&
|
||||||
|
scenario.judgement.completed?.completed === true &&
|
||||||
|
scenario.judgement.completed.motion === 'ranged' &&
|
||||||
|
rangedTelemetryIsSynchronized(scenario) &&
|
||||||
|
isFiniteBounds(scenario.judgement.completed.bounds) &&
|
||||||
|
boundsInside(scenario.judgement.completed.bounds, firstBattleBondChainJudgement.sceneBounds) &&
|
||||||
|
Number.isFinite(scenario.presentationDurationMs) &&
|
||||||
|
scenario.presentationDurationMs > 0
|
||||||
|
) &&
|
||||||
|
rangedScenarios[0].presentationDurationMs > rangedScenarios[1].presentationDurationMs &&
|
||||||
|
rangedScenarios[1].presentationDurationMs > rangedScenarios[2].presentationDurationMs &&
|
||||||
|
rangedGaugeDurations[0] > rangedGaugeDurations[1] &&
|
||||||
|
rangedGaugeDurations[1] > rangedGaugeDurations[2],
|
||||||
|
`Expected archer and extended-range pursuit motions to complete in all three battle speeds with ordered scaled timing: ${JSON.stringify(rangedScenarios)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const judgementNatural = firstBattleBondChainJudgement.scenarios.natural;
|
||||||
|
assert(
|
||||||
|
judgementNatural.hit === true &&
|
||||||
|
judgementNatural.baseDefeated === false &&
|
||||||
|
judgementNatural.attempt?.succeeded === true &&
|
||||||
|
judgementNatural.chain?.damage === judgementNatural.preview.chainDamage &&
|
||||||
|
judgementNatural.chainRollCalls.length === 1 &&
|
||||||
|
judgementNatural.chainRollCalls[0].rate === judgementNatural.preview.chainRate &&
|
||||||
|
judgementNatural.chainRollCalls[0].forcedResult === null &&
|
||||||
|
judgementNatural.chainRollCalls[0].rollPercentCalls === 1 &&
|
||||||
|
judgementNatural.chainRollCalls[0].succeeded === true,
|
||||||
|
`Expected the natural pursuit path to make exactly one unforced percentage roll after the base hit: ${JSON.stringify(judgementNatural)}`
|
||||||
|
);
|
||||||
|
const judgementNaturalFailure = firstBattleBondChainJudgement.scenarios.naturalFailure;
|
||||||
|
assert(
|
||||||
|
judgementNaturalFailure.hit === true &&
|
||||||
|
judgementNaturalFailure.baseDefeated === false &&
|
||||||
|
judgementNaturalFailure.attempt?.succeeded === false &&
|
||||||
|
judgementNaturalFailure.chain === null &&
|
||||||
|
judgementNaturalFailure.chainRollCalls.length === 1 &&
|
||||||
|
judgementNaturalFailure.chainRollCalls[0].rate === judgementNaturalFailure.preview.chainRate &&
|
||||||
|
judgementNaturalFailure.chainRollCalls[0].forcedResult === null &&
|
||||||
|
judgementNaturalFailure.chainRollCalls[0].rollPercentCalls === 1 &&
|
||||||
|
judgementNaturalFailure.chainRollCalls[0].succeeded === false,
|
||||||
|
`Expected the natural failed pursuit path to consume one unforced roll without applying follow-up damage: ${JSON.stringify(judgementNaturalFailure)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const judgementFailure = firstBattleBondChainJudgement.scenarios.failure;
|
||||||
|
const failureAttempt = judgementFailure.attempt;
|
||||||
|
const failurePresented = judgementFailure.judgement.presented;
|
||||||
|
const failureCompleted = judgementFailure.judgement.completed;
|
||||||
|
const expectedFailureMotion = judgementFailure.partnerClassKey === 'archer' ? 'ranged' : 'melee';
|
||||||
|
const failureLine = `공명 판정 · ${judgementFailure.partnerName} 18% · 호흡 불발`;
|
||||||
|
const neutralFailureCue = [judgementFailure.outcomeChip?.label, judgementFailure.mapCueLabel, ...judgementFailure.neutralMapTexts]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
assert(
|
||||||
|
judgementFailure.hit === true &&
|
||||||
|
judgementFailure.baseDefeated === false &&
|
||||||
|
judgementFailure.defeated === false &&
|
||||||
|
failureAttempt?.succeeded === false &&
|
||||||
|
failureAttempt.partnerId === judgementFailure.partnerId &&
|
||||||
|
failureAttempt.partnerName === judgementFailure.partnerName &&
|
||||||
|
failureAttempt.rate === 18 &&
|
||||||
|
failureAttempt.expectedDamage === judgementFailure.preview.chainDamage &&
|
||||||
|
judgementFailure.chain === null &&
|
||||||
|
judgementFailure.finalTargetHp === judgementFailure.initialTargetHp - judgementFailure.damage,
|
||||||
|
`Expected forced pursuit failure to retain the attempted partner and rate without applying follow-up damage: ${JSON.stringify(judgementFailure)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
judgementFailure.partnerStatsDelta.damageDealt === 0 &&
|
||||||
|
judgementFailure.partnerStatsDelta.defeats === 0 &&
|
||||||
|
judgementFailure.partnerStatsDelta.actions === 0 &&
|
||||||
|
judgementFailure.targetStatsDelta.damageTaken === judgementFailure.damage,
|
||||||
|
`Expected failed pursuit to leave supporter and defender pursuit statistics untouched: ${JSON.stringify(judgementFailure)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
judgementFailure.outcomeChip?.label === '판정 18%' &&
|
||||||
|
judgementFailure.mapCueLabel === `공명 판정 · ${judgementFailure.partnerName} 18%` &&
|
||||||
|
judgementFailure.neutralMapTexts.some((text) => text.includes(judgementFailure.mapCueLabel)) &&
|
||||||
|
!neutralFailureCue.includes('추격') &&
|
||||||
|
!neutralFailureCue.includes('격파') &&
|
||||||
|
!neutralFailureCue.includes('호흡 불발') &&
|
||||||
|
judgementFailure.formatted.includes(failureLine) &&
|
||||||
|
judgementFailure.mapResultTexts.some((text) => text.includes(failureLine)),
|
||||||
|
`Expected failed pursuit to keep the pre-contact cue neutral and then name the partner, rate, and exact failure reason: ${JSON.stringify(judgementFailure)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
failurePresented?.status === 'failed' &&
|
||||||
|
failurePresented.partnerId === judgementFailure.partnerId &&
|
||||||
|
failurePresented.partnerName === judgementFailure.partnerName &&
|
||||||
|
failurePresented.rate === 18 &&
|
||||||
|
failurePresented.expectedDamage === judgementFailure.preview.chainDamage &&
|
||||||
|
failurePresented.damage === 0 &&
|
||||||
|
failurePresented.defeated === false &&
|
||||||
|
failurePresented.presented === true &&
|
||||||
|
failurePresented.completed === false &&
|
||||||
|
failurePresented.motion === expectedFailureMotion &&
|
||||||
|
`${failurePresented.title} ${failurePresented.detail}`.includes('호흡 불발') &&
|
||||||
|
isFiniteBounds(failurePresented.bounds) &&
|
||||||
|
boundsInside(failurePresented.bounds, firstBattleBondChainJudgement.sceneBounds),
|
||||||
|
`Expected the failed judgement to expose one bounded in-progress non-damaging presentation: ${JSON.stringify(judgementFailure.judgement)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
failureCompleted?.status === 'failed' &&
|
||||||
|
failureCompleted.presented === true &&
|
||||||
|
failureCompleted.completed === true &&
|
||||||
|
failureCompleted.motion === expectedFailureMotion &&
|
||||||
|
failureCompleted.damage === 0 &&
|
||||||
|
failureCompleted.defeated === false &&
|
||||||
|
`${failureCompleted.title} ${failureCompleted.detail}`.includes('호흡 불발') &&
|
||||||
|
isFiniteBounds(failureCompleted.bounds) &&
|
||||||
|
boundsInside(failureCompleted.bounds, firstBattleBondChainJudgement.sceneBounds),
|
||||||
|
`Expected failed pursuit presentation to complete once while retaining its judgement payload: ${JSON.stringify(judgementFailure.judgement)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const judgementMiss = firstBattleBondChainJudgement.scenarios.miss;
|
||||||
|
const judgementLethal = firstBattleBondChainJudgement.scenarios.lethal;
|
||||||
|
assert(
|
||||||
|
judgementMiss.hit === false &&
|
||||||
|
judgementMiss.damage === 0 &&
|
||||||
|
judgementMiss.attempt === null &&
|
||||||
|
judgementMiss.chain === null &&
|
||||||
|
judgementMiss.finalTargetHp === judgementMiss.initialTargetHp &&
|
||||||
|
judgementMiss.judgement.completed === null,
|
||||||
|
`Expected a missed base attack to skip resonance judgement entirely: ${JSON.stringify(judgementMiss)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
judgementLethal.hit === true &&
|
||||||
|
judgementLethal.baseDefeated === true &&
|
||||||
|
judgementLethal.defeated === true &&
|
||||||
|
judgementLethal.attempt === null &&
|
||||||
|
judgementLethal.chain === null &&
|
||||||
|
judgementLethal.finalTargetHp === 0 &&
|
||||||
|
judgementLethal.judgement.completed === null,
|
||||||
|
`Expected a lethal base attack to skip resonance judgement entirely: ${JSON.stringify(judgementLethal)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sameJsonValue(firstBattleBondChainJudgement.restored, firstBattleBondChainJudgement.expectedRestored),
|
||||||
|
`Expected the judgement fixture to restore the full saveable battle state, camera, URL flags, selection, markers, cut-in objects, and prior debug snapshot: ${JSON.stringify(firstBattleBondChainJudgement)}`
|
||||||
|
);
|
||||||
|
|
||||||
const firstBattleOrderHud = await assertLiveSortieOrderHud(page, {
|
const firstBattleOrderHud = await assertLiveSortieOrderHud(page, {
|
||||||
battleId: 'first-battle-zhuo-commandery',
|
battleId: 'first-battle-zhuo-commandery',
|
||||||
orderId: 'elite',
|
orderId: 'elite',
|
||||||
|
|||||||
@@ -1301,6 +1301,32 @@ type BondChainResult = {
|
|||||||
defeated: boolean;
|
defeated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BondChainAttempt = {
|
||||||
|
bondId: string;
|
||||||
|
label: string;
|
||||||
|
partnerId: string;
|
||||||
|
partnerName: string;
|
||||||
|
rate: number;
|
||||||
|
expectedDamage: number;
|
||||||
|
succeeded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BondChainJudgementSnapshot = {
|
||||||
|
status: 'success' | 'failed';
|
||||||
|
partnerId: string;
|
||||||
|
partnerName: string;
|
||||||
|
rate: number;
|
||||||
|
expectedDamage: number;
|
||||||
|
damage: number;
|
||||||
|
defeated: boolean;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
presented: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
motion: 'melee' | 'ranged';
|
||||||
|
bounds: SortieOrderHudBounds | null;
|
||||||
|
};
|
||||||
|
|
||||||
type UnitBattleStats = {
|
type UnitBattleStats = {
|
||||||
damageDealt: number;
|
damageDealt: number;
|
||||||
damageTaken: number;
|
damageTaken: number;
|
||||||
@@ -1544,6 +1570,7 @@ type CombatResult = CombatPreview & {
|
|||||||
initiativeBonusDamageAmount: number;
|
initiativeBonusDamageAmount: number;
|
||||||
initiativePreventedDamageAmount: number;
|
initiativePreventedDamageAmount: number;
|
||||||
initiativeReadied: boolean;
|
initiativeReadied: boolean;
|
||||||
|
bondChainAttempt?: BondChainAttempt;
|
||||||
bondChain?: BondChainResult;
|
bondChain?: BondChainResult;
|
||||||
counter?: CombatResult;
|
counter?: CombatResult;
|
||||||
};
|
};
|
||||||
@@ -3302,6 +3329,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
private bondChainReadyMarkerViews: BondChainReadyMarkerView[] = [];
|
private bondChainReadyMarkerViews: BondChainReadyMarkerView[] = [];
|
||||||
private bondChainReadyPartnerMarkers = new Map<string, Phaser.GameObjects.Rectangle>();
|
private bondChainReadyPartnerMarkers = new Map<string, Phaser.GameObjects.Rectangle>();
|
||||||
private bondChainReadyFocusedPreview?: BondChainReadyFocusedPreview;
|
private bondChainReadyFocusedPreview?: BondChainReadyFocusedPreview;
|
||||||
|
private bondChainJudgementLast?: BondChainJudgementSnapshot;
|
||||||
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;
|
||||||
@@ -3432,6 +3460,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.hideTacticalCommandCutIn();
|
this.hideTacticalCommandCutIn();
|
||||||
this.tacticalCommandCutInCount = 0;
|
this.tacticalCommandCutInCount = 0;
|
||||||
this.tacticalCommandCutInLast = undefined;
|
this.tacticalCommandCutInLast = undefined;
|
||||||
|
this.bondChainJudgementLast = undefined;
|
||||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.hideTacticalCommandCutIn());
|
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.hideTacticalCommandCutIn());
|
||||||
this.resultFormationReviewVisible = false;
|
this.resultFormationReviewVisible = false;
|
||||||
this.resultFormationReviewFeedback = '';
|
this.resultFormationReviewFeedback = '';
|
||||||
@@ -10135,6 +10164,10 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.clearMarkers();
|
this.clearMarkers();
|
||||||
await this.showBondMapEffect(result);
|
await this.showBondMapEffect(result);
|
||||||
await this.playCombatCutIn(result);
|
await this.playCombatCutIn(result);
|
||||||
|
if (result.bondChain?.defeated) {
|
||||||
|
this.applyDefeatedStyle(result.defender);
|
||||||
|
this.updateMiniMap();
|
||||||
|
}
|
||||||
result.counter = this.resolveCounterAttack(result);
|
result.counter = this.resolveCounterAttack(result);
|
||||||
if (result.counter) {
|
if (result.counter) {
|
||||||
await this.delay(180);
|
await this.delay(180);
|
||||||
@@ -13632,7 +13665,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return forcedRoll ?? this.rollPercent(rate);
|
return forcedRoll ?? this.rollPercent(rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveBondChainFollowUp(preview: CombatPreview, hit: boolean): BondChainResult | undefined {
|
private resolveBondChainFollowUp(
|
||||||
|
preview: CombatPreview,
|
||||||
|
hit: boolean,
|
||||||
|
forcedRoll = this.debugBondChainRollOverride()
|
||||||
|
): { attempt: BondChainAttempt; result?: BondChainResult } | undefined {
|
||||||
if (!this.canResolveBondChain(preview, hit, preview.defender.hp)) {
|
if (!this.canResolveBondChain(preview, hit, preview.defender.hp)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -13641,8 +13678,19 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
if (!partner) {
|
if (!partner) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!this.bondChainRollSucceeded(preview.bondChainRate)) {
|
|
||||||
return undefined;
|
const succeeded = this.bondChainRollSucceeded(preview.bondChainRate, forcedRoll);
|
||||||
|
const attempt: BondChainAttempt = {
|
||||||
|
bondId: preview.bondChainBondId ?? '',
|
||||||
|
label: preview.bondChainLabel ?? '공명 연계',
|
||||||
|
partnerId: partner.id,
|
||||||
|
partnerName: partner.name,
|
||||||
|
rate: preview.bondChainRate,
|
||||||
|
expectedDamage: preview.bondChainDamage,
|
||||||
|
succeeded
|
||||||
|
};
|
||||||
|
if (!succeeded) {
|
||||||
|
return { attempt };
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousDefenderHp = preview.defender.hp;
|
const previousDefenderHp = preview.defender.hp;
|
||||||
@@ -13653,18 +13701,31 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.faceUnitToward(partner, preview.defender);
|
this.faceUnitToward(partner, preview.defender);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bondId: preview.bondChainBondId ?? '',
|
attempt,
|
||||||
label: preview.bondChainLabel ?? '공명 연계',
|
result: {
|
||||||
partnerId: partner.id,
|
bondId: attempt.bondId,
|
||||||
partnerName: partner.name,
|
label: attempt.label,
|
||||||
rate: preview.bondChainRate,
|
partnerId: partner.id,
|
||||||
damage,
|
partnerName: partner.name,
|
||||||
previousDefenderHp,
|
rate: preview.bondChainRate,
|
||||||
defeated
|
damage,
|
||||||
|
previousDefenderHp,
|
||||||
|
defeated
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false, usable?: BattleUsable): CombatResult {
|
private resolveCombatAction(
|
||||||
|
attacker: UnitData,
|
||||||
|
defender: UnitData,
|
||||||
|
action: DamageCommand,
|
||||||
|
isCounter = false,
|
||||||
|
usable?: BattleUsable,
|
||||||
|
forcedBondChainRoll?: boolean
|
||||||
|
): CombatResult {
|
||||||
|
if (!isCounter) {
|
||||||
|
this.bondChainJudgementLast = undefined;
|
||||||
|
}
|
||||||
const preview = this.combatPreview(attacker, defender, action, usable, isCounter);
|
const preview = this.combatPreview(attacker, defender, action, usable, isCounter);
|
||||||
const sortieOnlyPreview = this.combatPreview(attacker, defender, action, usable, isCounter, false, true);
|
const sortieOnlyPreview = this.combatPreview(attacker, defender, action, usable, isCounter, false, true);
|
||||||
const baselinePreview = this.combatPreview(attacker, defender, action, usable, isCounter, true, true);
|
const baselinePreview = this.combatPreview(attacker, defender, action, usable, isCounter, true, true);
|
||||||
@@ -13716,7 +13777,9 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
initiativeBonusDamageAmount,
|
initiativeBonusDamageAmount,
|
||||||
initiativePreventedDamageAmount
|
initiativePreventedDamageAmount
|
||||||
);
|
);
|
||||||
const bondChain = !isCounter ? this.resolveBondChainFollowUp(preview, hit) : undefined;
|
const bondChainResolution = !isCounter ? this.resolveBondChainFollowUp(preview, hit, forcedBondChainRoll) : undefined;
|
||||||
|
const bondChainAttempt = bondChainResolution?.attempt;
|
||||||
|
const bondChain = bondChainResolution?.result;
|
||||||
this.faceUnitToward(attacker, defender);
|
this.faceUnitToward(attacker, defender);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
this.flashDamage(defender, damage, critical, previousDefenderHp, bondChain?.previousDefenderHp);
|
this.flashDamage(defender, damage, critical, previousDefenderHp, bondChain?.previousDefenderHp);
|
||||||
@@ -13728,11 +13791,17 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.attackIntents = this.attackIntents.filter(
|
this.attackIntents = this.attackIntents.filter(
|
||||||
(intent) => intent.targetId !== defender.id && intent.attackerId !== defender.id
|
(intent) => intent.targetId !== defender.id && intent.attackerId !== defender.id
|
||||||
);
|
);
|
||||||
this.applyDefeatedStyle(defender);
|
if (!bondChain?.defeated) {
|
||||||
|
this.applyDefeatedStyle(defender);
|
||||||
|
} else {
|
||||||
|
this.syncUnitMotion(defender);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.syncUnitMotion(defender);
|
this.syncUnitMotion(defender);
|
||||||
}
|
}
|
||||||
this.updateMiniMap();
|
if (!bondChain?.defeated) {
|
||||||
|
this.updateMiniMap();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...preview,
|
...preview,
|
||||||
@@ -13755,6 +13824,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
initiativeBonusDamageAmount,
|
initiativeBonusDamageAmount,
|
||||||
initiativePreventedDamageAmount,
|
initiativePreventedDamageAmount,
|
||||||
initiativeReadied,
|
initiativeReadied,
|
||||||
|
bondChainAttempt,
|
||||||
bondChain
|
bondChain
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -14037,8 +14107,10 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const summaryInitiative = initiativeContribution ? `\n전술 명령: ${initiativeContribution}` : '';
|
const summaryInitiative = initiativeContribution ? `\n전술 명령: ${initiativeContribution}` : '';
|
||||||
const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : '';
|
const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : '';
|
||||||
const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : '';
|
const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : '';
|
||||||
const summaryBondChain = result.bondChain
|
const summaryBondChain = result.bondChainAttempt
|
||||||
? `\n공명 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 퇴각' : ''}`
|
? result.bondChain
|
||||||
|
? `\n공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 일치\n공명 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 공명 격파' : ''}`
|
||||||
|
: `\n공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발`
|
||||||
: '';
|
: '';
|
||||||
const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : '';
|
const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : '';
|
||||||
return `${headline}\n${resolution}${summaryBondChain}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`;
|
return `${headline}\n${resolution}${summaryBondChain}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`;
|
||||||
@@ -14069,6 +14141,16 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return result.hit ? result.damage : 0;
|
return result.hit ? result.damage : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bondChainMapCueLabel(result: CombatResult) {
|
||||||
|
if (result.bondChainAttempt) {
|
||||||
|
return `공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}%`;
|
||||||
|
}
|
||||||
|
if (result.bondExtendedRange) {
|
||||||
|
return '삼재진 · 2칸 공명';
|
||||||
|
}
|
||||||
|
return '공명 강화';
|
||||||
|
}
|
||||||
|
|
||||||
private combatResultSummaryLine(result: CombatResult) {
|
private combatResultSummaryLine(result: CombatResult) {
|
||||||
const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}→${result.defender.hp}` : '';
|
const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}→${result.defender.hp}` : '';
|
||||||
const counter = !result.isCounter && result.counter ? ' · 반격 발생' : '';
|
const counter = !result.isCounter && result.counter ? ' · 반격 발생' : '';
|
||||||
@@ -15452,6 +15534,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.lockedTargetPreview = undefined;
|
this.lockedTargetPreview = undefined;
|
||||||
this.battleOutcome = undefined;
|
this.battleOutcome = undefined;
|
||||||
this.resultSortieOrder = undefined;
|
this.resultSortieOrder = undefined;
|
||||||
|
this.bondChainJudgementLast = undefined;
|
||||||
this.phase = 'idle';
|
this.phase = 'idle';
|
||||||
|
|
||||||
this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(state.sortieOrderId ?? this.launchSortieOrderId);
|
this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(state.sortieOrderId ?? this.launchSortieOrderId);
|
||||||
@@ -16427,7 +16510,12 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
);
|
);
|
||||||
defenderStatus.hpText.setText(`${hpAfterBaseAttack} / ${result.defender.maxHp}`);
|
defenderStatus.hpText.setText(`${hpAfterBaseAttack} / ${result.defender.maxHp}`);
|
||||||
|
|
||||||
|
if (result.bondChainAttempt) {
|
||||||
|
await this.showBondChainJudgementEffect(result, left + panelWidth / 2, top + 142, depth + 19);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.bondChain) {
|
if (result.bondChain) {
|
||||||
|
let chainGaugePromise: Promise<void> | undefined;
|
||||||
await this.showBondChainCombatEffect(
|
await this.showBondChainCombatEffect(
|
||||||
result,
|
result,
|
||||||
defenderSprite,
|
defenderSprite,
|
||||||
@@ -16435,15 +16523,22 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
groundY,
|
groundY,
|
||||||
left + panelWidth / 2,
|
left + panelWidth / 2,
|
||||||
top + 142,
|
top + 142,
|
||||||
depth + 19
|
depth + 19,
|
||||||
|
() => {
|
||||||
|
chainGaugePromise = this.animateCombatGauge(
|
||||||
|
defenderStatus.hpFill,
|
||||||
|
result.bondChain!.previousDefenderHp / result.defender.maxHp,
|
||||||
|
result.defender.hp / result.defender.maxHp,
|
||||||
|
300
|
||||||
|
).then(() => {
|
||||||
|
defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
);
|
);
|
||||||
await this.animateCombatGauge(
|
await chainGaugePromise;
|
||||||
defenderStatus.hpFill,
|
}
|
||||||
result.bondChain.previousDefenderHp / result.defender.maxHp,
|
if (result.bondChainAttempt && this.bondChainJudgementLast) {
|
||||||
result.defender.hp / result.defender.maxHp,
|
this.bondChainJudgementLast.completed = true;
|
||||||
300
|
|
||||||
);
|
|
||||||
defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.animateGrowthGauge(
|
await this.animateGrowthGauge(
|
||||||
@@ -16596,8 +16691,8 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
if (result.baseDefeated) {
|
if (result.baseDefeated) {
|
||||||
return { icon: 'counter', label: '퇴각', tone: 0xd8732c };
|
return { icon: 'counter', label: '퇴각', tone: 0xd8732c };
|
||||||
}
|
}
|
||||||
if (result.bondChain) {
|
if (result.bondChainAttempt) {
|
||||||
return { icon: 'leadership', label: `추격 +${result.bondChain.damage}`, tone: palette.gold };
|
return { icon: 'leadership', label: `판정 ${result.bondChainAttempt.rate}%`, tone: palette.gold };
|
||||||
}
|
}
|
||||||
if (this.combatCounterWillTrigger(result)) {
|
if (this.combatCounterWillTrigger(result)) {
|
||||||
return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 };
|
return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 };
|
||||||
@@ -18308,7 +18403,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectPartnerIds = result.bondChain ? [result.bondChain.partnerId] : result.bondPartnerIds;
|
const effectPartnerIds = result.bondChainAttempt ? [result.bondChainAttempt.partnerId] : result.bondPartnerIds;
|
||||||
const partnerUnits = effectPartnerIds
|
const partnerUnits = effectPartnerIds
|
||||||
.map((partnerId) => battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0))
|
.map((partnerId) => battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0))
|
||||||
.filter((unit): unit is UnitData => Boolean(unit));
|
.filter((unit): unit is UnitData => Boolean(unit));
|
||||||
@@ -18343,22 +18438,28 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
line.lineTo(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.28);
|
line.lineTo(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.28);
|
||||||
line.strokePath();
|
line.strokePath();
|
||||||
}
|
}
|
||||||
const partnerLabel = result.bondExtendedRange ? `${partner.name} · 2칸` : `${partner.name} ♥ 공명`;
|
const partnerLabel = result.bondChainAttempt
|
||||||
|
? `${partner.name} · 판정 후보`
|
||||||
|
: result.bondExtendedRange
|
||||||
|
? `${partner.name} · 2칸`
|
||||||
|
: `${partner.name} ♥ 공명`;
|
||||||
objects.push(this.createBondBadge(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.86, partnerLabel, 35));
|
objects.push(this.createBondBadge(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.86, partnerLabel, 35));
|
||||||
});
|
});
|
||||||
objects.push(line);
|
objects.push(line);
|
||||||
objects.push(this.createBondBadge(
|
objects.push(this.createBondBadge(
|
||||||
attackerView.sprite.x,
|
attackerView.sprite.x,
|
||||||
attackerView.sprite.y - this.layout.tileSize * 0.96,
|
attackerView.sprite.y - this.layout.tileSize * 0.96,
|
||||||
result.bondChain
|
this.bondChainMapCueLabel(result),
|
||||||
? `공명 추격 · ${result.bondChain.partnerName}`
|
|
||||||
: result.bondExtendedRange
|
|
||||||
? '삼재진 · 2칸 공명'
|
|
||||||
: '공명 강화',
|
|
||||||
36
|
36
|
||||||
));
|
));
|
||||||
|
|
||||||
this.tweens.add({ targets: objects, alpha: 0, y: '-=12', duration: 620, ease: 'Sine.easeOut' });
|
this.tweens.add({
|
||||||
|
targets: objects,
|
||||||
|
alpha: 0,
|
||||||
|
y: '-=12',
|
||||||
|
duration: this.scaledBattleDuration(620, 150),
|
||||||
|
ease: 'Sine.easeOut'
|
||||||
|
});
|
||||||
return this.delay(660).then(() => objects.forEach((object) => object.destroy()));
|
return this.delay(660).then(() => objects.forEach((object) => object.destroy()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18424,6 +18525,95 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return this.delay(580);
|
return this.delay(580);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bondChainMotion(partner?: UnitData): 'melee' | 'ranged' {
|
||||||
|
return partner && (partner.classKey === 'archer' || this.attackRange(partner) > 1) ? 'ranged' : 'melee';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showBondChainJudgementEffect(result: CombatResult, textX: number, textY: number, depth: number) {
|
||||||
|
const attempt = result.bondChainAttempt;
|
||||||
|
if (!attempt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = battleUnits.find((unit) => unit.id === attempt.partnerId);
|
||||||
|
const succeeded = attempt.succeeded && Boolean(result.bondChain);
|
||||||
|
const status: BondChainJudgementSnapshot['status'] = succeeded ? 'success' : 'failed';
|
||||||
|
const motion = this.bondChainMotion(partner);
|
||||||
|
const width = 372;
|
||||||
|
const height = 66;
|
||||||
|
const tone = succeeded ? palette.gold : 0xa58f69;
|
||||||
|
const title = `공명 판정 ${attempt.rate}%`;
|
||||||
|
const detail = `${attempt.partnerName} · ${succeeded ? '호흡 일치' : '호흡 불발'}`;
|
||||||
|
const card = this.trackCombatObject(this.add.container(textX, textY));
|
||||||
|
card.setDepth(depth + 4);
|
||||||
|
card.setAlpha(0);
|
||||||
|
card.setScale(0.92);
|
||||||
|
|
||||||
|
const background = this.add.rectangle(0, 0, width, height, 0x11151a, 0.97);
|
||||||
|
background.setStrokeStyle(2, tone, succeeded ? 0.94 : 0.68);
|
||||||
|
const sideBand = this.add.rectangle(-width / 2 + 5, 0, 10, height, tone, succeeded ? 0.96 : 0.62);
|
||||||
|
const iconFrame = this.add.circle(-width / 2 + 39, 0, 22, 0x0a0f14, 0.96);
|
||||||
|
iconFrame.setStrokeStyle(1, tone, 0.78);
|
||||||
|
const icon = this.createBattleUiIcon(-width / 2 + 39, 0, 'leadership', 39);
|
||||||
|
const titleText = this.add.text(-width / 2 + 72, -23, title, {
|
||||||
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
|
fontSize: '20px',
|
||||||
|
color: succeeded ? '#fff2b8' : '#d8c9ac',
|
||||||
|
fontStyle: '700',
|
||||||
|
stroke: '#120d08',
|
||||||
|
strokeThickness: 3
|
||||||
|
});
|
||||||
|
const detailText = this.add.text(-width / 2 + 73, 7, detail, {
|
||||||
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: succeeded ? '#ffdf7b' : '#aeb2b5',
|
||||||
|
fontStyle: '700'
|
||||||
|
});
|
||||||
|
card.add([background, sideBand, iconFrame, icon, titleText, detailText]);
|
||||||
|
|
||||||
|
this.bondChainJudgementLast = {
|
||||||
|
status,
|
||||||
|
partnerId: attempt.partnerId,
|
||||||
|
partnerName: attempt.partnerName,
|
||||||
|
rate: attempt.rate,
|
||||||
|
expectedDamage: attempt.expectedDamage,
|
||||||
|
damage: result.bondChain?.damage ?? 0,
|
||||||
|
defeated: result.bondChain?.defeated ?? false,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
presented: true,
|
||||||
|
completed: false,
|
||||||
|
motion,
|
||||||
|
bounds: { x: textX - width / 2, y: textY - height / 2, width, height }
|
||||||
|
};
|
||||||
|
|
||||||
|
soundDirector.playEffect('bond-resonance', {
|
||||||
|
volume: succeeded ? 0.28 : 0.16,
|
||||||
|
rate: succeeded ? 1.08 : 0.82,
|
||||||
|
stopAfterMs: 620
|
||||||
|
});
|
||||||
|
this.tweens.add({
|
||||||
|
targets: card,
|
||||||
|
alpha: 1,
|
||||||
|
scale: 1,
|
||||||
|
y: textY - 4,
|
||||||
|
duration: this.scaledBattleDuration(160, 80),
|
||||||
|
ease: 'Back.easeOut'
|
||||||
|
});
|
||||||
|
await this.delay(succeeded ? 250 : 430);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: card,
|
||||||
|
alpha: 0,
|
||||||
|
y: card.y - 8,
|
||||||
|
duration: this.scaledBattleDuration(150, 75),
|
||||||
|
ease: 'Sine.easeIn'
|
||||||
|
});
|
||||||
|
await this.delay(170);
|
||||||
|
if (card.active) {
|
||||||
|
card.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async showBondChainCombatEffect(
|
private async showBondChainCombatEffect(
|
||||||
result: CombatResult,
|
result: CombatResult,
|
||||||
defenderSprite: Phaser.GameObjects.Sprite,
|
defenderSprite: Phaser.GameObjects.Sprite,
|
||||||
@@ -18431,7 +18621,8 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
groundY: number,
|
groundY: number,
|
||||||
textX: number,
|
textX: number,
|
||||||
textY: number,
|
textY: number,
|
||||||
depth: number
|
depth: number,
|
||||||
|
onImpact?: () => void
|
||||||
) {
|
) {
|
||||||
const chain = result.bondChain;
|
const chain = result.bondChain;
|
||||||
const partner = chain ? battleUnits.find((unit) => unit.id === chain.partnerId) : undefined;
|
const partner = chain ? battleUnits.find((unit) => unit.id === chain.partnerId) : undefined;
|
||||||
@@ -18441,8 +18632,8 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
|
|
||||||
soundDirector.playEffect('bond-resonance', { volume: 0.3, rate: 1.08, stopAfterMs: 780 });
|
soundDirector.playEffect('bond-resonance', { volume: 0.3, rate: 1.08, stopAfterMs: 780 });
|
||||||
|
|
||||||
const entryX = defenderX - 230;
|
const motion = this.bondChainMotion(partner);
|
||||||
const strikeX = defenderX - 82;
|
const entryX = defenderX - (motion === 'ranged' ? 360 : 270);
|
||||||
const supporterSprite = this.trackCombatObject(
|
const supporterSprite = this.trackCombatObject(
|
||||||
this.add.sprite(
|
this.add.sprite(
|
||||||
entryX,
|
entryX,
|
||||||
@@ -18456,49 +18647,162 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.applyCombatSpriteDisplaySize(supporterSprite, partner);
|
this.applyCombatSpriteDisplaySize(supporterSprite, partner);
|
||||||
this.applyActionSpriteBlend(supporterSprite);
|
this.applyActionSpriteBlend(supporterSprite);
|
||||||
|
|
||||||
const banner = this.trackCombatObject(this.add.text(
|
const bannerWidth = 388;
|
||||||
textX,
|
const bannerHeight = 68;
|
||||||
textY,
|
const banner = this.trackCombatObject(this.add.container(textX, textY));
|
||||||
`공명 추격 ${chain.partnerName}\n추가 피해 +${chain.damage}`,
|
banner.setDepth(depth + 3);
|
||||||
|
banner.setAlpha(0);
|
||||||
|
banner.setScale(0.94);
|
||||||
|
const bannerBg = this.add.rectangle(0, 0, bannerWidth, bannerHeight, 0x11151a, 0.98);
|
||||||
|
bannerBg.setStrokeStyle(2, palette.gold, 0.94);
|
||||||
|
const bannerBand = this.add.rectangle(-bannerWidth / 2 + 5, 0, 10, bannerHeight, palette.gold, 0.96);
|
||||||
|
const bannerIconFrame = this.add.circle(-bannerWidth / 2 + 39, 0, 22, 0x0a0f14, 0.96);
|
||||||
|
bannerIconFrame.setStrokeStyle(1, palette.gold, 0.82);
|
||||||
|
const bannerIcon = this.createBattleUiIcon(-bannerWidth / 2 + 39, 0, 'leadership', 39);
|
||||||
|
const bannerTitle = this.add.text(-bannerWidth / 2 + 72, -23, `공명 추격 · ${chain.partnerName}`, {
|
||||||
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
|
fontSize: '20px',
|
||||||
|
color: '#fff2b8',
|
||||||
|
fontStyle: '700',
|
||||||
|
stroke: '#2b1606',
|
||||||
|
strokeThickness: 3
|
||||||
|
});
|
||||||
|
const bannerDetail = this.add.text(-bannerWidth / 2 + 73, 7, `${motion === 'ranged' ? '원거리' : '근접'} 추격 준비`, {
|
||||||
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#ffdf7b',
|
||||||
|
fontStyle: '700'
|
||||||
|
});
|
||||||
|
banner.add([bannerBg, bannerBand, bannerIconFrame, bannerIcon, bannerTitle, bannerDetail]);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: [supporterSprite, banner],
|
||||||
|
alpha: 1,
|
||||||
|
duration: this.scaledBattleDuration(120, 70),
|
||||||
|
ease: 'Sine.easeOut'
|
||||||
|
});
|
||||||
|
this.tweens.add({
|
||||||
|
targets: banner,
|
||||||
|
scale: 1,
|
||||||
|
y: textY - 5,
|
||||||
|
duration: this.scaledBattleDuration(180, 90),
|
||||||
|
ease: 'Back.easeOut'
|
||||||
|
});
|
||||||
|
await this.delay(130);
|
||||||
|
|
||||||
|
const followUpResult: CombatResult = {
|
||||||
|
...result,
|
||||||
|
action: 'attack',
|
||||||
|
usable: undefined,
|
||||||
|
attacker: partner,
|
||||||
|
expectedDamage: chain.damage,
|
||||||
|
previousDefenderHp: chain.previousDefenderHp,
|
||||||
|
damage: chain.damage,
|
||||||
|
hit: true,
|
||||||
|
critical: false,
|
||||||
|
isCounter: false,
|
||||||
|
baseDefeated: chain.defeated,
|
||||||
|
defeated: chain.defeated,
|
||||||
|
bondChainAttempt: undefined,
|
||||||
|
bondChain: undefined,
|
||||||
|
counter: undefined
|
||||||
|
};
|
||||||
|
const contactDelay = motion === 'ranged'
|
||||||
|
? this.scaledBattleDuration(150) + this.scaledBattleDuration(332, 90)
|
||||||
|
: this.scaledBattleDuration(115) + this.scaledBattleDuration(partner.classKey === 'cavalry' ? 170 : 205);
|
||||||
|
const motionPromise = this.playCombatActionMotion(
|
||||||
|
followUpResult,
|
||||||
|
supporterSprite,
|
||||||
|
defenderSprite,
|
||||||
|
entryX,
|
||||||
|
defenderX,
|
||||||
|
groundY,
|
||||||
|
depth + 1,
|
||||||
|
'west'
|
||||||
|
);
|
||||||
|
await new Promise<void>((resolve) => this.time.delayedCall(contactDelay, resolve));
|
||||||
|
|
||||||
|
onImpact?.();
|
||||||
|
this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5);
|
||||||
|
|
||||||
|
const finishTitle = chain.defeated ? `공명 격파 · ${chain.partnerName}` : `공명 추격 · ${chain.partnerName}`;
|
||||||
|
const finishDetail = `추격 -${chain.damage}`;
|
||||||
|
bannerTitle.setText(finishTitle);
|
||||||
|
bannerDetail.setText(finishDetail);
|
||||||
|
if (this.bondChainJudgementLast) {
|
||||||
|
this.bondChainJudgementLast.title = finishTitle;
|
||||||
|
this.bondChainJudgementLast.detail = finishDetail;
|
||||||
|
this.bondChainJudgementLast.damage = chain.damage;
|
||||||
|
this.bondChainJudgementLast.defeated = chain.defeated;
|
||||||
|
this.bondChainJudgementLast.motion = motion;
|
||||||
|
this.bondChainJudgementLast.bounds = {
|
||||||
|
x: textX - bannerWidth / 2,
|
||||||
|
y: textY - 5 - bannerHeight / 2,
|
||||||
|
width: bannerWidth,
|
||||||
|
height: bannerHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const damageText = this.trackCombatObject(this.add.text(
|
||||||
|
defenderX - 12,
|
||||||
|
groundY - 112,
|
||||||
|
chain.defeated ? `공명 격파\n추격 -${chain.damage}` : `추격 -${chain.damage}`,
|
||||||
{
|
{
|
||||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
fontSize: '20px',
|
fontSize: chain.defeated ? '26px' : '23px',
|
||||||
color: '#fff2b8',
|
color: chain.defeated ? '#fff2b8' : '#ffdf7b',
|
||||||
fontStyle: '700',
|
fontStyle: '700',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
stroke: '#2b1606',
|
stroke: chain.defeated ? '#5a180f' : '#2b1606',
|
||||||
strokeThickness: 5,
|
strokeThickness: 5,
|
||||||
lineSpacing: 2
|
lineSpacing: -2
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
banner.setOrigin(0.5);
|
damageText.setOrigin(0.5);
|
||||||
banner.setDepth(depth + 3);
|
damageText.setDepth(depth + 6);
|
||||||
banner.setScale(0.88);
|
damageText.setScale(0.82);
|
||||||
banner.setAlpha(0);
|
this.tweens.add({
|
||||||
|
targets: damageText,
|
||||||
|
scale: chain.defeated ? 1.18 : 1,
|
||||||
|
y: damageText.y - 14,
|
||||||
|
alpha: 0,
|
||||||
|
duration: this.scaledBattleDuration(chain.defeated ? 620 : 480, 180),
|
||||||
|
ease: 'Back.easeOut'
|
||||||
|
});
|
||||||
|
|
||||||
const trail = this.trackCombatObject(this.add.graphics());
|
if (chain.defeated) {
|
||||||
trail.setDepth(depth - 1);
|
this.cameras.main.shake(this.scaledBattleDuration(180, 110), 0.008);
|
||||||
trail.lineStyle(7, 0xffdf7b, 0.7);
|
const finishRing = this.trackCombatObject(this.add.circle(defenderX - 24, groundY - 48, 24));
|
||||||
trail.beginPath();
|
finishRing.setDepth(depth + 5);
|
||||||
trail.moveTo(entryX, groundY - 40);
|
finishRing.setStrokeStyle(7, 0xffdf7b, 0.94);
|
||||||
trail.lineTo(defenderX - 36, groundY - 40);
|
const finishStar = this.trackCombatObject(this.add.star(defenderX - 24, groundY - 48, 10, 18, 62, 0xfff0b8, 0.96));
|
||||||
trail.strokePath();
|
finishStar.setDepth(depth + 4);
|
||||||
trail.setAlpha(0);
|
this.tweens.add({
|
||||||
|
targets: finishRing,
|
||||||
|
scale: 2.2,
|
||||||
|
alpha: 0,
|
||||||
|
duration: this.scaledBattleDuration(420, 140),
|
||||||
|
ease: 'Sine.easeOut'
|
||||||
|
});
|
||||||
|
this.tweens.add({
|
||||||
|
targets: finishStar,
|
||||||
|
scale: 1.8,
|
||||||
|
angle: 110,
|
||||||
|
alpha: 0,
|
||||||
|
duration: this.scaledBattleDuration(390, 140),
|
||||||
|
ease: 'Sine.easeOut'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.tweens.add({ targets: [supporterSprite, banner, trail], alpha: 1, duration: 120, ease: 'Sine.easeOut' });
|
await motionPromise;
|
||||||
this.tweens.add({ targets: banner, scale: 1, y: textY - 5, duration: 180, ease: 'Back.easeOut' });
|
this.tweens.add({
|
||||||
this.tweens.add({ targets: supporterSprite, x: strikeX, duration: 230, ease: 'Cubic.easeIn' });
|
targets: [supporterSprite, banner],
|
||||||
await this.delay(240);
|
alpha: 0,
|
||||||
|
y: '-=8',
|
||||||
const impact = this.trackCombatObject(this.add.star(defenderX - 24, groundY - 46, 8, 18, 52, 0xfff0b8, 0.94));
|
duration: this.scaledBattleDuration(180, 90),
|
||||||
impact.setDepth(depth + 2);
|
ease: 'Sine.easeIn'
|
||||||
defenderSprite.setTintFill(0xffe0a3);
|
});
|
||||||
this.tweens.add({ targets: impact, scale: 1.55, angle: 90, alpha: 0, duration: 300, ease: 'Sine.easeOut' });
|
await this.delay(200);
|
||||||
this.tweens.add({ targets: defenderSprite, x: defenderX + 14, duration: 90, yoyo: true, ease: 'Sine.easeOut' });
|
|
||||||
this.tweens.add({ targets: supporterSprite, x: entryX + 30, alpha: 0, duration: 260, delay: 80, ease: 'Sine.easeIn' });
|
|
||||||
await this.delay(220);
|
|
||||||
defenderSprite.clearTint();
|
|
||||||
await this.delay(180);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) {
|
private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) {
|
||||||
@@ -20293,12 +20597,17 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
|
|
||||||
view.sprite.setTintFill(0xffe0a3);
|
view.sprite.setTintFill(0xffe0a3);
|
||||||
this.time.delayedCall(120, () => {
|
this.time.delayedCall(120, () => {
|
||||||
if (unit.hp <= 0) {
|
if (displayedHp <= 0) {
|
||||||
this.applyDefeatedStyle(unit);
|
this.applyDefeatedStyle(unit);
|
||||||
} else {
|
} else {
|
||||||
view.sprite.clearTint();
|
view.sprite.clearTint();
|
||||||
this.syncUnitMotion(unit, view);
|
this.syncUnitMotion(unit, view);
|
||||||
this.applyUnitLegibilityStyle(unit, view);
|
// A successful resonance follow-up may already have reduced the real HP
|
||||||
|
// to zero while the map is intentionally showing the post-base-attack HP.
|
||||||
|
// Keep that defender visible until the pursuit finisher has played.
|
||||||
|
if (unit.hp > 0) {
|
||||||
|
this.applyUnitLegibilityStyle(unit, view);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -20351,7 +20660,11 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectLine = [
|
const effectLine = [
|
||||||
result.bondChain ? `공명 추격 ${result.bondChain.partnerName} +${result.bondChain.damage}` : '',
|
result.bondChain
|
||||||
|
? `${result.bondChain.defeated ? '공명 격파' : '공명 추격'} ${result.bondChain.partnerName} +${result.bondChain.damage}`
|
||||||
|
: result.bondChainAttempt
|
||||||
|
? `공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발`
|
||||||
|
: '',
|
||||||
this.statusEffectMapText(result.statusApplied),
|
this.statusEffectMapText(result.statusApplied),
|
||||||
this.combatSortieContributionText(result),
|
this.combatSortieContributionText(result),
|
||||||
this.combatInitiativeContributionText(result)
|
this.combatInitiativeContributionText(result)
|
||||||
@@ -20377,7 +20690,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
private combatMapDamageTitle(result: CombatResult) {
|
private combatMapDamageTitle(result: CombatResult) {
|
||||||
if (result.bondChain) {
|
if (result.bondChain) {
|
||||||
const totalDamage = result.damage + result.bondChain.damage;
|
const totalDamage = result.damage + result.bondChain.damage;
|
||||||
return result.bondChain.defeated ? `공명 퇴각 -${totalDamage}` : `합동 피해 -${totalDamage}`;
|
return result.bondChain.defeated ? `공명 격파 -${totalDamage}` : `합동 피해 -${totalDamage}`;
|
||||||
}
|
}
|
||||||
const damage = `-${result.damage}`;
|
const damage = `-${result.damage}`;
|
||||||
if (result.baseDefeated) {
|
if (result.baseDefeated) {
|
||||||
@@ -22103,6 +22416,15 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bondChainJudgementDebugState() {
|
||||||
|
return this.bondChainJudgementLast
|
||||||
|
? {
|
||||||
|
...this.bondChainJudgementLast,
|
||||||
|
bounds: this.bondChainJudgementLast.bounds ? { ...this.bondChainJudgementLast.bounds } : null
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private sortieOrderHudDebugState() {
|
private sortieOrderHudDebugState() {
|
||||||
const snapshot = this.sortieOrderHudSnapshot;
|
const snapshot = this.sortieOrderHudSnapshot;
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
@@ -22268,6 +22590,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
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(),
|
bondChainReadyPreview: this.bondChainReadyDebugState(),
|
||||||
|
bondChainJudgement: this.bondChainJudgementDebugState(),
|
||||||
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