import { Label, Layout, Node, Sprite, UITransform, Vec3, isValid } from 'cc'; import { SpriteLoad } from 'db://assets/mx/components/SpriteLoad'; import { TableNames } from '../../../game/ConfigTableData'; import { GBundle, GPath } from '../../../game/ConfigRes'; import { ConstNumType } from '../../../game/GameDataEnums'; import { MapGrid } from './MapGrid'; /** 解锁条件气泡预制体路径 */ const LOCK_TIP_PATH = 'prefab/'; /** 解锁条件气泡预制体名 */ const LOCK_TIP_NAME = '格子锁'; /** 预制体上显示数字的子节点名 */ const LOCK_TIP_NUM = 'num'; /** 动态条件图标子节点名前缀 */ const LOCK_TIP_COND = 'cond_'; /** 三选一宝箱提示子节点 */ const LOCK_TIP_BOX = 'box'; /** 条件图标占位尺寸(与 icons/img 五行图接近) */ const COND_ICON_SIZE = 22; /** 气泡世界坐标转本地时复用 */ const _tipWorld = new Vec3(); /** 气泡本地坐标复用 */ const _tipLocal = new Vec3(); /** herotype → 五行图标名(1金 2木 3水 4火 5土) */ const HERO_TYPE_ICON: string[] = ['', '金', '木', '水', '火', '土']; /** * 拖英雄推进区域解锁的结果。 * skip:不是解锁格,走原落子。 * blocked:还没轮到、条件没随出来,或英雄类型/等级不符。 * unlocked:英雄符合当前条件,整区可放置文字。 */ export type AreaUnlockDrag = 'skip' | 'blocked' | 'unlocked'; /** 拖上格子的英雄:类型与表等级 */ export interface AreaUnlockHero { /** Hero.herotype */ heroType: number; /** Hero.level */ level: number; } /** 单阵营解锁进度(与共享区域 ID 列表对齐) */ interface CampUnlockTrack { /** CampAreaID */ camp: number; /** 各区域是否已解锁 */ done: boolean[]; /** 各区域是否已随机条件 */ rolled: boolean[]; /** 各区域需求英雄类型 */ heroTypes: number[]; /** 各区域需求等级(含段位加值) */ needLevels: number[]; /** 段位等级加值 Rank.levelvalue */ levelValue: number; /** 是否已绑定关卡加值 */ levelReady: boolean; } /** * 地图解锁区域。 * 区域 ID 从小到大依次解锁;进度与条件按 CampAreaID 隔离(镜像图双方互不影响)。 * 当前区域从关卡 unlockland1-11 按权重随机一条条件,等级加上该阵营段位 levelvalue。 */ export class MapAreaUnlock { /** 地图上出现过的区域 ID(双方共享序号,进度按阵营分轨) */ private _ids: number[] = []; /** 各阵营解锁轨 */ private _tracks: CampUnlockTrack[] = []; private _grids: MapGrid[] = []; private _levelId = 0; private _warnedSprite = false; /** 按阵营各挂一个条件气泡 */ private _tips: Node[] = []; /** 与当前展示中的 _tips 对齐的阵营 CampAreaID */ private _tipCamps: number[] = []; /** * 绑定本局关卡与某阵营段位等级加值。 * @param levelId 关卡表 id * @param levelValue 段位等级加值 * @param campAreaId 阵营 CampAreaID(1 本机 / 2 AI) */ setLevel(levelId: number, levelValue: number, campAreaId: number): void { const camp = Math.floor(Number(campAreaId) || 0); if (camp <= 0) { console.warn('[MapAreaUnlock] setLevel 需要有效 CampAreaID', campAreaId); return; } this._levelId = levelId; const track = this._ensureTrack(camp); track.levelValue = levelValue; track.levelReady = true; this._rollCurrent(track); // 镜像图另一阵营若尚未绑定段位,先用同一加值出条件气泡(AI bind 后会覆盖 camp=2) for (let i = 0; i < this._tracks.length; i++) { const other = this._tracks[i]; if (other.camp === camp || other.levelReady) continue; other.levelValue = levelValue; other.levelReady = true; this._rollCurrent(other); } this.applyVisual(); } /** 归还条件气泡(卸地图前调用) */ clear(): void { this._releaseTips(); } /** * 按当前格子重建区域,并刷新等待解锁的显示。已解锁和已随机的条件按「阵营+区域 ID」保留。 * @param grids 地图格子 */ sync(grids: MapGrid[]): void { const prevIds = this._ids; const prevTracks = this._tracks; this._grids = grids; this._ids = []; this._tracks = []; this._warnedSprite = false; for (let i = 0; i < grids.length; i++) { const grid = grids[i]; if (!grid?.isCanBeDig || grid.unlockAreaId <= 0) continue; this._touchAreaId(grid.unlockAreaId); this._ensureTrack(grid.CampAreaID | 0); } this._sortAreas(); this._restoreTracks(prevIds, prevTracks); for (let i = 0; i < this._tracks.length; i++) { this._rollCurrent(this._tracks[i]); } this.applyVisual(); } /** * 某阵营当前正在解锁的区域 ID;没有则 0。 * @param campAreaId 阵营 CampAreaID */ currentId(campAreaId: number): number { const track = this._trackOf(campAreaId); if (!track) return 0; for (let i = 0; i < this._ids.length; i++) { if (!track.done[i]) return this._ids[i]; } return 0; } /** * 某阵营已完成解锁次数。 * @param campAreaId 阵营 CampAreaID */ completedCount(campAreaId: number): number { const track = this._trackOf(campAreaId); if (!track) return 0; let n = 0; for (let i = 0; i < track.done.length; i++) { if (track.done[i]) n++; } return n; } /** * 拖到当前解锁区域是否满足类型与等级(不改状态)。 * 类型 0 表示任意 herotype,等级须达到配置等级加段位加值。 */ canDrag(grid: MapGrid, hero: AreaUnlockHero | null): boolean { return this._probe(grid, hero) === 'unlocked'; } /** * 某阵营当前格子锁气泡(用于解锁引导连线终点)。 * @param campAreaId 阵营 CampAreaID */ tipOf(campAreaId: number): Node | null { const camp = Math.floor(Number(campAreaId) || 0); if (camp <= 0) return null; for (let i = 0; i < this._tipCamps.length; i++) { if (this._tipCamps[i] !== camp) continue; const tip = this._tips[i]; if (tip?.isValid && tip.active) return tip; return null; } return null; } /** * 把英雄拖到当前解锁区域。条件与 canDrag 相同,成功则解锁该阵营整区。 * @param grid 落点格 * @param hero 拖动的英雄;没有对应英雄时传 null */ tryDrag(grid: MapGrid, hero: AreaUnlockHero | null): AreaUnlockDrag { const hit = this._probe(grid, hero); if (hit !== 'unlocked') return hit; const camp = grid.CampAreaID | 0; const track = this._trackOf(camp); if (!track) { console.warn('[MapAreaUnlock] tryDrag 无阵营轨', camp); return 'blocked'; } const index = this._indexOf(this.currentId(camp)); if (index < 0) { console.warn('[MapAreaUnlock] tryDrag 找不到当前区域', camp); return 'blocked'; } track.done[index] = true; this._rollCurrent(track); return 'unlocked'; } /** * 某阵营下某区域的格子(不含对侧同 areaId)。 * @param areaId 解锁区域 ID * @param campAreaId 阵营 CampAreaID */ gridsOf(areaId: number, campAreaId: number): MapGrid[] { const camp = Math.floor(Number(campAreaId) || 0); const out: MapGrid[] = []; for (let i = 0; i < this._grids.length; i++) { const grid = this._grids[i]; if (!grid) continue; if (grid.unlockAreaId !== areaId) continue; if ((grid.CampAreaID | 0) !== camp) continue; out.push(grid); } return out; } /** * 刷新解锁区域皮:各阵营当前区域 Wait Unlocked;气泡按阵营分开放。 */ applyVisual(): void { for (let i = 0; i < this._grids.length; i++) { const grid = this._grids[i]; if (!grid) continue; const current = this.currentId(grid.CampAreaID | 0); const wait = grid.isAreaUnlock && current > 0 && grid.unlockAreaId === current; if (wait && !grid.waitUnlockedSprite && !this._warnedSprite) { console.warn('[MapAreaUnlock] 当前解锁区域缺少 Wait Unlocked', current, grid.Index); this._warnedSprite = true; } grid.waitingUnlock = wait; if (!wait && grid.isAreaUnlock) grid.updateGrid(); } this._refreshTips(); } /** * 解锁落点探测:skip 非解锁格;blocked 未轮到或类型/等级不符;unlocked 条件满足。 */ private _probe(grid: MapGrid, hero: AreaUnlockHero | null): AreaUnlockDrag { if (!grid?.isAreaUnlock) return 'skip'; const camp = grid.CampAreaID | 0; const track = this._trackOf(camp); if (!track) return 'blocked'; const current = this.currentId(camp); if (current <= 0 || grid.unlockAreaId !== current) return 'blocked'; const index = this._indexOf(current); if (index < 0 || !track.rolled[index]) return 'blocked'; if (!hero) return 'blocked'; const typeOk = track.heroTypes[index] === 0 || hero.heroType === track.heroTypes[index]; const levelOk = hero.level >= track.needLevels[index]; if (!typeOk || !levelOk) return 'blocked'; return 'unlocked'; } /** 各阵营当前待解锁区各自挂气泡 */ private _refreshTips(): void { const jobs: { camp: number; grids: MapGrid[]; num: number; heroType: number }[] = []; for (let t = 0; t < this._tracks.length; t++) { const track = this._tracks[t]; const current = this.currentId(track.camp); if (current <= 0) continue; const index = this._indexOf(current); if (index < 0 || !track.rolled[index]) continue; const group = this._waitingOfCamp(current, track.camp); if (group.length <= 0) continue; const heroType = track.heroTypes[index] | 0; const num = this._heroNumber(track.needLevels[index], heroType); if (!(num > 0)) continue; jobs.push({ camp: track.camp, grids: group, num, heroType }); } this._tipCamps.length = 0; if (jobs.length <= 0) { this._hideTips(); return; } if (!this._ensureTips(jobs.length, jobs[0].grids[0])) { this._hideTips(); return; } for (let i = 0; i < jobs.length; i++) { const tip = this._tips[i]; if (!tip?.isValid) { this._tipCamps.push(0); continue; } const numNode = tip.getChildByName(LOCK_TIP_NUM); const label = numNode?.getComponent(Label); if (!label) { console.warn('[MapAreaUnlock] 格子锁缺少 num Label'); tip.active = false; this._tipCamps.push(0); continue; } label.string = String(jobs[i].num); this._syncCondIcons(tip, jobs[i].heroType); const nextCount = this.completedCount(jobs[i].camp) + 1; this._syncBoxVisible(tip, this._isSelectTriggerCount(nextCount)); tip.getComponent(Layout)?.updateLayout(); this._placeTipCenter(tip, jobs[i].grids); tip.active = true; this._tipCamps.push(jobs[i].camp); } for (let i = jobs.length; i < this._tips.length; i++) { const tip = this._tips[i]; if (tip?.isValid) tip.active = false; } } /** * 下次解锁是否触发三选一时显示 box。 * @param tip 格子锁气泡 * @param show 是否显示 */ private _syncBoxVisible(tip: Node, show: boolean): void { const box = tip.getChildByName(LOCK_TIP_BOX); if (!box) { console.warn('[MapAreaUnlock] 格子锁缺少 box'); return; } box.active = show; } /** * Const27 是否包含该解锁次数(本文件内判断,避免依赖 SelectService 造成循环)。 * @param unlockCount 次数 */ private _isSelectTriggerCount(unlockCount: number): boolean { const n = Math.floor(Number(unlockCount) || 0); if (n <= 0) return false; const list = gg.data.table.getConstArray(ConstNumType.BattleSelectTriggerWaves); for (let i = 0; i < list.length; i++) { if (Math.floor(list[i]) === n) return true; } return false; } /** * 同步解锁条件五行图标:类型 0 不加载;1–5 各挂一张对应图标(锁与数字之间)。 * @param tip 格子锁气泡 * @param heroType 当前随机到的 Hero.herotype;0 为无属性限制 */ private _syncCondIcons(tip: Node, heroType: number): void { this._clearCondIcons(tip); const names = this._condIconNames(heroType); if (names.length <= 0) return; const numNode = tip.getChildByName(LOCK_TIP_NUM); let insertAt = numNode ? numNode.getSiblingIndex() : tip.children.length; for (let i = 0; i < names.length; i++) { const icon = this._makeCondIcon(names[i]); tip.insertChild(icon, insertAt); insertAt++; } } /** * 当前条件需要展示的五行图标名列表;无限制返回空。 * @param heroType Hero.herotype */ private _condIconNames(heroType: number): string[] { const type = Math.floor(Number(heroType) || 0); if (type <= 0) return []; const name = HERO_TYPE_ICON[type]; if (!name) { console.warn('[MapAreaUnlock] 未知解锁 herotype,无法加载条件图标', type); return []; } return [name]; } /** * 创建单张条件图标节点(SpriteLoad 从 icons 包加载)。 * @param name 金/木/水/火/土 */ private _makeCondIcon(name: string): Node { const node = new Node(LOCK_TIP_COND + name); node.layer = 33554432; const ut = node.addComponent(UITransform); ut.setContentSize(COND_ICON_SIZE, COND_ICON_SIZE); const sp = node.addComponent(Sprite); sp.sizeMode = Sprite.SizeMode.TRIMMED; node.addComponent(SpriteLoad).setSprite(GPath.ElementAttr(name), GBundle.Items); return node; } /** * 清除气泡上动态挂的条件图标(池化复用前必须清干净)。 * @param tip 格子锁气泡 */ private _clearCondIcons(tip: Node): void { if (!tip?.isValid) return; const children = tip.children; for (let i = children.length - 1; i >= 0; i--) { const child = children[i]; if (!child?.isValid) continue; if (child.name.indexOf(LOCK_TIP_COND) !== 0) continue; child.removeFromParent(); child.destroy(); } } /** * 某阵营当前待解锁格。 * @param current 当前区域 ID * @param camp 阵营 */ private _waitingOfCamp(current: number, camp: number): MapGrid[] { const out: MapGrid[] = []; for (let i = 0; i < this._grids.length; i++) { const grid = this._grids[i]; if (!grid?.isAreaUnlock || grid.unlockAreaId !== current) continue; if ((grid.CampAreaID | 0) !== camp) continue; out.push(grid); } return out; } /** * 取解锁等级对应的 Hero.number(界面展示用)。 * @param level 需求等级(已含段位加值) * @param heroType 需求英雄类型;0 为任意类型 */ private _heroNumber(level: number, heroType: number): number { const heroes = gg.data.table.getTableList(TableNames.Hero) ?? []; for (let i = 0; i < heroes.length; i++) { const hero = heroes[i]; if (Math.floor(Number(hero.level)) !== level) continue; if (heroType > 0 && Math.floor(Number(hero.herotype)) !== heroType) continue; const num = Math.floor(Number(hero.number)); if (num > 0) return num; } console.warn('[MapAreaUnlock] 找不到解锁等级对应 Hero.number', level, heroType); return 0; } /** * 确保气泡数量够用,并挂到地图根节点。 * @param count 需要的气泡数 * @param anchor 用于定位父节点的格子 */ private _ensureTips(count: number, anchor: MapGrid): boolean { const mapRoot = anchor.node?.parent?.parent; if (!mapRoot?.isValid) { console.warn('[MapAreaUnlock] 找不到地图根节点'); return false; } if (typeof gg === 'undefined' || !gg.res?.getNode) { console.warn('[MapAreaUnlock] gg.res 未就绪,无法加载格子锁'); return false; } while (this._tips.length < count) { const tip = gg.res.getNode(LOCK_TIP_PATH, LOCK_TIP_NAME, GBundle.BattleGame); if (!tip?.isValid) { console.warn('[MapAreaUnlock] 缺少格子锁预制体', LOCK_TIP_NAME); return this._tips.length > 0; } tip.parent = mapRoot; tip.setSiblingIndex(mapRoot.children.length - 1); this._tips.push(tip); } for (let i = 0; i < this._tips.length; i++) { const tip = this._tips[i]; if (!tip?.isValid) continue; if (tip.parent !== mapRoot) tip.parent = mapRoot; tip.setSiblingIndex(mapRoot.children.length - 1); } return true; } /** * 气泡放到该组格子世界坐标中心。 * @param tip 气泡节点 * @param grids 同阵营待解锁格子 */ private _placeTipCenter(tip: Node, grids: MapGrid[]): void { let sx = 0; let sy = 0; let sz = 0; let count = 0; for (let i = 0; i < grids.length; i++) { const node = grids[i]?.node; if (!node?.isValid) continue; const wp = node.worldPosition; sx += wp.x; sy += wp.y; sz += wp.z; count++; } if (count <= 0) { console.warn('[MapAreaUnlock] 待解锁格子节点无效,无法定位气泡'); return; } _tipWorld.set(sx / count, sy / count, sz / count); const parent = tip.parent; const ut = parent?.getComponent(UITransform); if (ut) { ut.convertToNodeSpaceAR(_tipWorld, _tipLocal); tip.setPosition(_tipLocal); return; } tip.setWorldPosition(_tipWorld); } /** 隐藏全部气泡(本局内复用节点) */ private _hideTips(): void { this._tipCamps.length = 0; for (let i = 0; i < this._tips.length; i++) { const tip = this._tips[i]; if (!tip?.isValid) continue; tip.active = false; } } /** 归还全部气泡到对象池 */ private _releaseTips(): void { for (let i = 0; i < this._tips.length; i++) { const tip = this._tips[i]; if (!tip || !isValid(tip)) continue; this._clearCondIcons(tip); if (tip['resKey']) gg.res.putNode(tip); else tip.destroy(); } this._tips.length = 0; this._tipCamps.length = 0; } /** * 给某阵营当前区域随机一条 unlockland 条件。 * @param track 阵营轨 */ private _rollCurrent(track: CampUnlockTrack): void { if (!track.levelReady) return; const id = this.currentId(track.camp); if (id <= 0) return; const index = this._indexOf(id); if (index < 0 || track.rolled[index]) return; const level = gg.data.table.getTableData(TableNames.Level, this._levelId); if (!level) { console.warn('[MapAreaUnlock] 找不到关卡', this._levelId); return; } const raw = this._landRaw(level, id); if (raw == null) return; const picked = this._pickLand(raw, id); if (!picked) return; track.heroTypes[index] = picked.heroType; track.needLevels[index] = picked.level + track.levelValue; track.rolled[index] = true; } /** * 按区域 ID 取 unlockland1-11。超出 1-11 时警告。 * @param level 关卡行 * @param areaId 解锁区域 ID */ private _landRaw(level: ITableLevel, areaId: number): string | null { switch (areaId) { case 1: return level.unlockland1; case 2: return level.unlockland2; case 3: return level.unlockland3; case 4: return level.unlockland4; case 5: return level.unlockland5; case 6: return level.unlockland6; case 7: return level.unlockland7; case 8: return level.unlockland8; case 9: return level.unlockland9; case 10: return level.unlockland10; case 11: return level.unlockland11; default: console.warn('[MapAreaUnlock] 解锁区域ID只支持 1-11', areaId); return null; } } /** * 按权重随机一条。格式:属性类型,等级,权重|属性类型,等级,权重。 * @param raw 配表原文 * @param areaId 解锁区域 ID */ private _pickLand(raw: string, areaId: number): { heroType: number; level: number } | null { const text = String(raw ?? '').trim(); if (!text) { console.warn('[MapAreaUnlock] 关卡未配置解锁条件', this._levelId, areaId); return null; } const types: number[] = []; const levels: number[] = []; const weights: number[] = []; const parts = text.split('|'); for (let i = 0; i < parts.length; i++) { const seg = parts[i].trim().split(','); if (seg.length !== 3) { console.warn('[MapAreaUnlock] 解锁条件格式应为 类型,等级,权重', areaId, parts[i]); continue; } const heroType = Number(seg[0]); const level = Number(seg[1]); const weight = Number(seg[2]); if (!Number.isFinite(heroType) || !Number.isFinite(level) || !Number.isFinite(weight)) { console.warn('[MapAreaUnlock] 解锁条件含非数字', areaId, parts[i]); continue; } if (weight <= 0) continue; types.push(heroType); levels.push(level); weights.push(weight); } if (types.length === 0) { console.warn('[MapAreaUnlock] 解锁条件没有有效权重', this._levelId, areaId, text); return null; } let total = 0; for (let i = 0; i < weights.length; i++) total += weights[i]; let roll = Math.random() * total; let pick = types.length - 1; for (let i = 0; i < weights.length; i++) { roll -= weights[i]; if (roll < 0) { pick = i; break; } } return { heroType: types[pick], level: levels[pick] }; } /** * 登记区域 ID(去重)。 * @param id 区域 ID */ private _touchAreaId(id: number): void { if (this._indexOf(id) >= 0) return; this._ids.push(id); } /** * 确保阵营轨存在;新建时按当前 _ids 长度填默认进度。 * @param camp CampAreaID */ private _ensureTrack(camp: number): CampUnlockTrack { const existing = this._trackOf(camp); if (existing) return existing; if (camp <= 0) { console.warn('[MapAreaUnlock] 无效 CampAreaID', camp); } const track: CampUnlockTrack = { camp, done: [], rolled: [], heroTypes: [], needLevels: [], levelValue: 0, levelReady: false, }; this._padTrack(track); this._tracks.push(track); return track; } /** * sync 后按旧轨恢复进度,并补齐数组长度。 * @param prevIds 同步前区域 ID * @param prevTracks 同步前阵营轨 */ private _restoreTracks(prevIds: number[], prevTracks: CampUnlockTrack[]): void { for (let t = 0; t < this._tracks.length; t++) { const track = this._tracks[t]; this._padTrack(track); let prev: CampUnlockTrack | null = null; for (let i = 0; i < prevTracks.length; i++) { if (prevTracks[i].camp === track.camp) { prev = prevTracks[i]; break; } } if (!prev) continue; track.levelValue = prev.levelValue; track.levelReady = prev.levelReady; for (let i = 0; i < this._ids.length; i++) { const old = this._oldIndex(this._ids[i], prevIds); if (old < 0) continue; track.done[i] = !!prev.done[old]; track.rolled[i] = !!prev.rolled[old]; track.heroTypes[i] = prev.heroTypes[old] ?? 0; track.needLevels[i] = prev.needLevels[old] ?? 0; } } } /** * 把轨内数组补到与 _ids 同长。 * @param track 阵营轨 */ private _padTrack(track: CampUnlockTrack): void { while (track.done.length < this._ids.length) track.done.push(false); while (track.rolled.length < this._ids.length) track.rolled.push(false); while (track.heroTypes.length < this._ids.length) track.heroTypes.push(0); while (track.needLevels.length < this._ids.length) track.needLevels.push(0); } /** 区域 ID 从小到大(同步重排轨内数组) */ private _sortAreas(): void { for (let i = 1; i < this._ids.length; i++) { const id = this._ids[i]; let j = i - 1; while (j >= 0 && this._ids[j] > id) { this._ids[j + 1] = this._ids[j]; j--; } this._ids[j + 1] = id; } } /** * @param camp CampAreaID */ private _trackOf(camp: number): CampUnlockTrack | null { for (let i = 0; i < this._tracks.length; i++) { if (this._tracks[i].camp === camp) return this._tracks[i]; } return null; } /** * @param id 区域 ID */ private _indexOf(id: number): number { for (let i = 0; i < this._ids.length; i++) { if (this._ids[i] === id) return i; } return -1; } /** * @param id 区域 ID * @param ids 旧列表 */ private _oldIndex(id: number, ids: number[]): number { for (let i = 0; i < ids.length; i++) { if (ids[i] === id) return i; } return -1; } }