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.
515 lines
20 KiB
515 lines
20 KiB
import { Node } from 'cc';
|
|
import { GBundle, GPath } from '../game/ConfigRes';
|
|
import { TableNames } from '../game/ConfigTableData';
|
|
import { GEvent } from '../../mx/module/event/GEvent';
|
|
import { SpriteLoad } from '../../mx/components/SpriteLoad';
|
|
import { ConstNumType, ItemNumType, StatisticsType } from '../game/GameDataEnums';
|
|
|
|
/** 匹配段位随机权重 Const id(对齐 ConstNumType.MatchRankWeight) */
|
|
const MATCH_RANK_WEIGHT_CONST_ID = ConstNumType.MatchRankWeight;
|
|
|
|
/** Rank.name 旧后缀(一~五)→ icons/level 阶位文件名 */
|
|
const RANK_SUB_TO_STAGE: Record<string, string> = {
|
|
'一': '初阶',
|
|
'二': '中阶',
|
|
'三': '高阶',
|
|
'四': '高阶',
|
|
'五': '圆满',
|
|
};
|
|
|
|
/** 已是 icons/level 用的阶位名 */
|
|
const RANK_STAGE_NAMES: Record<string, boolean> = {
|
|
'初阶': true,
|
|
'中阶': true,
|
|
'高阶': true,
|
|
'圆满': true,
|
|
};
|
|
|
|
/** Daily_Rank.reward_rank 解析项 */
|
|
export type RankRewardGroup = {
|
|
/** 奖励类型(2=成品 equip) */
|
|
type: number;
|
|
/** 道具/装备 id */
|
|
id: number;
|
|
/** 数量 */
|
|
num: number;
|
|
};
|
|
|
|
/** 段位与战场匹配、排行榜展示数据(存档仅 rankStars,段位由 Rank.level 推导) */
|
|
export class RankDataManager {
|
|
/** 玩家总星数(存档字段,默认 1,最低 1) */
|
|
static getPlayerRankStars(): number {
|
|
const n = Math.floor(Number(gg.data.doc.rankStars) || 0);
|
|
return n >= 1 ? n : 1;
|
|
}
|
|
|
|
/** 由总星数推导当前段位 id */
|
|
static getPlayerRankId(): number {
|
|
return this.resolveTierByStars(this.getPlayerRankStars())?.id ?? 1;
|
|
}
|
|
|
|
/** 读取 Rank 配表 */
|
|
static getRankConfig(rankId: number): ITableRank | null {
|
|
return gg.data.table.getTableData<ITableRank>(TableNames.Rank, rankId) ?? null;
|
|
}
|
|
|
|
/** 按总星数匹配 Rank 行(level[1]===-1 表示无上限,仅末档) */
|
|
static resolveTierByStars(stars: number): ITableRank | null {
|
|
const s = Math.max(1, Math.floor(Number(stars) || 0));
|
|
const list = (gg.data.table.getTableList<ITableRank>(TableNames.Rank) ?? [])
|
|
.slice()
|
|
.sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
|
|
if (list.length <= 0) return null;
|
|
for (const row of list) {
|
|
const range = row.level;
|
|
if (!range || range.length < 2) continue;
|
|
const min = Math.floor(Number(range[0]) || 0);
|
|
const max = Math.floor(Number(range[1]) || 0);
|
|
if (s >= min && (max === -1 || s <= max)) return row;
|
|
}
|
|
return list[list.length - 1] ?? null;
|
|
}
|
|
|
|
/** 段内星数 1~N(供俸禄弹窗星条) */
|
|
static getIntraTierStars(stars?: number): number {
|
|
const s = stars ?? this.getPlayerRankStars();
|
|
const row = this.resolveTierByStars(s);
|
|
if (!row?.level || row.level.length < 2) return 1;
|
|
const min = Math.floor(Number(row.level[0]) || 1);
|
|
return Math.max(1, s - min + 1);
|
|
}
|
|
|
|
/**
|
|
* 首页星条点亮数:总星数对 5 取余,余 0 为满 5 星。
|
|
* @param stars 总星数,缺省为玩家当前总星数
|
|
*/
|
|
static getStarModCount(stars?: number): number {
|
|
const rem = this.clampRankStars(stars ?? this.getPlayerRankStars()) % 5;
|
|
return rem === 0 ? 5 : rem;
|
|
}
|
|
|
|
/** 段位难度加值 */
|
|
static getRankDifficultyBonus(rankId?: number): number {
|
|
const row = this.getRankConfig(rankId ?? this.getPlayerRankId());
|
|
return row?.diffcult ?? 0;
|
|
}
|
|
|
|
/** 存档是否仍无对局痕迹(用于首局地图 / 首战引导) */
|
|
static hasNoBattleHistory(): boolean {
|
|
const doc = gg.data.doc;
|
|
if ((doc.matchWinCount ?? 0) > 0 || (doc.matchLoseCount ?? 0) > 0) return false;
|
|
if ((doc.rankStars ?? 1) > 1) return false;
|
|
if ((doc.failCount ?? 0) > 0 || (doc.dailyWins ?? 0) > 0) return false;
|
|
return true;
|
|
}
|
|
|
|
/** 是否尚未进入过战斗(新玩家首局) */
|
|
static isFirstEnterBattle(): boolean {
|
|
if (gg.data.getStatistics(StatisticsType.IsFirstEnter) !== 0) return false;
|
|
return this.hasNoBattleHistory();
|
|
}
|
|
|
|
/** 标记已进入过战斗 */
|
|
static markBattleEntered(): void {
|
|
if (gg.data.getStatistics(StatisticsType.IsFirstEnter) !== 0) return;
|
|
gg.data.setStatistics(StatisticsType.IsFirstEnter, 1);
|
|
gg.data.saveLocal();
|
|
}
|
|
|
|
/** 是否还有未完成的首次进战操作引导 */
|
|
static shouldShowFirstBattleGuide(): boolean {
|
|
if (gg.data.getStatistics(StatisticsType.Chapter1BattleGuideDone) !== 0) return false;
|
|
return this.hasNoBattleHistory();
|
|
}
|
|
|
|
/** 标记首次进战操作引导已完成 */
|
|
static markFirstBattleGuideDone(): void {
|
|
if (gg.data.getStatistics(StatisticsType.Chapter1BattleGuideDone) !== 0) return;
|
|
gg.data.setStatistics(StatisticsType.Chapter1BattleGuideDone, 1);
|
|
gg.data.saveLocal();
|
|
}
|
|
|
|
/** 读 Const 28 新玩家首关 Level id */
|
|
static readFirstEnterBattleLevelId(): number {
|
|
const id = Math.floor(Number(gg.data.table.getConst(ConstNumType.NewPlayerFirstMap)) || 0);
|
|
if (id <= 0) {
|
|
console.error('[RankDataManager] Const 28 新玩家首关地图 id 非法', id);
|
|
return id;
|
|
}
|
|
const clamped = gg.data.project.clampBattleLevelId(id);
|
|
if (clamped !== id) {
|
|
console.error('[RankDataManager] Const 28 地图 id 不在 Level 表', id);
|
|
}
|
|
return clamped;
|
|
}
|
|
|
|
/** 选本局 Level:GM 固定 > 首次进战读 Const28 > 按当天星期地图池随机 */
|
|
static pickBattleLevelId(): number {
|
|
const forced = Math.floor(Number(gg.game?.gmMapId) || 0);
|
|
if (forced > 0) {
|
|
return gg.data.project.clampBattleLevelId(forced);
|
|
}
|
|
if (this.isFirstEnterBattle()) {
|
|
return this.readFirstEnterBattleLevelId();
|
|
}
|
|
return this.pickRandomBattleLevelId();
|
|
}
|
|
|
|
/**
|
|
* JS `Date.getDay()`(0=周日 … 6=周六)→ Const 31~37。
|
|
* @param jsWeekday `getDay()` 返回值
|
|
*/
|
|
static resolveWeekdayLevelConstId(jsWeekday: number): number {
|
|
const d = Math.floor(Number(jsWeekday));
|
|
if (d < 0 || d > 6) {
|
|
console.error('[RankDataManager] 星期非法', jsWeekday);
|
|
return 0;
|
|
}
|
|
return ConstNumType.WeekMapMon + ((d + 6) % 7);
|
|
}
|
|
|
|
/** 按当天 Const 31~37 的 para1 随机 Level.id;GM gmMapId>0 时固定 */
|
|
static pickRandomBattleLevelId(): number {
|
|
const forced = Math.floor(Number(gg.game?.gmMapId) || 0);
|
|
if (forced > 0) {
|
|
return gg.data.project.clampBattleLevelId(forced);
|
|
}
|
|
const constId = this.resolveWeekdayLevelConstId(new Date().getDay());
|
|
if (constId < ConstNumType.WeekMapMon || constId > ConstNumType.WeekMapSun) {
|
|
console.error('[RankDataManager] 当日地图常量表 id 非法', constId);
|
|
return 0;
|
|
}
|
|
const row = gg.data.table.getConstData(constId);
|
|
const raw = gg.data.table.getConstArray(constId);
|
|
if (raw.length <= 0) {
|
|
console.error('[RankDataManager] 当日地图池为空', constId, '行存在=', !!row, 'para1=', row?.para1 ?? '');
|
|
return 0;
|
|
}
|
|
const pool: number[] = [];
|
|
for (let i = 0; i < raw.length; i++) {
|
|
const id = Math.floor(Number(raw[i]) || 0);
|
|
if (id <= 0) {
|
|
console.warn('[RankDataManager] 当日地图池含非法 id', constId, raw[i]);
|
|
continue;
|
|
}
|
|
if (!gg.data.table.getTableData<ITableLevel>(TableNames.Level, id)) {
|
|
console.warn('[RankDataManager] 当日地图池 id 不在 Level 表', constId, id);
|
|
continue;
|
|
}
|
|
pool.push(id);
|
|
}
|
|
if (pool.length <= 0) {
|
|
console.error('[RankDataManager] 当日地图池无有效 Level', constId);
|
|
return 0;
|
|
}
|
|
const idx = Math.floor(Math.random() * pool.length);
|
|
return pool[idx];
|
|
}
|
|
|
|
/**
|
|
* 按玩家总星 + Const 11 十档偏移匹配对手(§4.1)。
|
|
* @param playerStars 玩家总星;缺省读存档
|
|
*/
|
|
static resolveOpponentStarMatch(playerStars?: number): {
|
|
/** 相对玩家星偏移 */
|
|
starOffset: number;
|
|
/** 对手总星 */
|
|
opponentStars: number;
|
|
/** 对手段位 id */
|
|
opponentRankId: number;
|
|
} | null {
|
|
const list = (gg.data.table.getTableList<ITableRank>(TableNames.Rank) ?? [])
|
|
.slice()
|
|
.sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
|
|
if (list.length <= 0) {
|
|
console.log('[RankDataManager] Rank 表为空,无法匹配对手');
|
|
return null;
|
|
}
|
|
const first = list[0];
|
|
const last = list[list.length - 1];
|
|
const minStar = Math.max(1, Math.floor(Number(first.level?.[0]) || 1));
|
|
let maxStar = Math.floor(Number(last.level?.[1]) || 0);
|
|
if (maxStar === -1) {
|
|
maxStar = Math.max(minStar, Math.floor(Number(last.level?.[0]) || minStar));
|
|
}
|
|
if (maxStar < minStar) {
|
|
console.log('[RankDataManager] Rank 星范围非法', minStar, maxStar);
|
|
return null;
|
|
}
|
|
|
|
const pStars = this.clampRankStars(playerStars ?? this.getPlayerRankStars());
|
|
const offsets = [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4];
|
|
const raw = gg.data.table.getConstArray(ConstNumType.MatchRankWeight);
|
|
const weights: number[] = [];
|
|
for (let i = 0; i < offsets.length; i++) {
|
|
const w = Math.max(0, Math.floor(Number(raw[i]) || 0));
|
|
const s = pStars + offsets[i];
|
|
weights.push(s < minStar || s > maxStar ? 0 : w);
|
|
}
|
|
if (raw.length !== offsets.length) {
|
|
console.log('[RankDataManager] Const 11 应为 10 档权重');
|
|
}
|
|
let total = 0;
|
|
for (let i = 0; i < weights.length; i++) total += weights[i];
|
|
if (total <= 0) {
|
|
console.log(`[RankDataManager] Const 11 有效权重全为 0 pStars=${pStars} minStar=${minStar} maxStar=${maxStar}`);
|
|
return null;
|
|
}
|
|
let roll = Math.random() * total;
|
|
let starOffset = 0;
|
|
for (let i = 0; i < weights.length; i++) {
|
|
roll -= weights[i];
|
|
if (roll < 0) {
|
|
starOffset = offsets[i];
|
|
break;
|
|
}
|
|
if (i === weights.length - 1) starOffset = offsets[i];
|
|
}
|
|
const opponentStars = Math.max(minStar, Math.min(maxStar, pStars + starOffset));
|
|
const tier = this.resolveTierByStars(opponentStars);
|
|
if (!tier) {
|
|
console.log('[RankDataManager] resolveTierByStars 失败', opponentStars);
|
|
return null;
|
|
}
|
|
return {
|
|
starOffset,
|
|
opponentStars,
|
|
opponentRankId: tier.id,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 匹配对手段位 id:Const 11 按总星 `-5~+4` 十档偏移。
|
|
* @param _playerRankId 保留签名兼容;实际以总星为准
|
|
*/
|
|
static pickOpponentRankId(_playerRankId?: number): number {
|
|
const match = this.resolveOpponentStarMatch();
|
|
if (!match) {
|
|
console.log('[RankDataManager] 匹配失败,回退玩家当前段位');
|
|
return this.getPlayerRankId();
|
|
}
|
|
return match.opponentRankId;
|
|
}
|
|
|
|
/** 确保 Rank 表已加载 */
|
|
static async ensureTable(): Promise<void> {
|
|
await gg.data.table.ensureTableLoaded(TableNames.Rank);
|
|
}
|
|
|
|
/** 排行榜展示/比较用星数下限(默认 1) */
|
|
static clampRankStars(stars: number): number {
|
|
const n = Math.floor(Number(stars) || 0);
|
|
return n >= 1 ? n : 1;
|
|
}
|
|
|
|
/** 排行榜上传/展示分数(总星数) */
|
|
static getRankStars(): number {
|
|
return this.getPlayerRankStars();
|
|
}
|
|
|
|
/** @deprecated 使用 getRankStars */
|
|
static getSoulCount(): number {
|
|
return this.getRankStars();
|
|
}
|
|
|
|
/** 当前段位行(rank_detail=段内星数,供俸禄弹窗) */
|
|
static getCurrentRank(): (ITableRank & { rank_detail: number }) | null {
|
|
const row = this.resolveTierByStars(this.getPlayerRankStars());
|
|
if (!row) return null;
|
|
return Object.assign({}, row, { rank_detail: this.getIntraTierStars() });
|
|
}
|
|
|
|
/** 段位展示名 */
|
|
static rankDisplayName(rank: ITableRank | null): string {
|
|
return rank?.name?.trim() ?? '';
|
|
}
|
|
|
|
/**
|
|
* Rank 昵称 → icons/level 图片名(一/二 → 初阶/中阶;已是初阶等则原样)。
|
|
* 优先 `Rank.icon`;无后缀段位使用「段位·圆满」。
|
|
*/
|
|
static resolveRankLevelIconName(rankId?: number): string {
|
|
const id = rankId ?? this.getPlayerRankId();
|
|
const row = this.getRankConfig(id);
|
|
if (!row) {
|
|
console.log('[RankDataManager] 段位不存在', id);
|
|
return '';
|
|
}
|
|
const icon = String(row.icon ?? '').trim();
|
|
if (icon) return icon;
|
|
const name = String(row.name ?? '').trim();
|
|
if (!name) {
|
|
console.log('[RankDataManager] Rank.name 为空', id);
|
|
return '';
|
|
}
|
|
const dot = name.indexOf('·');
|
|
if (dot < 0) {
|
|
return `${name}·圆满`;
|
|
}
|
|
const tier = name.slice(0, dot);
|
|
const sub = name.slice(dot + 1);
|
|
if (RANK_STAGE_NAMES[sub]) return name;
|
|
const stage = RANK_SUB_TO_STAGE[sub];
|
|
if (!stage) {
|
|
console.log('[RankDataManager] 未知段位后缀', name);
|
|
return '';
|
|
}
|
|
return `${tier}·${stage}`;
|
|
}
|
|
|
|
/**
|
|
* 匹配/结算用段位文案(含段内星数)。
|
|
* @param rankId 段位 id
|
|
* @param stars 总星;缺省不拼星数
|
|
*/
|
|
static formatRankLevelDesc(rankId: number, stars?: number): string {
|
|
const name = this.resolveRankLevelIconName(rankId)
|
|
|| this.rankDisplayName(this.getRankConfig(rankId));
|
|
const base = String(name ?? '').trim();
|
|
if (!base) return '';
|
|
if (stars == null) return base;
|
|
const mod = this.getStarModCount(stars);
|
|
return `${base} ${mod}星`;
|
|
}
|
|
|
|
/** 绑定段位昵称图到节点(需已挂 SpriteLoad) */
|
|
static applyRankLevelIconToNode(node: Node | null, rankId?: number): void {
|
|
if (!node?.isValid) return;
|
|
const iconName = this.resolveRankLevelIconName(rankId);
|
|
if (!iconName) return;
|
|
const loader = node.getComponent(SpriteLoad);
|
|
if (!loader) {
|
|
console.log('[RankDataManager] 节点缺少 SpriteLoad', node.name);
|
|
return;
|
|
}
|
|
loader.setSprite(GPath.RankLevelIcon(iconName), GBundle.Items);
|
|
}
|
|
|
|
/** 确保 Rank 表后绑定段位昵称图 */
|
|
static async applyRankLevelIconToNodeAsync(node: Node | null, rankId?: number): Promise<void> {
|
|
await this.ensureTable();
|
|
this.applyRankLevelIconToNode(node, rankId);
|
|
}
|
|
|
|
/**
|
|
* 结算页 ui_star:子节点 star1~star5,内层 star 控制点亮。
|
|
* @param starRoot ui_star 根节点
|
|
* @param stars 总星数,缺省为玩家当前总星数
|
|
*/
|
|
static applySettlementStarsToNode(starRoot: Node | null, stars?: number): void {
|
|
if (!starRoot?.isValid) {
|
|
console.log('[RankDataManager] ui_star 缺失');
|
|
return;
|
|
}
|
|
const count = this.getStarModCount(stars);
|
|
for (let i = 1; i <= 5; i++) {
|
|
const slot = starRoot.getChildByName(`star${i}`);
|
|
if (!slot?.isValid) {
|
|
console.log('[RankDataManager] 星槽缺失', `star${i}`);
|
|
continue;
|
|
}
|
|
slot.active = true;
|
|
const icon = slot.getChildByName('star');
|
|
if (!icon?.isValid) {
|
|
console.log('[RankDataManager] 星图标缺失', `star${i}`);
|
|
continue;
|
|
}
|
|
icon.active = i <= count;
|
|
}
|
|
}
|
|
|
|
/** 结算页段位展示:ui_star 星条 + ui_rankLevel 昵称图(icons/level) */
|
|
static async refreshSettlementRankDisplay(starRoot: Node | null, rankLevelNode: Node | null, stars?: number): Promise<void> {
|
|
await this.ensureTable();
|
|
const totalStars = stars ?? this.getPlayerRankStars();
|
|
this.applySettlementStarsToNode(starRoot, totalStars);
|
|
const rankId = this.resolveTierByStars(totalStars)?.id;
|
|
this.applyRankLevelIconToNode(rankLevelNode, rankId);
|
|
}
|
|
|
|
/** 结算后段位 id 变化视为大段提升 */
|
|
static isMajorTierPromotion(before: ITableRank | null | undefined, after: ITableRank | null | undefined): boolean {
|
|
if (!before || !after) return false;
|
|
return (Number(before.id) || 0) !== (Number(after.id) || 0);
|
|
}
|
|
|
|
/** 按总星数反查榜单条目段位 */
|
|
static resolveByStars(stars: number): ITableRank | null {
|
|
return this.resolveTierByStars(stars);
|
|
}
|
|
|
|
/** @deprecated 使用 resolveByStars */
|
|
static resolveBySoulOrDefault(score: number): ITableRank | null {
|
|
return this.resolveByStars(score);
|
|
}
|
|
|
|
/**
|
|
* 战斗结算调整星数:胜 +1,负 -1,最低 1。
|
|
* @returns 星数是否变化
|
|
*/
|
|
static applyBattleResult(won: boolean, opts?: { save?: boolean }): boolean {
|
|
const cur = this.getPlayerRankStars();
|
|
const next = won ? cur + 1 : Math.max(1, cur - 1);
|
|
if (next === cur) return false;
|
|
gg.data.doc.rankStars = next;
|
|
if (opts?.save !== false) gg.data.saveLocal();
|
|
GEvent.Ins.emit(GEvent.UpdateRankStars);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Rank.reward 金币数量(格式 `道具id|数量`,如 `1|14`)。
|
|
* @param stars 总星数
|
|
*/
|
|
static parseRankTierMoneyReward(stars: number): number {
|
|
const row = this.resolveTierByStars(stars);
|
|
if (!row) {
|
|
console.log('[RankDataManager] 俸禄段位缺失', stars);
|
|
return 0;
|
|
}
|
|
const raw = String(row.reward ?? '').trim();
|
|
const parts = raw.split('|');
|
|
if (parts.length < 2) {
|
|
console.log('[RankDataManager] Rank.reward 非法', row.id);
|
|
return 0;
|
|
}
|
|
const itemId = Math.floor(Number(parts[0]) || 0);
|
|
const num = Math.floor(Number(parts[1]) || 0);
|
|
if (itemId !== ItemNumType.money) {
|
|
console.log('[RankDataManager] Rank.reward 非金币', row.id);
|
|
return 0;
|
|
}
|
|
if (!(num > 0)) {
|
|
console.log('[RankDataManager] Rank.reward 数量无效', row.id);
|
|
return 0;
|
|
}
|
|
return num;
|
|
}
|
|
|
|
/** Daily_Rank.reward_rank 解析(格式 type,id,num) */
|
|
static parseRewardRank(rewardStr: string): RankRewardGroup[] {
|
|
const raw = String(rewardStr ?? '').trim();
|
|
if (!raw) return [];
|
|
const list: RankRewardGroup[] = [];
|
|
const segments = raw.split('|');
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const parts = segments[i].split(',');
|
|
if (parts.length < 3) continue;
|
|
const type = Math.floor(Number(parts[0]) || 0);
|
|
const id = Math.floor(Number(parts[1]) || 0);
|
|
const num = Math.floor(Number(parts[2]) || 0);
|
|
if (!(id > 0) || !(num > 0)) continue;
|
|
list.push({ type, id, num });
|
|
}
|
|
if (list.length <= 0 && raw.indexOf('|') < 0) {
|
|
const parts = raw.split(',');
|
|
if (parts.length >= 3) {
|
|
const type = Math.floor(Number(parts[0]) || 0);
|
|
const id = Math.floor(Number(parts[1]) || 0);
|
|
const num = Math.floor(Number(parts[2]) || 0);
|
|
if (id > 0 && num > 0) list.push({ type, id, num });
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
}
|
|
|