import type { Iwt2BossTickResult } from './bossAi' import type { Iwt2ArenaEvent, Iwt2ArenaIndicator, Iwt2ArenaState, Iwt2BossEntityState, Iwt2EntityId, Iwt2MechanicArcState, Iwt2MechanicLinkState, Iwt2PartyEntityState, Iwt2Vec2, } from './types' import { applyPartyDamageInShape, createArcIndicator, createDonutIndicator, createLaneIndicator, indicatorPhaseFromAttack, } from './mechanics' import { addVec2, clampVec2ToArena, distanceVec2, normalizeVec2, scaleVec2, subtractVec2, withFallbackFacing, } from './vector' const STORMCOIL_PHASE = { arcRecover: 'stormcoilArcRecover', arcWindup: 'stormcoilArcWindup', chainRecover: 'stormcoilChainRecover', chainWindup: 'stormcoilChainWindup', donutRecover: 'stormcoilDonutRecover', donutWindup: 'stormcoilDonutWindup', } as const type StormcoilPhase = typeof STORMCOIL_PHASE[keyof typeof STORMCOIL_PHASE] const STORMCOIL_TUNING = { arcAngleRadians: Math.PI * 0.34, arcCooldown: 5.6, arcDamage: 22, arcInnerRadius: 78, arcOuterRadius: 196, arcRecover: 0.46, arcStunSeconds: 0.24, arcWindup: 0.74, chainCooldown: 7.4, chainDamage: 18, chainLinkRadius: 150, chainMaxLinks: 7, chainRecover: 0.5, chainStunSeconds: 0.38, chainWidth: 16, chainWindup: 0.68, disengageSpeed: 168, donutCooldown: 6.2, donutDamage: 24, donutInnerRadius: 64, donutOuterRadius: 156, donutRecover: 0.48, donutStunSeconds: 0.44, donutWindup: 0.82, meleeCooldown: 1.12, meleeDamage: 8, meleeRange: 58, moveSpeed: 124, wallContactLimit: 0.72, wallMargin: 68, } export function tickStormcoilWyrm(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { const events: Iwt2ArenaEvent[] = [] let party = state.party const incomingPhase = stormcoilPhase(state.boss) let boss = { ...state.boss, chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt), fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt), meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt), mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - dt), phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt), velocity: { x: 0, y: 0 }, } boss = { ...boss, wallContactSeconds: incomingPhase !== 'idle' ? 0 : isBossNearWall(boss, state) ? boss.wallContactSeconds + dt : Math.max(0, boss.wallContactSeconds - dt * 2), } const target = getBossTarget(party) if (boss.health <= 0 || !target) return withStormcoilIndicators({ boss, party, events }) const phase = stormcoilPhase(boss) if (phase === 'relocating') { boss = moveBossToward(boss, boss.relocateTarget, state, STORMCOIL_TUNING.disengageSpeed, dt) if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) { boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0, velocity: { x: 0, y: 0 }, wallContactSeconds: 0, } } return withStormcoilIndicators({ boss, party, events }) } if (phase === STORMCOIL_PHASE.donutWindup) { boss = { ...boss, velocity: { x: 0, y: 0 } } if (boss.phaseSecondsRemaining <= 0) { const result = applyStormDonut(party, boss, state.time + dt) party = result.party events.push(...result.events) boss = { ...boss, attackPhase: asBossPhase(STORMCOIL_PHASE.donutRecover), mechanicCircles: [], phaseSecondsRemaining: STORMCOIL_TUNING.donutRecover, } } return withStormcoilIndicators({ boss, party, events }) } if (phase === STORMCOIL_PHASE.arcWindup) { boss = { ...boss, velocity: { x: 0, y: 0 } } if (boss.phaseSecondsRemaining <= 0) { const result = applyStormArcs(party, boss, state.time + dt) party = result.party events.push(...result.events) boss = { ...boss, attackPhase: asBossPhase(STORMCOIL_PHASE.arcRecover), phaseSecondsRemaining: STORMCOIL_TUNING.arcRecover, } } return withStormcoilIndicators({ boss, party, events }) } if (phase === STORMCOIL_PHASE.chainWindup) { boss = { ...boss, velocity: { x: 0, y: 0 } } if (boss.phaseSecondsRemaining <= 0) { const result = applyChainLightning(party, boss, state.time + dt) party = result.party events.push(...result.events) boss = { ...boss, attackPhase: asBossPhase(STORMCOIL_PHASE.chainRecover), phaseSecondsRemaining: STORMCOIL_TUNING.chainRecover, } } return withStormcoilIndicators({ boss, party, events }) } if ( phase === STORMCOIL_PHASE.donutRecover || phase === STORMCOIL_PHASE.arcRecover || phase === STORMCOIL_PHASE.chainRecover ) { boss = { ...boss, velocity: { x: 0, y: 0 } } if (boss.phaseSecondsRemaining <= 0) { boss = { ...boss, attackPhase: 'idle', mechanicArcs: [], mechanicCircles: [], mechanicLinks: [], phaseSecondsRemaining: 0, } } return withStormcoilIndicators({ boss, party, events }) } if (boss.wallContactSeconds >= STORMCOIL_TUNING.wallContactLimit) { boss = { ...boss, attackPhase: 'relocating', mechanicArcs: [], mechanicCircles: [], mechanicLinks: [], phaseSecondsRemaining: 1.25, relocateTarget: wallDisengageTarget(boss, state), velocity: { x: 0, y: 0 }, } return withStormcoilIndicators({ boss, party, events }) } if (boss.fireballCooldownRemaining <= 0) { const links = createChainLinks(boss, party) if (links.length > 0) { boss = { ...boss, attackPhase: asBossPhase(STORMCOIL_PHASE.chainWindup), fireballCooldownRemaining: STORMCOIL_TUNING.chainCooldown, mechanicLinks: links, phaseSecondsRemaining: STORMCOIL_TUNING.chainWindup, velocity: { x: 0, y: 0 }, } return withStormcoilIndicators({ boss, party, events }) } boss = { ...boss, fireballCooldownRemaining: 1.0 } } if (boss.mechanicEnergy <= 0) { boss = { ...boss, attackPhase: asBossPhase(STORMCOIL_PHASE.arcWindup), facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing), mechanicArcs: createStormArcs(boss, target.position), mechanicEnergy: STORMCOIL_TUNING.arcCooldown, phaseSecondsRemaining: STORMCOIL_TUNING.arcWindup, velocity: { x: 0, y: 0 }, } return withStormcoilIndicators({ boss, party, events }) } if (boss.chargeCooldownRemaining <= 0) { boss = { ...boss, attackPhase: asBossPhase(STORMCOIL_PHASE.donutWindup), chargeCooldownRemaining: STORMCOIL_TUNING.donutCooldown, mechanicCircles: [{ id: 'stormcoil-donut-center', position: { ...boss.position }, radius: STORMCOIL_TUNING.donutOuterRadius, }], phaseSecondsRemaining: STORMCOIL_TUNING.donutWindup, velocity: { x: 0, y: 0 }, } return withStormcoilIndicators({ boss, party, events }) } const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt) party = meleeResult.party events.push(...meleeResult.events) boss = meleeResult.boss if (events.length > 0) return withStormcoilIndicators({ boss, party, events }) const desired = isBossNearWall(boss, state) ? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(arenaCenter(state), boss.position)), 84)) : target.position boss = moveBossToward(boss, desired, state, STORMCOIL_TUNING.moveSpeed, dt) boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing), } return withStormcoilIndicators({ boss, party, events }) } function applyStormDonut( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, time: number, ): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { const result = applyPartyDamageInShape(party, { innerRadius: STORMCOIL_TUNING.donutInnerRadius, kind: 'donut', outerRadius: STORMCOIL_TUNING.donutOuterRadius, position: boss.position, }, { damage: STORMCOIL_TUNING.donutDamage, knockdownSeconds: STORMCOIL_TUNING.donutStunSeconds, sourceId: boss.id, stunSeconds: STORMCOIL_TUNING.donutStunSeconds, time, }) return { party: result.party, events: result.events } } function applyStormArcs( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, time: number, ): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { let nextParty = party const events: Iwt2ArenaEvent[] = [] const hitEntityIds: Iwt2EntityId[] = [] for (const arc of boss.mechanicArcs) { const result = applyPartyDamageInShape(nextParty, { angleRadians: arc.angleRadians, direction: arc.direction, innerRadius: arc.innerRadius, kind: 'arc', outerRadius: arc.outerRadius, position: arc.position, }, { damage: STORMCOIL_TUNING.arcDamage, excludedEntityIds: hitEntityIds, knockdownSeconds: 0, sourceId: boss.id, stunSeconds: STORMCOIL_TUNING.arcStunSeconds, time, }) nextParty = result.party hitEntityIds.push(...result.hitEntityIds) events.push(...result.events) } return { party: nextParty, events } } function applyChainLightning( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, time: number, ): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { let nextParty = party const events: Iwt2ArenaEvent[] = [] const hitEntityIds: Iwt2EntityId[] = [] for (const link of boss.mechanicLinks) { const result = applyPartyDamageInShape(nextParty, { end: link.end, kind: 'lane', start: link.start, width: link.width, }, { damage: STORMCOIL_TUNING.chainDamage, excludedEntityIds: hitEntityIds, knockdownSeconds: 0, sourceId: boss.id, stunSeconds: STORMCOIL_TUNING.chainStunSeconds, time, }) nextParty = result.party hitEntityIds.push(...result.hitEntityIds) events.push(...result.events) } return { party: nextParty, events } } function createStormArcs(boss: Iwt2BossEntityState, targetPosition: Iwt2Vec2): Iwt2MechanicArcState[] { const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing) const directions = [ rotateVec2(facing, -0.66), facing, rotateVec2(facing, 0.66), ] return directions.map((direction, index) => ({ angleRadians: STORMCOIL_TUNING.arcAngleRadians, direction, id: `stormcoil-arc-${index}`, innerRadius: STORMCOIL_TUNING.arcInnerRadius, outerRadius: STORMCOIL_TUNING.arcOuterRadius, position: { ...boss.position }, })) } function createChainLinks( boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], ): Iwt2MechanicLinkState[] { const living = party.filter((member) => member.health > 0) const links: Iwt2MechanicLinkState[] = [] const nearest = living .map((member) => ({ member, distance: distanceVec2(member.position, boss.position) })) .sort((a, b) => a.distance - b.distance)[0]?.member if (nearest) { links.push({ end: { ...nearest.position }, id: `stormcoil-chain-${boss.id}-${nearest.id}`, start: { ...boss.position }, width: STORMCOIL_TUNING.chainWidth, }) } for (let a = 0; a < living.length; a += 1) { for (let b = a + 1; b < living.length; b += 1) { if (distanceVec2(living[a].position, living[b].position) > STORMCOIL_TUNING.chainLinkRadius) continue links.push({ end: { ...living[b].position }, id: `stormcoil-chain-${living[a].id}-${living[b].id}`, start: { ...living[a].position }, width: STORMCOIL_TUNING.chainWidth, }) if (links.length >= STORMCOIL_TUNING.chainMaxLinks) return links } } return links.length > 1 ? links : [] } function maybeApplyMelee( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, target: Iwt2PartyEntityState, time: number, ): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] } if (distanceVec2(boss.position, target.position) > STORMCOIL_TUNING.meleeRange + target.radius) { return { boss, party, events: [] } } const result = applyPartyDamageInShape(party, { kind: 'circle', position: boss.position, radius: STORMCOIL_TUNING.meleeRange, }, { damage: STORMCOIL_TUNING.meleeDamage, sourceId: boss.id, time, }) return { boss: { ...boss, meleeCooldownRemaining: STORMCOIL_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } }, events: result.events, party: result.party, } } function moveBossToward( boss: Iwt2BossEntityState, target: Iwt2Vec2, state: Iwt2ArenaState, speed: number, dt: number, ): Iwt2BossEntityState { const offset = subtractVec2(target, boss.position) const distance = Math.hypot(offset.x, offset.y) const nextPosition = clampVec2ToArena( distance <= speed * dt || distance <= 0.0001 ? target : addVec2(boss.position, scaleVec2(offset, (speed * dt) / distance)), boss.radius, state.bounds, ) return { ...boss, facing: withFallbackFacing(subtractVec2(target, nextPosition), boss.facing), position: nextPosition, velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0), } } function wallDisengageTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 { const center = arenaCenter(state) const toCenter = normalizeVec2(subtractVec2(center, boss.position)) return clampVec2ToArena(addVec2(boss.position, scaleVec2(toCenter, 180)), boss.radius, state.bounds) } function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean { const margin = boss.radius + STORMCOIL_TUNING.wallMargin return ( boss.position.x <= margin || boss.position.x >= state.bounds.width - margin || boss.position.y <= margin || boss.position.y >= state.bounds.height - margin ) } function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 { return { x: state.bounds.width * 0.5, y: state.bounds.height * 0.5, } } function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 { const cos = Math.cos(radians) const sin = Math.sin(radians) return normalizeVec2({ x: vector.x * cos - vector.y * sin, y: vector.x * sin + vector.y * cos, }) } function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined { const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0) if (livingTank) return livingTank return party.find((member) => member.health > 0) } function withStormcoilIndicators(result: Omit): Iwt2BossTickResult { return { ...result, indicators: createStormcoilIndicators(result.boss), } } function createStormcoilIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] { const indicators: Iwt2ArenaIndicator[] = [] const phase = stormcoilPhase(boss) if (phase === STORMCOIL_PHASE.donutWindup || phase === STORMCOIL_PHASE.donutRecover) { indicators.push(createDonutIndicator({ color: '#8ff2ff', id: `${boss.id}:stormcoil-donut`, innerRadius: STORMCOIL_TUNING.donutInnerRadius, mechanicId: 'stormcoil-charged-ring', outerRadius: STORMCOIL_TUNING.donutOuterRadius, phase: indicatorPhaseFromAttack( phase === STORMCOIL_PHASE.donutWindup, phase === STORMCOIL_PHASE.donutRecover, ), position: boss.position, sourceId: boss.id, })) } if (phase === STORMCOIL_PHASE.arcWindup || phase === STORMCOIL_PHASE.arcRecover) { for (const arc of boss.mechanicArcs) { indicators.push(createArcIndicator({ angleRadians: arc.angleRadians, color: phase === STORMCOIL_PHASE.arcRecover ? '#d8fbff' : '#60cfff', direction: arc.direction, id: `${boss.id}:${arc.id}`, innerRadius: arc.innerRadius, mechanicId: 'stormcoil-arc-trail', outerRadius: arc.outerRadius, phase: indicatorPhaseFromAttack( phase === STORMCOIL_PHASE.arcWindup, phase === STORMCOIL_PHASE.arcRecover, ), position: arc.position, sourceId: boss.id, })) } } if (phase === STORMCOIL_PHASE.chainWindup || phase === STORMCOIL_PHASE.chainRecover) { for (const link of boss.mechanicLinks) { indicators.push(createLaneIndicator({ color: '#b8f7ff', end: link.end, id: `${boss.id}:${link.id}`, mechanicId: 'stormcoil-chain-lightning', phase: indicatorPhaseFromAttack( phase === STORMCOIL_PHASE.chainWindup, phase === STORMCOIL_PHASE.chainRecover, ), sourceId: boss.id, start: link.start, width: link.width, })) } } return indicators } function stormcoilPhase(boss: Iwt2BossEntityState): string { return boss.attackPhase as string } function asBossPhase(phase: StormcoilPhase): Iwt2BossEntityState['attackPhase'] { return phase as unknown as Iwt2BossEntityState['attackPhase'] }