Index: ps/trunk/binaries/data/mods/public/simulation/components/Heal.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Heal.js +++ ps/trunk/binaries/data/mods/public/simulation/components/Heal.js @@ -49,9 +49,6 @@ { }; -// We have no dynamic state to save. -Heal.prototype.Serialize = null; - Heal.prototype.GetTimers = function() { return { @@ -97,15 +94,13 @@ 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 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) + 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; + } + + 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; } - // TODO we need a sound file -// PlaySound("heal_impact", this.entity); +}; + +/** + * @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) Index: ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js +++ ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js @@ -2679,83 +2679,49 @@ "HEALING": { "enter": function() { - if (!this.CheckRange(this.order.data, IID_Heal)) + let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); + if (!cmpHeal) { - this.SetNextState("APPROACHING"); + this.FinishOrder(); return true; } - if (!this.TargetIsAlive(this.order.data.target) || - !this.CanHeal(this.order.data.target)) + if (!this.CheckRange(this.order.data, IID_Heal)) { - this.SetNextState("FINDINGNEWTARGET"); + this.ProcessMessage("OutOfRange"); 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) + if (!cmpHeal.StartHealing(this.order.data.target, IID_UnitAI)) { - var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); - var repeatLeft = this.lastHealed + this.healTimers.repeat - cmpTimer.GetTime(); - prepare = Math.max(prepare, repeatLeft); + this.ProcessMessage("TargetInvalidated"); + return true; } - 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)) - { - this.SetNextState("FINDINGNEWTARGET"); - return; - } - if (!this.CheckRange(this.order.data, IID_Heal)) + "OutOfRange": function(msg) { + if (this.ShouldChaseTargetedEntity(this.order.data.target, this.order.data.force)) { - 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 @@ // 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); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Heal.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Heal.js +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Heal.js @@ -5,24 +5,30 @@ 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(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 @@ "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 @@ 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 @@ }); 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 @@ let otherTarget = 71; AddMock(otherTarget, IID_Ownership, { - "GetOwner": () => player + "GetOwner": () => otherPlayer }); AddMock(otherTarget, IID_Health, { - "IsUnhealable": () => false + "IsUnhealable": () => false, + "IsInjured": () => true +}); + +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 }); -TS_ASSERT_UNEVAL_EQUALS(cmpHeal.CanHeal(otherTarget), false); +cmpTimer.OnUpdate({ "turnLength": 2.2 }); +TS_ASSERT(!increased);