Index: binaries/data/mods/public/simulation/components/Heal.js =================================================================== --- binaries/data/mods/public/simulation/components/Heal.js +++ binaries/data/mods/public/simulation/components/Heal.js @@ -1,4 +1,205 @@ -function Heal() {} +class Heal +{ + Init() + { + this.target = INVALID_ENTITY; + } + + GetTimers() + { + return { + "prepare": 1000, + "repeat": this.GetRate() + }; + } + + /** + * @return {number} - The amount of HitPoints regenerated per interval. + */ + GetHP() + { + return ApplyValueModificationsToEntity("Heal/HP", +this.template.HP, this.entity); + } + + GetRate() + { + return ApplyValueModificationsToEntity("Heal/Rate", +this.template.Rate, this.entity); + } + + GetRange() + { + return { + "min": 0, + "max": ApplyValueModificationsToEntity("Heal/Range", +this.template.Range, this.entity) + }; + } + + GetUnhealableClasses() + { + return this.template.UnhealableClasses._string || ""; + } + + GetHealableClasses() + { + return this.template.HealableClasses._string || ""; + } + + GetRangeOverlays() + { + if (!this.template.RangeOverlay) + return []; + + return [{ + "radius": this.GetRange().max, + "texture": this.template.RangeOverlay.LineTexture, + "textureMask": this.template.RangeOverlay.LineTextureMask, + "thickness": +this.template.RangeOverlay.LineThickness + }]; + } + + /** + * Starts healing the specified target. + * + * @param {number} target - The target to heal. + * @return {Object} - The timers in the form { "prepare": {number}, "repeat": {number} }. + */ + StartHealing(target) + { + if (!this.IsInRange(target)) + { + this.StopHealing(false, true); + return; + } + + // We were already healing! + if (this.target != INVALID_ENTITY) + this.StopHealing(); + + 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.healTimer = cmpTimer.SetInterval(this.entity, IID_Heal, "PerformHeal", prepare, timings.repeat, null); + + return timings; + } + + /** + * Stops healing. + * @param {boolean} healed - Whether we stopped because the target was healed. + * @param {boolean} outOfRange - Whether we stopped because the target got out of our range. + */ + StopHealing(healed = false, outOfRange = false) + { + if (this.healTimer) + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + cmpTimer.CancelTimer(this.healTimer); + delete this.healTimer; + } + + let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); + if (cmpVisual) + cmpVisual.SelectAnimation("idle", false, 1.0); + + if (this.target != INVALID_ENTITY) + this.target = INVALID_ENTITY; + + Engine.PostMessage(this.entity, MT_HealingStopped, { + "healed": healed, + "outOfRange": outOfRange + }); + } + + /** + * 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. + */ + PerformHeal(data, lateness) + { + if (this.target == INVALID_ENTITY) + { + this.StopHealing(true); + return; + } + if (!this.IsInRange(this.target)) + { + this.StopHealing(false, true); + return; + } + + let cmpHealth = Engine.QueryInterface(this.target, IID_Health); + if (!cmpHealth || !cmpHealth.IsInjured()) + { + this.StopHealing(true); + return; + } + + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + this.lastHealed = cmpTimer.GetTime() - lateness; + + let targetState = cmpHealth.Increase(this.GetHP()); + + // Add experience points. + let cmpLoot = Engine.QueryInterface(this.target, IID_Loot); + let cmpPromotion = Engine.QueryInterface(this.entity, IID_Promotion); + if (targetState !== undefined && cmpLoot && cmpPromotion) + // HP healed * XP per HP + 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(true); + 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); + } + this.resyncAnimation = false; + } + } + + /** + * @param {number} - The entity ID of the target to check. + * @return {boolean} - Whether this entity is in range of its target. + */ + IsInRange(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.Schema = "Controls the healing abilities of the unit." + @@ -14,7 +215,7 @@ "Cavalry" + "Support Infantry" + "" + - "" + + "" + "" + "" + "" + @@ -26,105 +227,25 @@ "" + "" + "" + - "" + + "" + "" + "" + - "" + + "" + "" + "" + - "" + + "" + "" + "tokens" + "" + "" + "" + - "" + + "" + "" + "tokens" + "" + "" + ""; -Heal.prototype.Init = function() -{ -}; - -Heal.prototype.Serialize = null; // we have no dynamic state to save - -Heal.prototype.GetTimers = function() -{ - return { - "prepare": 1000, - "repeat": this.GetRate() - }; -}; - -Heal.prototype.GetHP = function() -{ - return ApplyValueModificationsToEntity("Heal/HP", +this.template.HP, this.entity); -}; - -Heal.prototype.GetRate = function() -{ - return ApplyValueModificationsToEntity("Heal/Rate", +this.template.Rate, this.entity); -}; - -Heal.prototype.GetRange = function() -{ - return { - "min": 0, - "max": ApplyValueModificationsToEntity("Heal/Range", +this.template.Range, this.entity) - }; -}; - -Heal.prototype.GetUnhealableClasses = function() -{ - return this.template.UnhealableClasses._string || ""; -}; - -Heal.prototype.GetHealableClasses = function() -{ - return this.template.HealableClasses._string || ""; -}; - -Heal.prototype.GetRangeOverlays = function() -{ - if (!this.template.RangeOverlay) - return []; - - return [{ - "radius": this.GetRange().max, - "texture": this.template.RangeOverlay.LineTexture, - "textureMask": this.template.RangeOverlay.LineTextureMask, - "thickness": +this.template.RangeOverlay.LineThickness - }]; -}; - -/** - * 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. - */ -Heal.prototype.PerformHeal = function(target) -{ - let cmpHealth = Engine.QueryInterface(target, IID_Health); - if (!cmpHealth) - return; - - let targetState = cmpHealth.Increase(this.GetHP()); - - // Add XP - let cmpLoot = Engine.QueryInterface(target, IID_Loot); - let cmpPromotion = Engine.QueryInterface(this.entity, IID_Promotion); - if (targetState !== undefined && cmpLoot && cmpPromotion) - { - // HP healed * XP per HP - cmpPromotion.IncreaseXp((targetState.new - targetState.old) / cmpHealth.GetMaxHitpoints() * cmpLoot.GetXp()); - } - //TODO we need a sound file -// PlaySound("heal_impact", this.entity); -}; - Heal.prototype.OnValueModification = function(msg) { if (msg.component != "Heal" || msg.valueNames.indexOf("Heal/Range") === -1) @@ -137,4 +258,10 @@ cmpUnitAI.UpdateRangeQueries(); }; +Heal.prototype.OnGlobalEntityRenamed = function(msg) +{ + if (msg.entity == this.target) + this.target = msg.newentity; +}; + Engine.RegisterComponentType(IID_Heal, "Heal", Heal); Index: binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- binaries/data/mods/public/simulation/components/UnitAI.js +++ binaries/data/mods/public/simulation/components/UnitAI.js @@ -193,6 +193,10 @@ // ignore }, + "HealingStopped": function(msg) { + // ignore + }, + // Formation handlers: "FormationLeave": function(msg) { @@ -2527,48 +2531,30 @@ } 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; + let healTimers = cmpHeal.StartHealing(this.order.data.target); + this.StartTimer(healTimers.prepare, healTimers.repeat); this.FaceTowardsTarget(this.order.data.target); return false; }, "leave": function() { - this.ResetAnimation(); + let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); + if (cmpHeal) + cmpHeal.StopHealing(); this.StopTimer(); }, - "Timer": function(msg) { - let target = this.order.data.target; - // Check the target is still alive and healable - if (!this.TargetIsAlive(target) || !this.CanHeal(target)) + "HealingStopped": function(msg) { + if (msg.data.healed) { this.SetNextState("FINDINGNEWTARGET"); return; } - // Check if we can still reach the target - if (!this.CheckRange(this.order.data, IID_Heal)) + if (msg.data.outOfRange) { - if (this.ShouldChaseTargetedEntity(target, this.order.data.force)) + if (this.ShouldChaseTargetedEntity(this.order.data.target, this.order.data.force)) { - // Can't reach it - try to chase after it if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); @@ -2578,21 +2564,19 @@ } else this.SetNextState("FINDINGNEWTARGET"); - return; } + }, - 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) + "Timer": function(msg) { + let target = this.order.data.target; + // Check the target is still alive and healable + if (!this.TargetIsAlive(target) || !this.CanHeal(target)) { - this.SetAnimationSync(this.healTimers.repeat, this.healTimers.repeat); - this.resyncAnimation = false; + this.SetNextState("FINDINGNEWTARGET"); + return; } + + this.FaceTowardsTarget(target); }, }, @@ -4136,6 +4120,11 @@ this.UnitFsm.ProcessMessage(this, {"type": "PackFinished", "packed": msg.packed}); }; +UnitAI.prototype.OnHealingStopped = function(msg) +{ + this.UnitFsm.ProcessMessage(this, { "type": "HealingStopped", "data": msg }); +}; + //// Helper functions to be called by the FSM //// UnitAI.prototype.GetWalkSpeed = function() Index: binaries/data/mods/public/simulation/components/interfaces/Heal.js =================================================================== --- binaries/data/mods/public/simulation/components/interfaces/Heal.js +++ binaries/data/mods/public/simulation/components/interfaces/Heal.js @@ -1 +1,7 @@ Engine.RegisterInterface("Heal"); + +/** + * Message of the form { "healed": boolean, "outOfRange": boolean } + * sent from Healing component whenever the entity stops healing. + */ +Engine.RegisterMessageType("HealingStopped"); Index: binaries/data/mods/public/simulation/components/tests/test_Heal.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_Heal.js +++ binaries/data/mods/public/simulation/components/tests/test_Heal.js @@ -3,20 +3,26 @@ 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 entity = 60; +let target = 70; +AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { + "IsInTargetRange": () => true +}); let template = { - "Range": 20, - "RangeOverlay" : { + "Range": "20", + "RangeOverlay": { "LineTexture": "heal_overlay_range.png", "LineTextureMask": "heal_overlay_range_mask.png", - "LineThickness": 0.35 + "LineThickness": "0.35" }, - "HP": 5, - "Rate": 2000, + "HP": "5", + "Rate": "2000", "UnhealableClasses": { "_string": "Cavalry" }, "HealableClasses": { "_string": "Support Infantry" }, }; @@ -39,72 +45,117 @@ }; let cmpHeal = ConstructComponent(60, "Heal", template); +let cmpTimer; -// Test Getters -TS_ASSERT_EQUALS(cmpHeal.GetRate(), 2000 + 200); - -TS_ASSERT_UNEVAL_EQUALS(cmpHeal.GetTimers(), { "prepare": 1000, "repeat": 2000 + 200 }); - -TS_ASSERT_EQUALS(cmpHeal.GetHP(), 5 + 100); - -TS_ASSERT_UNEVAL_EQUALS(cmpHeal.GetRange(), {"min":0, "max": 20 + 300 }); - -TS_ASSERT_EQUALS(cmpHeal.GetHealableClasses(), "Support Infantry"); +function test_Getters() +{ + TS_ASSERT_EQUALS(cmpHeal.GetRate(), 2000 + 200); -TS_ASSERT_EQUALS(cmpHeal.GetUnhealableClasses(), "Cavalry"); + TS_ASSERT_UNEVAL_EQUALS(cmpHeal.GetTimers(), { "prepare": 1000, "repeat": 2000 + 200 }); -TS_ASSERT_UNEVAL_EQUALS(cmpHeal.GetRangeOverlays(), [{ - "radius": 20 + 300, - "texture": "heal_overlay_range.png", - "textureMask": "heal_overlay_range_mask.png", - "thickness": 0.35 -}]); + TS_ASSERT_EQUALS(cmpHeal.GetHP(), 5 + 100); -// Test PerformHeal -let target = 70; + TS_ASSERT_UNEVAL_EQUALS(cmpHeal.GetRange(), { "min": 0, "max": 20 + 300 }); -let increased; -AddMock(target, IID_Health, { - "GetMaxHitpoints": () => 700, - "Increase": amount => { - increased = true; - TS_ASSERT_EQUALS(amount, 5 + 100); - return { "old": 600, "new": 600 + 5 + 100 }; - } -}); + TS_ASSERT_EQUALS(cmpHeal.GetHealableClasses(), "Support Infantry"); -cmpHeal.PerformHeal(target); -TS_ASSERT(increased); + TS_ASSERT_EQUALS(cmpHeal.GetUnhealableClasses(), "Cavalry"); -let looted; -AddMock(target, IID_Loot, { - "GetXp": () => { - looted = true; return 80; - } -}); + TS_ASSERT_UNEVAL_EQUALS(cmpHeal.GetRangeOverlays(), [{ + "radius": 20 + 300, + "texture": "heal_overlay_range.png", + "textureMask": "heal_overlay_range_mask.png", + "thickness": 0.35 + }]); +} +test_Getters(); -let promoted; -AddMock(entity, IID_Promotion, { - "IncreaseXp": amount => { - promoted = true; - TS_ASSERT_EQUALS(amount, (5 + 100) * 80 / 700); - } -}); +function test_Healing() +{ + cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); -increased = false; -cmpHeal.PerformHeal(target); -TS_ASSERT(increased && looted && promoted); - -// Test OnValueModification -let updated; -AddMock(entity, IID_UnitAI, { - "UpdateRangeQueries": () => { - updated = true; - } -}); + let increased; + AddMock(target, IID_Health, { + "GetMaxHitpoints": () => 700, + "Increase": amount => { + increased = true; + TS_ASSERT_EQUALS(amount, 5 + 100); + return { "old": 600, "new": 600 + 5 + 100 }; + }, + "IsInjured": () => true, + }); + + cmpHeal.StartHealing(target); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT(increased); + increased = false; + cmpTimer.OnUpdate({ "turnLength": 2.2 }); + TS_ASSERT(increased); + + let looted; + AddMock(target, IID_Loot, { + "GetXp": () => { + looted = true; return 80; + } + }); + + let promoted; + AddMock(entity, IID_Promotion, { + "IncreaseXp": amount => { + promoted = true; + TS_ASSERT_EQUALS(amount, (5 + 100) * 80 / 700); + } + }); + + increased = false; + cmpHeal.PerformHeal(target); + TS_ASSERT(increased && looted && promoted); + + // Test OnValueModification + let updated; + AddMock(entity, IID_UnitAI, { + "UpdateRangeQueries": () => { + updated = true; + } + }); + + cmpHeal.OnValueModification({ "component": "Heal", "valueNames": ["Heal/HP"] }); + TS_ASSERT(!updated); + + cmpHeal.OnValueModification({ "component": "Heal", "valueNames": ["Heal/Range"] }); + TS_ASSERT(updated); +} +test_Healing(); -cmpHeal.OnValueModification({ "component": "Heal", "valueNames": ["Heal/HP"] }); -TS_ASSERT(!updated); +function testStopHealing() +{ + cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); -cmpHeal.OnValueModification({ "component": "Heal", "valueNames": ["Heal/Range"] }); -TS_ASSERT(updated); + let 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 }; + }, + "IsInjured": () => true, + }); + cmpHeal.StartHealing(target); + cmpTimer.OnUpdate({ "turnLength": 1 }); + TS_ASSERT(increased); + + 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 }; + }, + "IsInjured": () => false, + }); + cmpTimer.OnUpdate({ "turnLength": 2.2 }); + TS_ASSERT(!increased); +} +testStopHealing();