Android build v1.1.23

This commit is contained in:
Warren H
2026-07-05 22:48:56 -04:00
parent 956c9e32f9
commit 9708ba9e40
106 changed files with 4294 additions and 458 deletions
+41 -16
View File
@@ -13,6 +13,7 @@ import type {
Iwt2PartyEntityId,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2RoguelikePressureState,
Iwt2StatusState,
Iwt2Vec2,
} from './types'
@@ -22,6 +23,7 @@ import { separateCircles } from './collision'
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
import { applyPartyDamageInShape } from './mechanics'
import { tickRoguelikePressure } from './roguelikePressure'
import {
addVec2,
clampVec2ToArena,
@@ -62,6 +64,8 @@ export function createInitialIwt2ArenaState(
bossId: Iwt2BossId = 'bulldrome',
bossIds?: Iwt2BossId[],
bossHealthScale = 1,
partyDamageTakenScale = 1,
roguelikePressure?: Iwt2RoguelikePressureState,
): Iwt2ArenaState {
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
@@ -69,11 +73,12 @@ export function createInitialIwt2ArenaState(
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map(createPartyMember),
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)),
projectiles: [],
hostileAdds: [],
hazards: [],
indicators: [],
roguelikePressure,
boss: bosses[0],
bosses,
nextEventId: 1,
@@ -216,8 +221,13 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
party: projectileResult.party,
time: separatedState.time,
})
const pressureResult = tickRoguelikePressure({
party: hazardResult.party,
pressure: separatedState.roguelikePressure,
time: separatedState.time,
})
const damageResult = applyPartyAttacks(
hazardResult.party,
pressureResult.party,
projectileResult.bosses,
projectileResult.hostileAdds,
separatedState.time,
@@ -234,7 +244,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
finalCollisionState,
)
const nextEvents = assignEventIds(
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...pressureResult.events, ...damageResult.events, ...hotResult.events],
state.nextEventId,
)
return {
@@ -242,6 +252,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
boss: getPrimaryBoss(damageResult.bosses),
bosses: damageResult.bosses,
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
roguelikePressure: pressureResult.pressure,
party: finalParty,
hostileAdds: damageResult.hostileAdds,
hazards: hazardResult.hazards,
@@ -270,7 +281,10 @@ function tickPartyHotEffects(
let nextTickInSeconds = effect.nextTickInSeconds - dt
if (member.health > 0 && nextTickInSeconds <= 0) {
const before = health
health = Math.min(member.maxHealth, health + effect.power)
const healingMultiplier = member.shield > 0
? member.shieldedHotHealingMultiplier ?? 1
: 1
health = Math.min(member.maxHealth, health + effect.power * healingMultiplier)
const healed = health - before
if (healed > 0) {
events.push({
@@ -310,8 +324,9 @@ function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2Par
})
}
function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[initial.classId]
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
return {
id: initial.id,
kind: 'party',
@@ -321,8 +336,16 @@ function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius: metadata.radius,
moveSpeed: metadata.moveSpeed,
attackRange: metadata.attackRange,
attackDamage: metadata.attackDamage,
attackCooldown: metadata.attackCooldown,
castTime: metadata.castTime,
projectileSpeed: metadata.projectileSpeed,
health: metadata.maxHealth,
maxHealth: metadata.maxHealth,
damageTakenScale: safeDamageTakenScale,
stunTakenScale: 1,
shield: 0,
mana: initial.classId === 'healer' ? 100 : 0,
maxMana: initial.classId === 'healer' ? 100 : 0,
@@ -447,13 +470,15 @@ function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2Arena
let position = member.position
for (const boss of state.bosses) {
if (boss.health <= 0) continue
const overlapBeforeSeparation = circleOverlapAmount({ position, radius: member.radius }, boss)
const separated = separateCircles(
{ position, radius: member.radius },
{ position: boss.position, radius: boss.radius },
state.bounds,
)
position = wallClearance(separated, member, state) <= 2
|| circleOverlapAmount({ position: separated, radius: member.radius }, boss) > 0.25
const overlapAfterSeparation = circleOverlapAmount({ position: separated, radius: member.radius }, boss)
position = overlapBeforeSeparation > 0.25
&& (wallClearance(separated, member, state) <= 2 || overlapAfterSeparation > 0.25)
? separateFromBossTowardCenter(member, boss, state)
: separated
}
@@ -802,7 +827,7 @@ function applyPartyAttacks(
if (!target) return member
if (!canPartyMemberHitTarget(member, target.position, target.radius)) return member
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.projectileSpeed > 0) {
if (member.projectileSpeed > 0) {
if (!member.attackReady) return member
const direction = normalizeVec2(subtractVec2(target.position, member.position))
projectiles.push({
@@ -810,29 +835,29 @@ function applyPartyAttacks(
sourceId: member.id,
owner: 'party',
classId: member.classId,
projectileKind: member.classId === 'mage' ? 'magic' : 'arrow',
projectileKind: member.classId === 'mage' ? 'fireball' : 'arrow',
color: metadata.accentColor,
position: {
x: member.position.x + direction.x * (member.radius + 4),
y: member.position.y + direction.y * (member.radius + 4),
},
velocity: scaleVec2(direction, metadata.projectileSpeed),
velocity: scaleVec2(direction, member.projectileSpeed),
radius: member.classId === 'mage' ? 7 : 4,
damage: metadata.attackDamage,
damage: member.attackDamage,
remainingSeconds: 1.2,
})
projectileId += 1
return {
...member,
attackCooldownRemaining: metadata.attackCooldown,
attackCooldownRemaining: member.attackCooldown,
attackReady: false,
}
}
if (member.attackCooldownRemaining > 0) return member
let damage = Math.min(metadata.attackDamage, target.health)
let damage = Math.min(member.attackDamage, target.health)
let defeated = target.health - damage <= 0
if (target.kind === 'boss') {
const damageResult = applyDamageToBoss(target, metadata.attackDamage)
const damageResult = applyDamageToBoss(target, member.attackDamage)
damage = damageResult.healthDamage
defeated = damageResult.boss.health <= 0
nextBosses = nextBosses.map((boss) => boss.id === target.id
@@ -862,13 +887,13 @@ function applyPartyAttacks(
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
attackCooldownRemaining: member.attackCooldown,
}
}
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
attackCooldownRemaining: member.attackCooldown,
}
})
return {
+4 -1
View File
@@ -11,6 +11,7 @@ import type {
Iwt2BossEntityState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2RoguelikePressureState,
} from './types'
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
@@ -58,8 +59,10 @@ export function createInitialIwt2ArenaState(
bossId?: Iwt2BossId,
bossIds?: Iwt2BossId[],
bossHealthScale?: number,
partyDamageTakenScale?: number,
roguelikePressure?: Iwt2RoguelikePressureState,
): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale))
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure))
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
+9 -3
View File
@@ -27,6 +27,7 @@ import { tickTobiKadachi } from './tobiKadachiAi'
import { tickVenomOrchidHydra } from './venomOrchidHydraAi'
import { tickYianKutKu } from './yianKutKuAi'
import {
applyPartyDamageToMember,
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
@@ -281,14 +282,19 @@ function maybeApplyMelee(
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
let appliedDamage = 0
let nextTargetHealth = target.health
const nextParty = party.map((member) => {
if (member.id !== target.id) return member
return { ...member, health: Math.max(0, member.health - BULLDROME_BOSS_METADATA.meleeDamage) }
const result = applyPartyDamageToMember(member, BULLDROME_BOSS_METADATA.meleeDamage)
appliedDamage = result.damage
nextTargetHealth = result.member.health
return result.member
})
const events = [
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, BULLDROME_BOSS_METADATA.meleeDamage),
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, appliedDamage),
]
if (target.health > 0 && target.health - BULLDROME_BOSS_METADATA.meleeDamage <= 0) {
if (target.health > 0 && nextTargetHealth <= 0) {
events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id))
}
return {
+4 -4
View File
@@ -37,22 +37,22 @@ const CINDERBACK_PHASE = {
type CinderbackPhase = typeof CINDERBACK_PHASE[keyof typeof CINDERBACK_PHASE]
const CINDERBACK_TUNING = {
armorSlamDamage: 20,
armorSlamDamage: 44,
armorSlamRadius: 118,
armorSlamRecover: 1.05,
armorSlamStunSeconds: 0.7,
armorSlamWindup: 0.62,
centerDisengageSpeed: 152,
firePuddleDamage: 5,
firePuddleDamage: 15,
firePuddleRadius: 34,
firePuddleSeconds: 6.4,
lavaTrailInterval: 0.26,
meleeCooldown: 1.12,
meleeDamage: 5,
meleeDamage: 11,
meleeRange: 58,
moveSpeed: 104,
ricochetCooldown: 7.2,
ricochetDamage: 18,
ricochetDamage: 40,
ricochetDuration: 3.1,
ricochetMaxBounces: 4,
ricochetSpeed: 470,
+4 -4
View File
@@ -44,26 +44,26 @@ const CRYSTAL_BAT_TUNING = {
clusterRadius: 84,
hoverDistance: 138,
meleeCooldown: 1.18,
meleeDamage: 4,
meleeDamage: 10,
meleeRange: 54,
moveSpeed: 138,
relocateCooldownFloor: 1.2,
relocateSeconds: 1.25,
relocateSpeed: 184,
shardCooldown: 5.8,
shardDamage: 13,
shardDamage: 34,
shardLaneWidth: 30,
shardRecover: 0.58,
shardStunSeconds: 0.35,
shardWindup: 0.82,
sonicCooldown: 6.4,
sonicDamage: 10,
sonicDamage: 30,
sonicRecover: 0.42,
sonicRingRadius: 58,
sonicStunSeconds: 0.32,
sonicWindup: 0.78,
swoopCooldown: 4.9,
swoopDamage: 17,
swoopDamage: 42,
swoopLaneWidth: 44,
swoopRecover: 0.62,
swoopSpeed: 430,
+3 -3
View File
@@ -36,16 +36,16 @@ type EmberMantisAttackPhase =
const EMBER_MANTIS = {
accentColor: '#ffb13b',
crossSlashCooldown: 7.5,
crossSlashDamage: 20,
crossSlashDamage: 44,
crossSlashStunSeconds: 0.45,
crossSlashWindup: 0.82,
duelistRange: 118,
lineSlashCooldown: 3.4,
lineSlashDamage: 15,
lineSlashDamage: 36,
lineSlashStunSeconds: 0.25,
lineSlashWindup: 0.48,
meleeCooldown: 0.8,
meleeDamage: 7,
meleeDamage: 12,
meleeRange: 52,
moveSpeed: 168,
recoverSeconds: 0.38,
+77
View File
@@ -0,0 +1,77 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
import {
IWT2_GEAR_SLOT_RECIPES,
IWT2_GEAR_SLOTS,
type Iwt2GearProgress,
} from '../content/gear'
import type { Iwt2ArenaState, Iwt2PartyEntityState } from './types'
import { applyIwt2InfusionLoadout } from './infusionLoadout'
const PER_LEVEL_OUTPUT = 0.04
const PER_LEVEL_HEALTH = 0.04
const PER_LEVEL_SPEED = 0.025
const PER_LEVEL_COOLDOWN = 0.03
const PER_LEVEL_PROJECTILE = 0.03
const PER_LEVEL_DAMAGE_TAKEN_REDUCTION = 0.03
const PER_LEVEL_STUN_REDUCTION = 0.08
export function applyIwt2PveGearStats<TState extends Pick<Iwt2ArenaState, 'party'>>(state: TState, gearProgress: Iwt2GearProgress): TState {
const party = state.party.map((member) => applyMemberGearStats(member, gearProgress))
return applyIwt2InfusionLoadout({
...state,
party,
}, gearProgress)
}
export function applyIwt2PveGearToHealerAbilities(
abilities: Iwt2HealerAbility[],
gearProgress: Iwt2GearProgress,
): Iwt2HealerAbility[] {
const healer = gearProgress.healer
const healingLevel = healer.slots.weapon.level
const cooldownLevel = healer.slots.helmet.level
if (healingLevel <= 0 && cooldownLevel <= 0) return abilities
return abilities.map((ability) => ({
...ability,
cooldownSeconds: roundOne(ability.cooldownSeconds * (1 - cooldownLevel * PER_LEVEL_COOLDOWN)),
power: Math.max(1, Math.round(ability.power * (1 + healingLevel * PER_LEVEL_OUTPUT))),
}))
}
function applyMemberGearStats(
member: Iwt2PartyEntityState,
gearProgress: Iwt2GearProgress,
): Iwt2PartyEntityState {
const classProgress = gearProgress[member.classId]
let next = { ...member }
for (const slotId of IWT2_GEAR_SLOTS) {
const level = classProgress.slots[slotId].level
if (level <= 0) continue
const statId = IWT2_GEAR_SLOT_RECIPES[member.classId][slotId].statId
if (statId === 'maxHealth') {
const maxHealth = Math.round(next.maxHealth * (1 + level * PER_LEVEL_HEALTH))
next = {
...next,
health: Math.min(maxHealth, Math.round(next.health * (maxHealth / next.maxHealth))),
maxHealth,
}
} else if (statId === 'moveSpeed') {
next = { ...next, moveSpeed: next.moveSpeed * (1 + level * PER_LEVEL_SPEED) }
} else if (statId === 'damage') {
next = { ...next, attackDamage: next.attackDamage * (1 + level * PER_LEVEL_OUTPUT) }
} else if (statId === 'attackCooldown') {
next = { ...next, attackCooldown: next.attackCooldown * (1 - level * PER_LEVEL_COOLDOWN) }
} else if (statId === 'projectileSpeed') {
next = { ...next, projectileSpeed: next.projectileSpeed * (1 + level * PER_LEVEL_PROJECTILE) }
} else if (statId === 'hazardDamageTaken') {
next = { ...next, damageTakenScale: next.damageTakenScale * (1 - level * PER_LEVEL_DAMAGE_TAKEN_REDUCTION) }
} else if (statId === 'stunResist') {
next = { ...next, stunTakenScale: Math.max(0, next.stunTakenScale * (1 - level * PER_LEVEL_STUN_REDUCTION)) }
}
}
return next
}
function roundOne(value: number): number {
return Math.max(0.1, Math.round(value * 10) / 10)
}
+40 -1
View File
@@ -1,4 +1,4 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
import type { Iwt2HealerAbility, Iwt2TriggeredAbilityEffect } from '../content/healerAbilities'
import type {
Iwt2ArenaEvent,
Iwt2ArenaState,
@@ -31,6 +31,8 @@ export function castIwt2HealerAbility<TState extends Iwt2ArenaState>(
const events: Iwt2ArenaEvent[] = []
let eventId = state.nextEventId
events.push(createArenaEvent(eventId, state.time, 'healerSpellCast', HEALER_ID, targetIds[0]))
eventId += 1
const targetIdSet = new Set(targetIds)
const nextParty = state.party.map((member) => {
const mana = member.id === HEALER_ID ? Math.max(0, member.mana - ability.manaCost) : member.mana
@@ -82,6 +84,12 @@ function targetIdsForAbility(
}
function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
const baseMember = applyAbilityCoreToMember(member, ability)
if (!ability.triggeredEffects || ability.triggeredEffects.length === 0) return baseMember
return ability.triggeredEffects.reduce(applyTriggeredEffectToMember, baseMember)
}
function applyAbilityCoreToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
if (ability.kind === 'hot') {
return {
...healMember(member, ability.power),
@@ -120,6 +128,37 @@ function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerA
return healMember(member, ability.power)
}
function applyTriggeredEffectToMember(
member: Iwt2PartyEntityState,
effect: Iwt2TriggeredAbilityEffect,
): Iwt2PartyEntityState {
if (effect.kind === 'hot') {
return {
...healMember(member, effect.power),
hotEffects: [
...member.hotEffects.filter((hotEffect) => hotEffect.id !== effect.id),
{
id: effect.id,
label: effect.name,
power: Math.max(1, Math.round(effect.power * 0.5)),
remainingSeconds: DEFAULT_HOT_SECONDS,
tickIntervalSeconds: DEFAULT_HOT_TICK_SECONDS,
nextTickInSeconds: DEFAULT_HOT_TICK_SECONDS,
},
],
}
}
if (effect.kind === 'shield') {
return {
...member,
shield: Math.max(member.shield, effect.power),
}
}
return healMember(member, effect.power)
}
function healMember(member: Iwt2PartyEntityState, amount: number): Iwt2PartyEntityState {
return {
...member,
+5 -5
View File
@@ -44,7 +44,7 @@ type HollowcrownAttackPhase = Iwt2BossEntityState['attackPhase'] | HollowcrownSp
const HOLLOWCROWN_TUNING = {
antlerCooldown: 7.4,
antlerDamage: 17,
antlerDamage: 42,
antlerLaneLength: 440,
antlerLaneOffset: 44,
antlerLineWidth: 30,
@@ -52,22 +52,22 @@ const HOLLOWCROWN_TUNING = {
antlerStunSeconds: 0.48,
antlerWindup: 0.82,
dashCooldown: 5.2,
dashDamage: 13,
dashDamage: 34,
dashLength: 330,
dashRecover: 0.42,
dashSpeed: 430,
dashStunSeconds: 0.32,
dashTrailDamage: 9,
dashTrailDamage: 20,
dashTrailSeconds: 0.5,
dashTrailWidth: 40,
hoofCooldown: 6.2,
hoofDamage: 12,
hoofDamage: 34,
hoofRadius: 46,
hoofRecover: 0.46,
hoofStunSeconds: 0.34,
hoofWindup: 0.74,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeDamage: 11,
meleeRange: 58,
moveSpeed: 116,
relocateSeconds: 1.25,
+5
View File
@@ -27,6 +27,11 @@ export {
castIwt2HealerAbility,
} from './healing'
export {
applyIwt2InfusionLoadout,
selectedInfusionForMember,
} from './infusionLoadout'
export {
addVec2,
clamp,
+43
View File
@@ -0,0 +1,43 @@
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbility } from '../content/infusionAbilities'
import type { Iwt2GearProgress } from '../content/gear'
import type { Iwt2ArenaState, Iwt2PartyEntityState } from './types'
export function applyIwt2InfusionLoadout<TState extends Pick<Iwt2ArenaState, 'party'>>(
state: TState,
gearProgress: Iwt2GearProgress,
): TState {
return {
...state,
party: state.party.map((member) => {
const ability = selectedInfusionForMember(member, gearProgress)
return ability ? applyInfusionToMember(member, ability) : member
}),
}
}
export function selectedInfusionForMember(
member: Pick<Iwt2PartyEntityState, 'classId'>,
gearProgress: Iwt2GearProgress,
): Iwt2InfusionAbility | null {
const abilityId = gearProgress[member.classId].infusionAbilityId
if (!abilityId) return null
const ability = IWT2_INFUSION_ABILITIES[abilityId]
return ability?.classId === member.classId ? ability : null
}
function applyInfusionToMember(
member: Iwt2PartyEntityState,
ability: Iwt2InfusionAbility,
): Iwt2PartyEntityState {
const baseMember = {
...member,
infusionAbilityId: ability.id,
}
if (ability.effectKey === 'unbreakable') {
return {
...baseMember,
stunTakenScale: 0,
}
}
return baseMember
}
+29 -11
View File
@@ -127,25 +127,22 @@ export function applyPartyDamageInShape(
const events: Iwt2ArenaEvent[] = []
const excluded = new Set(effect.excludedEntityIds ?? [])
const hitEntityIds: Iwt2EntityId[] = []
const stunSeconds = effect.stunSeconds ?? 0
const knockdownSeconds = effect.knockdownSeconds ?? stunSeconds
const nextParty = party.map((member) => {
if (member.health <= 0 || excluded.has(member.id) || !partyMemberIntersectsShape(member, shape)) {
return member
}
const stunSeconds = (effect.stunSeconds ?? 0) * member.stunTakenScale
const knockdownSeconds = (effect.knockdownSeconds ?? (effect.stunSeconds ?? 0)) * member.stunTakenScale
const damageResult = applyPartyDamageToMember(member, effect.damage)
hitEntityIds.push(member.id)
const shieldAbsorbed = Math.min(member.shield, effect.damage)
const healthDamage = effect.damage - shieldAbsorbed
const nextShield = member.shield - shieldAbsorbed
const nextHealth = Math.max(0, member.health - healthDamage)
events.push(createArenaEvent(
0,
effect.time,
effect.damageEventType ?? 'partyDamaged',
effect.sourceId,
member.id,
effect.damage,
damageResult.damage,
))
if (stunSeconds > 0 || knockdownSeconds > 0) {
events.push(createArenaEvent(
@@ -157,14 +154,12 @@ export function applyPartyDamageInShape(
Math.max(stunSeconds, knockdownSeconds),
))
}
if (member.health > 0 && nextHealth <= 0) {
if (member.health > 0 && damageResult.member.health <= 0) {
events.push(createArenaEvent(0, effect.time, 'entityDefeated', effect.sourceId, member.id))
}
return {
...member,
health: nextHealth,
shield: nextShield,
...damageResult.member,
status: {
...member.status,
stunnedSeconds: Math.max(member.status.stunnedSeconds, stunSeconds),
@@ -176,6 +171,29 @@ export function applyPartyDamageInShape(
return { party: nextParty, hitEntityIds, events }
}
export function applyPartyDamageToMember(
member: Iwt2PartyEntityState,
damage: number,
): {
member: Iwt2PartyEntityState
damage: number
} {
const shieldedDamageMultiplier = member.shield > 0
? member.shieldedDamageTakenMultiplier ?? 1
: 1
const scaledDamage = Math.max(0, damage * member.damageTakenScale * shieldedDamageMultiplier)
const shieldAbsorbed = Math.min(member.shield, scaledDamage)
const healthDamage = scaledDamage - shieldAbsorbed
return {
damage: scaledDamage,
member: {
...member,
health: Math.max(0, member.health - healthDamage),
shield: member.shield - shieldAbsorbed,
},
}
}
export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape: Iwt2HitShape): boolean {
if (shape.kind === 'lane') {
return circleIntersectsSegment(
+4 -4
View File
@@ -47,7 +47,7 @@ const OBSIDIAN_RAM = {
armorCracksBeforeShatter: 3,
centerDisengageSpeed: 118,
chargeCooldown: 4.4,
chargeDamage: 8,
chargeDamage: 24,
chargeLength: 340,
chargeRecover: 0.5,
chargeSpeed: 390,
@@ -55,11 +55,11 @@ const OBSIDIAN_RAM = {
chargeWidth: 54,
chargeWindup: 0.68,
fractureLaneCount: 5,
fractureLaneDamage: 6,
fractureLaneDamage: 20,
fractureLaneLength: 300,
fractureLaneWidth: 30,
meleeCooldown: 1.3,
meleeDamage: 2,
meleeDamage: 8,
meleeRange: 62,
moveSpeed: 82,
quakeCooldown: 7.4,
@@ -67,7 +67,7 @@ const OBSIDIAN_RAM = {
quakeRecover: 0.48,
quakeStunSeconds: 0.35,
quakeWindup: 0.78,
slabDamage: 10,
slabDamage: 22,
slabDuration: 5.8,
slabRadius: 42,
slabSlowRadius: 46,
+5 -8
View File
@@ -1,4 +1,3 @@
import { IWT2_CLASS_METADATA } from '../content/classes'
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
import { partyMemberIntersectsShape, type Iwt2HitShape } from './mechanics'
@@ -34,7 +33,6 @@ export function tickPartyMember(
inputMove: Iwt2Vec2,
dt: number,
): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[member.classId]
const status = {
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
@@ -52,7 +50,7 @@ export function tickPartyMember(
if (member.aiRole === 'player') {
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds))
const velocity = scaleVec2(normalizedInput, member.moveSpeed * movementSpeedMultiplier(status.slowedSeconds))
const position = clampVec2ToArena(
{
x: member.position.x + velocity.x * dt,
@@ -82,7 +80,7 @@ export function tickPartyMember(
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
? castSecondsRemaining
: metadata.castTime
: member.castTime
return {
...member,
velocity: { x: 0, y: 0 },
@@ -96,7 +94,7 @@ export function tickPartyMember(
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
const maxDistance = metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds) * dt
const maxDistance = member.moveSpeed * movementSpeedMultiplier(status.slowedSeconds) * dt
const distanceToDesired = distanceVec2(member.position, desired)
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
const position = movingToDesired
@@ -229,9 +227,8 @@ function rotateVec2(vector: Iwt2Vec2, angle: number): Iwt2Vec2 {
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius + targetRadius
if (member.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= member.attackRange + member.radius + targetRadius
}
function getPartyDesiredPosition(
+4 -4
View File
@@ -49,7 +49,7 @@ type ShardCone = {
const RIMEBASTION_BALANCE = {
iceWallCooldown: 7.2,
iceWallDamage: 6,
iceWallDamage: 18,
iceWallStunSeconds: 0.25,
iceWallWindup: 1,
iceCircleCount: 2,
@@ -57,16 +57,16 @@ const RIMEBASTION_BALANCE = {
iceWallLaneCount: 3,
iceWallWidth: 22,
meleeCooldown: 1.45,
meleeDamage: 4,
meleeDamage: 10,
meleeRange: 66,
moveSpeed: 64,
relocateCooldownFloor: 1.3,
relocateSpeed: 112,
relocateSeconds: 1.6,
shatterConeAngleRadians: Math.PI * 0.34,
shatterConeDamage: 9,
shatterConeDamage: 30,
shatterConeRange: 158,
shatterLaneDamage: 11,
shatterLaneDamage: 34,
shatterLaneWidth: 34,
shatterRecover: 0.52,
shatterStunSeconds: 0.4,
+92
View File
@@ -0,0 +1,92 @@
import type {
Iwt2ArenaEvent,
Iwt2PartyEntityState,
Iwt2RoguelikePressureContentType,
Iwt2RoguelikePressureState,
} from './types'
import { applyPartyDamageToMember, createArenaEvent } from './mechanics'
const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5
const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9
const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
const ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE = 1.25
const ROGUELIKE_RAID_DAMAGE_BASE_SCALE = 1.45
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
export function roguelikeIncomingDamageScale(
_stage: number,
contentType: Iwt2RoguelikePressureContentType,
): number {
const base = contentType === 'dungeon'
? ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE
: ROGUELIKE_RAID_DAMAGE_BASE_SCALE
return base
}
export function createRoguelikePressureState(
stage: number,
contentType: Iwt2RoguelikePressureContentType,
): Iwt2RoguelikePressureState {
const safeStage = safeRoguelikeStage(stage)
return {
stage: safeStage,
contentType,
damage: Math.round(ROGUELIKE_PRESSURE_BASE_DAMAGE + ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE),
intervalSeconds: ROGUELIKE_PRESSURE_INTERVAL_SECONDS,
nextPulseAt: ROGUELIKE_PRESSURE_INTERVAL_SECONDS,
}
}
export function tickRoguelikePressure({
party,
pressure,
time,
}: {
party: Iwt2PartyEntityState[]
pressure?: Iwt2RoguelikePressureState
time: number
}): {
events: Iwt2ArenaEvent[]
party: Iwt2PartyEntityState[]
pressure?: Iwt2RoguelikePressureState
} {
if (!pressure || time < pressure.nextPulseAt) {
return { events: [], party, pressure }
}
const events: Iwt2ArenaEvent[] = []
const nextParty = party.map((member) => {
if (member.health <= 0) return member
const before = member.health
const result = applyPartyDamageToMember(member, pressure.damage)
events.push(createArenaEvent(
0,
time,
'partyDamaged',
ROGUELIKE_PRESSURE_SOURCE_ID,
member.id,
result.damage,
))
if (before > 0 && result.member.health <= 0) {
events.push(createArenaEvent(0, time, 'entityDefeated', ROGUELIKE_PRESSURE_SOURCE_ID, member.id))
}
return result.member
})
let nextPulseAt = pressure.nextPulseAt
while (nextPulseAt <= time) nextPulseAt += pressure.intervalSeconds
return {
events,
party: nextParty,
pressure: {
...pressure,
nextPulseAt,
},
}
}
function safeRoguelikeStage(stage: number): number {
return Number.isFinite(stage) ? Math.max(1, Math.floor(stage)) : 1
}
+3 -3
View File
@@ -50,20 +50,20 @@ const SANDGLASS = {
burrowWindup: 0.48,
disengageSeconds: 0.72,
disengageSpeed: 148,
eruptionDamage: 15,
eruptionDamage: 70,
eruptionRadius: 50,
eruptionRecover: 0.52,
eruptionStunSeconds: 0.42,
eruptionWindup: 0.76,
hourglassCooldown: 8.1,
hourglassDamage: 6,
hourglassDamage: 35,
hourglassHazardSeconds: 2.2,
hourglassRadius: 66,
hourglassRecover: 0.48,
hourglassSafeSeconds: 1.05,
hourglassTickSeconds: 0.5,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeDamage: 22,
meleeRange: 58,
moveSpeed: 94,
wallMargin: 70,
+4 -4
View File
@@ -41,14 +41,14 @@ type StormcoilPhase = typeof STORMCOIL_PHASE[keyof typeof STORMCOIL_PHASE]
const STORMCOIL_TUNING = {
arcAngleRadians: Math.PI * 0.34,
arcCooldown: 5.6,
arcDamage: 6,
arcDamage: 22,
arcInnerRadius: 78,
arcOuterRadius: 196,
arcRecover: 0.46,
arcStunSeconds: 0.24,
arcWindup: 0.74,
chainCooldown: 7.4,
chainDamage: 5,
chainDamage: 18,
chainLinkRadius: 150,
chainMaxLinks: 7,
chainRecover: 0.5,
@@ -57,14 +57,14 @@ const STORMCOIL_TUNING = {
chainWindup: 0.68,
disengageSpeed: 168,
donutCooldown: 6.2,
donutDamage: 7,
donutDamage: 24,
donutInnerRadius: 64,
donutOuterRadius: 156,
donutRecover: 0.48,
donutStunSeconds: 0.44,
donutWindup: 0.82,
meleeCooldown: 1.12,
meleeDamage: 2,
meleeDamage: 8,
meleeRange: 58,
moveSpeed: 124,
wallContactLimit: 0.72,
+24
View File
@@ -1,5 +1,6 @@
import type { Iwt2BossId } from '../content/bosses'
import type { Iwt2PlayerClassId } from '../content/classes'
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
export type Iwt2Vec2 = {
x: number
@@ -47,13 +48,24 @@ export type Iwt2PartyEntityState = {
id: Iwt2PartyEntityId
kind: 'party'
classId: Iwt2PlayerClassId
infusionAbilityId?: Iwt2InfusionAbilityId
aiRole: Iwt2PartyAiRole
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
moveSpeed: number
attackRange: number
attackDamage: number
attackCooldown: number
castTime: number
projectileSpeed: number
health: number
maxHealth: number
damageTakenScale: number
stunTakenScale: number
shieldedDamageTakenMultiplier?: number
shieldedHotHealingMultiplier?: number
shield: number
mana: number
maxMana: number
@@ -270,6 +282,7 @@ export type Iwt2GroundHazardState = {
export type Iwt2ArenaEventType =
| 'bossDamaged'
| 'healerSpellCast'
| 'partyHealed'
| 'partyDamaged'
| 'partyStunned'
@@ -287,6 +300,16 @@ export type Iwt2ArenaEvent = {
value?: number
}
export type Iwt2RoguelikePressureContentType = 'dungeon' | 'raid' | 'stadium'
export type Iwt2RoguelikePressureState = {
stage: number
contentType: Iwt2RoguelikePressureContentType
intervalSeconds: number
damage: number
nextPulseAt: number
}
export type Iwt2ArenaIndicatorPhase = 'windup' | 'active' | 'recover'
export type Iwt2ArenaIndicatorBase = {
@@ -354,6 +377,7 @@ export type Iwt2ArenaState = {
boss: Iwt2BossEntityState
bosses: Iwt2BossEntityState[]
indicators: Iwt2ArenaIndicator[]
roguelikePressure?: Iwt2RoguelikePressureState
nextEventId: number
nextProjectileId: number
nextAddId: number
+5 -5
View File
@@ -42,29 +42,29 @@ const VENOM_ORCHID_HYDRA_PHASE = {
type VenomOrchidHydraPhase = typeof VENOM_ORCHID_HYDRA_PHASE[keyof typeof VENOM_ORCHID_HYDRA_PHASE]
const VENOM_ORCHID_HYDRA = {
bloomBurstDamage: 7,
bloomBurstDamage: 18,
bloomCooldown: 8.6,
bloomPodCount: 3,
bloomPodDamage: 3,
bloomPodDamage: 10,
bloomPodRadius: 30,
bloomPodSeconds: 1.4,
bloomRecover: 0.5,
bloomWindup: 0.9,
coneAngleRadians: Math.PI * 0.34,
coneCooldown: 5.7,
coneDamage: 10,
coneDamage: 28,
coneRange: 178,
coneRecover: 0.62,
coneStaggerWindup: 0.48,
coneStunSeconds: 0.18,
headOffset: 22,
meleeCooldown: 1.35,
meleeDamage: 4,
meleeDamage: 10,
meleeRange: 58,
moveSpeed: 58,
relocateSpeed: 114,
relocateSeconds: 1.45,
rootLaneDamage: 8,
rootLaneDamage: 22,
rootLaneLengthInset: 36,
rootLaneWidth: 28,
rootRecover: 0.58,
+1 -1
View File
@@ -45,7 +45,7 @@ export type Iwt2YianTickResult = {
const BIRD_COUNT = 3
const BIRD_MELEE_RANGE = 24
const BIRD_MELEE_COOLDOWN = 1.15
const BIRD_FLIGHT_DAMAGE = 10
const BIRD_FLIGHT_DAMAGE = 26
const YIAN_FIREBALL_BOUNCES = 8
const YIAN_SAFE_WALL_MARGIN = 118
const YIAN_CENTER_CAST_DISTANCE = 36