Heal using Heal instead of UnitAI.

Moves the healing logic from UnitAI to Heal.
Makes it easier for modders to change healing behaviour, e.g. letting
structures heal (instead of using an aura).

Differential revision: D2680
Comments by: @Stan, @wraitii
This was SVN commit r25207.
This commit is contained in:
Freagarach 2021-04-08 05:40:49 +00:00
parent 3c4a341906
commit f2d5603422
3 changed files with 220 additions and 91 deletions

View file

@ -49,9 +49,6 @@ Heal.prototype.Init = function()
{
};
// We have no dynamic state to save.
Heal.prototype.Serialize = null;
Heal.prototype.GetTimers = function()
{
return {
@ -97,15 +94,13 @@ Heal.prototype.GetHealableClasses = function()
Heal.prototype.CanHeal = function(target)
{
let cmpHealth = Engine.QueryInterface(target, IID_Health);
if (!cmpHealth || cmpHealth.IsUnhealable())
if (!cmpHealth || cmpHealth.IsUnhealable() || !cmpHealth.IsInjured())
return false;
// Verify that the target is owned by an ally or the player self.
let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership);
if (!cmpOwnership || !IsOwnedByAllyOfPlayer(cmpOwnership.GetOwner(), target))
return false;
// Verify that the target has the right class.
let cmpIdentity = Engine.QueryInterface(target, IID_Identity);
if (!cmpIdentity)
return false;
@ -129,28 +124,143 @@ Heal.prototype.GetRangeOverlays = function()
};
/**
* Heal the target entity. This should only be called after a successful range
* check, and should only be called after GetTimers().repeat msec has passed
* since the last call to PerformHeal.
* @param {number} target - The target to heal.
* @param {number} callerIID - The IID to notify on specific events.
* @return {boolean} - Whether we started healing.
*/
Heal.prototype.PerformHeal = function(target)
Heal.prototype.StartHealing = function(target, callerIID)
{
let cmpHealth = Engine.QueryInterface(target, IID_Health);
if (!cmpHealth)
return;
if (this.target)
this.StopHealing();
if (!this.CanHeal(target))
return false;
let timings = this.GetTimers();
let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
// If the repeat time since the last heal hasn't elapsed,
// delay the action to avoid healing too fast.
let prepare = timings.prepare;
if (this.lastHealed)
{
let repeatLeft = this.lastHealed + timings.repeat - cmpTimer.GetTime();
prepare = Math.max(prepare, repeatLeft);
}
let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
if (cmpVisual)
{
cmpVisual.SelectAnimation("heal", false, 1.0);
cmpVisual.SetAnimationSyncRepeat(timings.repeat);
cmpVisual.SetAnimationSyncOffset(prepare);
}
// If using a non-default prepare time, re-sync the animation when the timer runs.
this.resyncAnimation = prepare != timings.prepare;
this.target = target;
this.callerIID = callerIID;
this.timer = cmpTimer.SetInterval(this.entity, IID_Heal, "PerformHeal", prepare, timings.repeat, null);
return true;
};
/**
* @param {string} reason - The reason why we stopped healing. Currently implemented are:
* "outOfRange", "targetInvalidated".
*/
Heal.prototype.StopHealing = function(reason)
{
if (this.timer)
{
let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
cmpTimer.CancelTimer(this.timer);
delete this.timer;
}
let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
if (cmpVisual)
cmpVisual.SelectAnimation("idle", false, 1.0);
delete this.target;
// The callerIID component may start healing again,
// replacing the callerIID, hence save that.
let callerIID = this.callerIID;
delete this.callerIID;
if (reason && callerIID)
{
let component = Engine.QueryInterface(this.entity, callerIID);
if (component)
component.ProcessMessage(reason, null);
}
};
/**
* Heal our target entity.
* @params - data and lateness are unused.
*/
Heal.prototype.PerformHeal = function(data, lateness)
{
if (!this.CanHeal(this.target))
{
this.StopHealing("TargetInvalidated");
return;
}
if (!this.IsTargetInRange(this.target))
{
this.StopHealing("OutOfRange");
return;
}
// ToDo: Enable entities to keep facing a target.
Engine.QueryInterface(this.entity, IID_UnitAI)?.FaceTowardsTarget(this.target);
let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
this.lastHealed = cmpTimer.GetTime() - lateness;
let cmpHealth = Engine.QueryInterface(this.target, IID_Health);
let targetState = cmpHealth.Increase(this.GetHealth());
// Add experience.
let cmpLoot = Engine.QueryInterface(target, IID_Loot);
let cmpLoot = Engine.QueryInterface(this.target, IID_Loot);
let cmpPromotion = Engine.QueryInterface(this.entity, IID_Promotion);
if (targetState !== undefined && cmpLoot && cmpPromotion)
{
// Health healed times experience per health.
cmpPromotion.IncreaseXp((targetState.new - targetState.old) / cmpHealth.GetMaxHitpoints() * cmpLoot.GetXp());
// TODO we need a sound file.
// PlaySound("heal_impact", this.entity);
if (!cmpHealth.IsInjured())
{
this.StopHealing("TargetInvalidated");
return;
}
// TODO we need a sound file
// PlaySound("heal_impact", this.entity);
if (this.resyncAnimation)
{
let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
if (cmpVisual)
{
let repeat = this.GetTimers().repeat;
cmpVisual.SetAnimationSyncRepeat(repeat);
cmpVisual.SetAnimationSyncOffset(repeat);
}
delete this.resyncAnimation;
}
};
/**
* @param {number} - The entity ID of the target to check.
* @return {boolean} - Whether this entity is in range of its target.
*/
Heal.prototype.IsTargetInRange = function(target)
{
let range = this.GetRange();
let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager);
return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false);
};
Heal.prototype.OnValueModification = function(msg)

View file

@ -2679,83 +2679,49 @@ UnitAI.prototype.UnitFsmSpec = {
"HEALING": {
"enter": function() {
let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal);
if (!cmpHeal)
{
this.FinishOrder();
return true;
}
if (!this.CheckRange(this.order.data, IID_Heal))
{
this.SetNextState("APPROACHING");
this.ProcessMessage("OutOfRange");
return true;
}
if (!this.TargetIsAlive(this.order.data.target) ||
!this.CanHeal(this.order.data.target))
if (!cmpHeal.StartHealing(this.order.data.target, IID_UnitAI))
{
this.SetNextState("FINDINGNEWTARGET");
this.ProcessMessage("TargetInvalidated");
return true;
}
let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal);
this.healTimers = cmpHeal.GetTimers();
// If the repeat time since the last heal hasn't elapsed,
// delay the action to avoid healing too fast.
var prepare = this.healTimers.prepare;
if (this.lastHealed)
{
var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
var repeatLeft = this.lastHealed + this.healTimers.repeat - cmpTimer.GetTime();
prepare = Math.max(prepare, repeatLeft);
}
this.SelectAnimation("heal");
this.SetAnimationSync(prepare, this.healTimers.repeat);
this.StartTimer(prepare, this.healTimers.repeat);
// If using a non-default prepare time, re-sync the animation when the timer runs.
this.resyncAnimation = prepare != this.healTimers.prepare;
this.FaceTowardsTarget(this.order.data.target);
return false;
},
"leave": function() {
this.ResetAnimation();
this.StopTimer();
let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal);
if (cmpHeal)
cmpHeal.StopHealing();
},
"Timer": function(msg) {
let target = this.order.data.target;
if (!this.TargetIsAlive(target) || !this.CanHeal(target))
"OutOfRange": function(msg) {
if (this.ShouldChaseTargetedEntity(this.order.data.target, this.order.data.force))
{
this.SetNextState("FINDINGNEWTARGET");
return;
}
if (!this.CheckRange(this.order.data, IID_Heal))
{
if (this.ShouldChaseTargetedEntity(target, this.order.data.force))
{
if (this.CanPack())
{
this.PushOrderFront("Pack", { "force": true });
return;
}
this.SetNextState("HEAL.APPROACHING");
}
if (this.CanPack())
this.PushOrderFront("Pack", { "force": true });
else
this.SetNextState("FINDINGNEWTARGET");
return;
this.SetNextState("APPROACHING");
}
else
this.SetNextState("FINDINGNEWTARGET");
},
let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
this.lastHealed = cmpTimer.GetTime() - msg.lateness;
this.FaceTowardsTarget(target);
let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal);
cmpHeal.PerformHeal(target);
if (this.resyncAnimation)
{
this.SetAnimationSync(this.healTimers.repeat, this.healTimers.repeat);
this.resyncAnimation = false;
}
"TargetInvalidated": function(msg) {
this.SetNextState("FINDINGNEWTARGET");
},
},
@ -3410,7 +3376,6 @@ UnitAI.prototype.Init = function()
// For preventing increased action rate due to Stop orders or target death.
this.lastAttacked = undefined;
this.lastHealed = undefined;
this.formationAnimationVariant = undefined;
this.cheeringTime = +(this.template.CheeringTime || 0);

View file

@ -5,24 +5,30 @@ Engine.LoadComponentScript("interfaces/Heal.js");
Engine.LoadComponentScript("interfaces/Health.js");
Engine.LoadComponentScript("interfaces/Loot.js");
Engine.LoadComponentScript("interfaces/Promotion.js");
Engine.LoadComponentScript("interfaces/Timer.js");
Engine.LoadComponentScript("interfaces/UnitAI.js");
Engine.LoadComponentScript("Heal.js");
Engine.LoadComponentScript("Timer.js");
const entity = 60;
const player = 1;
const otherPlayer = 2;
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
"IsInTargetRange": () => true
});
let template = {
"Range": 20,
"Range": "20",
"RangeOverlay": {
"LineTexture": "heal_overlay_range.png",
"LineTextureMask": "heal_overlay_range_mask.png",
"LineThickness": 0.35
"LineThickness": "0.35"
},
"Health": 5,
"Interval": 2000,
"Health": "5",
"Interval": "2000",
"UnhealableClasses": { "_string": "Cavalry" },
"HealableClasses": { "_string": "Support Infantry" },
"HealableClasses": { "_string": "Support Infantry" }
};
AddMock(entity, IID_Ownership, {
@ -34,11 +40,11 @@ AddMock(SYSTEM_ENTITY, IID_PlayerManager, {
});
AddMock(player, IID_Player, {
"IsAlly": () => true
"IsAlly": (p) => p == player
});
AddMock(otherPlayer, IID_Player, {
"IsAlly": () => false
"IsAlly": (p) => p == player
});
ApplyValueModificationsToEntity = function(value, stat, ent)
@ -80,18 +86,18 @@ TS_ASSERT_UNEVAL_EQUALS(cmpHeal.GetRangeOverlays(), [{
"thickness": 0.35
}]);
// Test PerformHeal
// Test healing.
let target = 70;
AddMock(target, IID_Ownership, {
"GetOwner": () => player
});
let targetClasses;
let targetClasses = ["Infantry"];
AddMock(target, IID_Identity, {
"GetClassesList": () => targetClasses
});
let cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer");
let increased;
let unhealable = false;
AddMock(target, IID_Health, {
@ -101,12 +107,24 @@ AddMock(target, IID_Health, {
TS_ASSERT_EQUALS(amount, 5 + 100);
return { "old": 600, "new": 600 + 5 + 100 };
},
"IsUnhealable": () => unhealable
"IsUnhealable": () => unhealable,
"IsInjured": () => true
});
cmpHeal.PerformHeal(target);
TS_ASSERT(cmpHeal.StartHealing(target));
cmpTimer.OnUpdate({ "turnLength": 1 });
TS_ASSERT(increased);
increased = false;
cmpTimer.OnUpdate({ "turnLength": 2.2 });
TS_ASSERT(increased);
// Test we can't heal too quickly.
increased = false;
TS_ASSERT(cmpHeal.StartHealing(target));
cmpTimer.OnUpdate({ "turnLength": 2 });
TS_ASSERT(!increased);
// Test experience.
let looted;
AddMock(target, IID_Loot, {
"GetXp": () => {
@ -123,12 +141,14 @@ AddMock(entity, IID_Promotion, {
});
increased = false;
cmpHeal.PerformHeal(target);
TS_ASSERT(cmpHeal.StartHealing(target));
cmpTimer.OnUpdate({ "turnLength": 1 });
TS_ASSERT(increased && looted && promoted);
// Test OnValueModification
let updated;
AddMock(entity, IID_UnitAI, {
"FaceTowardsTarget": () => {},
"UpdateRangeQueries": () => {
updated = true;
}
@ -161,10 +181,44 @@ TS_ASSERT_UNEVAL_EQUALS(cmpHeal.CanHeal(target), false);
let otherTarget = 71;
AddMock(otherTarget, IID_Ownership, {
"GetOwner": () => player
"GetOwner": () => otherPlayer
});
AddMock(otherTarget, IID_Health, {
"IsUnhealable": () => false
"IsUnhealable": () => false,
"IsInjured": () => true
});
TS_ASSERT_UNEVAL_EQUALS(cmpHeal.CanHeal(otherTarget), false);
AddMock(otherTarget, IID_Identity, {
"GetClassesList": () => ["Infantry"]
});
TS_ASSERT(!cmpHeal.CanHeal(otherTarget));
// Test we stop healing when finished.
increased = false;
AddMock(target, IID_Health, {
"GetMaxHitpoints": () => 700,
"Increase": amount => {
increased = true;
TS_ASSERT_EQUALS(amount, 5 + 100);
return { "old": 600, "new": 600 + 5 + 100 };
},
"IsUnhealable": () => false,
"IsInjured": () => true
});
TS_ASSERT(cmpHeal.StartHealing(target));
cmpTimer.OnUpdate({ "turnLength": 2.2 });
TS_ASSERT(increased);
increased = false;
AddMock(target, IID_Health, {
"GetMaxHitpoints": () => 700,
"Increase": amount => {
increased = true;
TS_ASSERT(false);
},
"IsUnhealable": () => false,
"IsInjured": () => false
});
cmpTimer.OnUpdate({ "turnLength": 2.2 });
TS_ASSERT(!increased);