Created
October 21, 2023 17:25
-
-
Save Elmuti/53fcda60f890a98562f98cb4b38642cc to your computer and use it in GitHub Desktop.
server/unit.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { UnitStat } from "shared/unit/UnitStat"; | |
import { Damage } from "server/combat/Damage"; | |
import { SpellResult } from "shared/spell/SpellResult"; | |
import { angleBetween } from "shared/support/math"; | |
import { SpellCast } from "server/spell/SpellCast"; | |
import { Heal } from "server/combat/Heal"; | |
import { Dependency } from "@flamework/core"; | |
import { SpellService } from "server/spell/SpellService"; | |
import { UnitStates } from "shared/unit/UnitState"; | |
import { DRType } from "shared/combat/DRType"; | |
import { School } from "shared/spell/School"; | |
import { WorldModel } from "server/unit/WorldModel"; | |
import { UnitReplicator } from "server/net/UnitReplicator"; | |
import { Aura } from "server/aura/Aura"; | |
import { Signal } from "@rbxts/beacon"; | |
import { UnitDTO } from "shared/net/data-transfer-objects"; | |
import Remotes from "shared/net/remotes"; | |
import { CollisionGroup } from "shared/enums/CollisionGroup"; | |
import { playerHasteRating } from "server/formulas/PlayerFormulas"; | |
import { AUTO_ATTACK_MAX_DISTANCE, AUTOSHOT_MAX_DISTANCE } from "shared/support/SharedDefines"; | |
import { Workspace } from "@rbxts/services"; | |
import { UnitClass } from "shared/unit/UnitClass"; | |
import { DamageSourceType } from "shared/combat/DamageSourceType"; | |
import { UnitAttackType } from "shared/unit/UnitAttackType"; | |
import { SpellMechanics } from "shared/spell/SpellMechanics"; | |
import { UnitMotion } from "server/unit/UnitMotion"; | |
import { FleeMovement } from "server/unit/movement/generators/FleeMovement"; | |
import { todo, unreachable } from "shared/support/errors"; | |
import { AuraEffect, AuraEffectTypeMap, SpellData } from "shared/data/SpellData"; | |
import { calculateHarmfulSpellResult } from "server/combat/Combat"; | |
import { sumAuraDurationModifiers } from "shared/spell/spell-modifiers"; | |
import { ItemEquipSlot } from "shared/item/ItemEquipSlot"; | |
import { Item } from "server/item/Item"; | |
import { EquipItemResult } from "shared/item/EquipItemResult"; | |
type StatTable = { | |
base: number; | |
flat: number; | |
percent: number; | |
} | |
export interface UnitOptions { | |
health?: number; | |
maxHealth: number; | |
mana?: number; | |
maxMana: number; | |
level?: number; | |
name: string; | |
worldModel: WorldModel; | |
replicator: UnitReplicator; | |
player?: Player; | |
dead?: boolean; | |
} | |
const GLOBAL_COOLDOWN = 1.5; | |
const UNIT_FOV_DEGREES = 120; | |
export class Unit { | |
private readonly id: number; | |
private health: number; | |
private maxHealth: number; | |
private mana: number; | |
private maxMana: number; | |
private level: number; | |
private target?: Unit; | |
private readonly stats = new Map<UnitStat, StatTable>(); | |
private globalCooldownTimer = 0; | |
private attackTimer = 0; | |
private readonly name: string; | |
private currentCast?: SpellCast; | |
private autoAttack = false; | |
private readonly player?: Player; | |
private charmer?: Unit; | |
private readonly auras = new Array<Aura>(); | |
private readonly unitStates: UnitStates; | |
private readonly diminishingReturns = new Map<DRType, { stack: number, timer: number }>(); | |
private readonly spellLocks = new Map<School, number>(); | |
private readonly spellCooldowns = new Map<string, number>(); | |
private worldModel: WorldModel; | |
private replicator: UnitReplicator; | |
private lineOfSightRaycastParams = new RaycastParams(); | |
private attackType = UnitAttackType.Melee; | |
private comboPoints = 0; | |
private readonly unitMotion = new UnitMotion(); | |
private rooted = false; | |
private speedModifier = 1.0; | |
private readonly modifiers = new Array<AuraEffect>(); | |
private readonly equipment = new Map<ItemEquipSlot, Item>(); | |
private readonly inventory = new Array<unknown>(); | |
public readonly died = new Signal<void>(); | |
public readonly spellCasted = new Signal<[SpellData]>(); | |
constructor(id: number, options: UnitOptions) { | |
this.id = id; | |
this.health = options.health ?? options.maxHealth; | |
this.maxHealth = options.maxHealth; | |
this.mana = options.mana ?? options.maxMana; | |
this.maxMana = options.maxMana; | |
this.level = options.level ?? 1; | |
this.name = options.name; | |
this.worldModel = options.worldModel; | |
this.replicator = options.replicator; | |
this.player = options.player; | |
this.replicator.enable(); | |
this.worldModel.setCollisionGroup(CollisionGroup.Unit); | |
this.lineOfSightRaycastParams.CollisionGroup = CollisionGroup.Unit; | |
this.unitStates = { | |
dead: options.dead ?? false, | |
charmed: false, | |
evade: false, | |
fleeing: false, | |
rooted: false, | |
stunned: false, | |
}; | |
} | |
public getId(): number { | |
return this.id; | |
} | |
public getName(): string { | |
return this.name; | |
} | |
public onPhysics(dt: number): void { | |
this.worldModel.onPhysics(dt); | |
} | |
public update(dt: number): void { | |
this.unitMotion.onUpdate(dt); | |
this.worldModel.update(dt); | |
// Update global cooldown | |
if (this.globalCooldownTimer > 0) { | |
this.globalCooldownTimer -= dt; | |
if (this.globalCooldownTimer < 0) { | |
this.globalCooldownTimer = 0; | |
} | |
} | |
// update auto attack timer while we're not casting | |
if (this.currentCast === undefined && this.autoAttack) { | |
if (this.attackTimer > 0) { | |
this.attackTimer -= dt; | |
} | |
if (this.attackTimer <= 0) { | |
this.doAttack(); | |
} | |
} | |
// update current cast | |
if (this.currentCast) { | |
this.currentCast.update(dt); | |
if (this.currentCast.isFinished()) { | |
this.replicator.replicateCurrentSpellCast(undefined, this.currentCast.result()); | |
// todo: should it be cleared in all cases? | |
if (this.currentCast.result() !== SpellResult.Success) { | |
this.clearGlobalCooldown(); | |
} | |
this.currentCast = undefined; | |
} | |
} | |
// reduce diminishing returns | |
for (const [drType, data] of this.diminishingReturns) { | |
data.timer -= dt; | |
if (data.timer <= 0) { | |
this.diminishingReturns.delete(drType); | |
} | |
} | |
// reduce spell locks | |
for (const [school, timer] of this.spellLocks) { | |
const remaining = timer - dt; | |
if (remaining <= 0) { | |
this.spellLocks.delete(school); | |
} else { | |
this.spellLocks.set(school, remaining); | |
} | |
} | |
// reduce spell cooldowns | |
for (const [spell, timer] of this.spellCooldowns) { | |
const remaining = timer - dt; | |
if (remaining <= 0) { | |
this.spellCooldowns.delete(spell); | |
} else { | |
this.spellCooldowns.set(spell, remaining); | |
} | |
} | |
// update auras | |
const auraRemoveIndices = new Array<number>(); | |
let index = 0; | |
for (const aura of this.auras) { | |
aura.update(dt); | |
if (aura.shouldRemove()) { | |
auraRemoveIndices.push(index); | |
} | |
index += 1; | |
} | |
for (const index of auraRemoveIndices) { | |
this.removeAura(this.auras[index]); | |
} | |
} | |
public applyBaseStats(defStats: Map<UnitStat, number>) { | |
for (const [stat, value] of defStats) { | |
this.stats.set(stat, {base: value, flat: 0, percent: 0}); | |
} | |
} | |
public getStat(stat: UnitStat): number { | |
const statTable = this.stats.get(stat); | |
if (!statTable) { | |
return 0; | |
} else { | |
const { base, flat, percent } = statTable; | |
return math.ceil((base + flat) * (percent + 1)); | |
} | |
} | |
public modifyStatBase(stat: UnitStat, amount: number): void { | |
// Get stat table or create a new one if it does not exist | |
const exist = this.stats.get(stat); | |
const statTable = this.stats.get(stat) ?? { | |
base: amount, | |
flat: exist !== undefined ? exist.flat : 0, | |
percent: exist !== undefined ? exist.percent : 0, | |
}; | |
this.stats.set(stat, statTable); | |
} | |
public modifyStat(stat: UnitStat, amount: number, percent = false): void { | |
// Get stat table or create a new one if it does not exist | |
const statTable = this.stats.get(stat) ?? { | |
base: 0, | |
flat: 0, | |
percent: 0, | |
}; | |
if (percent) { | |
statTable.percent += amount; | |
} else { | |
statTable.flat += amount; | |
} | |
this.stats.set(stat, statTable); | |
} | |
public modifySpeed(percent: number): void { | |
if (percent === 0) { | |
warn("Called modifySpeed with 0% speed change"); | |
return; | |
} | |
// -500% to 500% | |
percent = math.clamp(percent, -5, 5); | |
this.speedModifier += percent; | |
this.updateHumanoidMovement(); | |
} | |
private updateHumanoidMovement(): void { | |
const humanoid = this.worldModel.getHumanoid(); | |
if (!humanoid) { | |
return; | |
} | |
if (this.rooted) { | |
humanoid.WalkSpeed = 0; | |
humanoid.JumpPower = 0; | |
} else { | |
humanoid.WalkSpeed = 16 * this.speedModifier; | |
humanoid.JumpPower = 50; | |
} | |
} | |
public root(): void { | |
this.rooted = true; | |
this.updateHumanoidMovement(); | |
} | |
public unroot(): void { | |
this.rooted = false; | |
this.updateHumanoidMovement(); | |
} | |
public isRooted(): boolean { | |
return this.rooted; | |
} | |
public cast(spellId: string, target: Unit | Vector3 | undefined, forceCast?: boolean): SpellResult { | |
const spellService = Dependency<SpellService>(); | |
const spellData = spellService.findSpell(spellId); | |
if (!spellData) { | |
warn(`Unit failed to cast. Spell not found, id: ${spellId}`); | |
return SpellResult.UnknownSpell; | |
} | |
const cast = new SpellCast( | |
spellData, | |
this, | |
target, | |
forceCast, | |
); | |
const result = cast.beginCast(); | |
if (result === SpellResult.Success) { | |
if (cast.isInstantCast()) { | |
// todo: replicate instant spell cast | |
} else { | |
this.currentCast = cast; | |
this.replicator.replicateCurrentSpellCast(cast, cast.result()); | |
} | |
if (!forceCast && !spellData.flags.ignoreGlobalCooldown) { | |
this.triggerGlobalCooldown(); | |
} | |
if (!forceCast && spellData.flags.resetSwingTimer) { | |
this.resetSwingTimer(); | |
} | |
} | |
return result; | |
} | |
public stopCasting(): void { | |
if (this.currentCast !== undefined) { | |
this.currentCast.stop(); | |
this.clearGlobalCooldown(); | |
} | |
} | |
public takeDamage(damage: Damage) { | |
const amount = damage.getDamageAmount(); | |
this.setHealth(this.getHealth() - amount); // todo | |
this.replicator.replicateDamageTaken(damage); | |
} | |
public takeHealing(healing: Heal) { | |
const amount = healing.getAmount(); | |
const final = this.getHealth() + amount; | |
const overheal = this.getHealth() - final; | |
warn("overheal: " + overheal); | |
if (overheal < 0) { | |
healing.setOverHeal(math.abs(overheal)); | |
} | |
print(`${healing.getCaster()} healing ${healing.getTarget()} for ${healing.getAmount()} (${math.abs(overheal)} overheal)`); | |
this.setHealth(this.getHealth() + amount); // todo | |
this.replicator.replicateHealTaken(healing); | |
} | |
public triggerGlobalCooldown(cooldown = GLOBAL_COOLDOWN): void { | |
this.globalCooldownTimer = cooldown; | |
this.replicator.replicateGlobalCooldown(cooldown); | |
} | |
public isOnGlobalCooldown(): boolean { | |
return this.globalCooldownTimer > 0; | |
} | |
public clearGlobalCooldown(): void { | |
this.globalCooldownTimer = 0; | |
this.replicator.replicateClearGlobalCooldown(); | |
} | |
// region: Spell cooldowns | |
public addSpellCooldown(spell: SpellData, cooldown?: number): void { | |
if (cooldown === undefined && spell.cooldown === 0) { | |
return; | |
} | |
cooldown ??= spell.cooldown; | |
this.spellCooldowns.set(spell.id, cooldown); | |
this.replicator.replicateSpellCooldown(spell, cooldown); | |
} | |
public clearAllSpellCooldowns(): void { | |
this.spellCooldowns.clear(); | |
this.replicator.replicateClearAllCooldowns(); | |
} | |
public isSpellOnCooldown(spell: SpellData): boolean { | |
return this.spellCooldowns.has(spell.id); | |
} | |
// endregion | |
public applyAura(aura: Aura): void { | |
const spell = aura.getSpell(); | |
const existingAura = this.findSpellAuraCastedByUnit(spell, aura.getCaster()!); // todo: undefined caster | |
if (existingAura && spell.maxStacks <= 1) { | |
// Unstackable aura already exists, we replace existing aura with this one | |
this.removeAura(existingAura); | |
} else if (existingAura && spell.maxStacks > 1) { | |
// Stackable aura | |
const maxStacks = existingAura.getMaxStacks(); | |
const currentStacks = existingAura.getStacks(); | |
if (currentStacks >= maxStacks) { | |
// Nothing to do | |
return; | |
} | |
existingAura.addStacks(1); | |
existingAura.resetDuration(); | |
this.replicator.replicateAuraStacksChanged(existingAura); | |
this.replicator.replicateAuraDurationChanged(existingAura); | |
return; | |
} | |
assert(spell.aura !== undefined); | |
// Reduce duration by the diminishing returns multiplier | |
let duration = spell.drType !== undefined | |
? spell.aura.duration * this.getDRMultiplier(spell.drType) | |
: spell.aura.duration; | |
duration += sumAuraDurationModifiers(this, aura.getSpell()); | |
aura.resetDuration(duration); | |
this.auras.push(aura); | |
aura.onApplied(this); | |
this.replicator.replicateAuraAdded(aura); | |
if (aura.hasMechanic("stun")) { | |
this.unitStates.stunned = true; | |
// If it's a player we take over network ownership before stunning them | |
if (this.isPlayer()) { | |
this.worldModel.setNetworkOwnershipToServer(); | |
} | |
this.worldModel.freeze(); | |
} | |
if (aura.hasMechanic("root")) { | |
this.unitStates.rooted = true; | |
this.root(); | |
} | |
const drCategory = aura.getDiminishingReturnsCategory(); | |
if (drCategory !== undefined) { | |
this.increaseDR(drCategory); | |
} | |
} | |
/** | |
* Returns the aura of a spell casted by a specific unit | |
* @param spell | |
* @param caster | |
*/ | |
private findSpellAuraCastedByUnit(spell: SpellData, caster: Unit): Aura | undefined { | |
return this.auras.find(aura => aura.getSpellId() === spell.id && aura.getCasterId() === caster.id); | |
} | |
/** | |
* @return true if was removed | |
*/ | |
public removeAura(aura: Aura): boolean { | |
const removedAura = this.auras.remove(this.auras.indexOf(aura)); | |
if (removedAura) { | |
removedAura.onRemoving(); | |
this.replicator.replicateAuraRemoved(removedAura); | |
if (aura.hasMechanic("stun") && !this.isAffectedByAuraWithMechanic("stun")) { | |
this.unitStates.stunned = false; | |
// Parts have to be unanchored before network ownership is set | |
this.worldModel.unfreeze(); | |
// Restore player control over character | |
if (this.isPlayer()) { | |
assert(this.player !== undefined); | |
this.worldModel.setNetworkOwnership(this.player); | |
} | |
} | |
if (aura.hasMechanic("root") && !this.isAffectedByAuraWithMechanic("root")) { | |
this.unitStates.rooted = false; | |
this.unroot(); | |
} | |
} | |
return removedAura !== undefined; | |
} | |
public removeAurasDueToSpell(spellId: string): void { | |
// todo | |
} | |
/** | |
* TODO: optimize | |
*/ | |
public removeAllAuras(): void { | |
for (const index of $range(this.auras.size() - 1, 0, -1)) { | |
const aura = this.auras[index]; | |
this.removeAura(aura); | |
} | |
} | |
public removeAurasWithMechanic(mechanic: keyof SpellMechanics): void { | |
for (const index of $range(this.auras.size() - 1, 0, -1)) { | |
const aura = this.auras[index]; | |
if (aura.hasMechanic(mechanic)) { | |
this.removeAura(aura); | |
} | |
} | |
} | |
public isAffectedByAuraWithMechanic(mechanic: keyof SpellMechanics): boolean { | |
return this.auras.some(aura => aura.hasMechanic(mechanic)); | |
} | |
public getTarget(): Unit | undefined { | |
return this.target; | |
} | |
public setTarget(target: Unit | undefined): void { | |
if (this.target === target) { | |
return; | |
} | |
this.target = target; | |
this.replicator.replicateTargetChanged(target); | |
if (target === undefined || !this.isHostileTo(target)) { | |
this.stopAttack(); | |
} | |
} | |
public clearTarget(): void { | |
this.setTarget(undefined); | |
} | |
/** | |
* Returns true if the target position is not obstructed by anything. | |
* Other units do not block line of sight. | |
*/ | |
public isInLineOfSight(target: Unit | Vector3 | CFrame): boolean { | |
const origin = this.getPosition(); | |
let targetPos; | |
if (typeIs(target, "vector")) { | |
targetPos = target; | |
} else if (typeIs(target, "CFrame")) { | |
targetPos = target.Position; | |
} else { | |
targetPos = target.getPosition(); | |
} | |
const direction = targetPos.sub(origin); | |
const raycastResult = Workspace.Raycast(origin, direction, this.lineOfSightRaycastParams); | |
return raycastResult === undefined; | |
} | |
public getDistanceTo(other: Unit | Vector3): number { | |
if (!typeIs(other, "vector")) { | |
other = other.getPosition(); | |
} | |
return this.getPosition().sub(other).Magnitude; | |
} | |
/** | |
* Returns the distance between this unit and `other`, disregarding the Y (height) axis. | |
* If Y difference is wanted, use {@link Unit#getDistanceTo} instead, which returns the 3D distance. | |
*/ | |
public getDistance2dTo(other: Unit | Vector3): number { | |
const { X: x1, Z: z1 } = this.getPosition(); | |
const { X: x2, Z: z2 } = typeIs(other, "vector") ? other : other.getPosition(); | |
const myPosition = new Vector3(x1, 0, z1); | |
const otherPosition = new Vector3(x2, 0, z2); | |
return myPosition.sub(otherPosition).Magnitude; | |
} | |
public getPosition(): Vector3 { | |
return this.worldModel.getPosition(); | |
} | |
public getRotation(): number { | |
return this.worldModel.getRotation(); | |
} | |
public isHostileTo(other: Unit): boolean { | |
if (this === other) { | |
return false; | |
} | |
return true; // todo | |
} | |
public isFriendlyTo(other: Unit): boolean { | |
return !this.isHostileTo(other); | |
} | |
/** | |
* Returns the angle difference between this unit and `other` unit. | |
*/ | |
public getAngleTo(other: Unit | Vector3): number { | |
const targetPosition = typeIs(other, "vector") ? other : other.getPosition(); | |
const rotation = this.getRotation(); | |
const myLookVector = CFrame.fromOrientation(0, rotation, 0).LookVector.Unit; | |
const directionTo = (targetPosition.sub(this.getPosition())).Unit; | |
return math.deg(angleBetween(myLookVector, directionTo)); | |
} | |
/** | |
* Returns true if this unit is facing `other` unit. | |
* @param other | |
*/ | |
public isFacing(other: Unit | Vector3): boolean { | |
return this.getAngleTo(other) <= UNIT_FOV_DEGREES / 2; | |
} | |
public isFlanking(other: Unit): boolean { | |
const meFacingOther = this.getAngleTo(other) <= 45; | |
const otherFacingMe = other.getAngleTo(this) <= 45; | |
const otherBackFacingMe = other.getAngleTo(this) >= 45; | |
return meFacingOther && !otherFacingMe && !otherBackFacingMe; | |
} | |
public isInFrontOf(other: Unit): boolean { | |
return this.isFacing(other) && other.isFacing(this); | |
} | |
public isBehind(other: Unit): boolean { | |
return this.isFacing(other) && !other.isFacing(this); | |
} | |
public isDead(): boolean { | |
return this.unitStates.dead; | |
} | |
public isAlive(): boolean { | |
return !this.isDead(); | |
} | |
public getHealth(): number { | |
return this.health; | |
} | |
public setHealth(health: number): void { | |
if (this.health === health) { | |
return; | |
} | |
this.health = math.clamp(health, 0, this.getMaxHealth()); | |
if (this.health <= 0) { | |
this.setDead(true); | |
} | |
this.replicator.replicateHealthChanged(this.health); | |
} | |
public getMaxHealth(): number { | |
return this.maxHealth; | |
} | |
public setMaxHealth(maxHealth: number): void { | |
if (this.maxHealth === maxHealth) { | |
return; | |
} | |
this.maxHealth = math.max(maxHealth, 1); | |
if (this.maxHealth > this.getHealth()) { | |
this.setHealth(this.maxHealth); | |
} | |
this.replicator.replicateMaxHealthChanged(this.maxHealth); | |
} | |
public getMana(): number { | |
return this.mana; | |
} | |
public setMana(mana: number): void { | |
if (this.mana === mana) { | |
return; | |
} | |
this.mana = math.clamp(mana, 0, this.getMaxMana()); | |
this.replicator.replicateManaChanged(this.mana); | |
} | |
public getMaxMana(): number { | |
return this.maxMana; | |
} | |
public setMaxMana(maxMana: number): void { | |
if (this.maxMana === maxMana) { | |
return; | |
} | |
this.maxMana = math.max(maxMana, 1); | |
if (this.maxMana > this.getMana()) { | |
this.setMana(this.maxMana) | |
} | |
this.replicator.replicateMaxManaChanged(this.maxMana); | |
} | |
public consumeMana(amount: number): void { | |
this.setMana(this.getMana() - amount); | |
} | |
public isMoving(): boolean { | |
return this.worldModel.isMoving(); | |
} | |
public isPlayer(): boolean { | |
return this.player !== undefined; | |
} | |
public isCharmed(): boolean { | |
return this.charmer !== undefined; | |
} | |
public isCharmedBy(unit: Unit): boolean { | |
return this.charmer === unit; | |
} | |
/** | |
* Add 1 DR stack | |
* @param mechanic | |
*/ | |
public increaseDR(mechanic: DRType): void { | |
const data = this.diminishingReturns.get(mechanic) ?? { | |
stack: 0, | |
timer: 0, | |
}; | |
data.stack = math.max(data.stack + 1, 4); | |
data.timer = 15; // todo: randomize? | |
this.diminishingReturns.set(mechanic, data); | |
} | |
/** | |
* Returns a number by which the aura duration is multiplied for DR mechanics | |
* @param drType | |
*/ | |
public getDRMultiplier(drType: DRType): number { | |
const data = this.diminishingReturns.get(drType); | |
if (!data) { | |
return 1; // normal duration | |
} | |
const { stack } = data; | |
if (stack === 0) { | |
return 1; // normal duration | |
} else if (stack === 1) { | |
return 0.5; // 1/2 duration | |
} else if (stack === 2) { | |
return 0.25; // 1/4 duration | |
} else if (stack >= 3) { | |
return 0; // immune | |
} | |
if (stack < 0) { | |
warn(`DRType ${drType} has stacks < 0`); | |
return 1; | |
} | |
unreachable(); | |
} | |
public setAttackType(at: UnitAttackType) { | |
this.attackType = at; | |
this.attackType = this.getSwingTimer(); | |
} | |
public getAttackType() { | |
return this.attackType; | |
} | |
public doAttack(): void { | |
const target = this.getTarget(); | |
if (target === undefined) { | |
return; | |
} | |
const isRanged = this.getAttackType() === UnitAttackType.Ranged; | |
const isFacing = this.isFacing(target); | |
const maxDist = isRanged ? AUTOSHOT_MAX_DISTANCE : AUTO_ATTACK_MAX_DISTANCE; | |
const isInDist = this.getDistanceTo(target) <= maxDist; | |
const isLos = this.isInLineOfSight(target); | |
if (!isFacing || !isInDist || !isLos) { | |
return; | |
} | |
//TODO: check hostile | |
//set swing timer to swing speed | |
this.resetSwingTimer(); | |
if (isRanged) { | |
this.cast("autoshot", target, true); | |
} else { | |
// TODO: have a helper function that returns the auto_attack spell since it's always going to exist | |
const spellService = Dependency<SpellService>(); | |
const autoAttackSpell = spellService.findSpell("auto_attack"); | |
assert(autoAttackSpell !== undefined); | |
this.cast("auto_attack", target, true); | |
} | |
} | |
public getLevel(): number { | |
return 1; | |
} | |
public getSwingTimer(): number { | |
const haste = this.getStat(UnitStat.Haste); | |
const hasteRate = this.getStat(UnitStat.HasteRating); | |
let final = haste; | |
if (this.isPlayer()) { | |
final += hasteRate / playerHasteRating(this.getLevel()); | |
} | |
const weaponSpeed = 2.5; // TODO | |
return weaponSpeed / (1 + (final / 100)); | |
} | |
public startAttack(attackTarget?: Unit, resetSwing = true): void { | |
if (!this.target && !attackTarget) { | |
return; | |
} | |
if (!this.target && attackTarget !== undefined) { | |
this.setTarget(attackTarget); | |
} | |
if (!this.autoAttack || resetSwing) { | |
this.resetSwingTimer(); | |
} | |
this.autoAttack = true; | |
print(`unit ${this.getName()} is now attacking ${attackTarget?.getName()}`); | |
} | |
public stopAttack(): void { | |
this.autoAttack = false; | |
this.resetSwingTimer(); | |
} | |
public resetSwingTimer(): void { | |
this.attackTimer = this.getSwingTimer(); | |
} | |
public serialize(): UnitDTO { | |
const serializedStats = new Map<UnitStat, number>(); | |
for (const [stat] of this.stats) { | |
serializedStats.set(stat, this.getStat(stat)); | |
} | |
const serializedAuras = this.auras.map(aura => aura.serialize()); | |
return { | |
id: this.id, | |
health: this.health, | |
maxHealth: this.maxHealth, | |
mana: this.mana, | |
maxMana: this.maxMana, | |
level: this.level, | |
target: this.target?.getId(), | |
stats: serializedStats, | |
name: this.name, | |
currentCast: this.currentCast?.getSpellId(), | |
player: this.player, | |
auras: serializedAuras, | |
states: this.unitStates, | |
}; | |
} | |
public sendSpellCastResult(result: SpellResult): void { | |
if (!this.player) { | |
return; | |
} | |
Remotes.Server.Get("SpellCastResult").SendToPlayer(this.player, result); | |
} | |
public isEvading(): boolean { | |
return this.unitStates.evade; | |
} | |
public isImmuneToDamageSchool(school: School): boolean { | |
return false; // todo | |
} | |
public getPrimaryStat(): UnitStat { //TODO: implement this better | |
if (this.isPlayer()) { | |
const pClass = this.getUnitClass(); | |
if (pClass === UnitClass.Warrior) { | |
return UnitStat.Strength; | |
} else if (pClass === UnitClass.Rogue) { | |
return UnitStat.Agility; | |
} else { | |
return UnitStat.Intelligence; | |
} | |
} | |
return UnitStat.Strength; | |
} | |
public getUnitClass(): UnitClass { | |
return UnitClass.Mage; | |
} | |
public getComboPoints(): number { | |
return this.comboPoints; | |
} | |
public addComboPoints(points = 1): void { | |
this.comboPoints = math.clamp(this.comboPoints + points, 0, 5); | |
this.replicator.replicateComboPoints(this.comboPoints); | |
} | |
public removeComboPoints(points: number): void { | |
this.comboPoints = math.clamp(this.comboPoints - points, 0, 5); | |
this.replicator.replicateComboPoints(this.comboPoints); | |
} | |
public removeAllComboPoints(): void { | |
this.removeComboPoints(5); | |
} | |
public setDead(dead: boolean): void { | |
if (this.unitStates.dead === dead) { | |
return; | |
} | |
this.unitStates.dead = dead; | |
this.replicator.replicateStateChanged("dead", dead); | |
this.stopAttack(); | |
// todo: other things that should happen when you're dead | |
} | |
/** | |
* Run around in fear | |
* @param duration How long the movement lasts | |
*/ | |
public moveFear(duration: number): void { | |
this.unitMotion.pushMovement(new FleeMovement(this, duration)); | |
} | |
/** | |
* Remove all active movement generators | |
*/ | |
public clearMovement(): void { | |
this.unitMotion.clearAll(); | |
} | |
public walkToPoint(point: Vector3): void { | |
const humanoid = this.worldModel.getHumanoid(); | |
if (humanoid !== undefined) { | |
humanoid.MoveTo(point); | |
} | |
} | |
public stopMoving(): void { | |
const humanoid = this.worldModel.getHumanoid(); | |
if (humanoid !== undefined) { | |
humanoid.WalkToPart = undefined; | |
} | |
} | |
public addSpellModifier(modifier: AuraEffect): void { | |
this.modifiers.push(modifier); | |
} | |
public equipItem(item: Item): EquipItemResult { | |
const template = item.getTemplate(); | |
if (template.slot === undefined) { | |
return EquipItemResult.CannotEquip; | |
} | |
this.equipment.set(template.slot, item); | |
// Add stats | |
for (const { stat, amount } of item.stats) { | |
this.modifyStat(stat, amount); | |
} | |
// Add passive auras | |
for (const spellId of template.equipSpells) { | |
this.cast(spellId, this, true); | |
} | |
return EquipItemResult.Success; | |
} | |
public unequipItem(item: Item): void { | |
const template = item.getTemplate(); | |
if (template.slot === undefined) { | |
return; | |
} | |
this.equipment.delete(template.slot); | |
// Remove stats | |
for (const { stat, amount } of item.stats) { | |
this.modifyStat(stat, -amount); | |
} | |
// Remove passive auras | |
for (const spellId of template.equipSpells) { | |
this.removeAurasDueToSpell(spellId); | |
} | |
} | |
public removeSpellModifier(modifier: AuraEffect): void { | |
this.modifiers.remove(this.modifiers.indexOf(modifier)); | |
} | |
/** | |
* Get an array of spell modifiers of a specific kind which affect a spell. | |
*/ | |
public getSpellModifiers<T extends AuraEffect["kind"]>(kind: T, spell: SpellData): ReadonlyArray<AuraEffectTypeMap[T]> { | |
const result = new Array<AuraEffect>(); | |
for (const modifier of this.modifiers) { | |
if (modifier.kind !== kind) { | |
continue; | |
} | |
if (!('modifiedSpells' in modifier)) { | |
continue; | |
} | |
if (typeIs(modifier.modifiedSpells, "string")) { | |
if (spell.id === modifier.modifiedSpells) { | |
result.push(modifier); | |
} | |
} else if (typeIs(modifier.modifiedSpells, "number")) { | |
if (spell.school === modifier.modifiedSpells) { | |
result.push(modifier); | |
} | |
} else if (typeIs(modifier.modifiedSpells, "table")) { | |
if (modifier.modifiedSpells.includes(spell.id)) { | |
result.push(modifier); | |
} | |
} | |
} | |
return result as unknown as ReadonlyArray<AuraEffectTypeMap[T]>; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment