fix(skill): implement Hydra summon cap and authentic entity visuals (Fixes #436)
This commit is contained in:
parent
1a5f897cd6
commit
0aa7c0a121
|
|
@ -1509,6 +1509,8 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hydraGroupSeqCounter = 0
|
||||||
|
|
||||||
export function castSkill(
|
export function castSkill(
|
||||||
skillId: number,
|
skillId: number,
|
||||||
targetX: number,
|
targetX: number,
|
||||||
|
|
@ -1612,7 +1614,7 @@ export function castSkill(
|
||||||
player.mana = Math.max(0, player.mana - manaCost)
|
player.mana = Math.max(0, player.mana - manaCost)
|
||||||
if (hudManager !== null) {
|
if (hudManager !== null) {
|
||||||
hudManager.mana = Math.max(0, hudManager.mana - manaCost)
|
hudManager.mana = Math.max(0, hudManager.mana - manaCost)
|
||||||
hudManager.syncPublishedState()
|
hudManager.syncPublishedState?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2219,10 +2221,28 @@ export function castSkill(
|
||||||
{ dx: 0, dy: -7 },
|
{ dx: 0, dy: -7 },
|
||||||
{ dx: 4, dy: -15 },
|
{ dx: 4, dy: -15 },
|
||||||
]
|
]
|
||||||
const slvl = 1
|
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(62) ?? 1)
|
||||||
const dmgRange = calculateSkillDamage(62, slvl)
|
const dmgRange = calculateSkillDamage(62, slvl)
|
||||||
const hydraDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
const hydraDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||||
|
|
||||||
|
// Enforce 1.13c Hydra summon cap (MAX_ACTIVE_HYDRA_GROUPS = 6 groups, 18 heads max) with FIFO eviction
|
||||||
|
const MAX_ACTIVE_HYDRA_GROUPS = 6
|
||||||
|
const activeTurrets = engine.projectiles.filter(
|
||||||
|
p => (p.isTurret || p.missileType === 'hydra_turret') && p.ttl > 0
|
||||||
|
)
|
||||||
|
const existingGroupIds = Array.from(new Set(
|
||||||
|
activeTurrets.map(p => (p as any).groupId ?? 0)
|
||||||
|
)).sort((a, b) => a - b)
|
||||||
|
|
||||||
|
if (existingGroupIds.length >= MAX_ACTIVE_HYDRA_GROUPS) {
|
||||||
|
const groupsToEvictCount = (existingGroupIds.length - MAX_ACTIVE_HYDRA_GROUPS) + 1
|
||||||
|
const evictGids = new Set(existingGroupIds.slice(0, groupsToEvictCount))
|
||||||
|
engine.projectiles = engine.projectiles.filter(
|
||||||
|
p => !((p.isTurret || p.missileType === 'hydra_turret') && evictGids.has((p as any).groupId ?? 0))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGroupId = ++hydraGroupSeqCounter
|
||||||
for (let i = 0; i < offsets.length; i++) {
|
for (let i = 0; i < offsets.length; i++) {
|
||||||
const off = offsets[i]!
|
const off = offsets[i]!
|
||||||
engine.projectiles.push({
|
engine.projectiles.push({
|
||||||
|
|
@ -2238,9 +2258,10 @@ export function castSkill(
|
||||||
isTurret: true,
|
isTurret: true,
|
||||||
fireCadenceTicks: 25,
|
fireCadenceTicks: 25,
|
||||||
headIndex: i,
|
headIndex: i,
|
||||||
|
groupId: currentGroupId,
|
||||||
spawnMissileType: 'hydra',
|
spawnMissileType: 'hydra',
|
||||||
pierce: false,
|
pierce: false,
|
||||||
})
|
} as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
player.cooldown = 40
|
player.cooldown = 40
|
||||||
|
|
@ -5359,16 +5380,54 @@ export function drawMissileProjectile(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shot.isTurret || shot.missileType === 'hydra_turret') {
|
if (shot.isTurret || shot.missileType === 'hydra_turret') {
|
||||||
// Authentic stationary hydra head turret plume
|
const age = shot.ageTicks ?? 0
|
||||||
const wobble = Math.sin((shot.ageTicks ?? 0) * 0.3 + (shot.headIndex ?? 0) * 1.5) * 2.5
|
const wobble = Math.sin(age * 0.25 + (shot.headIndex ?? 0) * 2.1) * 3
|
||||||
// Soft outer fire aura
|
const swayX = Math.cos(age * 0.18 + (shot.headIndex ?? 0) * 1.7) * 2.5
|
||||||
renderer.drawSolid(x - 12, y - 12 + wobble * 0.5, 24, 24, [1.0, 0.32, 0.05, 0.35])
|
|
||||||
// Mid fire core
|
// 1. Ground magma / lava fire pit (2:1 isometric ground ellipse base)
|
||||||
renderer.drawSolid(x - 8, y - 8 + wobble, 16, 16, [1.0, 0.65, 0.12, 0.65])
|
renderer.drawSolid(x - 14, y - 5, 28, 10, [0.8, 0.2, 0.02, 0.5])
|
||||||
// Inner hot core
|
renderer.drawSolid(x - 9, y - 3, 18, 6, [1.0, 0.5, 0.08, 0.75])
|
||||||
renderer.drawSolid(x - 4, y - 4 + wobble, 8, 8, [1.0, 0.92, 0.3, 0.9])
|
renderer.drawSolid(x - 4, y - 2, 8, 4, [1.0, 0.9, 0.3, 0.9])
|
||||||
// White flame peak
|
|
||||||
renderer.drawSolid(x - 2, y - 6 + wobble, 4, 6, [1.0, 1.0, 0.85, 0.95])
|
// 2. Serpentine segmented neck rising from the lava pit
|
||||||
|
const seg1Y = y - 7 + wobble * 0.3
|
||||||
|
const seg1X = x + swayX * 0.3
|
||||||
|
renderer.drawSolid(seg1X - 5, seg1Y - 3, 10, 6, [0.85, 0.28, 0.05, 0.85])
|
||||||
|
renderer.drawSolid(seg1X - 3, seg1Y - 2, 6, 4, [1.0, 0.6, 0.15, 0.9])
|
||||||
|
|
||||||
|
const seg2Y = y - 14 + wobble * 0.6
|
||||||
|
const seg2X = x + swayX * 0.7
|
||||||
|
renderer.drawSolid(seg2X - 4, seg2Y - 3, 8, 6, [0.9, 0.35, 0.05, 0.85])
|
||||||
|
renderer.drawSolid(seg2X - 2, seg2Y - 2, 4, 4, [1.0, 0.7, 0.2, 0.9])
|
||||||
|
|
||||||
|
// 3. Serpent head crown with flaming mane & glowing eyes
|
||||||
|
const headY = y - 22 + wobble
|
||||||
|
const headX = x + swayX
|
||||||
|
// Flaming crest / mane
|
||||||
|
renderer.drawSolid(headX - 6, headY - 8, 12, 12, [1.0, 0.35, 0.05, 0.6])
|
||||||
|
renderer.drawSolid(headX - 4, headY - 6, 8, 9, [1.0, 0.65, 0.15, 0.85])
|
||||||
|
renderer.drawSolid(headX - 2, headY - 4, 4, 6, [1.0, 0.95, 0.4, 0.95])
|
||||||
|
// Dragon/serpent snout & jaw
|
||||||
|
renderer.drawSolid(headX - 3, headY, 6, 4, [0.8, 0.2, 0.05, 0.9])
|
||||||
|
// Glowing incandescent eye
|
||||||
|
renderer.drawSolid(headX - 1, headY - 2, 2, 2, [1.0, 1.0, 0.9, 1.0])
|
||||||
|
|
||||||
|
// 4. If missile sprite art is available (firebolt/hydra), render the fiery head sprite over the crest
|
||||||
|
const art = missileArtMap?.get('hydra') ?? missileArtMap?.get('firebolt')
|
||||||
|
if (art !== undefined) {
|
||||||
|
const group = art.frames[0]
|
||||||
|
const frameCount = Math.max(1, art.meta.framesPerDirection)
|
||||||
|
const frameIndex = Math.floor((age * art.meta.animSpeed) / 16) % frameCount
|
||||||
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX = frame.anchorX !== undefined ? headX + frame.anchorX : headX - frame.width / 2
|
||||||
|
const drawY = frame.anchorY !== undefined ? headY + frame.anchorY : headY - frame.height / 2
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: art.handle,
|
||||||
|
blendMode: 'additive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@ describe('Issue #435: Charged Bolt (#38) Kinematics & Random Walk', () => {
|
||||||
// Launch a charged bolt moving strictly East (vx = 12, vy = 0)
|
// Launch a charged bolt moving strictly East (vx = 12, vy = 0)
|
||||||
let p: Projectile = {
|
let p: Projectile = {
|
||||||
missileType: 'chargedbolt',
|
missileType: 'chargedbolt',
|
||||||
sourceSkillId: 38,
|
skillId: '38',
|
||||||
x: 500,
|
x: 500,
|
||||||
y: 500,
|
y: 500,
|
||||||
vx: 12,
|
vx: 12,
|
||||||
vy: 0,
|
vy: 0,
|
||||||
ttl: 50,
|
ttl: 50,
|
||||||
damage: 10,
|
damage: 10,
|
||||||
radius: 8,
|
fromPlayer: true,
|
||||||
jitter: true,
|
jitter: true,
|
||||||
jitterIndex: 0,
|
jitterIndex: 0,
|
||||||
seed: 7,
|
seed: 7,
|
||||||
|
|
@ -54,14 +54,14 @@ describe('Issue #435: Charged Bolt (#38) Kinematics & Random Walk', () => {
|
||||||
it('ensures distinct random seeds yield divergent trajectories', () => {
|
it('ensures distinct random seeds yield divergent trajectories', () => {
|
||||||
let p1: Projectile = {
|
let p1: Projectile = {
|
||||||
missileType: 'chargedbolt',
|
missileType: 'chargedbolt',
|
||||||
sourceSkillId: 38,
|
skillId: '38',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
vx: 10,
|
vx: 10,
|
||||||
vy: 5,
|
vy: 5,
|
||||||
ttl: 30,
|
ttl: 30,
|
||||||
damage: 10,
|
damage: 10,
|
||||||
radius: 8,
|
fromPlayer: true,
|
||||||
jitter: true,
|
jitter: true,
|
||||||
jitterIndex: 0,
|
jitterIndex: 0,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
|
|
@ -69,14 +69,14 @@ describe('Issue #435: Charged Bolt (#38) Kinematics & Random Walk', () => {
|
||||||
|
|
||||||
let p2: Projectile = {
|
let p2: Projectile = {
|
||||||
missileType: 'chargedbolt',
|
missileType: 'chargedbolt',
|
||||||
sourceSkillId: 38,
|
skillId: '38',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
vx: 10,
|
vx: 10,
|
||||||
vy: 5,
|
vy: 5,
|
||||||
ttl: 30,
|
ttl: 30,
|
||||||
damage: 10,
|
damage: 10,
|
||||||
radius: 8,
|
fromPlayer: true,
|
||||||
jitter: true,
|
jitter: true,
|
||||||
jitterIndex: 5,
|
jitterIndex: 5,
|
||||||
seed: 29,
|
seed: 29,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
/**
|
||||||
|
* Issue #436: Hydra (#62) Summon Cap & Entity Visuals
|
||||||
|
*
|
||||||
|
* 1.13c Ground Truth Invariants Verification Suite:
|
||||||
|
* 1. Summon Cap: Maximum 6 active Hydra groups (18 heads total) concurrently active.
|
||||||
|
* 2. FIFO Eviction: When 7th group is cast, the oldest 1st group is automatically evicted (pruned to 0 ttl).
|
||||||
|
* 3. Exact 3-Head Offsets: Offsets [(-6, -11), (0, -7), (4, -15)] correctly assigned per group.
|
||||||
|
* 4. Slvl Scaling: Hydra damage properly respects caster skill level instead of hardcoded slvl 1.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { GameEngine } from '../../../src/game/engine.ts'
|
||||||
|
import { castSkill } from '../../../src/scene/act-scene.ts'
|
||||||
|
|
||||||
|
describe('Issue #436: Hydra (#62) Summon Cap & Entity Visuals', () => {
|
||||||
|
it('enforces maximum 6 groups (18 heads) and FIFO eviction on successive casts', () => {
|
||||||
|
const engine = new GameEngine(
|
||||||
|
{ widthPx: 1000, heightPx: 1000, overlap: () => 0 },
|
||||||
|
{ spawn: { x: 200, y: 200 }, stats: [], questDefs: [], npcDefs: [] } as any,
|
||||||
|
)
|
||||||
|
engine.world.player.x = 200
|
||||||
|
engine.world.player.y = 200
|
||||||
|
engine.world.player.mana = 1000
|
||||||
|
|
||||||
|
const statusEl = { textContent: '' } as HTMLElement
|
||||||
|
const dummyRuntime = {
|
||||||
|
walkable: { isWalkable: () => true },
|
||||||
|
collision: { isWalkable: () => true },
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// Cast Hydra 6 times (filling cap to 6 groups = 18 heads)
|
||||||
|
for (let c = 1; c <= 6; c++) {
|
||||||
|
engine.world.player.cooldown = 0
|
||||||
|
const ok = castSkill(62, 300 + c * 20, 300, {
|
||||||
|
engine,
|
||||||
|
runtime: dummyRuntime,
|
||||||
|
hudManager: null,
|
||||||
|
status: statusEl,
|
||||||
|
})
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(engine.projectiles.length).toBe(c * 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(engine.projectiles.length).toBe(18)
|
||||||
|
const firstGroupHeadX = engine.projectiles[0]!.x
|
||||||
|
|
||||||
|
// Cast 7th time -> triggers FIFO eviction of group 1
|
||||||
|
engine.world.player.cooldown = 0
|
||||||
|
const ok7 = castSkill(62, 500, 300, {
|
||||||
|
engine,
|
||||||
|
runtime: dummyRuntime,
|
||||||
|
hudManager: null,
|
||||||
|
status: statusEl,
|
||||||
|
})
|
||||||
|
expect(ok7).toBe(true)
|
||||||
|
|
||||||
|
// Total active heads must be capped at 18 (6 groups * 3 heads)
|
||||||
|
expect(engine.projectiles.length).toBe(18)
|
||||||
|
|
||||||
|
// First group must have been evicted and no longer present in engine.projectiles
|
||||||
|
const containsOldHead = engine.projectiles.some(p => p.x === firstGroupHeadX)
|
||||||
|
expect(containsOldHead).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('correctly reads slvl from hudManager for damage calculation', () => {
|
||||||
|
const engine = new GameEngine(
|
||||||
|
{ widthPx: 1000, heightPx: 1000, overlap: () => 0 },
|
||||||
|
{ spawn: { x: 200, y: 200 }, stats: [], questDefs: [], npcDefs: [] } as any,
|
||||||
|
)
|
||||||
|
engine.world.player.x = 200
|
||||||
|
engine.world.player.y = 200
|
||||||
|
engine.world.player.mana = 1000
|
||||||
|
engine.world.player.cooldown = 0
|
||||||
|
|
||||||
|
const mockHudManager = {
|
||||||
|
getSkillLevel: (skillId: number) => (skillId === 62 ? 20 : 0),
|
||||||
|
syncPublishedState: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusEl = { textContent: '' } as HTMLElement
|
||||||
|
const dummyRuntime = {
|
||||||
|
walkable: { isWalkable: () => true },
|
||||||
|
collision: { isWalkable: () => true },
|
||||||
|
} as any
|
||||||
|
|
||||||
|
castSkill(62, 350, 250, {
|
||||||
|
engine,
|
||||||
|
runtime: dummyRuntime,
|
||||||
|
hudManager: mockHudManager as any,
|
||||||
|
status: statusEl,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(engine.projectiles.length).toBe(3)
|
||||||
|
// At slvl 20, damage is ~162 (150-175 range), much higher than slvl 1 (14-19)
|
||||||
|
const head = engine.projectiles[0]!
|
||||||
|
expect(head.damage).toBeGreaterThan(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue