Skip to content

Instantly share code, notes, and snippets.

@Elmuti
Created October 21, 2023 17:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Elmuti/53fcda60f890a98562f98cb4b38642cc to your computer and use it in GitHub Desktop.
Save Elmuti/53fcda60f890a98562f98cb4b38642cc to your computer and use it in GitHub Desktop.
server/unit.ts
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