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,181 @@ -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) + { + // 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. + */ + StopHealing(healed = 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); + + Engine.PostMessage(this.entity, MT_HealingStateChanged, { + "healed": healed + }); + + if (this.target != INVALID_ENTITY) + this.target = INVALID_ENTITY; + } + + /** + * 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; + } + + 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; + } + } +} Heal.prototype.Schema = "Controls the healing abilities of the unit." + @@ -14,7 +191,7 @@ "Cavalry" + "Support Infantry" + "" + - "" + + "" + "" + "" + "" + @@ -26,105 +203,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 +234,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 }, + "HealingStateChanged": function(msg) { + // ignore + }, + // Formation handlers: "FormationLeave": function(msg) { @@ -2527,34 +2531,25 @@ } 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(); }, + "HealingStateChanged": function(msg) { + if (msg.healed) + this.SetNextState("FINDINGNEWTARGET"); + }, + "Timer": function(msg) { let target = this.order.data.target; // Check the target is still alive and healable @@ -2581,18 +2576,7 @@ 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) - { - this.SetAnimationSync(this.healTimers.repeat, this.healTimers.repeat); - this.resyncAnimation = false; - } }, }, @@ -4136,6 +4120,11 @@ this.UnitFsm.ProcessMessage(this, {"type": "PackFinished", "packed": msg.packed}); }; +UnitAI.prototype.OnHealingStateChanged = function(msg) +{ + this.UnitFsm.ProcessMessage(this, { "type": "HealingStateChanged", "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 } + * sent from Healing component whenever the entity stops healing. + */ +Engine.RegisterMessageType("HealingStateChanged"); 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,23 @@ 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; 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 +42,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 }); +function test_Getters() +{ + TS_ASSERT_EQUALS(cmpHeal.GetRate(), 2000 + 200); -TS_ASSERT_EQUALS(cmpHeal.GetHealableClasses(), "Support Infantry"); + TS_ASSERT_UNEVAL_EQUALS(cmpHeal.GetTimers(), { "prepare": 1000, "repeat": 2000 + 200 }); -TS_ASSERT_EQUALS(cmpHeal.GetUnhealableClasses(), "Cavalry"); + TS_ASSERT_EQUALS(cmpHeal.GetHP(), 5 + 100); -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_UNEVAL_EQUALS(cmpHeal.GetRange(), { "min": 0, "max": 20 + 300 }); -// Test PerformHeal -let target = 70; + TS_ASSERT_EQUALS(cmpHeal.GetHealableClasses(), "Support Infantry"); -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.GetUnhealableClasses(), "Cavalry"); -cmpHeal.PerformHeal(target); -TS_ASSERT(increased); + 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 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); - } -}); +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();