diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index b43c7d6..9637ea4 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -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)}` ); + 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, { battleId: 'first-battle-zhuo-commandery', orderId: 'elite', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index d6b3c0d..1927d9c 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1599,6 +1599,32 @@ type LockedTargetPreview = { 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 = { usable: BattleUsable; user: UnitData; @@ -3273,6 +3299,9 @@ export class BattleScene extends Phaser.Scene { private targetingAction?: DamageCommand; private selectedUsable?: BattleUsable; private lockedTargetPreview?: LockedTargetPreview; + private bondChainReadyMarkerViews: BondChainReadyMarkerView[] = []; + private bondChainReadyPartnerMarkers = new Map(); + private bondChainReadyFocusedPreview?: BondChainReadyFocusedPreview; private turnText?: Phaser.GameObjects.Text; private objectiveTrackerText?: Phaser.GameObjects.Text; private objectiveTrackerSubText?: Phaser.GameObjects.Text; @@ -5706,6 +5735,7 @@ export class BattleScene extends Phaser.Scene { } }); + this.positionBondChainReadyMarkers(); this.positionObjectiveMapMarkers(); battleUnits.forEach((unit) => this.positionUnitView(unit)); 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) { const view = this.unitViews.get(unit.id); if (!view) { @@ -6084,13 +6132,17 @@ export class BattleScene extends Phaser.Scene { if (target && canTarget) { const preview = this.combatPreview(unit, target, action, usable); + const bondChainReady = this.bondChainReadyPreviewData(preview); const locked = this.isLockedDamageTarget(unit, target, action, usable); const counter = preview.counterAvailable ? '반격 주의' : '반격 없음'; 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( tile, this.previewAccentColor(preview), - `${target.name}: 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}% · ${counter} · ${terrain}`, + feedbackText, key, 0xd9503f, 0.16, @@ -6636,6 +6688,12 @@ export class BattleScene extends Phaser.Scene { private clearMarkers() { this.markers.forEach((marker) => marker.destroy()); this.markers = []; + this.bondChainReadyMarkerViews.forEach((view) => { + view.background.destroy(); + view.label.destroy(); + }); + this.bondChainReadyMarkerViews = []; + this.bondChainReadyPartnerMarkers.clear(); this.lockedTargetPreview = undefined; this.clearPointerFeedback(); } @@ -9145,8 +9203,14 @@ export class BattleScene extends Phaser.Scene { const actionIcon = usable ? this.usableIcon(usable) : this.commandIcon('attack', unit); const accent = usable ? this.usableAccentColor(usable) : palette.gold; let objectiveWarningNotice: TargetingGuideNotice | undefined; - const candidates = targets.slice(0, 3).map((target): TargetingGuideCandidate => { - const preview = this.combatPreview(unit, target, action, usable); + const orderedTargets = targets + .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 successLabel = this.previewSuccessLabel(preview); const counterLabel = preview.counterAvailable ? '반격 주의' : '반격 없음'; @@ -9159,15 +9223,19 @@ export class BattleScene extends Phaser.Scene { return { icon: endsBattleWithPendingBonus ? 'failure' : this.previewDamageIcon(preview), 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} 피해`, - secondary: warningText ?? `${preview.hitRate}% ${successLabel} · ${counterLabel}`, - tone: endsBattleWithPendingBonus ? 0xd8732c : this.previewAccentColor(preview), + secondary: warningText ?? bondChainReady?.text ?? `${preview.hitRate}% ${successLabel} · ${counterLabel}`, + tone: endsBattleWithPendingBonus ? 0xd8732c : bondChainReady ? palette.gold : this.previewAccentColor(preview), metrics: [ { 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) }, endsBattleWithPendingBonus ? { 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 } ] }; @@ -9186,7 +9254,7 @@ export class BattleScene extends Phaser.Scene { ], candidates, overflowCount: Math.max(0, targets.length - candidates.length), - footer: '표시된 적 선택 · 확정 전 예측 확인', + footer: hasBondChainReady ? '금색 공명 · 명중 후 생존 시 확률 판정' : '표시된 적 선택 · 확정 전 예측 확인', notice: notice ?? objectiveWarningNotice }); } @@ -9487,9 +9555,9 @@ export class BattleScene extends Phaser.Scene { chip.setOrigin(0); chip.setStrokeStyle(1, metric.tone, 0.54); 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', - fontSize: '11px', + fontSize: metric.value.length > 7 ? '9px' : '11px', color: '#f2e3bf', fontStyle: '700', fixedWidth: chipWidth - 30 @@ -9776,6 +9844,150 @@ export class BattleScene extends Phaser.Scene { marker.setFillStyle(isLocked ? 0xf2e3bf : baseColor, isLocked ? 0.36 : 0.2); 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) { @@ -9834,6 +10046,7 @@ export class BattleScene extends Phaser.Scene { } }); this.markers.push(marker, hitArea); + this.renderBondChainReadyTargetMarker(preview); }); } @@ -10023,15 +10236,6 @@ export class BattleScene extends Phaser.Scene { preview.terrainAffinity !== 100 ? `적성${preview.terrainAffinity}%` : '' ].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') { summaries.push({ icon: 'intelligence', @@ -10220,6 +10424,53 @@ export class BattleScene extends Phaser.Scene { 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) { const { panelX, panelWidth } = this.layout; 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.renderPreviewModifierCards(left + 10, y + 312, width - 20, this.previewModifierSummaries(preview), accent); - const badgeY = y + 382; - const maxBadgeRight = left + width - 10; - let badgeX = left + 10; - if (isStrategy) { - badgeX = this.renderPreviewBadge(badgeX, badgeY, 'success', `성공 ${preview.hitRate}%`, maxBadgeRight); - } - badgeX = this.renderPreviewBadge(badgeX, badgeY, 'critical', `치명 ${preview.criticalRate}%`, maxBadgeRight); - badgeX = this.renderPreviewBadge(badgeX, badgeY, 'advantage', preview.matchupLabel, maxBadgeRight); - let detailBadgeX = this.renderPreviewBadge(badgeX, badgeY, 'terrain', `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`, maxBadgeRight); - if (preview.bondDamageBonus > 0 && preview.bondLabel) { - detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight); - } - if (preview.bondChainPartnerName && preview.bondChainRate > 0) { - detailBadgeX = this.renderPreviewBadge( - detailBadgeX, - badgeY, - 'leadership', - `추격 ${preview.bondChainPartnerName} ${preview.bondChainRate}% +${preview.bondChainDamage}`, - maxBadgeRight - ); - } - if (preview.equipmentEffectLabels.length > 0) { - this.renderPreviewBadge(detailBadgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight); + const showsBondChainReady = this.renderBondChainReadyPreviewBanner(preview, left + 10, y + 372, width - 20, locked); + if (!showsBondChainReady) { + const badgeY = y + 382; + const maxBadgeRight = left + width - 10; + let badgeX = left + 10; + if (isStrategy) { + badgeX = this.renderPreviewBadge(badgeX, badgeY, 'success', `성공 ${preview.hitRate}%`, maxBadgeRight); + } + badgeX = this.renderPreviewBadge(badgeX, badgeY, 'critical', `치명 ${preview.criticalRate}%`, maxBadgeRight); + badgeX = this.renderPreviewBadge(badgeX, badgeY, 'advantage', preview.matchupLabel, maxBadgeRight); + let detailBadgeX = this.renderPreviewBadge(badgeX, badgeY, 'terrain', `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`, maxBadgeRight); + if (preview.bondDamageBonus > 0 && preview.bondLabel) { + detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight); + } + if (preview.equipmentEffectLabels.length > 0) { + this.renderPreviewBadge(detailBadgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight); + } } if (showsObjectiveWarning) { this.renderPreviewObjectiveWarning(left + 10, y + 408, width - 20, pendingBonusLabels); @@ -21751,6 +21996,7 @@ export class BattleScene extends Phaser.Scene { private clearSidePanelContent() { this.sidePanelObjects.forEach((object) => object.destroy()); this.sidePanelObjects = []; + this.bondChainReadyFocusedPreview = undefined; 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 }; } + 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() { const snapshot = this.sortieOrderHudSnapshot; if (!snapshot) { @@ -21940,6 +22267,7 @@ export class BattleScene extends Phaser.Scene { turnPromptMode: this.turnPromptMode ?? null, pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible), pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null, + bondChainReadyPreview: this.bondChainReadyDebugState(), markerCount: this.markers.length, threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length, enemyMoveMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'enemy-move').length,