fix(skill): extend Meteor ground fire duration and add extinction decay animation (Fixes #437)
This commit is contained in:
parent
0aa7c0a121
commit
f12fd5674a
|
|
@ -1687,7 +1687,7 @@ export class MissileEngine {
|
||||||
|
|
||||||
// 3. Spawn 18 meteorfire submissiles using 18-offset table
|
// 3. Spawn 18 meteorfire submissiles using 18-offset table
|
||||||
const subFireName = m.record.hitSubMissile1 || 'meteorfire'
|
const subFireName = m.record.hitSubMissile1 || 'meteorfire'
|
||||||
const fireDuration = 30 + (Math.max(1, m.slvl) - 1) * 15
|
const fireDuration = 90 + (Math.max(1, m.slvl) - 1) * 15
|
||||||
const fireDmg = calculateMeteorResidualFireDamage(m.slvl)
|
const fireDmg = calculateMeteorResidualFireDamage(m.slvl)
|
||||||
const fireDmgPacket: SUnitDmgPacket = {
|
const fireDmgPacket: SUnitDmgPacket = {
|
||||||
skillId: m.sourceSkillId,
|
skillId: m.sourceSkillId,
|
||||||
|
|
|
||||||
|
|
@ -4778,7 +4778,7 @@ export function tickProjectiles(
|
||||||
|
|
||||||
// 3. Spawn 18 meteorfire submissiles using 18-offset table
|
// 3. Spawn 18 meteorfire submissiles using 18-offset table
|
||||||
const slvl = Math.max(1, projectile.slvl ?? 1)
|
const slvl = Math.max(1, projectile.slvl ?? 1)
|
||||||
const fireDuration = projectile.fireDuration ?? (30 + (slvl - 1) * 15)
|
const fireDuration = projectile.fireDuration ?? (90 + (slvl - 1) * 15)
|
||||||
const residualDmg = projectile.residualDamage ?? calculateMeteorResidualFireDamage(slvl).min
|
const residualDmg = projectile.residualDamage ?? calculateMeteorResidualFireDamage(slvl).min
|
||||||
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
|
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
|
||||||
const fx = projectile.x + off.x * 20
|
const fx = projectile.x + off.x * 20
|
||||||
|
|
@ -4801,6 +4801,7 @@ export function tickProjectiles(
|
||||||
aoeRadius: 16,
|
aoeRadius: 16,
|
||||||
pierce: true,
|
pierce: true,
|
||||||
slvl,
|
slvl,
|
||||||
|
fireDuration,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5287,10 +5287,16 @@ export function drawMissileProjectile(
|
||||||
} else if (missileArt.meta.name === 'meteorfire' && frameCount === 37) {
|
} else if (missileArt.meta.name === 'meteorfire' && frameCount === 37) {
|
||||||
// For meteorfire: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
|
// For meteorfire: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
|
||||||
// Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)).
|
// Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)).
|
||||||
const duration = shot.fireDuration ?? (30 + (Math.max(1, shot.slvl ?? 1) - 1) * 15)
|
// Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade.
|
||||||
|
const duration = shot.fireDuration ?? (90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15)
|
||||||
const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
|
const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
|
||||||
const animStep = Math.floor((elapsed * missileArt.meta.animSpeed) / 16)
|
const EXTINCTION_TICKS = 12
|
||||||
frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25)
|
if (shot.ttl <= EXTINCTION_TICKS) {
|
||||||
|
frameIndex = Math.max(0, Math.min(11, shot.ttl - 1))
|
||||||
|
} else {
|
||||||
|
const animStep = Math.floor((elapsed * missileArt.meta.animSpeed) / 16)
|
||||||
|
frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl)
|
const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl)
|
||||||
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
||||||
|
|
@ -5299,12 +5305,29 @@ export function drawMissileProjectile(
|
||||||
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
|
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
|
||||||
const frame = group?.[frameIndex] ?? group?.[0]
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2
|
const isExtinguishing = missileArt.meta.name === 'meteorfire' && shot.ttl <= 12
|
||||||
const drawY = (frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2) - altitude
|
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
|
||||||
|
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
|
||||||
|
const opacity = isExtinguishing ? decayRatio : 1.0
|
||||||
|
|
||||||
|
const drawW = frame.width * scale
|
||||||
|
const drawH = frame.height * scale
|
||||||
|
const baseAnchorX = frame.anchorX !== undefined ? frame.anchorX : -frame.width / 2
|
||||||
|
const baseAnchorY = frame.anchorY !== undefined ? frame.anchorY : -frame.height / 2
|
||||||
|
const drawX = shot.x + baseAnchorX * scale
|
||||||
|
const drawY = (shot.y + (baseAnchorY + frame.height) * scale - drawH) - altitude
|
||||||
|
|
||||||
const isAdditive = getMissileTxtData(shot.missileType ?? missileArt.meta.name).trans === 1
|
const isAdditive = getMissileTxtData(shot.missileType ?? missileArt.meta.name).trans === 1
|
||||||
renderer.draw(frame, drawX, drawY, {
|
renderer.draw(frame, drawX, drawY, {
|
||||||
atlas: missileArt.handle,
|
atlas: missileArt.handle,
|
||||||
...(isAdditive ? { blendMode: 'additive' } : {}),
|
...(isAdditive ? { blendMode: 'additive' } : {}),
|
||||||
|
...(isExtinguishing
|
||||||
|
? {
|
||||||
|
width: drawW,
|
||||||
|
height: drawH,
|
||||||
|
tint: [1, 1, 1, opacity],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Dual Sprite Falling Trajectory & Rendering:
|
// Dual Sprite Falling Trajectory & Rendering:
|
||||||
|
|
@ -5431,6 +5454,17 @@ export function drawMissileProjectile(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shot.missileType === 'meteorfire') {
|
||||||
|
const isExtinguishing = shot.ttl <= 12
|
||||||
|
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
|
||||||
|
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
|
||||||
|
const alpha = isExtinguishing ? decayRatio : 1.0
|
||||||
|
renderer.drawSolid(x - 12 * scale, y - 6 * scale, 24 * scale, 12 * scale, [1.0, 0.35, 0.05, 0.5 * alpha])
|
||||||
|
renderer.drawSolid(x - 8 * scale, y - 4 * scale, 16 * scale, 8 * scale, [1.0, 0.65, 0.1, 0.7 * alpha])
|
||||||
|
renderer.drawSolid(x - 4 * scale, y - 2 * scale, 8 * scale, 4 * scale, [1.0, 0.95, 0.3, 0.9 * alpha])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const vx = shot.vx
|
const vx = shot.vx
|
||||||
const vy = shot.vy
|
const vy = shot.vy
|
||||||
const speed = Math.hypot(vx, vy)
|
const speed = Math.hypot(vx, vy)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
/**
|
||||||
|
* Issue #437: Meteor (#56) Ground Fire Duration & Extinction Decay Animation
|
||||||
|
*
|
||||||
|
* 1.13c Ground Truth Invariants:
|
||||||
|
* 1. Duration: meteorfire base duration is 90 ticks (3.6s) + 15 ticks/lvl (90 + (slvl - 1) * 15).
|
||||||
|
* 2. Extinction Shrink & Fade: At life end (ttl <= 12), flames do not abruptly vanish in 1 frame.
|
||||||
|
* Instead, animation steps reverse from frame 11 down to 0, and renderer applies shrinking scale
|
||||||
|
* and opacity fade.
|
||||||
|
* 3. Procedural Fallback: When sprite art is unavailable, ground fire renders scaled and faded
|
||||||
|
* stationary flame patches rather than missile trails.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { tickProjectiles } from '../../../src/game/skills.ts'
|
||||||
|
import type { Projectile } from '../../../src/game/skills.ts'
|
||||||
|
import { drawMissileProjectile } from '../../../src/scene/act-scene.ts'
|
||||||
|
import type { LoadedMissileArt } from '../../../src/scene/act-scene.ts'
|
||||||
|
import { METEORFIRE_META } from '../../../src/render/missiles-meta.ts'
|
||||||
|
import type { SpriteRenderer, AtlasHandle } from '../../../src/render/renderer.ts'
|
||||||
|
|
||||||
|
describe('Issue #437: Meteor Ground Fire Duration & Extinction Animation', () => {
|
||||||
|
it('enforces 1.13c ground truth duration: 90 ticks base + 15 ticks/lvl', () => {
|
||||||
|
// slvl 1: 90 ticks (3.6s)
|
||||||
|
const meteorCenter1: Projectile = {
|
||||||
|
skillId: '56',
|
||||||
|
missileType: 'meteorcenter',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 0,
|
||||||
|
fromPlayer: true,
|
||||||
|
slvl: 1,
|
||||||
|
}
|
||||||
|
const outcome1 = tickProjectiles([meteorCenter1], [], { overlap: () => 0 })
|
||||||
|
const flames1 = outcome1.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||||
|
expect(flames1.length).toBe(18)
|
||||||
|
for (const f of flames1) {
|
||||||
|
expect(f.ttl).toBe(90)
|
||||||
|
}
|
||||||
|
|
||||||
|
// slvl 5: 90 + 4 * 15 = 150 ticks (6.0s)
|
||||||
|
const meteorCenter5: Projectile = {
|
||||||
|
skillId: '56',
|
||||||
|
missileType: 'meteorcenter',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 0,
|
||||||
|
fromPlayer: true,
|
||||||
|
slvl: 5,
|
||||||
|
}
|
||||||
|
const outcome5 = tickProjectiles([meteorCenter5], [], { overlap: () => 0 })
|
||||||
|
const flames5 = outcome5.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||||
|
expect(flames5.length).toBe(18)
|
||||||
|
for (const f of flames5) {
|
||||||
|
expect(f.ttl).toBe(150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// slvl 20: 90 + 19 * 15 = 375 ticks (15.0s)
|
||||||
|
const meteorCenter20: Projectile = {
|
||||||
|
skillId: '56',
|
||||||
|
missileType: 'meteorcenter',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 0,
|
||||||
|
fromPlayer: true,
|
||||||
|
slvl: 20,
|
||||||
|
}
|
||||||
|
const outcome20 = tickProjectiles([meteorCenter20], [], { overlap: () => 0 })
|
||||||
|
const flames20 = outcome20.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||||
|
expect(flames20.length).toBe(18)
|
||||||
|
for (const f of flames20) {
|
||||||
|
expect(f.ttl).toBe(375)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders reverse emergence sequence and shrinking scale/alpha during extinction (ttl <= 12)', () => {
|
||||||
|
const drawnCalls: Array<{ frameIdx: number; width?: number; height?: number; tint?: readonly number[] }> = []
|
||||||
|
const mockRenderer = {
|
||||||
|
draw(frame: any, _x: number, _y: number, options: any = {}) {
|
||||||
|
drawnCalls.push({
|
||||||
|
frameIdx: frame.frameIdx,
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
tint: options.tint,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
drawSolid: vi.fn(),
|
||||||
|
} as unknown as SpriteRenderer
|
||||||
|
|
||||||
|
const makeIndexedArt = (meta: any): LoadedMissileArt => ({
|
||||||
|
meta,
|
||||||
|
handle: 1 as unknown as AtlasHandle,
|
||||||
|
frames: [
|
||||||
|
meta.groups[0].map(([x, y, w, h, ax, ay]: number[], idx: number) => ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
anchorX: ax,
|
||||||
|
anchorY: ay,
|
||||||
|
frameIdx: idx,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const fireArt = makeIndexedArt(METEORFIRE_META)
|
||||||
|
|
||||||
|
// Extinction phase: test ttl = 12 down to ttl = 1
|
||||||
|
for (let ttl = 12; ttl >= 1; ttl--) {
|
||||||
|
drawnCalls.length = 0
|
||||||
|
const shot: Projectile = {
|
||||||
|
skillId: '56',
|
||||||
|
missileType: 'meteorfire',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: 10,
|
||||||
|
ttl,
|
||||||
|
ageTicks: 90 - ttl,
|
||||||
|
fromPlayer: true,
|
||||||
|
fireDuration: 90,
|
||||||
|
}
|
||||||
|
drawMissileProjectile(mockRenderer, shot, fireArt)
|
||||||
|
expect(drawnCalls.length).toBe(1)
|
||||||
|
const call = drawnCalls[0]!
|
||||||
|
|
||||||
|
// Frame index should step downwards from 11 to 0
|
||||||
|
const expectedFrame = Math.max(0, Math.min(11, ttl - 1))
|
||||||
|
expect(call.frameIdx).toBe(expectedFrame)
|
||||||
|
|
||||||
|
// Scale must shrink towards ground
|
||||||
|
const originalW = fireArt.frames[0]![expectedFrame]!.width
|
||||||
|
expect(call.width).toBeDefined()
|
||||||
|
expect(call.width!).toBeLessThanOrEqual(originalW)
|
||||||
|
|
||||||
|
// Opacity tint must fade towards 0
|
||||||
|
expect(call.tint).toBeDefined()
|
||||||
|
expect(call.tint![3]).toBeCloseTo(ttl / 12, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws shrinking procedural fallback when art is not loaded', () => {
|
||||||
|
const solidCalls: Array<{ x: number; y: number; w: number; h: number; color: readonly number[] }> = []
|
||||||
|
const mockRenderer = {
|
||||||
|
draw: vi.fn(),
|
||||||
|
drawSolid(x: number, y: number, w: number, h: number, color: any) {
|
||||||
|
solidCalls.push({ x, y, w, h, color })
|
||||||
|
},
|
||||||
|
} as unknown as SpriteRenderer
|
||||||
|
|
||||||
|
// Test extinction at ttl = 6 (50% decay)
|
||||||
|
const shotExtinguishing: Projectile = {
|
||||||
|
skillId: '56',
|
||||||
|
missileType: 'meteorfire',
|
||||||
|
x: 200,
|
||||||
|
y: 200,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: 10,
|
||||||
|
ttl: 6,
|
||||||
|
ageTicks: 84,
|
||||||
|
fromPlayer: true,
|
||||||
|
fireDuration: 90,
|
||||||
|
}
|
||||||
|
drawMissileProjectile(mockRenderer, shotExtinguishing)
|
||||||
|
expect(solidCalls.length).toBe(3) // 3 concentric fire layers
|
||||||
|
// Middle layer width at 50% decay: scale = 0.2 + 0.8 * 0.5 = 0.6 => w = 16 * 0.6 = 9.6
|
||||||
|
expect(solidCalls[1]!.w).toBeCloseTo(9.6, 1)
|
||||||
|
// Alpha at 50% decay: 0.7 * 0.5 = 0.35
|
||||||
|
expect(solidCalls[1]!.color[3]).toBeCloseTo(0.35, 2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -403,7 +403,7 @@ describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210 & #401)', () =>
|
||||||
const flames10 = outcome10.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
const flames10 = outcome10.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||||
expect(flames10.length).toBe(18)
|
expect(flames10.length).toBe(18)
|
||||||
for (const f of flames10) {
|
for (const f of flames10) {
|
||||||
expect(f.ttl).toBe(30 + 9 * 15) // 165 ticks = 6.60 seconds
|
expect(f.ttl).toBe(90 + 9 * 15) // 225 ticks = 9.00 seconds
|
||||||
expect(f.pierce).toBe(true)
|
expect(f.pierce).toBe(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue