feat(skills): implement Hydra (Skill 62) with 1.13c parity and visual assets (Ref #399)
This commit is contained in:
parent
ef2dbcdbdf
commit
ebaa1b4df2
|
|
@ -65,6 +65,9 @@ export interface ActiveMissile {
|
||||||
expired: boolean
|
expired: boolean
|
||||||
subMissileIdx?: number
|
subMissileIdx?: number
|
||||||
currentHitFrame?: number
|
currentHitFrame?: number
|
||||||
|
isTurret?: boolean | undefined
|
||||||
|
fireCadenceTicks?: number | undefined
|
||||||
|
spawnMissileType?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MissileStepResult {
|
export interface MissileStepResult {
|
||||||
|
|
@ -99,6 +102,10 @@ export class MissileEngine {
|
||||||
dmgPacket: SUnitDmgPacket
|
dmgPacket: SUnitDmgPacket
|
||||||
pierceChancePct?: number
|
pierceChancePct?: number
|
||||||
chainCount?: number
|
chainCount?: number
|
||||||
|
isTurret?: boolean
|
||||||
|
fireCadenceTicks?: number
|
||||||
|
spawnMissileType?: string
|
||||||
|
maxRangeTicks?: number
|
||||||
}): ActiveMissile | null {
|
}): ActiveMissile | null {
|
||||||
const rec =
|
const rec =
|
||||||
typeof params.missileNameOrId === 'number'
|
typeof params.missileNameOrId === 'number'
|
||||||
|
|
@ -240,10 +247,17 @@ export class MissileEngine {
|
||||||
const dy = params.targetY - params.startY
|
const dy = params.targetY - params.startY
|
||||||
const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
|
const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
|
||||||
const speedPxPerTick = Math.max(8, effectiveRec.vel || 18)
|
const speedPxPerTick = Math.max(8, effectiveRec.vel || 18)
|
||||||
const maxRangeTicks = Math.max(
|
const isTurret = Boolean(params.isTurret || effectiveRec.name.toLowerCase() === 'hydra_turret')
|
||||||
|
const vx = isTurret ? 0 : Math.cos(angleRad) * speedPxPerTick
|
||||||
|
const vy = isTurret ? 0 : Math.sin(angleRad) * speedPxPerTick
|
||||||
|
const maxRangeTicks = params.maxRangeTicks ?? (
|
||||||
|
isTurret
|
||||||
|
? 250 // 1.13c Hydra duration: 250 frames (10 seconds)
|
||||||
|
: Math.max(
|
||||||
10,
|
10,
|
||||||
(effectiveRec.range || 35) + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0),
|
(effectiveRec.range || 35) + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const missile: ActiveMissile = {
|
const missile: ActiveMissile = {
|
||||||
uid: `msl-${String(this.uidCounter++)}`,
|
uid: `msl-${String(this.uidCounter++)}`,
|
||||||
|
|
@ -256,8 +270,8 @@ export class MissileEngine {
|
||||||
record: effectiveRec,
|
record: effectiveRec,
|
||||||
x: params.startX,
|
x: params.startX,
|
||||||
y: params.startY,
|
y: params.startY,
|
||||||
vx: Math.cos(angleRad) * speedPxPerTick,
|
vx,
|
||||||
vy: Math.sin(angleRad) * speedPxPerTick,
|
vy,
|
||||||
angleRad,
|
angleRad,
|
||||||
ageTicks: 0,
|
ageTicks: 0,
|
||||||
maxRangeTicks,
|
maxRangeTicks,
|
||||||
|
|
@ -270,6 +284,17 @@ export class MissileEngine {
|
||||||
dmgPacket: params.dmgPacket,
|
dmgPacket: params.dmgPacket,
|
||||||
chainRemaining: params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0),
|
chainRemaining: params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0),
|
||||||
expired: false,
|
expired: false,
|
||||||
|
isTurret,
|
||||||
|
...(params.fireCadenceTicks !== undefined
|
||||||
|
? { fireCadenceTicks: params.fireCadenceTicks }
|
||||||
|
: isTurret
|
||||||
|
? { fireCadenceTicks: 25 }
|
||||||
|
: {}),
|
||||||
|
...(params.spawnMissileType !== undefined
|
||||||
|
? { spawnMissileType: params.spawnMissileType }
|
||||||
|
: isTurret
|
||||||
|
? { spawnMissileType: 'hydra' }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
this.missiles.push(missile)
|
this.missiles.push(missile)
|
||||||
|
|
@ -341,9 +366,51 @@ export class MissileEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Turret emitter (Hydra heads): stationary emitter periodically firing bolts at nearest target
|
||||||
|
const isTurretEmitter = Boolean(m.isTurret || m.name.toLowerCase() === 'hydra_turret')
|
||||||
|
if (isTurretEmitter) {
|
||||||
|
const cadence = m.fireCadenceTicks ?? 25
|
||||||
|
if (m.ageTicks % cadence === 0) {
|
||||||
|
let closestTargetId: string | null = null
|
||||||
|
let closestDist = Infinity
|
||||||
|
const maxRangePx = 480 // 1.13c Range = 30, Vel = 16 -> 480 px
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
if (target.statList.getHp256() <= 0) continue
|
||||||
|
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
||||||
|
const dx = pos.x - m.x
|
||||||
|
const dy = (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
if (dist <= maxRangePx && dist < closestDist) {
|
||||||
|
closestDist = dist
|
||||||
|
closestTargetId = target.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closestTargetId) {
|
||||||
|
const targetPos = targetPositions.get(closestTargetId) ?? { x: 0, y: 120 }
|
||||||
|
const subName = m.spawnMissileType || 'hydra'
|
||||||
|
this.spawnMissile({
|
||||||
|
missileNameOrId: subName,
|
||||||
|
sourceSkillId: m.sourceSkillId,
|
||||||
|
slvl: m.slvl,
|
||||||
|
owner: m.owner,
|
||||||
|
startX: m.x,
|
||||||
|
startY: m.y,
|
||||||
|
targetX: targetPos.x,
|
||||||
|
targetY: targetPos.y,
|
||||||
|
dmgPacket: m.dmgPacket,
|
||||||
|
})
|
||||||
|
subMissilesSpawned.push(subName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`)
|
// Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`)
|
||||||
// In 2:1 isometric ground perspective, vertical expansion is foreshortened by 0.5.
|
// In 2:1 isometric ground perspective, vertical expansion is foreshortened by 0.5.
|
||||||
if (m.sourceSkillId === 112 || m.name.toLowerCase() === 'blessedhammer') {
|
if (isTurretEmitter) {
|
||||||
|
// Stationary turret does not move
|
||||||
|
} else if (m.sourceSkillId === 112 || m.name.toLowerCase() === 'blessedhammer') {
|
||||||
m.angleRad += 0.24
|
m.angleRad += 0.24
|
||||||
const radius = 14 + m.ageTicks * 4
|
const radius = 14 + m.ageTicks * 4
|
||||||
m.x += Math.cos(m.angleRad) * (radius * 0.25)
|
m.x += Math.cos(m.angleRad) * (radius * 0.25)
|
||||||
|
|
@ -367,9 +434,9 @@ export class MissileEngine {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main Frozen Orb (pSrvDoFunc === 15) does not deal direct contact damage;
|
// Main Frozen Orb (pSrvDoFunc === 15) and Hydra turret emitters do not deal direct contact damage;
|
||||||
// all its damage is delivered through emitted child bolts (frozenorbbolt, frozenorbnova).
|
// all damage is delivered through emitted child bolts.
|
||||||
if (m.record.pSrvDoFunc === 15) {
|
if (m.record.pSrvDoFunc === 15 || isTurretEmitter) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,19 @@ export interface Projectile {
|
||||||
readonly subMissileIdx?: number | undefined
|
readonly subMissileIdx?: number | undefined
|
||||||
/** Affine curl damping trajectory in last 6 frames (e.g. Frozen Orb Nova). */
|
/** Affine curl damping trajectory in last 6 frames (e.g. Frozen Orb Nova). */
|
||||||
readonly isNovaCurl?: boolean | undefined
|
readonly isNovaCurl?: boolean | undefined
|
||||||
|
/** Stationary turret emitter (e.g. Hydra, Skill 62). */
|
||||||
|
readonly isTurret?: boolean | undefined
|
||||||
|
/** Fire cadence interval in ticks for turret emitters (e.g. 25). */
|
||||||
|
readonly fireCadenceTicks?: number | undefined
|
||||||
|
/** Head index within multi-head cluster (e.g. 0, 1, 2). */
|
||||||
|
readonly headIndex?: number | undefined
|
||||||
|
/** Pre-configured target world coordinate when targets array is empty. */
|
||||||
|
readonly targetX?: number | undefined
|
||||||
|
readonly targetY?: number | undefined
|
||||||
|
/** Missile type spawned by turret (defaults to 'hydra'). */
|
||||||
|
readonly spawnMissileType?: string | undefined
|
||||||
|
/** Number of elapsed ticks this projectile has lived. */
|
||||||
|
readonly ageTicks?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -446,6 +459,46 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
|
hydra: {
|
||||||
|
name: 'hydra',
|
||||||
|
id: 247,
|
||||||
|
vel: 16,
|
||||||
|
maxVel: 16,
|
||||||
|
range: 30,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'Firebolt',
|
||||||
|
animLen: 5,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 1,
|
||||||
|
numDirections: 16,
|
||||||
|
explosionMissile: 'fireexplode',
|
||||||
|
light: 5,
|
||||||
|
red: 255,
|
||||||
|
green: 255,
|
||||||
|
blue: 255,
|
||||||
|
speedPxPerSec: 400,
|
||||||
|
distancePx: 480,
|
||||||
|
},
|
||||||
|
hydrafire: {
|
||||||
|
name: 'hydrafire',
|
||||||
|
id: 247,
|
||||||
|
vel: 16,
|
||||||
|
maxVel: 16,
|
||||||
|
range: 30,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'Firebolt',
|
||||||
|
animLen: 5,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 1,
|
||||||
|
numDirections: 16,
|
||||||
|
explosionMissile: 'fireexplode',
|
||||||
|
light: 5,
|
||||||
|
red: 255,
|
||||||
|
green: 255,
|
||||||
|
blue: 255,
|
||||||
|
speedPxPerSec: 400,
|
||||||
|
distancePx: 480,
|
||||||
|
},
|
||||||
chargedbolt: {
|
chargedbolt: {
|
||||||
name: 'chargedbolt',
|
name: 'chargedbolt',
|
||||||
id: 56,
|
id: 56,
|
||||||
|
|
@ -2629,6 +2682,65 @@ export function tickProjectiles(
|
||||||
subMissileIdx = (idx + 19) % 64
|
subMissileIdx = (idx + 19) % 64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Stationary Turret Emitter (e.g. Hydra, Skill 62)
|
||||||
|
const isTurretEmitter =
|
||||||
|
projectile.isTurret === true ||
|
||||||
|
projectile.missileType === 'hydra_turret' ||
|
||||||
|
(projectile.missileType === 'hydra' && projectile.vx === 0 && projectile.vy === 0)
|
||||||
|
|
||||||
|
if (isTurretEmitter) {
|
||||||
|
x = projectile.x
|
||||||
|
y = projectile.y
|
||||||
|
vx = 0
|
||||||
|
vy = 0
|
||||||
|
const currentAge = (projectile.ageTicks ?? 0) + 1
|
||||||
|
const cadence = projectile.fireCadenceTicks ?? 25
|
||||||
|
if (currentAge % cadence === 0) {
|
||||||
|
let targetX: number | undefined
|
||||||
|
let targetY: number | undefined
|
||||||
|
let minDistance = Infinity
|
||||||
|
const maxRangePx = 480 // 1.13c Range = 30, Vel = 16 -> 480 px
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!target.alive) continue
|
||||||
|
const dx = target.x - x
|
||||||
|
const dy = (target.y - y) / ISO_GROUND_ASPECT_RATIO
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
if (dist <= maxRangePx && dist < minDistance) {
|
||||||
|
minDistance = dist
|
||||||
|
targetX = target.x
|
||||||
|
targetY = target.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetX === undefined && projectile.targetX !== undefined && projectile.targetY !== undefined) {
|
||||||
|
targetX = projectile.targetX
|
||||||
|
targetY = projectile.targetY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetX !== undefined && targetY !== undefined) {
|
||||||
|
const dx = targetX - x
|
||||||
|
const dy = targetY - y
|
||||||
|
const angle = Math.atan2(dy, dx)
|
||||||
|
const speed = 16 // 1.13c Missiles.txt: vel = 16 (400 px/s)
|
||||||
|
spawned.push({
|
||||||
|
skillId: projectile.skillId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
damage: projectile.damage > 0 ? projectile.damage : 28,
|
||||||
|
ttl: 30, // 1.13c Missiles.txt: range = 30
|
||||||
|
fromPlayer: projectile.fromPlayer,
|
||||||
|
missileType: projectile.spawnMissileType ?? 'hydra',
|
||||||
|
statusEffect: projectile.statusEffect,
|
||||||
|
statusDuration: projectile.statusDuration,
|
||||||
|
pierce: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
||||||
const moved: Projectile = {
|
const moved: Projectile = {
|
||||||
...projectile,
|
...projectile,
|
||||||
|
|
@ -2639,6 +2751,7 @@ export function tickProjectiles(
|
||||||
spiral,
|
spiral,
|
||||||
subMissileIdx,
|
subMissileIdx,
|
||||||
hitTargets,
|
hitTargets,
|
||||||
|
ageTicks: (projectile.ageTicks ?? 0) + 1,
|
||||||
ttl: projectile.ttl - 1,
|
ttl: projectile.ttl - 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2683,7 +2796,7 @@ export function tickProjectiles(
|
||||||
}
|
}
|
||||||
|
|
||||||
let struck = false
|
let struck = false
|
||||||
const isHarmlessEmitter = projectile.missileType === 'frozenorb'
|
const isHarmlessEmitter = projectile.missileType === 'frozenorb' || isTurretEmitter
|
||||||
if (!isHarmlessEmitter) {
|
if (!isHarmlessEmitter) {
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
if (!target.alive) continue
|
if (!target.alive) continue
|
||||||
|
|
@ -2762,6 +2875,11 @@ export interface Batch1SkillDef {
|
||||||
readonly elev3: number
|
readonly elev3: number
|
||||||
readonly elev4: number
|
readonly elev4: number
|
||||||
readonly elev5: number
|
readonly elev5: number
|
||||||
|
readonly emaxlev1?: number
|
||||||
|
readonly emaxlev2?: number
|
||||||
|
readonly emaxlev3?: number
|
||||||
|
readonly emaxlev4?: number
|
||||||
|
readonly emaxlev5?: number
|
||||||
readonly srcDamage: number
|
readonly srcDamage: number
|
||||||
readonly range?: number
|
readonly range?: number
|
||||||
readonly speed?: number
|
readonly speed?: number
|
||||||
|
|
@ -2776,13 +2894,14 @@ export const BATCH1_SKILLS: Readonly<Record<number, Batch1SkillDef>> = Object.fr
|
||||||
10: { id: 10, name: 'Jab', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
10: { id: 10, name: 'Jab', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
|
||||||
14: { id: 14, name: 'Power Strike', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 16, elev1: 8, elev2: 12, elev3: 16, elev4: 20, elev5: 24, srcDamage: 128 },
|
14: { id: 14, name: 'Power Strike', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 16, elev1: 8, elev2: 12, elev3: 16, elev4: 20, elev5: 24, srcDamage: 128 },
|
||||||
|
|
||||||
// Sorceress (6 skills)
|
// Sorceress (7 skills)
|
||||||
36: { id: 36, name: 'Fire Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 5, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 12, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 1000, speed: 500, castOverlay: 'fire_cast_1' },
|
36: { id: 36, name: 'Fire Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 5, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 12, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 1000, speed: 500, castOverlay: 'fire_cast_1' },
|
||||||
37: { id: 37, name: 'Warmth', classCode: 'sor', charClass: 'sorceress', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
37: { id: 37, name: 'Warmth', classCode: 'sor', charClass: 'sorceress', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
||||||
39: { id: 39, name: 'Ice Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 6, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 10, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 400, speed: 450, castOverlay: 'ice_cast_1' },
|
39: { id: 39, name: 'Ice Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 6, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 10, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 400, speed: 450, castOverlay: 'ice_cast_1' },
|
||||||
44: { id: 44, name: 'Frost Nova', classCode: 'sor', charClass: 'sorceress', type: 'area', mana: 9, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 2, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0, castOverlay: 'ice_cast_2' },
|
44: { id: 44, name: 'Frost Nova', classCode: 'sor', charClass: 'sorceress', type: 'area', mana: 9, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 2, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0, castOverlay: 'ice_cast_2' },
|
||||||
47: { id: 47, name: 'Fire Ball', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 10, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 12, emax: 28, elev1: 6, elev2: 12, elev3: 18, elev4: 24, elev5: 30, srcDamage: 0, range: 450, speed: 450, castOverlay: 'fire_cast_2' },
|
47: { id: 47, name: 'Fire Ball', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 10, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 12, emax: 28, elev1: 6, elev2: 12, elev3: 18, elev4: 24, elev5: 30, srcDamage: 0, range: 450, speed: 450, castOverlay: 'fire_cast_2' },
|
||||||
54: { id: 54, name: 'Teleport', classCode: 'sor', charClass: 'sorceress', type: 'movement', mana: 24, lvlmana: -1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0, castOverlay: 'teleport' },
|
54: { id: 54, name: 'Teleport', classCode: 'sor', charClass: 'sorceress', type: 'movement', mana: 24, lvlmana: -1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0, castOverlay: 'teleport' },
|
||||||
|
62: { id: 62, name: 'Hydra', classCode: 'sor', charClass: 'sorceress', type: 'summon', mana: 40, lvlmana: 1, minmana: 1, manashift: 7, delay: 40, hitshift: 7, emin: 28, emax: 39, elev1: 11, elev2: 15, elev3: 19, elev4: 23, elev5: 27, emaxlev1: 13, emaxlev2: 17, emaxlev3: 21, emaxlev4: 25, emaxlev5: 29, srcDamage: 0, range: 450, speed: 400, castOverlay: 'fire_cast_2' },
|
||||||
|
|
||||||
// Necromancer (5 skills)
|
// Necromancer (5 skills)
|
||||||
66: { id: 66, name: 'Amplify Damage', classCode: 'nec', charClass: 'necromancer', type: 'curse', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
66: { id: 66, name: 'Amplify Damage', classCode: 'nec', charClass: 'necromancer', type: 'curse', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
|
||||||
|
|
@ -2897,29 +3016,35 @@ export function calculateSkillDamage(
|
||||||
|
|
||||||
// Level bands (Level 2..8: lev1, 9..16: lev2, 17..22: lev3, 23..28: lev4, 29+: lev5)
|
// Level bands (Level 2..8: lev1, 9..16: lev2, 17..22: lev3, 23..28: lev4, 29+: lev5)
|
||||||
if (lvl > 1) {
|
if (lvl > 1) {
|
||||||
|
const maxElev1 = def.emaxlev1 ?? def.elev1
|
||||||
|
const maxElev2 = def.emaxlev2 ?? def.elev2
|
||||||
|
const maxElev3 = def.emaxlev3 ?? def.elev3
|
||||||
|
const maxElev4 = def.emaxlev4 ?? def.elev4
|
||||||
|
const maxElev5 = def.emaxlev5 ?? def.elev5
|
||||||
|
|
||||||
const b1 = Math.min(lvl - 1, 7)
|
const b1 = Math.min(lvl - 1, 7)
|
||||||
min += b1 * def.elev1
|
min += b1 * def.elev1
|
||||||
max += b1 * def.elev1
|
max += b1 * maxElev1
|
||||||
|
|
||||||
if (lvl > 8) {
|
if (lvl > 8) {
|
||||||
const b2 = Math.min(lvl - 8, 8)
|
const b2 = Math.min(lvl - 8, 8)
|
||||||
min += b2 * def.elev2
|
min += b2 * def.elev2
|
||||||
max += b2 * def.elev2
|
max += b2 * maxElev2
|
||||||
}
|
}
|
||||||
if (lvl > 16) {
|
if (lvl > 16) {
|
||||||
const b3 = Math.min(lvl - 16, 6)
|
const b3 = Math.min(lvl - 16, 6)
|
||||||
min += b3 * def.elev3
|
min += b3 * def.elev3
|
||||||
max += b3 * def.elev3
|
max += b3 * maxElev3
|
||||||
}
|
}
|
||||||
if (lvl > 22) {
|
if (lvl > 22) {
|
||||||
const b4 = Math.min(lvl - 22, 6)
|
const b4 = Math.min(lvl - 22, 6)
|
||||||
min += b4 * def.elev4
|
min += b4 * def.elev4
|
||||||
max += b4 * def.elev4
|
max += b4 * maxElev4
|
||||||
}
|
}
|
||||||
if (lvl > 28) {
|
if (lvl > 28) {
|
||||||
const b5 = lvl - 28
|
const b5 = lvl - 28
|
||||||
min += b5 * def.elev5
|
min += b5 * def.elev5
|
||||||
max += b5 * def.elev5
|
max += b5 * maxElev5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4845,8 +4845,20 @@ export const LIGHTNINGJAVELIN_META: MissileMeta = {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const HYDRA_META: MissileMeta = {
|
||||||
|
...FIREBOLT_META,
|
||||||
|
name: 'hydra',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HYDRAFIRE_META: MissileMeta = {
|
||||||
|
...FIREBOLT_META,
|
||||||
|
name: 'hydrafire',
|
||||||
|
}
|
||||||
|
|
||||||
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
|
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
|
||||||
'firebolt': FIREBOLT_META,
|
'firebolt': FIREBOLT_META,
|
||||||
|
'hydra': HYDRA_META,
|
||||||
|
'hydrafire': HYDRAFIRE_META,
|
||||||
'fireball': FIREBALL_META,
|
'fireball': FIREBALL_META,
|
||||||
'fireexplode': FIREEXPLODE_META,
|
'fireexplode': FIREEXPLODE_META,
|
||||||
'icebolt': ICEBOLT_META,
|
'icebolt': ICEBOLT_META,
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
||||||
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
||||||
import { npcDialog } from '../game/quests.ts'
|
import { npcDialog } from '../game/quests.ts'
|
||||||
import type { NpcDef } from '../game/quests.ts'
|
import type { NpcDef } from '../game/quests.ts'
|
||||||
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill } from '../game/skills.ts'
|
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill } from '../game/skills.ts'
|
||||||
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
||||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||||
import {
|
import {
|
||||||
|
|
@ -1051,6 +1051,7 @@ export function getSkillRange(
|
||||||
case 54: return 450
|
case 54: return 450
|
||||||
case 56: return 450
|
case 56: return 450
|
||||||
case 59: return 450
|
case 59: return 450
|
||||||
|
case 62: return 450
|
||||||
case 64: return 480
|
case 64: return 480
|
||||||
default: return 450
|
default: return 450
|
||||||
}
|
}
|
||||||
|
|
@ -1078,6 +1079,8 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
|
||||||
case 40: return 7
|
case 40: return 7
|
||||||
case 42: return 9
|
case 42: return 9
|
||||||
case 59: return 27
|
case 59: return 27
|
||||||
|
case 60: return 12
|
||||||
|
case 62: return 20
|
||||||
case 64: return 30
|
case 64: return 30
|
||||||
case 56: return 22
|
case 56: return 22
|
||||||
case 49: return 11
|
case 49: return 11
|
||||||
|
|
@ -1318,6 +1321,48 @@ export function castSkill(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skillId === 62) {
|
||||||
|
// Hydra (Skill 62): 3-head stationary turret emitter at target location
|
||||||
|
// Authentic 1.13c Overlay.txt head offsets: Head 1: (-6, -11), Head 2: (0, -7), Head 3: (+4, -15)
|
||||||
|
// 1.13c Skills.txt: Param1: 250 (10s duration), delay: 40 (1.6s cast delay)
|
||||||
|
// 1.13c Missiles.txt: vel: 16 (400 px/s), range: 30 (1.2s range = 480 px), HitShift: 7
|
||||||
|
const offsets = [
|
||||||
|
{ dx: -6, dy: -11 },
|
||||||
|
{ dx: 0, dy: -7 },
|
||||||
|
{ dx: 4, dy: -15 },
|
||||||
|
]
|
||||||
|
const slvl = 1
|
||||||
|
const dmgRange = calculateSkillDamage(62, slvl)
|
||||||
|
const hydraDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||||
|
|
||||||
|
for (let i = 0; i < offsets.length; i++) {
|
||||||
|
const off = offsets[i]!
|
||||||
|
engine.projectiles.push({
|
||||||
|
skillId: '62',
|
||||||
|
x: targetX + off.dx,
|
||||||
|
y: targetY + off.dy,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: hydraDamage,
|
||||||
|
ttl: 250, // 10 seconds at 25 fps
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'hydra_turret',
|
||||||
|
isTurret: true,
|
||||||
|
fireCadenceTicks: 25,
|
||||||
|
headIndex: i,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
spawnMissileType: 'hydra',
|
||||||
|
pierce: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
player.cooldown = 40
|
||||||
|
engine.metrics.casts += 1
|
||||||
|
status.textContent = '施展九头海蛇'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Projectile Skills
|
// Projectile Skills
|
||||||
let speed = 450
|
let speed = 450
|
||||||
let range = 450
|
let range = 450
|
||||||
|
|
@ -3616,6 +3661,11 @@ export async function loadMissileArtMap(
|
||||||
map.set('frozenorbbolt', iceboltArt)
|
map.set('frozenorbbolt', iceboltArt)
|
||||||
map.set('frozenorbnova', iceboltArt)
|
map.set('frozenorbnova', iceboltArt)
|
||||||
}
|
}
|
||||||
|
if (map.has('firebolt')) {
|
||||||
|
const fireboltArt = map.get('firebolt')!
|
||||||
|
if (!map.has('hydra')) map.set('hydra', fireboltArt)
|
||||||
|
if (!map.has('hydrafire')) map.set('hydrafire', fireboltArt)
|
||||||
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3649,6 +3699,21 @@ export function drawMissileProjectile(
|
||||||
|
|
||||||
const x = shot.x
|
const x = shot.x
|
||||||
const y = shot.y
|
const y = shot.y
|
||||||
|
|
||||||
|
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])
|
||||||
|
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,461 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c — Skill #062: Hydra (SOR)
|
||||||
|
* 7-Dimensional Verification Suite: Missile Physics, Turret Emitter,
|
||||||
|
* 3-Head Geometry, Visual Assets, Synergy & 1.13c Ground Truth Parity.
|
||||||
|
*
|
||||||
|
* Gitea Tracking Issue: #399
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {
|
||||||
|
parseMissilesTxt,
|
||||||
|
getMissileTxtData,
|
||||||
|
CANONICAL_113C_MISSILES,
|
||||||
|
BATCH1_SKILLS,
|
||||||
|
getBatch1SkillDef,
|
||||||
|
getSkillManaCost,
|
||||||
|
calculateSkillDamage,
|
||||||
|
tickProjectiles,
|
||||||
|
ISO_GROUND_ASPECT_RATIO,
|
||||||
|
getSkillCastOverlay,
|
||||||
|
SORCERESS_SKILL_CAST_OVERLAYS,
|
||||||
|
type Projectile,
|
||||||
|
} from '../src/game/skills.ts'
|
||||||
|
import {
|
||||||
|
MissileEngine,
|
||||||
|
} from '../src/game/engine/missile-engine.ts'
|
||||||
|
import {
|
||||||
|
getSharedDataRegistry,
|
||||||
|
PET_SUB_SKILL_IDS,
|
||||||
|
} from '../src/game/engine/data-registry.ts'
|
||||||
|
import { UnitStatList } from '../src/game/engine/stat-list.ts'
|
||||||
|
import { skillModule } from '../src/game/skills/impl/sor/skill-062-hydra.ts'
|
||||||
|
import {
|
||||||
|
HYDRA_META,
|
||||||
|
HYDRAFIRE_META,
|
||||||
|
FIREBOLT_META,
|
||||||
|
MISSILE_METAS,
|
||||||
|
} from '../src/render/missiles-meta.ts'
|
||||||
|
import {
|
||||||
|
getSkillRange,
|
||||||
|
getSkillMana,
|
||||||
|
castSkill,
|
||||||
|
} from '../src/scene/act-scene.ts'
|
||||||
|
import { GameEngine } from '../src/game/engine.ts'
|
||||||
|
|
||||||
|
describe('[Skill #062] Hydra — 7-Dimensional 1.13c Verification Suite (Issue #399)', () => {
|
||||||
|
const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt')
|
||||||
|
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 1: 1.13c Data Table Ground Truth Parity
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 1: 1.13c Data Table Ground Truth Parity', () => {
|
||||||
|
it('parses authentic 1.13c Missiles.txt and verifies row 247 hydra', () => {
|
||||||
|
const map = parseMissilesTxt(missilesTxtContent)
|
||||||
|
const hydra = map.get('hydra')
|
||||||
|
expect(hydra).toBeDefined()
|
||||||
|
expect(hydra!.id).toBe(247)
|
||||||
|
expect(hydra!.vel).toBe(16)
|
||||||
|
expect(hydra!.range).toBe(30)
|
||||||
|
expect(hydra!.speedPxPerSec).toBe(400) // 16 * 25
|
||||||
|
expect(hydra!.distancePx).toBe(480) // 16 * 30
|
||||||
|
expect(hydra!.celFile.toLowerCase()).toBe('firebolt')
|
||||||
|
expect(hydra!.animLen).toBe(5)
|
||||||
|
expect(hydra!.explosionMissile).toBe('fireexplode')
|
||||||
|
expect(hydra!.light).toBe(5)
|
||||||
|
expect(hydra!.red).toBe(255)
|
||||||
|
expect(hydra!.green).toBe(255)
|
||||||
|
expect(hydra!.blue).toBe(255)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies CANONICAL_113C_MISSILES export contains hydra and hydrafire', () => {
|
||||||
|
const hydra = getMissileTxtData('hydra')
|
||||||
|
expect(hydra.id).toBe(247)
|
||||||
|
expect(hydra.vel).toBe(16)
|
||||||
|
expect(hydra.range).toBe(30)
|
||||||
|
expect(hydra.speedPxPerSec).toBe(400)
|
||||||
|
expect(hydra.distancePx).toBe(480)
|
||||||
|
expect(hydra.explosionMissile).toBe('fireexplode')
|
||||||
|
|
||||||
|
const hydrafire = getMissileTxtData('hydrafire')
|
||||||
|
expect(hydrafire.id).toBe(247)
|
||||||
|
expect(hydrafire.vel).toBe(16)
|
||||||
|
expect(hydrafire.range).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies Skills.txt row 62 and PET_SUB_SKILL_IDS 337 in D2DataRegistry', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const rec = registry.getSkillById(62)
|
||||||
|
expect(rec).toBeDefined()
|
||||||
|
expect(rec!.name).toBe('Hydra')
|
||||||
|
expect(rec!.charClass).toBe('sor')
|
||||||
|
|
||||||
|
// PET_SUB_SKILL_IDS contains 337 (HydraMissile)
|
||||||
|
expect((PET_SUB_SKILL_IDS as readonly number[]).includes(337)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 2: Visual Assets & Metadata Parity
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 2: Visual Assets & Metadata Parity', () => {
|
||||||
|
it('verifies HYDRA_META and HYDRAFIRE_META alias FIREBOLT_META', () => {
|
||||||
|
expect(HYDRA_META.name).toBe('hydra')
|
||||||
|
expect(HYDRA_META.celFile).toBe('Firebolt')
|
||||||
|
expect(HYDRA_META.directions).toBe(16)
|
||||||
|
expect(HYDRA_META.framesPerDirection).toBe(5)
|
||||||
|
expect(HYDRA_META.animSpeed).toBe(16)
|
||||||
|
|
||||||
|
expect(HYDRAFIRE_META.name).toBe('hydrafire')
|
||||||
|
expect(HYDRAFIRE_META.celFile).toBe('Firebolt')
|
||||||
|
|
||||||
|
expect(MISSILE_METAS['hydra']).toBe(HYDRA_META)
|
||||||
|
expect(MISSILE_METAS['hydrafire']).toBe(HYDRAFIRE_META)
|
||||||
|
expect(MISSILE_METAS['firebolt']).toBe(FIREBOLT_META)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies baked PNG atlas and JSON metadata exist on disk with positive byte size', () => {
|
||||||
|
const fbPng = path.resolve(__dirname, '../public/missiles/firebolt.png')
|
||||||
|
const fbJson = path.resolve(__dirname, '../public/missiles/firebolt.json')
|
||||||
|
const fePng = path.resolve(__dirname, '../public/missiles/fireexplode.png')
|
||||||
|
const feJson = path.resolve(__dirname, '../public/missiles/fireexplode.json')
|
||||||
|
|
||||||
|
expect(fs.existsSync(fbPng)).toBe(true)
|
||||||
|
expect(fs.statSync(fbPng).size).toBeGreaterThan(1000)
|
||||||
|
expect(fs.existsSync(fbJson)).toBe(true)
|
||||||
|
expect(fs.existsSync(fePng)).toBe(true)
|
||||||
|
expect(fs.statSync(fePng).size).toBeGreaterThan(1000)
|
||||||
|
expect(fs.existsSync(feJson)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies cast overlay mapping for Hydra is fire_cast_2', () => {
|
||||||
|
expect(SORCERESS_SKILL_CAST_OVERLAYS[62]).toBe('fire_cast_2')
|
||||||
|
expect(getSkillCastOverlay(62)).toBe('fire_cast_2')
|
||||||
|
expect(getSkillCastOverlay('62')).toBe('fire_cast_2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 3: Stationary Turret Emitter Physics
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 3: Stationary Turret Emitter Physics', () => {
|
||||||
|
it('stationary turret holds position (vx=0, vy=0) and ticks age without drifting', () => {
|
||||||
|
const turret: Projectile = {
|
||||||
|
skillId: '62',
|
||||||
|
x: 200,
|
||||||
|
y: 150,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: 35,
|
||||||
|
ttl: 250, // 10s at 25 fps
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'hydra_turret',
|
||||||
|
isTurret: true,
|
||||||
|
fireCadenceTicks: 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: readonly Projectile[] = [turret]
|
||||||
|
const openTerrain = { overlap: () => 0 }
|
||||||
|
|
||||||
|
for (let tick = 1; tick <= 10; tick++) {
|
||||||
|
const res = tickProjectiles(current, [], openTerrain)
|
||||||
|
expect(res.alive.length).toBe(1)
|
||||||
|
const t = res.alive[0]!
|
||||||
|
expect(t.x).toBe(200)
|
||||||
|
expect(t.y).toBe(150)
|
||||||
|
expect(t.vx).toBe(0)
|
||||||
|
expect(t.vy).toBe(0)
|
||||||
|
expect(t.ageTicks).toBe(tick)
|
||||||
|
expect(t.ttl).toBe(250 - tick)
|
||||||
|
current = res.alive
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('periodically emits hydra bolt at closest enemy within 480 px every 25 ticks', () => {
|
||||||
|
const turret: Projectile = {
|
||||||
|
skillId: '62',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: 35,
|
||||||
|
ttl: 250,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'hydra_turret',
|
||||||
|
isTurret: true,
|
||||||
|
fireCadenceTicks: 25,
|
||||||
|
spawnMissileType: 'hydra',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enemy at (300, 100), distance 200 px <= 480 px
|
||||||
|
const enemy = { index: 1, x: 300, y: 100, radius: 16, alive: true }
|
||||||
|
let current: readonly Projectile[] = [turret]
|
||||||
|
const openTerrain = { overlap: () => 0 }
|
||||||
|
const spawnedBolts: Projectile[] = []
|
||||||
|
|
||||||
|
for (let tick = 1; tick <= 55; tick++) {
|
||||||
|
const res = tickProjectiles(current, [enemy], openTerrain)
|
||||||
|
if (res.spawnedProjectiles && res.spawnedProjectiles.length > 0) {
|
||||||
|
spawnedBolts.push(...res.spawnedProjectiles)
|
||||||
|
}
|
||||||
|
current = res.alive.filter(p => p.isTurret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have emitted at tick 25 and tick 50 -> 2 bolts!
|
||||||
|
expect(spawnedBolts.length).toBe(2)
|
||||||
|
for (const bolt of spawnedBolts) {
|
||||||
|
expect(bolt.missileType).toBe('hydra')
|
||||||
|
expect(bolt.ttl).toBe(30) // 1.13c range: 30
|
||||||
|
expect(bolt.damage).toBe(35)
|
||||||
|
expect(bolt.x).toBe(100)
|
||||||
|
expect(bolt.y).toBe(100)
|
||||||
|
// Directed along +x axis toward (300, 100)
|
||||||
|
expect(bolt.vx).toBeCloseTo(16, 0.1) // 1.13c vel: 16
|
||||||
|
expect(bolt.vy).toBeCloseTo(0, 0.1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects 2:1 isometric ground plane aspect ratio for distance targeting', () => {
|
||||||
|
const turret: Projectile = {
|
||||||
|
skillId: '62',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: 35,
|
||||||
|
ttl: 250,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'hydra_turret',
|
||||||
|
isTurret: true,
|
||||||
|
fireCadenceTicks: 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enemy A is at dx=0, dy=200 -> ground distance = 200 / 0.5 = 400 <= 480 (in range)
|
||||||
|
// Enemy B is at dx=0, dy=300 -> ground distance = 300 / 0.5 = 600 > 480 (out of range)
|
||||||
|
const enemyA = { index: 1, x: 100, y: 300, radius: 16, alive: true }
|
||||||
|
const enemyB = { index: 2, x: 100, y: 400, radius: 16, alive: true }
|
||||||
|
|
||||||
|
let current: readonly Projectile[] = [turret]
|
||||||
|
let spawned: Projectile[] = []
|
||||||
|
for (let tick = 1; tick <= 25; tick++) {
|
||||||
|
const res = tickProjectiles(current, [enemyA, enemyB], { overlap: () => 0 })
|
||||||
|
if (res.spawnedProjectiles) spawned.push(...res.spawnedProjectiles)
|
||||||
|
current = res.alive.filter(p => p.isTurret)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(spawned.length).toBe(1)
|
||||||
|
// Target was enemyA
|
||||||
|
expect(spawned[0]!.vy).toBeCloseTo(16, 0.1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 4: Harmless Turret Emitter & Single-Target Missile Collision
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 4: Harmless Turret Emitter & Single-Target Collision', () => {
|
||||||
|
it('turret head deals NO contact damage to overlapping monster and does not die', () => {
|
||||||
|
const turret: Projectile = {
|
||||||
|
skillId: '62',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
damage: 50,
|
||||||
|
ttl: 100,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'hydra_turret',
|
||||||
|
isTurret: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monster right on top of turret head
|
||||||
|
const monster = { index: 0, x: 100, y: 100, radius: 20, alive: true }
|
||||||
|
const res = tickProjectiles([turret], [monster], { overlap: () => 0 })
|
||||||
|
|
||||||
|
expect(res.hits.length).toBe(0) // No direct contact damage
|
||||||
|
expect(res.alive.length).toBe(1) // Turret survives
|
||||||
|
expect(res.alive[0]!.isTurret).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emitted hydra bolt deals damage, is single-target (pierce=false, collideKill=true)', () => {
|
||||||
|
const bolt: Projectile = {
|
||||||
|
skillId: '62',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 16,
|
||||||
|
vy: 0,
|
||||||
|
damage: 40,
|
||||||
|
ttl: 30,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'hydra',
|
||||||
|
pierce: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const target1 = { index: 1, x: 110, y: 100, radius: 15, alive: true }
|
||||||
|
const target2 = { index: 2, x: 130, y: 100, radius: 15, alive: true }
|
||||||
|
|
||||||
|
const res = tickProjectiles([bolt], [target1, target2], { overlap: () => 0 })
|
||||||
|
expect(res.hits.length).toBe(1)
|
||||||
|
expect(res.hits[0]!.targetIndex).toBe(1)
|
||||||
|
expect(res.hits[0]!.damage).toBe(40)
|
||||||
|
// Bolt should be consumed (not alive)
|
||||||
|
expect(res.alive.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 5: Obstacle / Wall Collision & Detonation
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 5: Obstacle / Wall Collision & Detonation', () => {
|
||||||
|
it('emitted hydra bolt detonates into fireexplode and stops on wall impact', () => {
|
||||||
|
const bolt: Projectile = {
|
||||||
|
skillId: '62',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 16,
|
||||||
|
vy: 0,
|
||||||
|
damage: 40,
|
||||||
|
ttl: 30,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'hydra',
|
||||||
|
pierce: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wall at x >= 110
|
||||||
|
const wallTerrain = { overlap: (x: number) => (x >= 110 ? 1 : 0) }
|
||||||
|
const res = tickProjectiles([bolt], [], wallTerrain)
|
||||||
|
|
||||||
|
expect(res.wallHits).toBe(1)
|
||||||
|
expect(res.alive.length).toBe(0)
|
||||||
|
expect(res.wallHitEvents).toBeDefined()
|
||||||
|
expect(res.wallHitEvents!.some(e => e.missileType === 'hydra')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 6: 1.13c Synergy & Level Scaling
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 6: 1.13c Synergy & Level Scaling', () => {
|
||||||
|
it('verifies 1.13c base damage formulas at slvl 1, 10, and 20', () => {
|
||||||
|
// slvl 1: EMin: 28, EMax: 39 with HitShift: 7 -> 14 - 19 (scaled by 2^(7-8) = 0.5, integer floor)
|
||||||
|
const d1 = calculateSkillDamage(62, 1)
|
||||||
|
expect(d1.min).toBe(14)
|
||||||
|
expect(d1.max).toBe(19)
|
||||||
|
|
||||||
|
// slvl 10: raw min = 28 + 7*11 + 2*15 = 135 -> 67.5 -> 67
|
||||||
|
// raw max = 39 + 7*13 + 2*17 = 164 -> 82 -> 82
|
||||||
|
const d10 = calculateSkillDamage(62, 10)
|
||||||
|
expect(d10.min).toBe(67)
|
||||||
|
expect(d10.max).toBe(82)
|
||||||
|
|
||||||
|
// slvl 20: raw min = 28 + 7*11 + 8*15 + 4*19 = 301 -> 150.5 -> 150
|
||||||
|
// raw max = 39 + 7*13 + 8*17 + 4*21 = 350 -> 175 -> 175
|
||||||
|
const d20 = calculateSkillDamage(62, 20)
|
||||||
|
expect(d20.min).toBe(150)
|
||||||
|
expect(d20.max).toBe(175)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies 1.13c mana cost formula: (mana + lvlmana * (lvl - 1)) * 2^(manashift - 8)', () => {
|
||||||
|
// mana = 40, lvlmana = 1, manashift = 7 -> factor = 0.5
|
||||||
|
expect(getSkillManaCost(62, 1)).toBe(20) // (40 + 0) * 0.5 = 20
|
||||||
|
expect(getSkillManaCost(62, 10)).toBe(24.5) // (40 + 9) * 0.5 = 24.5
|
||||||
|
expect(getSkillManaCost(62, 20)).toBe(29.5) // (40 + 19) * 0.5 = 29.5
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies hard point blvl synergy rule (Fire Bolt + Fire Ball +3% per hard point)', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const skillRec = registry.getSkillById(62)!
|
||||||
|
|
||||||
|
const statsHard = new UnitStatList()
|
||||||
|
statsHard.setBaseSkillLevel(62, 20)
|
||||||
|
statsHard.setBaseSkillLevel(36, 10) // 10 hard points in Fire Bolt
|
||||||
|
statsHard.setBaseSkillLevel(47, 10) // 10 hard points in Fire Ball
|
||||||
|
const resHard = skillModule.evaluate!({
|
||||||
|
registry,
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: 20,
|
||||||
|
blvl: 20,
|
||||||
|
statList: statsHard,
|
||||||
|
})
|
||||||
|
// (10 + 10) * 3% = 60%
|
||||||
|
expect(resHard.synergyBonusPct).toBe(60)
|
||||||
|
|
||||||
|
// Soft items / charged skills do NOT grant synergy
|
||||||
|
const statsSoft = new UnitStatList()
|
||||||
|
statsSoft.setBaseSkillLevel(62, 20)
|
||||||
|
statsSoft.addStat('item_allskills', 15)
|
||||||
|
statsSoft.setChargedSkillLevel(36, 20)
|
||||||
|
const resSoft = skillModule.evaluate!({
|
||||||
|
registry,
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: 20,
|
||||||
|
blvl: 20,
|
||||||
|
statList: statsSoft,
|
||||||
|
})
|
||||||
|
expect(resSoft.synergyBonusPct).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 7: 3-Head Cluster Geometry & Cast Dispatch
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 7: 3-Head Cluster Geometry & Cast Dispatch', () => {
|
||||||
|
it('castSkill spawns 3 turret heads with authentic Overlay.txt offsets [(-6, -11), (0, -7), (4, -15)]', () => {
|
||||||
|
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 = 100
|
||||||
|
engine.world.player.cooldown = 0
|
||||||
|
|
||||||
|
const statusEl = { textContent: '' } as HTMLElement
|
||||||
|
const dummyRuntime = {
|
||||||
|
walkable: { isWalkable: () => true },
|
||||||
|
collision: { isWalkable: () => true },
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const targetX = 400
|
||||||
|
const targetY = 300
|
||||||
|
const castOk = castSkill(62, targetX, targetY, {
|
||||||
|
engine,
|
||||||
|
runtime: dummyRuntime,
|
||||||
|
hudManager: null,
|
||||||
|
status: statusEl,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(castOk).toBe(true)
|
||||||
|
expect(statusEl.textContent).toBe('施展九头海蛇')
|
||||||
|
expect(engine.world.player.cooldown).toBe(40) // 1.13c delay: 40 frames
|
||||||
|
expect(engine.world.player.mana).toBe(80) // 100 - 20 = 80
|
||||||
|
expect(engine.projectiles.length).toBe(3)
|
||||||
|
|
||||||
|
const p0 = engine.projectiles[0]!
|
||||||
|
const p1 = engine.projectiles[1]!
|
||||||
|
const p2 = engine.projectiles[2]!
|
||||||
|
|
||||||
|
expect(p0.x).toBe(targetX - 6)
|
||||||
|
expect(p0.y).toBe(targetY - 11)
|
||||||
|
expect(p0.headIndex).toBe(0)
|
||||||
|
expect(p0.isTurret).toBe(true)
|
||||||
|
expect(p0.ttl).toBe(250) // 1.13c Param1 = 250 frames
|
||||||
|
|
||||||
|
expect(p1.x).toBe(targetX + 0)
|
||||||
|
expect(p1.y).toBe(targetY - 7)
|
||||||
|
expect(p1.headIndex).toBe(1)
|
||||||
|
expect(p1.isTurret).toBe(true)
|
||||||
|
|
||||||
|
expect(p2.x).toBe(targetX + 4)
|
||||||
|
expect(p2.y).toBe(targetY - 15)
|
||||||
|
expect(p2.headIndex).toBe(2)
|
||||||
|
expect(p2.isTurret).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getSkillRange and getSkillMana return canonical values for skill 62', () => {
|
||||||
|
expect(getSkillRange(62)).toBe(450)
|
||||||
|
expect(getSkillMana(62)).toBe(20)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue