mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
Test cases: 1. Target is not threatened. The IP should be placed on a direct heading from the origin to the target at the max ingress distance, or very near the origin airfield if the airfield is closer to the target than the IP distance. 2. Unthreatened home zone, max IP between origin and target, safe locations available for IP. The IP should be placed in LAR at the closest point to home. 3. Unthreatened home zone, origin within LAR, safe locations available for IP. The IP should be placed near the origin airfield to prevent backtracking more than needed. 4. Unthreatened home zone, origin entirely nearer the target than LAR, safe locations available for IP. The IP should be placed in LAR as close as possible to the origin. 5. Threatened home zone, safe locations available for IP. The IP should be placed in LAR as close as possible to the origin. 6. No safe IP. The IP should be placed in LAR at the point nearest the threat boundary.
1076 lines
29 KiB
JavaScript
1076 lines
29 KiB
JavaScript
// Won't actually enable anything unless the same property is set in
|
|
// mapmodel.py.
|
|
const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
|
|
|
|
const Colors = Object.freeze({
|
|
Blue: "#0084ff",
|
|
Red: "#c85050",
|
|
Green: "#80BA80",
|
|
Highlight: "#ffff00",
|
|
});
|
|
|
|
const Categories = Object.freeze([
|
|
"aa",
|
|
"allycamp",
|
|
"ammo",
|
|
"armor",
|
|
"coastal",
|
|
"comms",
|
|
"derrick",
|
|
"ewr",
|
|
"factory",
|
|
"farp",
|
|
"fuel",
|
|
"missile",
|
|
"oil",
|
|
"power",
|
|
"ship",
|
|
"village",
|
|
"ware",
|
|
"ww2bunker",
|
|
]);
|
|
|
|
const UnitState = Object.freeze({
|
|
Alive: "alive",
|
|
Damaged: "damaged",
|
|
Destroyed: "destroyed",
|
|
});
|
|
|
|
class CpIcons {
|
|
constructor() {
|
|
this.icons = {};
|
|
for (const player of [true, false]) {
|
|
this.icons[player] = {};
|
|
for (const state of Object.values(UnitState)) {
|
|
this.icons[player][state] = {
|
|
airfield: this.loadIcon("airfield", player, state),
|
|
cv: this.loadIcon("cv", player, state),
|
|
fob: this.loadIcon("fob", player, state),
|
|
lha: this.loadIcon("lha", player, state),
|
|
offmap: this.loadIcon("airfield", player, state),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
icon(category, player, state) {
|
|
return this.icons[player][state][category];
|
|
}
|
|
|
|
loadIcon(category, player, state) {
|
|
const color = player ? "blue" : "red";
|
|
return new L.Icon({
|
|
iconUrl: `../ground_assets/${category}_${color}_${state}.svg`,
|
|
iconSize: [32, 32],
|
|
});
|
|
}
|
|
}
|
|
|
|
class TgoIcons {
|
|
constructor() {
|
|
this.icons = {};
|
|
for (const category of Categories) {
|
|
this.icons[category] = {};
|
|
for (const player of [true, false]) {
|
|
this.icons[category][player] = {};
|
|
for (const state of Object.values(UnitState)) {
|
|
this.icons[category][player][state] = this.loadIcon(
|
|
category,
|
|
player,
|
|
state
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
icon(category, player, state) {
|
|
return this.icons[category][player][state];
|
|
}
|
|
|
|
loadIcon(category, player, state) {
|
|
const color = player ? "blue" : "red";
|
|
return new L.Icon({
|
|
iconUrl: `../ground_assets/${category}_${color}_${state}.svg`,
|
|
iconSize: [32, 32],
|
|
});
|
|
}
|
|
|
|
loadLegacyIcon(category, player) {
|
|
const playerSuffix = player ? "_blue" : "";
|
|
return new L.Icon({
|
|
iconUrl: `../ground_assets/${category}${playerSuffix}.png`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const Icons = Object.freeze({
|
|
ControlPoints: new CpIcons(),
|
|
Objectives: new TgoIcons(),
|
|
});
|
|
|
|
function metersToNauticalMiles(meters) {
|
|
return meters * 0.000539957;
|
|
}
|
|
|
|
function formatLatLng(latLng) {
|
|
const lat = latLng.lat.toFixed(2);
|
|
const lng = latLng.lng.toFixed(2);
|
|
const ns = lat >= 0 ? "N" : "S";
|
|
const ew = lng >= 0 ? "E" : "W";
|
|
return `${lat}°${ns} ${lng}°${ew}`;
|
|
}
|
|
|
|
const map = L.map("map", {
|
|
doubleClickZoom: false,
|
|
zoomControl: false,
|
|
}).setView([0, 0], 3);
|
|
L.control.scale({ maxWidth: 200 }).addTo(map);
|
|
|
|
const rulerOptions = {
|
|
position: "topleft",
|
|
circleMarker: {
|
|
color: Colors.Highlight,
|
|
radius: 2,
|
|
},
|
|
lineStyle: {
|
|
color: Colors.Highlight,
|
|
dashArray: "1,6",
|
|
},
|
|
lengthUnit: {
|
|
display: "nm",
|
|
decimal: "2",
|
|
factor: 0.539956803,
|
|
label: "Distance:",
|
|
},
|
|
angleUnit: {
|
|
display: "°",
|
|
decimal: 0,
|
|
label: "Bearing:",
|
|
},
|
|
};
|
|
L.control.ruler(rulerOptions).addTo(map);
|
|
|
|
// https://esri.github.io/esri-leaflet/api-reference/layers/basemap-layer.html
|
|
const baseLayers = {
|
|
"Imagery Clarity": L.esri.basemapLayer("ImageryClarity", { maxZoom: 17 }),
|
|
"Imagery Firefly": L.esri.basemapLayer("ImageryFirefly", { maxZoom: 17 }),
|
|
Topographic: L.esri.basemapLayer("Topographic", { maxZoom: 16 }),
|
|
};
|
|
|
|
const defaultBaseMap = baseLayers["Imagery Clarity"];
|
|
defaultBaseMap.addTo(map);
|
|
|
|
// Enabled by default, so addTo(map).
|
|
const controlPointsLayer = L.layerGroup().addTo(map);
|
|
const airDefensesLayer = L.layerGroup().addTo(map);
|
|
const factoriesLayer = L.layerGroup().addTo(map);
|
|
const shipsLayer = L.layerGroup().addTo(map);
|
|
const groundObjectsLayer = L.layerGroup().addTo(map);
|
|
const supplyRoutesLayer = L.layerGroup().addTo(map);
|
|
const frontLinesLayer = L.layerGroup().addTo(map);
|
|
const redSamThreatLayer = L.layerGroup().addTo(map);
|
|
const blueFlightPlansLayer = L.layerGroup().addTo(map);
|
|
|
|
// Added to map by the user via layer controls.
|
|
const blueSamThreatLayer = L.layerGroup();
|
|
const blueSamDetectionLayer = L.layerGroup();
|
|
const redSamDetectionLayer = L.layerGroup();
|
|
const redFlightPlansLayer = L.layerGroup();
|
|
const selectedFlightPlansLayer = L.layerGroup();
|
|
const allFlightPlansLayer = L.layerGroup();
|
|
|
|
const blueFullThreatZones = L.layerGroup();
|
|
const blueAircraftThreatZones = L.layerGroup();
|
|
const blueAirDefenseThreatZones = L.layerGroup();
|
|
const blueRadarSamThreatZones = L.layerGroup();
|
|
|
|
const redFullThreatZones = L.layerGroup();
|
|
const redAircraftThreatZones = L.layerGroup();
|
|
const redAirDefenseThreatZones = L.layerGroup();
|
|
const redRadarSamThreatZones = L.layerGroup();
|
|
|
|
const blueNavmesh = L.layerGroup();
|
|
const redNavmesh = L.layerGroup();
|
|
|
|
const inclusionZones = L.layerGroup();
|
|
const exclusionZones = L.layerGroup();
|
|
const seaZones = L.layerGroup();
|
|
const unculledZones = L.layerGroup();
|
|
|
|
const homeBubble = L.layerGroup();
|
|
const ipBubble = L.layerGroup();
|
|
const permissibleZone = L.layerGroup();
|
|
const safeZone = L.layerGroup();
|
|
|
|
const debugControlGroups = {
|
|
"Blue Threat Zones": {
|
|
Hide: L.layerGroup().addTo(map),
|
|
Full: blueFullThreatZones,
|
|
Aircraft: blueAircraftThreatZones,
|
|
"Air Defenses": blueAirDefenseThreatZones,
|
|
"Radar SAMs": blueRadarSamThreatZones,
|
|
},
|
|
"Red Threat Zones": {
|
|
Hide: L.layerGroup().addTo(map),
|
|
Full: redFullThreatZones,
|
|
Aircraft: redAircraftThreatZones,
|
|
"Air Defenses": redAirDefenseThreatZones,
|
|
"Radar SAMs": redRadarSamThreatZones,
|
|
},
|
|
Navmeshes: {
|
|
Hide: L.layerGroup().addTo(map),
|
|
Blue: blueNavmesh,
|
|
Red: redNavmesh,
|
|
},
|
|
"Map Zones": {
|
|
"Inclusion zones": inclusionZones,
|
|
"Exclusion zones": exclusionZones,
|
|
"Sea zones": seaZones,
|
|
"Culling exclusion zones": unculledZones,
|
|
},
|
|
};
|
|
|
|
if (ENABLE_EXPENSIVE_DEBUG_TOOLS) {
|
|
debugControlGroups["IP Zones"] = {
|
|
"Home bubble": homeBubble,
|
|
"IP bubble": ipBubble,
|
|
"Permissible zone": permissibleZone,
|
|
"Safe zone": safeZone,
|
|
};
|
|
}
|
|
|
|
// Main map controls. These are the ones that we expect users to interact with.
|
|
// These are always open, which unfortunately means that the scroll bar will not
|
|
// appear if the menu doesn't fit. This fits in the smallest window size we
|
|
// allow, but may need to start auto-collapsing it (or fix the plugin to add a
|
|
// scrollbar when non-collapsing) if it gets much larger.
|
|
L.control
|
|
.groupedLayers(
|
|
baseLayers,
|
|
{
|
|
"Points of Interest": {
|
|
"Control points": controlPointsLayer,
|
|
"Air defenses": airDefensesLayer,
|
|
Factories: factoriesLayer,
|
|
Ships: shipsLayer,
|
|
"Other ground objects": groundObjectsLayer,
|
|
"Supply routes": supplyRoutesLayer,
|
|
"Front lines": frontLinesLayer,
|
|
},
|
|
"Enemy Air Defenses": {
|
|
"Enemy SAM threat range": redSamThreatLayer,
|
|
"Enemy SAM detection range": redSamDetectionLayer,
|
|
},
|
|
"Allied Air Defenses": {
|
|
"Ally SAM threat range": blueSamThreatLayer,
|
|
"Ally SAM detection range": blueSamDetectionLayer,
|
|
},
|
|
"Flight Plans": {
|
|
Hide: L.layerGroup(),
|
|
"Show selected blue": selectedFlightPlansLayer,
|
|
"Show all blue": blueFlightPlansLayer,
|
|
"Show all red": redFlightPlansLayer,
|
|
"Show all": allFlightPlansLayer,
|
|
},
|
|
},
|
|
{
|
|
collapsed: false,
|
|
exclusiveGroups: ["Flight Plans"],
|
|
groupCheckboxes: true,
|
|
}
|
|
)
|
|
.addTo(map);
|
|
|
|
// Debug map controls. Hover over to open. Not something most users will want or
|
|
// need to interact with.
|
|
L.control
|
|
.groupedLayers(null, debugControlGroups, {
|
|
position: "topleft",
|
|
exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"],
|
|
groupCheckboxes: true,
|
|
})
|
|
.addTo(map);
|
|
|
|
let game;
|
|
new QWebChannel(qt.webChannelTransport, function (channel) {
|
|
game = channel.objects.game;
|
|
drawInitialMap();
|
|
game.cleared.connect(clearAllLayers);
|
|
game.mapCenterChanged.connect(recenterMap);
|
|
game.controlPointsChanged.connect(drawControlPoints);
|
|
game.groundObjectsChanged.connect(drawGroundObjects);
|
|
game.supplyRoutesChanged.connect(drawSupplyRoutes);
|
|
game.frontLinesChanged.connect(drawFrontLines);
|
|
game.flightsChanged.connect(drawFlightPlans);
|
|
game.threatZonesChanged.connect(drawThreatZones);
|
|
game.navmeshesChanged.connect(drawNavmeshes);
|
|
game.mapZonesChanged.connect(drawMapZones);
|
|
game.unculledZonesChanged.connect(drawUnculledZones);
|
|
game.ipZonesChanged.connect(drawIpZones);
|
|
});
|
|
|
|
function recenterMap(center) {
|
|
map.setView(center, 8, { animate: true, duration: 1 });
|
|
}
|
|
|
|
class ControlPoint {
|
|
constructor(cp) {
|
|
this.cp = cp;
|
|
// The behavior we want is for the CP to be draggable when it has no
|
|
// destination, but for the destination to be draggable when it does. The
|
|
// primary marker is always shown and draggable. When a destination exists,
|
|
// the primary marker marks the destination and the secondary marker marks
|
|
// the location. When no destination exists, the primary marker marks the
|
|
// location and the secondary marker is not shown.
|
|
this.primaryMarker = this.makePrimaryMarker();
|
|
this.secondaryMarker = this.makeSecondaryMarker();
|
|
this.path = this.makePath();
|
|
this.attachTooltipsAndHandlers();
|
|
this.cp.destinationChanged.connect(() => this.onDestinationChanged());
|
|
}
|
|
|
|
icon() {
|
|
return Icons.ControlPoints.icon(
|
|
this.cp.category,
|
|
this.cp.blue,
|
|
this.cp.status
|
|
);
|
|
}
|
|
|
|
hasDestination() {
|
|
return this.cp.destination.length > 0;
|
|
}
|
|
|
|
hideDestination() {
|
|
this.secondaryMarker.removeFrom(controlPointsLayer);
|
|
this.path.removeFrom(controlPointsLayer);
|
|
}
|
|
|
|
setDestination(destination) {
|
|
this.cp.setDestination([destination.lat, destination.lng]).then((err) => {
|
|
if (err) {
|
|
console.log(`Could not set control point destination: ${err}`);
|
|
this.locationMarker().bindPopup(err).openPopup();
|
|
// Reset markers and paths on error. On success this happens when we get
|
|
// the destinationChanged signal from the backend.
|
|
this.onDestinationChanged();
|
|
}
|
|
});
|
|
}
|
|
|
|
onDrag(destination) {
|
|
this.path.setLatLngs([this.cp.position, destination]);
|
|
this.path.addTo(controlPointsLayer);
|
|
const distance = metersToNauticalMiles(
|
|
destination.distanceTo(this.cp.position)
|
|
);
|
|
this.primaryMarker.unbindTooltip();
|
|
this.primaryMarker.bindTooltip(
|
|
`Move ${distance.toFixed(1)}nm to ${formatLatLng(destination)}`,
|
|
{
|
|
permanent: true,
|
|
}
|
|
);
|
|
this.cp
|
|
.destinationInRange([destination.lat, destination.lng])
|
|
.then((inRange) => {
|
|
this.path.setStyle({
|
|
color: inRange ? Colors.Green : Colors.Red,
|
|
});
|
|
});
|
|
}
|
|
|
|
detachTooltipsAndHandlers() {
|
|
this.primaryMarker.unbindTooltip();
|
|
this.primaryMarker.off("click");
|
|
this.primaryMarker.off("contextmenu");
|
|
this.secondaryMarker.unbindTooltip();
|
|
this.secondaryMarker.off("click");
|
|
this.secondaryMarker.off("contextmenu");
|
|
}
|
|
|
|
locationMarker(dragging = false) {
|
|
return this.hasDestination() || dragging
|
|
? this.secondaryMarker
|
|
: this.primaryMarker;
|
|
}
|
|
|
|
destinationMarker() {
|
|
return this.hasDestination() ? this.primaryMarker : null;
|
|
}
|
|
|
|
attachTooltipsAndHandlers(dragging = false) {
|
|
this.detachTooltipsAndHandlers();
|
|
const locationMarker = this.locationMarker(dragging);
|
|
const destinationMarker = this.destinationMarker();
|
|
locationMarker
|
|
.bindTooltip(`<h3 style="margin: 0;">${this.cp.name}</h3>`)
|
|
.on("click", () => {
|
|
this.cp.showInfoDialog();
|
|
})
|
|
.on("contextmenu", () => {
|
|
this.cp.showPackageDialog();
|
|
});
|
|
if (destinationMarker != null) {
|
|
const origin = locationMarker.getLatLng();
|
|
const destination = destinationMarker.getLatLng();
|
|
const distance = metersToNauticalMiles(
|
|
destination.distanceTo(origin)
|
|
).toFixed(1);
|
|
const dest = formatLatLng(destination);
|
|
destinationMarker.bindTooltip(
|
|
`${this.cp.name} moving ${distance}nm to ${dest} next turn`
|
|
);
|
|
destinationMarker.on("contextmenu", () => this.cp.cancelTravel());
|
|
destinationMarker.addTo(map);
|
|
}
|
|
}
|
|
|
|
makePrimaryMarker() {
|
|
const location = this.hasDestination()
|
|
? this.cp.destination
|
|
: this.cp.position;
|
|
// We might draw other markers on top of the CP. The tooltips from the other
|
|
// markers are helpful so we want to keep them, but make sure the CP is
|
|
// always the clickable thing.
|
|
return L.marker(location, {
|
|
icon: this.icon(),
|
|
zIndexOffset: 1000,
|
|
draggable: this.cp.mobile,
|
|
autoPan: true,
|
|
})
|
|
.on("dragstart", () => {
|
|
this.secondaryMarker.addTo(controlPointsLayer);
|
|
this.attachTooltipsAndHandlers(true);
|
|
})
|
|
.on("drag", (event) => {
|
|
const marker = event.target;
|
|
const newPosition = marker.getLatLng();
|
|
this.onDrag(newPosition);
|
|
})
|
|
.on("dragend", (event) => {
|
|
const marker = event.target;
|
|
const newPosition = marker.getLatLng();
|
|
this.setDestination(newPosition);
|
|
})
|
|
.addTo(map);
|
|
}
|
|
|
|
makeSecondaryMarker() {
|
|
return L.marker(this.cp.position, {
|
|
icon: this.icon(),
|
|
zIndexOffset: 1000,
|
|
});
|
|
}
|
|
|
|
makePath() {
|
|
const destination = this.hasDestination() ? this.cp.destination : [0, 0];
|
|
return L.polyline([this.cp.position, destination], {
|
|
color: Colors.Green,
|
|
weight: 1,
|
|
interactive: false,
|
|
});
|
|
}
|
|
|
|
onDestinationChanged() {
|
|
if (this.hasDestination()) {
|
|
this.primaryMarker.setLatLng(this.cp.destination);
|
|
this.primaryMarker.setOpacity(0.5);
|
|
this.secondaryMarker.addTo(controlPointsLayer);
|
|
this.path.setLatLngs([this.cp.position, this.cp.destination]);
|
|
this.path.addTo(controlPointsLayer);
|
|
this.path.setStyle({ color: Colors.Green });
|
|
} else {
|
|
this.hideDestination();
|
|
this.primaryMarker.setLatLng(this.cp.position);
|
|
this.primaryMarker.setOpacity(1);
|
|
}
|
|
this.attachTooltipsAndHandlers();
|
|
}
|
|
|
|
drawDestination() {
|
|
this.secondaryMarker.addTo(controlPointsLayer);
|
|
this.path.addTo(controlPointsLayer);
|
|
}
|
|
|
|
draw() {
|
|
this.primaryMarker.addTo(controlPointsLayer);
|
|
if (this.hasDestination()) {
|
|
this.drawDestination();
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawControlPoints() {
|
|
controlPointsLayer.clearLayers();
|
|
game.controlPoints.forEach((cp) => {
|
|
new ControlPoint(cp).draw();
|
|
});
|
|
}
|
|
|
|
class TheaterGroundObject {
|
|
constructor(tgo) {
|
|
this.tgo = tgo;
|
|
}
|
|
|
|
samIsThreat() {
|
|
for (const range of this.tgo.samThreatRanges) {
|
|
if (range > 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
icon() {
|
|
let state;
|
|
if (this.tgo.dead) {
|
|
state = UnitState.Destroyed;
|
|
} else if (this.tgo.category == "aa" && !this.samIsThreat()) {
|
|
state = UnitState.Damaged;
|
|
} else {
|
|
state = UnitState.Alive;
|
|
}
|
|
return Icons.Objectives.icon(this.tgo.category, this.tgo.blue, state);
|
|
}
|
|
|
|
layer() {
|
|
switch (this.tgo.category) {
|
|
case "aa":
|
|
return airDefensesLayer;
|
|
case "factory":
|
|
return factoriesLayer;
|
|
case "ship":
|
|
return shipsLayer;
|
|
default:
|
|
return groundObjectsLayer;
|
|
}
|
|
}
|
|
|
|
drawSamThreats() {
|
|
const detectionLayer = this.tgo.blue
|
|
? blueSamDetectionLayer
|
|
: redSamDetectionLayer;
|
|
const threatLayer = this.tgo.blue ? blueSamThreatLayer : redSamThreatLayer;
|
|
const threatColor = this.tgo.blue ? Colors.Blue : Colors.Red;
|
|
const detectionColor = this.tgo.blue ? "#bb89ff" : "#eee17b";
|
|
|
|
this.tgo.samDetectionRanges.forEach((range) => {
|
|
L.circle(this.tgo.position, {
|
|
radius: range,
|
|
color: detectionColor,
|
|
fill: false,
|
|
weight: 1,
|
|
interactive: false,
|
|
}).addTo(detectionLayer);
|
|
});
|
|
|
|
this.tgo.samThreatRanges.forEach((range) => {
|
|
L.circle(this.tgo.position, {
|
|
radius: range,
|
|
color: threatColor,
|
|
fill: false,
|
|
weight: 2,
|
|
interactive: false,
|
|
}).addTo(threatLayer);
|
|
});
|
|
}
|
|
|
|
draw() {
|
|
if (!this.tgo.blue && this.tgo.dead) {
|
|
// Don't bother drawing dead opfor TGOs. Blue is worth showing because
|
|
// some of them can be repaired, but the player can't interact with dead
|
|
// red things so there's no point in showing them.
|
|
return;
|
|
}
|
|
|
|
L.marker(this.tgo.position, { icon: this.icon() })
|
|
.bindTooltip(
|
|
`${this.tgo.name} (${
|
|
this.tgo.controlPointName
|
|
})<br />${this.tgo.units.join("<br />")}`
|
|
)
|
|
.on("click", () => this.tgo.showInfoDialog())
|
|
.on("contextmenu", () => this.tgo.showPackageDialog())
|
|
.addTo(this.layer());
|
|
this.drawSamThreats();
|
|
}
|
|
}
|
|
|
|
function drawGroundObjects() {
|
|
airDefensesLayer.clearLayers();
|
|
factoriesLayer.clearLayers();
|
|
shipsLayer.clearLayers();
|
|
groundObjectsLayer.clearLayers();
|
|
blueSamDetectionLayer.clearLayers();
|
|
redSamDetectionLayer.clearLayers();
|
|
blueSamThreatLayer.clearLayers();
|
|
redSamThreatLayer.clearLayers();
|
|
game.groundObjects.forEach((tgo) => {
|
|
new TheaterGroundObject(tgo).draw();
|
|
});
|
|
}
|
|
|
|
function drawSupplyRoutes() {
|
|
supplyRoutesLayer.clearLayers();
|
|
game.supplyRoutes.forEach((route) => {
|
|
let color;
|
|
if (route.frontActive) {
|
|
color = Colors.Red;
|
|
} else if (route.blue) {
|
|
color = "#2d3e50";
|
|
} else {
|
|
color = "#8c1414";
|
|
}
|
|
const line = L.polyline(route.points, {
|
|
color: color,
|
|
weight: route.isSea ? 4 : 6,
|
|
}).addTo(supplyRoutesLayer);
|
|
const activeTransports = route.activeTransports;
|
|
if (activeTransports.length > 0) {
|
|
line.bindTooltip(activeTransports.join("<br />"));
|
|
L.polyline(route.points, {
|
|
color: "#ffffff",
|
|
weight: 2,
|
|
}).addTo(supplyRoutesLayer);
|
|
} else {
|
|
line.bindTooltip("This supply route is inactive.");
|
|
}
|
|
});
|
|
}
|
|
|
|
function drawFrontLines() {
|
|
frontLinesLayer.clearLayers();
|
|
game.frontLines.forEach((front) => {
|
|
L.polyline(front.extents, { weight: 8, color: "#fe7d0a" })
|
|
.on("contextmenu", function () {
|
|
front.showPackageDialog();
|
|
})
|
|
.addTo(frontLinesLayer);
|
|
});
|
|
}
|
|
|
|
const SHOW_WAYPOINT_INFO_AT_ZOOM = 9;
|
|
|
|
class Waypoint {
|
|
constructor(waypoint, flight) {
|
|
this.waypoint = waypoint;
|
|
this.flight = flight;
|
|
this.marker = this.makeMarker();
|
|
this.waypoint.positionChanged.connect(() => this.relocate());
|
|
this.waypoint.timingChanged.connect(() => this.updateDescription());
|
|
}
|
|
|
|
position() {
|
|
return this.waypoint.position;
|
|
}
|
|
|
|
shouldMark() {
|
|
// We don't need a marker for the departure waypoint (and it's likely
|
|
// coincident with the landing waypoint, so hard to see). We do want to draw
|
|
// the path from it though.
|
|
//
|
|
// We also don't need the landing waypoint since we'll be drawing that path
|
|
// as well and it's clear what it is, and only obscured the CP icon.
|
|
//
|
|
// The divert waypoint also obscures the CP. We don't draw the path to it,
|
|
// but it can be seen in the flight settings page so it's not really a
|
|
// problem to exclude it.
|
|
//
|
|
// Bullseye ought to be (but currently isn't) drawn *once* rather than as a
|
|
// flight waypoint.
|
|
return !(
|
|
this.waypoint.isTakeoff ||
|
|
this.waypoint.isLanding ||
|
|
this.waypoint.isDivert ||
|
|
this.waypoint.isBullseye
|
|
);
|
|
}
|
|
|
|
draggable() {
|
|
// Target *points* are the exact location of a unit, whereas the target area
|
|
// is only the center of the objective. Allow moving the latter since its
|
|
// exact location isn't very important.
|
|
//
|
|
// Landing, and divert should be changed in the flight settings UI, takeoff
|
|
// cannot be changed because that's where the plane is.
|
|
//
|
|
// Moving the bullseye reference only makes it wrong.
|
|
return !(
|
|
this.waypoint.isTargetPoint ||
|
|
this.waypoint.isTakeoff ||
|
|
this.waypoint.isLanding ||
|
|
this.waypoint.isDivert ||
|
|
this.waypoint.isBullseye
|
|
);
|
|
}
|
|
|
|
description(dragging) {
|
|
const timing = dragging
|
|
? "Waiting to recompute TOT..."
|
|
: this.waypoint.timing;
|
|
return (
|
|
`${this.waypoint.number} ${this.waypoint.name}<br />` +
|
|
`${this.waypoint.altitudeFt} ft ${this.waypoint.altitudeReference}<br />` +
|
|
`${timing}`
|
|
);
|
|
}
|
|
|
|
relocate() {
|
|
this.marker.setLatLng(this.waypoint.position);
|
|
}
|
|
|
|
updateDescription(dragging) {
|
|
this.marker.setTooltipContent(this.description(dragging));
|
|
}
|
|
|
|
makeMarker() {
|
|
const zoom = map.getZoom();
|
|
return L.marker(this.waypoint.position, { draggable: this.draggable() })
|
|
.bindTooltip(this.description(), {
|
|
permanent: zoom >= SHOW_WAYPOINT_INFO_AT_ZOOM,
|
|
})
|
|
.on("dragstart", (e) => {
|
|
this.updateDescription(true);
|
|
})
|
|
.on("drag", (e) => {
|
|
const marker = e.target;
|
|
const destination = marker.getLatLng();
|
|
this.flight.updatePath(this.waypoint.number, destination);
|
|
})
|
|
.on("dragend", (e) => {
|
|
const marker = e.target;
|
|
const destination = marker.getLatLng();
|
|
this.waypoint
|
|
.setPosition([destination.lat, destination.lng])
|
|
.then((err) => {
|
|
if (err) {
|
|
console.log(err);
|
|
marker.bindPopup(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
includeInPath() {
|
|
return !this.waypoint.isDivert && !this.waypoint.isBullseye;
|
|
}
|
|
}
|
|
|
|
class Flight {
|
|
constructor(flight) {
|
|
this.flight = flight;
|
|
this.flightPlan = this.flight.flightPlan.map((p) => new Waypoint(p, this));
|
|
this.path = null;
|
|
this.commitBoundary = null;
|
|
this.flight.flightPlanChanged.connect(() => this.draw());
|
|
this.flight.commitBoundaryChanged.connect(() => this.drawCommitBoundary());
|
|
}
|
|
|
|
shouldMark(waypoint) {
|
|
return this.flight.selected && waypoint.shouldMark();
|
|
}
|
|
|
|
flightPlanLayer() {
|
|
return this.flight.blue ? blueFlightPlansLayer : redFlightPlansLayer;
|
|
}
|
|
|
|
updatePath(idx, position) {
|
|
const points = this.path.getLatLngs();
|
|
points[idx] = position;
|
|
this.path.setLatLngs(points);
|
|
}
|
|
|
|
drawPath(path) {
|
|
const color = this.flight.blue ? Colors.Blue : Colors.Red;
|
|
const layer = this.flightPlanLayer();
|
|
if (this.flight.selected) {
|
|
this.path = L.polyline(path, {
|
|
color: Colors.Highlight,
|
|
interactive: false,
|
|
})
|
|
.addTo(selectedFlightPlansLayer)
|
|
.addTo(layer)
|
|
.addTo(allFlightPlansLayer);
|
|
} else {
|
|
this.path = L.polyline(path, { color: color, interactive: false })
|
|
.addTo(layer)
|
|
.addTo(allFlightPlansLayer);
|
|
}
|
|
}
|
|
|
|
drawCommitBoundary() {
|
|
if (this.commitBoundary != null) {
|
|
this.commitBoundary
|
|
.removeFrom(selectedFlightPlansLayer)
|
|
.removeFrom(this.flightPlanLayer())
|
|
.removeFrom(allFlightPlansLayer);
|
|
}
|
|
if (this.flight.selected) {
|
|
if (this.flight.commitBoundary) {
|
|
this.commitBoundary = L.polyline(this.flight.commitBoundary, {
|
|
color: Colors.Highlight,
|
|
weight: 1,
|
|
interactive: false,
|
|
})
|
|
.addTo(selectedFlightPlansLayer)
|
|
.addTo(this.flightPlanLayer())
|
|
.addTo(allFlightPlansLayer);
|
|
}
|
|
}
|
|
}
|
|
|
|
draw() {
|
|
const path = [];
|
|
this.flightPlan.forEach((waypoint) => {
|
|
if (waypoint.includeInPath()) {
|
|
path.push(waypoint.position());
|
|
}
|
|
if (this.shouldMark(waypoint)) {
|
|
waypoint.marker
|
|
.addTo(selectedFlightPlansLayer)
|
|
.addTo(this.flightPlanLayer())
|
|
.addTo(allFlightPlansLayer);
|
|
}
|
|
});
|
|
|
|
this.drawPath(path);
|
|
this.drawCommitBoundary();
|
|
}
|
|
}
|
|
|
|
function drawFlightPlans() {
|
|
blueFlightPlansLayer.clearLayers();
|
|
redFlightPlansLayer.clearLayers();
|
|
selectedFlightPlansLayer.clearLayers();
|
|
allFlightPlansLayer.clearLayers();
|
|
let selected = null;
|
|
game.flights.forEach((flight) => {
|
|
// Draw the selected waypoint last so it's on top. bringToFront only brings
|
|
// it to the front of the *extant* elements, so any flights drawn later will
|
|
// be drawn on top. We could fight with manual Z-indexes but leaflet does a
|
|
// lot of that automatically so it'd be error prone.
|
|
if (flight.selected) {
|
|
selected = flight;
|
|
} else {
|
|
new Flight(flight).draw();
|
|
}
|
|
});
|
|
|
|
if (selected != null) {
|
|
new Flight(selected).draw();
|
|
}
|
|
}
|
|
|
|
function _drawThreatZones(zones, layer, player) {
|
|
const color = player ? Colors.Blue : Colors.Red;
|
|
for (const zone of zones) {
|
|
L.polyline(zone, {
|
|
color: color,
|
|
weight: 1,
|
|
fill: true,
|
|
fillOpacity: 0.4,
|
|
noClip: true,
|
|
interactive: false,
|
|
}).addTo(layer);
|
|
}
|
|
}
|
|
|
|
function drawThreatZones() {
|
|
blueFullThreatZones.clearLayers();
|
|
blueAircraftThreatZones.clearLayers();
|
|
blueAirDefenseThreatZones.clearLayers();
|
|
blueRadarSamThreatZones.clearLayers();
|
|
redFullThreatZones.clearLayers();
|
|
redAircraftThreatZones.clearLayers();
|
|
redAirDefenseThreatZones.clearLayers();
|
|
redRadarSamThreatZones.clearLayers();
|
|
|
|
_drawThreatZones(game.threatZones.blue.full, blueFullThreatZones, true);
|
|
_drawThreatZones(
|
|
game.threatZones.blue.aircraft,
|
|
blueAircraftThreatZones,
|
|
true
|
|
);
|
|
_drawThreatZones(
|
|
game.threatZones.blue.airDefenses,
|
|
blueAirDefenseThreatZones,
|
|
true
|
|
);
|
|
_drawThreatZones(
|
|
game.threatZones.blue.radarSams,
|
|
blueRadarSamThreatZones,
|
|
true
|
|
);
|
|
|
|
_drawThreatZones(game.threatZones.red.full, redFullThreatZones, false);
|
|
_drawThreatZones(
|
|
game.threatZones.red.aircraft,
|
|
redAircraftThreatZones,
|
|
false
|
|
);
|
|
_drawThreatZones(
|
|
game.threatZones.red.airDefenses,
|
|
redAirDefenseThreatZones,
|
|
false
|
|
);
|
|
_drawThreatZones(
|
|
game.threatZones.red.radarSams,
|
|
redRadarSamThreatZones,
|
|
false
|
|
);
|
|
}
|
|
|
|
function drawNavmesh(zones, layer) {
|
|
for (const zone of zones) {
|
|
L.polyline(zone.poly, {
|
|
color: "#000000",
|
|
weight: 1,
|
|
fillColor: zone.threatened ? "#ff0000" : "#00ff00",
|
|
fill: true,
|
|
fillOpacity: 0.1,
|
|
noClip: true,
|
|
interactive: false,
|
|
}).addTo(layer);
|
|
}
|
|
}
|
|
|
|
function drawNavmeshes() {
|
|
blueNavmesh.clearLayers();
|
|
redNavmesh.clearLayers();
|
|
|
|
drawNavmesh(game.navmeshes.blue, blueNavmesh);
|
|
drawNavmesh(game.navmeshes.red, redNavmesh);
|
|
}
|
|
|
|
function drawMapZones() {
|
|
seaZones.clearLayers();
|
|
inclusionZones.clearLayers();
|
|
exclusionZones.clearLayers();
|
|
|
|
for (const zone of game.mapZones.seaZones) {
|
|
L.polygon(zone, {
|
|
color: "#344455",
|
|
fillColor: "#344455",
|
|
fillOpacity: 1,
|
|
interactive: false,
|
|
}).addTo(seaZones);
|
|
}
|
|
|
|
for (const zone of game.mapZones.inclusionZones) {
|
|
L.polygon(zone, {
|
|
color: "#969696",
|
|
fillColor: "#4b4b4b",
|
|
fillOpacity: 1,
|
|
interactive: false,
|
|
}).addTo(inclusionZones);
|
|
}
|
|
|
|
for (const zone of game.mapZones.exclusionZones) {
|
|
L.polygon(zone, {
|
|
color: "#969696",
|
|
fillColor: "#303030",
|
|
fillOpacity: 1,
|
|
interactive: false,
|
|
}).addTo(exclusionZones);
|
|
}
|
|
}
|
|
|
|
function drawUnculledZones() {
|
|
unculledZones.clearLayers();
|
|
|
|
for (const zone of game.unculledZones) {
|
|
L.circle(zone.position, {
|
|
radius: zone.radius,
|
|
color: "#b4ff8c",
|
|
fill: false,
|
|
interactive: false,
|
|
}).addTo(unculledZones);
|
|
}
|
|
}
|
|
|
|
function drawIpZones() {
|
|
homeBubble.clearLayers();
|
|
ipBubble.clearLayers();
|
|
permissibleZone.clearLayers();
|
|
safeZone.clearLayers();
|
|
|
|
L.polygon(game.ipZones.homeBubble, {
|
|
color: Colors.Highlight,
|
|
fillOpacity: 0.1,
|
|
interactive: false,
|
|
}).addTo(homeBubble);
|
|
|
|
L.polygon(game.ipZones.ipBubble, {
|
|
color: "#bb89ff",
|
|
fillOpacity: 0.1,
|
|
interactive: false,
|
|
}).addTo(ipBubble);
|
|
|
|
L.polygon(game.ipZones.permissibleZone, {
|
|
color: "#ffffff",
|
|
fillOpacity: 0.1,
|
|
interactive: false,
|
|
}).addTo(permissibleZone);
|
|
|
|
L.polygon(game.ipZones.safeZone, {
|
|
color: Colors.Green,
|
|
fillOpacity: 0.1,
|
|
interactive: false,
|
|
}).addTo(safeZone);
|
|
}
|
|
|
|
function drawInitialMap() {
|
|
recenterMap(game.mapCenter);
|
|
drawControlPoints();
|
|
drawGroundObjects();
|
|
drawSupplyRoutes();
|
|
drawFrontLines();
|
|
drawFlightPlans();
|
|
drawThreatZones();
|
|
drawNavmeshes();
|
|
drawMapZones();
|
|
drawUnculledZones();
|
|
drawIpZones();
|
|
}
|
|
|
|
function clearAllLayers() {
|
|
map.eachLayer(function (layer) {
|
|
if (layer.clearLayers !== undefined) {
|
|
layer.clearLayers();
|
|
}
|
|
});
|
|
}
|
|
|
|
function setTooltipZoomThreshold(layerGroup, showAt) {
|
|
let showing = map.getZoom() >= showAt;
|
|
map.on("zoomend", function () {
|
|
const zoom = map.getZoom();
|
|
if (zoom < showAt && showing) {
|
|
showing = false;
|
|
layerGroup.eachLayer(function (layer) {
|
|
if (layer.getTooltip()) {
|
|
const tooltip = layer.getTooltip();
|
|
layer.unbindTooltip().bindTooltip(tooltip, {
|
|
permanent: false,
|
|
});
|
|
}
|
|
});
|
|
} else if (zoom >= showAt && !showing) {
|
|
showing = true;
|
|
layerGroup.eachLayer(function (layer) {
|
|
if (layer.getTooltip()) {
|
|
const tooltip = layer.getTooltip();
|
|
layer.unbindTooltip().bindTooltip(tooltip, {
|
|
permanent: true,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
setTooltipZoomThreshold(selectedFlightPlansLayer, SHOW_WAYPOINT_INFO_AT_ZOOM);
|