// ==UserScript==
// @name Diep.io Mod Menu Aimbot remix v4.2
// @namespace http://tampermonkey.net/
// @version 4.2
// @homepage https://github.com/ezydubs/diep_mod_menu
// @description Loop upgrade custom builds, render aim line, render factory guide circle, aim assist at the nearest enemy tank, hitboxes
// @author https://github.com/x032205 (remixed https://github.com/ezydubs)
// @match https://diep.io/*
// @grant none
// @license MIT
// @downloadURL https://raw.githubusercontent.com/ezydubs/diep_mod_menu/main/script.user.js
// @updateURL https://raw.githubusercontent.com/ezydubs/diep_mod_menu/main/script.user.js
// @supportURL https://github.com/ezydubs/diep_mod_menu/issues
// ==/UserScript==
const presets = [
{
name: "Rammer (triangles & annihilator) [ 5 / 7 / 7 / 0 / 0 / 0 / 7 / 7 ]",
build: "123123123123123888882382387777777",
},
{
name: "Bullet Umbrella (triangles) [ 0 / 1 / 2 / 2 / 7 / 7 / 7 / 7 ]",
build: "567445675675675675675678888888233",
},
{
name: "Glass Cannon (spreadshot, factory, etc) [ 0 / 0 / 0 / 6 / 7 / 7 / 7 / 6 ]",
build: "567456747654765476547566888821212",
},
{
name: "Balanced (overlord, fighter) [ 3 / 3 / 3 / 5 / 5 / 5 / 5 / 4 ]",
build: "567445674567456745671238123812388",
},
{
name: "The 1M Overlord [ 2 / 3 / 0 / 7 / 7 / 7 / 0 / 7 ]",
build: "564456456456456888456456888822112",
},
{
name: "Balanced Factory [ 2 / 3 / 0 / 5 / 6 / 7 / 6 / 4 ]",
build: "567456747654765476547566888821212",
},
{
name: "Armor (penta, rocketeer, trappers) [ 0 / 6 / 6 / 0 / 7 / 7 / 7 / 0 ]",
build: "567567567567567567567232323232323",
},
];
(function () {
"use strict";
const backdrop = document.createElement("div");
backdrop.id = "backdrop";
const toggle_text = document.createElement("code");
toggle_text.classList.add("watermark");
toggle_text.textContent = "Diep Mod Menu | Press [R] to toggle";
backdrop.appendChild(toggle_text);
const panel = document.createElement("div");
panel.id = "panel";
const side_panel = document.createElement("nav");
panel.appendChild(side_panel);
const separator = document.createElement("div");
separator.classList.add("separator");
panel.appendChild(separator);
const display_panel = document.createElement("div");
display_panel.classList.add("inner_panel");
panel.appendChild(display_panel);
// Auto Respawn
const auto_respawn = document.createElement("div");
auto_respawn.classList.add("view-option");
const auto_respawn_text = document.createElement("span");
auto_respawn_text.textContent = "Auto Respawn (uses [X03] username)";
const auto_respawn_label = document.createElement("label");
auto_respawn_label.classList.add("switch");
const auto_respawn_toggle = document.createElement("input");
auto_respawn_toggle.setAttribute("type", "checkbox");
auto_respawn_label.appendChild(auto_respawn_toggle);
const auto_respawn_div = document.createElement("div");
auto_respawn_label.appendChild(auto_respawn_div);
auto_respawn.appendChild(auto_respawn_label);
auto_respawn.appendChild(auto_respawn_text);
// Aim Line
const view_line = document.createElement("div");
view_line.classList.add("view-option");
const view_line_text = document.createElement("span");
view_line_text.textContent = "Aim line";
const view_line_label = document.createElement("label");
view_line_label.classList.add("switch");
const view_line_toggle = document.createElement("input");
view_line_toggle.setAttribute("type", "checkbox");
view_line_label.appendChild(view_line_toggle);
const view_line_div = document.createElement("div");
view_line_label.appendChild(view_line_div);
view_line.appendChild(view_line_label);
view_line.appendChild(view_line_text);
// Factory Circle
const view_circle = document.createElement("div");
view_circle.classList.add("view-option");
const view_circle_text = document.createElement("span");
view_circle_text.textContent = "Factory circle";
const view_circle_label = document.createElement("label");
view_circle_label.classList.add("switch");
const view_circle_toggle = document.createElement("input");
view_circle_toggle.setAttribute("type", "checkbox");
view_circle_label.appendChild(view_circle_toggle);
const view_circle_div = document.createElement("div");
view_circle_label.appendChild(view_circle_div);
view_circle.appendChild(view_circle_label);
view_circle.appendChild(view_circle_text);
// Auto Aim
const auto_aim = document.createElement("div");
auto_aim.classList.add("view-option");
const auto_aim_text = document.createElement("span");
auto_aim_text.textContent = "Auto Aim (nearest enemy tank)";
const auto_aim_label = document.createElement("label");
auto_aim_label.classList.add("switch");
const auto_aim_toggle = document.createElement("input");
auto_aim_toggle.setAttribute("type", "checkbox");
auto_aim_label.appendChild(auto_aim_toggle);
const auto_aim_div = document.createElement("div");
auto_aim_label.appendChild(auto_aim_div);
auto_aim.appendChild(auto_aim_label);
auto_aim.appendChild(auto_aim_text);
// Auto Aim: lead moving targets
const aim_lead = document.createElement("div");
aim_lead.classList.add("view-option");
const aim_lead_text = document.createElement("span");
aim_lead_text.textContent = "Auto Aim: lead moving targets";
const aim_lead_label = document.createElement("label");
aim_lead_label.classList.add("switch");
const aim_lead_toggle = document.createElement("input");
aim_lead_toggle.setAttribute("type", "checkbox");
aim_lead_label.appendChild(aim_lead_toggle);
const aim_lead_div = document.createElement("div");
aim_lead_label.appendChild(aim_lead_div);
aim_lead.appendChild(aim_lead_label);
aim_lead.appendChild(aim_lead_text);
// Auto Aim: fire only when the ring says the shot is likely to land
const fire_green = document.createElement("div");
fire_green.classList.add("view-option");
const fire_green_text = document.createElement("span");
fire_green_text.textContent = "Fire on green (needs Auto Aim)";
const fire_green_label = document.createElement("label");
fire_green_label.classList.add("switch");
const fire_green_toggle = document.createElement("input");
fire_green_toggle.setAttribute("type", "checkbox");
fire_green_label.appendChild(fire_green_toggle);
const fire_green_div = document.createElement("div");
fire_green_label.appendChild(fire_green_div);
fire_green.appendChild(fire_green_label);
fire_green.appendChild(fire_green_text);
// Off-screen tracking: edge arrows for enemies the game still sends just
// past the edge, for ones that just left (dead reckoned), and for the
// direction unseen shooters fire from.
const offscreen = document.createElement("div");
offscreen.classList.add("view-option");
const offscreen_text = document.createElement("span");
offscreen_text.textContent = "Track enemies off screen (edge arrows)";
const offscreen_label = document.createElement("label");
offscreen_label.classList.add("switch");
const offscreen_toggle = document.createElement("input");
offscreen_toggle.setAttribute("type", "checkbox");
offscreen_label.appendChild(offscreen_toggle);
const offscreen_div = document.createElement("div");
offscreen_label.appendChild(offscreen_div);
offscreen.appendChild(offscreen_label);
offscreen.appendChild(offscreen_text);
// Auto Aim: what the shots taught
const aim_status = document.createElement("div");
aim_status.classList.add("view-option");
const aim_status_text = document.createElement("span");
aim_status_text.textContent = "Aim log: no shots yet";
aim_status_text.style.fontSize = "13px";
const aim_reset = document.createElement("button");
aim_reset.textContent = "reset";
aim_reset.style.marginLeft = "6px";
aim_status.appendChild(aim_status_text);
aim_status.appendChild(aim_reset);
const aim_export = document.createElement("button");
aim_export.textContent = "save log";
aim_export.style.marginLeft = "6px";
aim_status.appendChild(aim_export);
// Game log: records the player's shots, the enemies and the player's own
// play without moving the mouse, so it works with manual aim too.
const game_log = document.createElement("div");
game_log.classList.add("view-option");
const game_log_text = document.createElement("span");
game_log_text.textContent = "Game log (shots, enemies, movement)";
const game_log_label = document.createElement("label");
game_log_label.classList.add("switch");
const game_log_toggle = document.createElement("input");
game_log_toggle.setAttribute("type", "checkbox");
game_log_label.appendChild(game_log_toggle);
const game_log_div = document.createElement("div");
game_log_label.appendChild(game_log_div);
game_log.appendChild(game_log_label);
game_log.appendChild(game_log_text);
const game_status = document.createElement("div");
game_status.classList.add("view-option");
const game_status_text = document.createElement("span");
game_status_text.textContent = "Game log: waiting for a game";
game_status_text.style.fontSize = "13px";
const game_copy = document.createElement("button");
game_copy.textContent = "copy summary";
game_copy.style.marginLeft = "6px";
game_status.appendChild(game_status_text);
game_status.appendChild(game_copy);
// Render Collisions
const render_collisions = document.createElement("div");
render_collisions.classList.add("view-option");
const render_collisions_text = document.createElement("span");
render_collisions_text.textContent = "Render Collisions";
const render_collisions_label = document.createElement("label");
render_collisions_label.classList.add("switch");
const render_collisions_toggle = document.createElement("input");
render_collisions_toggle.setAttribute("type", "checkbox");
render_collisions_label.appendChild(render_collisions_toggle);
const render_collisions_div = document.createElement("div");
render_collisions_label.appendChild(render_collisions_div);
render_collisions.appendChild(render_collisions_label);
render_collisions.appendChild(render_collisions_text);
render_collisions_toggle.addEventListener("change", function () {
if (render_collisions_toggle.checked) {
input.execute("ren_debug_collisions true");
} else {
input.execute("ren_debug_collisions false");
}
localStorage.setItem(
"mm_render_collisions",
render_collisions_toggle.checked,
);
});
// Render FPS
const render_fps = document.createElement("div");
render_fps.classList.add("view-option");
const render_fps_text = document.createElement("span");
render_fps_text.textContent = "Render FPS";
const render_fps_label = document.createElement("label");
render_fps_label.classList.add("switch");
const render_fps_toggle = document.createElement("input");
render_fps_toggle.setAttribute("type", "checkbox");
render_fps_label.appendChild(render_fps_toggle);
const render_fps_div = document.createElement("div");
render_fps_label.appendChild(render_fps_div);
render_fps.appendChild(render_fps_label);
render_fps.appendChild(render_fps_text);
render_fps_toggle.addEventListener("change", function () {
if (render_fps_toggle.checked) {
input.execute("ren_fps true");
} else {
input.execute("ren_fps false");
}
localStorage.setItem("mm_render_fps", render_fps_toggle.checked);
});
// Render Raw Health Values
const render_rhw = document.createElement("div");
render_rhw.classList.add("view-option");
const render_rhw_text = document.createElement("span");
render_rhw_text.textContent = "Render Raw Health Values";
const render_rhw_label = document.createElement("label");
render_rhw_label.classList.add("switch");
const render_rhw_toggle = document.createElement("input");
render_rhw_toggle.setAttribute("type", "checkbox");
render_rhw_label.appendChild(render_rhw_toggle);
const render_rhw_div = document.createElement("div");
render_rhw_label.appendChild(render_rhw_div);
render_rhw.appendChild(render_rhw_label);
render_rhw.appendChild(render_rhw_text);
render_rhw_toggle.addEventListener("change", function () {
if (render_rhw_toggle.checked) {
input.execute("ren_raw_health_values true");
} else {
input.execute("ren_raw_health_values false");
}
localStorage.setItem("mm_render_raw_health", render_rhw_toggle.checked);
});
// Hide UI
const hide_ui = document.createElement("div");
hide_ui.classList.add("view-option");
const hide_ui_text = document.createElement("span");
hide_ui_text.textContent = "Hide Game UI";
const hide_ui_label = document.createElement("label");
hide_ui_label.classList.add("switch");
const hide_ui_toggle = document.createElement("input");
hide_ui_toggle.setAttribute("type", "checkbox");
hide_ui_label.appendChild(hide_ui_toggle);
const hide_ui_div = document.createElement("div");
hide_ui_label.appendChild(hide_ui_div);
hide_ui.appendChild(hide_ui_label);
hide_ui.appendChild(hide_ui_text);
hide_ui_toggle.addEventListener("change", function () {
if (hide_ui_toggle.checked) {
input.execute("ren_ui false");
} else {
input.execute("ren_ui true");
}
localStorage.setItem("mm_hide_ui", hide_ui_toggle.checked);
});
// Visual Tab
const visual_tab = document.createElement("button");
visual_tab.classList.add("tab_button", "active");
side_panel.appendChild(visual_tab);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "32");
svg.setAttribute("height", "32");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "#BBBBBB");
svg.setAttribute("stroke-width", "2.5");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.innerHTML =
'';
visual_tab.appendChild(svg);
visual_tab.onclick = function () {
display_panel.innerHTML = ``;
display_panel.appendChild(auto_respawn);
display_panel.appendChild(view_line);
display_panel.appendChild(view_circle);
display_panel.appendChild(auto_aim);
display_panel.appendChild(aim_lead);
display_panel.appendChild(fire_green);
display_panel.appendChild(offscreen);
display_panel.appendChild(aim_status);
display_panel.appendChild(game_log);
display_panel.appendChild(game_status);
display_panel.appendChild(render_collisions);
display_panel.appendChild(render_fps);
display_panel.appendChild(render_rhw);
display_panel.appendChild(hide_ui);
setActiveTab(visual_tab);
};
const au_label = document.createElement("span");
au_label.classList.add("subheading");
au_label.textContent = "Custom Build";
const au_input = document.createElement("input");
au_input.ariaReadOnly = "true";
au_input.setAttribute("type", "number");
au_input.classList.add("custom-input");
au_input.placeholder = "000000000000000000000000000000000";
au_input.value = localStorage.getItem("diepModMenuBuild") || "";
au_input.addEventListener("input", function () {
if (au_input.value.length > 33) {
au_input.value = au_input.value.slice(0, 33);
}
localStorage.setItem("diepModMenuBuild", au_input.value);
});
const au_autoset = document.createElement("div");
au_autoset.classList.add("view-option");
const au_autoset_text = document.createElement("span");
au_autoset_text.textContent = "Keep build on respawn";
const au_autoset_label = document.createElement("label");
au_autoset_label.classList.add("switch");
const au_autoset_toggle = document.createElement("input");
au_autoset_toggle.setAttribute("type", "checkbox");
au_autoset_label.appendChild(au_autoset_toggle);
const au_autoset_div = document.createElement("div");
au_autoset_label.appendChild(au_autoset_div);
au_autoset.appendChild(au_autoset_label);
au_autoset.appendChild(au_autoset_text);
au_autoset_toggle.addEventListener("change", function () {
localStorage.setItem("mm_keep_build_on_spawn", au_autoset_toggle.checked);
});
// Presets
const au_presets_label = document.createElement("span");
au_presets_label.classList.add("subheading");
au_presets_label.textContent = "Presets";
const preset_panel = document.createElement("div");
preset_panel.classList.add("preset-panel");
presets.forEach((preset) => {
const presetButton = document.createElement("button");
presetButton.textContent = preset.name;
presetButton.classList.add("button");
presetButton.onclick = function () {
au_input.value = preset.build;
localStorage.setItem("diepModMenuBuild", preset.build);
input.execute("game_stats_build " + preset.build);
};
preset_panel.appendChild(presetButton);
});
// Auto Upgrade Tab
const auto_upgrades_tab = document.createElement("button");
auto_upgrades_tab.classList.add("tab_button");
side_panel.appendChild(auto_upgrades_tab);
const au_svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
au_svg.setAttribute("width", "32");
au_svg.setAttribute("height", "32");
au_svg.setAttribute("viewBox", "0 0 24 24");
au_svg.setAttribute("fill", "none");
au_svg.setAttribute("stroke", "#BBBBBB");
au_svg.setAttribute("stroke-width", "2.5");
au_svg.setAttribute("stroke-linecap", "round");
au_svg.setAttribute("stroke-linejoin", "round");
au_svg.innerHTML =
'';
auto_upgrades_tab.appendChild(au_svg);
auto_upgrades_tab.onclick = function () {
display_panel.innerHTML = ``;
display_panel.appendChild(au_label);
display_panel.appendChild(au_input);
display_panel.appendChild(au_autoset);
display_panel.appendChild(au_presets_label);
display_panel.appendChild(preset_panel);
setActiveTab(auto_upgrades_tab);
};
const credits_tab = document.createElement("button");
credits_tab.classList.add("tab_button");
side_panel.appendChild(credits_tab);
const credit_svg = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
credit_svg.setAttribute("width", "32");
credit_svg.setAttribute("height", "32");
credit_svg.setAttribute("viewBox", "0 0 24 24");
credit_svg.setAttribute("fill", "none");
credit_svg.setAttribute("stroke", "#BBBBBB");
credit_svg.setAttribute("stroke-width", "2.5");
credit_svg.setAttribute("stroke-linecap", "round");
credit_svg.setAttribute("stroke-linejoin", "round");
credit_svg.innerHTML =
'';
credits_tab.appendChild(credit_svg);
credits_tab.onclick = function () {
display_panel.innerHTML = `Discord: @x03
Github: @x032205`;
setActiveTab(credits_tab);
};
const style = document.createElement("style");
style.textContent = `
code { font-family: monospace; }
#panel {
font-family: 'Inter', sans-serif;
color: #EEEEEE;
font-size: 16px;
display: flex;
flex-direction: row;
max-width: 600px;
max-height: 520px;
width: 100%;
height: 100%;
padding: 12px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
gap: 6px;
background: hsla(0, 0%, 10%, 0.7);
backdrop-filter: blur(7px);
-webkit-backdrop-filter: blur(7px);
border: 1px solid hsla(0, 0%, 100%, 0.1);
border-radius: 8px;
z-index: 10;
}
.separator {
width: 1px;
height: 100%;
background-color: hsla(0, 0%, 100%, 0.1);
}
.switch {
display: inline-block;
font-size: 20px;
height: 1em;
width: 2em;
background: rgb(50, 50, 50);
border-radius: 1em;
margin-right: 10px;
cursor: pointer;
}
.switch input {
position: absolute;
opacity: 0;
cursor: pointer;
}
.switch div {
font-size: 20px;
height: 1em;
width: 1em;
border-radius: 1em;
background: rgb(100, 100, 100);
transition: all 100ms;
cursor: pointer;
}
.switch input:checked + div {
transform: translate3d(100%, 0, 0);
background: #EEEEEE;
}
nav {
display: flex;
flex-direction: column;
gap: 6px;
}
.inner_panel {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px;
width: 100%;
margin-left: 4px;
}
.tab_button {
display: flex;
width: 48px;
height: 48px;
justify-content: center;
align-items: center;
background: hsla(0, 0%, 20%, 0.5);
border-radius: 4px;
border: none;
transition: all 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.preset-panel {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.button {
color: #EEEEEE;
background: hsla(0, 0%, 20%, 0.5);
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
transition: background 0.2s;
}
.button:hover {
background: hsla(0, 0%, 30%, 0.5);
}
.tab_button:hover,
.tab_button.active {
background: hsla(0, 0%, 40%, 0.5);
}
#backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.watermark {
position: fixed;
top: 8px;
left: 50%;
transform: translateX(-50%);
}
.subheading { font-weight: 600; }
.view-option {
text-align: left;
align-items: center;
min-height: 28px;
padding: 2px 0;
display: flex;
}
.custom-input {
color: #EEEEEE;
background: hsla(0, 0%, 10%, 0.7);
border: 1px solid hsla(0, 0%, 100%, 0.1);
border-radius: 4px;
padding: 6px;
outline: none;
}
.text-muted { color: #BBBBBB; }
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
`;
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
document.head.appendChild(style);
function toggleDisplay(elementId) {
const element = document.getElementById(elementId);
const backdrop = document.getElementById("backdrop");
const isHidden = element.style.display === "none";
element.style.display = isHidden ? "block" : "none";
backdrop.style.display = isHidden ? "block" : "none";
}
function setActiveTab(activeTab) {
[visual_tab, auto_upgrades_tab, credits_tab].forEach((tab) =>
tab.classList.remove("active"),
);
activeTab.classList.add("active");
}
let X, Y, x, y;
let Z = false;
let radius = [];
document.body.onkeyup = function (ctx) {
if (ctx.keyCode === 82) {
toggleDisplay("backdrop");
} else if (document.activeElement === au_input) {
const key = parseInt(ctx.key);
if (key >= 1 && key <= 8) {
au_input.value += ctx.key;
localStorage.setItem("diepModMenuBuild", au_input.value);
} else if (ctx.keyCode === 8) {
au_input.value = au_input.value.slice(0, -1);
localStorage.setItem("diepModMenuBuild", au_input.value);
}
}
};
document.onmousemove = (event) => {
if (!event.isTrusted) return; // synthetic moves sent by auto aim
x = event.clientX;
y = event.clientY;
};
document.onmousedown = (e) => {
if (e.button === 2) Z = true;
};
document.onmouseup = (e) => {
if (e.button === 2) Z = false;
};
const canvas = document.createElement("canvas");
canvas.style.zIndex = "11";
canvas.style.position = "absolute";
canvas.style.top = "0px";
canvas.style.left = "0px";
canvas.style.pointerEvents = "none";
function getRadius() {
X = window.innerWidth / 2;
Y = window.innerHeight / 2;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
radius = [
window.innerWidth * 0.17681239669,
window.innerWidth * 0.06545454545,
window.innerWidth * 0.16751239669,
window.innerWidth * 0.36,
];
}
getRadius();
window.addEventListener("resize", getRadius);
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
// ---------------------------------------------------------------------
// Auto Aim
//
// diep.io draws the world with the 2D canvas API, so entity positions can
// be recovered by hooking the draw calls made on the game canvas:
// - a tank is gray parts (barrels, spawners, bases, turrets) followed by
// its body: two circles (dark border + body) or, for the Necromancer,
// a coloured square
// - a bullet is the same circle pair without gray parts
// - polygons, drones and traps are straight-line paths
// The player's own tank is the tank drawn at the centre of the screen; any
// other tank with a different colour is an enemy. Aiming works by
// dispatching a synthetic mousemove event, which the game accepts. Clicks
// are only accepted when trusted, so this moves the aim but never fires.
// With the lead on, the aim is placed where the target will be when a
// bullet arrives. The target's motion on screen includes the player's own
// motion, so the camera velocity is read from the background grid (or the
// drift of the shapes) and taken out first.
// ---------------------------------------------------------------------
const DEFAULT_BULLET_SPEED_IN_TANK_RADII = 20; // until own bullets are measured
const BULLET_MEASURE_MIN_MS = 600; // a bullet must be followed this long
const BULLET_MEASURE_MAX_MS = 1200; // and is measured over at most this long
const AIM_LATENCY_SECONDS = 0.2; // render and input delay to aim across
const MAX_LEAD_SECONDS = 3.5; // no shot is led further ahead than this
const SHOT_MAX_MS = 3000; // a shot is followed for at most this long
const BULLET_PROFILE_BUCKET_MS = 100; // bullet flight profile resolution
const BULLET_PROFILE_BUCKETS = 30; // and length (3 s)
const LEAD_SAMPLES = 20; // the lead correction is the median of this many shots
const LEAD_ADJUST_MIN = -0.3; // seconds taken off the lead, at most
const LEAD_ADJUST_MAX = 0.5; // seconds added to the lead, at most
const SHOT_LOG_SIZE = 1000; // shots kept in memory (400 are saved)
const SCRIPT_VERSION = "4.2"; // written into the game log
// Off-screen tracking. The server only sends what lies in a fixed-shape
// box around the view (measured: 34 px past the sides and 243 px past
// the top and bottom on a 1920x863 window), so an enemy that leaves it
// is dead reckoned from its last velocity for this long...
const GHOST_MS = 8000;
// ...at full speed for a second, then fading with this time constant.
const GHOST_TAU_SECONDS = 3;
// How fast the guess is allowed to be wrong, in own radii per second of
// absence: a returning tank within this of the guess gets its id back.
const GHOST_GATE_RADII_PER_SECOND = 4;
// An enemy bullet whose line passes within this many radii of the player
// with no drawn tank behind it marks an unseen shooter for this long.
const UNSEEN_FIRE_RADII = 8;
const UNSEEN_FIRE_MS = 1500;
// Fire on green: auto fire goes on when the ring's chance reaches this...
const FIRE_CHANCE_ON = 0.5;
// ...and off again only when it drops below this, so the edge of the
// green band does not flicker the trigger.
const FIRE_CHANCE_OFF = 0.45;
// With no chance reading yet (the first 15 s of a game), fire on flights
// up to this long: on every recorded session such shots landed 64-100%.
const FIRE_SHORT_LEAD_SECONDS = 0.6;
const FIRE_KEY_GAP_MS = 300; // least time between two presses of E
// Believed firing, yet no bullet for this long: auto fire is really off.
const FIRE_CHECK_MS = 3000;
const PROGRESS_SAMPLES = 20; // the dodge rate is the median of this many
const LEAD_FRACTION_MIN = 0.3;
const LEAD_FRACTION_MAX = 1.2;
// A target that dodges covers less of the predicted lead the longer the
// bullet flies: recorded tracks of a dodging player covered 95% of it at
// 0.4 s, 88% at 0.6 s, 82% at 0.8 s and 74% at 1.0 s, i.e. about 1 minus
// 0.26 per second of flight. The game's own auto turrets use roughly the
// same (about 70% at 0.6 s). This rate is what the shots learn.
const DEFAULT_DODGE_RATE = 0.3; // fraction of the lead lost per second
const DODGE_RATE_MIN = -0.2;
const DODGE_RATE_MAX = 1.0;
const FRACTION_STEPS = [0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1]; // leads tried
const VELOCITY_WINDOW_MS = 150; // velocities are measured over this much time
const TARGET_GRACE_MS = 250; // keep aiming this long when the target is not seen
const GRID_CELL = 50; // world units per background grid cell
const MAX_SPEED_IN_TANK_RADII = 20; // nothing drives faster than this
const SHAPE_COLORS = ["#ffe869", "#fc7677", "#768dfc", "#f177dd"];
const aim = {
enabled: false,
hooked: false,
gameCanvas: null,
frame: [], // entities found in the frame currently being drawn
entities: [], // entities of the last complete frame
frameShapes: [], // shape centres in the frame being drawn
shapes: [], // shape centres of the last complete frame
prevShapes: [],
frameGrid: null, // background grid offset in the frame being drawn
grid: null,
prevGrid: null,
cameraTrack: [], // integrated camera position samples {t, x, y}
cameraX: 0,
cameraY: 0,
cameraVx: 0, // camera velocity in canvas px/s
cameraVy: 0,
frameTime: 0,
entityTime: 0,
processedTime: -1,
path: null, // geometry of the path currently being built
pendingGray: null, // centre of the last gray part (barrel, base, turret)
lastGrayRect: null, // centre of the last gray barrel-like fill
lastCircle: null, // first circle of a border/body pair
lastBody: null, // last tank body drawn in this frame
selfColor: "#00b2e1",
selfRadius: 25,
bulletTracks: [], // own bullets followed from the barrel {x, y, t, ...}
bulletSpeeds: [], // recent measured bullet speeds in tank radii/s
bulletSpeed: DEFAULT_BULLET_SPEED_IN_TANK_RADII,
bulletProfile: [], // radii own bullets have flown after each 100 ms
muzzleRadii: 1.7, // where own bullets appear, in radii from the centre
muzzleSamples: [],
targetSeq: 0, // numbers targets so a shot knows which one it was for
lastLead: null, // lead used for the current aim {time, px, dist, speed}
leadAdjust: 0, // seconds learned from shots: >0 leads more, <0 less
dodgeRate: DEFAULT_DODGE_RATE, // lead lost per second of flight, learned
progressSamples: [], // the dodge rate each recent shot asked for
leadSamples: [], // the correction each recent steady shot asked for
shotLog: [], // one record per shot fired at a target, newest last
shotStats: { shots: 0, hits: 0, misses: 0, lost: 0, stray: 0 },
statusTime: -1e9,
gameStatusTime: -1e9,
passive: false, // game log only: nothing moves the mouse
mouseX: null, // the player's own mouse, in canvas px
mouseY: null,
aimHistory: [], // recent aim points {t, x, y, mx, my}: a bullet leaves with an old one
traj: [], // recent target positions in world px {t, x, y, sx, sy, id, R}
profileTime: -1e9,
trackRate: null, // dodge rate read off the current target's own track
trackRateTime: -1e9,
trackRateId: null,
rateById: {}, // the dodge rate last read for each target, by lock id
fractions: null, // lead fraction by flight time {0.6, 1, 1.5} for the target
game: newGameLog(),
selfFlash: false,
sessionShots: 0, // shots this page load (the stats below are lifetime)
sessionHits: 0,
target: null,
aimX: 0,
aimY: 0,
autoFire: false, // whether the game's auto fire (E) is on, as far as known
scriptFire: false, // the script turned it on (so it may turn it off)
wantFire: false, // fire on green wants to be firing right now
fireChance: null, // the chance the trigger last read
fireKeyTime: -1e9, // when E was last pressed by the script
firePresses: 0,
userHold: false, // the player holds the left button
userUpTime: -1e9,
lastBirth: -1e9, // when an own bullet last appeared
birthTimes: [], // recent births with the button up
tracked: [], // enemies followed frame to frame, kept a while unseen
enemyBullets: [], // enemy bullets followed to find unseen shooters
fireFrom: [], // recent directions unseen shooters fired from {t, angle, x, y}
};
function newGameLog() {
return {
startedAt: Date.now(),
frames: 0,
movingFrames: 0,
speedSum: 0, // own speed in radii/s, summed over moving frames
heading: null,
turns: 0, // own direction changes of more than 60 degrees
distSum: 0, // distance to the nearest enemy in view, in radii
distFrames: 0,
hitsTaken: 0,
nearMissFrames: 0, // frames with an enemy bullet within 1.5 radii
enemies: {}, // by target id: {shots, hits, dodgeRates, leadOnMe}
ghostReturns: 0, // enemies that came back where their ghost was
unseenShots: 0, // bullets from shooters that were not on screen
enemyLead: [], // seconds of lead enemies put on the player, per shot seen
playerLead: [], // with manual aim: the player's lead vs the model, per shot
dodgeProfile: null, // share of the predicted lead enemies really covered
};
}
const wrapAngle = (v) => Math.atan2(Math.sin(v), Math.cos(v));
// A tank flashes towards white when it takes damage.
function isFlash(color) {
if (typeof color !== "string" || color.length !== 7) return false;
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return Math.min(r, g, b) > 200;
}
function isGray(color) {
if (typeof color !== "string" || color.length !== 7 || color[0] !== "#") {
return false;
}
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return Math.abs(r - g) < 12 && Math.abs(g - b) < 12 && Math.abs(r - b) < 12;
}
function installAimHooks() {
if (aim.hooked) return;
aim.hooked = true;
const proto = CanvasRenderingContext2D.prototype;
const hook = (name, before) => {
const original = proto[name];
proto[name] = function (...args) {
if (aim.enabled && this.canvas === aim.gameCanvas) {
try {
before.call(this, args);
} catch (err) {
// never break the game's rendering
}
}
return original.apply(this, args);
};
};
const toDevice = (context, px, py) => {
const t = context.getTransform();
return [
t.a * px + t.c * py + t.e,
t.b * px + t.d * py + t.f,
Math.hypot(t.a, t.b),
];
};
const addPoint = (path, px, py) => {
if (px < path.minX) path.minX = px;
if (px > path.maxX) path.maxX = px;
if (py < path.minY) path.minY = py;
if (py > path.maxY) path.maxY = py;
};
// A gray dome drawn right on a body that was just drawn is a turret on top
// of that tank, not a part of the next tank.
const onLastBody = (point) =>
aim.lastBody !== null &&
Math.hypot(point.x - aim.lastBody.x, point.y - aim.lastBody.y) <
aim.lastBody.r * 1.2;
const addEntity = (x, y, r, color, tank) => {
aim.frame.push({ x, y, r, color, tank });
if (tank) aim.lastBody = { x, y, r };
aim.pendingGray = null;
aim.lastGrayRect = null;
aim.lastCircle = null;
};
hook("clearRect", function (args) {
// The game clears the whole canvas once at the start of every frame.
if (
args[2] >= this.canvas.width * 0.9 &&
args[3] >= this.canvas.height * 0.9
) {
aim.entities = aim.frame;
aim.shapes = aim.frameShapes;
aim.grid = aim.frameGrid;
aim.entityTime = aim.frameTime;
aim.frame = [];
aim.frameShapes = [];
aim.frameGrid = null;
aim.frameTime = performance.now();
aim.pendingGray = null;
aim.lastGrayRect = null;
aim.lastCircle = null;
aim.lastBody = null;
}
});
hook("fillRect", function () {
// The background grid is the only pattern fill on the game canvas.
if (typeof this.fillStyle === "object" && this.fillStyle !== null) {
const t = this.getTransform();
aim.frameGrid = { e: t.e, f: t.f, zoom: Math.hypot(t.a, t.b) };
}
});
hook("beginPath", function () {
aim.path = {
circle: null,
circles: 0,
lines: 0,
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
};
});
hook("moveTo", function (args) {
if (!aim.path) return;
const [px, py] = toDevice(this, args[0], args[1]);
addPoint(aim.path, px, py);
});
hook("lineTo", function (args) {
if (!aim.path) return;
aim.path.lines++;
const [px, py] = toDevice(this, args[0], args[1]);
addPoint(aim.path, px, py);
});
hook("rect", function (args) {
if (!aim.path) return;
aim.path.lines += 4;
const [x0, y0, w, h] = args;
for (const [px, py] of [
[x0, y0],
[x0 + w, y0],
[x0, y0 + h],
[x0 + w, y0 + h],
]) {
const [dx, dy] = toDevice(this, px, py);
addPoint(aim.path, dx, dy);
}
});
hook("arc", function (args) {
if (!aim.path) return;
const [px, py, scale] = toDevice(this, args[0], args[1]);
aim.path.circles++;
aim.path.circle = { x: px, y: py, r: args[2] * scale };
});
hook("fill", function () {
const path = aim.path;
if (!path) return;
const color = String(this.fillStyle);
const circle =
path.circles === 1 && path.lines === 0 ? path.circle : null;
const centre =
path.minX !== Infinity
? { x: (path.minX + path.maxX) / 2, y: (path.minY + path.maxY) / 2 }
: null;
if (isGray(color)) {
if (circle) {
// A gray circle is an auto-turret dome or a gray bullet. A dome sits
// on the barrel drawn just before it, and it belongs to a tank drawn
// later (Auto 3, Auto 5) only when no body lies under it yet.
const rect = aim.lastGrayRect;
const dome =
rect !== null &&
Math.hypot(rect.x - circle.x, rect.y - circle.y) < circle.r * 2;
aim.pendingGray =
dome && !onLastBody(circle) ? { x: circle.x, y: circle.y } : null;
} else if (centre) {
// Barrels, spawners and bases always belong to the tank drawn next.
aim.pendingGray = centre;
aim.lastGrayRect = centre;
}
aim.lastCircle = null;
return;
}
if (!circle) {
const pending = aim.pendingGray;
aim.pendingGray = null;
aim.lastGrayRect = null;
aim.lastCircle = null;
if (!centre) return;
if (SHAPE_COLORS.includes(color)) {
aim.frameShapes.push(centre);
return;
}
if (pending === null) return;
// A coloured polygon right after gray parts is a tank whose body is
// not round (the Necromancer). Drones and traps have no gray parts.
const r = Math.max(path.maxX - path.minX, path.maxY - path.minY) / 2;
if (
r >= aim.selfRadius * 0.6 &&
Math.hypot(pending.x - centre.x, pending.y - centre.y) < r * 4
) {
addEntity(centre.x, centre.y, r, color, true);
}
return;
}
const first = aim.lastCircle;
if (
first &&
Math.abs(first.x - circle.x) < 1 &&
Math.abs(first.y - circle.y) < 1 &&
first.r > circle.r
) {
// Second circle of a border/body pair: a complete tank or bullet.
const gray = aim.pendingGray;
const tank =
gray !== null &&
Math.hypot(gray.x - circle.x, gray.y - circle.y) < first.r * 4;
addEntity(circle.x, circle.y, first.r, color, tank);
} else {
aim.lastCircle = circle;
}
});
}
// Velocity from a short position history: robust against single frames
// that were rendered late or early.
function windowedVelocity(history) {
if (history.length < 2) return [0, 0];
const last = history[history.length - 1];
let first = history[0];
for (const sample of history) {
if (last.t - sample.t <= VELOCITY_WINDOW_MS) {
first = sample;
break;
}
}
const span = (last.t - first.t) / 1000;
if (span < 0.02) return [0, 0];
return [(last.x - first.x) / span, (last.y - first.y) / span];
}
function extendHistory(history, t, x, y) {
const kept = history.filter((sample) => t - sample.t <= VELOCITY_WINDOW_MS);
kept.push({ t, x, y });
return kept.slice(-12);
}
// Bring `delta`, known only modulo `period`, to the value nearest `expected`.
function unwrap(delta, period, expected) {
return delta - period * Math.round((delta - expected) / period);
}
function median(values) {
const sorted = values.slice().sort((a, b) => a - b);
return sorted[sorted.length >> 1];
}
// The shapes hardly move, so how they shift on screen between frames tells
// how the camera (the player) moved. The background grid scrolls the same
// way but repeats every cell, so it is only used when no shapes are in
// view, and only for plausible small steps.
function updateCameraVelocity() {
const dt = (aim.entityTime - aim.processedTime) / 1000;
const steady = dt > 0 && dt < 0.25;
const grid = aim.grid;
const prevGrid = aim.prevGrid;
aim.prevGrid = grid;
const shapes = aim.shapes;
const prevShapes = aim.prevShapes;
aim.prevShapes = shapes;
const maxStep =
MAX_SPEED_IN_TANK_RADII * aim.selfRadius * Math.max(dt, 0.016);
let dx = null;
let dy = null;
if (steady && shapes.length >= 3 && prevShapes.length >= 3) {
const dxs = [];
const dys = [];
for (const shape of shapes) {
let best = null;
let bestDist = Math.min(40, maxStep * 1.5);
for (const old of prevShapes) {
const d = Math.hypot(shape.x - old.x, shape.y - old.y);
if (d < bestDist) {
bestDist = d;
best = old;
}
}
if (best) {
dxs.push(shape.x - best.x);
dys.push(shape.y - best.y);
}
}
if (dxs.length >= 3) {
dx = -median(dxs);
dy = -median(dys);
}
}
if (
dx === null &&
steady &&
grid &&
prevGrid &&
grid.zoom > 0 &&
Math.abs(grid.zoom - prevGrid.zoom) < 1e-4
) {
const cell = GRID_CELL * grid.zoom;
const ux = unwrap(grid.e - prevGrid.e, cell, -aim.cameraVx * dt);
const uy = unwrap(grid.f - prevGrid.f, cell, -aim.cameraVy * dt);
// Only trust the grid for steps well inside one cell.
if (Math.hypot(ux, uy) < cell * 0.35) {
dx = -ux;
dy = -uy;
}
}
if (dx === null || Math.hypot(dx, dy) > maxStep) {
aim.cameraVx = 0;
aim.cameraVy = 0;
aim.cameraTrack = [];
return;
}
aim.cameraX += dx;
aim.cameraY += dy;
aim.cameraTrack = extendHistory(
aim.cameraTrack,
aim.entityTime,
aim.cameraX,
aim.cameraY,
);
[aim.cameraVx, aim.cameraVy] = windowedVelocity(aim.cameraTrack);
}
// `enemies` are all enemy tanks; `visible` those on the visible screen. The
// current target is followed wherever it is drawn; a new one must be
// visible, since the game also draws a margin the player cannot see.
// The player's own bullets are followed from the barrel. Each gives a
// bullet speed sample, since speed differs a lot between tanks and bullet
// speed upgrades (a Ranger's bullets fly twice as fast as a starting
// tank's). Speed is kept in tank radii per second so it survives zoom
// changes, and it is the average over the first second, as the lead
// needs. Drones and traps are left out by asking for a straight, fast
// path. A bullet fired while a target was locked is also a shot: how
// close it passed, and whether ahead of or behind the target, is logged
// and teaches the lead (see finishShot).
function updateBulletTracks(self) {
if (!self) return;
const t = aim.entityTime;
const tracks = aim.bulletTracks;
const target = aim.target;
for (const track of tracks) track.seen = false;
for (const bullet of aim.entities) {
if (bullet.tank || bullet.color !== aim.selfColor) continue;
let best = null;
let bestDist = Infinity;
for (const track of tracks) {
if (track.seen) continue;
const dt = (t - track.t) / 1000;
const d = Math.hypot(
bullet.x - (track.x + track.vx * dt),
bullet.y - (track.y + track.vy * dt),
);
if (d < bestDist) {
bestDist = d;
best = track;
}
}
const limit = best && best.samples > 0 ? bullet.r * 3 : bullet.r * 7;
if (best && bestDist < limit) {
const dt = (t - best.t) / 1000;
if (dt > 0) {
// The bullet's world displacement is its screen displacement plus
// the camera's.
const dx = bullet.x - best.x + (aim.cameraX - best.camX);
const dy = bullet.y - best.y + (aim.cameraY - best.camY);
best.vx = (bullet.x - best.x) / dt;
best.vy = (bullet.y - best.y) / dt;
best.path += Math.hypot(dx, dy);
best.worldX += dx;
best.worldY += dy;
best.samples++;
const bucket = Math.min(
BULLET_PROFILE_BUCKETS - 1,
Math.floor((t - best.born) / BULLET_PROFILE_BUCKET_MS),
);
best.reach[bucket] = Math.max(
best.reach[bucket] || 0,
Math.hypot(best.worldX, best.worldY) / best.radius,
);
if (best.shot && best.shot.stray === null) checkShotDirection(best);
}
best.x = bullet.x;
best.y = bullet.y;
best.r = bullet.r;
best.t = t;
best.camX = aim.cameraX;
best.camY = aim.cameraY;
best.seen = true;
if (best.shot) followShot(best, target);
} else if (
Math.hypot(bullet.x - self.x, bullet.y - self.y) <
self.r * 4
) {
// A new bullet at the barrel.
// Where it appears is where its flight starts, so that distance
// comes off every shot's flight.
aim.muzzleSamples.push(
Math.hypot(bullet.x - self.x, bullet.y - self.y) / self.r,
);
if (aim.muzzleSamples.length > 30) aim.muzzleSamples.shift();
if (aim.muzzleSamples.length >= 5) {
aim.muzzleRadii = median(aim.muzzleSamples.slice());
}
const track = {
x: bullet.x,
y: bullet.y,
r: bullet.r,
t,
vx: 0,
vy: 0,
camX: aim.cameraX,
camY: aim.cameraY,
born: t,
worldX: 0,
worldY: 0,
path: 0,
samples: 0,
radius: self.r,
reach: [], // radii flown, by 100 ms of flight
seen: true,
measured: false,
shot: null,
};
if (target && aim.lastLead) {
// The mouse position reaches the server a fifth of a second late,
// so the bullet was aimed with the aim point from back then.
const aimAtFiring = aimPointBefore(AIM_LATENCY_SECONDS * 1000);
track.shot = {
targetId: target.id,
r: target.r,
lead: aim.lastLead,
aimX: aimAtFiring.x,
aimY: aimAtFiring.y,
fromX: self.x,
fromY: self.y,
t0x: target.x, // where the target was when the shot left
t0y: target.y,
cam0x: aim.cameraX,
cam0y: aim.cameraY,
enemyId: target.id,
playerAim:
aim.passive && aimAtFiring.mx !== null
? { x: aimAtFiring.mx, y: aimAtFiring.my }
: null,
chance: shotChance(aim.lastLead.time), // the ring's reading at firing
auto: aim.autoFire, // the game's auto fire was on, as far as known
stray: null, // set once the bullet's direction is known
minGap: Infinity,
lastGap: Infinity,
closest: null,
};
}
aim.lastBirth = t;
// Births with the button up tell whether auto fire is really on
// (a teammate's bullet cannot: it appears at their barrel, not ours).
if (!aim.userHold) {
aim.birthTimes.push(t);
if (aim.birthTimes.length > 6) aim.birthTimes.shift();
}
tracks.push(track);
}
}
for (let i = tracks.length - 1; i >= 0; i--) {
const track = tracks[i];
const age = t - track.born;
if (!track.measured && (!track.seen || age >= BULLET_MEASURE_MAX_MS)) {
track.measured = true;
recordBulletSpeed(track, age);
}
if (track.seen && age < SHOT_MAX_MS) continue;
tracks.splice(i, 1);
mergeBulletProfile(track);
if (track.shot) finishShot(track, !track.seen);
}
}
// Bullets start slow, speed up and then slow down again, so one average
// speed misjudges long shots. The profile keeps how far a bullet has
// flown after each tenth of a second, from every straight bullet seen,
// and the lead reads the flight time to any distance off it.
function mergeBulletProfile(track) {
if (track.samples < 5) return;
const net = Math.hypot(track.worldX, track.worldY);
if (net < track.path * 0.85) return; // drones wander, traps stop
const profile = aim.bulletProfile;
for (let b = 0; b < track.reach.length; b++) {
const reach = track.reach[b];
if (reach === undefined) continue;
profile[b] =
profile[b] === undefined ? reach : profile[b] * 0.8 + reach * 0.2;
}
}
function bulletTravelTime(distRadii) {
const profile = aim.bulletProfile;
const step = BULLET_PROFILE_BUCKET_MS / 1000;
let prevBucket = -1;
let prevReach = 0;
for (let b = 0; b < profile.length; b++) {
const reach = profile[b];
if (reach === undefined) continue;
if (reach >= distRadii) {
const prevAge = (prevBucket + 1) * step;
const age = (b + 1) * step;
const part =
(distRadii - prevReach) / Math.max(reach - prevReach, 1e-6);
return prevAge + (age - prevAge) * part;
}
prevBucket = b;
prevReach = reach;
}
// Further than any bullet was followed: carry on at the last speed seen.
let speed = aim.bulletSpeed;
const earlier = profile[prevBucket - 2];
if (prevBucket >= 2 && earlier !== undefined) {
speed = Math.max(3, (prevReach - earlier) / (2 * step));
}
return (
(prevBucket + 1) * step + (distRadii - prevReach) / Math.max(speed, 1)
);
}
function recordBulletSpeed(track, age) {
if (age < BULLET_MEASURE_MIN_MS || track.samples < 5) return;
const net = Math.hypot(track.worldX, track.worldY);
if (net < track.path * 0.85) return; // drones wander, traps stop
const speed = net / (age / 1000) / track.radius;
if (speed < 5 || speed > 60) return;
aim.bulletSpeeds.push(speed);
if (aim.bulletSpeeds.length > 15) aim.bulletSpeeds.shift();
aim.bulletSpeed = median(aim.bulletSpeeds.slice());
}
// A bullet fired just after the aim moved still flies the old way (the
// mouse reaches the server a fraction of a second late), and says nothing
// about the lead. Once the bullet has flown a little, its direction is
// compared with the direction it was meant to take.
function aimPointBefore(ms) {
const wanted = aim.entityTime - ms;
let best = null;
for (const point of aim.aimHistory) {
if (
best === null ||
Math.abs(point.t - wanted) < Math.abs(best.t - wanted)
) {
best = point;
}
}
return best || { x: aim.aimX, y: aim.aimY, mx: aim.mouseX, my: aim.mouseY };
}
function checkShotDirection(track) {
const shot = track.shot;
if (Math.hypot(track.worldX, track.worldY) < track.radius * 1.5) return;
const flown = Math.atan2(track.worldY, track.worldX);
const meant = Math.atan2(shot.aimY - shot.fromY, shot.aimX - shot.fromX);
let diff = Math.abs(flown - meant);
if (diff > Math.PI) diff = 2 * Math.PI - diff;
shot.stray = diff > (12 * Math.PI) / 180;
}
// Note how close the bullet is to its target this frame. Gaps are edge to
// edge, so a negative gap means the two overlap.
function followShot(track, target) {
const shot = track.shot;
if (!target || target.id !== shot.targetId) return;
if (
shot.arrival === undefined &&
aim.entityTime - track.born >= shot.lead.time * 1000
) {
// Where the target really is when the bullet was due to arrive.
shot.arrival = {
tx: target.x,
ty: target.y,
camX: aim.cameraX,
camY: aim.cameraY,
};
}
const relX = track.x - target.x;
const relY = track.y - target.y;
const gap = Math.hypot(relX, relY) - target.r - track.r;
shot.lastGap = gap;
shot.r = target.r;
if (gap < shot.minGap) {
shot.minGap = gap;
shot.closeAge = aim.entityTime - track.born;
shot.closest = {
relX,
relY,
vx: (target.vx || 0) + aim.cameraVx,
vy: (target.vy || 0) + aim.cameraVy,
tx: target.x,
ty: target.y,
camX: aim.cameraX,
camY: aim.cameraY,
};
}
}
// The shot is over: log it, and learn from it. Distances are in own tank
// radii. `along` is how far ahead (+) or behind (-) the target the bullet
// passed, measured along the target's motion; ahead means the lead was
// too long, behind too short, and the lead scale moves a little towards
// the value that would have hit.
function finishShot(track, vanished) {
const shot = track.shot;
const R = aim.selfRadius || 1;
const entry = {
t: Math.round(track.born),
flightMs: Math.round(aim.entityTime - track.born),
dist: +(shot.lead.dist / R).toFixed(1),
targetSpeed: +(shot.lead.speed / R).toFixed(1),
lead: +(shot.lead.px / R).toFixed(1),
leadTime: +shot.lead.time.toFixed(2),
bulletSpeed: +aim.bulletSpeed.toFixed(1),
leadAdjust: +aim.leadAdjust.toFixed(2),
leadFraction: +(shot.lead.fraction || 1).toFixed(2),
dodgeRate: +aim.dodgeRate.toFixed(2),
result: "lost",
stray: !!shot.stray, // fired the way the aim pointed a moment earlier
chance: typeof shot.chance === "number" ? +shot.chance.toFixed(2) : null,
auto: !!shot.auto,
};
if (shot.closest) {
const c = shot.closest;
// A bullet that vanished while touching the target hit it.
const hit = vanished && shot.lastGap < track.r * 2;
// No verdict when the target left view before the bullet was due
// and was never touched: that shot is lost, not missed.
const unresolved = !hit && !shot.arrival && shot.minGap > track.r;
entry.result = hit ? "hit" : unresolved ? "lost" : "miss";
entry.gap = +(shot.minGap / R).toFixed(2);
entry.closeMs = Math.round(shot.closeAge);
const speed = Math.hypot(c.vx, c.vy);
if (speed > R) {
const ux = c.vx / speed;
const uy = c.vy / speed;
entry.along = +((c.relX * ux + c.relY * uy) / R).toFixed(2);
entry.across = +((c.relX * uy - c.relY * ux) / R).toFixed(2);
// Only a shot at a target that kept its velocity while the bullet
// flew says anything about the lead: a target that turns back reads
// as "bullet passed behind" whatever the lead was. Such a shot asks
// for the lead correction that would have hit (a hit asks for about
// what was used), and the correction becomes the median of the
// recent requests, which no single wild shot can move far.
const steady =
Math.hypot(c.vx - shot.lead.vx, c.vy - shot.lead.vy) <
Math.max(speed * 0.5, R * 1.5);
entry.steady = steady;
// A slow target cannot tell the lead apart from noise, and neither
// can one moving along the line of fire: only motion across it turns
// a timing error into a miss.
const flown = Math.hypot(track.worldX, track.worldY) || 1;
const lateral = Math.abs(
(track.worldX * uy - track.worldY * ux) / flown,
);
entry.lateral = +lateral.toFixed(2);
entry.targetR = +(shot.r / R).toFixed(2);
// How much of the predicted lead the target really covered by the
// time the bullet was due: 1 means it went where the lead said, less
// means it braked or turned back, negative means it reversed. A
// target that dodges covers less on average, and the lead is scaled
// by the median of that, which the sideways part of a dodge cannot
// shift. The position at the due time is used, not the one at the
// closest pass, because a braking target is passed early, which
// would make every dodge look bigger than it was.
const lead = shot.lead;
const fraction = lead.fraction || 1;
const fullLead = lead.fullPx || lead.px / fraction; // before the cut
const at = shot.arrival || c;
if (!shot.stray && fullLead > R && lead.speed > R * 3) {
const u0x = lead.vx / lead.speed;
const u0y = lead.vy / lead.speed;
const dispX = at.tx - shot.t0x + (at.camX - shot.cam0x);
const dispY = at.ty - shot.t0y + (at.camY - shot.cam0y);
const progress = (dispX * u0x + dispY * u0y) / fullLead;
entry.progress = +progress.toFixed(2);
const turn = Math.acos(
Math.min(1, Math.max(-1, u0x * ux + u0y * uy)),
);
entry.turnDeg = Math.round((turn * 180) / Math.PI);
// Only a shot with a lead long enough to measure, followed
// through to its due time, can teach the rate.
if (
shot.arrival &&
fullLead > R * 3 &&
lead.time >= 0.35 &&
speed > R * 3 &&
lateral > 0.7
) {
const rate = (1 - progress) / lead.time; // lead lost per second
aim.progressSamples.push(
Math.min(DODGE_RATE_MAX, Math.max(DODGE_RATE_MIN, rate)),
);
if (aim.progressSamples.length > PROGRESS_SAMPLES) {
aim.progressSamples.shift();
}
if (
aim.progressSamples.length >= 6 &&
aim.trackRate === null &&
!aim.game.dodgeProfile
) {
aim.dodgeRate = median(aim.progressSamples.slice());
}
entry.newDodgeRate = +aim.dodgeRate.toFixed(2);
}
}
// A shot that missed by several radii carries no timing: it went
// to the wrong target, or the target did something else entirely.
const teachable = !shot.stray;
const nearMiss =
Math.abs(entry.along) < 4 && Math.abs(entry.across) < 3;
if (
steady &&
teachable &&
nearMiss &&
speed > R * 3 &&
lead.speed > R * 3 &&
lateral > 0.7 &&
lead.time > 0.05
) {
// Seconds early (+) or late (-), as if the full lead had been used:
// the part the lead fraction took off is given back first.
const early =
(entry.along * R) / speed + (fullLead - lead.px) / speed;
const asked = Math.min(0.6, Math.max(-0.6, lead.adjust - early));
aim.leadSamples.push(asked);
if (aim.leadSamples.length > LEAD_SAMPLES) aim.leadSamples.shift();
if (aim.leadSamples.length >= 4) {
aim.leadAdjust = Math.min(
LEAD_ADJUST_MAX,
Math.max(LEAD_ADJUST_MIN, median(aim.leadSamples.slice())),
);
}
entry.asked = +asked.toFixed(2);
entry.newAdjust = +aim.leadAdjust.toFixed(2);
}
}
}
entry.enemy = shot.enemyId;
const record = enemyRecord(shot.enemyId);
if (record) {
record.shots++;
if (entry.result === "hit") record.hits++;
if (entry.newDodgeRate !== undefined) {
record.dodgeRates.push((1 - entry.progress) / shot.lead.time);
if (record.dodgeRates.length > 40) record.dodgeRates.shift();
}
}
if (shot.playerAim && shot.lead.px > 0) {
const own =
Math.hypot(shot.playerAim.x - shot.t0x, shot.playerAim.y - shot.t0y) /
R;
const vsModel =
Math.hypot(shot.playerAim.x - shot.aimX, shot.playerAim.y - shot.aimY) /
R;
entry.playerLead = +own.toFixed(2);
entry.playerVsModel = +vsModel.toFixed(2);
aim.game.playerLead.push({
lead: own,
modelLead: shot.lead.px / R,
vsModel,
});
if (aim.game.playerLead.length > 300) aim.game.playerLead.shift();
}
aim.shotLog.push(entry);
if (aim.shotLog.length > SHOT_LOG_SIZE) aim.shotLog.shift();
aim.sessionShots++;
if (entry.result === "hit") aim.sessionHits++;
const stats = aim.shotStats;
stats.shots++;
if (entry.result === "hit") stats.hits++;
else if (entry.result === "miss") stats.misses++;
else stats.lost++;
if (entry.stray) stats.stray++;
if (localStorage.getItem("mm_aim_verbose") === "true") {
console.log("[mm aim] shot", JSON.stringify(entry));
}
saveAimLearning();
}
// What the shots taught survives a reload: the lead scale, the counts and
// the last hundred shot records (see localStorage "mm_shot_log").
function saveAimLearning() {
try {
localStorage.setItem("mm_lead_adjust", String(aim.leadAdjust));
localStorage.setItem("mm_dodge_rate", String(aim.dodgeRate));
localStorage.setItem(
"mm_progress_samples",
JSON.stringify(aim.progressSamples),
);
localStorage.setItem("mm_lead_samples", JSON.stringify(aim.leadSamples));
localStorage.setItem("mm_shot_stats", JSON.stringify(aim.shotStats));
localStorage.setItem(
"mm_shot_log",
JSON.stringify(aim.shotLog.slice(-400)),
);
} catch (error) {
// Storage full or blocked: learning is then kept for this page only.
}
}
function loadAimLearning() {
try {
const rate = parseFloat(localStorage.getItem("mm_dodge_rate"));
if (rate >= DODGE_RATE_MIN && rate <= DODGE_RATE_MAX) {
aim.dodgeRate = rate;
}
const progress = JSON.parse(
localStorage.getItem("mm_progress_samples") || "[]",
);
if (Array.isArray(progress)) {
aim.progressSamples = progress
.filter(
(p) =>
typeof p === "number" &&
p >= DODGE_RATE_MIN &&
p <= DODGE_RATE_MAX,
)
.slice(-PROGRESS_SAMPLES);
}
const savedVersion = localStorage.getItem("mm_version");
if (savedVersion !== SCRIPT_VERSION) {
// The aim changed, so the timing learned with the old one is stale.
localStorage.setItem("mm_version", SCRIPT_VERSION);
localStorage.removeItem("mm_lead_adjust");
localStorage.removeItem("mm_lead_samples");
}
const adjust = parseFloat(localStorage.getItem("mm_lead_adjust"));
if (adjust >= LEAD_ADJUST_MIN && adjust <= LEAD_ADJUST_MAX) {
aim.leadAdjust = adjust;
}
const samples = JSON.parse(
localStorage.getItem("mm_lead_samples") || "[]",
);
if (Array.isArray(samples)) {
aim.leadSamples = samples
.filter((s) => typeof s === "number" && s >= -0.6 && s <= 0.6)
.slice(-LEAD_SAMPLES);
}
const stats = JSON.parse(localStorage.getItem("mm_shot_stats") || "null");
if (stats && typeof stats.shots === "number") {
aim.shotStats = {
shots: 0,
hits: 0,
misses: 0,
lost: 0,
stray: 0,
...stats,
};
}
const log = JSON.parse(localStorage.getItem("mm_shot_log") || "[]");
if (Array.isArray(log)) aim.shotLog = log;
} catch (error) {
// Nothing saved yet, or storage blocked.
}
}
function resetAimLearning() {
aim.game = newGameLog();
aim.traj = [];
aim.leadAdjust = 0;
aim.leadSamples = [];
aim.dodgeRate = DEFAULT_DODGE_RATE;
aim.fractions = null;
aim.rateById = {};
aim.progressSamples = [];
aim.shotLog = [];
aim.shotStats = { shots: 0, hits: 0, misses: 0, lost: 0, stray: 0 };
aim.statusTime = -1e9;
saveAimLearning();
updateAimStatus();
}
function updateAimStatus() {
if (aim.entityTime - aim.statusTime < 500) return;
aim.statusTime = aim.entityTime;
const stats = aim.shotStats;
const gaps = aim.shotLog
.filter((entry) => entry.gap !== undefined)
.map((entry) => entry.gap);
const pct = stats.shots ? Math.round((100 * stats.hits) / stats.shots) : 0;
const miss = gaps.length ? median(gaps).toFixed(1) : "-";
aim_status_text.textContent =
`Aim log: ${stats.shots} shots lifetime, ${stats.hits} hits (${pct}%), ` +
`this session ${aim.sessionShots} shots ${aim.sessionHits} hits, ` +
`median miss ${miss} radii, lead ${aim.leadAdjust >= 0 ? "+" : ""}${aim.leadAdjust.toFixed(2)} s, dodge ${aim.dodgeRate.toFixed(2)}/s${aim.trackRate === null ? "" : " (track)"}, ` +
`bullets ${aim.bulletSpeed.toFixed(0)} radii/s`;
}
// Where a followed enemy should be on screen now: its last world position
// carried on at its last world velocity, at full speed for a second and
// then fading, minus where the camera is now.
function ghostScreenPosition(e, t) {
const age = Math.max(0, (t - e.time) / 1000);
const run =
Math.min(age, 1) +
GHOST_TAU_SECONDS *
(1 - Math.exp(-Math.max(0, age - 1) / GHOST_TAU_SECONDS));
return [
e.worldX + e.worldVx * run - aim.cameraX,
e.worldY + e.worldVy * run - aim.cameraY,
];
}
// Follow every drawn enemy tank from frame to frame under one id, and
// keep the ones no longer drawn as ghosts for a while, so an enemy that
// steps out of the game's box and back keeps its id (and what was learned
// about it) and can be pointed at meanwhile.
function trackEnemies(enemies, gameCanvas) {
const t = aim.entityTime;
const tracked = aim.tracked;
for (const e of tracked) e.seen = false;
const pairs = [];
for (const enemy of enemies) {
for (const e of tracked) {
const away = Math.min(GHOST_MS, t - e.time) / 1000;
// It may have carried on (the ghost), or turned back and come in
// where it went out (its last seen spot): the nearer counts.
const [px, py] = ghostScreenPosition(e, t);
const lx = e.worldX - aim.cameraX;
const ly = e.worldY - aim.cameraY;
const gate =
Math.max(e.r * 2, 40) +
GHOST_GATE_RADII_PER_SECOND * aim.selfRadius * away;
const d = Math.min(
Math.hypot(enemy.x - px, enemy.y - py),
Math.hypot(enemy.x - lx, enemy.y - ly),
);
if (d < gate) pairs.push({ d, enemy, e });
}
}
pairs.sort((a, b) => a.d - b.d);
for (const p of pairs) {
if (p.enemy.id !== undefined || p.e.seen) continue;
const e = p.e;
if (t - e.time > TARGET_GRACE_MS * 2) aim.game.ghostReturns++;
e.seen = true;
e.x = p.enemy.x;
e.y = p.enemy.y;
e.r = p.enemy.r;
e.history = extendHistory(e.history, t, e.x, e.y);
[e.vx, e.vy] = windowedVelocity(e.history);
e.time = t;
e.worldX = e.x + aim.cameraX;
e.worldY = e.y + aim.cameraY;
e.worldVx = e.vx + aim.cameraVx;
e.worldVy = e.vy + aim.cameraVy;
p.enemy.id = e.id;
}
for (const enemy of enemies) {
if (enemy.id !== undefined) continue;
enemy.id = ++aim.targetSeq;
tracked.push({
id: enemy.id,
x: enemy.x,
y: enemy.y,
r: enemy.r,
vx: 0,
vy: 0,
history: [{ t, x: enemy.x, y: enemy.y }],
time: t,
worldX: enemy.x + aim.cameraX,
worldY: enemy.y + aim.cameraY,
worldVx: aim.cameraVx,
worldVy: aim.cameraVy,
seen: true,
});
}
// Ghosts go when too old, or when their guessed spot is well inside the
// view and nothing is there: that enemy died or the guess was wrong.
const W = gameCanvas.width;
const H = gameCanvas.height;
for (let i = tracked.length - 1; i >= 0; i--) {
const e = tracked[i];
if (e.seen) continue;
const age = t - e.time;
const [px, py] = ghostScreenPosition(e, t);
const m = e.r * 3;
const inside = px > m && px < W - m && py > m && py < H - m;
if (age > GHOST_MS || (inside && age > TARGET_GRACE_MS * 2)) {
tracked.splice(i, 1);
}
}
if (tracked.length > 40) tracked.splice(0, tracked.length - 40);
}
// Follow enemy bullets. One whose line passes near the player with no
// drawn tank behind it was fired by someone out of view: remember the
// direction it came from for a moment.
function trackEnemyBullets(self, enemies, gameCanvas) {
const t = aim.entityTime;
const R = aim.selfRadius || 1;
const tracks = aim.enemyBullets;
for (const track of tracks) track.seen = false;
for (const bullet of aim.entities) {
if (bullet.tank || bullet.color === aim.selfColor) continue;
if (SHAPE_COLORS.includes(bullet.color) || isGray(bullet.color)) continue;
let best = null;
let bestDist = Infinity;
for (const track of tracks) {
if (track.seen || track.color !== bullet.color) continue;
const dt = (t - track.t) / 1000;
const d = Math.hypot(
bullet.x - (track.x + track.vx * dt),
bullet.y - (track.y + track.vy * dt),
);
if (d < bestDist) {
bestDist = d;
best = track;
}
}
const limit = best && best.samples > 0 ? bullet.r * 3 : bullet.r * 7;
if (best && bestDist < limit) {
const dt = (t - best.t) / 1000;
if (dt > 0) {
best.vx = (bullet.x - best.x) / dt;
best.vy = (bullet.y - best.y) / dt;
best.samples++;
}
best.x = bullet.x;
best.y = bullet.y;
best.t = t;
best.seen = true;
} else {
tracks.push({
x: bullet.x,
y: bullet.y,
r: bullet.r,
color: bullet.color,
t,
vx: 0,
vy: 0,
samples: 0,
seen: true,
judged: false,
});
}
}
for (let i = tracks.length - 1; i >= 0; i--) {
const track = tracks[i];
if (!track.seen || t - track.t > 500) {
tracks.splice(i, 1);
continue;
}
if (track.judged || track.samples < 3) continue;
track.judged = true;
// Screen velocity is the bullet's own velocity plus the camera's.
const vx = track.vx + aim.cameraVx;
const vy = track.vy + aim.cameraVy;
const speed = Math.hypot(vx, vy);
if (speed < 5 * R) continue;
const ux = vx / speed;
const uy = vy / speed;
// Does its line pass near the player, and is the player ahead of it?
const relX = self.x - track.x;
const relY = self.y - track.y;
const along = relX * ux + relY * uy;
const across = relX * uy - relY * ux;
if (along < 0 || Math.abs(across) > UNSEEN_FIRE_RADII * R) continue;
// A drawn tank behind it on the line is the shooter: nothing unseen.
let known = false;
for (const enemy of enemies) {
const ex = enemy.x - track.x;
const ey = enemy.y - track.y;
const back = -(ex * ux + ey * uy);
const side = Math.abs(ex * uy - ey * ux);
if (back > -enemy.r && side < enemy.r * 2.5) {
known = true;
break;
}
}
if (known) continue;
// Where the line back to the shooter leaves the screen.
const W = gameCanvas.width;
const H = gameCanvas.height;
let s = Infinity;
if (ux > 0) s = Math.min(s, track.x / ux);
if (ux < 0) s = Math.min(s, (track.x - W) / ux);
if (uy > 0) s = Math.min(s, track.y / uy);
if (uy < 0) s = Math.min(s, (track.y - H) / uy);
if (!isFinite(s)) continue;
const angle = Math.atan2(-uy, -ux);
aim.game.unseenShots++;
const same = aim.fireFrom.find(
(f) => Math.abs(wrapAngle(f.angle - angle)) < Math.PI / 12,
);
if (same) {
same.t = t;
same.angle = angle;
same.x = track.x - ux * s;
same.y = track.y - uy * s;
} else {
aim.fireFrom.push({
t,
angle,
x: track.x - ux * s,
y: track.y - uy * s,
});
}
}
aim.fireFrom = aim.fireFrom.filter((f) => t - f.t < UNSEEN_FIRE_MS);
}
// Edge arrows for enemies drawn past the visible edge, for ghosts, and
// for the directions unseen shooters fired from.
function drawOffscreen() {
const gameCanvas = aim.gameCanvas;
if (!gameCanvas || gameCanvas.clientWidth === 0) return;
const k = gameCanvas.clientWidth / gameCanvas.width;
const W = gameCanvas.width;
const H = gameCanvas.height;
const cx = W / 2;
const cy = H / 2;
const R = aim.selfRadius || 1;
const t = aim.entityTime;
const inset = 28 / k;
// Where the ray from the centre towards (x, y) meets the inset edge.
const edgePoint = (x, y) => {
const dx = x - cx;
const dy = y - cy;
let s = Infinity;
if (dx > 0) s = Math.min(s, (W - inset - cx) / dx);
if (dx < 0) s = Math.min(s, (inset - cx) / dx);
if (dy > 0) s = Math.min(s, (H - inset - cy) / dy);
if (dy < 0) s = Math.min(s, (inset - cy) / dy);
if (!isFinite(s) || s > 1) return null; // inside the view
return [cx + dx * s, cy + dy * s, Math.atan2(dy, dx)];
};
const arrow = (x, y, angle, size, color, label) => {
ctx.save();
ctx.translate(x * k, y * k);
ctx.rotate(angle);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(size, 0);
ctx.lineTo(-size * 0.7, size * 0.6);
ctx.lineTo(-size * 0.7, -size * 0.6);
ctx.closePath();
ctx.fill();
ctx.restore();
if (label) {
ctx.font = "12px sans-serif";
ctx.fillStyle = color;
ctx.textAlign = "center";
const lx = x * k - Math.cos(angle) * (size + 16);
const ly = y * k - Math.sin(angle) * (size + 16) + 4;
ctx.fillText(label, lx, ly);
ctx.textAlign = "start";
}
};
for (const e of aim.tracked) {
const [px, py] = e.seen ? [e.x, e.y] : ghostScreenPosition(e, t);
const hit = edgePoint(px, py);
if (!hit) continue;
const age = e.seen ? 0 : (t - e.time) / 1000;
const dist = Math.hypot(px - cx, py - cy) / R;
const alpha = e.seen
? 0.9
: Math.max(0.3, 0.9 - (0.6 * age * 1000) / GHOST_MS);
const mine = aim.target && aim.target.id === e.id;
const color = e.seen
? `rgba(230, 50, 50, ${alpha})`
: `rgba(200, 120, 40, ${alpha})`;
const label = `${Math.round(dist)}R${e.seen ? "" : ` ${age.toFixed(1)}s`}`;
arrow(hit[0], hit[1], hit[2], mine ? 12 : 9, color, label);
}
for (const f of aim.fireFrom) {
const alpha = Math.max(0.2, 1 - (t - f.t) / UNSEEN_FIRE_MS);
const hit = edgePoint(f.x, f.y) || [f.x, f.y, f.angle];
arrow(
hit[0],
hit[1],
f.angle,
11,
`rgba(240, 190, 30, ${alpha})`,
"fire",
);
}
}
function pickTarget(enemies, visible, cx, cy) {
let nearest = null;
let nearestDist = Infinity;
for (const enemy of visible) {
const d = Math.hypot(enemy.x - cx, enemy.y - cy);
if (d < nearestDist) {
nearestDist = d;
nearest = enemy;
}
}
const prev = aim.target;
if (!prev) return nearest;
// Find the previous target again: it cannot have moved far in one frame.
let same = null;
let sameDist = Math.max(prev.r * 2, 40);
for (const enemy of enemies) {
const d = Math.hypot(enemy.x - prev.x, enemy.y - prev.y);
if (d < sameDist) {
sameDist = d;
same = enemy;
}
}
if (!same) {
// Not seen this frame: keep the lock for a moment before giving up.
if (aim.entityTime - prev.time < TARGET_GRACE_MS) return prev;
return nearest;
}
same.time = aim.entityTime;
same.id = prev.id;
same.history = extendHistory(
prev.history || [],
aim.entityTime,
same.x,
same.y,
);
[same.vx, same.vy] = windowedVelocity(same.history);
// Stay on the current target unless another enemy is much closer.
if (nearest && Math.hypot(same.x - cx, same.y - cy) > nearestDist * 1.5) {
return nearest;
}
return same;
}
function sendAim() {
const gameCanvas = aim.gameCanvas;
if (!gameCanvas || !aim.target || gameCanvas.clientWidth === 0) return;
// Entities are in canvas pixels; mouse events use CSS pixels.
const k = gameCanvas.clientWidth / gameCanvas.width;
window.dispatchEvent(
new MouseEvent("mousemove", {
clientX: aim.aimX * k,
clientY: aim.aimY * k,
bubbles: true,
cancelable: true,
view: window,
}),
);
}
// The menu shows the spawn button; the death screen shows the game-mode
// buttons. Neither shows the player's own tank.
function isVisible(element) {
return element !== null && element.offsetParent !== null;
}
function inMenu() {
return isVisible(document.getElementById("spawn-button"));
}
function isDead() {
return isVisible(document.querySelector(".action-button"));
}
function updateAutoAim(passive) {
aim.passive = passive === true;
aim.enabled = true;
installAimHooks();
const gameCanvas = document.getElementById("canvas");
aim.gameCanvas = gameCanvas;
if (!gameCanvas || gameCanvas.clientWidth === 0 || inMenu()) {
aim.target = null;
return;
}
if (aim.entityTime === aim.processedTime) return; // no new game frame yet
updateCameraVelocity();
aim.processedTime = aim.entityTime;
const cx = gameCanvas.width / 2;
const cy = gameCanvas.height / 2;
const tanks = aim.entities.filter((entity) => entity.tank);
// The player's own tank is the one drawn at the centre of the screen. A
// tank of the colour seen before that sits close to the centre is
// preferred, so an enemy passing over the centre is not taken for it;
// otherwise the nearest is taken. Nothing is learned while dead, when
// the killer is shown at the centre instead.
let self = null;
let selfDist = Infinity;
let selfMatch = false;
const reach = gameCanvas.height * 0.25; // the camera lags when moving
for (const tank of tanks) {
const d = Math.hypot(tank.x - cx, tank.y - cy);
if (d >= reach) continue;
const match = tank.color === aim.selfColor && d < 100;
if (
self === null ||
(match && !selfMatch) ||
(match === selfMatch && d < selfDist)
) {
self = tank;
selfDist = d;
selfMatch = match;
}
}
if (self && !isDead()) {
aim.selfColor = self.color;
aim.selfRadius = self.r;
updateBulletTracks(self);
}
const enemies = tanks.filter(
(tank) => tank !== self && tank.color !== aim.selfColor,
);
const visible = enemies.filter(
(tank) =>
tank.x >= 0 &&
tank.x <= gameCanvas.width &&
tank.y >= 0 &&
tank.y <= gameCanvas.height,
);
updateGameLog(self, visible);
if (offscreen_toggle.checked) {
trackEnemies(enemies, gameCanvas);
if (self) trackEnemyBullets(self, enemies, gameCanvas);
} else {
aim.tracked = [];
aim.enemyBullets = [];
aim.fireFrom = [];
}
// With manual aim, shots are judged against the enemy the player's
// mouse points at (within 20 degrees), else the nearest.
let candidates = visible;
if (aim.passive && aim.mouseX !== null && self) {
const mouseAngle = Math.atan2(aim.mouseY - self.y, aim.mouseX - self.x);
let best = null;
let bestDiff = (20 * Math.PI) / 180;
for (const enemy of visible) {
const angle = Math.atan2(enemy.y - self.y, enemy.x - self.x);
const diff = Math.abs(wrapAngle(angle - mouseAngle));
if (diff < bestDiff) {
bestDiff = diff;
best = enemy;
}
}
if (best) candidates = [best];
}
const target = pickTarget(enemies, candidates, cx, cy);
if (!target) {
aim.target = null;
updateGameStatus();
return;
}
if (target.vx === undefined) {
target.vx = 0;
target.vy = 0;
target.time = aim.entityTime;
target.history = [{ t: aim.entityTime, x: target.x, y: target.y }];
}
if (target.id === undefined) target.id = ++aim.targetSeq;
aim.target = target;
pushTrajectory(target, self);
// The target's real velocity is its velocity on screen plus the
// camera's, so the player's own movement does not shift the lead.
const worldVx = target.vx + aim.cameraVx;
const worldVy = target.vy + aim.cameraVy;
const worldSpeed = Math.hypot(worldVx, worldVy);
const selfX = self ? self.x : cx;
const selfY = self ? self.y : cy;
const distance = Math.hypot(target.x - selfX, target.y - selfY);
let aimX = target.x;
let aimY = target.y;
let leadTime = 0;
let leadFraction = 1;
if (
aim_lead_toggle.checked &&
worldSpeed <= MAX_SPEED_IN_TANK_RADII * aim.selfRadius
) {
// The target's world velocity carries it on for the input and render
// delay (a fifth of a second, measured from key press to camera
// motion) plus the bullet's flight. The bullet has to reach the target
// where it will be, not where it is: one running away is further off
// by the time the bullet gets there, so the flight time is refined a
// few times against the point it leads to (a fleeing tank at half
// the bullet's speed doubles the flight). An implausible speed means
// a bad estimate, and then there is no lead at all. What earlier
// shots taught is added as a correction in seconds (see finishShot).
const muzzle = aim.muzzleRadii * aim.selfRadius;
let travelTime = bulletTravelTime(
Math.max(0, distance - muzzle) / aim.selfRadius,
);
for (let i = 0; i < 5; i++) {
const ahead = AIM_LATENCY_SECONDS + travelTime;
const px = target.x + worldVx * ahead - selfX;
const py = target.y + worldVy * ahead - selfY;
const next = Math.min(
MAX_LEAD_SECONDS,
bulletTravelTime(
Math.max(0, Math.hypot(px, py) - muzzle) / aim.selfRadius,
),
);
const done = Math.abs(next - travelTime) < 0.01;
travelTime = next;
if (done) break;
}
leadTime = Math.min(
MAX_LEAD_SECONDS,
Math.max(0, AIM_LATENCY_SECONDS + travelTime + aim.leadAdjust),
);
// Never lead further than the target is away: beyond that the target
// is outrunning the bullet, or the velocity estimate is wrong.
if (worldSpeed * leadTime > distance) leadTime = distance / worldSpeed;
leadFraction = Math.min(
LEAD_FRACTION_MAX,
Math.max(LEAD_FRACTION_MIN, leadFractionFor(leadTime)),
);
// Dodging only matters across the line of fire: a target running
// straight away is met wherever it is on that line, so the part of
// its motion along the line keeps the full lead (which also keeps
// the flight time right), and only the sideways part is cut.
const ux = (target.x - selfX) / (distance || 1);
const uy = (target.y - selfY) / (distance || 1);
const radial = worldVx * ux + worldVy * uy;
const sideX = worldVx - radial * ux;
const sideY = worldVy - radial * uy;
aimX += (radial * ux + sideX * leadFraction) * leadTime;
aimY += (radial * uy + sideY * leadFraction) * leadTime;
}
aim.lastLead = {
time: leadTime,
px: Math.hypot(aimX - target.x, aimY - target.y),
fullPx: worldSpeed * leadTime, // the lead before the sideways cut
dist: distance,
speed: worldSpeed,
vx: worldVx,
vy: worldVy,
adjust: aim.leadAdjust,
fraction: leadFraction,
};
aim.aimX = aimX;
aim.aimY = aimY;
aim.aimHistory.push({
t: aim.entityTime,
x: aimX,
y: aimY,
mx: aim.mouseX,
my: aim.mouseY,
});
if (aim.aimHistory.length > 40) aim.aimHistory.shift();
updateAimStatus();
updateGameStatus();
if (!aim.passive) sendAim();
}
// Records how the player and the enemies move and fare, for the game log.
function updateGameLog(self, visible) {
const g = aim.game;
const R = aim.selfRadius || 1;
g.frames++;
// The camera moves with the player, so its velocity is the player's.
const speed = Math.hypot(aim.cameraVx, aim.cameraVy) / R;
if (speed > 1) {
g.movingFrames++;
g.speedSum += speed;
const heading = Math.atan2(aim.cameraVy, aim.cameraVx);
if (
g.heading !== null &&
Math.abs(wrapAngle(heading - g.heading)) > Math.PI / 3
) {
g.turns++;
}
g.heading = heading;
}
if (!self) return;
if (visible.length) {
let nearest = Infinity;
for (const enemy of visible) {
nearest = Math.min(
nearest,
Math.hypot(enemy.x - self.x, enemy.y - self.y),
);
}
g.distSum += nearest / R;
g.distFrames++;
}
const flashing = isFlash(self.color);
if (flashing && !aim.selfFlash) g.hitsTaken++;
aim.selfFlash = flashing;
// Enemy bullets: near misses on the player, and how much the enemy that
// fired them was leading the player (from where the bullet leaves).
for (const entity of aim.entities) {
if (entity.tank || entity.color === aim.selfColor) continue;
if (SHAPE_COLORS.includes(entity.color) || isGray(entity.color)) continue;
if (
Math.hypot(entity.x - self.x, entity.y - self.y) <
self.r + entity.r + R * 1.5
) {
g.nearMissFrames++;
}
for (const enemy of visible) {
const dx = entity.x - enemy.x;
const dy = entity.y - enemy.y;
const d = Math.hypot(dx, dy);
if (d < enemy.r * 1.2 || d > enemy.r * 2.2) continue; // just leaving the barrel
const toMe = Math.atan2(self.y - enemy.y, self.x - enemy.x);
const delta = wrapAngle(Math.atan2(dy, dx) - toMe);
if (Math.abs(delta) > Math.PI / 4) continue; // not fired at the player
const dist = Math.hypot(self.x - enemy.x, self.y - enemy.y);
// The player's velocity across the line of fire, as the enemy sees it.
const vx = aim.cameraVx - (enemy.vx || 0);
const vy = aim.cameraVy - (enemy.vy || 0);
const across = -Math.sin(toMe) * vx + Math.cos(toMe) * vy;
if (Math.abs(across) < R * 2) continue;
const lead = (dist * Math.tan(delta)) / across; // seconds of lead
if (lead > -1 && lead < 2) {
g.enemyLead.push(+lead.toFixed(2));
if (g.enemyLead.length > 400) g.enemyLead.shift();
const record = enemyRecord(enemy.id);
if (record) {
record.leadOnMe.push(+lead.toFixed(2));
if (record.leadOnMe.length > 100) record.leadOnMe.shift();
}
}
}
}
}
function enemyRecord(id) {
if (id === undefined) return null;
const enemies = aim.game.enemies;
if (!enemies[id]) {
enemies[id] = {
shots: 0,
hits: 0,
dodgeRates: [],
leadOnMe: [],
seenFrames: 0,
};
}
return enemies[id];
}
function pushTrajectory(target, self) {
const record = enemyRecord(target.id);
if (record) record.seenFrames++;
aim.traj.push({
t: aim.entityTime,
x: target.x + aim.cameraX,
y: target.y + aim.cameraY,
sx: (self ? self.x : 0) + aim.cameraX,
sy: (self ? self.y : 0) + aim.cameraY,
id: target.id,
R: aim.selfRadius,
});
if (aim.traj.length > 20000) aim.traj.splice(0, 2000);
if (aim.entityTime - aim.profileTime > 15000 && aim.traj.length > 600) {
aim.profileTime = aim.entityTime;
aim.game.dodgeProfile = dodgeProfile(aim.traj);
}
// A new target starts from what this lobby's enemies have done so far
// (the session profile), or the default; after two seconds its own
// track says how much it really dodges, which is far quicker than
// waiting for shots at it to be judged.
if (target.id !== aim.trackRateId) {
aim.trackRateId = target.id;
aim.trackRate = null;
aim.trackRateTime = aim.entityTime - 1000;
const known = aim.rateById[target.id];
if (known !== undefined) {
// Back on a target seen before: pick up its rate where it was left.
aim.dodgeRate = known.rate;
aim.trackRate = known.rate;
aim.fractions = known.fractions;
} else {
aim.fractions = sessionFractions();
const dp = aim.game.dodgeProfile;
const prior =
dp && dp[0.6] && dp[0.6].covered !== null
? Math.min(
DODGE_RATE_MAX,
Math.max(DODGE_RATE_MIN, (1 - dp[0.6].covered) / 0.6),
)
: DEFAULT_DODGE_RATE;
aim.dodgeRate = (aim.dodgeRate + prior) / 2;
}
}
if (aim.entityTime - aim.trackRateTime > 2000) {
aim.trackRateTime = aim.entityTime;
const read = trackDodgeRate(target.id);
if (read !== null) {
// A short track is a weak reading: weigh it against what the rate
// was, in proportion to how much track there is, and never let one
// reading move it more than a little.
const weight = Math.min(1, read.samples / 300);
let rate = aim.dodgeRate + (read.rate - aim.dodgeRate) * weight;
rate = Math.max(
aim.dodgeRate - 0.15,
Math.min(aim.dodgeRate + 0.15, rate),
);
aim.trackRate = rate;
aim.dodgeRate = rate;
const fractions = {};
for (const T of [0.6, 1, 1.5]) {
const was =
aim.fractions && aim.fractions[T] !== undefined
? aim.fractions[T]
: Math.min(1, Math.max(0.3, 1 - rate * T));
const got = read.fractions[T];
if (got === undefined) {
fractions[T] = was;
continue;
}
let next = was + (got - was) * weight;
next = Math.max(was - 0.15, Math.min(was + 0.15, next));
fractions[T] = next;
}
aim.fractions = fractions;
aim.rateById[target.id] = { rate, fractions };
if (Object.keys(aim.rateById).length > 60) aim.rateById = {};
}
}
}
// The dodge rate of one target from its last 15 s of movement: how much
// of a straight-line lead it covered over 0.6 s and 1.0 s.
// What this lobby's enemies have asked for so far, from the session
// profile, for a target not yet seen.
function sessionFractions() {
const dp = aim.game.dodgeProfile;
if (!dp) return null;
const out = {};
let any = false;
for (const T of [0.6, 1, 1.5]) {
if (dp[T] && dp[T].best !== undefined) {
out[T] = dp[T].best;
any = true;
}
}
return any ? out : null;
}
// The lead fraction for a flight time, read between the measured ones.
function leadFractionFor(leadTime) {
const f = aim.fractions;
if (!f) {
return Math.min(
LEAD_FRACTION_MAX,
Math.max(LEAD_FRACTION_MIN, 1 - aim.dodgeRate * leadTime),
);
}
const grid = [0.6, 1, 1.5].filter((T) => f[T] !== undefined);
if (!grid.length) return 1;
if (leadTime <= grid[0]) return Math.max(f[grid[0]], 0.9); // short flights: near full lead
for (let i = 1; i < grid.length; i++) {
if (leadTime <= grid[i]) {
const a = f[grid[i - 1]];
const b = f[grid[i]];
const part = (leadTime - grid[i - 1]) / (grid[i] - grid[i - 1]);
return a + (b - a) * part;
}
}
return f[grid[grid.length - 1]];
}
// The dodge rate of one target from its last 15 s of movement. Rather
// than the average share of a straight lead it covered (most targets
// either run on or turn back, so the average lands between the two and
// hits neither), the lead fraction that would have hit most often over
// 0.6 s, 1.0 s and 1.5 s flights is found by trying each (see
// bestFractionOf for how near-ties are settled), and turned into a
// rate.
// The lead fraction to take from a count of would-hits per tried
// fraction. The single best step is a poor pick when the counts are
// flat or two-humped (a circling target lands as often at 0.5 as at 1.0
// and hardly at all between): each reading then jumps to one end. So
// the fractions within a tenth of the best count are averaged, weighted
// by their counts, which settles between the humps and still lands on
// a clear peak.
function bestFractionOf(hitsByStep) {
let best = 0;
for (const h of hitsByStep) if (h > best) best = h;
if (best <= 0) return 1;
let sum = 0;
let weight = 0;
for (let s = 0; s < FRACTION_STEPS.length; s++) {
if (hitsByStep[s] >= best * 0.9) {
sum += FRACTION_STEPS[s] * hitsByStep[s];
weight += hitsByStep[s];
}
}
return sum / weight;
}
function trackDodgeRate(id) {
const traj = aim.traj;
const since = aim.entityTime - 15000;
let start = traj.length;
while (
start > 0 &&
traj[start - 1].id === id &&
traj[start - 1].t >= since
) {
start--;
}
const seg = traj.slice(start);
if (seg.length < 90) return null;
const R = seg[seg.length - 1].R || 1;
const rates = [];
const fractions = {};
let samples = 0;
for (const T of [0.6, 1, 1.5]) {
const rows = [];
let j = 0;
for (let i = 10; i < seg.length; i++) {
const f = seg[i];
let k = i;
while (k > 0 && f.t - seg[k].t < 150) k--;
const dt = (f.t - seg[k].t) / 1000;
if (dt < 0.075) continue;
const vx = (f.x - seg[k].x) / dt;
const vy = (f.y - seg[k].y) / dt;
if (Math.hypot(vx, vy) < 3 * R) continue;
if (j < i) j = i;
while (j < seg.length && seg[j].t < f.t + T * 1000) j++;
if (j >= seg.length) break;
const g = seg[j];
if (g.t - (f.t + T * 1000) > 60) continue;
const lx = f.x - f.sx;
const ly = f.y - f.sy;
const ld = Math.hypot(lx, ly) || 1;
rows.push({
dx: g.x - f.x,
dy: g.y - f.y,
vx,
vy,
ux: lx / ld,
uy: ly / ld,
});
}
if (rows.length < 40) continue;
const hitsByStep = FRACTION_STEPS.map(() => 0);
for (const r of rows) {
// The aim leads the motion along the line of fire in full and
// cuts only the sideways part, so the search does the same.
const radial = r.vx * r.ux + r.vy * r.uy;
const sideX = r.vx - radial * r.ux;
const sideY = r.vy - radial * r.uy;
for (let s = 0; s < FRACTION_STEPS.length; s++) {
const ex = r.dx - (radial * r.ux + sideX * FRACTION_STEPS[s]) * T;
const ey = r.dy - (radial * r.uy + sideY * FRACTION_STEPS[s]) * T;
if (Math.abs(ex * r.uy - ey * r.ux) < R * 1.3) hitsByStep[s]++;
}
}
const bestFraction = bestFractionOf(hitsByStep);
fractions[T] = bestFraction;
if (T <= 1) rates.push((1 - bestFraction) / T);
samples += rows.length;
}
if (!rates.length) return null;
const rate = rates.reduce((a, b) => a + b, 0) / rates.length;
return {
rate: Math.min(DODGE_RATE_MAX, Math.max(DODGE_RATE_MIN, rate)),
fractions,
samples,
};
}
// How much of a straight-line prediction the enemies really covered, by
// flight time: the measure the dodge rate is learned from, taken here from
// every moment of their recorded movement rather than only from shots.
function dodgeProfile(traj) {
const R = traj[traj.length - 1].R || 1;
const out = {};
for (const T of [0.4, 0.6, 0.8, 1, 1.5]) {
const covered = [];
let landed = 0;
let tried = 0;
const fraction = Math.min(
LEAD_FRACTION_MAX,
Math.max(LEAD_FRACTION_MIN, leadFractionFor(T)),
);
const byFraction = FRACTION_STEPS.map(() => 0);
let j = 0;
for (let i = 10; i < traj.length; i++) {
const f = traj[i];
let k = i;
while (k > 0 && f.t - traj[k].t < 150 && traj[k].id === f.id) k--;
const dt = (f.t - traj[k].t) / 1000;
if (dt < 0.075 || traj[k].id !== f.id) continue;
const vx = (f.x - traj[k].x) / dt;
const vy = (f.y - traj[k].y) / dt;
const speed = Math.hypot(vx, vy);
if (j < i) j = i;
while (j < traj.length && traj[j].t < f.t + T * 1000) j++;
if (j >= traj.length) break;
const g = traj[j];
if (g.id !== f.id || g.t - (f.t + T * 1000) > 60) continue;
const dx = g.x - f.x;
const dy = g.y - f.y;
if (speed >= 3 * R) {
covered.push((dx * vx + dy * vy) / (speed * speed * T));
}
// Would a bullet aimed with the model's lead, arriving now, have
// landed? Only the error across the line of fire misses.
const lx = f.x - f.sx;
const ly = f.y - f.sy;
const ld = Math.hypot(lx, ly) || 1;
const ux = lx / ld;
const uy = ly / ld;
const radial = vx * ux + vy * uy;
const sideX = vx - radial * ux;
const sideY = vy - radial * uy;
const ex = dx - (radial * ux + sideX * fraction) * T;
const ey = dy - (radial * uy + sideY * fraction) * T;
tried++;
if (Math.abs(ex * uy - ey * ux) < R * 1.3) landed++;
// The same, for each lead fraction, to find the one that lands most.
for (let s = 0; s < FRACTION_STEPS.length; s++) {
const fx = dx - (radial * ux + sideX * FRACTION_STEPS[s]) * T;
const fy = dy - (radial * uy + sideY * FRACTION_STEPS[s]) * T;
if (Math.abs(fx * uy - fy * ux) < R * 1.3) byFraction[s]++;
}
}
const best = bestFractionOf(byFraction);
let bestHits = 0;
for (const h of byFraction) if (h > bestHits) bestHits = h;
out[T] =
tried >= 30
? {
n: tried,
covered:
covered.length >= 30 ? +median(covered).toFixed(2) : null,
hit: +(landed / tried).toFixed(2),
best: +best.toFixed(2),
bestHit: +(bestHits / tried).toFixed(2),
}
: null;
}
return out;
}
// The chance a shot fired now lands, read off the recorded movement of
// the enemies for the flight time in question.
function shotChance(leadTime) {
const dp = aim.game.dodgeProfile;
if (!dp) return null;
const grid = [0.4, 0.6, 0.8, 1, 1.5].filter((T) => dp[T]);
if (!grid.length) return null;
if (leadTime <= grid[0]) return dp[grid[0]].hit;
for (let i = 1; i < grid.length; i++) {
if (leadTime <= grid[i]) {
const a = dp[grid[i - 1]].hit;
const b = dp[grid[i]].hit;
const part = (leadTime - grid[i - 1]) / (grid[i] - grid[i - 1]);
return a + (b - a) * part;
}
}
const last = dp[grid[grid.length - 1]].hit;
return last * Math.max(0.2, grid[grid.length - 1] / leadTime);
}
function gameSummary(long) {
const g = aim.game;
const s = aim.shotStats;
const minutes = ((Date.now() - g.startedAt) / 60000).toFixed(1);
const pct = s.shots ? Math.round((100 * s.hits) / s.shots) : 0;
const moving = g.frames ? Math.round((100 * g.movingFrames) / g.frames) : 0;
const speed = g.movingFrames
? (g.speedSum / g.movingFrames).toFixed(1)
: "0";
const dist = g.distFrames ? (g.distSum / g.distFrames).toFixed(1) : "-";
const dp = g.dodgeProfile;
const covered = (T) =>
dp && dp[T] ? `${Math.round(dp[T].covered * 100)}%` : "-";
const enemyLead =
g.enemyLead.length >= 5 ? `${median(g.enemyLead).toFixed(2)} s` : "-";
const line =
`Game log ${minutes} min: ${aim.sessionShots} shots, ${aim.sessionShots ? Math.round((100 * aim.sessionHits) / aim.sessionShots) : 0}% hit, took ${g.hitsTaken} hits, ` +
`moving ${moving}% at ${speed} radii/s, enemies ${dist} radii away, ` +
`they cover ${covered(0.6)} of the lead at 0.6 s, they lead you by ${enemyLead}`;
if (!long) return line;
const lines = [`Script v${SCRIPT_VERSION}`, line, ""];
lines.push(
`Dodge profile (share of a straight-line lead the enemies really covered): 0.4 s ${covered(0.4)}, 0.6 s ${covered(0.6)}, 0.8 s ${covered(0.8)}, 1.0 s ${covered(1)}`,
);
const chance = (T) =>
dp && dp[T] ? `${Math.round(dp[T].hit * 100)}%` : "-";
lines.push(
`Chance a shot lands by flight time: 0.4 s ${chance(0.4)}, 0.6 s ${chance(0.6)}, 0.8 s ${chance(0.8)}, 1.0 s ${chance(1)}, 1.5 s ${chance(1.5)}`,
);
const fr = aim.fractions;
const frText = fr
? ` lead fractions ${[0.6, 1, 1.5].map((T) => (fr[T] === undefined ? "-" : fr[T].toFixed(2))).join("/")} at 0.6/1.0/1.5 s,`
: "";
lines.push(
`Learned: dodge rate ${aim.dodgeRate.toFixed(2)}/s,${frText} lead correction ${aim.leadAdjust.toFixed(2)} s, bullets ${aim.bulletSpeed.toFixed(1)} radii/s`,
);
lines.push(
`Near misses on you: ${g.nearMissFrames} frames; direction changes: ${g.turns}`,
);
lines.push(
`Fire on green: ${fire_green_toggle.checked ? "on" : "off"}, ${aim.firePresses} auto fire presses by the script`,
);
lines.push(
`Off screen: ${g.ghostReturns} enemies came back where their ghost was, ${g.unseenShots} bullets from shooters out of view`,
);
if (g.playerLead.length >= 5) {
const own = median(g.playerLead.map((p) => p.lead));
const model = median(g.playerLead.map((p) => p.modelLead));
const off = median(g.playerLead.map((p) => p.vsModel));
lines.push(
`Your manual aim: you led ${own.toFixed(1)} radii where the model would lead ${model.toFixed(1)}; median ${off.toFixed(1)} radii from the model's point (${g.playerLead.length} shots)`,
);
}
const ids = Object.keys(g.enemies);
if (ids.length) {
lines.push(
"",
"Enemies (by lock id): shots at them, hits, dodge rate, lead they put on you",
);
for (const id of ids) {
const e = g.enemies[id];
if (e.shots < 3 && e.leadOnMe.length < 3) continue;
const rate = e.dodgeRates.length
? median(e.dodgeRates).toFixed(2) + "/s"
: "-";
const lead =
e.leadOnMe.length >= 3 ? median(e.leadOnMe).toFixed(2) + " s" : "-";
lines.push(
`#${id}: ${e.shots} shots, ${e.hits} hits, dodge ${rate}, leads you ${lead}, seen ${(e.seenFrames / 60).toFixed(0)} s`,
);
}
}
return lines.join("\n");
}
function updateGameStatus() {
if (aim.entityTime - aim.gameStatusTime < 1000) return;
aim.gameStatusTime = aim.entityTime;
game_status_text.textContent = gameSummary(false);
}
function drawAimMarker() {
const gameCanvas = aim.gameCanvas;
const target = aim.target;
if (!gameCanvas || !target || gameCanvas.clientWidth === 0) return;
const k = gameCanvas.clientWidth / gameCanvas.width;
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(255, 60, 60, 0.9)";
ctx.beginPath();
ctx.arc(target.x * k, target.y * k, target.r * k + 6, 0, 2 * Math.PI);
ctx.stroke();
if (!aim_lead_toggle.checked && !aim.passive) return;
// The lead point, coloured by the chance the shot lands: green when the
// enemies' recorded movement says a bullet with this flight time hits
// more than half the time, red under a quarter.
const chance = aim.lastLead ? shotChance(aim.lastLead.time) : null;
ctx.strokeStyle =
chance === null
? "rgba(255, 60, 60, 0.9)"
: chance >= 0.5
? "rgba(40, 200, 80, 0.95)"
: chance >= 0.25
? "rgba(240, 190, 30, 0.95)"
: "rgba(230, 50, 50, 0.95)";
ctx.beginPath();
ctx.arc(aim.aimX * k, aim.aimY * k, 5, 0, 2 * Math.PI);
ctx.stroke();
if (aim.wantFire) {
// Filled while fire on green holds the trigger.
ctx.fillStyle = ctx.strokeStyle;
ctx.fill();
}
if (chance !== null) {
ctx.font = "12px sans-serif";
ctx.fillStyle = ctx.strokeStyle;
ctx.fillText(
`${Math.round(chance * 100)}%`,
aim.aimX * k + 8,
aim.aimY * k - 8,
);
}
}
// The game also reads real mouse movement, so re-apply the aim right after
// it. Clicks are left alone: moving the mouse between press and release
// would cancel clicks on the game's own buttons (upgrades, stats).
window.addEventListener(
"mousemove",
(event) => {
if (!event.isTrusted) return;
const gameCanvas = aim.gameCanvas;
if (gameCanvas && gameCanvas.clientWidth > 0) {
const k = gameCanvas.width / gameCanvas.clientWidth;
aim.mouseX = event.clientX * k;
aim.mouseY = event.clientY * k;
}
if (auto_aim_toggle.checked) sendAim();
},
true,
);
// Fire on green. The game ignores synthetic mouse and pointer presses but
// takes a synthetic E, its auto fire key, so that is the fire control. E
// toggles, so the script keeps a belief of whether auto fire is on: it
// follows the player's own E presses, and checks the belief against
// whether bullets actually come out.
function pressAutoFire() {
for (const type of ["keydown", "keyup"]) {
window.dispatchEvent(
new KeyboardEvent(type, {
key: "e",
code: "KeyE",
keyCode: 69,
which: 69,
bubbles: true,
cancelable: true,
}),
);
}
aim.autoFire = !aim.autoFire;
aim.scriptFire = aim.autoFire;
aim.fireKeyTime = aim.entityTime;
aim.firePresses++;
}
function updateFireControl() {
const active =
fire_green_toggle.checked && auto_aim_toggle.checked && !aim.passive;
const playing = !inMenu() && !isDead();
const t = aim.entityTime;
aim.fireChance = null;
if (!active) {
aim.wantFire = false;
// Switched off: let go of an auto fire the script itself turned on.
if (
playing &&
aim.autoFire &&
aim.scriptFire &&
t - aim.fireKeyTime > FIRE_KEY_GAP_MS
) {
pressAutoFire();
}
return;
}
let want = false;
if (playing && aim.target && aim.lastLead) {
const chance = shotChance(aim.lastLead.time);
aim.fireChance = chance;
want =
chance === null
? aim.lastLead.time <= FIRE_SHORT_LEAD_SECONDS
: chance >= (aim.wantFire ? FIRE_CHANCE_OFF : FIRE_CHANCE_ON);
}
aim.wantFire = want;
if (!playing) return; // keys do nothing on the menu or death screen
// Believed on, yet nothing has been fired for a while: it is off.
if (
aim.autoFire &&
t - Math.max(aim.lastBirth, aim.fireKeyTime) > FIRE_CHECK_MS
) {
aim.autoFire = false;
aim.scriptFire = false;
}
// Believed off, yet bullets keep coming with the button up: it is on.
// Two of them, clear of the last press and the last click, so that a
// single click's bullet does not count.
if (!aim.autoFire && !aim.userHold) {
const recent = aim.birthTimes.filter(
(b) =>
t - b < FIRE_CHECK_MS &&
b > aim.fireKeyTime + 500 &&
b > aim.userUpTime + 700,
);
if (recent.length >= 2) {
aim.autoFire = true;
aim.scriptFire = true;
}
}
if (want !== aim.autoFire && t - aim.fireKeyTime > FIRE_KEY_GAP_MS) {
pressAutoFire();
}
}
// The player's own E presses flip auto fire too; keep the belief in step.
window.addEventListener(
"keydown",
(event) => {
if (!event.isTrusted || event.repeat) return;
if (event.code !== "KeyE" && event.key !== "e" && event.key !== "E") {
return;
}
const el = document.activeElement;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return;
aim.autoFire = !aim.autoFire;
aim.scriptFire = false;
},
true,
);
window.addEventListener(
"mousedown",
(event) => {
if (event.isTrusted && event.button === 0) aim.userHold = true;
},
true,
);
window.addEventListener(
"mouseup",
(event) => {
if (event.isTrusted && event.button === 0) {
aim.userHold = false;
aim.userUpTime = aim.entityTime;
}
},
true,
);
let lastRun = 0;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (view_line_toggle.checked) {
ctx.beginPath();
ctx.moveTo(X, Y);
ctx.lineTo(x, y);
ctx.lineWidth = 50;
ctx.strokeStyle = "rgba(0, 0, 0, 0.05)";
ctx.stroke();
ctx.beginPath();
ctx.moveTo(X, Y);
ctx.lineTo(x, y);
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(0, 0, 0, 0.7)";
ctx.stroke();
}
if (view_circle_toggle.checked) {
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(0, 0, 0, 0.7)";
ctx.beginPath();
ctx.arc(X, Y, radius[3], 0, 2 * Math.PI);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, radius[1], 0, 2 * Math.PI);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, Z ? radius[0] : radius[2], 0, 2 * Math.PI);
ctx.stroke();
}
if (auto_aim_toggle.checked) {
updateAutoAim(false);
drawAimMarker();
} else if (game_log_toggle.checked) {
updateAutoAim(true);
drawAimMarker();
} else {
aim.enabled = false;
aim.target = null;
}
updateFireControl();
if (offscreen_toggle.checked && aim.enabled) drawOffscreen();
// Functions that run at a 3 second cooldown
if (Date.now() - lastRun >= 1000) {
if (auto_respawn_toggle.checked) {
input.execute("game_spawn rfbdaily");
}
if (au_autoset_toggle.checked) {
input.execute("game_stats_build " + au_input.value);
}
lastRun = Date.now();
}
requestAnimationFrame(draw);
}
draw();
// Load saved toggle states
auto_respawn_toggle.checked =
localStorage.getItem("mm_auto_respawn") === "true";
view_line_toggle.checked = localStorage.getItem("mm_view_line") === "true";
view_circle_toggle.checked =
localStorage.getItem("mm_view_circle") === "true";
auto_aim_toggle.checked = localStorage.getItem("mm_auto_aim") === "true";
aim_lead_toggle.checked = localStorage.getItem("mm_aim_lead") !== "false";
fire_green_toggle.checked = localStorage.getItem("mm_fire_green") === "true";
offscreen_toggle.checked = localStorage.getItem("mm_offscreen") !== "false";
game_log_toggle.checked = localStorage.getItem("mm_game_log") !== "false";
game_log_toggle.addEventListener("change", function () {
localStorage.setItem("mm_game_log", game_log_toggle.checked);
});
game_copy.addEventListener("click", function () {
const text = gameSummary(true);
if (navigator.clipboard) navigator.clipboard.writeText(text);
console.log(text);
});
loadAimLearning();
aim_reset.addEventListener("click", resetAimLearning);
aim_export.addEventListener("click", function () {
const data = {
savedAt: new Date().toISOString(),
version: SCRIPT_VERSION,
summary: gameSummary(true),
fireGreen: fire_green_toggle.checked,
dodgeRate: aim.dodgeRate,
leadAdjust: aim.leadAdjust,
bulletSpeed: aim.bulletSpeed,
bulletProfile: aim.bulletProfile,
game: aim.game,
shots: aim.shotLog,
track: aim.traj,
};
const blob = new Blob([JSON.stringify(data)], {
type: "application/json",
});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "diep-game-log.json";
link.click();
URL.revokeObjectURL(link.href);
});
au_autoset_toggle.checked =
localStorage.getItem("mm_keep_build_on_spawn") === "true";
// Load + execute toggle states
render_collisions_toggle.checked =
localStorage.getItem("mm_render_collisions") === "true";
render_fps_toggle.checked = localStorage.getItem("mm_render_fps") === "true";
render_rhw_toggle.checked =
localStorage.getItem("mm_render_raw_health") === "true";
hide_ui_toggle.checked = localStorage.getItem("mm_hide_ui") === "true";
// Add event listeners to save toggle states
auto_respawn_toggle.addEventListener("change", function () {
localStorage.setItem("mm_auto_respawn", auto_respawn_toggle.checked);
});
view_line_toggle.addEventListener("change", function () {
localStorage.setItem("mm_view_line", view_line_toggle.checked);
});
view_circle_toggle.addEventListener("change", function () {
localStorage.setItem("mm_view_circle", view_circle_toggle.checked);
});
auto_aim_toggle.addEventListener("change", function () {
localStorage.setItem("mm_auto_aim", auto_aim_toggle.checked);
});
aim_lead_toggle.addEventListener("change", function () {
localStorage.setItem("mm_aim_lead", aim_lead_toggle.checked);
});
fire_green_toggle.addEventListener("change", function () {
localStorage.setItem("mm_fire_green", fire_green_toggle.checked);
});
offscreen_toggle.addEventListener("change", function () {
localStorage.setItem("mm_offscreen", offscreen_toggle.checked);
});
visual_tab.click();
setTimeout(() => {
if (input) {
if (render_collisions_toggle.checked) {
input.execute("ren_debug_collisions true");
}
if (render_fps_toggle.checked) {
input.execute("ren_fps true");
}
if (render_rhw_toggle.checked) {
input.execute("ren_raw_health_values true");
}
if (hide_ui_toggle.checked) {
input.execute("ren_ui false");
}
}
}, 300);
})();