fix(skill): implement Glacial Spike explosion and 64px AoE freeze (Fixes #430)
This commit is contained in:
parent
0aa6c3680d
commit
71466df5e7
|
|
@ -70,6 +70,7 @@ export interface MonsterSimInput {
|
|||
y: number
|
||||
stats: { id: string }
|
||||
chillTicks?: number
|
||||
frozenTicks?: number
|
||||
}
|
||||
|
||||
export interface AnimatorSimInput {
|
||||
|
|
@ -125,13 +126,18 @@ export function executeMonsterRenderLoop(
|
|||
const drawY = frame.anchorY !== undefined
|
||||
? monster.y + frame.anchorY
|
||||
: monster.y - frame.height + FEET_HEIGHT / 2
|
||||
const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
|
||||
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
|
||||
const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase())
|
||||
const baseBlendMode = art.blendMode ?? (isAdditiveFallback ? 'additive' : 'normal')
|
||||
renderer.draw(frame, drawX, drawY, {
|
||||
atlas: art.handle,
|
||||
blendMode: baseBlendMode,
|
||||
...(isChilled ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } : {}),
|
||||
...(isFrozen
|
||||
? { tint: [0.25, 0.6, 1.0, 1.0] as const, paletteRow: 1 }
|
||||
: isChilled
|
||||
? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 }
|
||||
: {}),
|
||||
})
|
||||
if (art.glowGroups !== undefined) {
|
||||
const glowGroup = art.glowGroups[groupIndex]
|
||||
|
|
@ -151,8 +157,13 @@ export function executeMonsterRenderLoop(
|
|||
if (!missingArtList.includes(missingId)) {
|
||||
missingArtList.push(missingId)
|
||||
}
|
||||
const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
|
||||
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
|
||||
const tint = isChilled ? ([0.4, 0.65, 1.0, 1.0] as const) : ([0.9, 0.2, 0.2, 1.0] as const)
|
||||
const tint = isFrozen
|
||||
? ([0.25, 0.6, 1.0, 1.0] as const)
|
||||
: isChilled
|
||||
? ([0.4, 0.65, 1.0, 1.0] as const)
|
||||
: ([0.9, 0.2, 0.2, 1.0] as const)
|
||||
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, tint)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -550,6 +550,17 @@ export class GameEngine {
|
|||
maxFrames,
|
||||
radius: hit.aoeRadius ?? 24,
|
||||
})
|
||||
if (missileType === 'glacialspike' || hit.skillId === '55' || hit.skillId === 'glacialspike') {
|
||||
const ejectaData = CANONICAL_113C_MISSILES['glacialspikeejecta']
|
||||
this.explosions.push({
|
||||
missileType: 'glacialspikeejecta',
|
||||
x: hit.x,
|
||||
y: hit.y,
|
||||
frame: 0,
|
||||
maxFrames: ejectaData?.animLen || 15,
|
||||
radius: hit.aoeRadius ?? 64,
|
||||
})
|
||||
}
|
||||
this.world.events.push({
|
||||
kind: 'missileExplode',
|
||||
missileType: explType,
|
||||
|
|
@ -567,7 +578,15 @@ export class GameEngine {
|
|||
for (const m of this.world.monsters) {
|
||||
if (m.state === 'dead' || m.hp <= 0) continue
|
||||
if (Math.hypot(m.x - wallHit.x, m.y - wallHit.y) <= wallHit.aoeRadius) {
|
||||
damageMonster(this.world, m.index, 10)
|
||||
const dmg = wallHit.damage ?? 10
|
||||
damageMonster(this.world, m.index, dmg)
|
||||
if (wallHit.statusEffect === 'freeze' || wallHit.missileType === 'glacialspike' || wallHit.skillId === '55' || wallHit.skillId === 'glacialspike') {
|
||||
m.frozenTicks = wallHit.statusDuration ?? 75
|
||||
} else if (wallHit.statusEffect === 'chill') {
|
||||
m.chillTicks = wallHit.statusDuration ?? 100
|
||||
} else if (wallHit.statusEffect === 'poison') {
|
||||
m.poisonTicks = wallHit.statusDuration ?? 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -585,6 +604,17 @@ export class GameEngine {
|
|||
maxFrames,
|
||||
radius: wallHit.aoeRadius ?? 24,
|
||||
})
|
||||
if (missileType === 'glacialspike' || wallHit.skillId === '55' || wallHit.skillId === 'glacialspike') {
|
||||
const ejectaData = CANONICAL_113C_MISSILES['glacialspikeejecta']
|
||||
this.explosions.push({
|
||||
missileType: 'glacialspikeejecta',
|
||||
x: wallHit.x,
|
||||
y: wallHit.y,
|
||||
frame: 0,
|
||||
maxFrames: ejectaData?.animLen || 15,
|
||||
radius: wallHit.aoeRadius ?? 64,
|
||||
})
|
||||
}
|
||||
this.world.events.push({
|
||||
kind: 'missileExplode',
|
||||
missileType: explType,
|
||||
|
|
|
|||
|
|
@ -1145,6 +1145,17 @@ export class MissileEngine {
|
|||
targetY: hitWallY,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
this.spawnMissile({
|
||||
missileNameOrId: 'glacialspikeejecta',
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: hitWallX,
|
||||
startY: hitWallY,
|
||||
targetX: hitWallX,
|
||||
targetY: hitWallY,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
subMissilesSpawned.push('glacialspikeejecta')
|
||||
} else if (m.name === 'iceblast' || m.sourceSkillId === 45) {
|
||||
|
|
@ -1463,7 +1474,19 @@ export class MissileEngine {
|
|||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
this.spawnMissile({
|
||||
missileNameOrId: 'glacialspikeejecta',
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
subMissilesSpawned.push('glacialspikeejecta')
|
||||
} else if (m.name === 'teeth') {
|
||||
const expName = m.record.explosionMissile || 'teethexplode'
|
||||
this.spawnMissile({
|
||||
|
|
|
|||
|
|
@ -815,6 +815,27 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
glacialspikeejecta: {
|
||||
name: 'glacialspikeejecta',
|
||||
id: 89,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 15,
|
||||
levRange: 0,
|
||||
celFile: 'FreezeExplodeEjecta',
|
||||
animLen: 15,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 8,
|
||||
explosionMissile: '',
|
||||
light: 11,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
frozenorb: {
|
||||
name: 'frozenorb',
|
||||
id: 260,
|
||||
|
|
@ -4384,6 +4405,9 @@ export interface ProjectileOutcome {
|
|||
readonly skillId?: string | undefined
|
||||
readonly missileType?: string | undefined
|
||||
readonly aoeRadius?: number | undefined
|
||||
readonly damage?: number | undefined
|
||||
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
|
||||
readonly statusDuration?: number | undefined
|
||||
}[] | undefined
|
||||
/** Newly spawned child projectiles during this tick (e.g. Frozen Orb bolts / nova). */
|
||||
readonly spawnedProjectiles?: readonly Projectile[] | undefined
|
||||
|
|
@ -4454,6 +4478,9 @@ export function tickProjectiles(
|
|||
skillId?: string | undefined
|
||||
missileType?: string | undefined
|
||||
aoeRadius?: number | undefined
|
||||
damage?: number | undefined
|
||||
statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
|
||||
statusDuration?: number | undefined
|
||||
}[] = []
|
||||
let expired = 0
|
||||
let wallHits = 0
|
||||
|
|
@ -4848,6 +4875,9 @@ export function tickProjectiles(
|
|||
skillId: projectile.skillId,
|
||||
missileType: projectile.missileType,
|
||||
aoeRadius: projectile.aoeRadius,
|
||||
damage: projectile.damage,
|
||||
statusEffect: projectile.statusEffect,
|
||||
statusDuration: projectile.statusDuration,
|
||||
})
|
||||
if (projectile.missileType === 'frozenorb') {
|
||||
for (let i = 0; i < 64; i += 4) {
|
||||
|
|
@ -4871,6 +4901,33 @@ export function tickProjectiles(
|
|||
pierce: false,
|
||||
})
|
||||
}
|
||||
} else if (projectile.missileType === 'glacialspike') {
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x,
|
||||
y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 15,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: 'freezeexplode',
|
||||
aoeRadius: projectile.aoeRadius ?? 64,
|
||||
pierce: true,
|
||||
})
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x,
|
||||
y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 15,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: 'glacialspikeejecta',
|
||||
aoeRadius: projectile.aoeRadius ?? 64,
|
||||
pierce: true,
|
||||
})
|
||||
} else if (isBlizzardShard(projectile.missileType)) {
|
||||
const expType = getBlizzardExplosionMissile(projectile.missileType!)
|
||||
spawned.push({
|
||||
|
|
@ -4900,6 +4957,8 @@ export function tickProjectiles(
|
|||
projectile.missileType === 'meteor' ||
|
||||
projectile.missileType === 'meteortail' ||
|
||||
projectile.missileType === 'firewallmaker' ||
|
||||
projectile.missileType === 'freezeexplode' ||
|
||||
projectile.missileType === 'glacialspikeejecta' ||
|
||||
isTurretEmitter
|
||||
if (!isHarmlessEmitter) {
|
||||
for (const target of targets) {
|
||||
|
|
@ -5015,7 +5074,34 @@ export function tickProjectiles(
|
|||
|
||||
if (!projectile.pierce) {
|
||||
struck = true
|
||||
if (isBlizzardShard(projectile.missileType)) {
|
||||
if (projectile.missileType === 'glacialspike') {
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x,
|
||||
y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 15,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: 'freezeexplode',
|
||||
aoeRadius: projectile.aoeRadius ?? 64,
|
||||
pierce: true,
|
||||
})
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x,
|
||||
y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 15,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: 'glacialspikeejecta',
|
||||
aoeRadius: projectile.aoeRadius ?? 64,
|
||||
pierce: true,
|
||||
})
|
||||
} else if (isBlizzardShard(projectile.missileType)) {
|
||||
const expType = getBlizzardExplosionMissile(projectile.missileType!)
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
|
|
|
|||
|
|
@ -5090,6 +5090,21 @@ export function drawExplosion(
|
|||
if (explosionArt !== undefined) {
|
||||
const frameCount = Math.max(1, explosionArt.meta.framesPerDirection)
|
||||
const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1)
|
||||
if (explosionArt.meta.directions > 1) {
|
||||
for (let dir = 0; dir < explosionArt.meta.directions; dir++) {
|
||||
const group = explosionArt.frames[dir]
|
||||
const frame = group?.[frameIndex] ?? group?.[0]
|
||||
if (frame !== undefined) {
|
||||
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
|
||||
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
|
||||
renderer.draw(frame, drawX, drawY, {
|
||||
atlas: explosionArt.handle,
|
||||
blendMode: 'additive',
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const group = explosionArt.frames[0]
|
||||
const frame = group?.[frameIndex] ?? group?.[0]
|
||||
if (frame !== undefined) {
|
||||
|
|
@ -6382,7 +6397,9 @@ if (typeof window !== 'undefined') {
|
|||
const clip = art.clips['nu'] ?? art.clips['wl']
|
||||
if (clip !== undefined) animator.play('nu', clip)
|
||||
}
|
||||
animator.tick()
|
||||
if (!monster.frozenTicks || monster.frozenTicks <= 0) {
|
||||
animator.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const idx of monsterAnimators.keys()) {
|
||||
|
|
@ -6770,6 +6787,7 @@ if (typeof window !== 'undefined') {
|
|||
const drawY = frame.anchorY !== undefined
|
||||
? monster.y + frame.anchorY
|
||||
: monster.y - frame.height + FEET_HEIGHT / 2
|
||||
const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
|
||||
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
|
||||
const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase())
|
||||
const baseBlendMode = art.blendMode ?? (isAdditiveFallback ? 'additive' : 'normal')
|
||||
|
|
@ -6777,7 +6795,11 @@ if (typeof window !== 'undefined') {
|
|||
atlas: art.handle,
|
||||
blendMode: baseBlendMode,
|
||||
elevationY: Math.max(0, frame.height - 16),
|
||||
...(isChilled ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } : {}),
|
||||
...(isFrozen
|
||||
? { tint: [0.25, 0.6, 1.0, 1.0] as const, paletteRow: 1 }
|
||||
: isChilled
|
||||
? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 }
|
||||
: {}),
|
||||
})
|
||||
if (art.glowGroups !== undefined) {
|
||||
const glowGroup = art.glowGroups[groupIndex]
|
||||
|
|
@ -6798,8 +6820,13 @@ if (typeof window !== 'undefined') {
|
|||
state.missingMonsterArt.push(missingId)
|
||||
console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`)
|
||||
}
|
||||
const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
|
||||
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
|
||||
const tint = isChilled ? ([0.4, 0.65, 1.0, 1.0] as const) : ([0.9, 0.2, 0.2, 1.0] as const)
|
||||
const tint = isFrozen
|
||||
? ([0.25, 0.6, 1.0, 1.0] as const)
|
||||
: isChilled
|
||||
? ([0.4, 0.65, 1.0, 1.0] as const)
|
||||
: ([0.9, 0.2, 0.2, 1.0] as const)
|
||||
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, tint)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -272,4 +272,33 @@ describe('Monster Rendering Dispatch in act-scene.ts (Milestone 2 / R2)', () =>
|
|||
expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal')
|
||||
expect(renderer.drawCalls[1]!.options.blendMode).toBe('additive')
|
||||
})
|
||||
|
||||
it('Case 8: Frozen monster applies ice blue tint [0.25, 0.6, 1.0, 1.0] and paletteRow: 1 to base body without polluting additive glow pass', () => {
|
||||
const renderer = new MockRenderer()
|
||||
const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true, glowFrameSize: [64, 64] })
|
||||
const artMap = new Map<string, LoadedMonsterArt>([['frozen_fr', art]])
|
||||
const monster: MonsterSimInput = {
|
||||
index: 9,
|
||||
state: 'chase',
|
||||
corpseTicks: 0,
|
||||
facing: 0,
|
||||
x: 200,
|
||||
y: 300,
|
||||
stats: { id: 'frozen_fr' },
|
||||
frozenTicks: 75,
|
||||
}
|
||||
|
||||
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
|
||||
|
||||
expect(renderer.drawCalls).toHaveLength(2)
|
||||
const [baseCall, glowCall] = renderer.drawCalls
|
||||
|
||||
expect(baseCall!.options.paletteRow).toBe(1)
|
||||
expect(baseCall!.options.tint).toEqual([0.25, 0.6, 1.0, 1.0])
|
||||
expect(baseCall!.options.blendMode).toBe('normal')
|
||||
|
||||
expect(glowCall!.options.paletteRow).toBeUndefined()
|
||||
expect(glowCall!.options.tint).toBeUndefined()
|
||||
expect(glowCall!.options.blendMode).toBe('additive')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1170,8 +1170,12 @@ describe('Diablo II v1.13c Sorceress Cold & Fire Cross-Tree E2E Integration Stre
|
|||
// Fire Wall struck 5 times (once per tick): 5 * 50 = 250 fire damage!
|
||||
expect(totalFireDamage).toBe(250)
|
||||
// Fire Wall patch is still burning (30 - 5 = 25 ttl)
|
||||
expect(projs).toHaveLength(1)
|
||||
expect(projs[0]!.ttl).toBe(25)
|
||||
const fwPatches = projs.filter(p => p.missileType === 'firewall')
|
||||
expect(fwPatches).toHaveLength(1)
|
||||
expect(fwPatches[0]!.ttl).toBe(25)
|
||||
// Glacial Spike explosion & ejecta submissiles are active
|
||||
expect(projs.some(p => p.missileType === 'freezeexplode')).toBe(true)
|
||||
expect(projs.some(p => p.missileType === 'glacialspikeejecta')).toBe(true)
|
||||
})
|
||||
|
||||
it('verifies player running with Blaze active generates footstep patches while casting Glacial Spike with zero self-damage', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue