-
Notifications
You must be signed in to change notification settings - Fork 1.5k
/
UnitMovement.kt
940 lines (805 loc) · 45.1 KB
/
UnitMovement.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
package com.unciv.logic.map.mapunit.movement
import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.map.BFS
import com.unciv.logic.map.HexMath.getDistance
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.tile.Tile
import com.unciv.models.UnitActionType
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.components.UnitMovementMemoryType
import java.util.TreeSet
fun List<UnitMovement.MovementStep>.toBackwardsCompatiblePath(): List<Tile> {
val backwardsCompatiblePath = this.filter { it.totalCost.movementLeft == 0f || it == this.last() }
return backwardsCompatiblePath.map { it.tile }
}
class UnitMovement(val unit: MapUnit) {
private val pathfindingCache = PathfindingCache(unit)
class ParentTileAndTotalDistance(val tile:Tile, val parentTile: Tile, val totalDistance: Float)
fun isUnknownTileWeShouldAssumeToBePassable(tile: Tile) = !unit.civ.hasExplored(tile)
/**
* Does not consider if tiles can actually be entered, use canMoveTo for that.
* If a tile can be reached within the turn, but it cannot be passed through, the total distance to it is set to unitMovement
*/
fun getDistanceToTilesWithinTurn(
origin: Vector2,
unitMovement: Float,
considerZoneOfControl: Boolean = true,
tilesToIgnore: HashSet<Tile>? = null,
passThroughCache: HashMap<Tile, Boolean> = HashMap(),
movementCostCache: HashMap<Pair<Tile, Tile>, Float> = HashMap()
): PathsToTilesWithinTurn {
val distanceToTiles = PathsToTilesWithinTurn()
val currentUnitTile = unit.currentTile
// This is for performance, because this is called all the time
val unitTile = if (origin == currentUnitTile.position) currentUnitTile else currentUnitTile.tileMap[origin]
distanceToTiles[unitTile] = ParentTileAndTotalDistance(unitTile, unitTile, 0f)
// If I can't move my only option is to stay...
if (unitMovement == 0f || unit.cache.cannotMove) return distanceToTiles
var tilesToCheck = listOf(unitTile)
while (tilesToCheck.isNotEmpty()) {
val updatedTiles = ArrayList<Tile>()
for (tileToCheck in tilesToCheck)
for (neighbor in tileToCheck.neighbors) {
if (tilesToIgnore?.contains(neighbor) == true) continue // ignore this tile
var totalDistanceToTile: Float = when {
!neighbor.isExplored(unit.civ) ->
distanceToTiles[tileToCheck]!!.totalDistance + 1f // If we don't know then we just guess it to be 1.
!passThroughCache.getOrPut(neighbor) { canPassThrough(neighbor) } -> unitMovement // Can't go here.
// The reason that we don't just "return" is so that when calculating how to reach an enemy,
// You need to assume his tile is reachable, otherwise all movement algorithms on reaching enemy
// cities and units goes kaput.
else -> {
val key = Pair(tileToCheck, neighbor)
val movementCost =
movementCostCache.getOrPut(key) {
MovementCost.getMovementCostBetweenAdjacentTiles(unit, tileToCheck, neighbor, considerZoneOfControl)
}
distanceToTiles[tileToCheck]!!.totalDistance + movementCost
}
}
if (!distanceToTiles.containsKey(neighbor) || distanceToTiles[neighbor]!!.totalDistance > totalDistanceToTile) { // this is the new best path
if (totalDistanceToTile < unitMovement - Constants.minimumMovementEpsilon) // We can still keep moving from here!
updatedTiles += neighbor
else
totalDistanceToTile = unitMovement
// In Civ V, you can always travel between adjacent tiles, even if you don't technically
// have enough movement points - it simply depletes what you have
distanceToTiles[neighbor] = ParentTileAndTotalDistance(neighbor, tileToCheck, totalDistanceToTile)
}
}
tilesToCheck = updatedTiles
}
return distanceToTiles
}
data class MovementStep(val previousStep: MovementStep?, val tile: Tile, val movementCost:Float, val totalCost: MovementStepTotalCost)
data class MovementStepTotalCost(/** Turn 0 means the initial turn */ val turn: Int, val movementLeft: Float):Comparable<MovementStepTotalCost> {
override operator fun compareTo(other: MovementStepTotalCost) =
compareValuesBy(this, other, {it.turn}, {-it.movementLeft})
}
/** Problem and solution documented at https://yairm210.medium.com/multi-turn-pathfinding-7136bd0bdaf0 */
fun getShortestPathNew(destination: Tile, considerZoneOfControl: Boolean = true,
/** For allowing optional avoid of damaging tiles, tiles outside borders, etc */ shouldAvoidTile: (Tile) -> Boolean = {false},
maxTurns: Int = 25,
): List<MovementStep> {
if (unit.cache.cannotMove) return listOf()
val startingTile = unit.getTile()
val initialStep = MovementStep(null, startingTile, 0f, MovementStepTotalCost(0, unit.currentMovement))
if (startingTile.position == destination) {
// edge case that's needed, so that workers will know that they can reach their own tile. *sigh*
pathfindingCache.setShortestPathCache(destination, listOf(startingTile))
return listOf(initialStep)
}
val tileToBestStep = HashMap<Tile, MovementStep>() // contains a map of "you can get from X to Y in that turn"
tileToBestStep[startingTile] = initialStep
val tilesToCheck = TreeSet { t: Tile, t2: Tile ->
val tStep = tileToBestStep[t]!!
val t2Step = tileToBestStep[t2]!!
// This last comparitor is REQUIRED otherwise the tree will think that tiles the same distance away are the same and will throw the second one away!
compareValuesBy(tStep, t2Step, {it.totalCost}, {it.tile.aerialDistanceTo(destination)}, {it.tile.position.hashCode()})
}
tilesToCheck.add(startingTile)
val canEndTurnInCache = HashMap<Tile, Boolean>()
val unitMaxMovement = unit.getMaxMovement().toFloat()
val movementCostCache = HashMap<Pair<Tile, Tile>, Float>()
val shouldAvoidTileCache = HashMap<Tile, Boolean>()
val canPassThroughCache = HashMap<Tile, Boolean>()
while (tilesToCheck.isNotEmpty()){
val currentTileToCheck = tilesToCheck.pollFirst()!!
val currentTileStep = tileToBestStep[currentTileToCheck]!!
for (neighbor in currentTileToCheck.neighbors){
if (shouldAvoidTileCache.getOrPut(neighbor){ shouldAvoidTile(neighbor) }) continue
val currentBestStepToNeighbor = tileToBestStep[neighbor]
// If this tile can't beat the current best then no point checking
if (currentBestStepToNeighbor!=null && (currentBestStepToNeighbor.totalCost < currentTileStep.totalCost))
continue
if (!canPassThroughCache.getOrPut(neighbor){ canPassThrough(neighbor) }) continue
val movementBetweenTiles: Float = if (!neighbor.isExplored(unit.civ)) 1f // If we don't know then we just guess it to be 1.
else movementCostCache.getOrPut(currentTileToCheck to neighbor) {
MovementCost.getMovementCostBetweenAdjacentTiles(unit, currentTileToCheck, neighbor, considerZoneOfControl)
}
val newStep = getNextStep(currentTileStep, neighbor, movementBetweenTiles, canEndTurnInCache, unitMaxMovement)
?: continue
if (neighbor == destination){
val entirePath = arrayListOf<MovementStep>()
var currentStep = newStep
// We do NOT include the origin tile in this list
while (currentStep.previousStep != null){
entirePath.add(currentStep)
currentStep = currentStep.previousStep!!
}
return entirePath.reversed()
}
if (currentBestStepToNeighbor == null ||
newStep.totalCost < currentBestStepToNeighbor.totalCost) { // We have a winner!
tileToBestStep[neighbor] = newStep
if (newStep.totalCost.movementLeft == 0f && newStep.totalCost.turn == maxTurns) continue // don't schedule further expansion
tilesToCheck.add(neighbor)
}
}
}
return listOf() // no path
}
fun getNextStep(currentTileStep:MovementStep, neighbor:Tile, movementBetweenTiles:Float,
canEndTurnInCache:HashMap<Tile, Boolean>, unitMaxMovement:Float):MovementStep? {
fun canEndTurnIn(tile:Tile) = canEndTurnInCache.getOrPut(tile) { unit.movement.canMoveTo(tile) }
fun Float.normalizeMovementLeft() = if (this > Constants.minimumMovementEpsilon) this else 0f
val currentTile = currentTileStep.tile
// We can move directly
if (currentTileStep.totalCost.movementLeft != 0f || canEndTurnIn(currentTile)) {
val newTotalCost = if (currentTileStep.totalCost.movementLeft == 0f) MovementStepTotalCost(
currentTileStep.totalCost.turn + 1,
(unitMaxMovement - movementBetweenTiles).normalizeMovementLeft()
)
else MovementStepTotalCost(currentTileStep.totalCost.turn, (currentTileStep.totalCost.movementLeft - movementBetweenTiles).normalizeMovementLeft())
return MovementStep(currentTileStep, neighbor, movementBetweenTiles, newTotalCost)
}
// Backtracking nonsense - we CANNOT end the turn on the previous tile, AND we have no movement left, that means we need to do some alternate history
val previousStep = currentTileStep.previousStep ?: return null
if (previousStep.totalCost.movementLeft == 0f) return null // We backtracked until a previous end-of-turn and couldn't find any intermediate tiles
if (!canEndTurnIn(previousStep.tile)) return null
// We found somewhere we could have rested - let's do some alternate history!
val currentTileAlternateHistory = MovementStep(currentTileStep.previousStep, currentTile, currentTileStep.movementCost,
MovementStepTotalCost(currentTileStep.previousStep.totalCost.turn+1, (unitMaxMovement - currentTileStep.movementCost).normalizeMovementLeft()))
if (currentTileAlternateHistory.totalCost.movementLeft == 0f) return null // We tried, and even if we rest there we STILL have to stop at current tile... :/
val newTotalCost = MovementStepTotalCost(currentTileAlternateHistory.totalCost.turn, (currentTileAlternateHistory.totalCost.movementLeft-currentTileStep.movementCost).normalizeMovementLeft())
return MovementStep(currentTileAlternateHistory, neighbor, movementBetweenTiles, newTotalCost)
}
/**
* Does not consider if the [destination] tile can actually be entered, use [canMoveTo] for that.
* Returns an empty list if there's no way to get to the destination.
*/
fun getShortestPath(destination: Tile, avoidDamagingTerrain: Boolean = false): List<Tile> {
if (UncivGame.Current.settings.experimentalMovement) {
fun shouldAvoidEnemyTile(tile:Tile) = unit.isCivilian() && unit.isAutomated() && tile.isEnemyTerritory(unit.civ)
if (avoidDamagingTerrain){
val shortestPathWithoutDamagingTiles = getShortestPathNew(destination,
shouldAvoidTile = { shouldAvoidEnemyTile(it) || unit.getDamageFromTerrain(it) > 0 })
if (shortestPathWithoutDamagingTiles.isNotEmpty()) return shortestPathWithoutDamagingTiles.toBackwardsCompatiblePath()
}
return getShortestPathNew(destination, shouldAvoidTile = ::shouldAvoidEnemyTile).toBackwardsCompatiblePath()
}
if (unit.cache.cannotMove) return listOf()
// First try and find a path without damaging terrain
if (!avoidDamagingTerrain && unit.civ.passThroughImpassableUnlocked && unit.baseUnit.isLandUnit()) {
val damageFreePath = getShortestPath(destination, true)
if (damageFreePath.isNotEmpty()) return damageFreePath
}
if (unit.baseUnit.isWaterUnit()
&& destination.neighbors.none { isUnknownTileWeShouldAssumeToBePassable(it) || it.isWater }) {
// edge case where this unit is a boat and all of the tiles around the destination are
// explored and known to be land so we know a priori that no path exists
pathfindingCache.setShortestPathCache(destination, listOf())
return listOf()
}
val cachedPath = pathfindingCache.getShortestPathCache(destination)
if (cachedPath.isNotEmpty())
return cachedPath
val currentTile = unit.getTile()
if (currentTile.position == destination) {
// edge case that's needed, so that workers will know that they can reach their own tile. *sigh*
pathfindingCache.setShortestPathCache(destination, listOf(currentTile))
return listOf(currentTile)
}
var tilesToCheck = listOf(currentTile)
val movementTreeParents = HashMap<Tile, Tile?>() // contains a map of "you can get from X to Y in that turn"
movementTreeParents[currentTile] = null
var distance = 1
val unitMaxMovement = unit.getMaxMovement().toFloat()
val newTilesToCheck = ArrayList<Tile>()
val visitedTiles: HashSet<Tile> = hashSetOf(currentTile)
val civilization = unit.civ
val passThroughCache = HashMap<Tile, Boolean>()
val movementCostCache = HashMap<Pair<Tile, Tile>, Float>()
val canMoveToCache = HashMap<Tile, Boolean>()
while (true) {
newTilesToCheck.clear()
var tilesByPreference = tilesToCheck.sortedBy { it.aerialDistanceTo(destination) }
// Avoid embarkation when possible
if (unit.type.isLandUnit()) tilesByPreference = tilesByPreference.sortedByDescending { it.isLand }
for (tileToCheck in tilesByPreference) {
val distanceToTilesThisTurn = if (distance == 1) {
getDistanceToTiles(true, passThroughCache, movementCostCache) // check cache
}
else {
getDistanceToTilesWithinTurn(
tileToCheck.position,
unitMaxMovement,
false,
visitedTiles,
passThroughCache,
movementCostCache
)
}
for (reachableTile in distanceToTilesThisTurn.keys) {
// Avoid damaging terrain on first pass
if (avoidDamagingTerrain && unit.getDamageFromTerrain(reachableTile) > 0)
continue
// Avoid Enemy Territory if Civilian and Automated. For multi-turn pathing
if (unit.isCivilian() && unit.isAutomated() && reachableTile.isEnemyTerritory(civilization))
continue
if (reachableTile == destination) {
val path = mutableListOf(destination)
// Traverse the tree upwards to get the list of tiles leading to the destination
var intermediateTile = tileToCheck
while (intermediateTile != currentTile) {
path.add(intermediateTile)
intermediateTile = movementTreeParents[intermediateTile]!!
}
path.reverse() // and reverse in order to get the list in chronological order
pathfindingCache.setShortestPathCache(destination, path)
return path
} else {
if (movementTreeParents.containsKey(reachableTile)) continue // We cannot be faster than anything existing...
if (!isUnknownTileWeShouldAssumeToBePassable(reachableTile) &&
!canMoveToCache.getOrPut(reachableTile) { canMoveTo(reachableTile) })
// This is a tile that we can't actually enter - either an intermediary tile containing our unit, or an enemy unit/city
continue
movementTreeParents[reachableTile] = tileToCheck
newTilesToCheck.add(reachableTile)
}
}
}
if (newTilesToCheck.isEmpty()) {
// there is NO PATH (eg blocked by enemy units)
pathfindingCache.setShortestPathCache(destination, emptyList())
return emptyList()
}
// add newTilesToCheck to visitedTiles so we do not path over these tiles in a later iteration
visitedTiles.addAll(newTilesToCheck)
// no need to check tiles that are surrounded by reachable tiles, only need to check the edgemost tiles.
// Because anything we can reach from intermediate tiles, can be more easily reached by the edgemost tiles,
// since we'll have to pass through an edgemost tile in order to reach the destination anyway
tilesToCheck = newTilesToCheck.filterNot { tile -> tile.neighbors.all { it in visitedTiles } }
distance++
}
}
class UnreachableDestinationException(msg: String) : Exception(msg)
fun getTileToMoveToThisTurn(finalDestination: Tile): Tile {
val currentTile = unit.getTile()
if (currentTile == finalDestination) return currentTile
// If we can fly, head there directly
if ((unit.baseUnit.movesLikeAirUnits() || unit.isPreparingParadrop()) && canMoveTo(finalDestination)) return finalDestination
val distanceToTiles = getDistanceToTiles()
// If the tile is far away, we need to build a path how to get there, and then take the first step
if (!distanceToTiles.containsKey(finalDestination))
return getShortestPath(finalDestination).firstOrNull()
?: throw UnreachableDestinationException("$unit ${unit.currentTile} cannot reach $finalDestination")
// we should be able to get there this turn
if (canMoveTo(finalDestination))
return finalDestination
// Someone is blocking to the path to the final tile...
val destinationNeighbors = finalDestination.neighbors
return when (currentTile) {
in destinationNeighbors -> currentTile // We're right nearby anyway, no need to move
else -> destinationNeighbors
.filter { distanceToTiles.containsKey(it) && canMoveTo(it) }
.minByOrNull { distanceToTiles.getValue(it).totalDistance } // we can get a little closer
?: currentTile // We can't get closer...
}
}
/**
* @return The tile that we reached this turn
*/
fun headTowards(destination: Tile): Tile {
val destinationTileThisTurn = getTileToMoveToThisTurn(destination)
moveToTile(destinationTileThisTurn)
return unit.currentTile
}
/** This is performance-heavy - use as last resort, only after checking everything else!
* Also note that REACHABLE tiles are not necessarily tiles that the unit CAN ENTER */
fun canReach(destination: Tile): Boolean {
if (unit.cache.cannotMove) return destination == unit.getTile()
if (unit.baseUnit.movesLikeAirUnits() || unit.isPreparingParadrop())
return canReachInCurrentTurn(destination)
return getShortestPath(destination).any()
}
fun canReachInCurrentTurn(destination: Tile): Boolean {
if (unit.cache.cannotMove) return destination == unit.getTile()
if (unit.baseUnit.movesLikeAirUnits())
return unit.currentTile.aerialDistanceTo(destination) <= unit.getMaxMovementForAirUnits()
if (unit.isPreparingParadrop())
return getDistance(unit.currentTile.position, destination.position) <= unit.cache.paradropRange && canParadropOn(destination)
return getDistanceToTiles().containsKey(destination)
}
fun getReachableTilesInCurrentTurn(): Sequence<Tile> {
return when {
unit.cache.cannotMove -> sequenceOf(unit.getTile())
unit.baseUnit.movesLikeAirUnits() ->
unit.getTile().getTilesInDistanceRange(IntRange(1, unit.getMaxMovementForAirUnits()))
unit.isPreparingParadrop() ->
unit.getTile().getTilesInDistance(unit.cache.paradropRange)
.filter { unit.movement.canParadropOn(it) }
else ->
unit.movement.getDistanceToTiles().keys.asSequence()
}
}
/** Returns whether we can perform a swap move to the specified tile */
fun canUnitSwapTo(destination: Tile): Boolean {
return canReachInCurrentTurn(destination) && canUnitSwapToReachableTile(destination)
}
/** Returns the tiles to which we can perform a swap move */
fun getUnitSwappableTiles(): Sequence<Tile> {
return getReachableTilesInCurrentTurn().filter { canUnitSwapToReachableTile(it) }
}
/**
* Returns whether we can perform a unit swap move to the specified tile, given that it is
* reachable in the current turn
*/
private fun canUnitSwapToReachableTile(reachableTile: Tile): Boolean {
// Air units cannot swap
if (unit.baseUnit.movesLikeAirUnits()) return false
// We can't swap with ourself
if (reachableTile == unit.getTile()) return false
if (unit.cache.cannotMove) return false
// Check whether the tile contains a unit of the same type as us that we own and that can
// also reach our tile in its current turn.
val otherUnit = (
if (unit.isCivilian())
reachableTile.civilianUnit
else
reachableTile.militaryUnit
) ?: return false
val ourPosition = unit.getTile()
if (otherUnit.owner != unit.owner
|| otherUnit.cache.cannotMove // redundant, line below would cover it too
|| !otherUnit.movement.canReachInCurrentTurn(ourPosition)) return false
if (!canMoveTo(reachableTile, canSwap = true)) return false
if (!otherUnit.movement.canMoveTo(ourPosition, canSwap = true)) return false
// All clear!
return true
}
/**
* Displace a unit - choose a viable tile close by if possible and 'teleport' the unit there.
* This will not use movement points or check for a possible route.
* It is used e.g. if an enemy city expands its borders, or trades or diplomacy change a unit's
* allowed position. Does not teleport transported units on their own, these are teleported when
* the transporting unit is moved.
* CAN DESTROY THE UNIT.
*/
fun teleportToClosestMoveableTile() {
if (unit.isTransported) return // handled when carrying unit is teleported
var allowedTile: Tile? = null
var distance = 0
// When we didn't limit the allowed distance the game would sometimes spend a whole minute looking for a suitable tile.
if (canPassThrough(unit.getTile())
&& !isCityCenterCannotEnter(unit.getTile()))
return // This unit can stay here - e.g. it has "May enter foreign tiles without open borders"
while (allowedTile == null && distance < 5) {
distance++
allowedTile = unit.getTile().getTilesAtDistance(distance)
// can the unit be placed safely there? Is tile either unowned or friendly?
.filter { canMoveTo(it) && it.getOwner()?.isAtWarWith(unit.civ) != true }
// out of those where it can be placed, can it reach them in any meaningful way?
.firstOrNull { getPathBetweenTiles(unit.currentTile, it).contains(it) }
}
// No tile within 4 spaces? move him to a city.
val origin = unit.getTile()
if (allowedTile == null)
allowedTile = unit.civ.cities.flatMap { it.getTiles() }
.sortedBy { it.aerialDistanceTo(origin) }.firstOrNull{ canMoveTo(it) }
if (allowedTile != null) {
unit.removeFromTile() // we "teleport" them away
unit.putInTile(allowedTile)
// Cancel sleep or fortification if forcibly displaced - for now, leave movement / auto / explore orders
if (unit.isSleeping() || unit.isFortified())
unit.action = null
unit.mostRecentMoveType = UnitMovementMemoryType.UnitTeleported
// bring along the payloads
val payloadUnits = origin.getUnits().filter { it.isTransported && unit.canTransport(it) }.toList()
for (payload in payloadUnits) {
payload.removeFromTile()
payload.putInTile(allowedTile)
payload.isTransported = true // restore the flag to not leave the payload in the city
payload.mostRecentMoveType = UnitMovementMemoryType.UnitTeleported
}
}
// it's possible that there is no close tile, and all the guy's cities are full.
// Nothing we can do.
else unit.destroy()
}
fun moveToTile(destination: Tile, considerZoneOfControl: Boolean = true) {
if (destination == unit.getTile() || unit.isDestroyed) return // already here (or dead)!
// Reset closestEnemy chache
if (unit.baseUnit.movesLikeAirUnits()) { // air units move differently from all other units
if (unit.action != UnitActionType.Automate.value) unit.action = null
unit.removeFromTile()
unit.isTransported = false // it has left the carrier by own means
unit.putInTile(destination)
unit.currentMovement = 0f
unit.mostRecentMoveType = UnitMovementMemoryType.UnitTeleported
return
}
if (unit.isPreparingParadrop()) { // paradropping units move differently
unit.action = null
unit.removeFromTile()
unit.putInTile(destination)
unit.mostRecentMoveType = UnitMovementMemoryType.UnitTeleported
unit.useMovementPoints(1f)
unit.attacksThisTurn += 1
// Check if unit maintenance changed
// Is also done for other units, but because we skip everything else, we have to manually check it
// The reason we skip everything, is that otherwise `getPathToTile()` throws an exception
// As we can not reach our destination in a single turn
if (unit.canGarrison()
&& (unit.getTile().isCityCenter() || destination.isCityCenter())
&& unit.civ.hasUnique(UniqueType.UnitsInCitiesNoMaintenance)
) unit.civ.updateStatsForNextTurn()
return
}
val distanceToTiles = getDistanceToTiles(considerZoneOfControl)
val pathToDestination = distanceToTiles.getPathToTile(destination)
val movableTiles = pathToDestination.takeWhile { canPassThrough(it) }
val lastReachableTile = movableTiles.lastOrNull { canMoveTo(it) }
?: return // no tiles can pass though/can move to
unit.mostRecentMoveType = UnitMovementMemoryType.UnitMoved
val pathToLastReachableTile = distanceToTiles.getPathToTile(lastReachableTile)
if (unit.isFortified() || unit.isSetUpForSiege() || unit.isSleeping())
unit.action = null // un-fortify/un-setup/un-sleep after moving
// If this unit is a carrier, keep record of its air payload whereabouts.
val origin = unit.getTile()
var needToFindNewRoute = false
// Cache this in case something goes wrong
var lastReachedEnterableTile = unit.getTile()
var previousTile = unit.getTile()
var passingMovementSpent = 0f // Movement points spent since last tile we could end our turn on
unit.removeFromTile()
for (tile in pathToLastReachableTile) {
if (!unit.movement.canPassThrough(tile)) {
// AAAH something happened making our previous path invalid
// Maybe we spawned a unit using ancient ruins, or our old route went through
// fog of war, and we found an obstacle halfway?
// Anyway: PANIC!! We stop this route, and after leaving the game in a valid state,
// we try again.
needToFindNewRoute = true
break // If you ever remove this break, remove the `assumeCanPassThrough` param below
}
unit.moveThroughTile(tile)
// This fixes a bug where tiles in the fog of war would always only cost 1 mp
if (!unit.civ.gameInfo.gameParameters.godMode)
passingMovementSpent += MovementCost.getMovementCostBetweenAdjacentTiles(unit, previousTile, tile)
// In case something goes wrong, cache the last tile we were able to end on
// We can assume we can pass through this tile, as we would have broken earlier
if (unit.movement.canMoveTo(tile, assumeCanPassThrough = true)) {
lastReachedEnterableTile = tile
unit.useMovementPoints(passingMovementSpent)
passingMovementSpent = 0f
}
previousTile = tile
// We can't continue, stop here.
if (unit.isDestroyed || unit.currentMovement - passingMovementSpent < Constants.minimumMovementEpsilon) {
break
}
}
val finalTileReached = lastReachedEnterableTile
// Silly floats which are almost zero
if (unit.currentMovement < Constants.minimumMovementEpsilon)
unit.currentMovement = 0f
if (!unit.isDestroyed)
unit.putInTile(finalTileReached)
// The .toList() here is because we have a sequence that's running on the units in the tile,
// then if we move one of the units we'll get a ConcurrentModificationException, se we save them all to a list
val payloadUnits = origin.getUnits().filter { it.isTransported && unit.canTransport(it) }.toList()
// bring along the payloads
for (payload in payloadUnits) {
payload.removeFromTile()
for (tile in pathToLastReachableTile) {
payload.moveThroughTile(tile)
if (tile == finalTileReached) break // this is the final tile the transport reached
}
payload.putInTile(finalTileReached)
payload.isTransported = true // restore the flag to not leave the payload in the city
payload.mostRecentMoveType = UnitMovementMemoryType.UnitMoved
}
// Unit maintenance changed
if (unit.canGarrison()
&& (origin.isCityCenter() || finalTileReached.isCityCenter())
&& unit.civ.hasUnique(UniqueType.UnitsInCitiesNoMaintenance)
) unit.civ.updateStatsForNextTurn()
// Under rare cases (see #8044), we can be headed to a tile and *the entire path* is blocked by other units, so we can't "enter" that tile.
// If, in such conditions, the *destination tile* is unenterable, needToFindNewRoute will trigger, so we need to catch this situation to avoid infinite loop
if (needToFindNewRoute && unit.currentTile != origin) {
moveToTile(destination, considerZoneOfControl)
}
unit.updateUniques()
}
/**
* Swaps this unit with the unit on the given tile
* Precondition: this unit can swap-move to the given tile, as determined by canUnitSwapTo
*/
fun swapMoveToTile(destination: Tile) {
val otherUnit = (
if (unit.isCivilian())
destination.civilianUnit
else
destination.militaryUnit
)?: return // The precondition guarantees that there is an eligible same-type unit at the destination
val ourOldPosition = unit.getTile()
val theirOldPosition = otherUnit.getTile()
val ourPayload = ourOldPosition.getUnits().filter { it.isTransported }.toList()
val theirPayload = theirOldPosition.getUnits().filter { it.isTransported }.toList()
// Swap the units
// Step 1: Release the destination tile
otherUnit.removeFromTile()
for (payload in theirPayload)
payload.removeFromTile()
// Step 2: Perform the movement
unit.movement.moveToTile(destination)
// Step 3: Release the newly taken tile
unit.removeFromTile()
for (payload in ourPayload)
payload.removeFromTile()
// Step 4: Restore the initial position after step 1
otherUnit.putInTile(theirOldPosition)
for (payload in theirPayload) {
payload.putInTile(theirOldPosition)
payload.isTransported = true // restore the flag to not leave the payload in the city
}
// Step 5: Perform the another movement
otherUnit.movement.moveToTile(ourOldPosition)
// Step 6: Restore the position in the new tile after step 3
unit.putInTile(theirOldPosition)
for (payload in ourPayload) {
payload.putInTile(theirOldPosition)
payload.isTransported = true // restore the flag to not leave the payload in the city
}
// Step 6: Update states
otherUnit.mostRecentMoveType = UnitMovementMemoryType.UnitMoved
unit.mostRecentMoveType = UnitMovementMemoryType.UnitMoved
}
private fun isCityCenterCannotEnter(tile: Tile) = tile.isCityCenter()
&& tile.getOwner() != unit.civ
&& !tile.getCity()!!.hasJustBeenConquered
/**
* Designates whether we can enter the tile - without attacking
* DOES NOT designate whether we can reach that tile in the current turn
*/
fun canMoveTo(tile: Tile, assumeCanPassThrough: Boolean = false, canSwap: Boolean = false): Boolean {
if (unit.baseUnit.movesLikeAirUnits())
return canAirUnitMoveTo(tile, unit)
if (!assumeCanPassThrough && !canPassThrough(tile))
return false
// even if they'll let us pass through, we can't enter their city - unless we just captured it
if (isCityCenterCannotEnter(tile))
return false
return if (unit.isCivilian())
(tile.civilianUnit == null || (canSwap && tile.civilianUnit!!.owner == unit.owner))
&& (tile.militaryUnit == null || tile.militaryUnit!!.owner == unit.owner)
else
// can skip checking for airUnit since not a city
(tile.militaryUnit == null || (canSwap && tile.militaryUnit!!.owner == unit.owner))
&& (tile.civilianUnit == null || tile.civilianUnit!!.owner == unit.owner || unit.civ.isAtWarWith(tile.civilianUnit!!.civ))
}
private fun canAirUnitMoveTo(tile: Tile, unit: MapUnit): Boolean {
// landing in the city
if (tile.isCityCenter()) {
if (tile.airUnits.filter { !it.isTransported }.size < 6 && tile.getCity()?.civ == unit.civ)
return true // if city is free - no problem, get in
} // let's check whether it enters city on carrier now...
if (tile.militaryUnit != null) {
val unitAtDestination = tile.militaryUnit!!
return unitAtDestination.canTransport(unit)
}
return false
}
// Can a paratrooper land at this tile?
private fun canParadropOn(destination: Tile): Boolean {
if (unit.cache.cannotMove) return false
// Can only move to land tiles within range that are visible and not impassible
// Based on some testing done in the base game
if (!destination.isLand || destination.isImpassible() || !unit.civ.viewableTiles.contains(destination)) return false
return true
}
/**
* @returns whether this unit can pass through [tile].
* Note that sometimes, a tile can be passed through but not entered. Use [canMoveTo] to
* determine whether a unit can enter a tile.
*
* This is the most called function in the entire game,
* so multiple callees of this function have been optimized,
* because optimization on this function results in massive benefits!
*/
fun canPassThrough(tile: Tile): Boolean {
if (tile.isImpassible()) {
// special exception - ice tiles are technically impassible, but some units can move through them anyway
// helicopters can pass through impassable tiles like mountains
if (!unit.cache.canPassThroughImpassableTiles && !(unit.cache.canEnterIceTiles && tile.terrainFeatures.contains(Constants.ice))
// carthage-like uniques sometimes allow passage through impassible tiles
&& !(unit.civ.passThroughImpassableUnlocked && unit.civ.passableImpassables.contains(tile.lastTerrain.name)))
return false
}
if (tile.isLand
&& unit.baseUnit.isWaterUnit()
&& !tile.isCityCenter())
return false
val unitSpecificAllowOcean: Boolean by lazy {
unit.civ.tech.specificUnitsCanEnterOcean &&
unit.civ.getMatchingUniques(UniqueType.UnitsMayEnterOcean)
.any { unit.matchesFilter(it.params[0]) }
}
if (tile.isWater && unit.baseUnit.isLandUnit() && !unit.cache.canMoveOnWater) {
if (!unit.civ.tech.unitsCanEmbark) return false
if (tile.isOcean && !unit.civ.tech.embarkedUnitsCanEnterOcean && !unitSpecificAllowOcean)
return false
}
if (tile.isOcean && !unit.civ.tech.allUnitsCanEnterOcean) { // Apparently all Polynesian naval units can enter oceans
if (!unitSpecificAllowOcean && unit.cache.cannotEnterOceanTiles) return false
}
if (unit.hasUnique(UniqueType.CanTradeWithCityStateForGoldAndInfluence) && tile.getOwner()?.isCityState() == true)
return true
if (!unit.cache.canEnterForeignTerrain && !tile.canCivPassThrough(unit.civ)) return false
// The first unit is:
// 1. Either military unit
// 2. or unprotected civilian
// 3. or unprotected air unit while no civilians on tile
val firstUnit = tile.getFirstUnit()
// Moving to non-empty tile
if (firstUnit != null && unit.civ != firstUnit.civ) {
// Allow movement through unguarded, at-war Civilian Unit. Capture on the way
// But not for Embarked Units capturing on Water
if (!(unit.baseUnit.isLandUnit() && tile.isWater && !unit.cache.canMoveOnWater)
&& firstUnit.isCivilian() && unit.civ.isAtWarWith(firstUnit.civ))
return true
// Cannot enter hostile tile with any unit in there
if (unit.civ.isAtWarWith(firstUnit.civ))
return false
}
return true
}
fun getDistanceToTiles(
considerZoneOfControl: Boolean = true,
passThroughCache: HashMap<Tile, Boolean> = HashMap(),
movementCostCache: HashMap<Pair<Tile, Tile>, Float> = HashMap())
: PathsToTilesWithinTurn {
val cacheResults = pathfindingCache.getDistanceToTiles(considerZoneOfControl)
if (cacheResults != null) {
return cacheResults
}
val distanceToTiles = getDistanceToTilesWithinTurn(
unit.currentTile.position,
unit.currentMovement,
considerZoneOfControl,
null,
passThroughCache,
movementCostCache
)
pathfindingCache.setDistanceToTiles(considerZoneOfControl, distanceToTiles)
return distanceToTiles
}
fun getAerialPathsToCities(): HashMap<Tile, ArrayList<Tile>> {
var tilesToCheck = ArrayList<Tile>()
/** each tile reached points to its parent tile, where we got to it from */
val tilesReached = HashMap<Tile, Tile>()
val startingTile = unit.currentTile
tilesToCheck.add(startingTile)
tilesReached[startingTile] = startingTile
while (tilesToCheck.isNotEmpty()) {
val newTilesToCheck = ArrayList<Tile>()
for (currentTileToCheck in tilesToCheck) {
val reachableTiles = currentTileToCheck.getTilesInDistance(unit.getRange())
.filter { unit.movement.canMoveTo(it) }
for (reachableTile in reachableTiles) {
if (tilesReached.containsKey(reachableTile)) continue
tilesReached[reachableTile] = currentTileToCheck
newTilesToCheck.add(reachableTile)
}
}
tilesToCheck = newTilesToCheck
}
val pathsToCities = HashMap<Tile, ArrayList<Tile>>()
for (city in tilesReached.keys) {
val path = ArrayList<Tile>()
var currentCity = city
while (currentCity != startingTile) { // we don't add the "starting tile" to the arraylist
path.add(currentCity)
currentCity = tilesReached[currentCity]!! // go to parent
}
path.reverse()
pathsToCities[city] = path
}
pathsToCities.remove(startingTile)
return pathsToCities
}
/**
* @returns the set of [Tile] between [from] and [to] tiles.
* It takes into account the terrain and units possibilities of entering the terrain,
* however ignores the diplomatic aspects of such movement like crossing closed borders.
*/
private fun getPathBetweenTiles(from: Tile, to: Tile): MutableSet<Tile> {
val tmp = unit.cache.canEnterForeignTerrain
unit.cache.canEnterForeignTerrain = true // the trick to ignore tiles owners
val bfs = BFS(from) { canPassThrough(it) }
bfs.stepUntilDestination(to)
unit.cache.canEnterForeignTerrain = tmp
return bfs.getReachedTiles()
}
fun clearPathfindingCache() = pathfindingCache.clear()
}
/**
* Cache for the results of [UnitMovement.getDistanceToTiles] accounting for zone of control.
* [UnitMovement.getDistanceToTiles] is called in numerous places for AI pathfinding so
* being able to skip redundant calculations helps out over a long game (especially with high level
* AI or a big map). Same thing with [UnitMovement.getShortestPath] which is called in
* [UnitMovement.canReach] and in [UnitMovement.headTowards]. Often, the AI will
* see if it can reach a tile using canReach then if it can, it will headTowards it. We can cache
* the result since otherwise this is a redundant calculation that will find the same path.
*/
class PathfindingCache(private val unit: MapUnit) {
private var shortestPathCache = listOf<Tile>()
private var destination: Tile? = null
private val distanceToTilesCache = mutableMapOf<Boolean, PathsToTilesWithinTurn>()
private var movement = -1f
private var currentTile: Tile? = null
/** Check if the caches are valid (only checking if the unit has moved or consumed movement points;
* the isPlayerCivilization check is performed in the functions because we want isValid() == false
* to have a specific behavior) */
private fun isValid(): Boolean = (movement == unit.currentMovement) && (unit.getTile() == currentTile)
fun getShortestPathCache(destination: Tile): List<Tile> {
if (unit.civ.isHuman()) return listOf()
if (isValid() && this.destination == destination) {
return shortestPathCache
}
return listOf()
}
fun setShortestPathCache(destination: Tile, newShortestPath: List<Tile>) {
if (unit.civ.isHuman()) return
if (isValid()) {
shortestPathCache = newShortestPath
this.destination = destination
}
}
fun getDistanceToTiles(zoneOfControl: Boolean): PathsToTilesWithinTurn? {
if (unit.civ.isHuman()) return null
if (isValid())
return distanceToTilesCache[zoneOfControl]
return null
}
fun setDistanceToTiles(zoneOfControl: Boolean, paths: PathsToTilesWithinTurn) {
if (unit.civ.isHuman()) return
if (!isValid()) {
clear() // we want to reset the entire cache at this point
}
distanceToTilesCache[zoneOfControl] = paths
}
fun clear() {
distanceToTilesCache.clear()
movement = unit.currentMovement
currentTile = unit.getTile()
destination = null
shortestPathCache = listOf()
}
}
class PathsToTilesWithinTurn : LinkedHashMap<Tile, UnitMovement.ParentTileAndTotalDistance>() {
fun getPathToTile(tile: Tile): List<Tile> {
if (!containsKey(tile))
throw Exception("Can't reach this tile!")
val reversePathList = ArrayList<Tile>()
var currentTile = tile
while (get(currentTile)!!.parentTile != currentTile) {
reversePathList.add(currentTile)
currentTile = get(currentTile)!!.parentTile
}
return reversePathList.reversed()
}
}