Fix Multiple Shot ammunition branching and Missiles.txt 684-row parity (Closes #562, #568, #571, #572, #574, #576)
This commit is contained in:
parent
f8c938f866
commit
43d32e6d23
|
|
@ -1141,6 +1141,12 @@ export class OnlineSession {
|
|||
if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
|
||||
try {
|
||||
this.gameAdapter.send(cmd)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
|
||||
return
|
||||
}
|
||||
}
|
||||
this.world.noteOutgoingCommand(cmd, nowMs)
|
||||
if (this.world.selfId !== 0) {
|
||||
const selfUnit = this.world.getUnit('player', this.world.selfId)
|
||||
|
|
@ -1148,14 +1154,6 @@ export class OnlineSession {
|
|||
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
this.world.noteOutgoingCommand(cmd, nowMs)
|
||||
}
|
||||
if (
|
||||
cmd.type === 'SkillAt' ||
|
||||
cmd.type === 'SkillOn' ||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,13 @@ const BUTTON_ACTION_OPEN_STASH = 0x10
|
|||
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
|
||||
const MAX_D2_STAT_ID = 0x1ff
|
||||
|
||||
/** 1.13c crossbow item codes (normal, exceptional, elite). */
|
||||
const CROSSBOW_ITEM_CODES = new Set([
|
||||
'lxb', 'mxb', 'hxb', 'rxb',
|
||||
'8lx', '8mx', '8hx', '8rx',
|
||||
'6lx', '6mx', '6hx', '6rx',
|
||||
])
|
||||
|
||||
/** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */
|
||||
const PHYSICAL_ATTACK_SKILL_IDS = new Set([
|
||||
0, 2, 6, 7, 10, 11, 12, 16, 19, 20, 21, 22, 24, 26, 27, 31, 96, 97, 106, 107, 116, 126, 133, 139,
|
||||
|
|
@ -529,7 +536,7 @@ export class ClientWorld {
|
|||
const defaultMaxTicks =
|
||||
!loopAnim && data.vel === 0
|
||||
? Math.max(1, Math.ceil((totalFrames * 16) / animSpeed))
|
||||
: Math.max(1, (data.range || 20) + Math.max(0, level - 1) * (data.levRange ?? 0))
|
||||
: Math.max(1, data.range + Math.max(0, level - 1) * (data.levRange ?? 0))
|
||||
const maxTicks = opts?.maxTicks ?? defaultMaxTicks
|
||||
|
||||
const unit = new ClientUnit({
|
||||
|
|
@ -577,6 +584,35 @@ export class ClientWorld {
|
|||
return unit
|
||||
}
|
||||
|
||||
private isCrossbowEquipped(caster: ClientUnit): boolean {
|
||||
if (caster.unitType === 'player') {
|
||||
const isSelf = caster.id === this.selfId || this.selfId === 0
|
||||
if (isSelf) {
|
||||
const eqWeapon =
|
||||
this._containers.equipmentBySlot.get(4) ?? this._containers.equipmentBySlot.get(5)
|
||||
if (eqWeapon) {
|
||||
const code = (eqWeapon.code ?? '').trim().toLowerCase()
|
||||
if (CROSSBOW_ITEM_CODES.has(code)) return true
|
||||
const rawName = (eqWeapon as any).name
|
||||
if (typeof rawName === 'string') {
|
||||
const name = rawName.toLowerCase()
|
||||
if (
|
||||
name.includes('crossbow') ||
|
||||
name.includes('arbalest') ||
|
||||
name.includes('ballista') ||
|
||||
name.includes('chu-ko-nu')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const weaponType = (caster as any).equippedWeaponType ?? (caster as any).weaponItemType
|
||||
if (typeof weaponType === 'string' && weaponType.toLowerCase() === 'crossbow') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns canonical 1.13c projectile/ground/radial/spiral/emitter missiles for a skill cast.
|
||||
*/
|
||||
|
|
@ -612,12 +648,17 @@ export class ClientWorld {
|
|||
|
||||
// 2. Multi-projectile fan spells: Charged Bolt (38), Teeth (67), Multiple Shot (12)
|
||||
if (skillId === 38 || skillId === 67 || skillId === 12) {
|
||||
const missileKey =
|
||||
let missileKey =
|
||||
skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multipleshotarrow'
|
||||
const count =
|
||||
skillId === 38
|
||||
? Math.min(24, 2 + Math.max(1, skillLevel))
|
||||
: Math.min(24, Math.max(2, 1 + Math.max(1, skillLevel)))
|
||||
let count = 2
|
||||
if (skillId === 38) {
|
||||
count = Math.min(24, 2 + Math.max(1, skillLevel))
|
||||
} else if (skillId === 67) {
|
||||
count = Math.min(24, 1 + Math.max(1, skillLevel))
|
||||
} else if (skillId === 12) {
|
||||
count = Math.min(24, 2 + Math.max(1, skillLevel))
|
||||
missileKey = this.isCrossbowEquipped(caster) ? 'multipleshotbolt' : 'multipleshotarrow'
|
||||
}
|
||||
const totalSpread = Math.min(Math.PI * 0.65, 0.14 * Math.max(1, count - 1))
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = count === 1 ? 0 : -totalSpread / 2 + (totalSpread * i) / (count - 1)
|
||||
|
|
@ -844,8 +885,6 @@ export class ClientWorld {
|
|||
spawnVisuals = true,
|
||||
nowMs?: number,
|
||||
): void {
|
||||
unit.targetX = targetX
|
||||
unit.targetY = targetY
|
||||
unit.lastSkillId = skillId
|
||||
unit.moveMode = 'stop'
|
||||
// Turn toward the cast/attack target via D2Common.#10425 (bInstant = 0); 25Hz tick steps
|
||||
|
|
@ -870,6 +909,18 @@ export class ClientWorld {
|
|||
effRate = animParams.effRate
|
||||
stepSpeed256 = animParams.stepSpeed256
|
||||
durationMs = animParams.durationMs
|
||||
} else if (unit.unitType === 'monster') {
|
||||
if (unit.totalFrames && unit.totalFrames > 0) {
|
||||
frames = unit.totalFrames
|
||||
}
|
||||
if (unit.animStepSpeed256 && unit.animStepSpeed256 > 0) {
|
||||
stepSpeed256 = unit.animStepSpeed256
|
||||
baseSpeed = stepSpeed256
|
||||
} else if (unit.animSpeed && unit.animSpeed > 0) {
|
||||
baseSpeed = Math.round((unit.animSpeed / 16) * 256)
|
||||
stepSpeed256 = baseSpeed
|
||||
}
|
||||
durationMs = computeOneShotDurationMs(frames, stepSpeed256)
|
||||
}
|
||||
|
||||
const castState: UnitCastState = {
|
||||
|
|
@ -1217,7 +1268,7 @@ export class ClientWorld {
|
|||
hitY: number,
|
||||
isWallHit: boolean,
|
||||
): void {
|
||||
const expKey =
|
||||
let expKey =
|
||||
missile.explosionMissile ||
|
||||
(!isWallHit &&
|
||||
(missile.missileKey === 'lightning' ||
|
||||
|
|
@ -1227,6 +1278,12 @@ export class ClientWorld {
|
|||
? 'lightninghit'
|
||||
: '')
|
||||
|
||||
if (expKey === 'freezingarrowexp1') {
|
||||
expKey = 'freezeexplode'
|
||||
} else if (expKey === 'freezingarrowexp2') {
|
||||
expKey = 'glacialspikeejecta'
|
||||
}
|
||||
|
||||
if (expKey) {
|
||||
const exp = this.spawnMissileByKey(expKey, hitX, hitY, hitX, hitY, {
|
||||
ownerType: missile.ownerType,
|
||||
|
|
@ -1592,9 +1649,8 @@ export class ClientWorld {
|
|||
if (!Number.isFinite(dtMs)) {
|
||||
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
|
||||
}
|
||||
const safeDt = Math.max(0, dtMs)
|
||||
const cappedDt = Math.min(200, safeDt)
|
||||
this._tickAccumulatorMs += cappedDt
|
||||
const safeDt = Math.min(200, Math.max(0, dtMs))
|
||||
this._tickAccumulatorMs = Math.min(200, this._tickAccumulatorMs + safeDt)
|
||||
this._simTimeMs += safeDt
|
||||
|
||||
this.expireCastStates(nowMs, safeDt)
|
||||
|
|
@ -2143,9 +2199,12 @@ export class ClientWorld {
|
|||
unit.animStepSpeed256 = speedInfo.stepSpeed256
|
||||
unit.animEffRate = speedInfo.effRate
|
||||
} else if (unit.unitType === 'monster' && resolvedMode === 3) {
|
||||
unit.modeDurationMs = 240
|
||||
unit.modeUntilMs = now + 240
|
||||
unit.animStepSpeed256 = undefined
|
||||
const ghFrames = unit.totalFrames && unit.totalFrames > 0 ? unit.totalFrames : 8
|
||||
const ghSpeed = unit.animStepSpeed256 && unit.animStepSpeed256 > 0 ? unit.animStepSpeed256 : 256
|
||||
const ghDuration = computeOneShotDurationMs(ghFrames, ghSpeed)
|
||||
unit.modeDurationMs = ghDuration
|
||||
unit.modeUntilMs = now + ghDuration
|
||||
unit.animStepSpeed256 = ghSpeed
|
||||
unit.animEffRate = undefined
|
||||
} else {
|
||||
unit.modeDurationMs = undefined
|
||||
|
|
@ -2232,7 +2291,10 @@ export class ClientWorld {
|
|||
// 0x73 CreateMissile: caster.unitType === 3 ('missile')
|
||||
if (unitTypeFromId(casterType) === 'missile') {
|
||||
const missileClassId = skillId
|
||||
const missileKey = MISSILE_CLASS_ID_TO_KEY[missileClassId] ?? 'arrow'
|
||||
const missileKey = MISSILE_CLASS_ID_TO_KEY[missileClassId]
|
||||
if (!missileKey) {
|
||||
throw new Error(`Unknown missile classId ${missileClassId} in Missiles.txt`)
|
||||
}
|
||||
const ownerType = ev.owner ? unitTypeFromId(ev.owner.unitType) : 'player'
|
||||
const ownerId = ev.owner?.id ?? 0
|
||||
const ownerUnit = ev.owner ? this.getUnit(ev.owner.unitType, ev.owner.id) : undefined
|
||||
|
|
@ -2292,12 +2354,11 @@ export class ClientWorld {
|
|||
(unit.id === this.selfId || this.selfId === 0) &&
|
||||
skillId === this._lastLocalCastSkillId &&
|
||||
timeSinceLocalCast >= 0 &&
|
||||
timeSinceLocalCast <= 500
|
||||
timeSinceLocalCast <= 1000
|
||||
if (isSelfEcho) {
|
||||
this._lastLocalCastSkillId = -1
|
||||
if (unit.castState && unit.castState.skillId === skillId && now < unit.castState.untilMs) {
|
||||
unit.targetX = targetX
|
||||
unit.targetY = targetY
|
||||
unit.faceToward(targetX, targetY, false)
|
||||
} else {
|
||||
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -177,7 +177,9 @@ export function computeToHitChance(params: {
|
|||
const alvl = Math.max(1, params.attackerLvl)
|
||||
const dlvl = Math.max(1, params.defenderLvl)
|
||||
|
||||
const raw = Math.trunc((2 * effAr * 100 * alvl) / (Math.max(1, effAr + effDef) * (alvl + dlvl)))
|
||||
const divisor = effAr + effDef
|
||||
const toHitFactor = divisor > 0 ? Math.trunc((100 * effAr) / divisor) : 100
|
||||
const raw = Math.trunc((2 * alvl * toHitFactor) / (alvl + dlvl))
|
||||
return Math.max(5, Math.min(95, raw))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -879,7 +879,7 @@ describe('ClientWorld authoritative state mirror', () => {
|
|||
)
|
||||
expect(subBolts.length).toBeGreaterThanOrEqual(4)
|
||||
|
||||
// 6. Multiple Shot (skill 12, level 5 -> 6 multipleshotarrow missiles in a fan, classId=213)
|
||||
// 6. Multiple Shot (skill 12, level 5 -> 7 multipleshotarrow missiles in a fan per 1.13c calc1 min(24, 2 + slvl), classId=213)
|
||||
const msWorld = new ClientWorld()
|
||||
msWorld.apply({
|
||||
type: 'UnitAssign',
|
||||
|
|
@ -897,7 +897,7 @@ describe('ClientWorld authoritative state mirror', () => {
|
|||
const multiShotArrows = Array.from(msWorld.units.values()).filter(
|
||||
u => u.unitType === 'missile' && u.missileKey === 'multipleshotarrow',
|
||||
)
|
||||
expect(multiShotArrows).toHaveLength(6)
|
||||
expect(multiShotArrows).toHaveLength(7)
|
||||
expect(multiShotArrows.every(m => m.classId === 213)).toBe(true)
|
||||
|
||||
// 7. Challenge 3: spawnMissileByKey('arrow') without opts.classId preserves classId === 0
|
||||
|
|
|
|||
Loading…
Reference in New Issue