From 0aa7c0a1212a6b2ff1228218cbf1571a48cf34bc Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 24 Sep 2026 16:10:44 +0000 Subject: [PATCH] fix(skill): implement Hydra summon cap and authentic entity visuals (Fixes #436) --- src/scene/act-scene.ts | 85 +++++++++++++--- .../issue-435-charged-bolt-kinematics.test.ts | 12 +-- .../issue-436-hydra-cap-and-animation.test.ts | 97 +++++++++++++++++++ 3 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 tests/skills/sor/issue-436-hydra-cap-and-animation.test.ts diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 5934cfc..cec9561 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -1509,6 +1509,8 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n } } +let hydraGroupSeqCounter = 0 + export function castSkill( skillId: number, targetX: number, @@ -1612,7 +1614,7 @@ export function castSkill( player.mana = Math.max(0, player.mana - manaCost) if (hudManager !== null) { hudManager.mana = Math.max(0, hudManager.mana - manaCost) - hudManager.syncPublishedState() + hudManager.syncPublishedState?.() } } } @@ -2219,10 +2221,28 @@ export function castSkill( { dx: 0, dy: -7 }, { dx: 4, dy: -15 }, ] - const slvl = 1 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(62) ?? 1) const dmgRange = calculateSkillDamage(62, slvl) 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++) { const off = offsets[i]! engine.projectiles.push({ @@ -2238,9 +2258,10 @@ export function castSkill( isTurret: true, fireCadenceTicks: 25, headIndex: i, + groupId: currentGroupId, spawnMissileType: 'hydra', pierce: false, - }) + } as any) } player.cooldown = 40 @@ -5359,16 +5380,54 @@ export function drawMissileProjectile( } if (shot.isTurret || shot.missileType === 'hydra_turret') { - // Authentic stationary hydra head turret plume - const wobble = Math.sin((shot.ageTicks ?? 0) * 0.3 + (shot.headIndex ?? 0) * 1.5) * 2.5 - // Soft outer fire aura - renderer.drawSolid(x - 12, y - 12 + wobble * 0.5, 24, 24, [1.0, 0.32, 0.05, 0.35]) - // Mid fire core - renderer.drawSolid(x - 8, y - 8 + wobble, 16, 16, [1.0, 0.65, 0.12, 0.65]) - // Inner hot core - renderer.drawSolid(x - 4, y - 4 + wobble, 8, 8, [1.0, 0.92, 0.3, 0.9]) - // White flame peak - renderer.drawSolid(x - 2, y - 6 + wobble, 4, 6, [1.0, 1.0, 0.85, 0.95]) + const age = shot.ageTicks ?? 0 + const wobble = Math.sin(age * 0.25 + (shot.headIndex ?? 0) * 2.1) * 3 + const swayX = Math.cos(age * 0.18 + (shot.headIndex ?? 0) * 1.7) * 2.5 + + // 1. Ground magma / lava fire pit (2:1 isometric ground ellipse base) + renderer.drawSolid(x - 14, y - 5, 28, 10, [0.8, 0.2, 0.02, 0.5]) + renderer.drawSolid(x - 9, y - 3, 18, 6, [1.0, 0.5, 0.08, 0.75]) + renderer.drawSolid(x - 4, y - 2, 8, 4, [1.0, 0.9, 0.3, 0.9]) + + // 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 } diff --git a/tests/skills/sor/issue-435-charged-bolt-kinematics.test.ts b/tests/skills/sor/issue-435-charged-bolt-kinematics.test.ts index a7f925d..12a31cb 100644 --- a/tests/skills/sor/issue-435-charged-bolt-kinematics.test.ts +++ b/tests/skills/sor/issue-435-charged-bolt-kinematics.test.ts @@ -19,14 +19,14 @@ describe('Issue #435: Charged Bolt (#38) Kinematics & Random Walk', () => { // Launch a charged bolt moving strictly East (vx = 12, vy = 0) let p: Projectile = { missileType: 'chargedbolt', - sourceSkillId: 38, + skillId: '38', x: 500, y: 500, vx: 12, vy: 0, ttl: 50, damage: 10, - radius: 8, + fromPlayer: true, jitter: true, jitterIndex: 0, seed: 7, @@ -54,14 +54,14 @@ describe('Issue #435: Charged Bolt (#38) Kinematics & Random Walk', () => { it('ensures distinct random seeds yield divergent trajectories', () => { let p1: Projectile = { missileType: 'chargedbolt', - sourceSkillId: 38, + skillId: '38', x: 0, y: 0, vx: 10, vy: 5, ttl: 30, damage: 10, - radius: 8, + fromPlayer: true, jitter: true, jitterIndex: 0, seed: 1, @@ -69,14 +69,14 @@ describe('Issue #435: Charged Bolt (#38) Kinematics & Random Walk', () => { let p2: Projectile = { missileType: 'chargedbolt', - sourceSkillId: 38, + skillId: '38', x: 0, y: 0, vx: 10, vy: 5, ttl: 30, damage: 10, - radius: 8, + fromPlayer: true, jitter: true, jitterIndex: 5, seed: 29, diff --git a/tests/skills/sor/issue-436-hydra-cap-and-animation.test.ts b/tests/skills/sor/issue-436-hydra-cap-and-animation.test.ts new file mode 100644 index 0000000..94ae524 --- /dev/null +++ b/tests/skills/sor/issue-436-hydra-cap-and-animation.test.ts @@ -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) + }) +})