// ==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); })();