Index: binaries/data/config/default.cfg =================================================================== --- binaries/data/config/default.cfg +++ binaries/data/config/default.cfg @@ -313,6 +313,8 @@ deselectgroup = Ctrl ; Modifier to deselect units when clicking group icon, instead of selecting rotate.cw = RightBracket ; Rotate building placement preview clockwise rotate.ccw = LeftBracket ; Rotate building placement preview anticlockwise +attackgroundbombardradius.decrease = PgDn ; Decrease attack ground bombard radius. +attackgroundbombardradius.increase = PgUp ; Increase attack ground bombard radius. [hotkey.session.gui] toggle = "Alt+G" ; Toggle visibility of session GUI @@ -372,6 +374,8 @@ rankabovestatusbar = true ; Show rank icons above status bars experiencestatusbar = true ; Show an experience status bar above each selected unit respoptooltipsort = 0 ; Sorting players in the resources and population tooltip by value (0 - no sort, -1 - ascending, 1 - descending) +attackgroundbombardradius = 2.0 ; Default radius of the area to bombard with attack ground (m). +attackgroundbombardchange = 0.5 ; Value to add/subtract from the radius with each push of the hotkey. [gui.session.minimap] blinkduration = 1.7 ; The blink duration while pinging Index: binaries/data/mods/public/gui/options/options.json =================================================================== --- binaries/data/mods/public/gui/options/options.json +++ binaries/data/mods/public/gui/options/options.json @@ -435,6 +435,23 @@ "max": 30 }, { + "type": "number", + "label": "Attack-Ground Bombard Size", + "tooltip": "The default value of the bombard radius (m).", + "config": "gui.session.attackgroundbombardradius", + "callback": "updateDefaultAttackGroundSize", + "min": 1, + "max": 20 + }, + { + "type": "slider", + "label": "Attack-Ground Increment value", + "tooltip": "Value to add/subtract from the radius with each push of the hotkey.", + "config": "gui.session.attackgroundbombardchange", + "min": 0.1, + "max": 10 + }, + { "type": "boolean", "label": "Chat Notification Attack", "tooltip": "Show a chat notification if you are attacked by another player.", Index: binaries/data/mods/public/gui/session/input.js =================================================================== --- binaries/data/mods/public/gui/session/input.js +++ binaries/data/mods/public/gui/session/input.js @@ -17,6 +17,7 @@ const ACTION_REPAIR = 2; const ACTION_GUARD = 3; const ACTION_PATROL = 4; +const ACTION_ATTACKGROUND = 5; var preSelectedAction = ACTION_NONE; const INPUT_NORMAL = 0; @@ -896,9 +897,13 @@ case "mousebuttondown": if (ev.button == SDL_BUTTON_LEFT && preSelectedAction != ACTION_NONE) { - var action = determineAction(ev.x, ev.y); + let action = determineAction(ev.x, ev.y); if (!action) break; + + if (preSelectedAction == ACTION_ATTACKGROUND) + action.radius = g_AttackGroundSize; + if (!Engine.HotkeyIsPressed("session.queue")) { preSelectedAction = ACTION_NONE; @@ -912,7 +917,17 @@ inputState = INPUT_NORMAL; break; } - // else + + case "hotkeydown": + if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "session.attackgroundbombardradius.decrease") + { + AttackGroundBombardRadiusChange(-1); + } + else if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "session.attackgroundbombardradius.increase") + { + AttackGroundBombardRadiusChange(1); + } + default: // Slight hack: If selection is empty, reset the input state if (g_Selection.toList().length == 0) @@ -1322,6 +1337,34 @@ } } +// Attack ground: +// When the user uses the hotkey the radius of the bombarded area is increased/decreased. +var g_AttackGroundSize = getDefaultAttackGroundSize(); +function AttackGroundBombardRadiusChange(dir) +{ + g_AttackGroundSize += dir * +Engine.ConfigDB_GetValue("user", "gui.session.attackgroundbombardchange"); + if (g_AttackGroundSize < 0 || !Number.isFinite(g_AttackGroundSize)) + g_AttackGroundSize = 0; + + updateSelectionDetails(); +} + +function getDefaultAttackGroundSize() +{ + let num = +Engine.ConfigDB_GetValue("user", "gui.session.attackgroundbombardradius"); + return Number.isFinite(num) && num >= 0 ? num : 0; +} + +function getAttackGroundSize() +{ + return Math.max(g_AttackGroundSize, 0); +} + +function updateDefaultAttackGroundSize() +{ + g_AttackGroundSize = getDefaultAttackGroundSize(); +} + // Batch training: // When the user shift-clicks, we set these variables and switch to INPUT_BATCHTRAINING // When the user releases shift, or clicks on a different training button, we create the batched units Index: binaries/data/mods/public/gui/session/unit_actions.js =================================================================== --- binaries/data/mods/public/gui/session/unit_actions.js +++ binaries/data/mods/public/gui/session/unit_actions.js @@ -236,6 +236,47 @@ "specificness": 10, }, + "attack-ground": { + "execute": function(target, action, selection, queued) + { + Engine.PostNetworkCommand({ + "type": "attack-ground", + "entities": selection, + "target": target, + "radius": action.radius, + "queued": queued + }); + + DrawTargetMarker(target); + + return true; + }, + "getActionInfo": function(selection, target) + { + let tooltip = sprintf(translate("Bombard radius: %(radius)s"), { + "radius": g_AttackGroundSize.toFixed(1) + }); + + return { + "possible": true, + "tooltip": tooltip + }; + }, + "preSelectedActionCheck": function(target, selection) + { + if (preSelectedAction != ACTION_ATTACKGROUND) + return false; + + return { + "type": "attack-ground", + "cursor": "action-attack", + "tooltip": getActionInfo("attack-ground", target, selection).tooltip, + "target": target + }; + }, + "specificness": 50, + }, + "patrol": { "execute": function(target, action, selection, queued) @@ -1095,6 +1136,26 @@ }, }, + "attack-ground": { + "getInfo": function(entStates) + { + if (entStates.every(entState => !entState.attack || + Object.keys(entState.attack).every(type => !entState.attack[type].attackGround))) + return false; + + return { + "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.attackground") + + translate("Attack the selected ground."), + "icon": "attack-request.png" + }; + }, + "execute": function(entStates) + { + inputState = INPUT_PRESELECTEDACTION; + preSelectedAction = ACTION_ATTACKGROUND; + }, + }, + "garrison": { "getInfo": function(entStates) { @@ -1511,6 +1572,11 @@ "cursor": cursor }; } + if (action == "attack-ground") + return { + "possible": true, + "tooltip": g_UnitActions[action].getActionInfo("attack-ground", selection).tooltip + } return { "possible": ["move", "attack-move", "remove-guard", "patrol"].indexOf(action) != -1 Index: binaries/data/mods/public/simulation/components/Attack.js =================================================================== --- binaries/data/mods/public/simulation/components/Attack.js +++ binaries/data/mods/public/simulation/components/Attack.js @@ -117,6 +117,7 @@ "0.0" + "" + "" + + "" + "" + "" + "" + @@ -186,6 +187,9 @@ "" + "" + "" + + ""+ + "" + + "" + "" + "" + "" + @@ -287,8 +291,24 @@ return []; }; +/** + * Whether the entity is able to attack ground (with the requested attack type). + * @param {string | undefined} attackType - The attack type requested. + * @return {boolean} Whether the entity is able to attack ground. + */ +Attack.prototype.CanAttackGround = function(attackType) +{ + if (attackType) + return "AttackGround" in this.template[attackType]; + + return this.GetAttackTypes().some(type => "AttackGround" in this.template[type]); +}; + Attack.prototype.CanAttack = function(target, wantedTypes) { + if (target instanceof Vector3D) + return this.CanAttackGround(); + let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) return true; @@ -389,6 +409,10 @@ Attack.prototype.GetBestAttackAgainst = function(target, allowCapture) { + // ToDo: Add support for more attack types based on some sort of preference (more splash is more better, DPS, range or something else?). + if (target instanceof Vector3D) + return this.GetAttackTypes().find(type => this.CanAttackGround(type)); + let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) { @@ -512,9 +536,11 @@ }; /** - * Attack the target entity. This should only be called after a successful range check, + * Attack the target. 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 PerformAttack. + * @param {string} type - The type of the attack (e.g. "Melee", "Ranged", "Capture", "Slaughter"). + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. */ Attack.prototype.PerformAttack = function(type, target) { @@ -538,16 +564,23 @@ if (!cmpPosition || !cmpPosition.IsInWorld()) return; let selfPosition = cmpPosition.GetPosition(); - let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); - if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) - return; - let targetPosition = cmpTargetPosition.GetPosition(); - let previousTargetPosition = Engine.QueryInterface(target, IID_Position).GetPreviousPosition(); - let targetVelocity = Vector3D.sub(targetPosition, previousTargetPosition).div(turnLength); + let predictedPosition; + if (typeof target == "number") + { + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + return; + let targetPosition = cmpTargetPosition.GetPosition(); + + let previousTargetPosition = Engine.QueryInterface(target, IID_Position).GetPreviousPosition(); + let targetVelocity = Vector3D.sub(targetPosition, previousTargetPosition).div(turnLength); - let timeToTarget = this.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity); - let predictedPosition = (timeToTarget !== false) ? Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition) : targetPosition; + let timeToTarget = this.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity); + predictedPosition = timeToTarget ? Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition) : targetPosition; + } + else + predictedPosition = target; // Add inaccuracy based on spread. let distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/Ranged/Spread", +this.template[type].Projectile.Spread, this.entity) * @@ -557,11 +590,11 @@ let offsetX = randNorm[0] * distanceModifiedSpread; let offsetZ = randNorm[1] * distanceModifiedSpread; - let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, targetPosition.y, predictedPosition.z + offsetZ); + let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, predictedPosition.y, predictedPosition.z + offsetZ); // Recalculate when the missile will hit the target position. let realHorizDistance = realTargetPosition.horizDistanceTo(selfPosition); - timeToTarget = realHorizDistance / horizSpeed; + let timeToTarget = realHorizDistance / horizSpeed; let missileDirection = Vector3D.sub(realTargetPosition, selfPosition).div(realHorizDistance); Index: binaries/data/mods/public/simulation/components/Damage.js =================================================================== --- binaries/data/mods/public/simulation/components/Damage.js +++ binaries/data/mods/public/simulation/components/Damage.js @@ -82,7 +82,7 @@ * Handles hit logic after the projectile travel time has passed. * @param {Object} data - The data sent by the caller. * @param {number} data.attacker - The entity id of the attacker. - * @param {number} data.target - The entity id of the target. + * @param {number | Vector3D} data.target - Either the target entity ID, or a Vector3D of a position to attack. * @param {Vector2D} data.origin - The origin of the projectile hit. * @param {Object} data.strengths - Data of the form { 'hack': number, 'pierce': number, 'crush': number }. * @param {string} data.type - The type of damage. @@ -129,27 +129,33 @@ let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); - // Deal direct damage if we hit the main target - // and if the target has DamageReceiver (not the case for a mirage for example) - let cmpDamageReceiver = Engine.QueryInterface(data.target, IID_DamageReceiver); - if (cmpDamageReceiver && this.TestCollision(data.target, data.position, lateness)) + let targetPosition; + if (typeof data.target == "number") { - data.multiplier = GetDamageBonus(data.attacker, data.target, data.type, data.bonus); - this.CauseDamage(data); - cmpProjectileManager.RemoveProjectile(data.projectileId); - - let cmpStatusReceiver = Engine.QueryInterface(data.target, IID_StatusEffectsReceiver); - if (cmpStatusReceiver && data.statusEffects) - cmpStatusReceiver.InflictEffects(data.statusEffects); + // Deal direct damage if we hit the main target + // and if the target has DamageReceiver (not the case for a mirage for example) + let cmpDamageReceiver = Engine.QueryInterface(data.target, IID_DamageReceiver); + if (cmpDamageReceiver && this.TestCollision(data.target, data.position, lateness)) + { + data.multiplier = GetDamageBonus(data.attacker, data.target, data.type, data.bonus); + this.CauseDamage(data); + cmpProjectileManager.RemoveProjectile(data.projectileId); + + let cmpStatusReceiver = Engine.QueryInterface(data.target, IID_StatusEffectsReceiver); + if (cmpStatusReceiver && data.statusEffects) + cmpStatusReceiver.InflictEffects(data.statusEffects); - return; + return; + } + targetPosition = this.InterpolatedLocation(data.target, lateness); } + else + targetPosition = data.target; - let targetPosition = this.InterpolatedLocation(data.target, lateness); if (!targetPosition) return; - // If we didn't hit the main target look for nearby units + // Look for nearby units. let cmpPlayer = QueryPlayerIDInterface(data.attackerOwner); let ents = this.EntitiesNearPoint(Vector2D.from3D(data.position), targetPosition.horizDistanceTo(data.position) * 2, cmpPlayer.GetEnemies()); for (let ent of ents) Index: binaries/data/mods/public/simulation/components/FormationAttack.js =================================================================== --- binaries/data/mods/public/simulation/components/FormationAttack.js +++ binaries/data/mods/public/simulation/components/FormationAttack.js @@ -23,44 +23,53 @@ FormationAttack.prototype.GetRange = function(target) { - var result = {"min": 0, "max": this.canAttackAsFormation ? -1 : 0}; - var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); +let result = { + "min": 0, + "max": this.canAttackAsFormation ? -1 : 0, + "elevationBonus": this.canAttackAsFormation ? -1 : 0 + }; + let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) { warn("FormationAttack component used on a non-formation entity"); return result; } - var members = cmpFormation.GetMembers(); - for (var ent of members) + let members = cmpFormation.GetMembers(); + for (let ent of members) { - var cmpAttack = Engine.QueryInterface(ent, IID_Attack); + let cmpAttack = Engine.QueryInterface(ent, IID_Attack); if (!cmpAttack) continue; - var type = cmpAttack.GetBestAttackAgainst(target); + let type = cmpAttack.GetBestAttackAgainst(target); if (!type) continue; // if the formation can attack, take the minimum max range (so units are certainly in range), // If the formation can't attack, take the maximum max range as the point where the formation will be disbanded // Always take the minimum min range (to not get impossible situations) - var range = cmpAttack.GetRange(type); - + let range = cmpAttack.GetRange(type); if (this.canAttackAsFormation) { if (range.max < result.max || result.max < 0) result.max = range.max; + + if (range.elevationBonus < result.elevationBonus || result.max < 0) + result.elevationBonus = range.elevationBonus; } else { if (range.max > result.max || range.max < 0) result.max = range.max; + + if (range.elevationBonus > result.elevationBonus) + result.elevationBonus = range.elevationBonus; } if (range.min < result.min) result.min = range.min; } // add half the formation size, so it counts as the range for the units on the first row - var extraRange = cmpFormation.GetSize().depth/2; + let extraRange = cmpFormation.GetSize().depth / 2; if (result.max >= 0) result.max += extraRange; Index: binaries/data/mods/public/simulation/components/GuiInterface.js =================================================================== --- binaries/data/mods/public/simulation/components/GuiInterface.js +++ binaries/data/mods/public/simulation/components/GuiInterface.js @@ -429,6 +429,8 @@ // not in world, set a default? ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; } + + ret.attack[type].attackGround = cmpAttack.CanAttackGround(type); } } Index: binaries/data/mods/public/simulation/components/Identity.js =================================================================== --- binaries/data/mods/public/simulation/components/Identity.js +++ binaries/data/mods/public/simulation/components/Identity.js @@ -17,7 +17,15 @@ "" + "" + "" + - "" + + "" + + "" + + "tokens" + + "" + + "" + + "" + + "" + + "" + + "" + "" + "" + "" + @@ -95,15 +103,10 @@ { this.classesList = GetIdentityClasses(this.template); this.visibleClassesList = GetVisibleIdentityClasses(this.template); + if (this.template.Phenotype) + this.phenotype = this.template.Phenotype == "random" ? pickRandom(this.GetPossiblePhenotypes()) : this.template.Phenotype; }; -Identity.prototype.Deserialize = function () -{ - this.Init(); -}; - -Identity.prototype.Serialize = null; // we have no dynamic state to save - Identity.prototype.GetCiv = function() { return this.template.Civ; @@ -114,9 +117,22 @@ return this.template.Lang || "greek"; // ugly default }; -Identity.prototype.GetGender = function() +/** + * Get a list of possible Phenotypes. + * @return {string[]} A list of possible phenotypes. + */ +Identity.prototype.GetPossiblePhenotypes = function() +{ + return !!this.template.PossiblePhenotypes ? this.template.PossiblePhenotypes._string.split(/\s+/) : []; +}; + +/** + * Get the current Phenotype. + * @return {string} The current phenotype. + */ +Identity.prototype.GetPhenotype = function() { - return this.template.Gender || "male"; // ugly default + return this.phenotype; }; Identity.prototype.GetRank = function() Index: binaries/data/mods/public/simulation/components/Sound.js =================================================================== --- binaries/data/mods/public/simulation/components/Sound.js +++ binaries/data/mods/public/simulation/components/Sound.js @@ -35,16 +35,16 @@ if (name in this.template.SoundGroups) { // Replace the "{lang}" codes with this entity's civ ID - var cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity); + let cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity); if (!cmpIdentity) return; - var lang = cmpIdentity.GetLang(); - // Replace the "{gender}" codes with this entity's gender ID - var gender = cmpIdentity.GetGender(); - - var soundName = this.template.SoundGroups[name].replace(/\{lang\}/g, lang) - .replace(/\{gender\}/g, gender); - var cmpSoundManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_SoundManager); + let lang = cmpIdentity.GetLang(); + // Replace the "{phenotype}" codes with this entity's phenotype ID + let phenotype = cmpIdentity.GetPhenotype(); + + let soundName = this.template.SoundGroups[name].replace(/\{lang\}/g, lang) + .replace(/\{phenotype\}/g, phenotype); + let cmpSoundManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_SoundManager); if (cmpSoundManager) cmpSoundManager.PlaySoundGroup(soundName, this.entity); } 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 @@ -399,7 +399,7 @@ } // Work out how to attack the given target - var type = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); + let type = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); if (!type) { // Oops, we can't attack at all @@ -427,7 +427,7 @@ if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.ATTACKING"); else - this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); + this.SetNextState("INDIVIDUAL.COMBAT"); return; } @@ -451,7 +451,28 @@ if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.APPROACHING"); else - this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); + this.SetNextState("INDIVIDUAL.COMBAT"); + }, + + "Order.AttackGround": function(msg) { + // Work out how to attack the given target + let target = this.order.data.target; + let type = this.GetBestAttackAgainst(target); + if (!type) + { + // Oops, we can't attack at all + this.FinishOrder(); + return; + } + this.order.data.attackType = type; + + // Distribute the attacks over the area. + let randNorm = randomNormal2D(); + let offsetX = randNorm[0] * this.order.data.radius; + let offsetZ = randNorm[1] * this.order.data.radius; + this.order.data.target = new Vector3D(target.x + offsetX, target.y, target.z + offsetZ); + + this.SetNextState("INDIVIDUAL.COMBAT"); }, "Order.Patrol": function(msg) { @@ -716,15 +737,15 @@ }, "Order.Attack": function(msg) { - var target = msg.data.target; - var allowCapture = msg.data.allowCapture; - var cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); + let target = msg.data.target; + let allowCapture = msg.data.allowCapture; + let cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember()) target = cmpTargetUnitAI.GetFormationController(); - var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); // Check if we are already in range, otherwise walk there - if (!this.CheckTargetAttackRange(target, target)) + if (!this.CheckFormationTargetAttackRange(target, target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { @@ -741,6 +762,23 @@ this.SetNextState("MEMBER"); }, + "Order.AttackGround": function(msg) { + let target = msg.data.target; + + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + // Check if we are already in range, otherwise walk there + if (!this.CheckFormationTargetAttackRange(target)) + { + this.SetNextState("COMBAT.APPROACHING"); + return; + } + this.CallMemberFunction("AttackGround", [target, msg.data.radius, false]); + if (cmpAttack.CanAttackAsFormation()) + this.SetNextState("COMBAT.ATTACKING"); + else + this.SetNextState("MEMBER"); + }, + "Order.Garrison": function(msg) { if (!Engine.QueryInterface(msg.data.target, IID_GarrisonHolder)) { @@ -1108,14 +1146,17 @@ "COMBAT": { "APPROACHING": { "enter": function() { - if (!this.MoveTo(this.order.data)) + if (!this.MoveFormationToTargetAttackRange(this.order.data.target)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); - cmpFormation.SetRearrange(true); - cmpFormation.MoveMembersIntoFormation(true, true); + if (cmpFormation) + { + cmpFormation.SetRearrange(true); + cmpFormation.MoveMembersIntoFormation(true, true); + } }, "leave": function() { @@ -1123,8 +1164,13 @@ }, "MovementUpdate": function(msg) { + let target = this.order.data.target; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); - this.CallMemberFunction("Attack", [this.order.data.target, this.order.data.allowCapture, false]); + if (typeof target == "number") + this.CallMemberFunction("Attack", [target, this.order.data.allowCapture, false]); + else + this.CallMemberFunction("AttackGround", [target, this.order.data.radius, false]); + if (cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else @@ -1135,39 +1181,68 @@ "ATTACKING": { // Wait for individual members to finish "enter": function(msg) { - var target = this.order.data.target; - var allowCapture = this.order.data.allowCapture; + let target = this.order.data.target; + let allowCapture = this.order.data.allowCapture; + let attackEntity = typeof target == "number"; + // Check if we are already in range, otherwise walk there - if (!this.CheckTargetAttackRange(target, target)) + if (!this.CheckFormationTargetAttackRange(target)) { - if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) + if (!attackEntity || this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + if (attackEntity) + this.PushOrderFront("Attack", { + "target": target, + "force": false, + "allowCapture": allowCapture + }); + else + this.PushOrderFront("AttackGround", { + "target": target, + "radius": this.order.data.radius, + "force": false + }); return true; } this.FinishOrder(); return true; } - var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); + let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); // TODO fix the rearranging while attacking as formation - cmpFormation.SetRearrange(!this.IsAttackingAsFormation()); - cmpFormation.MoveMembersIntoFormation(false, false); + if (cmpFormation) + { + cmpFormation.SetRearrange(!this.IsAttackingAsFormation()); + cmpFormation.MoveMembersIntoFormation(false, false); + } this.StartTimer(200, 200); return false; }, "Timer": function(msg) { - var target = this.order.data.target; - var allowCapture = this.order.data.allowCapture; + let target = this.order.data.target; + let allowCapture = this.order.data.allowCapture; + let attackEntity = typeof target == "number"; + // Check if we are already in range, otherwise walk there - if (!this.CheckTargetAttackRange(target, target)) + if (!this.CheckFormationTargetAttackRange(target)) { - if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) + if (!attackEntity || this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + if (attackEntity) + this.PushOrderFront("Attack", { + "target": target, + "force": false, + "allowCapture": allowCapture + }); + else + this.PushOrderFront("AttackGround", { + "target": target, + "radius": this.order.data.radius, + "force": false + }); return; } this.FinishOrder(); @@ -1177,7 +1252,7 @@ "leave": function(msg) { this.StopTimer(); - var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); + let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation) cmpFormation.SetRearrange(true); }, @@ -1744,6 +1819,35 @@ }, "COMBAT": { + "enter": function() { + // If we are already at the target, try attacking it from here + if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) + { + // For packable units within attack range: + // 1. If unpacked, we can attack the target. + // 2. If packed, we first need to unpack, then follow case 1. + if (this.CanUnpack()) + { + this.PushOrderFront("Unpack", { "force": true }); + return; + } + + this.SetNextState("ATTACKING"); + return; + } + + // For packable units out of attack range: + // 1. If packed, we need to move to attack range and then unpack. + // 2. If unpacked, we first need to pack, then follow case 1. + if (this.CanPack()) + { + this.PushOrderFront("Pack", { "force": true }); + return; + } + + this.SetNextState("APPROACHING"); + }, + "Order.LeaveFoundation": function(msg) { // Ignore the order as we're busy. return { "discardOrder": true }; @@ -1767,7 +1871,9 @@ // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); - this.StartTimer(1000, 1000); + // If attack ground is asked do not start the timer (ground does not run away). + if (typeof this.order.data.target == "number") + this.StartTimer(1000, 1000); }, "leave": function() { @@ -1838,13 +1944,18 @@ "ATTACKING": { "enter": function() { let target = this.order.data.target; - let cmpFormation = Engine.QueryInterface(target, IID_Formation); - // if the target is a formation, save the attacking formation, and pick a member - if (cmpFormation) + let attackEntity = typeof target == "number"; + + if (attackEntity) { - this.order.data.formationTarget = target; - target = cmpFormation.GetClosestMember(this.entity); - this.order.data.target = target; + // If the target is a formation, save the attacking formation and pick a member. + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpFormation) + { + this.order.data.formationTarget = target; + target = cmpFormation.GetClosestMember(this.entity); + this.order.data.target = target; + } } if (!this.CanAttack(target)) @@ -1895,7 +2006,13 @@ this.FaceTowardsTarget(this.order.data.target); + // Attack ground with BuildingAI not yet implemented. let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + if (cmpBuildingAI && !attackEntity) + { + this.FinishOrder(); + return true; + } if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(this.order.data.target); }, @@ -1911,18 +2028,22 @@ "Timer": function(msg) { let target = this.order.data.target; - let cmpFormation = Engine.QueryInterface(target, IID_Formation); + let attackEntity = typeof target == "number"; - // if the target is a formation, save the attacking formation, and pick a member - if (cmpFormation) + if (attackEntity) { - let thisObject = this; - let filter = function(t) { - return thisObject.CanAttack(t); - }; - this.order.data.formationTarget = target; - target = cmpFormation.GetClosestMember(this.entity, filter); - this.order.data.target = target; + // If the target is a formation, save the attacking formation and pick a member. + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpFormation) + { + let thisObject = this; + let filter = function(t) { + return thisObject.CanAttack(t); + }; + this.order.data.formationTarget = target; + target = cmpFormation.GetClosestMember(this.entity, filter); + this.order.data.target = target; + } } // Check the target is still alive and attackable @@ -1971,6 +2092,11 @@ this.SetNextState("COMBAT.CHASING"); return; } + else if (this.MoveToTargetAttackRange(target, this.order.data.attackType)) + { + this.SetNextState("COMBAT.APPROACHING"); + return; + } }, // TODO: respond to target deaths immediately, rather than waiting @@ -4248,28 +4374,47 @@ return cmpUnitMotion.MoveToTargetRange(target, 0, 1); }; +/** + * Move the entity so we hope the target is in range. + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {number} iid - The interface ID to check the range for. + * @param {string} type - The type for which the range is to be checked. + * @return {boolean} - Whether the order to move has succeeded? + */ UnitAI.prototype.MoveToTargetRange = function(target, iid, type) { + let attackEntity = typeof target == "number"; + if (!this.CheckTargetVisible(target) || this.IsTurret()) return false; - var cmpRanged = Engine.QueryInterface(this.entity, iid); + let cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return false; - var range = cmpRanged.GetRange(type); + let range = cmpRanged.GetRange(type); + + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + if (attackEntity) + return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max, true); - var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); }; /** - * Move unit so we hope the target is in the attack range - * for melee attacks, this goes straight to the default range checks - * for ranged attacks, the parabolic range is used + * Move unit so we hope the target is in the attack range. + * For melee attacks, this goes straight to the default range checks. + * For ranged attacks, the parabolic range is used. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {string} type - The type of the attack which is to be used. + * @return {boolean} - Whether the order to move has succeeded? */ UnitAI.prototype.MoveToTargetAttackRange = function(target, type) { - // for formation members, the formation will take care of the range check + let attackEntity = typeof target == "number"; + + // For formation members, the formation will take care of the range check. if (this.IsFormationMember()) { let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); @@ -4277,9 +4422,12 @@ return false; } - let cmpFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpFormation) - target = cmpFormation.GetClosestMember(this.entity); + if (attackEntity) + { + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpFormation) + target = cmpFormation.GetClosestMember(this.entity); + } if (type != "Ranged") return this.MoveToTargetRange(target, IID_Attack, type); @@ -4290,22 +4438,23 @@ let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); let range = cmpAttack.GetRange(type); - let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) + let cmpSelfPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpSelfPosition.IsInWorld()) return false; - let s = thisCmpPosition.GetPosition(); + let selfPosition = cmpSelfPosition.GetPosition(); - let targetCmpPosition = Engine.QueryInterface(target, IID_Position); - if (!targetCmpPosition.IsInWorld()) + let targetPosition = this.GetTargetPosition3D(target); + if (!targetPosition) return false; - let t = targetCmpPosition.GetPosition(); - // h is positive when I'm higher than the target - let h = s.y - t.y + range.elevationBonus; + // h Is positive when I'm higher than the target. + let h = selfPosition.y - targetPosition.y + range.elevationBonus; - let parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); - // No negative roots please - if (h <= -range.max / 2) + // No negative roots please. + let parabolicMaxRange; + if (h > -range.max / 2) + parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); + else // return false? Or hope you come close enough? parabolicMaxRange = 0; @@ -4313,7 +4462,11 @@ let guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange; let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange); + + if (attackEntity) + return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange); + + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, guessedMaxRange); }; UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max) @@ -4325,6 +4478,50 @@ return cmpUnitMotion.MoveToTargetRange(target, min, max); }; +/** + * Move formation so we hope the target is within the attack range. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @return {boolean} - Whether the order to move has succeeded? + */ +UnitAI.prototype.MoveFormationToTargetAttackRange = function(target) +{ + let attackEntity = typeof target == "number"; + // For formation members, the formation will take care of the range check. + if (this.IsFormationMember()) + { + let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); + if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation()) + return false; + } + + if (attackEntity) + { + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpFormation) + target = cmpFormation.GetClosestMember(this.entity); + } + + if (!this.CheckTargetVisible(target) || this.IsTurret()) + return false; + + let cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack); + if (!cmpFormationAttack) + return false; + let range = cmpFormationAttack.GetRange(target); + + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + if (cmpUnitMotion) + { + if (attackEntity) + return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + else + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max, true); + } + + return false; +}; + UnitAI.prototype.MoveToGarrisonRange = function(target) { if (!this.CheckTargetVisible(target)) @@ -4367,26 +4564,44 @@ return cmpObstructionManager.IsInPointRange(this.entity, x, z, min, max, false); }; +/** + * Check if the target is inside the range. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {iid} number - The type of the interface which ought to be querried. + * @param {string} type - The type of the attack which is to be used. + * @return {boolean} - Whether the location is within range. + */ UnitAI.prototype.CheckTargetRange = function(target, iid, type) { - var cmpRanged = Engine.QueryInterface(this.entity, iid); + let attackEntity = typeof target == "number"; + let cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return false; - var range = cmpRanged.GetRange(type); + let range = cmpRanged.GetRange(type); let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + if (attackEntity) + return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + + return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, false); }; /** - * Check if the target is inside the attack range - * For melee attacks, this goes straigt to the regular range calculation + * Check if the target is inside the attack range. + * For melee attacks, this goes straight to the regular range calculation. * For ranged attacks, the parabolic formula is used to accout for bigger ranges - * when the target is lower, and smaller ranges when the target is higher + * when the target is lower and smaller ranges when the target is higher. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {string} type - The type of the attack which is to be used. + * @return {boolean} - Whether the attack-location is within attacking distance. */ UnitAI.prototype.CheckTargetAttackRange = function(target, type) { - // for formation members, the formation will take care of the range check + let attackEntity = typeof target == "number"; + + // For formation members, the formation will take care of the range check. if (this.IsFormationMember()) { let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); @@ -4395,36 +4610,40 @@ return true; } - let cmpFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpFormation) - target = cmpFormation.GetClosestMember(this.entity); + if (attackEntity) + { + let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpTargetFormation) + target = cmpTargetFormation.GetClosestMember(this.entity); + } if (type != "Ranged") return this.CheckTargetRange(target, IID_Attack, type); - let targetCmpPosition = Engine.QueryInterface(target, IID_Position); - if (!targetCmpPosition || !targetCmpPosition.IsInWorld()) + let targetPosition = this.GetTargetPosition3D(target); + if (!targetPosition) return false; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); let range = cmpAttack.GetRange(type); - let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) + let cmpSelfPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpSelfPosition.IsInWorld()) return false; + let selfPosition = cmpSelfPosition.GetPosition(); - let s = thisCmpPosition.GetPosition(); - - let t = targetCmpPosition.GetPosition(); - - let h = s.y - t.y + range.elevationBonus; + let h = selfPosition.y - targetPosition.y + range.elevationBonus; let maxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); if (maxRange < 0) return false; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, maxRange, false); + + if (attackEntity) + return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, maxRange, false); + + return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, maxRange, false); }; UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max) @@ -4433,6 +4652,43 @@ return cmpObstructionManager.IsInTargetRange(this.entity, target, min, max, false); }; +/** + * Check if the target is inside the attack range of the formation. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @return {boolean} - Whether the attack-location is within attacking distance. + */ +UnitAI.prototype.CheckFormationTargetAttackRange = function(target) +{ + let attackEntity = typeof target == "number"; + // for formation members, the formation will take care of the range check + if (this.IsFormationMember()) + { + let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); + if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation() + && cmpFormationUnitAI.order.data.target == target) + return true; + } + + if (attackEntity) + { + let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpTargetFormation) + target = cmpTargetFormation.GetClosestMember(this.entity); + } + + let cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack); + if (!cmpFormationAttack) + return false; + let range = cmpFormationAttack.GetRange(target); + + let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); + if (attackEntity) + return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + + return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, false); +}; + UnitAI.prototype.CheckGarrisonRange = function(target) { var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); @@ -4450,19 +4706,25 @@ /** * Returns true if the target entity is visible through the FoW/SoD. + * @param {number | Vector3D} target - Either an entity ID, or a Vector3D of a position. + * @return {boolean} Whether the target is visible. */ UnitAI.prototype.CheckTargetVisible = function(target) { - var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); + // Assume an attackground target is either visible or not important whether it is. + if (target instanceof Vector3D) + return true; + + let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return false; - var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); + let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (!cmpRangeManager) return false; // Entities that are hidden and miraged are considered visible - var cmpFogging = Engine.QueryInterface(target, IID_Fogging); + let cmpFogging = Engine.QueryInterface(target, IID_Fogging); if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner())) return true; @@ -4499,12 +4761,10 @@ */ UnitAI.prototype.FaceTowardsTarget = function(target) { - let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); - if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + let targetPosition = this.GetTargetPosition2D(target); + if (!targetPosition) return; - let targetPosition = cmpTargetPosition.GetPosition2D(); - // Use cmpUnitMotion for units that support that, otherwise try cmpPosition (e.g. turrets) let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) @@ -4681,7 +4941,8 @@ */ UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force) { - if (this.IsTurret()) + // When attacking ground or are turret, we can't chase. + if (target instanceof Vector3D || this.IsTurret()) return false; if (this.GetStance().respondChase) @@ -4751,6 +5012,43 @@ this.PushOrderFront("MoveIntoFormation", { "x": pos.x, "z": pos.z, "force": true }); }; +/* + * Returns the 3D target position. + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @return {Vector3D} - The 3D-position of the target. + */ +UnitAI.prototype.GetTargetPosition3D = function(target) +{ + if (typeof target == "number") + { + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + return undefined; + + return cmpTargetPosition.GetPosition(); + } + + return target; +}; + +/* + * Returns the 2D target position. + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @return {Vector3D} - The 3D-position of the target. + */ +UnitAI.prototype.GetTargetPosition2D = function(target) +{ + if (typeof target == "number") + { + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + return undefined; + + return cmpTargetPosition.GetPosition2D(); + } + return Vector2D.from3D(target); +}; + UnitAI.prototype.GetTargetPositions = function() { var targetPositions = []; @@ -4768,6 +5066,10 @@ targetPositions.push(new Vector2D(order.data.x, order.data.z)); break; // and continue the loop + case "AttackGround": + targetPositions.push(Vector2D.from3D(order.data.target)); + break; + case "WalkToTarget": case "WalkToTargetRange": // This doesn't move to the target (just into range), but a later order will. case "Guard": @@ -5043,6 +5345,23 @@ }; /** + * Adds AttackGround order to queue, forced by the player. + * + * @param {Vector3D} target - The x,y,z-values where the entities need to attack ground. + * @param {number} radius - The radius from the target in which the entities need to attack ground. + * @param {boolean} queued - Whether the order is queued or not. + */ +UnitAI.prototype.AttackGround = function(target, radius, queued) +{ + if (this.CanAttack(target)) + this.AddOrder("AttackGround", { + "target": target, + "radius": radius, + "force": true + }, queued); +}; + +/** * Adds garrison order to the queue, forced by the player. */ UnitAI.prototype.Garrison = function(target, queued) @@ -5651,9 +5970,10 @@ { if (!orderData) orderData = this.order.data; - let cmpPosition = Engine.QueryInterface(orderData.target, IID_Position); - if (cmpPosition && cmpPosition.IsInWorld()) - orderData.lastPos = cmpPosition.GetPosition(); + + let targetPosition = this.GetTargetPosition3D(orderData.target); + if (targetPosition) + orderData.lastPos = targetPosition; }; UnitAI.prototype.SetHeldPosition = function(x, z) Index: binaries/data/mods/public/simulation/components/tests/test_Attack.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_Attack.js +++ binaries/data/mods/public/simulation/components/tests/test_Attack.js @@ -101,7 +101,8 @@ "Multiplier": 3 } } - } + }, + "AttackGround": {} }, "Capture": { "Value": 8, @@ -152,6 +153,10 @@ TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80 }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackStrengths("Capture"), { "value": 8 }); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround("Capture"), false); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround("Ranged"), true); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround(), true); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackStrengths("Ranged"), { "Hack": 0, "Pierce": 10, Index: binaries/data/mods/public/simulation/components/tests/test_Identity.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_Identity.js +++ binaries/data/mods/public/simulation/components/tests/test_Identity.js @@ -2,12 +2,13 @@ let cmpIdentity = ConstructComponent(5, "Identity", { "Civ": "iber", - "GenericName": "Iberian Skirmisher" + "GenericName": "Iberian Skirmisher", + "Phenotype": "male", }); TS_ASSERT_EQUALS(cmpIdentity.GetCiv(), "iber"); TS_ASSERT_EQUALS(cmpIdentity.GetLang(), "greek"); -TS_ASSERT_EQUALS(cmpIdentity.GetGender(), "male"); +TS_ASSERT_EQUALS(cmpIdentity.GetPhenotype(), "male"); TS_ASSERT_EQUALS(cmpIdentity.GetRank(), ""); TS_ASSERT_UNEVAL_EQUALS(cmpIdentity.GetClassesList(), []); TS_ASSERT_UNEVAL_EQUALS(cmpIdentity.GetVisibleClassesList(), []); @@ -20,7 +21,7 @@ cmpIdentity = ConstructComponent(6, "Identity", { "Civ": "iber", "Lang": "iberian", - "Gender": "female", + "Phenotype": "female", "GenericName": "Iberian Skirmisher", "SpecificName": "Lusitano Ezpatari", "SelectionGroupName": "units/iber_infantry_javelinist_b", @@ -39,7 +40,7 @@ TS_ASSERT_EQUALS(cmpIdentity.GetCiv(), "iber"); TS_ASSERT_EQUALS(cmpIdentity.GetLang(), "iberian"); -TS_ASSERT_EQUALS(cmpIdentity.GetGender(), "female"); +TS_ASSERT_EQUALS(cmpIdentity.GetPhenotype(), "female"); TS_ASSERT_EQUALS(cmpIdentity.GetRank(), "Basic"); TS_ASSERT_UNEVAL_EQUALS(cmpIdentity.GetClassesList(), ["CitizenSoldier", "Human", "Organic", "Javelin", "Basic"]); TS_ASSERT_UNEVAL_EQUALS(cmpIdentity.GetVisibleClassesList(), ["Javelin"]); Index: binaries/data/mods/public/simulation/helpers/Commands.js =================================================================== --- binaries/data/mods/public/simulation/helpers/Commands.js +++ binaries/data/mods/public/simulation/helpers/Commands.js @@ -202,6 +202,21 @@ }); }, + "attack-ground": function(player, cmd, data) + { + let entities = []; + let target = new Vector3D(cmd.target.x, cmd.target.y, cmd.target.z); + for (let ent of data.entities) + { + let cmpAttack = Engine.QueryInterface(ent, IID_Attack); + if (cmpAttack && cmpAttack.CanAttackGround()) + entities.push(ent); + } + GetFormationUnitAIs(entities, player).forEach(cmpUnitAI => { + cmpUnitAI.AttackGround(target, cmd.radius, cmd.queued); + }); + }, + "patrol": function(player, cmd, data) { let allowCapture = cmd.allowCapture || cmd.allowCapture == null; Index: binaries/data/mods/public/simulation/templates/template_gaia.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_gaia.xml +++ binaries/data/mods/public/simulation/templates/template_gaia.xml @@ -5,6 +5,7 @@ gaia Gaia + default true Index: binaries/data/mods/public/simulation/templates/template_structure.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_structure.xml +++ binaries/data/mods/public/simulation/templates/template_structure.xml @@ -60,6 +60,7 @@ gaia Structure Structure + default false Index: binaries/data/mods/public/simulation/templates/template_unit.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit.xml +++ binaries/data/mods/public/simulation/templates/template_unit.xml @@ -51,6 +51,7 @@ special/formations/flank special/formations/battle_line + male false Index: binaries/data/mods/public/simulation/templates/template_unit_cavalry.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_cavalry.xml +++ binaries/data/mods/public/simulation/templates/template_unit_cavalry.xml @@ -80,11 +80,11 @@ - - voice/{lang}/civ/civ_{gender}_walk.xml - voice/{lang}/civ/civ_{gender}_attack.xml - voice/{lang}/civ/civ_{gender}_gather.xml - voice/{lang}/civ/civ_{gender}_garrison.xml + + voice/{lang}/civ/civ_{phenotype}_walk.xml + voice/{lang}/civ/civ_{phenotype}_attack.xml + voice/{lang}/civ/civ_{phenotype}_gather.xml + voice/{lang}/civ/civ_{phenotype}_garrison.xml actor/mounted/movement/walk.xml actor/mounted/movement/walk.xml attack/impact/arrow_metal.xml Index: binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry.xml +++ binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry.xml @@ -42,11 +42,11 @@ - - voice/{lang}/civ/civ_{gender}_walk.xml - voice/{lang}/civ/civ_{gender}_attack.xml - voice/{lang}/civ/civ_{gender}_gather.xml - voice/{lang}/civ/civ_{gender}_garrison.xml + + voice/{lang}/civ/civ_{phenotype}_walk.xml + voice/{lang}/civ/civ_{phenotype}_attack.xml + voice/{lang}/civ/civ_{phenotype}_gather.xml + voice/{lang}/civ/civ_{phenotype}_garrison.xml actor/mounted/movement/walk.xml actor/mounted/movement/walk.xml attack/weapon/sword.xml Index: binaries/data/mods/public/simulation/templates/template_unit_champion_infantry.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_champion_infantry.xml +++ binaries/data/mods/public/simulation/templates/template_unit_champion_infantry.xml @@ -32,16 +32,16 @@ interface/alarm/alarm_create_infantry.xml - - voice/{lang}/civ/civ_{gender}_walk.xml - voice/{lang}/civ/civ_{gender}_attack.xml - voice/{lang}/civ/civ_{gender}_gather.xml - voice/{lang}/civ/civ_{gender}_repair.xml - voice/{lang}/civ/civ_{gender}_garrison.xml + + voice/{lang}/civ/civ_{phenotype}_walk.xml + voice/{lang}/civ/civ_{phenotype}_attack.xml + voice/{lang}/civ/civ_{phenotype}_gather.xml + voice/{lang}/civ/civ_{phenotype}_repair.xml + voice/{lang}/civ/civ_{phenotype}_garrison.xml actor/human/movement/walk.xml actor/human/movement/walk.xml attack/weapon/sword.xml - actor/human/death/{gender}_death.xml + actor/human/death/{phenotype}_death.xml Index: binaries/data/mods/public/simulation/templates/template_unit_hero.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_hero.xml +++ binaries/data/mods/public/simulation/templates/template_unit_hero.xml @@ -54,20 +54,20 @@ - + interface/alarm/alarm_create_infantry.xml - voice/{lang}/civ/civ_{gender}_heal.xml - voice/{lang}/civ/civ_{gender}_walk.xml - voice/{lang}/civ/civ_{gender}_attack.xml - voice/{lang}/civ/civ_{gender}_gather.xml - voice/{lang}/civ/civ_{gender}_repair.xml - voice/{lang}/civ/civ_{gender}_garrison.xml + voice/{lang}/civ/civ_{phenotype}_heal.xml + voice/{lang}/civ/civ_{phenotype}_walk.xml + voice/{lang}/civ/civ_{phenotype}_attack.xml + voice/{lang}/civ/civ_{phenotype}_gather.xml + voice/{lang}/civ/civ_{phenotype}_repair.xml + voice/{lang}/civ/civ_{phenotype}_garrison.xml attack/weapon/sword.xml attack/impact/arrow_metal.xml attack/weapon/arrowfly.xml actor/human/movement/walk.xml actor/human/movement/walk.xml - actor/human/death/{gender}_death.xml + actor/human/death/{phenotype}_death.xml Index: binaries/data/mods/public/simulation/templates/template_unit_infantry.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_infantry.xml +++ binaries/data/mods/public/simulation/templates/template_unit_infantry.xml @@ -100,18 +100,18 @@ - - voice/{lang}/civ/civ_{gender}_walk.xml - voice/{lang}/civ/civ_{gender}_attack.xml - voice/{lang}/civ/civ_{gender}_gather.xml - voice/{lang}/civ/civ_{gender}_repair.xml - voice/{lang}/civ/civ_{gender}_garrison.xml + + voice/{lang}/civ/civ_{phenotype}_walk.xml + voice/{lang}/civ/civ_{phenotype}_attack.xml + voice/{lang}/civ/civ_{phenotype}_gather.xml + voice/{lang}/civ/civ_{phenotype}_repair.xml + voice/{lang}/civ/civ_{phenotype}_garrison.xml actor/human/movement/walk.xml actor/human/movement/run.xml attack/impact/arrow_metal.xml attack/weapon/sword.xml attack/weapon/arrowfly.xml - actor/human/death/{gender}_death.xml + actor/human/death/{phenotype}_death.xml resource/construction/con_wood.xml resource/foraging/forage_leaves.xml resource/farming/farm.xml Index: binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged.xml +++ binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged.xml @@ -24,6 +24,7 @@ Human + Index: binaries/data/mods/public/simulation/templates/template_unit_support_female_citizen.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_support_female_citizen.xml +++ binaries/data/mods/public/simulation/templates/template_unit_support_female_citizen.xml @@ -48,7 +48,7 @@ Female Citizen - female + female FemaleCitizen Citizen Worker @@ -70,15 +70,15 @@ interface/alarm/alarm_create_female.xml - - voice/{lang}/civ/civ_{gender}_walk.xml - voice/{lang}/civ/civ_{gender}_attack.xml - voice/{lang}/civ/civ_{gender}_build.xml - voice/{lang}/civ/civ_{gender}_gather.xml - voice/{lang}/civ/civ_{gender}_repair.xml - voice/{lang}/civ/civ_{gender}_garrison.xml + + voice/{lang}/civ/civ_{phenotype}_walk.xml + voice/{lang}/civ/civ_{phenotype}_attack.xml + voice/{lang}/civ/civ_{phenotype}_build.xml + voice/{lang}/civ/civ_{phenotype}_gather.xml + voice/{lang}/civ/civ_{phenotype}_repair.xml + voice/{lang}/civ/civ_{phenotype}_garrison.xml attack/weapon/sword.xml - actor/human/death/{gender}_death.xml + actor/human/death/{phenotype}_death.xml resource/construction/con_wood.xml resource/foraging/forage_leaves.xml resource/farming/farm.xml Index: binaries/data/mods/public/simulation/templates/template_unit_support_healer.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_support_healer.xml +++ binaries/data/mods/public/simulation/templates/template_unit_support_healer.xml @@ -42,16 +42,16 @@ interface/alarm/alarm_create_infantry.xml - - voice/{lang}/civ/civ_{gender}_walk.xml - voice/{lang}/civ/civ_{gender}_attack.xml - voice/{lang}/civ/civ_{gender}_gather.xml - voice/{lang}/civ/civ_{gender}_repair.xml - voice/{lang}/civ/civ_{gender}_heal.xml - voice/{lang}/civ/civ_{gender}_garrison.xml + + voice/{lang}/civ/civ_{phenotype}_walk.xml + voice/{lang}/civ/civ_{phenotype}_attack.xml + voice/{lang}/civ/civ_{phenotype}_gather.xml + voice/{lang}/civ/civ_{phenotype}_repair.xml + voice/{lang}/civ/civ_{phenotype}_heal.xml + voice/{lang}/civ/civ_{phenotype}_garrison.xml actor/human/movement/walk.xml actor/human/movement/run.xml - actor/human/death/{gender}_death.xml + actor/human/death/{phenotype}_death.xml Index: binaries/data/mods/public/simulation/templates/template_unit_support_slave.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_support_slave.xml +++ binaries/data/mods/public/simulation/templates/template_unit_support_slave.xml @@ -65,14 +65,14 @@ - - voice/{lang}/civ/civ_{gender}_walk.xml - voice/{lang}/civ/civ_{gender}_gather.xml - voice/{lang}/civ/civ_{gender}_repair.xml - voice/{lang}/civ/civ_{gender}_garrison.xml + + voice/{lang}/civ/civ_{phenotype}_walk.xml + voice/{lang}/civ/civ_{phenotype}_gather.xml + voice/{lang}/civ/civ_{phenotype}_repair.xml + voice/{lang}/civ/civ_{phenotype}_garrison.xml actor/human/movement/walk.xml actor/human/movement/run.xml - actor/human/death/{gender}_death.xml + actor/human/death/{phenotype}_death.xml resource/construction/con_wood.xml resource/foraging/forage_leaves.xml resource/farming/farm.xml Index: binaries/data/mods/public/simulation/templates/template_unit_support_trader.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_support_trader.xml +++ binaries/data/mods/public/simulation/templates/template_unit_support_trader.xml @@ -19,16 +19,16 @@ - - voice/{lang}/civ/civ_{gender}_trade.xml - voice/{lang}/civ/civ_{gender}_walk.xml - voice/{lang}/civ/civ_{gender}_attack.xml - voice/{lang}/civ/civ_{gender}_gather.xml - voice/{lang}/civ/civ_{gender}_repair.xml + + voice/{lang}/civ/civ_{phenotype}_trade.xml + voice/{lang}/civ/civ_{phenotype}_walk.xml + voice/{lang}/civ/civ_{phenotype}_attack.xml + voice/{lang}/civ/civ_{phenotype}_gather.xml + voice/{lang}/civ/civ_{phenotype}_repair.xml actor/human/movement/walk.xml actor/human/movement/run.xml attack/weapon/sword.xml - actor/human/death/{gender}_death.xml + actor/human/death/{phenotype}_death.xml resource/construction/con_wood.xml resource/foraging/forage_leaves.xml resource/farming/farm.xml Index: binaries/data/mods/public/simulation/templates/units/brit_hero_boudicca.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/brit_hero_boudicca.xml +++ binaries/data/mods/public/simulation/templates/units/brit_hero_boudicca.xml @@ -12,7 +12,7 @@ Chariot Boudicca (Chariot) Boadicea - female + female units/brit_hero_boudicca.png Index: binaries/data/mods/public/simulation/templates/units/brit_hero_boudicca_cavalry_javelinist.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/brit_hero_boudicca_cavalry_javelinist.xml +++ binaries/data/mods/public/simulation/templates/units/brit_hero_boudicca_cavalry_javelinist.xml @@ -7,7 +7,7 @@ brit Boudicca (Sword) Boadicea - female + female units/brit_hero_boudicca.png Index: binaries/data/mods/public/simulation/templates/units/brit_hero_boudicca_sword.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/brit_hero_boudicca_sword.xml +++ binaries/data/mods/public/simulation/templates/units/brit_hero_boudicca_sword.xml @@ -7,7 +7,7 @@ brit Boudicca (Sword) Boadicea - female + female units/brit_hero_boudicca.png Index: binaries/data/mods/public/simulation/templates/units/cart_support_healer_b.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/cart_support_healer_b.xml +++ binaries/data/mods/public/simulation/templates/units/cart_support_healer_b.xml @@ -2,7 +2,7 @@ cart - female + female units/cart_support_healer_b Kehinit units/cart_support_healer.png Index: binaries/data/mods/public/simulation/templates/units/iber_support_healer_b.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/iber_support_healer_b.xml +++ binaries/data/mods/public/simulation/templates/units/iber_support_healer_b.xml @@ -2,7 +2,7 @@ iber - female + female units/iber_support_healer_b Priestess of Ataekina Emakumezko Apaiz de Ataekina Index: binaries/data/mods/public/simulation/templates/units/kush_hero_amanirenas.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/kush_hero_amanirenas.xml +++ binaries/data/mods/public/simulation/templates/units/kush_hero_amanirenas.xml @@ -7,7 +7,7 @@ kush Amanirenas Amnirense qore li kdwe li - female + female units/kush_hero_amanirenas.png Index: binaries/data/mods/public/simulation/templates/units/kush_hero_amanirenas_chariot.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/kush_hero_amanirenas_chariot.xml +++ binaries/data/mods/public/simulation/templates/units/kush_hero_amanirenas_chariot.xml @@ -7,7 +7,7 @@ kush Amanirenas Amnirense qore li kdwe li - female + female units/kush_hero_amanirenas.png Index: binaries/data/mods/public/simulation/templates/units/maur_champion_maiden.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/maur_champion_maiden.xml +++ binaries/data/mods/public/simulation/templates/units/maur_champion_maiden.xml @@ -2,7 +2,7 @@ maur - female + female Maiden Guard Visha Kanya units/maur_champion_maiden Index: binaries/data/mods/public/simulation/templates/units/maur_champion_maiden_archer.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/maur_champion_maiden_archer.xml +++ binaries/data/mods/public/simulation/templates/units/maur_champion_maiden_archer.xml @@ -2,7 +2,7 @@ maur - female + female Maiden Guard Archer Visha Kanya units/maur_champion_maiden_archer.png Index: binaries/data/mods/public/simulation/templates/units/ptol_hero_cleopatra.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/ptol_hero_cleopatra.xml +++ binaries/data/mods/public/simulation/templates/units/ptol_hero_cleopatra.xml @@ -7,7 +7,7 @@ ptol - female + female Cleopatra VII Kleopatra H' Philopator units/ptol_hero_cleopatra.png Index: source/simulation2/components/CCmpVisualActor.cpp =================================================================== --- source/simulation2/components/CCmpVisualActor.cpp +++ source/simulation2/components/CCmpVisualActor.cpp @@ -23,6 +23,7 @@ #include "simulation2/MessageTypes.h" #include "ICmpFootprint.h" +#include "ICmpIdentity.h" #include "ICmpUnitRenderer.h" #include "ICmpOwnership.h" #include "ICmpPosition.h" @@ -35,6 +36,7 @@ #include "simulation2/serialization/SerializeTemplates.h" +#include #include "graphics/Decal.h" #include "graphics/Frustum.h" #include "graphics/Model.h" @@ -198,7 +200,16 @@ if (m_IsFoundationActor) m_BaseActorName = m_ActorName = paramNode.GetChild("FoundationActor").ToString(); else - m_BaseActorName = m_ActorName = paramNode.GetChild("Actor").ToString(); + { + std::wstring baseActorString = paramNode.GetChild("Actor").ToString(); + + CmpPtr cmpIdentity(GetEntityHandle()); + const std::wstring pattern = L"{phenotype}"; + if (cmpIdentity) + boost::replace_all(baseActorString , pattern, cmpIdentity->GetPhenotype()); + + m_BaseActorName = m_ActorName = baseActorString; + } m_VisibleInAtlasOnly = paramNode.GetChild("VisibleInAtlasOnly").ToBool(); m_IsActorOnly = paramNode.GetChild("ActorOnly").IsOk(); Index: source/simulation2/components/ICmpIdentity.h =================================================================== --- source/simulation2/components/ICmpIdentity.h +++ source/simulation2/components/ICmpIdentity.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2011 Wildfire Games. +/* Copyright (C) 2019 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -29,6 +29,8 @@ public: virtual std::string GetSelectionGroupName() = 0; + virtual std::wstring GetPhenotype() = 0; + DECLARE_INTERFACE_TYPE(Identity) }; Index: source/simulation2/components/ICmpIdentity.cpp =================================================================== --- source/simulation2/components/ICmpIdentity.cpp +++ source/simulation2/components/ICmpIdentity.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2011 Wildfire Games. +/* Copyright (C) 2019 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -35,6 +35,11 @@ { return m_Script.Call("GetSelectionGroupName"); } + + virtual std::wstring GetPhenotype() + { + return m_Script.Call("GetPhenotype"); + } }; REGISTER_COMPONENT_SCRIPT_WRAPPER(IdentityScripted)