You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/script/battle/ai/runtime/AiPassiveCdHost.ts

265 lines
9.7 KiB

3 weeks ago
import { MapCellKind } from 'db://assets/mx/module/merge';
import type { DailyShopParsed } from '../../../manager/DailyShopTypes';
import { BattleBuffCarryStore } from '../../../manager/battleBuff/BattleBuffCarryStore';
import {
passiveBuffUsesBattleGameCd,
readBattleBuffPassiveCdSec,
readBattleBuffPassiveSpawnWeapon2Id,
} from '../../../manager/battleBuff/BattleBuffPassiveSkillUtil';
import { BattleBuffFunctionType } from '../../../manager/battleBuff/BattleBuffTypes';
import type { BattleBagSession } from '../../bag/BattleBagSession';
import { PEACH_TREE_WEAPON2_ID } from '../../bag/BagPrepConfig';
import type { ProjectBattleHandle } from '../../runtime/BattleRuntime';
import { aiOp, aiWarn } from './AiLog';
import { AiHeadlessBagHost } from './AiHeadlessBagHost';
/** 单槽 CD 被动状态 */
type AiPassiveCdSlot = {
/** 携带解析结果 */
parsed: DailyShopParsed;
/** CD 总秒 */
cdTotalSec: number;
/** CD 剩余秒 */
cdLeftSec: number;
/** 本轮 CD 结束是否已尝试触发 */
triggerConsumed: boolean;
};
/** AI CD 被动 UI 快照 */
export type AiPassiveCdUiSlot = {
/** buff id */
buffId: number;
/** CD 剩余秒 */
cdLeftSec: number;
/** CD 总秒 */
cdTotalSec: number;
};
/**
2 weeks ago
* AI 局内 CD 被动:蟠桃树(3)。
3 weeks ago
* 无 UI,CD 满直接写入 AI bag(不播飞行)。
*/
export class AiPassiveCdHost {
private _bagHost: AiHeadlessBagHost | null = null;
private _battle: ProjectBattleHandle | null = null;
private _onBoardChanged: (() => void) | null = null;
private readonly _slots: AiPassiveCdSlot[] = [];
/**
* 绑定并按 CarryStore 重建 CD 槽。
* @param bagHost 无头背包
* @param battle 战斗
* @param onBoardChanged 盘面变更回调
*/
bind(bagHost: AiHeadlessBagHost, battle: ProjectBattleHandle, onBoardChanged: () => void): void {
this.dispose();
this._bagHost = bagHost;
this._battle = battle;
this._onBoardChanged = onBoardChanged;
this._rebuildSlots();
}
/** 解除绑定 */
dispose(): void {
this._slots.length = 0;
this._bagHost = null;
this._battle = null;
this._onBoardChanged = null;
}
/**
* 帧更新 CD。
* @param dtSec 帧间隔
*/
tick(dtSec: number): void {
if (!this._bagHost || !this._battle || this._slots.length <= 0) return;
const dt = Math.max(0, Number(dtSec) || 0);
for (let i = 0; i < this._slots.length; i++) {
this._tickSlot(this._slots[i], i, dt);
}
}
/**
* CD 被动 UI 快照。
* @returns 槽列表
*/
snapshotUiSlots(): AiPassiveCdUiSlot[] {
const out: AiPassiveCdUiSlot[] = [];
for (let i = 0; i < this._slots.length; i++) {
const slot = this._slots[i];
out.push({
buffId: slot.parsed.id,
cdLeftSec: slot.cdLeftSec,
cdTotalSec: slot.cdTotalSec,
});
}
return out;
}
/** 从 CarryStore 重建有 CD 的被动槽 */
private _rebuildSlots(): void {
this._slots.length = 0;
const passives = BattleBuffCarryStore.getPassives(AiHeadlessBagHost.AI_PLAYER_ID);
for (let i = 0; i < passives.length; i++) {
const parsed = passives[i];
const row = parsed.row as ITableBattlebuff;
const ft = Math.floor(Number(row.functiontype) || 0);
if (!passiveBuffUsesBattleGameCd(ft)) continue;
2 weeks ago
if (ft !== BattleBuffFunctionType.PeachTree) {
3 weeks ago
aiWarn(`AI CD 被动未接线 functiontype=${ft} id=${parsed.id} name=${parsed.name}`);
continue;
}
const cdSec = Math.max(0, Math.floor(Number(parsed.cd) || 0) || readBattleBuffPassiveCdSec(row));
this._slots.push({
parsed,
cdTotalSec: cdSec,
cdLeftSec: cdSec,
triggerConsumed: false,
});
aiOp(`AI CD被动槽#${this._slots.length - 1} id=${parsed.id} ${parsed.name} ft=${ft} cd=${cdSec}s`);
}
}
/**
* 单槽倒计时与触发。
* @param slot 槽
* @param slotIndex 下标
* @param dt 帧间隔
*/
private _tickSlot(slot: AiPassiveCdSlot, slotIndex: number, dt: number): void {
if (slot.cdLeftSec > 0) {
slot.cdLeftSec = Math.max(0, slot.cdLeftSec - dt);
return;
}
if (slot.triggerConsumed) return;
slot.triggerConsumed = true;
const handled = this._onCdReady(slot, slotIndex);
if (!handled) {
this._restartCd(slot);
}
}
/**
2 weeks ago
* CD 结束:按 functiontype 产字。
3 weeks ago
* @param slot 槽
* @param slotIndex 下标
* @returns 是否已受理(含成功落子);false 则立刻重开 CD
*/
private _onCdReady(slot: AiPassiveCdSlot, slotIndex: number): boolean {
const row = slot.parsed.row as ITableBattlebuff;
const ft = Math.floor(Number(row.functiontype) || 0);
if (ft === BattleBuffFunctionType.PeachTree) {
return this._trySpawnPeachTree(row, slot, slotIndex);
}
return false;
}
/**
* 蟠桃树:空 Dig 优先否则空 Put,直接落字。
* Dig 落子保持 Dig;Put 落子 Put→Dig。peer 按落子后 kind 对齐(禁止误 Dig→Put)。
* @param row 表行
* @param slot 槽
* @param slotIndex 下标
*/
private _trySpawnPeachTree(row: ITableBattlebuff, slot: AiPassiveCdSlot, slotIndex: number): boolean {
const bag = this._bagHost?.bagSession;
if (!bag) {
aiWarn('AI 蟠桃树失败:bag 未就绪');
return false;
}
const weapon2Id = readBattleBuffPassiveSpawnWeapon2Id(row);
if (weapon2Id <= 0) return false;
const gridIndex = bag.findPassiveDigSpawnGrid();
if (gridIndex == null) {
// 与玩家一致:无空位不消费 CD(返回 false → 立刻重开,下帧再试)
// 但玩家返回 false 会 restartCd,即无空位也重开满 CD。对齐玩家:restart。
aiOp(`AI 蟠桃树无空位 slot#${slotIndex},重开 CD`);
return false;
}
const beforeKind = bag.merge.map.getCellKind(gridIndex);
const peerBefore = this._peerCellKind(bag, gridIndex);
const reason = bag.grantPassiveTileOnDigGrid(gridIndex, weapon2Id);
if (reason) {
aiWarn(`AI 蟠桃树落子拒绝 reason=${reason} map[${gridIndex}] w2=${weapon2Id}`);
return false;
}
const afterKind = bag.merge.map.getCellKind(gridIndex);
this._syncPeerCellKind(bag, gridIndex, afterKind);
const peerAfter = this._peerCellKind(bag, gridIndex);
const tile = bag.merge.map.getTile(gridIndex);
aiOp(
`[蟠桃同步] 被动产字 map[${gridIndex}] w2=${weapon2Id} slot#${slotIndex}`
+ ` before=${beforeKind} after=${afterKind}`
+ ` peerBefore=${peerBefore} peerAfter=${peerAfter}`
+ ` hasTile=${!!tile} lv=${tile?.level ?? 0}`,
);
if (afterKind !== MapCellKind.Dig) {
aiWarn(`[蟠桃同步] 产字后应为 Dig map[${gridIndex}] after=${afterKind}`);
}
if (peerAfter !== afterKind) {
aiWarn(`[蟠桃同步] peer 仍不一致 map[${gridIndex}] ai=${afterKind} peer=${peerAfter}`);
}
this._warnPeachPeerMismatch(`afterSpawn:slot#${slotIndex}`);
this._onBoardChanged?.();
this._restartCd(slot);
return true;
}
/**
* 读取本机 bag 对应格 kind。
* @param bag AI bag
* @param gridIndex 格
*/
private _peerCellKind(bag: BattleBagSession, gridIndex: number): MapCellKind | null {
const peer = this._battle?.bagSession;
if (!peer || peer === bag) return null;
return peer.merge.map.getCellKind(gridIndex);
}
/**
* 将本机 merge 格语义对齐到 AI 落子后的 kind,避免双 bag 分叉。
* @param bag AI bag
* @param gridIndex 格
* @param kind 落子后 kind
*/
private _syncPeerCellKind(bag: BattleBagSession, gridIndex: number, kind: MapCellKind | null): void {
const peer = this._battle?.bagSession;
if (!peer || peer === bag) return;
bag.alignPeerCellKind(peer, gridIndex, kind);
}
/**
* 扫描 AI 盘面蟠桃与本机 kind;不一致则 warn。
* @param tag 触发点
*/
private _warnPeachPeerMismatch(tag: string): void {
const bag = this._bagHost?.bagSession;
const peer = this._battle?.bagSession;
if (!bag || !peer || peer === bag) return;
const indices = bag.merge.map.gridIndices;
for (let i = 0; i < indices.length; i++) {
const gridIndex = indices[i];
const tile = bag.merge.map.getTile(gridIndex);
if (!tile) continue;
// 仅看 weapon2=102 蟠桃树(与 WeaponCompose keepDig 一致,避免循环依赖读表失败漏报)
if (tile.weapon2Id !== PEACH_TREE_WEAPON2_ID) continue;
const aiKind = bag.merge.map.getCellKind(gridIndex);
const peerKind = peer.merge.map.getCellKind(gridIndex);
if (aiKind === MapCellKind.Dig && peerKind === MapCellKind.Dig) continue;
aiWarn(
`[蟠桃同步] 不一致 ${tag} map[${gridIndex}]`
+ ` ai=${aiKind} peer=${peerKind} w2=${tile.weapon2Id} lv=${tile.level ?? 1}`,
);
}
}
/**
* 重启 CD。
* @param slot 槽
*/
private _restartCd(slot: AiPassiveCdSlot): void {
slot.cdLeftSec = slot.cdTotalSec;
slot.triggerConsumed = false;
}
}