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,20 +1141,18 @@ export class OnlineSession {
|
||||||
if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
|
if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
|
||||||
try {
|
try {
|
||||||
this.gameAdapter.send(cmd)
|
this.gameAdapter.send(cmd)
|
||||||
this.world.noteOutgoingCommand(cmd, nowMs)
|
|
||||||
if (this.world.selfId !== 0) {
|
|
||||||
const selfUnit = this.world.getUnit('player', this.world.selfId)
|
|
||||||
if (selfUnit?.castState && !this.predictor.getSnapshot().moving) {
|
|
||||||
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
|
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
this.world.noteOutgoingCommand(cmd, nowMs)
|
this.world.noteOutgoingCommand(cmd, nowMs)
|
||||||
|
if (this.world.selfId !== 0) {
|
||||||
|
const selfUnit = this.world.getUnit('player', this.world.selfId)
|
||||||
|
if (selfUnit?.castState && !this.predictor.getSnapshot().moving) {
|
||||||
|
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
cmd.type === 'SkillAt' ||
|
cmd.type === 'SkillAt' ||
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
|
||||||
const MAX_D2_STAT_ID = 0x1ff
|
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`). */
|
/** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */
|
||||||
const PHYSICAL_ATTACK_SKILL_IDS = new Set([
|
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,
|
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 =
|
const defaultMaxTicks =
|
||||||
!loopAnim && data.vel === 0
|
!loopAnim && data.vel === 0
|
||||||
? Math.max(1, Math.ceil((totalFrames * 16) / animSpeed))
|
? 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 maxTicks = opts?.maxTicks ?? defaultMaxTicks
|
||||||
|
|
||||||
const unit = new ClientUnit({
|
const unit = new ClientUnit({
|
||||||
|
|
@ -577,6 +584,35 @@ export class ClientWorld {
|
||||||
return unit
|
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.
|
* 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)
|
// 2. Multi-projectile fan spells: Charged Bolt (38), Teeth (67), Multiple Shot (12)
|
||||||
if (skillId === 38 || skillId === 67 || skillId === 12) {
|
if (skillId === 38 || skillId === 67 || skillId === 12) {
|
||||||
const missileKey =
|
let missileKey =
|
||||||
skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multipleshotarrow'
|
skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multipleshotarrow'
|
||||||
const count =
|
let count = 2
|
||||||
skillId === 38
|
if (skillId === 38) {
|
||||||
? Math.min(24, 2 + Math.max(1, skillLevel))
|
count = Math.min(24, 2 + Math.max(1, skillLevel))
|
||||||
: Math.min(24, Math.max(2, 1 + 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))
|
const totalSpread = Math.min(Math.PI * 0.65, 0.14 * Math.max(1, count - 1))
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const offset = count === 1 ? 0 : -totalSpread / 2 + (totalSpread * i) / (count - 1)
|
const offset = count === 1 ? 0 : -totalSpread / 2 + (totalSpread * i) / (count - 1)
|
||||||
|
|
@ -844,8 +885,6 @@ export class ClientWorld {
|
||||||
spawnVisuals = true,
|
spawnVisuals = true,
|
||||||
nowMs?: number,
|
nowMs?: number,
|
||||||
): void {
|
): void {
|
||||||
unit.targetX = targetX
|
|
||||||
unit.targetY = targetY
|
|
||||||
unit.lastSkillId = skillId
|
unit.lastSkillId = skillId
|
||||||
unit.moveMode = 'stop'
|
unit.moveMode = 'stop'
|
||||||
// Turn toward the cast/attack target via D2Common.#10425 (bInstant = 0); 25Hz tick steps
|
// Turn toward the cast/attack target via D2Common.#10425 (bInstant = 0); 25Hz tick steps
|
||||||
|
|
@ -870,6 +909,18 @@ export class ClientWorld {
|
||||||
effRate = animParams.effRate
|
effRate = animParams.effRate
|
||||||
stepSpeed256 = animParams.stepSpeed256
|
stepSpeed256 = animParams.stepSpeed256
|
||||||
durationMs = animParams.durationMs
|
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 = {
|
const castState: UnitCastState = {
|
||||||
|
|
@ -1217,7 +1268,7 @@ export class ClientWorld {
|
||||||
hitY: number,
|
hitY: number,
|
||||||
isWallHit: boolean,
|
isWallHit: boolean,
|
||||||
): void {
|
): void {
|
||||||
const expKey =
|
let expKey =
|
||||||
missile.explosionMissile ||
|
missile.explosionMissile ||
|
||||||
(!isWallHit &&
|
(!isWallHit &&
|
||||||
(missile.missileKey === 'lightning' ||
|
(missile.missileKey === 'lightning' ||
|
||||||
|
|
@ -1227,6 +1278,12 @@ export class ClientWorld {
|
||||||
? 'lightninghit'
|
? 'lightninghit'
|
||||||
: '')
|
: '')
|
||||||
|
|
||||||
|
if (expKey === 'freezingarrowexp1') {
|
||||||
|
expKey = 'freezeexplode'
|
||||||
|
} else if (expKey === 'freezingarrowexp2') {
|
||||||
|
expKey = 'glacialspikeejecta'
|
||||||
|
}
|
||||||
|
|
||||||
if (expKey) {
|
if (expKey) {
|
||||||
const exp = this.spawnMissileByKey(expKey, hitX, hitY, hitX, hitY, {
|
const exp = this.spawnMissileByKey(expKey, hitX, hitY, hitX, hitY, {
|
||||||
ownerType: missile.ownerType,
|
ownerType: missile.ownerType,
|
||||||
|
|
@ -1592,9 +1649,8 @@ export class ClientWorld {
|
||||||
if (!Number.isFinite(dtMs)) {
|
if (!Number.isFinite(dtMs)) {
|
||||||
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
|
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
|
||||||
}
|
}
|
||||||
const safeDt = Math.max(0, dtMs)
|
const safeDt = Math.min(200, Math.max(0, dtMs))
|
||||||
const cappedDt = Math.min(200, safeDt)
|
this._tickAccumulatorMs = Math.min(200, this._tickAccumulatorMs + safeDt)
|
||||||
this._tickAccumulatorMs += cappedDt
|
|
||||||
this._simTimeMs += safeDt
|
this._simTimeMs += safeDt
|
||||||
|
|
||||||
this.expireCastStates(nowMs, safeDt)
|
this.expireCastStates(nowMs, safeDt)
|
||||||
|
|
@ -2143,9 +2199,12 @@ export class ClientWorld {
|
||||||
unit.animStepSpeed256 = speedInfo.stepSpeed256
|
unit.animStepSpeed256 = speedInfo.stepSpeed256
|
||||||
unit.animEffRate = speedInfo.effRate
|
unit.animEffRate = speedInfo.effRate
|
||||||
} else if (unit.unitType === 'monster' && resolvedMode === 3) {
|
} else if (unit.unitType === 'monster' && resolvedMode === 3) {
|
||||||
unit.modeDurationMs = 240
|
const ghFrames = unit.totalFrames && unit.totalFrames > 0 ? unit.totalFrames : 8
|
||||||
unit.modeUntilMs = now + 240
|
const ghSpeed = unit.animStepSpeed256 && unit.animStepSpeed256 > 0 ? unit.animStepSpeed256 : 256
|
||||||
unit.animStepSpeed256 = undefined
|
const ghDuration = computeOneShotDurationMs(ghFrames, ghSpeed)
|
||||||
|
unit.modeDurationMs = ghDuration
|
||||||
|
unit.modeUntilMs = now + ghDuration
|
||||||
|
unit.animStepSpeed256 = ghSpeed
|
||||||
unit.animEffRate = undefined
|
unit.animEffRate = undefined
|
||||||
} else {
|
} else {
|
||||||
unit.modeDurationMs = undefined
|
unit.modeDurationMs = undefined
|
||||||
|
|
@ -2232,7 +2291,10 @@ export class ClientWorld {
|
||||||
// 0x73 CreateMissile: caster.unitType === 3 ('missile')
|
// 0x73 CreateMissile: caster.unitType === 3 ('missile')
|
||||||
if (unitTypeFromId(casterType) === 'missile') {
|
if (unitTypeFromId(casterType) === 'missile') {
|
||||||
const missileClassId = skillId
|
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 ownerType = ev.owner ? unitTypeFromId(ev.owner.unitType) : 'player'
|
||||||
const ownerId = ev.owner?.id ?? 0
|
const ownerId = ev.owner?.id ?? 0
|
||||||
const ownerUnit = ev.owner ? this.getUnit(ev.owner.unitType, ev.owner.id) : undefined
|
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) &&
|
(unit.id === this.selfId || this.selfId === 0) &&
|
||||||
skillId === this._lastLocalCastSkillId &&
|
skillId === this._lastLocalCastSkillId &&
|
||||||
timeSinceLocalCast >= 0 &&
|
timeSinceLocalCast >= 0 &&
|
||||||
timeSinceLocalCast <= 500
|
timeSinceLocalCast <= 1000
|
||||||
if (isSelfEcho) {
|
if (isSelfEcho) {
|
||||||
this._lastLocalCastSkillId = -1
|
this._lastLocalCastSkillId = -1
|
||||||
if (unit.castState && unit.castState.skillId === skillId && now < unit.castState.untilMs) {
|
if (unit.castState && unit.castState.skillId === skillId && now < unit.castState.untilMs) {
|
||||||
unit.targetX = targetX
|
unit.faceToward(targetX, targetY, false)
|
||||||
unit.targetY = targetY
|
|
||||||
} else {
|
} else {
|
||||||
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs)
|
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 alvl = Math.max(1, params.attackerLvl)
|
||||||
const dlvl = Math.max(1, params.defenderLvl)
|
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))
|
return Math.max(5, Math.min(95, raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -879,7 +879,7 @@ describe('ClientWorld authoritative state mirror', () => {
|
||||||
)
|
)
|
||||||
expect(subBolts.length).toBeGreaterThanOrEqual(4)
|
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()
|
const msWorld = new ClientWorld()
|
||||||
msWorld.apply({
|
msWorld.apply({
|
||||||
type: 'UnitAssign',
|
type: 'UnitAssign',
|
||||||
|
|
@ -897,7 +897,7 @@ describe('ClientWorld authoritative state mirror', () => {
|
||||||
const multiShotArrows = Array.from(msWorld.units.values()).filter(
|
const multiShotArrows = Array.from(msWorld.units.values()).filter(
|
||||||
u => u.unitType === 'missile' && u.missileKey === 'multipleshotarrow',
|
u => u.unitType === 'missile' && u.missileKey === 'multipleshotarrow',
|
||||||
)
|
)
|
||||||
expect(multiShotArrows).toHaveLength(6)
|
expect(multiShotArrows).toHaveLength(7)
|
||||||
expect(multiShotArrows.every(m => m.classId === 213)).toBe(true)
|
expect(multiShotArrows.every(m => m.classId === 213)).toBe(true)
|
||||||
|
|
||||||
// 7. Challenge 3: spawnMissileByKey('arrow') without opts.classId preserves classId === 0
|
// 7. Challenge 3: spawnMissileByKey('arrow') without opts.classId preserves classId === 0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue