Line data Source code
1 : function Attack() {}
2 :
3 2 : var g_AttackTypes = ["Melee", "Ranged", "Capture"];
4 :
5 2 : Attack.prototype.preferredClassesSchema =
6 : "<optional>" +
7 : "<element name='PreferredClasses' a:help='Space delimited list of classes preferred for attacking. If an entity has any of theses classes, it is preferred. The classes are in decending order of preference'>" +
8 : "<attribute name='datatype'>" +
9 : "<value>tokens</value>" +
10 : "</attribute>" +
11 : "<text/>" +
12 : "</element>" +
13 : "</optional>";
14 :
15 2 : Attack.prototype.restrictedClassesSchema =
16 : "<optional>" +
17 : "<element name='RestrictedClasses' a:help='Space delimited list of classes that cannot be attacked by this entity. If target entity has any of these classes, it cannot be attacked'>" +
18 : "<attribute name='datatype'>" +
19 : "<value>tokens</value>" +
20 : "</attribute>" +
21 : "<text/>" +
22 : "</element>" +
23 : "</optional>";
24 :
25 2 : Attack.prototype.Schema =
26 : "<a:help>Controls the attack abilities and strengths of the unit.</a:help>" +
27 : "<a:example>" +
28 : "<Melee>" +
29 : "<AttackName>Spear</AttackName>" +
30 : "<Damage>" +
31 : "<Hack>10.0</Hack>" +
32 : "<Pierce>0.0</Pierce>" +
33 : "<Crush>5.0</Crush>" +
34 : "</Damage>" +
35 : "<MaxRange>4.0</MaxRange>" +
36 : "<RepeatTime>1000</RepeatTime>" +
37 : "<Bonuses>" +
38 : "<Bonus1>" +
39 : "<Civ>pers</Civ>" +
40 : "<Classes>Infantry</Classes>" +
41 : "<Multiplier>1.5</Multiplier>" +
42 : "</Bonus1>" +
43 : "<BonusCavMelee>" +
44 : "<Classes>Cavalry Melee</Classes>" +
45 : "<Multiplier>1.5</Multiplier>" +
46 : "</BonusCavMelee>" +
47 : "</Bonuses>" +
48 : "<RestrictedClasses datatype=\"tokens\">Champion</RestrictedClasses>" +
49 : "<PreferredClasses datatype=\"tokens\">Cavalry Infantry</PreferredClasses>" +
50 : "</Melee>" +
51 : "<Ranged>" +
52 : "<AttackName>Bow</AttackName>" +
53 : "<Damage>" +
54 : "<Hack>0.0</Hack>" +
55 : "<Pierce>10.0</Pierce>" +
56 : "<Crush>0.0</Crush>" +
57 : "</Damage>" +
58 : "<MaxRange>44.0</MaxRange>" +
59 : "<MinRange>20.0</MinRange>" +
60 : "<Origin>" +
61 : "<X>0</X>" +
62 : "<Y>10.0</Y>" +
63 : "<Z>0</Z>" +
64 : "</Origin>" +
65 : "<PrepareTime>800</PrepareTime>" +
66 : "<RepeatTime>1600</RepeatTime>" +
67 : "<EffectDelay>1000</EffectDelay>" +
68 : "<Bonuses>" +
69 : "<Bonus1>" +
70 : "<Classes>Cavalry</Classes>" +
71 : "<Multiplier>2</Multiplier>" +
72 : "</Bonus1>" +
73 : "</Bonuses>" +
74 : "<Projectile>" +
75 : "<Speed>50.0</Speed>" +
76 : "<Spread>2.5</Spread>" +
77 : "<ActorName>props/units/weapons/rock_flaming.xml</ActorName>" +
78 : "<ImpactActorName>props/units/weapons/rock_explosion.xml</ImpactActorName>" +
79 : "<ImpactAnimationLifetime>0.1</ImpactAnimationLifetime>" +
80 : "<FriendlyFire>false</FriendlyFire>" +
81 : "</Projectile>" +
82 : "<RestrictedClasses datatype=\"tokens\">Champion</RestrictedClasses>" +
83 : "<Splash>" +
84 : "<Shape>Circular</Shape>" +
85 : "<Range>20</Range>" +
86 : "<FriendlyFire>false</FriendlyFire>" +
87 : "<Damage>" +
88 : "<Hack>0.0</Hack>" +
89 : "<Pierce>10.0</Pierce>" +
90 : "<Crush>0.0</Crush>" +
91 : "</Damage>" +
92 : "</Splash>" +
93 : "</Ranged>" +
94 : "<Slaughter>" +
95 : "<Damage>" +
96 : "<Hack>1000.0</Hack>" +
97 : "<Pierce>0.0</Pierce>" +
98 : "<Crush>0.0</Crush>" +
99 : "</Damage>" +
100 : "<RepeatTime>1000</RepeatTime>" +
101 : "<MaxRange>4.0</MaxRange>" +
102 : "</Slaughter>" +
103 : "</a:example>" +
104 : "<oneOrMore>" +
105 : "<element>" +
106 : "<anyName a:help='Currently one of Melee, Ranged, Capture or Slaughter.'/>" +
107 : "<interleave>" +
108 : "<element name='AttackName' a:help='Name of the attack, to be displayed in the GUI. Optionally includes a translate context attribute.'>" +
109 : "<optional>" +
110 : "<attribute name='context'>" +
111 : "<text/>" +
112 : "</attribute>" +
113 : "</optional>" +
114 : "<text/>" +
115 : "</element>" +
116 : AttackHelper.BuildAttackEffectsSchema() +
117 : "<element name='MaxRange' a:help='Maximum attack range (in metres)'><ref name='nonNegativeDecimal'/></element>" +
118 : "<optional>" +
119 : "<element name='MinRange' a:help='Minimum attack range (in metres). Defaults to 0.'><ref name='nonNegativeDecimal'/></element>" +
120 : "</optional>" +
121 : "<optional>"+
122 : "<element name='Origin' a:help='The offset from which the attack occurs, relative to the entity position. Defaults to {0,0,0}.'>" +
123 : "<interleave>" +
124 : "<element name='X'>" +
125 : "<ref name='nonNegativeDecimal'/>" +
126 : "</element>" +
127 : "<element name='Y'>" +
128 : "<ref name='nonNegativeDecimal'/>" +
129 : "</element>" +
130 : "<element name='Z'>" +
131 : "<ref name='nonNegativeDecimal'/>" +
132 : "</element>" +
133 : "</interleave>" +
134 : "</element>" +
135 : "</optional>" +
136 : "<optional>" +
137 : "<element name='RangeOverlay'>" +
138 : "<interleave>" +
139 : "<element name='LineTexture'><text/></element>" +
140 : "<element name='LineTextureMask'><text/></element>" +
141 : "<element name='LineThickness'><ref name='nonNegativeDecimal'/></element>" +
142 : "</interleave>" +
143 : "</element>" +
144 : "</optional>" +
145 : "<optional>" +
146 : "<element name='PrepareTime' a:help='Time from the start of the attack command until the attack actually occurs (in milliseconds). This value relative to RepeatTime should closely match the \"event\" point in the actor's attack animation. Defaults to 0.'>" +
147 : "<data type='nonNegativeInteger'/>" +
148 : "</element>" +
149 : "</optional>" +
150 : "<element name='RepeatTime' a:help='Time between attacks (in milliseconds). The attack animation will be stretched to match this time'>" + // TODO: it shouldn't be stretched
151 : "<data type='positiveInteger'/>" +
152 : "</element>" +
153 : "<optional>" +
154 : "<element name='EffectDelay' a:help='Delay of applying the effects, in milliseconds after the attack has landed. Defaults to 0.'><ref name='nonNegativeDecimal'/></element>" +
155 : "</optional>" +
156 : "<optional>" +
157 : "<element name='Splash'>" +
158 : "<interleave>" +
159 : "<element name='Shape' a:help='Shape of the splash damage, can be circular or linear'><text/></element>" +
160 : "<element name='Range' a:help='Size of the area affected by the splash'><ref name='nonNegativeDecimal'/></element>" +
161 : "<element name='FriendlyFire' a:help='Whether the splash damage can hurt non enemy units'><data type='boolean'/></element>" +
162 : AttackHelper.BuildAttackEffectsSchema() +
163 : "</interleave>" +
164 : "</element>" +
165 : "</optional>" +
166 : "<optional>" +
167 : "<element name='Projectile'>" +
168 : "<interleave>" +
169 : "<element name='Speed' a:help='Speed of projectiles (in meters per second).'>" +
170 : "<ref name='positiveDecimal'/>" +
171 : "</element>" +
172 : "<element name='Spread' a:help='Standard deviation of the bivariate normal distribution of hits at 100 meters. A disk at 100 meters from the attacker with this radius (2x this radius, 3x this radius) is expected to include the landing points of 39.3% (86.5%, 98.9%) of the rounds.'><ref name='nonNegativeDecimal'/></element>" +
173 : "<element name='Gravity' a:help='The gravity affecting the projectile. This affects the shape of the flight curve.'>" +
174 : "<ref name='nonNegativeDecimal'/>" +
175 : "</element>" +
176 : "<element name='FriendlyFire' a:help='Whether stray missiles can hurt non enemy units.'><data type='boolean'/></element>" +
177 : "<optional>" +
178 : "<element name='LaunchPoint' a:help='Delta from the unit position where to launch the projectile.'>" +
179 : "<attribute name='y'>" +
180 : "<data type='decimal'/>" +
181 : "</attribute>" +
182 : "</element>" +
183 : "</optional>" +
184 : "<optional>" +
185 : "<element name='ActorName' a:help='actor of the projectile animation.'>" +
186 : "<text/>" +
187 : "</element>" +
188 : "</optional>" +
189 : "<optional>" +
190 : "<element name='ImpactActorName' a:help='actor of the projectile impact animation'>" +
191 : "<text/>" +
192 : "</element>" +
193 : "<element name='ImpactAnimationLifetime' a:help='length of the projectile impact animation.'>" +
194 : "<ref name='positiveDecimal'/>" +
195 : "</element>" +
196 : "</optional>" +
197 : "</interleave>" +
198 : "</element>" +
199 : "</optional>" +
200 : Attack.prototype.preferredClassesSchema +
201 : Attack.prototype.restrictedClassesSchema +
202 : "</interleave>" +
203 : "</element>" +
204 : "</oneOrMore>";
205 :
206 2 : Attack.prototype.Init = function()
207 : {
208 : };
209 :
210 2 : Attack.prototype.GetAttackTypes = function(wantedTypes)
211 : {
212 474 : let types = g_AttackTypes.filter(type => !!this.template[type]);
213 158 : if (!wantedTypes)
214 33 : return types;
215 :
216 162 : let wantedTypesReal = wantedTypes.filter(wtype => wtype.indexOf("!") != 0);
217 375 : return types.filter(type => wantedTypes.indexOf("!" + type) == -1 &&
218 : (!wantedTypesReal || !wantedTypesReal.length || wantedTypesReal.indexOf(type) != -1));
219 : };
220 :
221 2 : Attack.prototype.GetPreferredClasses = function(type)
222 : {
223 17 : if (this.template[type] && this.template[type].PreferredClasses &&
224 : this.template[type].PreferredClasses._string)
225 16 : return this.template[type].PreferredClasses._string.split(/\s+/);
226 :
227 1 : return [];
228 : };
229 :
230 2 : Attack.prototype.GetRestrictedClasses = function(type)
231 : {
232 57 : if (this.template[type] && this.template[type].RestrictedClasses &&
233 : this.template[type].RestrictedClasses._string)
234 46 : return this.template[type].RestrictedClasses._string.split(/\s+/);
235 :
236 11 : return [];
237 : };
238 :
239 2 : Attack.prototype.CanAttack = function(target, wantedTypes)
240 : {
241 133 : const cmpFormation = Engine.QueryInterface(target, IID_Formation);
242 133 : if (cmpFormation)
243 0 : return true;
244 :
245 133 : const cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position);
246 133 : const cmpTargetPosition = Engine.QueryInterface(target, IID_Position);
247 133 : if (!cmpThisPosition || !cmpTargetPosition || !cmpThisPosition.IsInWorld() || !cmpTargetPosition.IsInWorld())
248 0 : return false;
249 :
250 133 : const cmpResistance = QueryMiragedInterface(target, IID_Resistance);
251 133 : if (!cmpResistance)
252 0 : return false;
253 :
254 133 : const cmpIdentity = QueryMiragedInterface(target, IID_Identity);
255 133 : if (!cmpIdentity)
256 0 : return false;
257 :
258 133 : const cmpHealth = QueryMiragedInterface(target, IID_Health);
259 133 : const targetClasses = cmpIdentity.GetClassesList();
260 133 : if (targetClasses.indexOf("Domestic") != -1 && this.template.Slaughter && cmpHealth && cmpHealth.GetHitpoints() &&
261 22 : (!wantedTypes || !wantedTypes.filter(wType => wType.indexOf("!") != 0).length || wantedTypes.indexOf("Slaughter") != -1))
262 8 : return true;
263 :
264 125 : const cmpEntityPlayer = QueryOwnerInterface(this.entity);
265 125 : const cmpTargetPlayer = QueryOwnerInterface(target);
266 125 : if (!cmpTargetPlayer || !cmpEntityPlayer)
267 0 : return false;
268 :
269 125 : const types = this.GetAttackTypes(wantedTypes);
270 125 : const entityOwner = cmpEntityPlayer.GetPlayerID();
271 125 : const targetOwner = cmpTargetPlayer.GetPlayerID();
272 125 : const cmpCapturable = QueryMiragedInterface(target, IID_Capturable);
273 :
274 : // Check if the relative height difference is larger than the attack range
275 : // If the relative height is bigger, it means they will never be able to
276 : // reach each other, no matter how close they come.
277 125 : const heightDiff = Math.abs(cmpThisPosition.GetHeightOffset() - cmpTargetPosition.GetHeightOffset());
278 :
279 125 : for (const type of types)
280 : {
281 150 : if (type != "Capture" && (!cmpEntityPlayer.IsEnemy(targetOwner) || !cmpHealth || !cmpHealth.GetHitpoints()))
282 49 : continue;
283 :
284 101 : if (type == "Capture" && (!cmpCapturable || !cmpCapturable.CanCapture(entityOwner)))
285 45 : continue;
286 :
287 56 : if (heightDiff > this.GetRange(type).max)
288 0 : continue;
289 :
290 56 : const restrictedClasses = this.GetRestrictedClasses(type);
291 56 : if (!restrictedClasses.length)
292 11 : return true;
293 :
294 45 : if (!MatchesClassList(targetClasses, restrictedClasses))
295 38 : return true;
296 : }
297 :
298 76 : return false;
299 : };
300 :
301 : /**
302 : * Returns undefined if we have no preference or the lowest index of a preferred class.
303 : */
304 2 : Attack.prototype.GetPreference = function(target)
305 : {
306 4 : let cmpIdentity = Engine.QueryInterface(target, IID_Identity);
307 4 : if (!cmpIdentity)
308 0 : return undefined;
309 :
310 4 : let targetClasses = cmpIdentity.GetClassesList();
311 :
312 : let minPref;
313 4 : for (let type of this.GetAttackTypes())
314 : {
315 4 : let preferredClasses = this.GetPreferredClasses(type);
316 4 : for (let pref = 0; pref < preferredClasses.length; ++pref)
317 : {
318 7 : if (MatchesClassList(targetClasses, preferredClasses[pref]))
319 : {
320 2 : if (pref === 0)
321 1 : return pref;
322 1 : if ((minPref === undefined || minPref > pref))
323 1 : minPref = pref;
324 : }
325 : }
326 : }
327 3 : return minPref;
328 : };
329 :
330 : /**
331 : * Get the full range of attack using all available attack types.
332 : */
333 2 : Attack.prototype.GetFullAttackRange = function()
334 : {
335 1 : let ret = { "min": Infinity, "max": 0 };
336 1 : for (let type of this.GetAttackTypes())
337 : {
338 3 : let range = this.GetRange(type);
339 3 : ret.min = Math.min(ret.min, range.min);
340 3 : ret.max = Math.max(ret.max, range.max);
341 : }
342 1 : return ret;
343 : };
344 :
345 2 : Attack.prototype.GetAttackEffectsData = function(type, splash)
346 : {
347 20 : let template = this.template[type];
348 20 : if (!template)
349 0 : return undefined;
350 20 : if (splash)
351 4 : template = template.Splash;
352 20 : return AttackHelper.GetAttackEffectsData("Attack/" + type + (splash ? "/Splash" : ""), template, this.entity);
353 : };
354 :
355 : /**
356 : * Find the best attack against a target.
357 : * @param {number} target - The entity-ID of the target.
358 : * @param {boolean} allowCapture - Whether capturing is allowed.
359 : * @return {string} - The preferred attack type.
360 : */
361 2 : Attack.prototype.GetBestAttackAgainst = function(target, allowCapture)
362 : {
363 18 : let types = this.GetAttackTypes();
364 18 : if (Engine.QueryInterface(target, IID_Formation))
365 : // TODO: Formation against formation needs review
366 0 : return g_AttackTypes.find(attack => types.indexOf(attack) != -1);
367 :
368 18 : const cmpIdentity = Engine.QueryInterface(target, IID_Identity);
369 18 : if (!cmpIdentity)
370 0 : return undefined;
371 :
372 : // Always slaughter domestic animals instead of using a normal attack
373 18 : if (this.template.Slaughter && cmpIdentity.HasClass("Domestic"))
374 4 : return "Slaughter";
375 :
376 14 : const targetClasses = cmpIdentity.GetClassesList();
377 14 : const getPreferrence = attackType => {
378 12 : let pref = 0;
379 12 : if (MatchesClassList(targetClasses, this.GetPreferredClasses(attackType)))
380 2 : pref += 2;
381 12 : if (allowCapture ? attackType === "Capture" : attackType !== "Capture")
382 5 : pref++;
383 12 : return pref;
384 : };
385 :
386 42 : return types.filter(type => this.CanAttack(target, [type])).sort((a, b) => {
387 6 : const prefA = getPreferrence(a);
388 6 : const prefB = getPreferrence(b);
389 6 : return (types.indexOf(a) + (prefA > 0 ? prefA + types.length : 0)) -
390 : (types.indexOf(b) + (prefB > 0 ? prefB + types.length : 0))
391 : }).pop();
392 : };
393 :
394 2 : Attack.prototype.CompareEntitiesByPreference = function(a, b)
395 : {
396 0 : let aPreference = this.GetPreference(a);
397 0 : let bPreference = this.GetPreference(b);
398 :
399 0 : if (aPreference === null && bPreference === null) return 0;
400 0 : if (aPreference === null) return 1;
401 0 : if (bPreference === null) return -1;
402 0 : return aPreference - bPreference;
403 : };
404 :
405 2 : Attack.prototype.GetAttackName = function(type)
406 : {
407 0 : return {
408 : "name": this.template[type].AttackName._string || this.template[type].AttackName,
409 : "context": this.template[type].AttackName["@context"]
410 : };
411 : };
412 :
413 2 : Attack.prototype.GetRepeatTime = function(type)
414 : {
415 4 : let repeatTime = 1000;
416 :
417 4 : if (this.template[type] && this.template[type].RepeatTime)
418 2 : repeatTime = +this.template[type].RepeatTime;
419 :
420 4 : return ApplyValueModificationsToEntity("Attack/" + type + "/RepeatTime", repeatTime, this.entity);
421 : };
422 :
423 2 : Attack.prototype.GetTimers = function(type)
424 : {
425 2 : return {
426 : "prepare": ApplyValueModificationsToEntity("Attack/" + type + "/PrepareTime", +(this.template[type].PrepareTime || 0), this.entity),
427 : "repeat": this.GetRepeatTime(type)
428 : };
429 : };
430 :
431 2 : Attack.prototype.GetSplashData = function(type)
432 : {
433 2 : if (!this.template[type].Splash)
434 1 : return undefined;
435 :
436 1 : return {
437 : "attackData": this.GetAttackEffectsData(type, true),
438 : "friendlyFire": this.template[type].Splash.FriendlyFire == "true",
439 : "radius": ApplyValueModificationsToEntity("Attack/" + type + "/Splash/Range", +this.template[type].Splash.Range, this.entity),
440 : "shape": this.template[type].Splash.Shape,
441 : };
442 : };
443 :
444 2 : Attack.prototype.GetRange = function(type)
445 : {
446 59 : if (!type)
447 0 : return this.GetFullAttackRange();
448 :
449 59 : let max = +this.template[type].MaxRange;
450 59 : max = ApplyValueModificationsToEntity("Attack/" + type + "/MaxRange", max, this.entity);
451 :
452 59 : let min = +(this.template[type].MinRange || 0);
453 59 : min = ApplyValueModificationsToEntity("Attack/" + type + "/MinRange", min, this.entity);
454 :
455 59 : return { "max": max, "min": min };
456 : };
457 :
458 2 : Attack.prototype.GetAttackYOrigin = function(type)
459 : {
460 0 : if (!this.template[type].Origin)
461 0 : return 0;
462 0 : return ApplyValueModificationsToEntity("Attack/" + type + "/Origin/Y", +this.template[type].Origin.Y, this.entity);
463 : };
464 :
465 : /**
466 : * @param {number} target - The target to attack.
467 : * @param {string} type - The type of attack to use.
468 : * @param {number} callerIID - The IID to notify on specific events.
469 : *
470 : * @return {boolean} - Whether we started attacking.
471 : */
472 2 : Attack.prototype.StartAttacking = function(target, type, callerIID)
473 : {
474 0 : if (this.target)
475 0 : this.StopAttacking();
476 :
477 0 : if (!this.CanAttack(target, [type]))
478 0 : return false;
479 :
480 0 : const cmpResistance = QueryMiragedInterface(target, IID_Resistance);
481 0 : if (!cmpResistance || !cmpResistance.AddAttacker(this.entity))
482 0 : return false;
483 :
484 0 : let timings = this.GetTimers(type);
485 0 : let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
486 :
487 : // If the repeat time since the last attack hasn't elapsed,
488 : // delay the action to avoid attacking too fast.
489 0 : let prepare = timings.prepare;
490 0 : if (this.lastAttacked)
491 : {
492 0 : let repeatLeft = this.lastAttacked + timings.repeat - cmpTimer.GetTime();
493 0 : prepare = Math.max(prepare, repeatLeft);
494 : }
495 :
496 0 : let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
497 0 : if (cmpVisual)
498 : {
499 0 : cmpVisual.SelectAnimation("attack_" + type.toLowerCase(), false, 1.0);
500 0 : cmpVisual.SetAnimationSyncRepeat(timings.repeat);
501 0 : cmpVisual.SetAnimationSyncOffset(prepare);
502 : }
503 :
504 : // If using a non-default prepare time, re-sync the animation when the timer runs.
505 0 : this.resyncAnimation = prepare != timings.prepare;
506 0 : this.target = target;
507 0 : this.callerIID = callerIID;
508 0 : this.timer = cmpTimer.SetInterval(this.entity, IID_Attack, "Attack", prepare, timings.repeat, type);
509 :
510 0 : return true;
511 : };
512 :
513 : /**
514 : * @param {string} reason - The reason why we stopped attacking.
515 : */
516 2 : Attack.prototype.StopAttacking = function(reason)
517 : {
518 0 : if (!this.target)
519 0 : return;
520 :
521 0 : let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
522 0 : cmpTimer.CancelTimer(this.timer);
523 0 : delete this.timer;
524 :
525 0 : const cmpResistance = QueryMiragedInterface(this.target, IID_Resistance);
526 0 : if (cmpResistance)
527 0 : cmpResistance.RemoveAttacker(this.entity);
528 :
529 0 : delete this.target;
530 :
531 0 : let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
532 0 : if (cmpVisual)
533 0 : cmpVisual.SelectAnimation("idle", false, 1.0);
534 :
535 : // The callerIID component may start again,
536 : // replacing the callerIID, hence save that.
537 0 : let callerIID = this.callerIID;
538 0 : delete this.callerIID;
539 :
540 0 : if (reason && callerIID)
541 : {
542 0 : let component = Engine.QueryInterface(this.entity, callerIID);
543 0 : if (component)
544 0 : component.ProcessMessage(reason, null);
545 : }
546 : };
547 :
548 : /**
549 : * Attack our target entity.
550 : * @param {string} data - The attack type to use.
551 : * @param {number} lateness - The offset of the actual call and when it was expected.
552 : */
553 2 : Attack.prototype.Attack = function(type, lateness)
554 : {
555 0 : if (!this.CanAttack(this.target, [type]))
556 : {
557 0 : this.StopAttacking("TargetInvalidated");
558 0 : return;
559 : }
560 :
561 : // ToDo: Enable entities to keep facing a target.
562 0 : Engine.QueryInterface(this.entity, IID_UnitAI)?.FaceTowardsTarget(this.target);
563 :
564 0 : let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
565 0 : this.lastAttacked = cmpTimer.GetTime() - lateness;
566 :
567 : // BuildingAI has its own attack routine.
568 0 : if (!Engine.QueryInterface(this.entity, IID_BuildingAI))
569 0 : this.PerformAttack(type, this.target);
570 :
571 0 : if (!this.target)
572 0 : return;
573 :
574 : // We check the range after the attack to facilitate chasing.
575 0 : if (!this.IsTargetInRange(this.target, type))
576 : {
577 0 : this.StopAttacking("OutOfRange");
578 0 : return;
579 : }
580 :
581 0 : if (this.resyncAnimation)
582 : {
583 0 : let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
584 0 : if (cmpVisual)
585 : {
586 0 : let repeat = this.GetTimers(type).repeat;
587 0 : cmpVisual.SetAnimationSyncRepeat(repeat);
588 0 : cmpVisual.SetAnimationSyncOffset(repeat);
589 : }
590 0 : delete this.resyncAnimation;
591 : }
592 : };
593 :
594 : /**
595 : * Attack the target entity. This should only be called after a successful range check,
596 : * and should only be called after GetTimers().repeat msec has passed since the last
597 : * call to PerformAttack.
598 : */
599 2 : Attack.prototype.PerformAttack = function(type, target)
600 : {
601 1 : let cmpPosition = Engine.QueryInterface(this.entity, IID_Position);
602 1 : if (!cmpPosition || !cmpPosition.IsInWorld())
603 0 : return;
604 1 : let selfPosition = cmpPosition.GetPosition();
605 :
606 1 : let cmpTargetPosition = Engine.QueryInterface(target, IID_Position);
607 1 : if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld())
608 0 : return;
609 1 : let targetPosition = cmpTargetPosition.GetPosition();
610 :
611 1 : let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership);
612 1 : if (!cmpOwnership)
613 0 : return;
614 1 : let attackerOwner = cmpOwnership.GetOwner();
615 :
616 1 : let data = {
617 : "type": type,
618 : "attackData": this.GetAttackEffectsData(type),
619 : "splash": this.GetSplashData(type),
620 : "attacker": this.entity,
621 : "attackerOwner": attackerOwner,
622 : "target": target,
623 : };
624 :
625 1 : let delay = +(this.template[type].EffectDelay || 0);
626 :
627 1 : if (this.template[type].Projectile)
628 : {
629 1 : let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
630 1 : let turnLength = cmpTimer.GetLatestTurnLength()/1000;
631 : // In the future this could be extended:
632 : // * Obstacles like trees could reduce the probability of the target being hit
633 : // * Obstacles like walls should block projectiles entirely
634 :
635 1 : let horizSpeed = +this.template[type].Projectile.Speed;
636 1 : let gravity = +this.template[type].Projectile.Gravity;
637 : // horizSpeed /= 2; gravity /= 2; // slow it down for testing
638 :
639 : // We will try to estimate the position of the target, where we can hit it.
640 : // We first estimate the time-till-hit by extrapolating linearly the movement
641 : // of the last turn. We compute the time till an arrow will intersect the target.
642 1 : let targetVelocity = Vector3D.sub(targetPosition, cmpTargetPosition.GetPreviousPosition()).div(turnLength);
643 :
644 1 : let timeToTarget = PositionHelper.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity);
645 :
646 : // 'Cheat' and use UnitMotion to predict the position in the near-future.
647 : // This avoids 'dancing' issues with units zigzagging over very short distances.
648 : // However, this could fail if the player gives several short move orders, so
649 : // occasionally fall back to basic interpolation.
650 1 : let predictedPosition = targetPosition;
651 1 : if (timeToTarget !== false)
652 : {
653 : // Don't predict too far in the future, but avoid threshold effects.
654 : // After 1 second, always use the 'dumb' interpolated past-motion prediction.
655 1 : let useUnitMotion = randBool(Math.max(0, 0.75 - timeToTarget / 1.333));
656 1 : if (useUnitMotion)
657 : {
658 0 : let cmpTargetUnitMotion = Engine.QueryInterface(target, IID_UnitMotion);
659 0 : let cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI);
660 0 : if (cmpTargetUnitMotion && (!cmpTargetUnitAI || !cmpTargetUnitAI.IsFormationMember()))
661 : {
662 0 : let pos2D = cmpTargetUnitMotion.EstimateFuturePosition(timeToTarget);
663 0 : predictedPosition.x = pos2D.x;
664 0 : predictedPosition.z = pos2D.y;
665 : }
666 : else
667 0 : predictedPosition = Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition);
668 : }
669 : else
670 1 : predictedPosition = Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition);
671 : }
672 :
673 1 : let predictedHeight = cmpTargetPosition.GetHeightAt(predictedPosition.x, predictedPosition.z);
674 :
675 : // Add inaccuracy based on spread.
676 1 : let distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/" + type + "/Spread", +this.template[type].Projectile.Spread, this.entity) *
677 : predictedPosition.horizDistanceTo(selfPosition) / 100;
678 :
679 1 : let randNorm = randomNormal2D();
680 1 : let offsetX = randNorm[0] * distanceModifiedSpread;
681 1 : let offsetZ = randNorm[1] * distanceModifiedSpread;
682 :
683 1 : data.position = new Vector3D(predictedPosition.x + offsetX, predictedHeight, predictedPosition.z + offsetZ);
684 :
685 1 : let realHorizDistance = data.position.horizDistanceTo(selfPosition);
686 1 : timeToTarget = realHorizDistance / horizSpeed;
687 1 : delay += timeToTarget * 1000;
688 :
689 1 : data.direction = Vector3D.sub(data.position, selfPosition).div(realHorizDistance);
690 :
691 1 : let actorName = this.template[type].Projectile.ActorName || "";
692 1 : let impactActorName = this.template[type].Projectile.ImpactActorName || "";
693 1 : let impactAnimationLifetime = this.template[type].Projectile.ImpactAnimationLifetime || 0;
694 :
695 : // TODO: Use unit rotation to implement x/z offsets.
696 1 : let deltaLaunchPoint = new Vector3D(0, +this.template[type].Projectile.LaunchPoint["@y"], 0);
697 1 : let launchPoint = Vector3D.add(selfPosition, deltaLaunchPoint);
698 :
699 1 : let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual);
700 1 : if (cmpVisual)
701 : {
702 : // if the projectile definition is missing from the template
703 : // then fallback to the projectile name and launchpoint in the visual actor
704 0 : if (!actorName)
705 0 : actorName = cmpVisual.GetProjectileActor();
706 :
707 0 : let visualActorLaunchPoint = cmpVisual.GetProjectileLaunchPoint();
708 0 : if (visualActorLaunchPoint.length() > 0)
709 0 : launchPoint = visualActorLaunchPoint;
710 : }
711 :
712 1 : let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager);
713 1 : data.projectileId = cmpProjectileManager.LaunchProjectileAtPoint(launchPoint, data.position, horizSpeed, gravity, actorName, impactActorName, impactAnimationLifetime);
714 :
715 1 : let cmpSound = Engine.QueryInterface(this.entity, IID_Sound);
716 1 : data.attackImpactSound = cmpSound ? cmpSound.GetSoundGroup("attack_impact_" + type.toLowerCase()) : "";
717 :
718 1 : data.friendlyFire = this.template[type].Projectile.FriendlyFire == "true";
719 : }
720 : else
721 : {
722 0 : data.position = targetPosition;
723 0 : data.direction = Vector3D.sub(targetPosition, selfPosition);
724 : }
725 1 : if (delay)
726 : {
727 1 : let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer);
728 1 : cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_DelayedDamage, "Hit", delay, data);
729 : }
730 : else
731 0 : Engine.QueryInterface(SYSTEM_ENTITY, IID_DelayedDamage).Hit(data, 0);
732 : };
733 :
734 : /**
735 : * @param {number} - The entity ID of the target to check.
736 : * @return {boolean} - Whether this entity is in range of its target.
737 : */
738 2 : Attack.prototype.IsTargetInRange = function(target, type)
739 : {
740 0 : const range = this.GetRange(type);
741 0 : return Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager).IsInTargetParabolicRange(
742 : this.entity,
743 : target,
744 : range.min,
745 : range.max,
746 : this.GetAttackYOrigin(type),
747 : false);
748 : };
749 :
750 2 : Attack.prototype.OnValueModification = function(msg)
751 : {
752 0 : if (msg.component != "Attack")
753 0 : return;
754 :
755 0 : let cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI);
756 0 : if (!cmpUnitAI)
757 0 : return;
758 :
759 0 : if (this.GetAttackTypes().some(type =>
760 0 : msg.valueNames.indexOf("Attack/" + type + "/MaxRange") != -1))
761 0 : cmpUnitAI.UpdateRangeQueries();
762 : };
763 :
764 2 : Attack.prototype.GetRangeOverlays = function(type = "Ranged")
765 : {
766 0 : if (!this.template[type] || !this.template[type].RangeOverlay)
767 0 : return [];
768 :
769 0 : let range = this.GetRange(type);
770 0 : let rangeOverlays = [];
771 0 : for (let i in range)
772 0 : if ((i == "min" || i == "max") && range[i])
773 0 : rangeOverlays.push({
774 : "radius": range[i],
775 : "texture": this.template[type].RangeOverlay.LineTexture,
776 : "textureMask": this.template[type].RangeOverlay.LineTextureMask,
777 : "thickness": +this.template[type].RangeOverlay.LineThickness,
778 : });
779 0 : return rangeOverlays;
780 : };
781 :
782 2 : Engine.RegisterComponentType(IID_Attack, "Attack", Attack);
|