humingxing 1 week ago
parent 2f8f9305a8
commit 1388366f39
  1. 161
      assets/script/battle/combat/WeaponDropService.ts
  2. 2
      assets/script/battle/runtime/BattleRuntime.ts
  3. 36
      assets/script/ui/BattleGame/BattleWeaponDropPresenter.ts
  4. 31
      docs/需求-装备掉落.md

@ -6,16 +6,20 @@ import type { ProjectBattleHandle } from '../runtime/BattleRuntime';
/** 万分比分母 */ /** 万分比分母 */
const WAN = 10000; const WAN = 10000;
/** Rank.weaponweight 下标 → Weapon.quality:绿 / 蓝 / 紫 / 橙 */
const WEIGHT_QUALITY = [1, 2, 3, 4];
/** 掷骰失败哨兵(0 为白色,不可与失败混用) */
const ROLL_FAIL = -1;
/** 局内一次武器碎片掉落 */ /** Level.expdrop 一条加权宝石 */
interface IExpDropEntry {
/** Weapon.id(宝石) */
gemId: number;
/** 权重 */
weight: number;
}
/** 局内一次宝石掉落 */
export interface WeaponDropRecord { export interface WeaponDropRecord {
/** Weapon.id */ /** Weapon.id(宝石) */
weaponId: number; weaponId: number;
/** 碎片 Item.id(Weapon.itemid) */ /** 道具 Item.id(Weapon.itemid) */
itemId: number; itemId: number;
/** Weapon.quality */ /** Weapon.quality */
quality: number; quality: number;
@ -23,9 +27,9 @@ export interface WeaponDropRecord {
isSelf: boolean; isSelf: boolean;
} }
/** 结算用碎片堆叠项 */ /** 结算用掉落堆叠项 */
export interface WeaponDropRewardItem { export interface WeaponDropRewardItem {
/** 碎片 Item.id */ /** Item.id */
itemId: number; itemId: number;
/** 数量 */ /** 数量 */
num: number; num: number;
@ -35,12 +39,12 @@ export interface WeaponDropRewardItem {
export interface WeaponDropAggregatedEntry { export interface WeaponDropAggregatedEntry {
/** 代表记录(首条,用于外观) */ /** 代表记录(首条,用于外观) */
record: WeaponDropRecord; record: WeaponDropRecord;
/** 碎片数量 */ /** 数量 */
count: number; count: number;
} }
/** /**
* 击杀掉落武器碎片:概率、品质、武器抽取与本局记录。 * 击杀掉落宝石:概率沿用 Monster/Rank.weapondrop,选品读本关 Level.expdrop。
*/ */
export class WeaponDropService { export class WeaponDropService {
private readonly _self: WeaponDropRecord[] = []; private readonly _self: WeaponDropRecord[] = [];
@ -48,14 +52,17 @@ export class WeaponDropService {
/** 按击杀顺序的全部掉落(暂停列表用) */ /** 按击杀顺序的全部掉落(暂停列表用) */
private readonly _all: WeaponDropRecord[] = []; private readonly _all: WeaponDropRecord[] = [];
private readonly _pendingVisual: WeaponDropRecord[] = []; private readonly _pendingVisual: WeaponDropRecord[] = [];
private _poolByQuality: ITableWeapon[][] | null = null; /** 本关 expdrop 解析缓存 */
private _expPool: IExpDropEntry[] | null = null;
/** 缓存对应的 Level.id */
private _expLevelId = 0;
/** 己方已掉落碎片 */ /** 己方已掉落 */
get selfDrops(): readonly WeaponDropRecord[] { get selfDrops(): readonly WeaponDropRecord[] {
return this._self; return this._self;
} }
/** 敌方已掉落碎片 */ /** 敌方已掉落 */
get otherDrops(): readonly WeaponDropRecord[] { get otherDrops(): readonly WeaponDropRecord[] {
return this._other; return this._other;
} }
@ -88,7 +95,7 @@ export class WeaponDropService {
return this._pendingVisual.shift() ?? null; return this._pendingVisual.shift() ?? null;
} }
/** 胜利结算:双方碎片按 itemId 堆叠 */ /** 胜利结算:双方掉落按 itemId 堆叠 */
collectRewardItems(): WeaponDropRewardItem[] { collectRewardItems(): WeaponDropRewardItem[] {
const out: WeaponDropRewardItem[] = []; const out: WeaponDropRewardItem[] = [];
this._stackInto(out, this._self); this._stackInto(out, this._self);
@ -97,7 +104,7 @@ export class WeaponDropService {
} }
/** /**
* 按 weaponId 合并掉落(结算列表:同武器共用一个预制体并显示数量)。 * 按 weaponId 合并掉落(结算列表:同宝石共用一个预制体并显示数量)。
* @param drops 本局全部掉落 * @param drops 本局全部掉落
*/ */
static aggregateByWeaponId(drops: readonly WeaponDropRecord[]): WeaponDropAggregatedEntry[] { static aggregateByWeaponId(drops: readonly WeaponDropRecord[]): WeaponDropAggregatedEntry[] {
@ -122,10 +129,12 @@ export class WeaponDropService {
this._other.length = 0; this._other.length = 0;
this._all.length = 0; this._all.length = 0;
this._pendingVisual.length = 0; this._pendingVisual.length = 0;
this._expPool = null;
this._expLevelId = 0;
} }
/** /**
* 按品质权重与武器表掷出一条掉落。 * 概率成功后,按本关 Level.expdrop 权重抽一颗宝石。
* @param battle 当前战斗 * @param battle 当前战斗
* @param killer 击杀者 * @param killer 击杀者
* @param victim 死亡实体 * @param victim 死亡实体
@ -135,94 +144,108 @@ export class WeaponDropService {
if (!monster) return null; if (!monster) return null;
const rank = RankDataManager.getRankConfig(battle.playerRankId); const rank = RankDataManager.getRankConfig(battle.playerRankId);
if (!rank) { if (!rank) {
console.log('[WeaponDrop] Rank 缺失', battle.playerRankId); console.warn('[WeaponDrop] Rank 缺失', battle.playerRankId);
return null; return null;
} }
const monsterDrop = Math.floor(Number(monster.weapondrop) || 0); const monsterDrop = Math.floor(Number(monster.weapondrop) || 0);
const rankDrop = Math.floor(Number(rank.weapondrop) || 0); const rankDrop = Math.floor(Number(rank.weapondrop) || 0);
if (monsterDrop < 0 || rankDrop < 0) { if (monsterDrop < 0 || rankDrop < 0) {
console.log('[WeaponDrop] weapondrop 非法', monster.id, monster.weapondrop, rank.weapondrop); console.warn('[WeaponDrop] weapondrop 非法', monster.id, monster.weapondrop, rank.weapondrop);
return null; return null;
} }
const chance = monsterDrop + rankDrop; const chance = monsterDrop + rankDrop;
if (chance <= 0) return null; if (chance <= 0) return null;
if (Math.random() * WAN >= chance) return null; if (Math.random() * WAN >= chance) return null;
const quality = this._rollQuality(rank.weaponweight); const gem = this._pickGemFromExpDrop(battle.levelConfigId);
if (quality < 0) return null; if (!gem) return null;
const weapon = this._pickWeapon(quality); const itemId = Math.floor(Number(gem.itemid) || 0);
if (!weapon) return null;
const itemId = Math.floor(Number(weapon.itemid) || 0);
if (itemId <= 0) { if (itemId <= 0) {
console.log('[WeaponDrop] 武器无碎片 id', weapon.id); console.warn('[WeaponDrop] 宝石无道具 id', gem.id);
return null; return null;
} }
const ownerId = battle.resolveKillPeachOwnerPlayerId(killer, victim); const ownerId = battle.resolveKillPeachOwnerPlayerId(killer, victim);
return { return {
weaponId: Math.floor(Number(weapon.id) || 0), weaponId: Math.floor(Number(gem.id) || 0),
itemId, itemId,
quality, quality: Math.floor(Number(gem.quality) || 0),
isSelf: ownerId === battle.localPlayerId, isSelf: ownerId === battle.localPlayerId,
}; };
} }
/** /**
* 按绿蓝紫橙四档权重随机品质。 * 按 Level.expdrop「宝石id,权重|...」加权抽取。
* @param weights Rank.weaponweight * @param levelId Level.id
*/ */
private _rollQuality(weights: readonly number[] | null | undefined): number { private _pickGemFromExpDrop(levelId: number): ITableWeapon | null {
if (!weights || weights.length < WEIGHT_QUALITY.length) { const pool = this._ensureExpPool(levelId);
console.log('[WeaponDrop] weaponweight 长度非法'); if (!pool.length) return null;
return ROLL_FAIL;
}
let total = 0; let total = 0;
for (let i = 0; i < WEIGHT_QUALITY.length; i++) { for (let i = 0; i < pool.length; i++) total += pool[i].weight;
const w = Math.max(0, Math.floor(Number(weights[i]) || 0));
total += w;
}
if (total <= 0) { if (total <= 0) {
console.log('[WeaponDrop] weaponweight 全 0'); console.warn('[WeaponDrop] expdrop 权重全 0', levelId);
return ROLL_FAIL; return null;
} }
let roll = Math.random() * total; let roll = Math.random() * total;
for (let i = 0; i < WEIGHT_QUALITY.length; i++) { let gemId = 0;
roll -= Math.max(0, Math.floor(Number(weights[i]) || 0)); for (let i = 0; i < pool.length; i++) {
if (roll < 0) return WEIGHT_QUALITY[i]; roll -= pool[i].weight;
if (roll < 0) {
gemId = pool[i].gemId;
break;
} }
return WEIGHT_QUALITY[WEIGHT_QUALITY.length - 1]; }
if (gemId <= 0) gemId = pool[pool.length - 1].gemId;
const gem = gg.data.table.getTableData<ITableWeapon>(TableNames.Weapon, gemId);
if (!gem) {
console.warn('[WeaponDrop] expdrop 宝石表缺失', gemId, levelId);
return null;
}
return gem;
} }
/** /**
* 从该品质武器池均匀随机一把。 * 解析并缓存本关 expdrop。
* @param quality Weapon.quality * @param levelId Level.id
*/ */
private _pickWeapon(quality: number): ITableWeapon | null { private _ensureExpPool(levelId: number): IExpDropEntry[] {
this._ensurePools(); const id = Math.floor(Number(levelId) || 0);
const pool = this._poolByQuality?.[quality]; if (this._expPool && this._expLevelId === id) return this._expPool;
if (!pool || pool.length <= 0) { this._expLevelId = id;
console.log('[WeaponDrop] 该品质无可用武器', quality); this._expPool = [];
return null; if (id <= 0) {
console.warn('[WeaponDrop] levelId 无效');
return this._expPool;
}
const level = gg.data.table.getTableData<ITableLevel>(TableNames.Level, id);
if (!level) {
console.warn('[WeaponDrop] Level 缺失', id);
return this._expPool;
}
const raw = String(level.expdrop ?? '').trim();
if (!raw) {
console.warn('[WeaponDrop] expdrop 为空', id);
return this._expPool;
}
const parts = raw.split('|');
for (let i = 0; i < parts.length; i++) {
const part = parts[i].trim();
if (!part) continue;
const segs = part.split(',');
if (segs.length < 2) {
console.warn('[WeaponDrop] expdrop 段格式非法', id, part);
continue;
} }
const idx = Math.floor(Math.random() * pool.length); const gemId = Math.floor(Number(segs[0]) || 0);
return pool[idx] ?? null; const weight = Math.floor(Number(segs[1]) || 0);
} if (gemId <= 0 || weight <= 0) {
console.warn('[WeaponDrop] expdrop 条目非法', id, part);
/** 按品质缓存 itemid>0 的武器 */
private _ensurePools(): void {
if (this._poolByQuality) return;
const buckets: ITableWeapon[][] = [[], [], [], [], []];
const list = gg.data.table.getTableList<ITableWeapon>(TableNames.Weapon) ?? [];
for (let i = 0; i < list.length; i++) {
const row = list[i];
if (!(Math.floor(Number(row.itemid) || 0) > 0)) continue;
const q = Math.floor(Number(row.quality) || 0);
if (q < 0 || q > 4) {
console.log('[WeaponDrop] 武器品质越界', row.id, q);
continue; continue;
} }
buckets[q].push(row); this._expPool.push({ gemId, weight });
} }
this._poolByQuality = buckets; if (!this._expPool.length) console.warn('[WeaponDrop] expdrop 无有效条目', id, raw);
return this._expPool;
} }
/** /**

@ -217,7 +217,7 @@ export class BattleRuntime implements IBattleRuntimeHandle {
readonly MonsterSpawner = new MonsterSpawnerBridge(); readonly MonsterSpawner = new MonsterSpawnerBridge();
/** 本局理论秒伤(UI / 三选1 危险系数共用) */ /** 本局理论秒伤(UI / 三选1 危险系数共用) */
readonly dpsMeter = new BattleDpsMeter(); readonly dpsMeter = new BattleDpsMeter();
/** 局内武器碎片掉落 */ /** 局内宝石掉落(选品 Level.expdrop) */
readonly weaponDrop = new WeaponDropService(); readonly weaponDrop = new WeaponDropService();
/** 当前理论秒伤;由 UIBattleGame 刷新 */ /** 当前理论秒伤;由 UIBattleGame 刷新 */
currentDps = 0; currentDps = 0;

@ -107,28 +107,28 @@ export class WeaponDropView extends Component {
} }
/** /**
* 按武器信息刷新底框与图标(掉落 / 暂停列表共用)。 * 按宝石信息刷新底框与图标(掉落 / 暂停列表共用)。
* @param node 武器预制体根节点 * @param node 「武器」预制体根节点(仍复用该预制)
* @param record 掉落记录 * @param record 掉落记录
*/ */
static applyVisual(node: Node, record: WeaponDropRecord): void { static applyVisual(node: Node, record: WeaponDropRecord): void {
WeaponDropView.clearFragmentCount(node); WeaponDropView.clearFragmentCount(node);
const weapon = gg.equip.getEquip(record.weaponId); const weapon = gg.equip.getEquip(record.weaponId);
if (!weapon) { if (!weapon) {
console.log('[WeaponDrop] 武器表缺失', record.weaponId); console.warn('[WeaponDrop] 宝石表缺失', record.weaponId);
return; return;
} }
const bgIndex = WeaponDropView._qualityBgIndex(record.quality, record.weaponId); const bgIndex = WeaponDropView._gemFrameIndex(record.quality, record.weaponId);
WeaponDropView._setSprite(node, `weapon/武器底${bgIndex}`); WeaponDropView._setSprite(node, `weapon/宝石框大${bgIndex}`);
const bgSprite = node.getComponent(Sprite); const bgSprite = node.getComponent(Sprite);
if (bgSprite) bgSprite.type = Sprite.Type.SLICED; if (bgSprite) bgSprite.type = Sprite.Type.SLICED;
const iconName = String(weapon.icon ?? '').trim(); const iconName = String(weapon.icon ?? '').trim();
if (!iconName) { if (!iconName) {
console.log('[WeaponDrop] icon 缺失', record.weaponId); console.warn('[WeaponDrop] icon 缺失', record.weaponId);
} }
const icon = node.getChildByName(ICON_NODE); const icon = node.getChildByName(ICON_NODE);
if (!icon?.isValid) { if (!icon?.isValid) {
console.log('[WeaponDrop] 缺少图标节点', record.weaponId); console.warn('[WeaponDrop] 缺少图标节点', record.weaponId);
return; return;
} }
WeaponDropView._setSprite(icon, iconName ? `weapon/${iconName}` : '', true); WeaponDropView._setSprite(icon, iconName ? `weapon/${iconName}` : '', true);
@ -143,20 +143,20 @@ export class WeaponDropView extends Component {
WeaponDropView.clearFragmentCount(node); WeaponDropView.clearFragmentCount(node);
const weapon = gg.equip.getEquip(record.weaponId); const weapon = gg.equip.getEquip(record.weaponId);
if (!weapon) { if (!weapon) {
console.log('[WeaponDrop] 武器表缺失', record.weaponId); console.warn('[WeaponDrop] 宝石表缺失', record.weaponId);
return; return;
} }
const bgIndex = WeaponDropView._qualityBgIndex(record.quality, record.weaponId); const bgIndex = WeaponDropView._gemFrameIndex(record.quality, record.weaponId);
WeaponDropView._setSprite(node, `weapon/武器底${bgIndex}`); WeaponDropView._setSprite(node, `weapon/宝石框大${bgIndex}`);
const bgSprite = node.getComponent(Sprite); const bgSprite = node.getComponent(Sprite);
if (bgSprite) bgSprite.type = Sprite.Type.SLICED; if (bgSprite) bgSprite.type = Sprite.Type.SLICED;
const iconName = String(weapon.icon ?? '').trim(); const iconName = String(weapon.icon ?? '').trim();
if (!iconName) { if (!iconName) {
console.log('[WeaponDrop] icon 缺失', record.weaponId); console.warn('[WeaponDrop] icon 缺失', record.weaponId);
} }
const icon = node.getChildByName(SETTLE_ICON_NODE); const icon = node.getChildByName(SETTLE_ICON_NODE);
if (!icon?.isValid) { if (!icon?.isValid) {
console.log('[WeaponDrop] 缺少 name 节点', record.weaponId); console.warn('[WeaponDrop] 缺少 name 节点', record.weaponId);
return; return;
} }
WeaponDropView._setSprite(icon, iconName ? `weapon/${iconName}` : '', true); WeaponDropView._setSprite(icon, iconName ? `weapon/${iconName}` : '', true);
@ -225,11 +225,15 @@ export class WeaponDropView extends Component {
if (lb) lb.string = ''; if (lb) lb.string = '';
} }
/** Weapon.quality 0~4 → 武器底 0~4(白/绿/蓝/紫/橙) */ /**
private static _qualityBgIndex(quality: number, weaponId: number): number { * Weapon.quality → 宝石框大资源下标(现有资源 1~5)。
* @param quality 品质
* @param weaponId 宝石 id(仅日志)
*/
private static _gemFrameIndex(quality: number, weaponId: number): number {
const q = Number(quality) | 0; const q = Number(quality) | 0;
if (q < 0 || q > 4) { if (q < 1 || q > 5) {
console.log('[WeaponDrop] 品质无效', weaponId, q); console.warn('[WeaponDrop] 宝石品质无对应宝石框大', weaponId, q);
} }
return q; return q;
} }

@ -1,16 +1,23 @@
# 需求:击杀怪物掉落装备碎片 # 需求:击杀怪物掉落宝石
> 状态:**已确认(局内实现)** > 状态:**已实现(局内)**
> 日期:2026-09-04 > 日期:2026-10-07
> 说明:老项目掉落武器碎片;新产品 `Weapon` 表即为宝石,流程复用,选品改为关卡 `expdrop`。
## 规则 ## 规则
1. 击杀怪物后按 `Monster.weapondrop + Rank.weapondrop`(万分比)掷骰,成功则掉落 **1 个武器碎片**。 1. 击杀怪物后按 `Monster.weapondrop + Rank.weapondrop`(万分比)掷骰,成功则掉落 **1 颗宝石**(入账为 `Weapon.itemid` 对应 `Item`,`Item.type=3`)。
2. 段位取本局玩家 `playerRankId`(需求中的「当前段位」)。 2. 段位取本局玩家 `playerRankId`。
3. 品质按 `Rank.weaponweight` 四档权重随机:绿 / 蓝 / 紫 / 橙,对应 `Weapon.quality` 1 / 2 / 3 / 4。 3. **选品**:读本关 `Level.expdrop`,格式 `宝石id,权重|宝石id,权重...`,按权重随机一颗(**不再**使用 `Rank.weaponweight` 品质池从全表抽取)。
4. 品质确定后,从该品质且 `itemid > 0` 的武器中均匀随机一把,入账为 `Weapon.itemid` 碎片。 4. 己方击杀:掉落在死亡点,点击后飞向左上角暂停按钮并渐隐。掉落弹跳全程须完整留在屏幕内。
5. 己方击杀:掉落在死亡点,点击后飞向左上角暂停按钮并渐隐。掉落弹跳全程须完整留在屏幕内。 5. AI 击杀:掉落后自动飞向暂停按钮并渐隐。
6. AI 击杀:掉落后自动飞向暂停按钮并渐隐。 6. 暂停页随时展示本局已掷出的己方 / 敌方掉落。
7. 暂停页随时展示本局已掷出的己方 / 敌方碎片(「武器」预制体)。 7. 胜利领取时获得双方全部掉落;失败 / 投降不获得。
8. 胜利领取时获得双方全部碎片;失败 / 投降不获得。 8. 还魂丹暂缓击杀不掉落(与击杀掉桃一致)。
9. 还魂丹暂缓击杀不掉落(与击杀掉桃一致)。
## 实现
- 逻辑:`assets/script/battle/combat/WeaponDropService.ts`
- 概率:`Monster.weapondrop + Rank.weapondrop`(段位加值,与老项目一致)
- 选品:本关 `Level.expdrop`(`宝石id,权重|...`)
- 表现:仍复用战场「武器」预制体;底框改 `icons/weapon/宝石框大{quality}`(1~5),图标读 `Weapon.icon`。

Loading…
Cancel
Save