diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec901e97c73..d486f262f04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,16 @@ jobs: run: util/checkconventionalcommit.py working-directory: crawl-ref/source continue-on-error: true + - name: Setup node.js + uses: actions/setup-node@v6 + with: + node-version: 22.20.0 + - name: Install node.js dependencies + run: npm ci + - name: JavaScript linting (biome) + run: npm run lint + - name: JavaScript formatting (biome) + run: npm run format build_source: permissions: @@ -139,6 +149,10 @@ jobs: - name: Install dependencies run: ./deps.py --compiler ${{ matrix.compiler }} --build-opts "${{ matrix.build_opts }}" --debug-opts "${{ matrix.debug }}" working-directory: .github/workflows + - name: Setup node.js + uses: actions/setup-node@v6 + with: + node-version: 22.20.0 - name: Setup ccache uses: hendrikmuhs/ccache-action@v1.2 with: diff --git a/.gitignore b/.gitignore index fad58842c9f..a91c75adbb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .cquery_cache/ .DS_Store .vscode/ +node_modules/ .vs *.sublime-workspace diff --git a/crawl-ref/.gitignore b/crawl-ref/.gitignore index 2a73c0d290b..47b85770c3c 100644 --- a/crawl-ref/.gitignore +++ b/crawl-ref/.gitignore @@ -115,8 +115,11 @@ chunk /source/webserver/game_data/static/gui.png /source/webserver/game_data/static/icons.png /source/webserver/game_data/static/tileinfo-*.js +/source/webserver/game_data/static/game.js /source/webserver/static/stone_soup_icon-32x32.png /source/webserver/static/title_*.png +/source/webserver/static/index-*.js +/source/webserver/static/index-*.css # Makefile-generated junk makefile.dep diff --git a/crawl-ref/source/Makefile b/crawl-ref/source/Makefile index dd528664530..f5bd68db978 100644 --- a/crawl-ref/source/Makefile +++ b/crawl-ref/source/Makefile @@ -1326,20 +1326,50 @@ TITLEIMGS = denzi_dragon denzi_kitchen_duty denzi_summoner \ benadryl_antaeus king7artist_eustachio benadryl_oni \ ylam_formicid_shrikes lemurrobot_gozag_vaults +GENERATEDJS = $(TILEINFOJS:%=webserver/game_data/static/tileinfo-%.js) + +WEBSERVERGAME = webserver/game_data/static/game.js + +WEBSERVERLOBBY = webserver/static/index-*.js \ + webserver/static/index-*.css \ + webserver/templates/client.html + STATICFILES = $(TILEIMAGEFILES:%=webserver/game_data/static/%.png) \ - webserver/static/stone_soup_icon-32x32.png \ - $(TITLEIMGS:%=webserver/static/title_%.png) \ - $(TILEINFOJS:%=webserver/game_data/static/tileinfo-%.js) \ - webserver/webtiles/version.txt + webserver/static/stone_soup_icon-32x32.png \ + $(TITLEIMGS:%=webserver/static/title_%.png) \ + $(GENERATEDJS) \ + $(WEBSERVERGAME) \ + $(WEBSERVERLOBBY) \ + webserver/webtiles/version.txt $(TILEINFOJS:%=$(RLTILES)/tileinfo-%.js): build-rltiles webserver/webtiles/version.txt: .ver @git describe $(MERGE_BASE) > webserver/webtiles/version.txt -webserver/game_data/static/%.js: $(RLTILES)/%.js +webserver/game_data/static/tileinfo-%.js: $(RLTILES)/tileinfo-%.js $(QUIET_COPY)$(COPY) $< webserver/game_data/static/ +NPMPACKAGELOCKS = ../../package.json \ + ../../package-lock.json \ + webserver/client/game/package.json \ + webserver/client/lobby/package.json \ + +NPMNODEMODULES = ../../node_modules/.modified + +$(NPMNODEMODULES): $(NPMPACKAGELOCKS) + npm ci + @rm -f ../../node_modules/.modified + @touch -m ../../node_modules/.modified + +$(WEBSERVERGAME): $(GENERATEDJS) webserver/client/game/src/*.js $(NPMNODEMODULES) + npm run build --workspace=crawl-game + +$(WEBSERVERLOBBY): webserver/client/lobby/src/*.js $(NPMNODEMODULES) + @rm -f webserver/static/index-*.js + @rm -f webserver/static/index-*.css + npm run build --workspace=crawl-lobby + clean-webserver: $(RM) $(STATICFILES) webserver/*.pyc diff --git a/crawl-ref/source/rltiles/tool/tile_list_processor.cc b/crawl-ref/source/rltiles/tool/tile_list_processor.cc index 275956007d0..2ea91c92647 100644 --- a/crawl-ref/source/rltiles/tool/tile_list_processor.cc +++ b/crawl-ref/source/rltiles/tool/tile_list_processor.cc @@ -1673,39 +1673,32 @@ bool tile_list_processor::write_data(bool image, bool code) if (m_abstract.size() == 0) { - fprintf(fp, "define(["); if (m_start_value_module.size() > 0) - fprintf(fp, "\"./tileinfo-%s\"", m_start_value_module.c_str()); - fprintf(fp, "], function(m) {\n"); + fprintf(fp, "import m from \"./tileinfo-%s\"\n", m_start_value_module.c_str()); } else { - fprintf(fp, "define([\"jquery\","); - for (const auto& abstract : m_abstract) - fprintf(fp, "\"./tileinfo-%s\", ", abstract.first.c_str()); - fprintf(fp, "],\n function ($, "); - for (size_t i = 0; i < m_abstract.size(); ++i) + for (const auto &abstract : m_abstract) { - if (i < m_abstract.size() - 1) - fprintf(fp, "%s, ", m_abstract[i].first.c_str()); - else - fprintf(fp, "%s", m_abstract[i].first.c_str()); + fprintf(fp, "import %s from \"./tileinfo-%s\"\n", + abstract.first.c_str(), abstract.first.c_str()); } - fprintf(fp, ") {\n"); } fprintf(fp, "// This file has been automatically generated.\n\n"); - fprintf(fp, "var exports = {};\n"); + fprintf(fp, "const exports = { "); if (m_abstract.size() > 0) { - for (const auto& abstract : m_abstract) - fprintf(fp, "$.extend(exports, %s);\n", abstract.first.c_str()); + for (const auto &abstract : m_abstract) + fprintf(fp, "...%s, ", abstract.first.c_str()); } + fprintf(fp, "};\n"); + if (m_start_value_module.size() > 0) - fprintf(fp, "\nvar val = m.%s;\n", m_start_value.c_str()); + fprintf(fp, "\nlet val = m.%s;\n", m_start_value.c_str()); else - fprintf(fp, "\nvar val = %s;\n", m_start_value.c_str()); + fprintf(fp, "\nlet val = %s;\n", m_start_value.c_str()); string old_enum_name = ""; int count = 0; @@ -1862,7 +1855,7 @@ bool tile_list_processor::write_data(bool image, bool code) fprintf(fp, "};\n\n"); } - fprintf(fp, "return exports;\n});\n"); + fprintf(fp, "export default exports\n"); fclose(fp); } diff --git a/crawl-ref/source/webserver/.gitignore b/crawl-ref/source/webserver/.gitignore index 4c86c436c01..df88ed4c772 100644 --- a/crawl-ref/source/webserver/.gitignore +++ b/crawl-ref/source/webserver/.gitignore @@ -8,3 +8,4 @@ banned_players.txt webtiles/version.txt Pipfile Pipfile.lock +templates/client.html diff --git a/crawl-ref/source/webserver/Makefile b/crawl-ref/source/webserver/Makefile index fa867235218..bdb55630c30 100644 --- a/crawl-ref/source/webserver/Makefile +++ b/crawl-ref/source/webserver/Makefile @@ -1,9 +1,7 @@ lint: flake8 --mypy-config=mypy.ini *.py webtiles/*.py - format: isort --check-only --recursive . - .PHONY: lint format diff --git a/crawl-ref/source/webserver/client/game/.gitignore b/crawl-ref/source/webserver/client/game/.gitignore new file mode 100644 index 00000000000..a547bf36d8d --- /dev/null +++ b/crawl-ref/source/webserver/client/game/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/crawl-ref/source/webserver/client/game/biome.json b/crawl-ref/source/webserver/client/game/biome.json new file mode 100644 index 00000000000..7b33387a879 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/biome.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.6/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": ["*", "src/*", "!src/contrib"] + + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noVar": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/crawl-ref/source/webserver/client/game/index.html b/crawl-ref/source/webserver/client/game/index.html new file mode 100644 index 00000000000..50c6326fe24 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/index.html @@ -0,0 +1,12 @@ + + + + + + Crawl Game + + +
+ + + diff --git a/crawl-ref/source/webserver/client/game/package.json b/crawl-ref/source/webserver/client/game/package.json new file mode 100644 index 00000000000..3b9ba53b3e3 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/package.json @@ -0,0 +1,25 @@ +{ + "name": "crawl-game", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "npm-run-all build:vite copy", + "build:vite": "vite build", + "lint": "biome lint ./src/*", + "lint:fix": "biome lint ./src/* --write --unsafe", + "format": "biome format ./src/*", + "preview": "vite preview", + "copy": "cp dist/static/* ../../game_data/static" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.6", + "typescript": "~5.9.3", + "vite": "^7.1.7" + }, + "dependencies": { + "focus-trap": "^7.6.5", + "jquery": "^1.12.4" + } +} diff --git a/crawl-ref/source/webserver/client/game/src/action_panel.js b/crawl-ref/source/webserver/client/game/src/action_panel.js new file mode 100644 index 00000000000..f62842bd96e --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/action_panel.js @@ -0,0 +1,556 @@ +import $ from "jquery"; +import { createFocusTrap } from "focus-trap"; + +import comm from "./comm"; +import client from "./client"; + +import cr from "./cell_renderer"; +import enums from "./enums"; +import options from "./options"; +import player from "./player"; +import icons from "../../../game_data/static/tileinfo-icons"; +import gui from "../../../game_data/static/tileinfo-gui"; +import main from "../../../game_data/static/tileinfo-main"; +import util from "./util"; +import ui from "./ui"; + +let filtered_inv; +let renderer, $canvas, $settings, $tooltip; +let borders_width; +let minimized; +let settings_visible; +let tooltip_timeout = null; +// Options +let panel_disabled; +let scale, orientation, font_family, font_size; +let font; // cached font name for the canvas: size (in px) + family +let draw_glyphs; +let selected = -1; +const NUM_RESERVED_BUTTONS = 2; + +function send_options() { + if (client.is_watching()) return; + options.set("action_panel_orientation", orientation, false); + options.set("action_panel_show", !minimized, false); + options.set("action_panel_scale", scale, false); + options.set("action_panel_font_size", font_size, false); + // TODO: some cleaner approach to this than multiple msgs + options.send("action_panel_orientation"); + options.send("action_panel_show"); + options.send("action_panel_scale"); + options.send("action_panel_font_size"); +} + +function hide_panel(send_opts = true) { + $("#action-panel-settings").hide(); + $("#action-panel").addClass("hidden"); + // if the player configured the panel to not show any items, + // don't even show the placeholder button, don't update settings, etc. + if (!panel_disabled) { + $("#action-panel-placeholder").removeClass("hidden").show(); + // order of these two matters + minimized = true; + hide_settings(); + if (send_opts) send_options(); + } +} + +function show_panel(send_opts = true) { + if (settings_visible) return; + $("#action-panel-settings").hide(); // sanitize + $("#action-panel").removeClass("hidden"); + $("#action-panel-placeholder").addClass("hidden"); + minimized = false; + if (send_opts) send_options(); +} + +function show_settings(e) { + if (selected > 0) return false; + hide_tooltip(); + const o_button = $(`#action-orient-${orientation}`); + // Initialize the form with the current values + o_button.prop("checked", true); + + // TODO: should these just reset to 100/16, rather than this somewhat + // complicate context-sensitive behavior? + // use parseInt to cut out any decimals + const scale_percent = parseInt(scale, 10); + $("#scale-val").val(scale_percent); + if (!$("#scale-val").data("default")) + $("#scale-val").data("default", scale_percent); + + $("#font-size-val").val(font_size); + if (!$("#font-size-val").data("default")) + $("#font-size-val").data("default", font_size); + + // Show the context menu near the cursor + $settings = $("#action-panel-settings"); + $settings.css({ top: `${e.pageY + 10}px`, left: `${e.pageX + 10}px` }); + $settings.show(); + settings_visible = true; + + // TODO: I have had to set the buttons with tabindex -1, as I cannot + // for the life of me get focus-trap to intercept their key input + // correctly for esc and tab/shift-tab. This is non-ideal for + // accessibility reasons. + $settings[0].focus_trap = createFocusTrap($settings[0], { + escapeDeactivates: true, + onActivate: () => { + $settings.addClass("focus-trap"); + }, + onDeactivate: () => { + $settings.removeClass("focus-trap"); + // ugly to do this as a timeout, but it is the best way I've + // found to get the key handling sequence right. This ensures + // that if a mousedown event triggers deactivate, it is handled + // while the settings panel is still open. + setTimeout(hide_settings_final, 50); + }, + returnFocusOnDeactivate: false, + clickOutsideDeactivates: true, + }).activate(); + + return false; +} + +function hide_settings() { + if (!settings_visible) return; + $settings[0].focus_trap.deactivate(); +} + +// triggered via focus-trap onDeactivate +function hide_settings_final() { + $("#action-panel-settings").hide(); + settings_visible = false; + + // TODO: I can't quite figure out why the conditional is necessary, but + // maybe something about the timing. Without it, sync_focus_state + // steals focus from the chat window. + if (!$("#chat").hasClass("focus-trap")) ui.sync_focus_state(); + + // somewhat hacky: if hide_settings is triggered by hide_panel, + // don't send options twice. Assumes that this flag is set first... + if (!minimized) send_options(); +} + +function hide_tooltip() { + if (tooltip_timeout) clearTimeout(tooltip_timeout); + $tooltip.hide(); +} + +function show_tooltip(x, y, slot) { + if (slot >= filtered_inv.length) { + hide_tooltip(); + return; + } + $tooltip.css({ top: `${y + 10}px`, left: `${x + 10}px` }); + if (slot === -2) { + $tooltip.html( + "Left click: minimize
" + + "Right click: open settings" + ); + } else if (slot === -1 && game.get_input_mode() === enums.mouse_mode.COMMAND) + $tooltip.html("Left click: show main menu"); + else { + const item = filtered_inv[slot]; + $tooltip.empty().text(`${String.fromCharCode(item.letter)} - `); + $tooltip.append(player.inventory_item_desc(item.slot)); + if (game.get_input_mode() === enums.mouse_mode.COMMAND) { + if (item.action_verb) + $tooltip.append( + "
Left click: " + + item.action_verb.toLowerCase() + + "" + ); + $tooltip.append("
Right click: describe"); + } + } + $tooltip.show(); +} + +// Initial setup for the panel and its settings menu. +// Note that "game_init" happens before the client receives +// the options and inventory data from the server. +$(document).bind("game_init", () => { + $canvas = $("#action-panel"); + $settings = $("#action-panel-settings"); + $tooltip = $("#action-panel-tooltip"); + if (client.is_watching()) { + $canvas.addClass("hidden"); + return; + } + + renderer = new cr.DungeonCellRenderer(); + borders_width = (parseInt($canvas.css("border-left-width"), 10) || 0) * 2; + minimized = false; + settings_visible = false; + tooltip_timeout = null; + filtered_inv = []; + + $canvas.on("update", update); + + $canvas.on("mousemove mouseleave mousedown mouseenter", (ev) => { + handle_mouse(ev); + }); + + $canvas.contextmenu(() => false); + + // We don't need a context menu for the context menu + $settings.contextmenu(() => false); + + // Clicking on the panel/Close button closes the settings menu + $("#action-panel, #close-settings").click(() => { + hide_settings(); + }); + + // Triggering this function on keyup might be too aggressive, + // but at least the player doesn't have to press Enter to confirm changes + $("#action-panel-settings input[type=radio],input[type=number]").on( + "change keyup", + (e) => { + if (e.which === 27) { + hide_settings(); + e.preventDefault(); + return; + } + const input = e.target; + if (input.type === "number" && !input.checkValidity()) return; + options.set(input.name, input.value); + } + ); + + $("#action-panel-settings button.reset").click(function () { + const input = $(this).siblings("input"); + const default_value = input.data("default"); + input.val(default_value); + options.set(input.prop("name"), default_value); + }); + + $("#minimize-panel").click(hide_panel); + + $("#action-panel-placeholder").click(() => { + show_panel(); + update(); + }); + + // To prevent the game from showing an empty panel before + // any inventory data arrives, we hide it via inline CSS + // and the "hidden" class. The next line deactivates + // the inline rule, and the first call to update() will + // remove "hidden" if the (filtered) inventory is not empty. + $canvas.show(); +}); + +function _horizontal() { + return orientation === "horizontal"; +} + +function _update_font_props() { + font = `${font_size || "16"}px ${font_family || "monospace"}`; +} + +function handle_mouse(ev) { + if (ev.type === "mouseleave") { + selected = -1; + hide_tooltip(); + update(); + } else { + // focus-trap handles this case: the settings panel is about to + // be closed and we should ignore the click + if (ev.type === "mousedown" && settings_visible) return; + + // for finding the mouse position, we need to *not* use the device + // pixel ratio to adjust the scale + const cell_width = (renderer.cell_width * scale) / 100; + const cell_height = (renderer.cell_height * scale) / 100; + const _cell_length = _horizontal() ? cell_width : cell_height; + const loc = { + x: Math.round(ev.clientX / cell_width - 0.5), + y: Math.round(ev.clientY / cell_height - 0.5), + }; + + if (ev.type === "mousemove" || ev.type === "mouseenter") { + const oldselected = selected; + selected = _horizontal() ? loc.x : loc.y; + update(); + if (oldselected !== selected && !settings_visible) { + hide_tooltip(); + tooltip_timeout = setTimeout(() => { + show_tooltip(ev.pageX, ev.pageY, selected - NUM_RESERVED_BUTTONS); + }, 500); + } + } else if (ev.type === "mousedown" && ev.which === 1) { + if (selected === 0) + // It should be available even in targeting mode + hide_panel(); + else if ( + game.get_input_mode() === enums.mouse_mode.COMMAND && + selected === 1 + ) { + comm.send_message("main_menu_action"); + } else if ( + game.get_input_mode() === enums.mouse_mode.COMMAND && + selected >= NUM_RESERVED_BUTTONS && + selected < filtered_inv.length + NUM_RESERVED_BUTTONS + ) { + comm.send_message("inv_item_action", { + slot: filtered_inv[selected - NUM_RESERVED_BUTTONS].slot, + }); + } + } else if (ev.type === "mousedown" && ev.which === 3) { + if (selected === 0) + // right click on the x shows settings + show_settings(ev); + else if ( + game.get_input_mode() === enums.mouse_mode.COMMAND && + selected >= NUM_RESERVED_BUTTONS && + selected < filtered_inv.length + NUM_RESERVED_BUTTONS + ) { + comm.send_message("inv_item_describe", { + slot: filtered_inv[selected - NUM_RESERVED_BUTTONS].slot, + }); + } + } + } +} + +function draw_action( + texture, + tiles, + item, + offset, + scale, + needs_cursor, + text, + useless +) { + if (item && draw_glyphs) { + // XX just the glyph is not very informative. One idea might + // be to tack on the subtype icon, but those are currently + // baked into the item tile so this would be a lot of work. + renderer.render_glyph( + _horizontal() ? offset : 0, + _horizontal() ? 0 : offset, + item, + true, + true, + scale + ); + } else { + tiles = Array.isArray(tiles) ? tiles : [tiles]; + tiles.forEach((tile) => { + renderer.draw_tile( + tile, + _horizontal() ? offset : 0, + _horizontal() ? 0 : offset, + texture, + undefined, + undefined, + undefined, + undefined, + scale + ); + }); + } + + if (text) { + // TODO: at some scalings, this don't dodge the green highlight + // square very well + renderer.draw_quantity( + text, + _horizontal() ? offset : 0, + _horizontal() ? 0 : offset, + font + ); + } + + if (needs_cursor) { + renderer.draw_icon( + icons.CURSOR3, + _horizontal() ? offset : 0, + _horizontal() ? 0 : offset, + undefined, + undefined, + scale + ); + } + + if (useless) { + renderer.draw_icon( + icons.OOR_MESH, + _horizontal() ? offset : 0, + _horizontal() ? 0 : offset, + undefined, + undefined, + scale + ); + } +} + +function update() { + if (client.is_watching()) return; + + // Have we received the inventory yet? + // Note: an empty inventory will still have 52 empty slots. + const inventory_initialized = Object.values(player.inv).length; + if (!inventory_initialized) { + $("#action-panel").addClass("hidden"); + return; + } + + if (panel_disabled || minimized) { + hide_panel(false); + return; + } + + // Filter + filtered_inv = Object.values(player.inv).filter((item) => item.quantity && item.action_panel_order >= 0); + + // primary sort: determined by the `action_panel` option + // secondary sort: determined by subtype + filtered_inv.sort((a, b) => { + if (a.action_panel_order === b.action_panel_order) + return a.sub_type - b.sub_type; + + return a.action_panel_order - b.action_panel_order; + }); + + // Render + const ratio = window.devicePixelRatio; + + // first we readjust the dimensions according to whether the panel + // should be horizontal or vertical, and how much space is available. + // These calculations are in logical pixels. + const adjusted_scale = scale / 100; + + const cell_width = renderer.cell_width * adjusted_scale; + const cell_height = renderer.cell_height * adjusted_scale; + const cell_length = _horizontal() ? cell_width : cell_height; + const required_length = + cell_length * (filtered_inv.length + NUM_RESERVED_BUTTONS); + let available_length = _horizontal() + ? $("#dungeon").width() + : $("#dungeon").height(); + available_length -= borders_width; + const max_cells = Math.floor(available_length / cell_length); + const panel_length = Math.min(required_length, available_length); + + util.init_canvas( + $canvas[0], + _horizontal() ? panel_length : cell_width, + _horizontal() ? cell_height : panel_length + ); + renderer.init($canvas[0]); + renderer.clear(); + + // now draw the thing. From this point forward, use device pixels. + const cell = renderer.scaled_size(); + const inc = (_horizontal() ? cell.width : cell.height) * adjusted_scale; + + // XX The "X" should definitely be a different/custom icon + // TODO: select tile via something like c++ `tileidx_command` + draw_action(gui, gui.PROMPT_NO, null, 0, adjusted_scale, selected === 0); + draw_action(gui, gui.CMD_GAME_MENU, null, inc, adjusted_scale, selected === 1); + + draw_glyphs = options.get("action_panel_glyphs"); + + if (draw_glyphs) { + // need to manually initialize for this type of renderer + renderer.glyph_mode_font_size = options.get("glyph_mode_font_size"); + renderer.glyph_mode_font = options.get("glyph_mode_font"); + renderer.glyph_mode_update_font_metrics(); + } + + // Inventory items + filtered_inv.slice(0, max_cells).forEach((item, idx) => { + const offset = inc * (idx + NUM_RESERVED_BUTTONS); + const qty_field_name = item.qty_field; + let qty = ""; + if (Object.hasOwn(item, qty_field_name)) qty = item[qty_field_name]; + const cursor_required = selected === idx + NUM_RESERVED_BUTTONS; + + draw_action( + main, + item.tile, + item, + offset, + adjusted_scale, + cursor_required, + qty, + item.useless + ); + }); + + if (available_length < required_length) { + const ellipsis = icons.ELLIPSIS; + let x_pos = 0, + y_pos = 0; + + if (_horizontal()) + x_pos = + available_length - icons.get_tile_info(ellipsis).w * adjusted_scale; + else + y_pos = + available_length - icons.get_tile_info(ellipsis).h * adjusted_scale; + + renderer.draw_icon( + ellipsis, + x_pos * ratio, + y_pos * ratio, + -2, + -2, + adjusted_scale + ); + } + $canvas.removeClass("hidden"); +} + +options.add_listener(() => { + if (client.is_watching()) return; + // synchronize visible state with new options. Because of messy timing + // issues with the crawl binary, this will run at least twice on + // startup. + let update_required = false; + + const new_scale = options.get("action_panel_scale"); + if (scale !== new_scale) { + scale = new_scale; + update_required = true; + } + + // is one of: horizontal, vertical + const new_orientation = options.get("action_panel_orientation"); + if (orientation !== new_orientation) { + orientation = new_orientation; + update_required = true; + } + + const new_min = !options.get("action_panel_show"); + if (new_min !== minimized) { + minimized = new_min; + update_required = true; + } + + const new_font_family = options.get("action_panel_font_family"); + if (font_family !== new_font_family) { + font_family = new_font_family; + _update_font_props(); + update_required = true; + } + + const new_font_size = options.get("action_panel_font_size"); + if (font_size !== new_font_size) { + font_size = new_font_size; + _update_font_props(); + update_required = true; + } + + // The panel should be disabled completely only if the player + // set the action_panel option to an empty string in the .rc + panel_disabled = options.get("action_panel_disabled"); + + if (update_required) { + if (!minimized) show_panel(false); + update(); + } +}); diff --git a/crawl-ref/source/webserver/client/game/src/cell_renderer.js b/crawl-ref/source/webserver/client/game/src/cell_renderer.js new file mode 100644 index 00000000000..ab67f827d29 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/cell_renderer.js @@ -0,0 +1,1332 @@ +import $ from "jquery"; + +import dngn from "../../../game_data/static/tileinfo-dngn"; +import gui from "../../../game_data/static/tileinfo-gui"; +import icons from "../../../game_data/static/tileinfo-icons"; +import main from "../../../game_data/static/tileinfo-main"; +import tileinfo_player from "../../../game_data/static/tileinfo-player"; +import enums from "./enums"; +import map_knowledge from "./map_knowledge"; +import options from "./options"; +import player from "./player"; +import tileinfos from "./tileinfos"; +import view_data from "./view_data"; + +function DungeonCellRenderer() { + this.set_cell_size(32, 32); +} + +let fg_term_colours, bg_term_colours; +let healthy, hp_spend, magic, magic_spend; + +function determine_colours() { + fg_term_colours = []; + bg_term_colours = []; + const $game = $("#game"); + for (let i = 0; i < 16; ++i) { + const elem = $(``); + $game.append(elem); + fg_term_colours.push(elem.css("color")); + bg_term_colours.push(elem.css("background-color")); + elem.detach(); + } + + // FIXME: CSS lookup doesn't work on Chrome after saving and loading a + // game, style information is missing for some reason. + // Use hard coded values instead. + healthy = "#8ae234"; + hp_spend = "#b30009"; + magic = "#5e78ff"; + // healthy = $("#stats_hp_bar_full").css("background-color"); + // hp_spend = $("#stats_hp_bar_decrease").css("background-color"); + // magic = $("#stats_mp_bar_full").css("background-color"); + magic_spend = "black"; +} + +function in_water(cell) { + return cell.bg.WATER && !cell.fg.FLYING; +} + +function split_term_colour(col) { + const fg = col & 0xf; + const bg = 0; + const attr = (col & 0xf0) >> 4; + const param = (col & 0xf000) >> 12; + return { fg: fg, bg: bg, attr: attr, param: param }; +} + +function term_colour_apply_attributes(col) { + if (col.attr === enums.CHATTR.HILITE) { + col.bg = col.param; + if (col.bg === col.fg) col.fg = 0; + } + if (col.attr === enums.CHATTR.REVERSE) { + const z = col.bg; + col.bg = col.fg; + col.fg = z; + } +} + +function get_img(id) { + return $(`#${id}`)[0]; +} + +function obj_to_str(o) { + return $.toJSON(o); +} + +$.extend(DungeonCellRenderer.prototype, { + init: function (element) { + this.element = element; + this.ctx = this.element.getContext("2d"); + }, + + set_cell_size: function (w, h) { + this.cell_width = Math.floor(w); + this.cell_height = Math.floor(h); + this.x_scale = this.cell_width / 32; + this.y_scale = this.cell_height / 32; + }, + + glyph_mode_font_name: function (scale, device) { + let glyph_scale; + if (scale) glyph_scale = scale * 100; + else { + if (this.ui_state === enums.ui.VIEW_MAP) + glyph_scale = options.get("tile_map_scale"); + else glyph_scale = options.get("tile_viewport_scale"); + glyph_scale = (glyph_scale - 100) / 2 + 100; + } + if (device) glyph_scale = glyph_scale * window.devicePixelRatio; + + return ( + Math.floor((this.glyph_mode_font_size * glyph_scale) / 100) + + "px " + + this.glyph_mode_font + ); + }, + + glyph_mode_update_font_metrics: function () { + this.ctx.font = this.glyph_mode_font_name(); + + // Glyph used here does not matter because fontBoundingBoxAscent + // and fontBoundingBoxDescent are specific to the font whereas all + // glyphs in a monospaced font will have the same width + const metrics = this.ctx.measureText("@"); + this.glyph_mode_font_width = Math.ceil(metrics.width); + + // 2022: this feature appears to still be unavailable by default + // in firefox + if (metrics.fontBoundingBoxAscent) { + this.glyph_mode_baseline = metrics.fontBoundingBoxAscent; + this.glyph_mode_line_height = + metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; + } else { + // Inspired by https://stackoverflow.com/q/1134586/ + const body = document.body; + const ref_glyph = document.createElement("span"); + const ref_block = document.createElement("div"); + const div = document.createElement("div"); + + ref_glyph.innerHTML = "@"; + ref_glyph.style.font = this.ctx.font; + + ref_block.style.display = "inline-block"; + ref_block.style.width = "1px"; + ref_block.style.height = "0px"; + + div.style.visibility = "hidden"; + div.appendChild(ref_glyph); + div.appendChild(ref_block); + body.appendChild(div); + + try { + ref_block.style["vertical-align"] = "baseline"; + this.glyph_mode_baseline = ref_block.offsetTop - ref_glyph.offsetTop; + ref_block.style["vertical-align"] = "bottom"; + this.glyph_mode_line_height = ref_block.offsetTop - ref_glyph.offsetTop; + } finally { + document.body.removeChild(div); + } + } + }, + + render_cursors: function (cx, cy, x, y) { + $.each(view_data.cursor_locs, (type, loc) => { + if (loc && loc.x === cx && loc.y === cy) { + let idx; + + switch (type) { + case enums.CURSOR_TUTORIAL: + idx = icons.TUTORIAL_CURSOR; + break; + case enums.CURSOR_MOUSE: + idx = icons.CURSOR; + // TODO: tilei.CURSOR2 if not visible + break; + case enums.CURSOR_MAP: + idx = icons.CURSOR; + break; + } + + this.draw_icon(idx, x, y); + } + }); + }, + + scaled_size: function (w, h) { + w = w === undefined ? this.cell_width : w; + h = h === undefined ? this.cell_height : h; + const ratio = window.devicePixelRatio; + const width = Math.floor(w * ratio); + const height = Math.floor(h * ratio); + return { width: width, height: height }; + }, + + clear: function () { + this.ctx.fillStyle = "black"; + // element dimensions are already scaled + this.ctx.fillRect(0, 0, this.element.width, this.element.height); + }, + + do_render_cell: function (cx, cy, x, y, map_cell, cell) { + const scaled = this.scaled_size(); + + this.ctx.fillStyle = "black"; + this.ctx.fillRect(x, y, scaled.width, scaled.height); + + map_cell = map_cell || map_knowledge.get(cx, cy); + cell = cell || map_cell.t; + + if (!cell) { + this.render_cursors(cx, cy, x, y); + return; + } + + // track whether this cell overlaps to the top or left + this.current_sy = 0; + this.current_left_overlap = 0; + + cell.fg = enums.prepare_fg_flags(cell.fg || 0); + cell.bg = enums.prepare_bg_flags(cell.bg || 0); + cell.cloud = enums.prepare_fg_flags(cell.cloud || 0); + cell.icons = cell.icons || []; + cell.flv = cell.flv || {}; + cell.flv.f = cell.flv.f || 0; + cell.flv.s = cell.flv.s || 0; + map_cell.g = map_cell.g || " "; + if (map_cell.col === undefined) map_cell.col = 7; + + if (options.get("tile_display_mode") === "glyphs") { + this.render_glyph(x, y, map_cell, false); + + this.render_cursors(cx, cy, x, y); + this.draw_ray(x, y, cell); + return; + } + + // cell is basically a packed_cell + doll + mcache entries + if (options.get("tile_display_mode") === "tiles") + this.draw_background(x, y, cell); + + const fg_idx = cell.fg.value; + const is_in_water = in_water(cell); + + // draw clouds + if (cell.cloud.value) { + this.ctx.save(); + // If there will be a front/back cloud pair, draw + // the underlying one with correct alpha + if (fg_idx) { + try { + this.ctx.globalAlpha = 0.6; + this.set_nonsubmerged_clip(x, y, 20); + this.draw_main(cell.cloud.value, x, y); + } finally { + this.ctx.restore(); + } + + this.ctx.save(); + try { + this.ctx.globalAlpha = 0.2; + this.set_submerged_clip(x, y, 20); + this.draw_main(cell.cloud.value, x, y); + } finally { + this.ctx.restore(); + } + } else { + try { + this.ctx.globalAlpha = 1.0; + this.draw_main(cell.cloud.value, x, y); + } finally { + this.ctx.restore(); + } + } + } + + // Canvas doesn't support applying an alpha gradient + // to an image while drawing; so to achieve the same + // effect as in local tiles, it would probably be best + // to pregenerate water tiles with the (inverse) alpha + // gradient built in. This simply draws the lower + // half with increased transparency; for now, it looks + // good enough. + + const renderer = this; + function draw_dolls() { + if (fg_idx >= main.MAIN_MAX && cell.doll) { + const mcache_map = {}; + if (cell.mcache) { + for (let i = 0; i < cell.mcache.length; ++i) + mcache_map[cell.mcache[i][0]] = i; + } + $.each(cell.doll, (_i, doll_part) => { + let xofs = 0; + let yofs = 0; + if (mcache_map[doll_part[0]] !== undefined) { + const mind = mcache_map[doll_part[0]]; + xofs = cell.mcache[mind][1]; + yofs = cell.mcache[mind][2]; + } + renderer.draw_player(doll_part[0], x, y, xofs, yofs, doll_part[1]); + }); + } + + if (fg_idx >= tileinfo_player.MCACHE_START && cell.mcache) { + $.each(cell.mcache, (_i, mcache_part) => { + if (mcache_part) { + renderer.draw_player( + mcache_part[0], + x, + y, + mcache_part[1], + mcache_part[2] + ); + } + }); + } + } + + if (is_in_water && options.get("tile_display_mode") === "tiles") { + this.ctx.save(); + try { + this.ctx.globalAlpha = cell.trans ? 0.5 : 1.0; + + this.set_nonsubmerged_clip(x, y, 20); + + draw_dolls(); + } finally { + this.ctx.restore(); + } + + this.ctx.save(); + try { + this.ctx.globalAlpha = cell.trans ? 0.1 : 0.3; + this.set_submerged_clip(x, y, 20); + + draw_dolls(); + } finally { + this.ctx.restore(); + } + } else if (options.get("tile_display_mode") === "tiles") { + this.ctx.save(); + try { + this.ctx.globalAlpha = cell.trans ? 0.55 : 1.0; + + draw_dolls(); + } finally { + this.ctx.restore(); + } + } + + this.draw_foreground(x, y, map_cell); + + // draw clouds over stuff + if (fg_idx && cell.cloud.value) { + this.ctx.save(); + try { + this.ctx.globalAlpha = 0.4; + this.set_nonsubmerged_clip(x, y, 20); + this.draw_main(cell.cloud.value, x, y); + } finally { + this.ctx.restore(); + } + + this.ctx.save(); + try { + this.ctx.globalAlpha = 0.8; + this.set_submerged_clip(x, y, 20); + this.draw_main(cell.cloud.value, x, y); + } finally { + this.ctx.restore(); + } + } + + // Draw main-tile overlays (i.e. zaps), on top of clouds. + if (cell.ov) { + $.each(cell.ov, (_i, overlay) => { + if (dngn.FEAT_MAX <= overlay && overlay < main.MAIN_MAX) { + renderer.draw_main(overlay, x, y); + } + }); + } + + this.render_flash(x, y, map_cell); + + this.render_cursors(cx, cy, x, y); + + if ( + cx === player.pos.x && + cy === player.pos.y && + map_knowledge.player_on_level() + ) { + this.draw_minibars(x, y); + } + + // Debug helper + if (cell.mark) { + this.ctx.fillStyle = "red"; + this.ctx.font = "12px monospace"; + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "middle"; + const scaled = this.scaled_size(); + this.ctx.fillText( + cell.mark, + x + 0.5 * scaled.width, + y + 0.5 * scaled.height + ); + } + + cell.sy = this.current_sy; + cell.left_overlap = this.current_left_overlap; + }, + + // adapted from DungeonRegion::draw_minibars in tilereg_dgn.cc + draw_minibars: function (x, y) { + const show_health = options.get("tile_show_minihealthbar"); + const show_magic = options.get("tile_show_minimagicbar"); + + // don't draw if hp and mp is full + if ( + (player.hp === player.hp_max || !show_health) && + (player.mp === player.mp_max || !show_magic) + ) + return; + + const cell = this.scaled_size(); + const bar_height = Math.floor(cell.height / 16); + let hp_bar_offset = bar_height; + + // TODO: use different colors if heavily wounded, like in the tiles version + if (player.mp_max > 0 && show_magic) { + let mp_percent = player.mp / player.mp_max; + if (mp_percent < 0) mp_percent = 0; + + this.ctx.fillStyle = magic_spend; + this.ctx.fillRect( + x, + y + cell.height - bar_height, + cell.width, + bar_height + ); + + this.ctx.fillStyle = magic; + this.ctx.fillRect( + x, + y + cell.height - bar_height, + cell.width * mp_percent, + bar_height + ); + + hp_bar_offset += bar_height; + } + + if (show_health) { + let hp_percent = player.hp / player.hp_max; + if (hp_percent < 0) hp_percent = 0; + + this.ctx.fillStyle = hp_spend; + this.ctx.fillRect( + x, + y + cell.height - hp_bar_offset, + cell.width, + bar_height + ); + + this.ctx.fillStyle = healthy; + this.ctx.fillRect( + x, + y + cell.height - hp_bar_offset, + cell.width * hp_percent, + bar_height + ); + } + }, + + render_cell: function (cx, cy, x, y, map_cell, cell) { + if (window.debug_mode) this.do_render_cell(cx, cy, x, y, map_cell, cell); + else { + try { + this.do_render_cell(cx, cy, x, y, map_cell, cell); + } catch (err) { + console.error( + "Error while drawing cell " + + obj_to_str(cell) + + " at " + + cx + + "/" + + cy + + ": " + + err + ); + } + } + }, + + render_glyph: function (x, y, map_cell, omit_bg, square, scale) { + // `map_cell` can be anything as long as it provides `col` and `g` + const col = split_term_colour(map_cell.col); + if (omit_bg && col.attr === enums.CHATTR.REVERSE) col.attr = 0; + term_colour_apply_attributes(col); + const cell = this.scaled_size(); + let w = cell.width; + let h = cell.height; + if (scale) { + // assume x and y are already scaled... + w = w * scale; + h = h * scale; + } + + let prefix = ""; + if (col.attr === enums.CHATTR.BOLD) prefix = "bold "; + + if (!omit_bg) { + this.ctx.fillStyle = bg_term_colours[col.bg]; + this.ctx.fillRect(x, y, w, h); + } + this.ctx.fillStyle = fg_term_colours[col.fg]; + this.ctx.font = prefix + this.glyph_mode_font_name(scale, true); + + this.ctx.save(); + + try { + this.ctx.beginPath(); + this.ctx.rect(x, y, w, h); + this.ctx.clip(); + + if (square) { + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "middle"; + this.ctx.fillText( + map_cell.g, + Math.floor(x + w / 2), + Math.floor(y + h / 2) + ); + } else { + this.ctx.fillText( + map_cell.g, + x, + y + this.glyph_mode_baseline * window.devicePixelRatio + ); + } + } finally { + this.ctx.restore(); + } + }, + + render_flash: function (x, y, map_cell) { + if (map_cell.flc) { + const col = view_data.get_flash_colour(map_cell.flc, map_cell.fla); + this.ctx.save(); + try { + this.ctx.fillStyle = `rgb(${col.r},${col.g},${col.b})`; + this.ctx.globalAlpha = col.a / 255; + const cell = this.scaled_size(); + this.ctx.fillRect(x, y, cell.width, cell.height); + } finally { + this.ctx.restore(); + } + } + }, + + set_submerged_clip: function (_x, y, water_level) { + this.ctx.beginPath(); + const scaled = this.scaled_size(null, water_level * this.y_scale); + // this clip is across the entire row + this.ctx.rect( + 0, + y + scaled.height, + this.element.width, + this.element.height - y - scaled.height + ); + this.ctx.clip(); + }, + + set_nonsubmerged_clip: function (_x, y, water_level) { + this.ctx.beginPath(); + const scaled = this.scaled_size(null, water_level * this.y_scale); + this.ctx.rect(0, 0, this.element.width, y + scaled.height); + this.ctx.clip(); + }, + + // Much of the following is more or less directly copied from tiledgnbuf.cc + draw_blood_overlay: function (x, y, cell, is_wall) { + let offset; + + if (cell.liquefied && !is_wall) { + offset = cell.flv.s % dngn.tile_count(dngn.LIQUEFACTION); + this.draw_dngn(dngn.LIQUEFACTION + offset, x, y); + } else if (cell.bloody) { + cell.blood_rotation = cell.blood_rotation || 0; + let basetile; + if (is_wall) { + basetile = cell.old_blood ? dngn.WALL_OLD_BLOOD : dngn.WALL_BLOOD_S; + basetile += dngn.tile_count(basetile) * cell.blood_rotation; + basetile = + dngn.WALL_BLOOD_S + + dngn.tile_count(dngn.WALL_BLOOD_S) * cell.blood_rotation; + } else basetile = dngn.BLOOD; + offset = cell.flv.s % dngn.tile_count(basetile); + this.draw_dngn(basetile + offset, x, y); + } else if (cell.moldy) { + offset = cell.flv.s % dngn.tile_count(dngn.MOLD); + this.draw_dngn(dngn.MOLD + offset, x, y); + } else if (cell.glowing_mold) { + offset = cell.flv.s % dngn.tile_count(dngn.GLOWING_MOLD); + this.draw_dngn(dngn.GLOWING_MOLD + offset, x, y); + } + }, + + draw_ray: function (x, y, cell) { + const bg = cell.bg; + const _bg_idx = cell.bg.value; + + if (bg.RAY) this.draw_dngn(dngn.RAY, x, y); + else if (bg.RAY_OOR) this.draw_dngn(dngn.RAY_OUT_OF_RANGE, x, y); + else if (bg.LANDING) this.draw_dngn(dngn.LANDING, x, y); + else if (bg.RAY_MULTI) this.draw_dngn(dngn.RAY_MULTI, x, y); + }, + + draw_background: function (x, y, cell) { + const bg = cell.bg; + const bg_idx = cell.bg.value; + + if (cell.mangrove_water && bg_idx > dngn.DNGN_UNSEEN) + this.draw_dngn(dngn.DNGN_SHALLOW_WATER, x, y); + else if (bg_idx >= dngn.DNGN_FIRST_TRANSPARENT) { + this.draw_dngn(cell.flv.f, x, y); // f = floor + + // Draw floor overlays beneath the feature + if (cell.ov) { + $.each(cell.ov, (_i, overlay) => { + if (overlay && overlay <= dngn.FLOOR_MAX) + this.draw_dngn(overlay, x, y); + }); + } + } + + // Draw blood beneath feature tiles. + if (bg_idx > dngn.WALL_MAX) this.draw_blood_overlay(x, y, cell); + + if (cell.mangrove_water) { + // Draw the tree submerged + this.ctx.save(); + try { + this.ctx.globalAlpha = 1.0; + + this.set_nonsubmerged_clip(x, y, 20); + + this.draw_dngn(bg_idx, x, y); + } finally { + this.ctx.restore(); + } + + this.ctx.save(); + try { + this.ctx.globalAlpha = 0.3; + this.set_submerged_clip(x, y, 20); + + this.draw_dngn(bg_idx, x, y); + } finally { + this.ctx.restore(); + } + } else this.draw_dngn(bg_idx, x, y); + + if (bg_idx > dngn.DNGN_UNSEEN) { + // Draw blood on top of wall tiles. + if (bg_idx <= dngn.WALL_MAX) + this.draw_blood_overlay(x, y, cell, bg_idx > dngn.FLOOR_MAX); + + // Draw overlays + let ray_tile = 0; + if (cell.ov) { + $.each(cell.ov, (_i, overlay) => { + if (overlay > dngn.DNGN_MAX) return; + else if ( + overlay === dngn.RAY || + overlay === dngn.RAY_MULTI || + overlay === dngn.RAY_OUT_OF_RANGE + ) { + // these need to be drawn last because of the + // way alpha blending happens here, but for + // hard-to-change reasons they are assembled on + // the server side in the wrong order. (In local + // tiles it's ok to blend them in any order.) + // assumption: only one can appear on any tile. + // TODO: a more general fix for this issue? + ray_tile = overlay; + } else if ( + overlay && + (bg_idx < dngn.DNGN_FIRST_TRANSPARENT || overlay > dngn.FLOOR_MAX) + ) { + this.draw_dngn(overlay, x, y); + } + }); + } + if (ray_tile) this.draw_dngn(ray_tile, x, y); + + if (!bg.UNSEEN) { + if (bg.KRAKEN_NW) this.draw_dngn(dngn.KRAKEN_OVERLAY_NW, x, y); + else if (bg.ELDRITCH_NW) this.draw_dngn(dngn.ELDRITCH_OVERLAY_NW, x, y); + if (bg.KRAKEN_NE) this.draw_dngn(dngn.KRAKEN_OVERLAY_NE, x, y); + else if (bg.ELDRITCH_NE) this.draw_dngn(dngn.ELDRITCH_OVERLAY_NE, x, y); + if (bg.KRAKEN_SE) this.draw_dngn(dngn.KRAKEN_OVERLAY_SE, x, y); + else if (bg.ELDRITCH_SE) this.draw_dngn(dngn.ELDRITCH_OVERLAY_SE, x, y); + if (bg.KRAKEN_SW) this.draw_dngn(dngn.KRAKEN_OVERLAY_SW, x, y); + else if (bg.ELDRITCH_SW) this.draw_dngn(dngn.ELDRITCH_OVERLAY_SW, x, y); + } + + if (!bg.UNSEEN) { + if (cell.sanctuary) this.draw_dngn(dngn.SANCTUARY, x, y); + if (cell.blasphemy) this.draw_dngn(dngn.BLASPHEMY, x, y); + if (cell.has_bfb_corpse) this.draw_dngn(dngn.BLOOD_FOR_BLOOD, x, y); + if (cell.silenced) this.draw_dngn(dngn.SILENCED, x, y); + if (cell.halo === enums.HALO_RANGE) + this.draw_dngn(dngn.HALO_RANGE, x, y); + if ( + cell.halo >= enums.HALO_UMBRA_FIRST && + cell.halo <= enums.HALO_UMBRA_LAST + ) { + const variety = cell.halo - enums.HALO_UMBRA_FIRST; + this.draw_dngn(dngn.UMBRA + variety, x, y); + } + if (cell.orb_glow) + this.draw_dngn(dngn.ORB_GLOW + cell.orb_glow - 1, x, y); + if (cell.quad_glow) this.draw_dngn(dngn.QUAD_GLOW, x, y); + if (cell.disjunct) + this.draw_dngn(dngn.DISJUNCT + cell.disjunct - 1, x, y); + if (cell.awakened_forest) this.draw_icon(icons.BERSERK, x, y); + + if (cell.fg) { + const fg = cell.fg; + if (fg.PET) this.draw_dngn(dngn.HALO_FRIENDLY, x, y); + else if (fg.GD_NEUTRAL) this.draw_dngn(dngn.HALO_GD_NEUTRAL, x, y); + else if (fg.NEUTRAL) this.draw_dngn(dngn.HALO_NEUTRAL, x, y); + + // Monster difficulty. Ghosts get a special highlight. + if (fg.GHOST) { + if (fg.TRIVIAL) this.draw_dngn(dngn.THREAT_GHOST_TRIVIAL, x, y); + else if (fg.EASY) this.draw_dngn(dngn.THREAT_GHOST_EASY, x, y); + else if (fg.TOUGH) this.draw_dngn(dngn.THREAT_GHOST_TOUGH, x, y); + else if (fg.NASTY) this.draw_dngn(dngn.THREAT_GHOST_NASTY, x, y); + else if (fg.UNUSUAL) this.draw_dngn(dngn.THREAT_UNUSUAL, x, y); + } else { + if (fg.TRIVIAL) this.draw_dngn(dngn.THREAT_TRIVIAL, x, y); + else if (fg.EASY) this.draw_dngn(dngn.THREAT_EASY, x, y); + else if (fg.TOUGH) this.draw_dngn(dngn.THREAT_TOUGH, x, y); + else if (fg.NASTY) this.draw_dngn(dngn.THREAT_NASTY, x, y); + else if (fg.UNUSUAL) this.draw_dngn(dngn.THREAT_UNUSUAL, x, y); + } + + if (cell.highlighted_summoner) + this.draw_dngn(dngn.HALO_SUMMONER, x, y); + } + + // Apply the travel exclusion under the foreground if the cell is + // visible. It will be applied later if the cell is unseen. + if (bg.EXCL_CTR) this.draw_dngn(dngn.TRAVEL_EXCLUSION_CENTRE_BG, x, y); + else if (bg.TRAV_EXCL) this.draw_dngn(dngn.TRAVEL_EXCLUSION_BG, x, y); + } + } + + this.draw_ray(x, y, cell); + }, + + draw_submerged_tile: function (base_idx, idx, x, y, trans, img_scale) { + this.ctx.save(); + try { + this.ctx.globalAlpha = trans ? 0.5 : 1.0; + + this.set_nonsubmerged_clip(x, y, 20); + + if (base_idx) this.draw_main(base_idx, x, y, img_scale); + + this.draw_main(idx, x, y, img_scale); + } finally { + this.ctx.restore(); + } + + this.ctx.save(); + try { + this.ctx.globalAlpha = trans ? 0.1 : 0.3; + this.set_submerged_clip(x, y, 20); + + if (base_idx) this.draw_main(base_idx, x, y, img_scale); + + this.draw_main(idx, x, y, img_scale); + } finally { + this.ctx.restore(); + } + }, + + draw_foreground: function (x, y, map_cell, img_scale) { + const cell = map_cell.t; + const fg = cell.fg; + const bg = cell.bg; + const fg_idx = cell.fg.value; + const is_in_water = in_water(cell); + + if ( + fg_idx && + fg_idx <= main.MAIN_MAX && + options.get("tile_display_mode") === "tiles" + ) { + const base_idx = cell.base; + if (is_in_water) { + this.draw_submerged_tile(base_idx, fg_idx, x, y, cell.trans, img_scale); + } else { + if (base_idx) this.draw_main(base_idx, x, y, img_scale); + + this.draw_main(fg_idx, x, y, img_scale); + } + + if (fg_idx >= main.PARCHMENT_LOW && fg_idx <= main.PARCHMENT_HIGH) { + if (cell.overlay1) { + if (is_in_water) { + this.draw_submerged_tile( + null, + cell.overlay1, + x, + y, + cell.trans, + img_scale + ); + } else this.draw_main(cell.overlay1, x, y, img_scale); + } + if (cell.overlay2) { + if (is_in_water) { + this.draw_submerged_tile( + null, + cell.overlay2, + x, + y, + cell.trans, + img_scale + ); + } else this.draw_main(cell.overlay2, x, y, img_scale); + } + } + } else if (options.get("tile_display_mode") === "hybrid") { + this.render_glyph(x, y, map_cell, true, true); + this.draw_ray(x, y, cell); + img_scale = undefined; // TODO: make this work? + } + + if (fg.NET) + this.draw_icon(icons.TRAP_NET, x, y, undefined, undefined, img_scale); + + if (fg.WEB) + this.draw_icon(icons.TRAP_WEB, x, y, undefined, undefined, img_scale); + + if (fg.S_UNDER) + this.draw_icon( + icons.SOMETHING_UNDER, + x, + y, + undefined, + undefined, + img_scale + ); + + // Pet mark + if (fg.PET) + this.draw_icon(icons.FRIENDLY, x, y, undefined, undefined, img_scale); + else if (fg.GD_NEUTRAL) + this.draw_icon(icons.GOOD_NEUTRAL, x, y, undefined, undefined, img_scale); + else if (fg.NEUTRAL) + this.draw_icon(icons.NEUTRAL, x, y, undefined, undefined, img_scale); + + let status_shift = 0; + if (fg.PARALYSED) { + this.draw_icon(icons.PARALYSED, x, y, undefined, undefined, img_scale); + status_shift += 12; + } else if (fg.STAB) { + this.draw_icon(icons.STAB_BRAND, x, y, undefined, undefined, img_scale); + status_shift += 12; + } else if (fg.MAY_STAB) { + this.draw_icon(icons.UNAWARE, x, y, undefined, undefined, img_scale); + status_shift += 7; + } else if (fg.FLEEING) { + this.draw_icon(icons.FLEEING, x, y, undefined, undefined, img_scale); + status_shift += 3; + } + + if (fg.POISON) { + this.draw_icon(icons.POISON, x, y, -status_shift, 0, img_scale); + status_shift += 5; + } else if (fg.MORE_POISON) { + this.draw_icon(icons.MORE_POISON, x, y, -status_shift, 0, img_scale); + status_shift += 5; + } else if (fg.MAX_POISON) { + this.draw_icon(icons.MAX_POISON, x, y, -status_shift, 0, img_scale); + status_shift += 5; + } + + for (let i = 0; i < cell.icons.length; ++i) { + status_shift += this.draw_icon_type( + cell.icons[i], + x, + y, + -status_shift, + 0, + img_scale + ); + } + + if (bg.UNSEEN && (bg.value || fg.value)) + this.draw_icon(icons.MESH, x, y, undefined, undefined, img_scale); + + if (bg.OOR && (bg.value || fg.value)) + this.draw_icon(icons.OOR_MESH, x, y, undefined, undefined, img_scale); + + if (bg.MM_UNSEEN && (bg.value || fg.value)) + this.draw_icon( + icons.MAGIC_MAP_MESH, + x, + y, + undefined, + undefined, + img_scale + ); + + if (bg.RAMPAGE) + this.draw_icon(icons.RAMPAGE, x, y, undefined, undefined, img_scale); + + // Don't let the "new stair" icon cover up any existing icons, but + // draw it otherwise. + if (bg.NEW_STAIR && status_shift === 0) + this.draw_icon(icons.NEW_STAIR, x, y, undefined, undefined, img_scale); + + if (bg.NEW_TRANSPORTER && status_shift === 0) + this.draw_icon( + icons.NEW_TRANSPORTER, + x, + y, + undefined, + undefined, + img_scale + ); + + if (bg.EXCL_CTR && bg.UNSEEN) + this.draw_icon( + icons.TRAVEL_EXCLUSION_CENTRE_FG, + x, + y, + undefined, + undefined, + img_scale + ); + else if (bg.TRAV_EXCL && bg.UNSEEN) + this.draw_icon( + icons.TRAVEL_EXCLUSION_FG, + x, + y, + undefined, + undefined, + img_scale + ); + + // Tutorial cursor takes precedence over other cursors. + if (bg.TUT_CURSOR) { + this.draw_icon( + icons.TUTORIAL_CURSOR, + x, + y, + undefined, + undefined, + img_scale + ); + } else if (bg.CURSOR1) { + this.draw_icon(icons.CURSOR, x, y, undefined, undefined, img_scale); + } else if (bg.CURSOR2) { + this.draw_icon(icons.CURSOR2, x, y, undefined, undefined, img_scale); + } else if (bg.CURSOR3) { + this.draw_icon(icons.CURSOR3, x, y, undefined, undefined, img_scale); + } + + if (cell.travel_trail & 0xf) { + this.draw_icon( + icons.TRAVEL_PATH_FROM + (cell.travel_trail & 0xf) - 1, + x, + y, + undefined, + undefined, + img_scale + ); + } + if (cell.travel_trail & 0xf0) { + this.draw_icon( + icons.TRAVEL_PATH_TO + ((cell.travel_trail & 0xf0) >> 4) - 1, + x, + y, + undefined, + undefined, + img_scale + ); + } + + if (fg.MDAM_LIGHT) + this.draw_icon( + icons.MDAM_LIGHTLY_DAMAGED, + x, + y, + undefined, + undefined, + img_scale + ); + else if (fg.MDAM_MOD) + this.draw_icon( + icons.MDAM_MODERATELY_DAMAGED, + x, + y, + undefined, + undefined, + img_scale + ); + else if (fg.MDAM_HEAVY) + this.draw_icon( + icons.MDAM_HEAVILY_DAMAGED, + x, + y, + undefined, + undefined, + img_scale + ); + else if (fg.MDAM_SEV) + this.draw_icon( + icons.MDAM_SEVERELY_DAMAGED, + x, + y, + undefined, + undefined, + img_scale + ); + else if (fg.MDAM_ADEAD) + this.draw_icon( + icons.MDAM_ALMOST_DEAD, + x, + y, + undefined, + undefined, + img_scale + ); + + if (options.get("tile_show_demon_tier") === true) { + if (fg.DEMON_1) + this.draw_icon(icons.DEMON_NUM1, x, y, undefined, undefined, img_scale); + else if (fg.DEMON_2) + this.draw_icon(icons.DEMON_NUM2, x, y, undefined, undefined, img_scale); + else if (fg.DEMON_3) + this.draw_icon(icons.DEMON_NUM3, x, y, undefined, undefined, img_scale); + else if (fg.DEMON_4) + this.draw_icon(icons.DEMON_NUM4, x, y, undefined, undefined, img_scale); + else if (fg.DEMON_5) + this.draw_icon(icons.DEMON_NUM5, x, y, undefined, undefined, img_scale); + } + }, + + draw_icon_type: function (idx, x, y, ofsx, ofsy, img_scale) { + switch (idx) { + //These icons are in the lower right, so status_shift doesn't need changing. + case icons.BERSERK: + case icons.IDEALISED: + case icons.TOUCH_OF_BEOGH: + case icons.SHADOWLESS: + // Anim. weap. and summoned might overlap, but that's okay + case icons.SUMMONED: + case icons.MINION: + case icons.UNREWARDING: + case icons.ANIMATED_WEAPON: + case icons.VENGEANCE_TARGET: + case icons.VAMPIRE_THRALL: + case icons.ENKINDLED_1: + case icons.ENKINDLED_2: + case icons.NOBODY_MEMORY_1: + case icons.NOBODY_MEMORY_2: + case icons.NOBODY_MEMORY_3: + case icons.PYRRHIC: + case icons.FRENZIED: + this.draw_icon(idx, x, y, undefined, undefined, img_scale); + return 0; + case icons.DRAIN: + case icons.MIGHT: + case icons.SWIFT: + case icons.DAZED: + case icons.HASTED: + case icons.SLOWED: + case icons.CORRODED: + case icons.INFESTED: + case icons.WEAKENED: + case icons.PETRIFIED: + case icons.PETRIFYING: + case icons.BOUND_SOUL: + case icons.POSSESSABLE: + case icons.PARTIALLY_CHARGED: + case icons.FULLY_CHARGED: + case icons.VITRIFIED: + case icons.CONFUSED: + case icons.LACED_WITH_CHAOS: + case icons.SENTINEL_MARK: + case icons.DIMMED: + this.draw_icon(idx, x, y, ofsx, ofsy, img_scale); + return 6; + case icons.CONC_VENOM: + case icons.FIRE_CHAMP: + case icons.INNER_FLAME: + case icons.PAIN_MIRROR: + case icons.STICKY_FLAME: + this.draw_icon(idx, x, y, ofsx, ofsy, img_scale); + return 7; + case icons.ANGUISH: + case icons.FIRE_VULN: + case icons.RESISTANCE: + case icons.GHOSTLY: + case icons.MALMUTATED: + this.draw_icon(idx, x, y, ofsx, ofsy, img_scale); + return 8; + case icons.RECALL: + case icons.TELEPORTING: + this.draw_icon(idx, x, y, ofsx, ofsy, img_scale); + return 9; + case icons.BLIND: + case icons.BRILLIANCE: + case icons.SLOWLY_DYING: + case icons.WATERLOGGED: + case icons.STILL_WINDS: + case icons.ANTIMAGIC: + case icons.REPEL_MISSILES: + case icons.INJURY_BOND: + case icons.GLOW_LIGHT: + case icons.GLOW_HEAVY: + case icons.BULLSEYE: + case icons.CURSE_OF_AGONY: + case icons.REGENERATION: + case icons.RETREAT: + case icons.RIMEBLIGHT: + case icons.UNDYING_ARMS: + case icons.BIND: + case icons.SIGN_OF_RUIN: + case icons.WEAK_WILLED: + case icons.DOUBLED_VIGOUR: + case icons.KINETIC_GRAPNEL: + case icons.TEMPERED: + case icons.HEART: + case icons.UNSTABLE: + case icons.VEXED: + case icons.PARADOX: + case icons.WARDING: + case icons.FIGMENT: + this.draw_icon(idx, x, y, ofsx, ofsy, img_scale); + return 10; + case icons.CONSTRICTED: + case icons.VILE_CLUTCH: + case icons.PAIN_BOND: + this.draw_icon(idx, x, y, ofsx, ofsy, img_scale); + return 11; + } + }, + + // Helper functions for drawing from specific textures + draw_tile: function (idx, x, y, mod, ofsx, ofsy, y_max, centre, img_scale) { + // assumption: x and y are already appropriately scaled for the + // canvas. Now we just need to figure out where in the scaled + // cell size the tile belongs. + const info = mod.get_tile_info(idx); + const img = get_img(mod.get_img(idx)); + if (!info) { + throw new Error(`Tile not found: ${idx}`); + } + + // this somewhat convoluted approach is to avoid fp scaling + // artifacts at scale 1.0 + let img_xscale = this.x_scale; + let img_yscale = this.y_scale; + if (img_scale !== undefined) { + img_xscale = img_xscale * img_scale; + img_yscale = img_yscale * img_scale; + } else img_scale = 1.0; + + centre = centre === undefined ? true : centre; + const size_ox = !centre ? 0 : 32 / 2 - info.w / 2; + const size_oy = !centre ? 0 : 32 - info.h; + const pos_sy_adjust = (ofsy || 0) + info.oy + size_oy; + const pos_ey_adjust = pos_sy_adjust + info.ey - info.sy; + const sy = pos_sy_adjust; + let ey = pos_ey_adjust; + if (y_max && y_max < ey) ey = y_max; + + if (sy >= ey) return; + + const total_x_offset = (ofsx || 0) + info.ox + size_ox; + + // Offsets can be negative, in which case we are drawing overlapped + // with a cell either to the right or above. If so, store the + // overlap in cell state so that it can later be checked to trigger + // a redraw. + // These store logical pixels, but that currently doesn't matter + // because they are only used for a comparison vs 0 + // See dungeon_renderer.js, render_loc. + if (total_x_offset < this.current_left_overlap) + this.current_left_overlap = total_x_offset; + + if (sy < this.current_sy) this.current_sy = sy; + + // dimensions in the source (the tilesheet) + const w = info.ex - info.sx; + const h = ey - sy; + const ratio = window.devicePixelRatio; + // dimensions in the target cell. To get this right at the pixel + // level, we need to calculate the height/width as if it is offset + // relative to the cell origin. Because of differences in how x/y + // are treated above, these may look like they're doing something + // different, but they shouldn't be. + const scaled_w = + Math.floor((total_x_offset + w) * img_xscale * ratio) - + Math.floor(total_x_offset * img_xscale * ratio); + const scaled_h = + Math.floor(ey * img_yscale * ratio) - Math.floor(sy * img_yscale * ratio); + + this.ctx.imageSmoothingEnabled = options.get("tile_filter_scaling"); + this.ctx.drawImage( + img, + info.sx, + info.sy + sy - pos_sy_adjust, + w, + h, + x + Math.floor(total_x_offset * img_xscale * ratio), + y + Math.floor(sy * img_yscale * ratio), + scaled_w, + scaled_h + ); + }, + + draw_dngn: function (idx, x, y, img_scale) { + this.draw_tile( + idx, + x, + y, + dngn, + undefined, + undefined, + undefined, + undefined, + img_scale + ); + }, + + draw_gui: function (idx, x, y, img_scale) { + this.draw_tile( + idx, + x, + y, + gui, + undefined, + undefined, + undefined, + undefined, + img_scale + ); + }, + + draw_main: function (idx, x, y, img_scale) { + this.draw_tile( + idx, + x, + y, + main, + undefined, + undefined, + undefined, + undefined, + img_scale + ); + }, + + draw_player: function (idx, x, y, ofsx, ofsy, y_max, img_scale) { + this.draw_tile( + idx, + x, + y, + tileinfo_player, + ofsx, + ofsy, + y_max, + undefined, + img_scale + ); + }, + + draw_icon: function (idx, x, y, ofsx, ofsy, img_scale) { + this.draw_tile( + idx, + x, + y, + icons, + ofsx, + ofsy, + undefined, + undefined, + img_scale + ); + }, + + draw_quantity: function (qty, x, y, font) { + qty = Math.max(0, Math.min(999, qty)); + + this.ctx.fillStyle = "white"; + this.ctx.font = font; + + this.ctx.shadowColor = "black"; + this.ctx.shadowBlur = 2; + this.ctx.shadowOffsetX = 1; + this.ctx.shadowOffsetY = 1; + this.ctx.textAlign = "left"; + this.ctx.textBaseline = "top"; + this.ctx.fillText(qty, x + 2, y + 2); + }, + + draw_from_texture: function ( + idx, + x, + y, + tex, + ofsx, + ofsy, + y_max, + centre, + img_scale + ) { + const mod = tileinfos(tex); + this.draw_tile(idx, x, y, mod, ofsx, ofsy, y_max, centre, img_scale); + }, +}); + +$(document) + .off("game_init.cell_renderer") + .on("game_init.cell_renderer", () => { + determine_colours(); + }); + +export default { + DungeonCellRenderer: DungeonCellRenderer, +}; diff --git a/crawl-ref/source/webserver/client/game/src/client.js b/crawl-ref/source/webserver/client/game/src/client.js new file mode 100644 index 00000000000..2a56a455c07 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/client.js @@ -0,0 +1 @@ +export default window.DCSS.client; diff --git a/crawl-ref/source/webserver/client/game/src/comm.js b/crawl-ref/source/webserver/client/game/src/comm.js new file mode 100644 index 00000000000..9f7538e8b88 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/comm.js @@ -0,0 +1 @@ +export default window.DCSS.comm; diff --git a/crawl-ref/source/webserver/client/game/src/contrib/simplebar.js b/crawl-ref/source/webserver/client/game/src/contrib/simplebar.js new file mode 100644 index 00000000000..1687d1723bf --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/contrib/simplebar.js @@ -0,0 +1,3353 @@ +/** + * SimpleBar.js - v3.0.0-beta.4 + * Scrollbars, simpler. + * https://grsmto.github.io/simplebar/ + * + * Includes the following embedded node package dependencies: + * - can-use-dom + * - lodash-throttle + * - resize-observer-polyfill + * - scrollbarwidth + * + * Made by Adrien Denat from a fork by Jonathan Nicol + * Under MIT License + */ + +const _isObject = (it) => + typeof it === "object" ? it !== null : typeof it === "function"; + +const _anObject = (it) => { + if (!_isObject(it)) throw TypeError(`${it} is not an object!`); + return it; +}; + +const _fails = (exec) => { + try { + return !!exec(); + } catch (_e) { + return true; + } +}; + +// Thank's IE8 for his funny defineProperty +const _descriptors = !_fails( + () => + Object.defineProperty({}, "a", { + get: () => 7, + }).a !== 7 +); + +const commonjsGlobal = + typeof window !== "undefined" + ? window + : typeof global !== "undefined" + ? global + : typeof self !== "undefined" + ? self + : {}; + +function createCommonjsModule(fn, module) { + return (module = { exports: {} }), fn(module, module.exports), module.exports; +} + +const _global = createCommonjsModule((module) => { + // https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 + const global = (module.exports = + typeof window !== "undefined" && window.Math === Math + ? window + : typeof self !== "undefined" && self.Math === Math + ? self + : // eslint-disable-next-line no-new-func + Function("return this")()); + if (typeof __g === "number") __g = global; // eslint-disable-line no-undef +}); + +const document$1 = _global.document; +// typeof document.createElement is 'object' in old IE +const is = _isObject(document$1) && _isObject(document$1.createElement); +const _domCreate = (it) => (is ? document$1.createElement(it) : {}); + +const _ie8DomDefine = + !_descriptors && + !_fails( + () => + Object.defineProperty(_domCreate("div"), "a", { + get: () => 7, + }).a !== 7 + ); + +// 7.1.1 ToPrimitive(input [, PreferredType]) + +// instead of the ES6 spec version, we didn't implement @@toPrimitive case +// and the second argument - flag - preferred type is a string +const _toPrimitive = (it, S) => { + if (!_isObject(it)) return it; + let fn, val; + if ( + S && + typeof (fn = it.toString) === "function" && + !_isObject((val = fn.call(it))) + ) + return val; + if ( + typeof (fn = it.valueOf) === "function" && + !_isObject((val = fn.call(it))) + ) + return val; + if ( + !S && + typeof (fn = it.toString) === "function" && + !_isObject((val = fn.call(it))) + ) + return val; + throw TypeError("Can't convert object to primitive value"); +}; + +const dP = Object.defineProperty; + +const f = _descriptors + ? Object.defineProperty + : function defineProperty(O, P, Attributes) { + _anObject(O); + P = _toPrimitive(P, true); + _anObject(Attributes); + if (_ie8DomDefine) + try { + return dP(O, P, Attributes); + } catch (_e) { + /* empty */ + } + if ("get" in Attributes || "set" in Attributes) + throw TypeError("Accessors not supported!"); + if ("value" in Attributes) O[P] = Attributes.value; + return O; + }; + +const _objectDp = { + f: f, +}; + +const _propertyDesc = (bitmap, value) => ({ + enumerable: !(bitmap & 1), + configurable: !(bitmap & 2), + writable: !(bitmap & 4), + value: value, +}); + +const _hide = _descriptors + ? (object, key, value) => _objectDp.f(object, key, _propertyDesc(1, value)) + : (object, key, value) => { + object[key] = value; + return object; + }; + +const hasOwnProperty = {}.hasOwnProperty; +const _has = (it, key) => hasOwnProperty.call(it, key); + +let id = 0; +const px = Math.random(); +const _uid = (key) => + "Symbol(".concat( + key === undefined ? "" : key, + ")_", + (++id + px).toString(36) + ); + +const _core = createCommonjsModule((module) => { + const core = (module.exports = { version: "2.5.7" }); + if (typeof __e === "number") __e = core; // eslint-disable-line no-undef +}); +const _core_1 = _core.version; + +const _redefine = createCommonjsModule((module) => { + const SRC = _uid("src"); + const TO_STRING = "toString"; + const $toString = Function[TO_STRING]; + const TPL = `${$toString}`.split(TO_STRING); + + _core.inspectSource = (it) => $toString.call(it); + + (module.exports = (O, key, val, safe) => { + const isFunction = typeof val === "function"; + if (isFunction) _has(val, "name") || _hide(val, "name", key); + if (O[key] === val) return; + if (isFunction) + _has(val, SRC) || + _hide(val, SRC, O[key] ? `${O[key]}` : TPL.join(String(key))); + if (O === _global) { + O[key] = val; + } else if (!safe) { + delete O[key]; + _hide(O, key, val); + } else if (O[key]) { + O[key] = val; + } else { + _hide(O, key, val); + } + // add fake Function#toString for correct work wrapped methods / constructors with methods like LoDash isNative + })(Function.prototype, TO_STRING, function toString() { + return (typeof this === "function" && this[SRC]) || $toString.call(this); + }); +}); + +// 7.2.1 RequireObjectCoercible(argument) +const _defined = (it) => { + if (it === undefined) throw TypeError(`Can't call method on ${it}`); + return it; +}; + +const _library = false; + +const _shared = createCommonjsModule((module) => { + const SHARED = "__core-js_shared__"; + const store = _global[SHARED] || (_global[SHARED] = {}); + + (module.exports = (key, value) => + store[key] || (store[key] = value !== undefined ? value : {}))( + "versions", + [] + ).push({ + version: _core.version, + mode: _library ? "pure" : "global", + copyright: "© 2018 Denis Pushkarev (zloirock.ru)", + }); +}); + +const _wks = createCommonjsModule((module) => { + const store = _shared("wks"); + + const Symbol = _global.Symbol; + const USE_SYMBOL = typeof Symbol === "function"; + + const $exports = (module.exports = (name) => + store[name] || + (store[name] = + (USE_SYMBOL && Symbol[name]) || + (USE_SYMBOL ? Symbol : _uid)(`Symbol.${name}`))); + + $exports.store = store; +}); + +const _fixReWks = (KEY, length, exec) => { + const SYMBOL = _wks(KEY); + const fns = exec(_defined, SYMBOL, ""[KEY]); + const strfn = fns[0]; + const rxfn = fns[1]; + if ( + _fails(() => { + const O = {}; + O[SYMBOL] = () => 7; + return ""[KEY](O) !== 7; + }) + ) { + _redefine(String.prototype, KEY, strfn); + _hide( + RegExp.prototype, + SYMBOL, + length === 2 + ? // 21.2.5.8 RegExp.prototype[@@replace](string, replaceValue) + // 21.2.5.11 RegExp.prototype[@@split](string, limit) + function (string, arg) { + return rxfn.call(string, this, arg); + } + : // 21.2.5.6 RegExp.prototype[@@match](string) + // 21.2.5.9 RegExp.prototype[@@search](string) + function (string) { + return rxfn.call(string, this); + } + ); + } +}; + +// @@replace logic +_fixReWks("replace", 2, (defined, REPLACE, $replace) => { + // 21.1.3.14 String.prototype.replace(searchValue, replaceValue) + return [ + function replace(searchValue, replaceValue) { + const O = defined(this); + const fn = searchValue === undefined ? undefined : searchValue[REPLACE]; + return fn !== undefined + ? fn.call(searchValue, O, replaceValue) + : $replace.call(String(O), searchValue, replaceValue); + }, + $replace, + ]; +}); + +const dP$1 = _objectDp.f; +const FProto = Function.prototype; +const nameRE = /^\s*function ([^ (]*)/; +const NAME = "name"; + +// 19.2.4.2 name +NAME in FProto || + (_descriptors && + dP$1(FProto, NAME, { + configurable: true, + get: function () { + try { + return `${this}`.match(nameRE)[1]; + } catch (_e) { + return ""; + } + }, + })); + +// @@match logic +_fixReWks("match", 1, (defined, MATCH, $match) => { + // 21.1.3.11 String.prototype.match(regexp) + return [ + function match(regexp) { + const O = defined(this); + const fn = regexp === undefined ? undefined : regexp[MATCH]; + return fn !== undefined + ? fn.call(regexp, O) + : new RegExp(regexp)[MATCH](String(O)); + }, + $match, + ]; +}); + +// 22.1.3.31 Array.prototype[@@unscopables] +const UNSCOPABLES = _wks("unscopables"); +const ArrayProto = Array.prototype; +if (ArrayProto[UNSCOPABLES] === undefined) _hide(ArrayProto, UNSCOPABLES, {}); +const _addToUnscopables = (key) => { + ArrayProto[UNSCOPABLES][key] = true; +}; + +const _iterStep = (done, value) => ({ value: value, done: !!done }); + +const _iterators = {}; + +const toString = {}.toString; + +const _cof = (it) => toString.call(it).slice(8, -1); + +// fallback for non-array-like ES3 and non-enumerable old V8 strings + +// eslint-disable-next-line no-prototype-builtins +const _iobject = Object("z").propertyIsEnumerable(0) + ? Object + : (it) => (_cof(it) === "String" ? it.split("") : Object(it)); + +// to indexed object, toObject with fallback for non-array-like ES3 strings + +const _toIobject = (it) => _iobject(_defined(it)); + +const _aFunction = (it) => { + if (typeof it !== "function") throw TypeError(`${it} is not a function!`); + return it; +}; + +// optional / simple context binding + +const _ctx = (fn, that, length) => { + _aFunction(fn); + if (that === undefined) return fn; + switch (length) { + case 1: + return (a) => fn.call(that, a); + case 2: + return (a, b) => fn.call(that, a, b); + case 3: + return (a, b, c) => fn.call(that, a, b, c); + } + return (/* ...args */) => fn.apply(that, arguments); +}; + +const PROTOTYPE = "prototype"; + +const $export = (type, name, source) => { + const IS_FORCED = type & $export.F; + const IS_GLOBAL = type & $export.G; + const IS_STATIC = type & $export.S; + const IS_PROTO = type & $export.P; + const IS_BIND = type & $export.B; + const target = IS_GLOBAL + ? _global + : IS_STATIC + ? _global[name] || (_global[name] = {}) + : _global[name]?.[PROTOTYPE]; + const exports = IS_GLOBAL ? _core : _core[name] || (_core[name] = {}); + const expProto = exports[PROTOTYPE] || (exports[PROTOTYPE] = {}); + let key, own, out, exp; + if (IS_GLOBAL) source = name; + for (key in source) { + // contains in native + own = !IS_FORCED && target && target[key] !== undefined; + // export native or passed + out = (own ? target : source)[key]; + // bind timers to global for call from export context + exp = + IS_BIND && own + ? _ctx(out, _global) + : IS_PROTO && typeof out === "function" + ? _ctx(Function.call, out) + : out; + // extend global + if (target) _redefine(target, key, out, type & $export.U); + // export + if (exports[key] !== out) _hide(exports, key, exp); + if (IS_PROTO && expProto[key] !== out) expProto[key] = out; + } +}; +_global.core = _core; +// type bitmap +$export.F = 1; // forced +$export.G = 2; // global +$export.S = 4; // static +$export.P = 8; // proto +$export.B = 16; // bind +$export.W = 32; // wrap +$export.U = 64; // safe +$export.R = 128; // real proto method for `library` +const _export = $export; + +// 7.1.4 ToInteger +const ceil = Math.ceil; +const floor = Math.floor; +const _toInteger = (it) => + Number.isNaN((it = +it)) ? 0 : (it > 0 ? floor : ceil)(it); + +// 7.1.15 ToLength + +const min = Math.min; +const _toLength = (it) => { + return it > 0 ? min(_toInteger(it), 0x1fffffffffffff) : 0; // pow(2, 53) - 1 == 9007199254740991 +}; + +const max = Math.max; +const min$1 = Math.min; +const _toAbsoluteIndex = (index, length) => { + index = _toInteger(index); + return index < 0 ? max(index + length, 0) : min$1(index, length); +}; + +// false -> Array#indexOf +// true -> Array#includes + +const _arrayIncludes = (IS_INCLUDES) => ($this, el, fromIndex) => { + const O = _toIobject($this); + const length = _toLength(O.length); + let index = _toAbsoluteIndex(fromIndex, length); + let value; + // Array#includes uses SameValueZero equality algorithm + // eslint-disable-next-line no-self-compare + if (IS_INCLUDES && el !== el) + while (length > index) { + value = O[index++]; + // eslint-disable-next-line no-self-compare + if (value !== value) return true; + // Array#indexOf ignores holes, Array#includes - not + } + else + for (; length > index; index++) + if (IS_INCLUDES || index in O) { + if (O[index] === el) return IS_INCLUDES || index || 0; + } + return !IS_INCLUDES && -1; +}; + +const shared = _shared("keys"); + +const _sharedKey = (key) => shared[key] || (shared[key] = _uid(key)); + +const arrayIndexOf = _arrayIncludes(false); +const IE_PROTO = _sharedKey("IE_PROTO"); + +const _objectKeysInternal = (object, names) => { + const O = _toIobject(object); + let i = 0; + const result = []; + let key; + for (key in O) if (key !== IE_PROTO) _has(O, key) && result.push(key); + // Don't enum bug & hidden keys + while (names.length > i) + if (_has(O, (key = names[i++]))) { + ~arrayIndexOf(result, key) || result.push(key); + } + return result; +}; + +// IE 8- don't enum bug keys +const _enumBugKeys = + "constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split( + "," + ); + +// 19.1.2.14 / 15.2.3.14 Object.keys(O) + +const _objectKeys = + Object.keys || + function keys(O) { + return _objectKeysInternal(O, _enumBugKeys); + }; + +const _objectDps = _descriptors + ? Object.defineProperties + : function defineProperties(O, Properties) { + _anObject(O); + const keys = _objectKeys(Properties); + const length = keys.length; + let i = 0; + let P; + while (length > i) _objectDp.f(O, (P = keys[i++]), Properties[P]); + return O; + }; + +const document$2 = _global.document; +const _html = document$2?.documentElement; + +// 19.1.2.2 / 15.2.3.5 Object.create(O [, Properties]) + +const IE_PROTO$1 = _sharedKey("IE_PROTO"); +const Empty = () => { + /* empty */ +}; +const PROTOTYPE$1 = "prototype"; + +// Create object with fake `null` prototype: use iframe Object with cleared prototype +let createDict = () => { + // Thrash, waste and sodomy: IE GC bug + const iframe = _domCreate("iframe"); + let i = _enumBugKeys.length; + const lt = "<"; + const gt = ">"; + let iframeDocument; + iframe.style.display = "none"; + _html.appendChild(iframe); + iframe.src = "javascript:"; // eslint-disable-line no-script-url + // createDict = iframe.contentWindow.Object; + // html.removeChild(iframe); + iframeDocument = iframe.contentWindow.document; + iframeDocument.open(); + iframeDocument.write(`${lt}script${gt}document.F=Object${lt}/script${gt}`); + iframeDocument.close(); + createDict = iframeDocument.F; + while (i--) delete createDict[PROTOTYPE$1][_enumBugKeys[i]]; + return createDict(); +}; + +const _objectCreate = + Object.create || + function create(O, Properties) { + let result; + if (O !== null) { + Empty[PROTOTYPE$1] = _anObject(O); + result = new Empty(); + Empty[PROTOTYPE$1] = null; + // add "__proto__" for Object.getPrototypeOf polyfill + result[IE_PROTO$1] = O; + } else result = createDict(); + return Properties === undefined ? result : _objectDps(result, Properties); + }; + +const def = _objectDp.f; + +const TAG = _wks("toStringTag"); + +const _setToStringTag = (it, tag, stat) => { + if (it && !_has((it = stat ? it : it.prototype), TAG)) + def(it, TAG, { configurable: true, value: tag }); +}; + +const IteratorPrototype = {}; + +// 25.1.2.1.1 %IteratorPrototype%[@@iterator]() +_hide(IteratorPrototype, _wks("iterator"), function () { + return this; +}); + +const _iterCreate = (Constructor, NAME, next) => { + Constructor.prototype = _objectCreate(IteratorPrototype, { + next: _propertyDesc(1, next), + }); + _setToStringTag(Constructor, `${NAME} Iterator`); +}; + +// 7.1.13 ToObject(argument) + +const _toObject = (it) => Object(_defined(it)); + +// 19.1.2.9 / 15.2.3.2 Object.getPrototypeOf(O) + +const IE_PROTO$2 = _sharedKey("IE_PROTO"); +const ObjectProto = Object.prototype; + +const _objectGpo = + Object.getPrototypeOf || + ((O) => { + O = _toObject(O); + if (_has(O, IE_PROTO$2)) return O[IE_PROTO$2]; + if (typeof O.constructor === "function" && O instanceof O.constructor) { + return O.constructor.prototype; + } + return O instanceof Object ? ObjectProto : null; + }); + +const ITERATOR = _wks("iterator"); +const BUGGY = !([].keys && "next" in [].keys()); // Safari has buggy iterators w/o `next` +const FF_ITERATOR = "@@iterator"; +const KEYS = "keys"; +const VALUES = "values"; + +const returnThis = function () { + return this; +}; + +const _iterDefine = ( + Base, + NAME, + Constructor, + next, + DEFAULT, + IS_SET, + FORCED +) => { + _iterCreate(Constructor, NAME, next); + const getMethod = (kind) => { + if (!BUGGY && kind in proto) return proto[kind]; + switch (kind) { + case KEYS: + return function keys() { + return new Constructor(this, kind); + }; + case VALUES: + return function values() { + return new Constructor(this, kind); + }; + } + return function entries() { + return new Constructor(this, kind); + }; + }; + const TAG = `${NAME} Iterator`; + const DEF_VALUES = DEFAULT === VALUES; + let VALUES_BUG = false; + const proto = Base.prototype; + const $native = + proto[ITERATOR] || proto[FF_ITERATOR] || (DEFAULT && proto[DEFAULT]); + let $default = $native || getMethod(DEFAULT); + const $entries = DEFAULT + ? !DEF_VALUES + ? $default + : getMethod("entries") + : undefined; + const $anyNative = NAME === "Array" ? proto.entries || $native : $native; + let methods, key, IteratorPrototype; + // Fix native + if ($anyNative) { + IteratorPrototype = _objectGpo($anyNative.call(new Base())); + if (IteratorPrototype !== Object.prototype && IteratorPrototype.next) { + // Set @@toStringTag to native iterators + _setToStringTag(IteratorPrototype, TAG, true); + // fix for some old engines + if (!_library && typeof IteratorPrototype[ITERATOR] !== "function") + _hide(IteratorPrototype, ITERATOR, returnThis); + } + } + // fix Array#{values, @@iterator}.name in V8 / FF + if (DEF_VALUES && $native && $native.name !== VALUES) { + VALUES_BUG = true; + $default = function values() { + return $native.call(this); + }; + } + // Define iterator + if ((!_library || FORCED) && (BUGGY || VALUES_BUG || !proto[ITERATOR])) { + _hide(proto, ITERATOR, $default); + } + // Plug for library + _iterators[NAME] = $default; + _iterators[TAG] = returnThis; + if (DEFAULT) { + methods = { + values: DEF_VALUES ? $default : getMethod(VALUES), + keys: IS_SET ? $default : getMethod(KEYS), + entries: $entries, + }; + if (FORCED) + for (key in methods) { + if (!(key in proto)) _redefine(proto, key, methods[key]); + } + else _export(_export.P + _export.F * (BUGGY || VALUES_BUG), NAME, methods); + } + return methods; +}; + +// 22.1.3.4 Array.prototype.entries() +// 22.1.3.13 Array.prototype.keys() +// 22.1.3.29 Array.prototype.values() +// 22.1.3.30 Array.prototype[@@iterator]() +const es6_array_iterator = _iterDefine( + Array, + "Array", + function (iterated, kind) { + this._t = _toIobject(iterated); // target + this._i = 0; // next index + this._k = kind; // kind + // 22.1.5.2.1 %ArrayIteratorPrototype%.next() + }, + function () { + const O = this._t; + const kind = this._k; + const index = this._i++; + if (!O || index >= O.length) { + this._t = undefined; + return _iterStep(1); + } + if (kind === "keys") return _iterStep(0, index); + if (kind === "values") return _iterStep(0, O[index]); + return _iterStep(0, [index, O[index]]); + }, + "values" +); + +// argumentsList[@@iterator] is %ArrayProto_values% (9.4.4.6, 9.4.4.7) +_iterators.Arguments = _iterators.Array; + +_addToUnscopables("keys"); +_addToUnscopables("values"); +_addToUnscopables("entries"); + +const ITERATOR$1 = _wks("iterator"); +const TO_STRING_TAG = _wks("toStringTag"); +const ArrayValues = _iterators.Array; + +const DOMIterables = { + CSSRuleList: true, // TODO: Not spec compliant, should be false. + CSSStyleDeclaration: false, + CSSValueList: false, + ClientRectList: false, + DOMRectList: false, + DOMStringList: false, + DOMTokenList: true, + DataTransferItemList: false, + FileList: false, + HTMLAllCollection: false, + HTMLCollection: false, + HTMLFormElement: false, + HTMLSelectElement: false, + MediaList: true, // TODO: Not spec compliant, should be false. + MimeTypeArray: false, + NamedNodeMap: false, + NodeList: true, + PaintRequestList: false, + Plugin: false, + PluginArray: false, + SVGLengthList: false, + SVGNumberList: false, + SVGPathSegList: false, + SVGPointList: false, + SVGStringList: false, + SVGTransformList: false, + SourceBufferList: false, + StyleSheetList: true, // TODO: Not spec compliant, should be false. + TextTrackCueList: false, + TextTrackList: false, + TouchList: false, +}; + +for ( + let collections = _objectKeys(DOMIterables), i = 0; + i < collections.length; + i++ +) { + const NAME$1 = collections[i]; + const explicit = DOMIterables[NAME$1]; + const Collection = _global[NAME$1]; + const proto = Collection?.prototype; + let key; + if (proto) { + if (!proto[ITERATOR$1]) _hide(proto, ITERATOR$1, ArrayValues); + if (!proto[TO_STRING_TAG]) _hide(proto, TO_STRING_TAG, NAME$1); + _iterators[NAME$1] = ArrayValues; + if (explicit) + for (key in es6_array_iterator) + if (!proto[key]) _redefine(proto, key, es6_array_iterator[key], true); + } +} + +// true -> String#at +// false -> String#codePointAt +const _stringAt = (TO_STRING) => (that, pos) => { + const s = String(_defined(that)); + const i = _toInteger(pos); + const l = s.length; + let a, b; + if (i < 0 || i >= l) return TO_STRING ? "" : undefined; + a = s.charCodeAt(i); + return a < 0xd800 || + a > 0xdbff || + i + 1 === l || + (b = s.charCodeAt(i + 1)) < 0xdc00 || + b > 0xdfff + ? TO_STRING + ? s.charAt(i) + : a + : TO_STRING + ? s.slice(i, i + 2) + : ((a - 0xd800) << 10) + (b - 0xdc00) + 0x10000; +}; + +const $at = _stringAt(true); + +// 21.1.3.27 String.prototype[@@iterator]() +_iterDefine( + String, + "String", + function (iterated) { + this._t = String(iterated); // target + this._i = 0; // next index + // 21.1.5.2.1 %StringIteratorPrototype%.next() + }, + function () { + const O = this._t; + const index = this._i; + let point; + if (index >= O.length) return { value: undefined, done: true }; + point = $at(O, index); + this._i += point.length; + return { value: point, done: false }; + } +); + +// call something on iterator step with safe closing on error + +const _iterCall = (iterator, fn, value, entries) => { + try { + return entries ? fn(_anObject(value)[0], value[1]) : fn(value); + // 7.4.6 IteratorClose(iterator, completion) + } catch (e) { + const ret = iterator.return; + if (ret !== undefined) _anObject(ret.call(iterator)); + throw e; + } +}; + +// check on default Array iterator + +const ITERATOR$2 = _wks("iterator"); +const ArrayProto$1 = Array.prototype; + +const _isArrayIter = (it) => + it !== undefined && + (_iterators.Array === it || ArrayProto$1[ITERATOR$2] === it); + +const _createProperty = (object, index, value) => { + if (index in object) _objectDp.f(object, index, _propertyDesc(0, value)); + else object[index] = value; +}; + +// getting tag from 19.1.3.6 Object.prototype.toString() + +const TAG$1 = _wks("toStringTag"); +// ES3 wrong here +const ARG = _cof((() => arguments)()) === "Arguments"; + +// fallback for IE11 Script Access Denied error +const tryGet = (it, key) => { + try { + return it[key]; + } catch (_e) { + /* empty */ + } +}; + +const _classof = (it) => { + let O, T, B; + return it === undefined + ? "Undefined" + : it === null + ? "Null" + : // @@toStringTag case + typeof (T = tryGet((O = Object(it)), TAG$1)) === "string" + ? T + : // builtinTag case + ARG + ? _cof(O) + : // ES3 arguments fallback + (B = _cof(O)) === "Object" && typeof O.callee === "function" + ? "Arguments" + : B; +}; + +const ITERATOR$3 = _wks("iterator"); + +const core_getIteratorMethod = (_core.getIteratorMethod = (it) => { + if (it !== undefined) + return it[ITERATOR$3] || it["@@iterator"] || _iterators[_classof(it)]; +}); + +const ITERATOR$4 = _wks("iterator"); +let SAFE_CLOSING = false; + +try { + const riter = [7][ITERATOR$4](); + riter.return = () => { + SAFE_CLOSING = true; + }; +} catch (_e) { + /* empty */ +} + +const _iterDetect = (exec, skipClosing) => { + if (!skipClosing && !SAFE_CLOSING) return false; + let safe = false; + try { + const arr = [7]; + const iter = arr[ITERATOR$4](); + iter.next = () => ({ done: (safe = true) }); + arr[ITERATOR$4] = () => iter; + exec(arr); + } catch (_e) { + /* empty */ + } + return safe; +}; + +_export(_export.S + _export.F * !_iterDetect((_iter) => {}), "Array", { + // 22.1.2.1 Array.from(arrayLike, mapfn = undefined, thisArg = undefined) + from: function from( + arrayLike /* , mapfn = undefined, thisArg = undefined */ + ) { + const O = _toObject(arrayLike); + const C = typeof this === "function" ? this : Array; + const aLen = arguments.length; + let mapfn = aLen > 1 ? arguments[1] : undefined; + const mapping = mapfn !== undefined; + let index = 0; + const iterFn = core_getIteratorMethod(O); + let length, result, step, iterator; + if (mapping) mapfn = _ctx(mapfn, aLen > 2 ? arguments[2] : undefined, 2); + // if object isn't iterable or it's array with default iterator - use simple case + if (iterFn !== undefined && !(C === Array && _isArrayIter(iterFn))) { + for ( + iterator = iterFn.call(O), result = new C(); + !(step = iterator.next()).done; + index++ + ) { + _createProperty( + result, + index, + mapping + ? _iterCall(iterator, mapfn, [step.value, index], true) + : step.value + ); + } + } else { + length = _toLength(O.length); + for (result = new C(length); length > index; index++) { + _createProperty( + result, + index, + mapping ? mapfn(O[index], index) : O[index] + ); + } + } + result.length = index; + return result; + }, +}); + +const f$1 = Object.getOwnPropertySymbols; + +const _objectGops = { + f: f$1, +}; + +const f$2 = {}.propertyIsEnumerable; + +const _objectPie = { + f: f$2, +}; + +// 19.1.2.1 Object.assign(target, source, ...) + +const $assign = Object.assign; + +// should work with symbols and should have deterministic property order (V8 bug) +const _objectAssign = + !$assign || + _fails(() => { + const A = {}; + const B = {}; + // eslint-disable-next-line no-undef + const S = Symbol(); + const K = "abcdefghijklmnopqrst"; + A[S] = 7; + K.split("").forEach((k) => { + B[k] = k; + }); + return ( + $assign({}, A)[S] !== 7 || Object.keys($assign({}, B)).join("") !== K + ); + }) + ? function assign(target, _source) { + // eslint-disable-line no-unused-vars + const T = _toObject(target); + const aLen = arguments.length; + let index = 1; + const getSymbols = _objectGops.f; + const isEnum = _objectPie.f; + while (aLen > index) { + const S = _iobject(arguments[index++]); + const keys = getSymbols + ? _objectKeys(S).concat(getSymbols(S)) + : _objectKeys(S); + const length = keys.length; + let j = 0; + let key; + while (length > j) + if (isEnum.call(S, (key = keys[j++]))) T[key] = S[key]; + } + return T; + } + : $assign; + +// 19.1.3.1 Object.assign(target, source) + +_export(_export.S + _export.F, "Object", { assign: _objectAssign }); + +function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } +} + +function _defineProperties(target, props) { + for (let i = 0; i < props.length; i++) { + const descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } +} + +function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) _defineProperties(Constructor.prototype, protoProps); + if (staticProps) _defineProperties(Constructor, staticProps); + return Constructor; +} + +const scrollbarWidth = createCommonjsModule((module, _exports) => { + /*! scrollbarWidth.js v0.1.3 | felixexter | MIT | https://github.com/felixexter/scrollbarWidth */ + ((_root, factory) => { + module.exports = factory(); + })(commonjsGlobal, () => { + function scrollbarWidth() { + if (typeof document === "undefined") { + return 0; + } + + let body = document.body, + box = document.createElement("div"), + boxStyle = box.style, + width; + + boxStyle.position = "absolute"; + boxStyle.top = boxStyle.left = "-9999px"; + boxStyle.width = boxStyle.height = "100px"; + boxStyle.overflow = "scroll"; + + body.appendChild(box); + + width = box.offsetWidth - box.clientWidth; + + body.removeChild(box); + + return width; + } + + return scrollbarWidth; + }); +}); + +/** + * lodash (Custom Build) + * Build: `lodash modularize exports="npm" -o ./` + * Copyright jQuery Foundation and other contributors + * Released under MIT license + * Based on Underscore.js 1.8.3 + * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + */ + +/** Used as the `TypeError` message for "Functions" methods. */ +const FUNC_ERROR_TEXT = "Expected a function"; + +/** Used as references for various `Number` constants. */ +const NAN = 0 / 0; + +/** `Object#toString` result references. */ +const symbolTag = "[object Symbol]"; + +/** Used to match leading and trailing whitespace. */ +const reTrim = /^\s+|\s+$/g; + +/** Used to detect bad signed hexadecimal string values. */ +const reIsBadHex = /^[-+]0x[0-9a-f]+$/i; + +/** Used to detect binary string values. */ +const reIsBinary = /^0b[01]+$/i; + +/** Used to detect octal string values. */ +const reIsOctal = /^0o[0-7]+$/i; + +/** Built-in method references without a dependency on `root`. */ +const freeParseInt = parseInt; + +/** Detect free variable `global` from Node.js. */ +const freeGlobal = + typeof commonjsGlobal === "object" && + commonjsGlobal && + commonjsGlobal.Object === Object && + commonjsGlobal; + +/** Detect free variable `self`. */ +const freeSelf = + typeof self === "object" && self && self.Object === Object && self; + +/** Used as a reference to the global object. */ +const root = freeGlobal || freeSelf || Function("return this")(); + +/** Used for built-in method references. */ +const objectProto = Object.prototype; + +/** + * Used to resolve the + * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) + * of values. + */ +const objectToString = objectProto.toString; + +/* Built-in method references for those with the same name as other `lodash` methods. */ +const nativeMax = Math.max, + nativeMin = Math.min; + +/** + * Gets the timestamp of the number of milliseconds that have elapsed since + * the Unix epoch (1 January 1970 00:00:00 UTC). + * + * @static + * @memberOf _ + * @since 2.4.0 + * @category Date + * @returns {number} Returns the timestamp. + * @example + * + * _.defer(function(stamp) { + * console.log(_.now() - stamp); + * }, _.now()); + * // => Logs the number of milliseconds it took for the deferred invocation. + */ +const now = () => root.Date.now(); + +/** + * Creates a debounced function that delays invoking `func` until after `wait` + * milliseconds have elapsed since the last time the debounced function was + * invoked. The debounced function comes with a `cancel` method to cancel + * delayed `func` invocations and a `flush` method to immediately invoke them. + * Provide `options` to indicate whether `func` should be invoked on the + * leading and/or trailing edge of the `wait` timeout. The `func` is invoked + * with the last arguments provided to the debounced function. Subsequent + * calls to the debounced function return the result of the last `func` + * invocation. + * + * **Note:** If `leading` and `trailing` options are `true`, `func` is + * invoked on the trailing edge of the timeout only if the debounced function + * is invoked more than once during the `wait` timeout. + * + * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred + * until to the next tick, similar to `setTimeout` with a timeout of `0`. + * + * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) + * for details over the differences between `_.debounce` and `_.throttle`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Function + * @param {Function} func The function to debounce. + * @param {number} [wait=0] The number of milliseconds to delay. + * @param {Object} [options={}] The options object. + * @param {boolean} [options.leading=false] + * Specify invoking on the leading edge of the timeout. + * @param {number} [options.maxWait] + * The maximum time `func` is allowed to be delayed before it's invoked. + * @param {boolean} [options.trailing=true] + * Specify invoking on the trailing edge of the timeout. + * @returns {Function} Returns the new debounced function. + * @example + * + * // Avoid costly calculations while the window size is in flux. + * jQuery(window).on('resize', _.debounce(calculateLayout, 150)); + * + * // Invoke `sendMail` when clicked, debouncing subsequent calls. + * jQuery(element).on('click', _.debounce(sendMail, 300, { + * 'leading': true, + * 'trailing': false + * })); + * + * // Ensure `batchLog` is invoked once after 1 second of debounced calls. + * var debounced = _.debounce(batchLog, 250, { 'maxWait': 1000 }); + * var source = new EventSource('/stream'); + * jQuery(source).on('message', debounced); + * + * // Cancel the trailing debounced invocation. + * jQuery(window).on('popstate', debounced.cancel); + */ +function debounce(func, wait, options) { + let lastArgs, + lastThis, + maxWait, + result, + timerId, + lastCallTime, + lastInvokeTime = 0, + leading = false, + maxing = false, + trailing = true; + + if (typeof func !== "function") { + throw new TypeError(FUNC_ERROR_TEXT); + } + wait = toNumber(wait) || 0; + if (isObject(options)) { + leading = !!options.leading; + maxing = "maxWait" in options; + maxWait = maxing + ? nativeMax(toNumber(options.maxWait) || 0, wait) + : maxWait; + trailing = "trailing" in options ? !!options.trailing : trailing; + } + + function invokeFunc(time) { + const args = lastArgs, + thisArg = lastThis; + + lastArgs = lastThis = undefined; + lastInvokeTime = time; + result = func.apply(thisArg, args); + return result; + } + + function leadingEdge(time) { + // Reset any `maxWait` timer. + lastInvokeTime = time; + // Start the timer for the trailing edge. + timerId = setTimeout(timerExpired, wait); + // Invoke the leading edge. + return leading ? invokeFunc(time) : result; + } + + function remainingWait(time) { + const timeSinceLastCall = time - lastCallTime, + timeSinceLastInvoke = time - lastInvokeTime, + result = wait - timeSinceLastCall; + + return maxing ? nativeMin(result, maxWait - timeSinceLastInvoke) : result; + } + + function shouldInvoke(time) { + const timeSinceLastCall = time - lastCallTime, + timeSinceLastInvoke = time - lastInvokeTime; + + // Either this is the first call, activity has stopped and we're at the + // trailing edge, the system time has gone backwards and we're treating + // it as the trailing edge, or we've hit the `maxWait` limit. + return ( + lastCallTime === undefined || + timeSinceLastCall >= wait || + timeSinceLastCall < 0 || + (maxing && timeSinceLastInvoke >= maxWait) + ); + } + + function timerExpired() { + const time = now(); + if (shouldInvoke(time)) { + return trailingEdge(time); + } + // Restart the timer. + timerId = setTimeout(timerExpired, remainingWait(time)); + } + + function trailingEdge(time) { + timerId = undefined; + + // Only invoke if we have `lastArgs` which means `func` has been + // debounced at least once. + if (trailing && lastArgs) { + return invokeFunc(time); + } + lastArgs = lastThis = undefined; + return result; + } + + function cancel() { + if (timerId !== undefined) { + clearTimeout(timerId); + } + lastInvokeTime = 0; + lastArgs = lastCallTime = lastThis = timerId = undefined; + } + + function flush() { + return timerId === undefined ? result : trailingEdge(now()); + } + + function debounced() { + const time = now(), + isInvoking = shouldInvoke(time); + + lastArgs = arguments; + lastThis = this; + lastCallTime = time; + + if (isInvoking) { + if (timerId === undefined) { + return leadingEdge(lastCallTime); + } + if (maxing) { + // Handle invocations in a tight loop. + timerId = setTimeout(timerExpired, wait); + return invokeFunc(lastCallTime); + } + } + if (timerId === undefined) { + timerId = setTimeout(timerExpired, wait); + } + return result; + } + debounced.cancel = cancel; + debounced.flush = flush; + return debounced; +} + +/** + * Creates a throttled function that only invokes `func` at most once per + * every `wait` milliseconds. The throttled function comes with a `cancel` + * method to cancel delayed `func` invocations and a `flush` method to + * immediately invoke them. Provide `options` to indicate whether `func` + * should be invoked on the leading and/or trailing edge of the `wait` + * timeout. The `func` is invoked with the last arguments provided to the + * throttled function. Subsequent calls to the throttled function return the + * result of the last `func` invocation. + * + * **Note:** If `leading` and `trailing` options are `true`, `func` is + * invoked on the trailing edge of the timeout only if the throttled function + * is invoked more than once during the `wait` timeout. + * + * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred + * until to the next tick, similar to `setTimeout` with a timeout of `0`. + * + * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) + * for details over the differences between `_.throttle` and `_.debounce`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Function + * @param {Function} func The function to throttle. + * @param {number} [wait=0] The number of milliseconds to throttle invocations to. + * @param {Object} [options={}] The options object. + * @param {boolean} [options.leading=true] + * Specify invoking on the leading edge of the timeout. + * @param {boolean} [options.trailing=true] + * Specify invoking on the trailing edge of the timeout. + * @returns {Function} Returns the new throttled function. + * @example + * + * // Avoid excessively updating the position while scrolling. + * jQuery(window).on('scroll', _.throttle(updatePosition, 100)); + * + * // Invoke `renewToken` when the click event is fired, but not more than once every 5 minutes. + * var throttled = _.throttle(renewToken, 300000, { 'trailing': false }); + * jQuery(element).on('click', throttled); + * + * // Cancel the trailing throttled invocation. + * jQuery(window).on('popstate', throttled.cancel); + */ +function throttle(func, wait, options) { + let leading = true, + trailing = true; + + if (typeof func !== "function") { + throw new TypeError(FUNC_ERROR_TEXT); + } + if (isObject(options)) { + leading = "leading" in options ? !!options.leading : leading; + trailing = "trailing" in options ? !!options.trailing : trailing; + } + return debounce(func, wait, { + leading: leading, + maxWait: wait, + trailing: trailing, + }); +} + +/** + * Checks if `value` is the + * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) + * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an object, else `false`. + * @example + * + * _.isObject({}); + * // => true + * + * _.isObject([1, 2, 3]); + * // => true + * + * _.isObject(_.noop); + * // => true + * + * _.isObject(null); + * // => false + */ +function isObject(value) { + const type = typeof value; + return !!value && (type === "object" || type === "function"); +} + +/** + * Checks if `value` is object-like. A value is object-like if it's not `null` + * and has a `typeof` result of "object". + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is object-like, else `false`. + * @example + * + * _.isObjectLike({}); + * // => true + * + * _.isObjectLike([1, 2, 3]); + * // => true + * + * _.isObjectLike(_.noop); + * // => false + * + * _.isObjectLike(null); + * // => false + */ +function isObjectLike(value) { + return !!value && typeof value === "object"; +} + +/** + * Checks if `value` is classified as a `Symbol` primitive or object. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a symbol, else `false`. + * @example + * + * _.isSymbol(Symbol.iterator); + * // => true + * + * _.isSymbol('abc'); + * // => false + */ +function isSymbol(value) { + return ( + typeof value === "symbol" || + (isObjectLike(value) && objectToString.call(value) === symbolTag) + ); +} + +/** + * Converts `value` to a number. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to process. + * @returns {number} Returns the number. + * @example + * + * _.toNumber(3.2); + * // => 3.2 + * + * _.toNumber(Number.MIN_VALUE); + * // => 5e-324 + * + * _.toNumber(Infinity); + * // => Infinity + * + * _.toNumber('3.2'); + * // => 3.2 + */ +function toNumber(value) { + if (typeof value === "number") { + return value; + } + if (isSymbol(value)) { + return NAN; + } + if (isObject(value)) { + const other = typeof value.valueOf === "function" ? value.valueOf() : value; + value = isObject(other) ? `${other}` : other; + } + if (typeof value !== "string") { + return value === 0 ? value : +value; + } + value = value.replace(reTrim, ""); + const isBinary = reIsBinary.test(value); + return isBinary || reIsOctal.test(value) + ? freeParseInt(value.slice(2), isBinary ? 2 : 8) + : reIsBadHex.test(value) + ? NAN + : +value; +} + +const lodash_throttle = throttle; + +/** + * A collection of shims that provide minimal functionality of the ES6 collections. + * + * These implementations are not meant to be used outside of the ResizeObserver + * modules as they cover only a limited range of use cases. + */ +/* eslint-disable require-jsdoc, valid-jsdoc */ +const MapShim = (() => { + if (typeof Map !== "undefined") { + return Map; + } + + /** + * Returns index in provided array that matches the specified key. + * + * @param {Array} arr + * @param {*} key + * @returns {number} + */ + function getIndex(arr, key) { + let result = -1; + + arr.some((entry, index) => { + if (entry[0] === key) { + result = index; + + return true; + } + + return false; + }); + + return result; + } + + return (() => { + function anonymous() { + this.__entries__ = []; + } + + const prototypeAccessors = { size: { configurable: true } }; + + /** + * @returns {boolean} + */ + prototypeAccessors.size.get = function () { + return this.__entries__.length; + }; + + /** + * @param {*} key + * @returns {*} + */ + anonymous.prototype.get = function (key) { + const index = getIndex(this.__entries__, key); + const entry = this.__entries__[index]; + + return entry?.[1]; + }; + + /** + * @param {*} key + * @param {*} value + * @returns {void} + */ + anonymous.prototype.set = function (key, value) { + const index = getIndex(this.__entries__, key); + + if (~index) { + this.__entries__[index][1] = value; + } else { + this.__entries__.push([key, value]); + } + }; + + /** + * @param {*} key + * @returns {void} + */ + anonymous.prototype.delete = function (key) { + const entries = this.__entries__; + const index = getIndex(entries, key); + + if (~index) { + entries.splice(index, 1); + } + }; + + /** + * @param {*} key + * @returns {void} + */ + anonymous.prototype.has = function (key) { + return !!~getIndex(this.__entries__, key); + }; + + /** + * @returns {void} + */ + anonymous.prototype.clear = function () { + this.__entries__.splice(0); + }; + + /** + * @param {Function} callback + * @param {*} [ctx=null] + * @returns {void} + */ + anonymous.prototype.forEach = function (callback, ctx) { + if (ctx === void 0) ctx = null; + + for (let i = 0, list = this.__entries__; i < list.length; i += 1) { + const entry = list[i]; + + callback.call(ctx, entry[1], entry[0]); + } + }; + + Object.defineProperties(anonymous.prototype, prototypeAccessors); + + return anonymous; + })(); +})(); + +/** + * Detects whether window and document objects are available in current environment. + */ +const isBrowser = + typeof window !== "undefined" && + typeof document !== "undefined" && + window.document === document; + +// Returns global object of a current environment. +const global$1 = (() => { + if (typeof global !== "undefined" && global.Math === Math) { + return global; + } + + if (typeof self !== "undefined" && self.Math === Math) { + return self; + } + + if (typeof window !== "undefined" && window.Math === Math) { + return window; + } + + // eslint-disable-next-line no-new-func + return Function("return this")(); +})(); + +/** + * A shim for the requestAnimationFrame which falls back to the setTimeout if + * first one is not supported. + * + * @returns {number} Requests' identifier. + */ +const requestAnimationFrame$1 = (() => { + if (typeof requestAnimationFrame === "function") { + // It's required to use a bounded function because IE sometimes throws + // an "Invalid calling object" error if rAF is invoked without the global + // object on the left hand side. + return requestAnimationFrame.bind(global$1); + } + + return (callback) => setTimeout(() => callback(Date.now()), 1000 / 60); +})(); + +// Defines minimum timeout before adding a trailing call. +const trailingTimeout = 2; + +/** + * Creates a wrapper function which ensures that provided callback will be + * invoked only once during the specified delay period. + * + * @param {Function} callback - Function to be invoked after the delay period. + * @param {number} delay - Delay after which to invoke callback. + * @returns {Function} + */ +const throttle$1 = (callback, delay) => { + let leadingCall = false, + trailingCall = false, + lastCallTime = 0; + + /** + * Invokes the original callback function and schedules new invocation if + * the "proxy" was called during current request. + * + * @returns {void} + */ + function resolvePending() { + if (leadingCall) { + leadingCall = false; + + callback(); + } + + if (trailingCall) { + proxy(); + } + } + + /** + * Callback invoked after the specified delay. It will further postpone + * invocation of the original function delegating it to the + * requestAnimationFrame. + * + * @returns {void} + */ + function timeoutCallback() { + requestAnimationFrame$1(resolvePending); + } + + /** + * Schedules invocation of the original function. + * + * @returns {void} + */ + function proxy() { + const timeStamp = Date.now(); + + if (leadingCall) { + // Reject immediately following calls. + if (timeStamp - lastCallTime < trailingTimeout) { + return; + } + + // Schedule new call to be in invoked when the pending one is resolved. + // This is important for "transitions" which never actually start + // immediately so there is a chance that we might miss one if change + // happens amids the pending invocation. + trailingCall = true; + } else { + leadingCall = true; + trailingCall = false; + + setTimeout(timeoutCallback, delay); + } + + lastCallTime = timeStamp; + } + + return proxy; +}; + +// Minimum delay before invoking the update of observers. +const REFRESH_DELAY = 20; + +// A list of substrings of CSS properties used to find transition events that +// might affect dimensions of observed elements. +const transitionKeys = [ + "top", + "right", + "bottom", + "left", + "width", + "height", + "size", + "weight", +]; + +// Check if MutationObserver is available. +const mutationObserverSupported = typeof MutationObserver !== "undefined"; + +/** + * Singleton controller class which handles updates of ResizeObserver instances. + */ +const ResizeObserverController = function () { + this.connected_ = false; + this.mutationEventsAdded_ = false; + this.mutationsObserver_ = null; + this.observers_ = []; + + this.onTransitionEnd_ = this.onTransitionEnd_.bind(this); + this.refresh = throttle$1(this.refresh.bind(this), REFRESH_DELAY); +}; + +/** + * Adds observer to observers list. + * + * @param {ResizeObserverSPI} observer - Observer to be added. + * @returns {void} + */ + +/** + * Holds reference to the controller's instance. + * + * @private {ResizeObserverController} + */ + +/** + * Keeps reference to the instance of MutationObserver. + * + * @private {MutationObserver} + */ + +/** + * Indicates whether DOM listeners have been added. + * + * @private {boolean} + */ +ResizeObserverController.prototype.addObserver = function (observer) { + if (!~this.observers_.indexOf(observer)) { + this.observers_.push(observer); + } + + // Add listeners if they haven't been added yet. + if (!this.connected_) { + this.connect_(); + } +}; + +/** + * Removes observer from observers list. + * + * @param {ResizeObserverSPI} observer - Observer to be removed. + * @returns {void} + */ +ResizeObserverController.prototype.removeObserver = function (observer) { + const observers = this.observers_; + const index = observers.indexOf(observer); + + // Remove observer if it's present in registry. + if (~index) { + observers.splice(index, 1); + } + + // Remove listeners if controller has no connected observers. + if (!observers.length && this.connected_) { + this.disconnect_(); + } +}; + +/** + * Invokes the update of observers. It will continue running updates insofar + * it detects changes. + * + * @returns {void} + */ +ResizeObserverController.prototype.refresh = function () { + const changesDetected = this.updateObservers_(); + + // Continue running updates if changes have been detected as there might + // be future ones caused by CSS transitions. + if (changesDetected) { + this.refresh(); + } +}; + +/** + * Updates every observer from observers list and notifies them of queued + * entries. + * + * @private + * @returns {boolean} Returns "true" if any observer has detected changes in + * dimensions of it's elements. + */ +ResizeObserverController.prototype.updateObservers_ = function () { + // Collect observers that have active observations. + const activeObservers = this.observers_.filter( + (observer) => (observer.gatherActive(), observer.hasActive()) + ); + + // Deliver notifications in a separate cycle in order to avoid any + // collisions between observers, e.g. when multiple instances of + // ResizeObserver are tracking the same element and the callback of one + // of them changes content dimensions of the observed target. Sometimes + // this may result in notifications being blocked for the rest of observers. + activeObservers.forEach((observer) => observer.broadcastActive()); + + return activeObservers.length > 0; +}; + +/** + * Initializes DOM listeners. + * + * @private + * @returns {void} + */ +ResizeObserverController.prototype.connect_ = function () { + // Do nothing if running in a non-browser environment or if listeners + // have been already added. + if (!isBrowser || this.connected_) { + return; + } + + // Subscription to the "Transitionend" event is used as a workaround for + // delayed transitions. This way it's possible to capture at least the + // final state of an element. + document.addEventListener("transitionend", this.onTransitionEnd_); + + window.addEventListener("resize", this.refresh); + + if (mutationObserverSupported) { + this.mutationsObserver_ = new MutationObserver(this.refresh); + + this.mutationsObserver_.observe(document, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }); + } else { + document.addEventListener("DOMSubtreeModified", this.refresh); + + this.mutationEventsAdded_ = true; + } + + this.connected_ = true; +}; + +/** + * Removes DOM listeners. + * + * @private + * @returns {void} + */ +ResizeObserverController.prototype.disconnect_ = function () { + // Do nothing if running in a non-browser environment or if listeners + // have been already removed. + if (!isBrowser || !this.connected_) { + return; + } + + document.removeEventListener("transitionend", this.onTransitionEnd_); + window.removeEventListener("resize", this.refresh); + + if (this.mutationsObserver_) { + this.mutationsObserver_.disconnect(); + } + + if (this.mutationEventsAdded_) { + document.removeEventListener("DOMSubtreeModified", this.refresh); + } + + this.mutationsObserver_ = null; + this.mutationEventsAdded_ = false; + this.connected_ = false; +}; + +/** + * "Transitionend" event handler. + * + * @private + * @param {TransitionEvent} event + * @returns {void} + */ +ResizeObserverController.prototype.onTransitionEnd_ = function (ref) { + let propertyName = ref.propertyName; + if (propertyName === void 0) propertyName = ""; + + // Detect whether transition may affect dimensions of an element. + const isReflowProperty = transitionKeys.some( + (key) => !!~propertyName.indexOf(key) + ); + + if (isReflowProperty) { + this.refresh(); + } +}; + +/** + * Returns instance of the ResizeObserverController. + * + * @returns {ResizeObserverController} + */ +ResizeObserverController.getInstance = function () { + if (!this.instance_) { + this.instance_ = new ResizeObserverController(); + } + + return this.instance_; +}; + +ResizeObserverController.instance_ = null; + +/** + * Defines non-writable/enumerable properties of the provided target object. + * + * @param {Object} target - Object for which to define properties. + * @param {Object} props - Properties to be defined. + * @returns {Object} Target object. + */ +const defineConfigurable = (target, props) => { + for (let i = 0, list = Object.keys(props); i < list.length; i += 1) { + const key = list[i]; + + Object.defineProperty(target, key, { + value: props[key], + enumerable: false, + writable: false, + configurable: true, + }); + } + + return target; +}; + +/** + * Returns the global object associated with provided element. + * + * @param {Object} target + * @returns {Object} + */ +const getWindowOf = (target) => { + // Assume that the element is an instance of Node, which means that it + // has the "ownerDocument" property from which we can retrieve a + // corresponding global object. + const ownerGlobal = target?.ownerDocument?.defaultView; + + // Return the local global object if it's not possible extract one from + // provided element. + return ownerGlobal || global$1; +}; + +// Placeholder of an empty content rectangle. +const emptyRect = createRectInit(0, 0, 0, 0); + +/** + * Converts provided string to a number. + * + * @param {number|string} value + * @returns {number} + */ +function toFloat(value) { + return parseFloat(value) || 0; +} + +/** + * Extracts borders size from provided styles. + * + * @param {CSSStyleDeclaration} styles + * @param {...string} positions - Borders positions (top, right, ...) + * @returns {number} + */ +function getBordersSize(styles) { + let positions = [], + len = arguments.length - 1; + while (len-- > 0) positions[len] = arguments[len + 1]; + + return positions.reduce((size, position) => { + const value = styles[`border-${position}-width`]; + + return size + toFloat(value); + }, 0); +} + +/** + * Extracts paddings sizes from provided styles. + * + * @param {CSSStyleDeclaration} styles + * @returns {Object} Paddings box. + */ +function getPaddings(styles) { + const positions = ["top", "right", "bottom", "left"]; + const paddings = {}; + + for (let i = 0, list = positions; i < list.length; i += 1) { + const position = list[i]; + + const value = styles[`padding-${position}`]; + + paddings[position] = toFloat(value); + } + + return paddings; +} + +/** + * Calculates content rectangle of provided SVG element. + * + * @param {SVGGraphicsElement} target - Element content rectangle of which needs + * to be calculated. + * @returns {DOMRectInit} + */ +function getSVGContentRect(target) { + const bbox = target.getBBox(); + + return createRectInit(0, 0, bbox.width, bbox.height); +} + +/** + * Calculates content rectangle of provided HTMLElement. + * + * @param {HTMLElement} target - Element for which to calculate the content rectangle. + * @returns {DOMRectInit} + */ +function getHTMLElementContentRect(target) { + // Client width & height properties can't be + // used exclusively as they provide rounded values. + const clientWidth = target.clientWidth; + const clientHeight = target.clientHeight; + + // By this condition we can catch all non-replaced inline, hidden and + // detached elements. Though elements with width & height properties less + // than 0.5 will be discarded as well. + // + // Without it we would need to implement separate methods for each of + // those cases and it's not possible to perform a precise and performance + // effective test for hidden elements. E.g. even jQuery's ':visible' filter + // gives wrong results for elements with width & height less than 0.5. + if (!clientWidth && !clientHeight) { + return emptyRect; + } + + const styles = getWindowOf(target).getComputedStyle(target); + const paddings = getPaddings(styles); + const horizPad = paddings.left + paddings.right; + const vertPad = paddings.top + paddings.bottom; + + // Computed styles of width & height are being used because they are the + // only dimensions available to JS that contain non-rounded values. It could + // be possible to utilize the getBoundingClientRect if only it's data wasn't + // affected by CSS transformations let alone paddings, borders and scroll bars. + let width = toFloat(styles.width), + height = toFloat(styles.height); + + // Width & height include paddings and borders when the 'border-box' box + // model is applied (except for IE). + if (styles.boxSizing === "border-box") { + // Following conditions are required to handle Internet Explorer which + // doesn't include paddings and borders to computed CSS dimensions. + // + // We can say that if CSS dimensions + paddings are equal to the "client" + // properties then it's either IE, and thus we don't need to subtract + // anything, or an element merely doesn't have paddings/borders styles. + if (Math.round(width + horizPad) !== clientWidth) { + width -= getBordersSize(styles, "left", "right") + horizPad; + } + + if (Math.round(height + vertPad) !== clientHeight) { + height -= getBordersSize(styles, "top", "bottom") + vertPad; + } + } + + // Following steps can't be applied to the document's root element as its + // client[Width/Height] properties represent viewport area of the window. + // Besides, it's as well not necessary as the itself neither has + // rendered scroll bars nor it can be clipped. + if (!isDocumentElement(target)) { + // In some browsers (only in Firefox, actually) CSS width & height + // include scroll bars size which can be removed at this step as scroll + // bars are the only difference between rounded dimensions + paddings + // and "client" properties, though that is not always true in Chrome. + const vertScrollbar = Math.round(width + horizPad) - clientWidth; + const horizScrollbar = Math.round(height + vertPad) - clientHeight; + + // Chrome has a rather weird rounding of "client" properties. + // E.g. for an element with content width of 314.2px it sometimes gives + // the client width of 315px and for the width of 314.7px it may give + // 314px. And it doesn't happen all the time. So just ignore this delta + // as a non-relevant. + if (Math.abs(vertScrollbar) !== 1) { + width -= vertScrollbar; + } + + if (Math.abs(horizScrollbar) !== 1) { + height -= horizScrollbar; + } + } + + return createRectInit(paddings.left, paddings.top, width, height); +} + +/** + * Checks whether provided element is an instance of the SVGGraphicsElement. + * + * @param {Element} target - Element to be checked. + * @returns {boolean} + */ +const isSVGGraphicsElement = (() => { + // Some browsers, namely IE and Edge, don't have the SVGGraphicsElement + // interface. + if (typeof SVGGraphicsElement !== "undefined") { + return (target) => target instanceof getWindowOf(target).SVGGraphicsElement; + } + + // If it's so, then check that element is at least an instance of the + // SVGElement and that it has the "getBBox" method. + // eslint-disable-next-line no-extra-parens + return (target) => + target instanceof getWindowOf(target).SVGElement && + typeof target.getBBox === "function"; +})(); + +/** + * Checks whether provided element is a document element (). + * + * @param {Element} target - Element to be checked. + * @returns {boolean} + */ +function isDocumentElement(target) { + return target === getWindowOf(target).document.documentElement; +} + +/** + * Calculates an appropriate content rectangle for provided html or svg element. + * + * @param {Element} target - Element content rectangle of which needs to be calculated. + * @returns {DOMRectInit} + */ +function getContentRect(target) { + if (!isBrowser) { + return emptyRect; + } + + if (isSVGGraphicsElement(target)) { + return getSVGContentRect(target); + } + + return getHTMLElementContentRect(target); +} + +/** + * Creates rectangle with an interface of the DOMRectReadOnly. + * Spec: https://drafts.fxtf.org/geometry/#domrectreadonly + * + * @param {DOMRectInit} rectInit - Object with rectangle's x/y coordinates and dimensions. + * @returns {DOMRectReadOnly} + */ +function createReadOnlyRect(ref) { + const x = ref.x; + const y = ref.y; + const width = ref.width; + const height = ref.height; + + // If DOMRectReadOnly is available use it as a prototype for the rectangle. + const Constr = + typeof DOMRectReadOnly !== "undefined" ? DOMRectReadOnly : Object; + const rect = Object.create(Constr.prototype); + + // Rectangle's properties are not writable and non-enumerable. + defineConfigurable(rect, { + x: x, + y: y, + width: width, + height: height, + top: y, + right: x + width, + bottom: height + y, + left: x, + }); + + return rect; +} + +/** + * Creates DOMRectInit object based on the provided dimensions and the x/y coordinates. + * Spec: https://drafts.fxtf.org/geometry/#dictdef-domrectinit + * + * @param {number} x - X coordinate. + * @param {number} y - Y coordinate. + * @param {number} width - Rectangle's width. + * @param {number} height - Rectangle's height. + * @returns {DOMRectInit} + */ +function createRectInit(x, y, width, height) { + return { x: x, y: y, width: width, height: height }; +} + +/** + * Class that is responsible for computations of the content rectangle of + * provided DOM element and for keeping track of it's changes. + */ +const ResizeObservation = function (target) { + this.broadcastWidth = 0; + this.broadcastHeight = 0; + this.contentRect_ = createRectInit(0, 0, 0, 0); + + this.target = target; +}; + +/** + * Updates content rectangle and tells whether it's width or height properties + * have changed since the last broadcast. + * + * @returns {boolean} + */ + +/** + * Reference to the last observed content rectangle. + * + * @private {DOMRectInit} + */ + +/** + * Broadcasted width of content rectangle. + * + * @type {number} + */ +ResizeObservation.prototype.isActive = function () { + const rect = getContentRect(this.target); + + this.contentRect_ = rect; + + return ( + rect.width !== this.broadcastWidth || rect.height !== this.broadcastHeight + ); +}; + +/** + * Updates 'broadcastWidth' and 'broadcastHeight' properties with a data + * from the corresponding properties of the last observed content rectangle. + * + * @returns {DOMRectInit} Last observed content rectangle. + */ +ResizeObservation.prototype.broadcastRect = function () { + const rect = this.contentRect_; + + this.broadcastWidth = rect.width; + this.broadcastHeight = rect.height; + + return rect; +}; + +const ResizeObserverEntry = function (target, rectInit) { + const contentRect = createReadOnlyRect(rectInit); + + // According to the specification following properties are not writable + // and are also not enumerable in the native implementation. + // + // Property accessors are not being used as they'd require to define a + // private WeakMap storage which may cause memory leaks in browsers that + // don't support this type of collections. + defineConfigurable(this, { target: target, contentRect: contentRect }); +}; + +const ResizeObserverSPI = function (callback, controller, callbackCtx) { + this.activeObservations_ = []; + this.observations_ = new MapShim(); + + if (typeof callback !== "function") { + throw new TypeError( + "The callback provided as parameter 1 is not a function." + ); + } + + this.callback_ = callback; + this.controller_ = controller; + this.callbackCtx_ = callbackCtx; +}; + +/** + * Starts observing provided element. + * + * @param {Element} target - Element to be observed. + * @returns {void} + */ + +/** + * Registry of the ResizeObservation instances. + * + * @private {Map} + */ + +/** + * Public ResizeObserver instance which will be passed to the callback + * function and used as a value of it's "this" binding. + * + * @private {ResizeObserver} + */ + +/** + * Collection of resize observations that have detected changes in dimensions + * of elements. + * + * @private {Array} + */ +ResizeObserverSPI.prototype.observe = function (target) { + if (!arguments.length) { + throw new TypeError("1 argument required, but only 0 present."); + } + + // Do nothing if current environment doesn't have the Element interface. + if (typeof Element === "undefined" || !(Element instanceof Object)) { + return; + } + + if (!(target instanceof getWindowOf(target).Element)) { + throw new TypeError('parameter 1 is not of type "Element".'); + } + + const observations = this.observations_; + + // Do nothing if element is already being observed. + if (observations.has(target)) { + return; + } + + observations.set(target, new ResizeObservation(target)); + + this.controller_.addObserver(this); + + // Force the update of observations. + this.controller_.refresh(); +}; + +/** + * Stops observing provided element. + * + * @param {Element} target - Element to stop observing. + * @returns {void} + */ +ResizeObserverSPI.prototype.unobserve = function (target) { + if (!arguments.length) { + throw new TypeError("1 argument required, but only 0 present."); + } + + // Do nothing if current environment doesn't have the Element interface. + if (typeof Element === "undefined" || !(Element instanceof Object)) { + return; + } + + if (!(target instanceof getWindowOf(target).Element)) { + throw new TypeError('parameter 1 is not of type "Element".'); + } + + const observations = this.observations_; + + // Do nothing if element is not being observed. + if (!observations.has(target)) { + return; + } + + observations.delete(target); + + if (!observations.size) { + this.controller_.removeObserver(this); + } +}; + +/** + * Stops observing all elements. + * + * @returns {void} + */ +ResizeObserverSPI.prototype.disconnect = function () { + this.clearActive(); + this.observations_.clear(); + this.controller_.removeObserver(this); +}; + +/** + * Collects observation instances the associated element of which has changed + * it's content rectangle. + * + * @returns {void} + */ +ResizeObserverSPI.prototype.gatherActive = function () { + this.clearActive(); + + this.observations_.forEach((observation) => { + if (observation.isActive()) { + this.activeObservations_.push(observation); + } + }); +}; + +/** + * Invokes initial callback function with a list of ResizeObserverEntry + * instances collected from active resize observations. + * + * @returns {void} + */ +ResizeObserverSPI.prototype.broadcastActive = function () { + // Do nothing if observer doesn't have active observations. + if (!this.hasActive()) { + return; + } + + const ctx = this.callbackCtx_; + + // Create ResizeObserverEntry instance for every active observation. + const entries = this.activeObservations_.map( + (observation) => + new ResizeObserverEntry(observation.target, observation.broadcastRect()) + ); + + this.callback_.call(ctx, entries, ctx); + this.clearActive(); +}; + +/** + * Clears the collection of active observations. + * + * @returns {void} + */ +ResizeObserverSPI.prototype.clearActive = function () { + this.activeObservations_.splice(0); +}; + +/** + * Tells whether observer has active observations. + * + * @returns {boolean} + */ +ResizeObserverSPI.prototype.hasActive = function () { + return this.activeObservations_.length > 0; +}; + +// Registry of internal observers. If WeakMap is not available use current shim +// for the Map collection as it has all required methods and because WeakMap +// can't be fully polyfilled anyway. +const observers = + typeof WeakMap !== "undefined" ? new WeakMap() : new MapShim(); + +/** + * ResizeObserver API. Encapsulates the ResizeObserver SPI implementation + * exposing only those methods and properties that are defined in the spec. + */ +const ResizeObserver = function (callback) { + if (!(this instanceof ResizeObserver)) { + throw new TypeError("Cannot call a class as a function."); + } + if (!arguments.length) { + throw new TypeError("1 argument required, but only 0 present."); + } + + const controller = ResizeObserverController.getInstance(); + const observer = new ResizeObserverSPI(callback, controller, this); + + observers.set(this, observer); +}; + +// Expose public methods of ResizeObserver. +["observe", "unobserve", "disconnect"].forEach((method) => { + ResizeObserver.prototype[method] = function () { + return (ref = observers.get(this))[method].apply(ref, arguments); + let ref; + }; +}); + +const index = (() => { + // Export existing implementation if available. + if (typeof global$1.ResizeObserver !== "undefined") { + return global$1.ResizeObserver; + } + + return ResizeObserver; +})(); + +const canUseDOM = !!( + typeof window !== "undefined" && + window.document && + window.document.createElement +); + +const canUseDom = canUseDOM; + +const SimpleBar = + /*#__PURE__*/ + (() => { + function SimpleBar(element, options) { + _classCallCheck(this, SimpleBar); + + this.onScrollX = () => { + if (!this.scrollXTicking) { + window.requestAnimationFrame(this.scrollX); + this.scrollXTicking = true; + } + }; + + this.onScrollY = () => { + if (!this.scrollYTicking) { + window.requestAnimationFrame(this.scrollY); + this.scrollYTicking = true; + } + }; + + this.scrollX = () => { + this.showScrollbar("x"); + + this.positionScrollbar("x"); + + this.scrollXTicking = false; + }; + + this.scrollY = () => { + this.showScrollbar("y"); + + this.positionScrollbar("y"); + + this.scrollYTicking = false; + }; + + this.onMouseEnter = () => { + this.showScrollbar("x"); + + this.showScrollbar("y"); + }; + + this.onMouseMove = (e) => { + const bboxY = this.trackY.getBoundingClientRect(); + + const bboxX = this.trackX.getBoundingClientRect(); + + this.mouseX = e.clientX; + this.mouseY = e.clientY; + + if (this.isWithinBounds(bboxY)) { + this.showScrollbar("y"); + } + + if (this.isWithinBounds(bboxX)) { + this.showScrollbar("x"); + } + }; + + this.onWindowResize = () => { + this.hideNativeScrollbar(); + }; + + this.hideScrollbars = () => { + const bboxY = this.trackY.getBoundingClientRect(); + + const bboxX = this.trackX.getBoundingClientRect(); + + if (!this.isWithinBounds(bboxY)) { + this.scrollbarY.classList.remove("visible"); + + this.isVisible.y = false; + } + + if (!this.isWithinBounds(bboxX)) { + this.scrollbarX.classList.remove("visible"); + + this.isVisible.x = false; + } + }; + + this.onMouseDown = (e) => { + const bboxY = this.scrollbarY.getBoundingClientRect(); + + const bboxX = this.scrollbarX.getBoundingClientRect(); + + if (this.isWithinBounds(bboxY)) { + e.preventDefault(); + + this.onDrag(e, "y"); + } + + if (this.isWithinBounds(bboxX)) { + e.preventDefault(); + + this.onDrag(e, "x"); + } + }; + + this.drag = (e) => { + let eventOffset, track, scrollEl; + e.preventDefault(); + + if (this.currentAxis === "y") { + eventOffset = e.pageY; + track = this.trackY; + scrollEl = this.scrollContentEl; + } else { + eventOffset = e.pageX; + track = this.trackX; + scrollEl = this.contentEl; + } // Calculate how far the user's mouse is from the top/left of the scrollbar (minus the dragOffset). + + const dragPos = + eventOffset - + track.getBoundingClientRect()[this.offsetAttr[this.currentAxis]] - + this.dragOffset[this.currentAxis]; // Convert the mouse position into a percentage of the scrollbar height/width. + + const dragPerc = dragPos / track[this.sizeAttr[this.currentAxis]]; // Scroll the content by the same percentage. + + const scrollPos = + dragPerc * this.contentEl[this.scrollSizeAttr[this.currentAxis]]; + scrollEl[this.scrollOffsetAttr[this.currentAxis]] = scrollPos; + }; + + this.onEndDrag = () => { + document.removeEventListener("mousemove", this.drag); + document.removeEventListener("mouseup", this.onEndDrag); + }; + + this.el = element; + this.flashTimeout; + this.contentEl; + this.scrollContentEl; + this.dragOffset = { + x: 0, + y: 0, + }; + this.isEnabled = { + x: true, + y: true, + }; + this.isVisible = { + x: false, + y: false, + }; + this.scrollOffsetAttr = { + x: "scrollLeft", + y: "scrollTop", + }; + this.sizeAttr = { + x: "offsetWidth", + y: "offsetHeight", + }; + this.scrollSizeAttr = { + x: "scrollWidth", + y: "scrollHeight", + }; + this.offsetAttr = { + x: "left", + y: "top", + }; + this.handleSize = { + x: 0, + y: 0, + }; + this.globalObserver; + this.mutationObserver; + this.resizeObserver; + this.currentAxis; + this.scrollbarWidth; + this.options = Object.assign({}, SimpleBar.defaultOptions, options); + this.isRtl = this.options.direction === "rtl"; + this.classNames = this.options.classNames; + this.offsetSize = 20; + this.recalculateImmediate = this.recalculate.bind(this); + this.recalculate = lodash_throttle(this.recalculate.bind(this), 1000); + this.onMouseMove = lodash_throttle(this.onMouseMove.bind(this), 100); + this.init(); + } + + _createClass( + SimpleBar, + [ + { + key: "init", + value: function init() { + // Save a reference to the instance, so we know this DOM node has already been instancied + this.el.SimpleBar = this; + this.initDOM(); // We stop here on server-side + + if (canUseDom) { + // Calculate content size + this.hideNativeScrollbar(); + this.render(); + this.initListeners(); + } + }, + }, + { + key: "initDOM", + value: function initDOM() { + // make sure this element doesn't have the elements yet + if ( + Array.from(this.el.children).filter((child) => + child.classList.contains(this.classNames.scrollContent) + ).length + ) { + // assume that element has his DOM already initiated + this.trackX = this.el.querySelector( + ".".concat(this.classNames.track, ".horizontal") + ); + this.trackY = this.el.querySelector( + ".".concat(this.classNames.track, ".vertical") + ); + this.scrollContentEl = this.el.querySelector( + ".".concat(this.classNames.scrollContent) + ); + this.contentEl = this.el.querySelector( + ".".concat(this.classNames.content) + ); + } else { + // Prepare DOM + this.scrollContentEl = document.createElement("div"); + this.contentEl = document.createElement("div"); + this.scrollContentEl.classList.add(this.classNames.scrollContent); + this.contentEl.classList.add(this.classNames.content); + + while (this.el.firstChild) { + this.contentEl.appendChild(this.el.firstChild); + } + + this.scrollContentEl.appendChild(this.contentEl); + this.el.appendChild(this.scrollContentEl); + } + + if (!this.trackX || !this.trackY) { + const track = document.createElement("div"); + const scrollbar = document.createElement("div"); + track.classList.add(this.classNames.track); + scrollbar.classList.add(this.classNames.scrollbar); + + if (!this.options.autoHide) { + scrollbar.classList.add("visible"); + } + + track.appendChild(scrollbar); + this.trackX = track.cloneNode(true); + this.trackX.classList.add("horizontal"); + this.trackY = track.cloneNode(true); + this.trackY.classList.add("vertical"); + this.el.insertBefore(this.trackX, this.el.firstChild); + this.el.insertBefore(this.trackY, this.el.firstChild); + } + + this.scrollbarX = this.trackX.querySelector( + ".".concat(this.classNames.scrollbar) + ); + this.scrollbarY = this.trackY.querySelector( + ".".concat(this.classNames.scrollbar) + ); + this.el.setAttribute("data-simplebar", "init"); + }, + }, + { + key: "initListeners", + value: function initListeners() { + // Event listeners + if (this.options.autoHide) { + this.el.addEventListener("mouseenter", this.onMouseEnter); + } + + this.el.addEventListener("mousedown", this.onMouseDown); + this.el.addEventListener("mousemove", this.onMouseMove); + this.contentEl.addEventListener("scroll", this.onScrollX); + this.scrollContentEl.addEventListener("scroll", this.onScrollY); // Browser zoom triggers a window resize + + window.addEventListener("resize", this.onWindowResize); // MutationObserver is IE11+ + + if (typeof MutationObserver !== "undefined") { + // create an observer instance + this.mutationObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + this.isChildNode(mutation.target) || + mutation.addedNodes.length + ) { + this.recalculate(); + } + }); + }); // pass in the target node, as well as the observer options + + this.mutationObserver.observe(this.el, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }); + } + + this.resizeObserver = new index(this.recalculate); + this.resizeObserver.observe(this.el); + }, + /** + * Recalculate scrollbar + */ + }, + { + key: "recalculate", + value: function recalculate() { + this.render(); + }, + }, + { + key: "render", + value: function render() { + this.contentSizeX = this.contentEl[this.scrollSizeAttr.x]; + this.contentSizeY = + this.contentEl[this.scrollSizeAttr.y] - + (this.scrollbarWidth || this.offsetSize); + this.trackXSize = this.trackX[this.sizeAttr.x]; + this.trackYSize = this.trackY[this.sizeAttr.y]; // Set isEnabled to false if scrollbar is not necessary (content is shorter than wrapper) + + this.isEnabled.x = this.trackXSize < this.contentSizeX; + this.isEnabled.y = this.trackYSize < this.contentSizeY; + this.resizeScrollbar("x"); + this.resizeScrollbar("y"); + this.positionScrollbar("x"); + this.positionScrollbar("y"); + this.toggleTrackVisibility("x"); + this.toggleTrackVisibility("y"); + }, + /** + * Resize scrollbar + */ + }, + { + key: "resizeScrollbar", + value: function resizeScrollbar() { + const axis = + arguments.length > 0 && arguments[0] !== undefined + ? arguments[0] + : "y"; + let scrollbar; + let contentSize; + let trackSize; + + if (!this.isEnabled[axis] && !this.options.forceVisible) { + return; + } + + if (axis === "x") { + scrollbar = this.scrollbarX; + contentSize = this.contentSizeX; + trackSize = this.trackXSize; + } else { + // 'y' + scrollbar = this.scrollbarY; + contentSize = this.contentSizeY; + trackSize = this.trackYSize; + } + + const scrollbarRatio = trackSize / contentSize; // Calculate new height/position of drag handle. + + this.handleSize[axis] = Math.max( + ~~(scrollbarRatio * trackSize), + this.options.scrollbarMinSize + ); + + if (this.options.scrollbarMaxSize) { + this.handleSize[axis] = Math.min( + this.handleSize[axis], + this.options.scrollbarMaxSize + ); + } + + if (axis === "x") { + scrollbar.style.width = "".concat(this.handleSize[axis], "px"); + } else { + scrollbar.style.height = "".concat(this.handleSize[axis], "px"); + } + }, + }, + { + key: "positionScrollbar", + value: function positionScrollbar() { + const axis = + arguments.length > 0 && arguments[0] !== undefined + ? arguments[0] + : "y"; + let scrollbar; + let scrollOffset; + let contentSize; + let trackSize; + + if (axis === "x") { + scrollbar = this.scrollbarX; + scrollOffset = this.contentEl[this.scrollOffsetAttr[axis]]; // Either scrollTop() or scrollLeft(). + + contentSize = this.contentSizeX; + trackSize = this.trackXSize; + } else { + // 'y' + scrollbar = this.scrollbarY; + scrollOffset = this.scrollContentEl[this.scrollOffsetAttr[axis]]; // Either scrollTop() or scrollLeft(). + + contentSize = this.contentSizeY; + trackSize = this.trackYSize; + } + + const scrollPourcent = scrollOffset / (contentSize - trackSize); + const handleOffset = ~~( + (trackSize - this.handleSize[axis]) * + scrollPourcent + ); + + if (this.isEnabled[axis] || this.options.forceVisible) { + if (axis === "x") { + scrollbar.style.transform = "translate3d(".concat( + handleOffset, + "px, 0, 0)" + ); + } else { + scrollbar.style.transform = "translate3d(0, ".concat( + handleOffset, + "px, 0)" + ); + } + } + }, + }, + { + key: "toggleTrackVisibility", + value: function toggleTrackVisibility() { + const axis = + arguments.length > 0 && arguments[0] !== undefined + ? arguments[0] + : "y"; + const track = axis === "y" ? this.trackY : this.trackX; + const scrollbar = axis === "y" ? this.scrollbarY : this.scrollbarX; + + if (this.isEnabled[axis] || this.options.forceVisible) { + track.style.visibility = "visible"; + } else { + track.style.visibility = "hidden"; + } // Even if forceVisible is enabled, scrollbar itself should be hidden + + if (this.options.forceVisible) { + if (this.isEnabled[axis]) { + scrollbar.style.visibility = "visible"; + } else { + scrollbar.style.visibility = "hidden"; + } + } + }, + }, + { + key: "hideNativeScrollbar", + value: function hideNativeScrollbar() { + // Recalculate scrollbarWidth in case it's a zoom + const offset = 20; // matched in style.css: [data-simplebar="init"] {...} + this.scrollbarWidth = scrollbarWidth(); + this.scrollContentEl.style[ + this.isRtl ? "paddingLeft" : "paddingRight" + ] = "".concat( + (this.scrollbarWidth || this.offsetSize) + offset, + "px" + ); + this.scrollContentEl.style.marginBottom = "-".concat( + this.scrollbarWidth * 2 || this.offsetSize, + "px" + ); + this.contentEl.style.paddingBottom = "".concat( + this.scrollbarWidth || this.offsetSize, + "px" + ); + + if (this.scrollbarWidth !== 0) { + this.contentEl.style[this.isRtl ? "marginLeft" : "marginRight"] = + "-".concat(offset, "px"); + } + }, + /** + * On scroll event handling + */ + }, + { + key: "showScrollbar", + + /** + * Show scrollbar + */ + value: function showScrollbar() { + const axis = + arguments.length > 0 && arguments[0] !== undefined + ? arguments[0] + : "y"; + let scrollbar; // Scrollbar already visible + + if (this.isVisible[axis]) { + return; + } + + if (axis === "x") { + scrollbar = this.scrollbarX; + } else { + // 'y' + scrollbar = this.scrollbarY; + } + + if (this.isEnabled[axis]) { + scrollbar.classList.add("visible"); + this.isVisible[axis] = true; + } + + if (!this.options.autoHide) { + return; + } + + window.clearInterval(this.flashTimeout); + this.flashTimeout = window.setInterval( + this.hideScrollbars, + this.options.timeout + ); + }, + /** + * Hide Scrollbar + */ + }, + { + key: "onDrag", + + /** + * on scrollbar handle drag + */ + value: function onDrag(e) { + const axis = + arguments.length > 1 && arguments[1] !== undefined + ? arguments[1] + : "y"; + // Preventing the event's default action stops text being + // selectable during the drag. + e.preventDefault(); + const scrollbar = axis === "y" ? this.scrollbarY : this.scrollbarX; // Measure how far the user's mouse is from the top of the scrollbar drag handle. + + const eventOffset = axis === "y" ? e.pageY : e.pageX; + this.dragOffset[axis] = + eventOffset - + scrollbar.getBoundingClientRect()[this.offsetAttr[axis]]; + this.currentAxis = axis; + document.addEventListener("mousemove", this.drag); + document.addEventListener("mouseup", this.onEndDrag); + }, + /** + * Drag scrollbar handle + */ + }, + { + key: "getScrollElement", + + /** + * Getter for original scrolling element + */ + value: function getScrollElement() { + const axis = + arguments.length > 0 && arguments[0] !== undefined + ? arguments[0] + : "y"; + return axis === "y" ? this.scrollContentEl : this.contentEl; + }, + /** + * Getter for content element + */ + }, + { + key: "getContentElement", + value: function getContentElement() { + return this.contentEl; + }, + }, + { + key: "removeListeners", + value: function removeListeners() { + // Event listeners + if (this.options.autoHide) { + this.el.removeEventListener("mouseenter", this.onMouseEnter); + } + + this.scrollContentEl.removeEventListener("scroll", this.onScrollY); + this.contentEl.removeEventListener("scroll", this.onScrollX); + this.mutationObserver.disconnect(); + this.resizeObserver.disconnect(); + }, + /** + * UnMount mutation observer and delete SimpleBar instance from DOM element + */ + }, + { + key: "unMount", + value: function unMount() { + this.removeListeners(); + this.el.SimpleBar = null; + }, + /** + * Recursively walks up the parent nodes looking for this.el + */ + }, + { + key: "isChildNode", + value: function isChildNode(el) { + if (el === null) return false; + if (el === this.el) return true; + return this.isChildNode(el.parentNode); + }, + /** + * Check if mouse is within bounds + */ + }, + { + key: "isWithinBounds", + value: function isWithinBounds(bbox) { + return ( + this.mouseX >= bbox.left && + this.mouseX <= bbox.left + bbox.width && + this.mouseY >= bbox.top && + this.mouseY <= bbox.top + bbox.height + ); + }, + }, + ], + [ + { + key: "initHtmlApi", + value: function initHtmlApi() { + this.initDOMLoadedElements = this.initDOMLoadedElements.bind(this); // MutationObserver is IE11+ + + if (typeof MutationObserver !== "undefined") { + // Mutation observer to observe dynamically added elements + this.globalObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + Array.from(mutation.addedNodes).forEach((addedNode) => { + if (addedNode.nodeType === 1) { + if (addedNode.hasAttribute("data-simplebar")) { + !addedNode.SimpleBar && + new SimpleBar( + addedNode, + SimpleBar.getElOptions(addedNode) + ); + } else { + Array.from( + addedNode.querySelectorAll("[data-simplebar]") + ).forEach((el) => { + !el.SimpleBar && + new SimpleBar(el, SimpleBar.getElOptions(el)); + }); + } + } + }); + Array.from(mutation.removedNodes).forEach((removedNode) => { + if (removedNode.nodeType === 1) { + if (removedNode.hasAttribute("data-simplebar")) { + removedNode.SimpleBar?.unMount(); + } else { + Array.from( + removedNode.querySelectorAll("[data-simplebar]") + ).forEach((el) => { + el.SimpleBar?.unMount(); + }); + } + } + }); + }); + }); + this.globalObserver.observe(document, { + childList: true, + subtree: true, + }); + } // Taken from jQuery `ready` function + // Instantiate elements already present on the page + + if ( + document.readyState === "complete" || + (document.readyState !== "loading" && + !document.documentElement.doScroll) + ) { + // Handle it asynchronously to allow scripts the opportunity to delay init + window.setTimeout(this.initDOMLoadedElements); + } else { + document.addEventListener( + "DOMContentLoaded", + this.initDOMLoadedElements + ); + window.addEventListener("load", this.initDOMLoadedElements); + } + }, // Helper function to retrieve options from element attributes + }, + { + key: "getElOptions", + value: function getElOptions(el) { + const options = Array.from(el.attributes).reduce( + (acc, attribute) => { + const option = attribute.name.match(/data-simplebar-(.+)/); + + if (option) { + const key = option[1].replace(/\W+(.)/g, (_x, chr) => + chr.toUpperCase() + ); + + switch (attribute.value) { + case "true": + acc[key] = true; + break; + + case "false": + acc[key] = false; + break; + + case undefined: + acc[key] = true; + break; + + default: + acc[key] = attribute.value; + } + } + + return acc; + }, + {} + ); + return options; + }, + }, + { + key: "removeObserver", + value: function removeObserver() { + this.globalObserver.disconnect(); + }, + }, + { + key: "initDOMLoadedElements", + value: function initDOMLoadedElements() { + document.removeEventListener( + "DOMContentLoaded", + this.initDOMLoadedElements + ); + window.removeEventListener("load", this.initDOMLoadedElements); + Array.from(document.querySelectorAll("[data-simplebar]")).forEach( + (el) => { + if (!el.SimpleBar) + new SimpleBar(el, SimpleBar.getElOptions(el)); + } + ); + }, + }, + { + key: "defaultOptions", + get: function get() { + return { + autoHide: true, + forceVisible: false, + classNames: { + content: "simplebar-content", + scrollContent: "simplebar-scroll-content", + scrollbar: "simplebar-scrollbar", + track: "simplebar-track", + }, + scrollbarMinSize: 25, + scrollbarMaxSize: 0, + direction: "ltr", + timeout: 1000, + }; + }, + }, + ] + ); + + return SimpleBar; + })(); + +if (canUseDom) { + SimpleBar.initHtmlApi(); +} + +export default SimpleBar; diff --git a/crawl-ref/source/webserver/client/game/src/debug.js b/crawl-ref/source/webserver/client/game/src/debug.js new file mode 100644 index 00000000000..58182bf471b --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/debug.js @@ -0,0 +1,52 @@ +import $ from "jquery"; + +import c from "./client"; +import r from "./dungeon_renderer"; +import mm from "./minimap"; +import ml from "./monster_list"; +import mk from "./map_knowledge"; +import display from "./display"; + +const exports = {}; + +exports.client = c; +exports.renderer = r; +exports.$ = $; +exports.minimap = mm; +exports.monster_list = ml; +exports.map_knowledge = mk; +exports.display = display; + +window.debug = exports; + +// Debug helper +exports.mark_cell = (x, y, mark) => { + mark = mark || "m"; + + if (mk.get(x, y).t) mk.get(x, y).t.mark = mark; + + r.render_loc(x, y); +}; +exports.unmark_cell = (x, y) => { + const cell = mk.get(x, y); + if (cell) { + delete cell.t.mark; + } + + r.render_loc(x, y); +}; +exports.mark_all = () => { + const view = r.view; + for (let x = 0; x < r.cols; x++) + for (let y = 0; y < r.rows; y++) + mark_cell(view.x + x, view.y + y, `${view.x + x}/${view.y + y}`); +}; +exports.unmark_all = () => { + const view = r.view; + for (let x = 0; x < r.cols; x++) + for (let y = 0; y < r.rows; y++) unmark_cell(view.x + x, view.y + y); +}; + +exports.obj_to_str = (o) => $.toJSON(o); + +export default exports; diff --git a/crawl-ref/source/webserver/client/game/src/display.js b/crawl-ref/source/webserver/client/game/src/display.js new file mode 100644 index 00000000000..247cbc76f97 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/display.js @@ -0,0 +1,102 @@ +import $ from "jquery"; + +import comm from "./comm"; + +import map_knowledge from "./map_knowledge"; +import monster_list from "./monster_list"; +import minimap from "./minimap"; +import dungeon_renderer from "./dungeon_renderer"; + +function invalidate(minimap_too) { + const b = map_knowledge.bounds(); + if (!b) return; + + const view = dungeon_renderer.view; + + const xs = minimap_too ? b.left : view.x; + const ys = minimap_too ? b.top : view.y; + const xe = minimap_too ? b.right : view.x + dungeon_renderer.cols - 1; + const ye = minimap_too ? b.bottom : view.y + dungeon_renderer.rows - 1; + for (let x = xs; x <= xe; x++) + for (let y = ys; y <= ye; y++) { + map_knowledge.touch(x, y); + } +} + +function display() { + // Update the display. + if (!map_knowledge.bounds()) return; + + const t1 = new Date(); + + if (map_knowledge.reset_bounds_changed()) minimap.center(); + + const dirty_locs = map_knowledge.dirty(); + for (let i = 0; i < dirty_locs.length; i++) { + const loc = dirty_locs[i]; + const cell = map_knowledge.get(loc.x, loc.y); + cell.dirty = false; + monster_list.update_loc(loc); + dungeon_renderer.render_loc(loc.x, loc.y, cell); + minimap.update(loc.x, loc.y, cell); + } + map_knowledge.reset_dirty(); + + dungeon_renderer.animate(); + + monster_list.update(); + + const render_time = Date.now() - t1; + if (!window.render_times) window.render_times = []; + if (window.render_times.length >= 20) window.render_times.shift(); + window.render_times.push(render_time); +} + +function clear_map() { + map_knowledge.clear(); + + dungeon_renderer.clear(); + + minimap.clear(); + + monster_list.clear(); +} + +// Message handlers +function handle_map_message(data) { + if (data.clear) clear_map(); + + if (data.player_on_level != null) + map_knowledge.set_player_on_level(data.player_on_level); + + if (data.vgrdc) minimap.do_view_center_update(data.vgrdc.x, data.vgrdc.y); + + if (data.cells) map_knowledge.merge(data.cells); + + // Mark cells overlapped by dirty cells as dirty + $.each(map_knowledge.dirty().slice(), (_i, loc) => { + const cell = map_knowledge.get(loc.x, loc.y); + // high cell + if (cell.t?.sy && cell.t.sy < 0) map_knowledge.touch(loc.x, loc.y - 1); + // left overlap + if (cell.t?.left_overlap && cell.t.left_overlap < 0) { + map_knowledge.touch(loc.x - 1, loc.y); + // If we overlap at both top *and* left, we may additionally + // overlap diagonally. + if (cell.t.sy && cell.t.sy < 0) map_knowledge.touch(loc.x - 1, loc.y - 1); + } + }); + + display(); +} + +function _handle_vgrdc(_data) {} + +comm.register_handlers({ + map: handle_map_message, +}); + +export default { + invalidate: invalidate, + display: display, +}; diff --git a/crawl-ref/source/webserver/client/game/src/dungeon_renderer.js b/crawl-ref/source/webserver/client/game/src/dungeon_renderer.js new file mode 100644 index 00000000000..d86d759a6b0 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/dungeon_renderer.js @@ -0,0 +1,441 @@ +import $ from "jquery"; + +import comm from "./comm"; + +import cr from "./cell_renderer"; +import map_knowledge from "./map_knowledge"; +import options from "./options"; +import dngn from "../../../game_data/static/tileinfo-dngn"; +import util from "./util"; +import view_data from "./view_data"; +import enums from "./enums"; + +let global_anim_counter = 0; + +function is_torch(basetile) { + return ( + basetile === dngn.WALL_BRICK_DARK_2_TORCH || + basetile === dngn.WALL_BRICK_DARK_4_TORCH || + basetile === dngn.WALL_BRICK_DARK_6_TORCH + ); +} + +function cell_is_animated(cell) { + if (cell == null || cell.bg == null) return false; + const base_bg = dngn.basetile(cell.bg.value); + if (base_bg >= dngn.DNGN_LAVA && base_bg < dngn.FLOOR_MAX) + return options.get("tile_water_anim"); + else if ( + (base_bg >= dngn.DNGN_ENTER_ZOT_CLOSED && + base_bg < dngn.DNGN_CACHE_OF_FRUIT) || + (base_bg >= dngn.DNGN_SILVER_STATUE && base_bg < dngn.ARCANE_CONDUIT) || + (base_bg >= dngn.ARCANE_CONDUIT && base_bg < dngn.STORM_CONDUIT) || + (base_bg >= dngn.WALL_STONE_CRACKLE_1 && + base_bg <= dngn.WALL_STONE_CRACKLE_4) || + is_torch(base_bg) || + base_bg === dngn.DNGN_TRAP_HARLEQUIN + ) { + return options.get("tile_misc_anim"); + } else return false; +} + +function animate_cell(cell) { + const base_bg = dngn.basetile(cell.bg.value); + if ( + base_bg === dngn.DNGN_PORTAL_WIZARD_LAB || + base_bg === dngn.DNGN_EXIT_NECROPOLIS || + base_bg === dngn.DNGN_ALTAR_JIYVA || + base_bg === dngn.DNGN_TRAP_HARLEQUIN || + (base_bg >= dngn.ARCANE_CONDUIT && base_bg < dngn.STORM_CONDUIT) || + is_torch(base_bg) + ) { + cell.bg.value = + base_bg + ((cell.bg.value - base_bg + 1) % dngn.tile_count(base_bg)); + } else if ( + (base_bg > dngn.DNGN_LAVA && base_bg < dngn.BLOOD) || + (base_bg >= dngn.WALL_STONE_CRACKLE_1 && + base_bg <= dngn.WALL_STONE_CRACKLE_4) || + (base_bg >= dngn.DNGN_SILVER_STATUE && base_bg < dngn.ARCANE_CONDUIT) + ) { + cell.bg.value = + base_bg + Math.floor(Math.random() * dngn.tile_count(base_bg)); + } else if (base_bg === dngn.DNGN_LAVA) { + const tile = (cell.bg.value - base_bg) % 4; + cell.bg.value = + base_bg + ((tile + 4 * global_anim_counter) % dngn.tile_count(base_bg)); + } +} + +function DungeonViewRenderer() { + cr.DungeonCellRenderer.call(this); + + this.cols = 0; + this.rows = 0; + + this.view = { x: 0, y: 0 }; + this.view_center = { x: 0, y: 0 }; + this.ui_state = -1; + this.last_sent_cursor = { x: 0, y: 0 }; +} + +DungeonViewRenderer.prototype = new cr.DungeonCellRenderer(); + +$.extend(DungeonViewRenderer.prototype, { + init: function (element) { + $(element) + .off("update_cells mousemove mouseleave mousedown") + .on("update_cells", (_ev, cells) => { + $.each(cells, (_i, loc) => { + this.render_loc(loc.x, loc.y); + }); + }) + .on("mousemove mouseleave mousedown", (ev) => { + this.handle_mouse(ev); + }); + + cr.DungeonCellRenderer.prototype.init.call(this, element); + }, + + handle_mouse: function (ev) { + if (!options.get("tile_web_mouse_control")) return; + if (ev.type === "mouseleave") { + if (this.tooltip_timeout) { + clearTimeout(this.tooltip_timeout); + this.tooltip_timeout = null; + } + + view_data.remove_cursor(enums.CURSOR_MOUSE); + } else { + // convert logical mouse coordinates to device coordinates + const scaled = this.scaled_size(ev.clientX, ev.clientY); + const cell_size = this.scaled_size(); + const loc = { + x: Math.floor(scaled.width / cell_size.width) + this.view.x, + y: Math.floor(scaled.height / cell_size.height) + this.view.y, + }; + + view_data.place_cursor(enums.CURSOR_MOUSE, loc); + + if (game.can_target()) { + // XX refactor into mouse_control.js? + if ( + loc.x !== this.last_sent_cursor.x || + loc.y !== this.last_sent_cursor.y + ) { + this.last_sent_cursor = { x: loc.x, y: loc.y }; + comm.send_message("target_cursor", this.last_sent_cursor); + } + } + + if (ev.type === "mousemove") { + if (this.tooltip_timeout) clearTimeout(this.tooltip_timeout); + + const element = this.element; + this.tooltip_timeout = setTimeout(() => { + const new_ev = $.extend({}, ev, { + type: "cell_tooltip", + cell: loc, + }); + $(element).trigger(new_ev); + }, 500); + } else if (ev.type === "mousedown") { + const new_ev = $.extend({}, ev, { + type: "cell_click", + cell: loc, + }); + $(this.element).trigger(new_ev); + } + } + }, + + set_size: function (c, r) { + if ( + this.cols === c && + this.rows === r && + this.element.width === c * this.cell_width && + this.element.height === r * this.cell_height + ) + return; + + this.cols = c; + this.rows = r; + this.view.x = this.view_center.x - Math.floor(c / 2); + this.view.y = this.view_center.y - Math.floor(r / 2); + util.init_canvas(this.element, c * this.cell_width, r * this.cell_height); + this.init(this.element); + this.clear(); // clear the canvas and override default alpha fill + }, + + set_view_center: function (x, y) { + this.view_center.x = x; + this.view_center.y = y; + const old_view = this.view; + this.view = { + x: x - Math.floor(this.cols / 2), + y: y - Math.floor(this.rows / 2), + }; + this.shift(this.view.x - old_view.x, this.view.y - old_view.y); + }, + + // convert grid coordinates/dimensions to scaled canvas coordinates + // by default, use map grid coordinates; set screen_grid to instead + // use the visible grid (with 0,0 in the upper left of the screen) + canvas_coords: function (cx, cy, cw, ch, screen_grid) { + const scaled_cell = this.scaled_size(); + cw = cw || 1; + ch = ch || 1; + cx = cx || 0; + cy = cy || 0; + if (!screen_grid) { + cx -= this.view.x; + cy -= this.view.y; + } + return { + x: cx * scaled_cell.width, + y: cy * scaled_cell.height, + width: cw * scaled_cell.width, + height: ch * scaled_cell.height, + }; + }, + + // Shifts the dungeon view by cx/cy cells. + shift: function (x, y) { + if (x === 0 && y === 0) return; + + if (x > this.cols) x = this.cols; + if (x < -this.cols) x = -this.cols; + if (y > this.rows) y = this.rows; + if (y < -this.rows) y = -this.rows; + + let sx, sy, dx, dy; + + if (x > 0) { + sx = x; + dx = 0; + } else { + sx = 0; + dx = -x; + } + if (y > 0) { + sy = y; + dy = 0; + } else { + sy = 0; + dy = -y; + } + + const cw = this.cols - Math.abs(x); + const ch = this.rows - Math.abs(y); + + const source = this.canvas_coords(sx, sy, cw, ch, true); + + if (source.width > 0 && source.height > 0) { + // copy from source to dest + // written a bit over-generally: don't really need to + // recalculate destination size, unless this function is + // ever used for rescaling + const dest = this.canvas_coords(dx, dy, cw, ch, true); + this.ctx.drawImage( + this.element, + source.x, + source.y, + source.width, + source.height, + dest.x, + dest.y, + dest.width, + dest.height + ); + } + + // Render cells that came into view + for (let cy = 0; cy < dy; cy++) + for (let cx = 0; cx < this.cols; cx++) + this.render_grid_cell(cx + this.view.x, cy + this.view.y); + + for (let cy = dy; cy < this.rows - sy; cy++) { + for (let cx = 0; cx < dx; cx++) + this.render_grid_cell(cx + this.view.x, cy + this.view.y); + for (let cx = this.cols - sx; cx < this.cols; cx++) + this.render_grid_cell(cx + this.view.x, cy + this.view.y); + } + + for (let cy = this.rows - sy; cy < this.rows; cy++) + for (let cx = 0; cx < this.cols; cx++) + this.render_grid_cell(cx + this.view.x, cy + this.view.y); + }, + + fit_to: function (width, height, min_diameter) { + let scale; + if (this.ui_state === enums.ui.VIEW_MAP) + scale = options.get("tile_map_scale"); + else scale = options.get("tile_viewport_scale"); + const tile_size = Math.floor( + (options.get("tile_cell_pixels") * scale) / 100 + ); + const cell_size = { + w: Math.floor(tile_size), + h: Math.floor(tile_size), + }; + + if (options.get("tile_display_mode") === "glyphs") { + // TODO: this should rescale to ensure los, similar to tiles + this.glyph_mode_update_font_metrics(); + this.set_cell_size( + this.glyph_mode_font_width, + this.glyph_mode_line_height + ); + } else if ( + min_diameter * cell_size.w > width || + min_diameter * cell_size.h > height + ) { + // TODO: this produces bad results in hybrid mode, because the + // font size isn't appropriately updated + // scale down if necessary, so that los is in view + const rescale = Math.min( + width / (min_diameter * cell_size.w), + height / (min_diameter * cell_size.h) + ); + this.set_cell_size( + Math.floor(cell_size.w * rescale), + Math.floor(cell_size.h * rescale) + ); + } else this.set_cell_size(cell_size.w, cell_size.h); + + const view_width = Math.floor(width / this.cell_width); + const view_height = Math.floor(height / this.cell_height); + this.set_size(view_width, view_height); + }, + + in_view: function (cx, cy) { + return ( + cx >= this.view.x && + this.view.x + this.cols > cx && + cy >= this.view.y && + this.view.y + this.rows > cy + ); + }, + + // render a single cell on the map grid + render_grid_cell: function (cx, cy, map_cell, cell) { + if (!this.in_view(cx, cy)) return; + map_cell = map_cell || map_knowledge.get(cx, cy); + cell = cell || map_cell.t; + const scaled = this.canvas_coords(cx, cy); + this.render_cell(cx, cy, scaled.x, scaled.y, map_cell, cell); + }, + + // render a cell on the map grid, recursing as necessary based on + // overlaps with adjacent cells below/right + render_loc: function (cx, cy, map_cell) { + this.render_grid_cell(cx, cy, map_cell); + + // Redraw the cell below if it overlapped + if (this.in_view(cx, cy + 1)) { + const cell_below = map_knowledge.get(cx, cy + 1); + if (cell_below.t?.sy && cell_below.t.sy < 0) + this.render_loc(cx, cy + 1, cell_below); + } + // Redraw the cell to the right if it overlapped + if (this.in_view(cx + 1, cy)) { + const cell_right = map_knowledge.get(cx + 1, cy); + if (cell_right.t?.left_overlap && cell_right.t.left_overlap < 0) { + this.render_loc(cx + 1, cy, cell_right); + } + } + // And the cell to the bottom-right if both overlapped + if (this.in_view(cx + 1, cy + 1)) { + const cell_diag = map_knowledge.get(cx + 1, cy + 1); + if ( + cell_diag.t?.sy && + cell_diag.t.sy < 0 && + cell_diag.t.left_overlap && + cell_diag.t.left_overlap < 0 + ) { + this.render_loc(cx + 1, cy + 1, cell_diag); + } + } + }, + + animate: function () { + global_anim_counter++; + if (global_anim_counter >= 65536) global_anim_counter = 0; + + for (let cy = this.view.y; cy < this.view.y + this.rows; ++cy) + for (let cx = this.view.x; cx < this.view.x + this.cols; ++cx) { + const map_cell = map_knowledge.get(cx, cy); + const cell = map_cell.t; + if (map_knowledge.visible(map_cell) && cell_is_animated(cell)) { + animate_cell(cell); + this.render_grid_cell(cx, cy, map_cell, cell); + } + } + }, + + // This is mostly here so that it can inherit cell size + new_renderer: function (tiles) { + tiles = tiles || []; + const renderer = new cr.DungeonCellRenderer(); + const canvas = $(""); + renderer.set_cell_size(this.cell_width, this.cell_height); + util.init_canvas(canvas[0], this.cell_width, this.cell_height); + renderer.init(canvas[0]); + renderer.draw_tiles(tiles); + + return renderer; + }, + + set_ui_state: function (s) { + this.ui_state = s; + }, + + update_mouse_mode: function (_m) { + if (!game.can_target()) this.last_sent_cursor = { x: 0, y: 0 }; + }, +}); + +const renderer = new DungeonViewRenderer(); +let anim_interval = null; + +function update_animation_interval() { + if (anim_interval) { + clearTimeout(anim_interval); + anim_interval = null; + } + if (options.get("tile_realtime_anim")) { + anim_interval = setInterval(() => { + renderer.animate(); + }, 1000 / 4); + } +} + +options.add_listener(update_animation_interval); + +$(document).off("game_init.dungeon_renderer"); +$(document).on("game_init.dungeon_renderer", () => { + renderer.cols = 0; + renderer.rows = 0; + renderer.view = { x: 0, y: 0 }; + renderer.view_center = { x: 0, y: 0 }; + + renderer.init($("#dungeon")[0]); +}); + +$(document) + .off("game_cleanup.dungeon_renderer") + .on("game_cleanup.dungeon_renderer", () => { + if (anim_interval) { + clearTimeout(anim_interval); + anim_interval = null; + } + }); + +/* Hack to show animations (if real-time ones are not enabled) + even in turns where nothing else happens */ +$(document) + .off("text_update.dungeon_renderer") + .on("text_update.dungeon_renderer", () => { + renderer.animate(); + }); + +export default renderer; diff --git a/crawl-ref/source/webserver/client/game/src/enums.js b/crawl-ref/source/webserver/client/game/src/enums.js new file mode 100644 index 00000000000..3fe9bc87aee --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/enums.js @@ -0,0 +1,305 @@ +// TODO: Generate this automatically from enum.h? + +let exports = {}, + val; +// Various constants +exports.gxm = 80; +exports.gym = 70; +exports.stat_width = 42; + +// UI States (tileweb.h) +exports.ui = {}; +exports.ui.NORMAL = 0; +exports.ui.CRT = 1; +exports.ui.VIEW_MAP = 2; + +// Mouse modes +val = 0; +exports.mouse_mode = {}; +exports.mouse_mode.NORMAL = val++; +exports.mouse_mode.COMMAND = val++; +exports.mouse_mode.TARGET = val++; +exports.mouse_mode.TARGET_DIR = val++; +exports.mouse_mode.TARGET_PATH = val++; +exports.mouse_mode.MORE = val++; +exports.mouse_mode.MACRO = val++; +exports.mouse_mode.PROMPT = val++; +exports.mouse_mode.YESNO = val++; +exports.mouse_mode.MAX = val++; + +// Textures +val = 0; +exports.texture = {}; +exports.texture.FLOOR = val++; // floor.png +exports.texture.WALL = val++; // wall.png +exports.texture.FEAT = val++; // feat.png +exports.texture.PLAYER = val++; // player.png +exports.texture.DEFAULT = val++; // main.png +exports.texture.GUI = val++; // gui.png +exports.texture.ICONS = val++; // icons.png + +// Cursors +exports.CURSOR_MOUSE = 0; +exports.CURSOR_TUTORIAL = 1; +exports.CURSOR_MAP = 2; +exports.CURSOR_MAX = 3; + +// Halo flags +exports.HALO_NONE = 0; +exports.HALO_RANGE = 1; +exports.HALO_UMBRA_FIRST = 2; +exports.HALO_UMBRA_1 = exports.HALO_UMBRA_FIRST; +exports.HALO_UMBRA_2 = 3; +exports.HALO_UMBRA_3 = 4; +exports.HALO_UMBRA_4 = 5; +exports.HALO_UMBRA_LAST = exports.HALO_UMBRA_4; + +// Tile flags. +// Mostly this complicated because they need more than 32 bits. + +function array_and(arr1, arr2) { + const result = []; + for (let i = 0; i < arr1.length && i < arr2.length; ++i) { + result.push(arr1[i] & arr2[i]); + } + return result; +} + +function array_equal(arr1, arr2) { + // return (arr1 <= arr2) && (arr1 >= arr2); + for (let i = 0; i < arr1.length || i < arr2.length; ++i) { + if ((arr1[i] || 0) !== (arr2[i] || 0)) return false; + } + return true; +} + +function array_nonzero(arr) { + for (let i = 0; i < arr.length; ++i) { + if (arr[i] !== 0) return true; + } + return false; +} + +function prepare_flags(tileidx, flagdata, cache) { + if (typeof tileidx === "number") tileidx = [tileidx]; + else if (tileidx.value !== undefined) return tileidx; + while (tileidx.length < 2) tileidx.push(0); + + if (cache[[tileidx[0], tileidx[1]]] !== undefined) + return cache[[tileidx[0], tileidx[1]]]; + + for (const flagname in flagdata.flags) { + const flagmask = flagdata.flags[flagname]; + if (Array.isArray(flagmask)) + tileidx[flagname] = array_nonzero(array_and(tileidx, flagmask)); + else tileidx[flagname] = (tileidx[0] & flagmask) !== 0; + } + + for (let i = 0; i < flagdata.exclusive_flags.length; ++i) { + const excl = flagdata.exclusive_flags[i]; + let val; + if (Array.isArray(excl.mask)) val = array_and(tileidx, excl.mask); + else val = [tileidx[0] & excl.mask]; + + for (const flagname in excl) { + if (flagname === "mask") continue; + if (Array.isArray(excl[flagname])) + tileidx[flagname] = array_equal(val, excl[flagname]); + else tileidx[flagname] = val[0] === excl[flagname]; + } + } + + tileidx.value = tileidx[0] & flagdata.mask; + cache[[tileidx[0], tileidx[1]]] = tileidx; + cache.size++; + return tileidx; +} + +/* Hex literals are signed, so values with the highest bit set + would have to be written in 2-complement; this way is easier to + read */ +const highbit = 1 << 31; + +// Foreground flags + +// 3 mutually exclusive flags for attitude. +const fg_flags = { flags: {}, exclusive_flags: [] }; +fg_flags.exclusive_flags.push({ + mask: 0x00030000, + PET: 0x00010000, + GD_NEUTRAL: 0x00020000, + NEUTRAL: 0x00030000, +}); + +fg_flags.flags.S_UNDER = 0x00040000; +fg_flags.flags.FLYING = 0x00080000; + +// 4 mutually exclusive flags for behaviour. +fg_flags.exclusive_flags.push({ + mask: 0x00700000, + STAB: 0x00100000, + MAY_STAB: 0x00200000, + FLEEING: 0x00300000, + PARALYSED: 0x00400000, +}); + +fg_flags.flags.NET = 0x00800000; +fg_flags.flags.WEB = 0x01000000; + +// Three levels of poison in 2 bits. +fg_flags.exclusive_flags.push({ + mask: [0, 0x18000000], + POISON: [0, 0x08000000], + MORE_POISON: [0, 0x10000000], + MAX_POISON: [0, 0x18000000], +}); + +// 5 mutually exclusive flags for threat level. +fg_flags.exclusive_flags.push({ + mask: [0, 0x60000000 | highbit], + TRIVIAL: [0, 0x20000000], + EASY: [0, 0x40000000], + TOUGH: [0, 0x60000000], + NASTY: [0, highbit], + UNUSUAL: [0, 0x60000000 | highbit], +}); + +fg_flags.flags.GHOST = [0, 0x00100000]; + +// MDAM has 5 possibilities, so uses 3 bits. +fg_flags.exclusive_flags.push({ + mask: [0x40000000 | highbit, 0x01], + MDAM_LIGHT: [0x40000000, 0x00], + MDAM_MOD: [highbit, 0x00], + MDAM_HEAVY: [0x40000000 | highbit, 0x00], + MDAM_SEV: [0x00000000, 0x01], + MDAM_ADEAD: [0x40000000 | highbit, 0x01], +}); + +// Demon difficulty has 5 possibilities, so uses 3 bits. +fg_flags.exclusive_flags.push({ + mask: [0, 0x0e], + DEMON_5: [0, 0x02], + DEMON_4: [0, 0x04], + DEMON_3: [0, 0x06], + DEMON_2: [0, 0x08], + DEMON_1: [0, 0x0e], +}); + +fg_flags.mask = 0x0000ffff; + +// Background flags +const bg_flags = { flags: {}, exclusive_flags: [] }; +bg_flags.flags.RAY = 0x00010000; +bg_flags.flags.MM_UNSEEN = 0x00020000; +bg_flags.flags.UNSEEN = 0x00040000; +bg_flags.exclusive_flags.push({ + mask: 0x00180000, + CURSOR1: 0x00180000, + CURSOR2: 0x00080000, + CURSOR3: 0x00100000, +}); +bg_flags.flags.TUT_CURSOR = 0x00200000; +bg_flags.flags.TRAV_EXCL = 0x00400000; +bg_flags.flags.EXCL_CTR = 0x00800000; +bg_flags.flags.RAY_OOR = 0x01000000; +bg_flags.flags.OOR = 0x02000000; +bg_flags.flags.WATER = 0x04000000; +bg_flags.flags.NEW_STAIR = 0x08000000; +bg_flags.flags.NEW_TRANSPORTER = 0x10000000; + +// Kraken tentacle overlays. +bg_flags.flags.KRAKEN_NW = 0x20000000; +bg_flags.flags.KRAKEN_NE = 0x40000000; +bg_flags.flags.KRAKEN_SE = highbit; +bg_flags.flags.KRAKEN_SW = [0, 0x01]; + +bg_flags.flags.RAMPAGE = [0, 0x020]; + +bg_flags.flags.LANDING = [0, 0x200]; +bg_flags.flags.RAY_MULTI = [0, 0x400]; +bg_flags.mask = 0x0000ffff; + +// Since the current flag implementation is really slow we use a trivial +// cache system for now. +let fg_cache = { size: 0 }; +exports.prepare_fg_flags = (tileidx) => { + if (fg_cache.size >= 100) fg_cache = { size: 0 }; + return prepare_flags(tileidx, fg_flags, fg_cache); +}; +let bg_cache = { size: 0 }; +exports.prepare_bg_flags = (tileidx) => { + if (bg_cache.size >= 250) bg_cache = { size: 0 }; + return prepare_flags(tileidx, bg_flags, bg_cache); +}; + +// Menu flags -- see menu.h +// many things here are unimplemented +const mf = {}; +mf.NOSELECT = 0x0001; +mf.SINGLESELECT = 0x0002; +mf.MULTISELECT = 0x0004; +mf.SELECT_QTY = 0x0008; +mf.ANYPRINTABLE = 0x0010; +mf.SELECT_BY_PAGE = 0x0020; +mf.INIT_HOVER = 0x0040; +mf.WRAP = 0x0080; +mf.ALLOW_FILTER = 0x0100; +mf.ALLOW_FORMATTING = 0x0200; +mf.SHOW_PAGENUMBERS = 0x0400; +// ... +mf.START_AT_END = 0x1000; +mf.PRESELECTED = 0x2000; +// ... +mf.ARROWS_SELECT = 0x40000; +exports.menu_flag = mf; + +val = 0; +exports.CHATTR = {}; +exports.CHATTR.NORMAL = val++; +exports.CHATTR.STANDOUT = val++; +exports.CHATTR.BOLD = val++; +exports.CHATTR.BLINK = val++; +exports.CHATTR.UNDERLINE = val++; +exports.CHATTR.REVERSE = val++; +exports.CHATTR.DIM = val++; +exports.CHATTR.HILITE = val++; +exports.CHATTR.ATTRMASK = 0xf; + +// Minimap features +val = 0; +exports.MF_UNSEEN = val++; +exports.MF_FLOOR = val++; +exports.MF_WALL = val++; +exports.MF_MAP_FLOOR = val++; +exports.MF_MAP_WALL = val++; +exports.MF_DOOR = val++; +exports.MF_ITEM = val++; +exports.MF_MONS_FRIENDLY = val++; +exports.MF_MONS_PEACEFUL = val++; +exports.MF_MONS_NEUTRAL = val++; +exports.MF_MONS_HOSTILE = val++; +exports.MF_MONS_NO_EXP = val++; +exports.MF_STAIR_UP = val++; +exports.MF_STAIR_DOWN = val++; +exports.MF_STAIR_BRANCH = val++; +exports.MF_FEATURE = val++; +exports.MF_WATER = val++; +exports.MF_LAVA = val++; +exports.MF_TRAP = val++; +exports.MF_EXCL_ROOT = val++; +exports.MF_EXCL = val++; +exports.MF_PLAYER = val++; +exports.MF_DEEP_WATER = val++; +exports.MF_PORTAL = val++; +exports.MF_MAX = val++; + +exports.MF_SKIP = val++; + +exports.reverse_lookup = (e, value) => { + for (const prop in e) { + if (e[prop] === value) return prop; + } +}; + +export default exports; diff --git a/crawl-ref/source/webserver/client/game/src/game.js b/crawl-ref/source/webserver/client/game/src/game.js new file mode 100644 index 00000000000..ffcadf5d334 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/game.js @@ -0,0 +1,470 @@ +import $ from "jquery"; + +import client from "./client"; +import comm from "./comm"; +import display from "./display"; +import dungeon_renderer from "./dungeon_renderer"; +import enums from "./enums"; +import key_conversion from "./key_conversion"; +import messages from "./messages"; +import minimap from "./minimap"; +import mouse_control from "./mouse_control"; +import options from "./options"; + +import "./text"; +import "./menu"; +import "./action_panel"; +import "./player"; +import "./ui"; +import "./ui-layouts"; + +let layout_parameters = null, + ui_state, + input_mode; +let msg_height; +const show_diameter = 17; + +function setup_keycodes() { + key_conversion.reset_keycodes(); + // the `codes` check and the else clause are here to prevent crashes + // while players' caches get updated client.js and key_conversion.js + // TODO: remove someday + if (key_conversion.codes) key_conversion.enable_code_conversion(); + else { + key_conversion.simple[96] = -1000; // numpad 0 + key_conversion.simple[97] = -1001; // numpad 1 + key_conversion.simple[98] = -1002; // numpad 2 + key_conversion.simple[99] = -1003; // numpad 3 + key_conversion.simple[100] = -1004; // numpad 4 + key_conversion.simple[101] = -1005; // numpad 5 + key_conversion.simple[102] = -1006; // numpad 6 + key_conversion.simple[103] = -1007; // numpad 7 + key_conversion.simple[104] = -1008; // numpad 8 + key_conversion.simple[105] = -1009; // numpad 9 + key_conversion.simple[106] = -1015; // numpad * + key_conversion.simple[107] = -1016; // numpad + + key_conversion.simple[108] = -1019; // numpad . (some firefox versions? TODO: confirm) + key_conversion.simple[109] = -1018; // numpad - + key_conversion.simple[110] = -1019; // numpad . + key_conversion.simple[111] = -1012; // numpad / + + // fix some function key issues (see note in key_conversions.js). + key_conversion.simple[112] = -265; // F1 + key_conversion.simple[113] = -266; // F2 + key_conversion.simple[114] = -267; // F3 + key_conversion.simple[115] = -268; // F4 + key_conversion.simple[116] = -269; // F5 + key_conversion.simple[117] = -270; // F6 + key_conversion.simple[118] = -271; // F7 + key_conversion.simple[119] = -272; // F8 + key_conversion.simple[120] = -273; // F9 + key_conversion.simple[121] = -274; // F10 + // reserve F11 for the browser (full screen) + // reserve F12 for toggling chat + key_conversion.simple[124] = -277; // F13, may not work + // F13 may also show up as printscr, but we don't want to bind that + key_conversion.simple[125] = -278; // F14, may not work? + key_conversion.simple[126] = -279; // F15, may not work? + key_conversion.simple[127] = -280; + key_conversion.simple[128] = -281; + key_conversion.simple[129] = -282; + key_conversion.simple[130] = -283; + } + + // any version-specific keycode overrides can be added here. (Though + // hopefully this will be rarely needed in the future...) +} + +function init() { + layout_parameters = null; + ui_state = -1; + options.clear(); + setup_keycodes(); +} + +$(document).on("game_preinit game_cleanup", init); + +function layout_params_differ(old_params, new_params) { + if (!old_params) return true; + for (const param in new_params) { + if ( + Object.hasOwn(old_params, param) && + old_params[param] !== new_params[param] + ) + return true; + } + return false; +} + +function layout(params, force) { + const window_width = $(window).width(); + params.window_width = window_width; + const window_height = $(window).height(); + params.window_height = window_height; + + if (!force && !layout_params_differ(layout_parameters, params)) return false; + + layout_parameters = params; + + const state = ui_state; + set_ui_state(enums.ui.NORMAL); + + // Determine width of stats area + const old_html = $("#stats").html(); + let s = ""; + for (let i = 0; i < enums.stat_width; i++) s = `${s} `; + $("#stats").html(s); + const stat_width_px = $("#stats").outerWidth(); + $("#stats").html(old_html); + + // Determine height of messages area + + // need to clone this, not copy html, since there can be event handlers + // embedded in here on an input box. + const old_messages = $("#messages").clone(true); + const old_scroll_top = $("#messages_container").scrollTop(); + s = ""; + for (let i = 0; i < msg_height + 1; i++) s = `${s}
`; + $("#messages").html(s); + const msg_height_px = $("#messages").outerHeight(); + $("#messages").replaceWith(old_messages); + $("#messages_container").scrollTop(old_scroll_top); + + const remaining_width = window_width - stat_width_px; + const remaining_height = window_height - msg_height_px; + + layout_parameters.remaining_width = remaining_width; + layout_parameters.remaining_height = remaining_height; + + // Position controls + client.set_layer("normal"); + $("#messages_container").css({ + "max-height": (msg_height_px * msg_height) / (msg_height + 1), + width: remaining_width, + }); + dungeon_renderer.fit_to(remaining_width, remaining_height, show_diameter); + + minimap.fit_to(stat_width_px, layout_parameters); + + $("#stats").width(stat_width_px); + $("#monster_list").width(stat_width_px); + $("#mobile_input input").width(remaining_width - 12); // 2*padding+2*border + + // Go back to the old layer + set_ui_state(state); + + // Update the view + display.invalidate(true); + display.display(); + minimap.update_overlay(); + + const possible_input = $("#messages .game_message input"); + if (possible_input) possible_input.focus(); + + // Input helper for mobile browsers + // XX should this really happen in `layout`? + if (!client.is_watching()) { + const mobile_input = options.get("tile_web_mobile_input_helper"); + if (mobile_input === "true" || (mobile_input === "auto" && is_mobile())) { + $("#mobile_input").show(); + $("#mobile_input input") + .off("keydown") + .on("keydown", handle_mobile_keydown) + .off("input") + .on("input", handle_mobile_input) + .off("mousedown focusout") + .on("mousedown", mobile_input_click); + // the following requires a fairly modern browser + $(document).on("visibilitychange", (_ev) => { + // try to regularize the behavior, on iOS it's flaky + // otherwise + // bug: on iOS zooming out to view tabs doesn't seem + // to trip this event handler. Presumably because + // "prerendered" isn't yet implemented? + if (document.visibilityState !== "visible") + mobile_input_force_defocus(); + }); + } + } +} + +options.add_listener(() => { + minimap.init_options(); + if (layout_parameters) layout(layout_parameters, true); + display.invalidate(true); + display.display(); + glyph_mode_font_init(); + init_custom_text_colours(); +}); + +function toggle_full_window_dungeon_view(full) { + // Toggles the dungeon view for X map mode + if (layout_parameters == null) return; + if (full) { + let width = layout_parameters.remaining_width; + let height = layout_parameters.remaining_height; + + if (options.get("tile_level_map_hide_sidebar") === true) { + width = layout_parameters.window_width - 5; + $("#right_column").hide(); + } + if (options.get("tile_level_map_hide_messages") === true) { + height = layout_parameters.window_height - 5; + messages.hide(); + } + $(".action-panel").hide(); + + dungeon_renderer.fit_to(width, height, show_diameter); + } else { + dungeon_renderer.fit_to( + layout_parameters.remaining_width, + layout_parameters.remaining_height, + show_diameter + ); + $("#right_column").show(); + $(".action-panel").show(); + messages.show(); + } + minimap.stop_minimap_farview(); + minimap.update_overlay(); + display.invalidate(true); + display.display(); +} + +function set_ui_state(state) { + dungeon_renderer.set_ui_state(state); + if (state === ui_state) return; + + const old_state = ui_state; + ui_state = state; + switch (ui_state) { + case enums.ui.NORMAL: + client.set_layer("normal"); + if (old_state === enums.ui.VIEW_MAP) + toggle_full_window_dungeon_view(false); + break; + + case enums.ui.CRT: + client.set_layer("crt"); + break; + + case enums.ui.VIEW_MAP: + toggle_full_window_dungeon_view(true); + break; + } +} + +function handle_set_layout(data) { + if (data.message_pane.height) { + msg_height = + data.message_pane.height + (data.message_pane.small_more ? 0 : -1); + messages.message_pane_height = msg_height; + } else msg_height = messages.message_pane_height; + + if (layout_parameters == null) layout({}); + else { + const params = $.extend({}, layout_parameters); + layout(params, true); + } +} + +function handle_set_ui_state(data) { + set_ui_state(data.state); +} + +function handle_set_ui_cutoff(data) { + const popups = document.querySelectorAll("#ui-stack > .ui-popup"); + Array.from(popups).forEach((p, i) => { + p.classList.toggle("hidden", i <= data.cutoff); + }); +} + +function set_input_mode(mode) { + if (mode === input_mode) return; + input_mode = mode; + dungeon_renderer.update_mouse_mode(input_mode); + if (mode === enums.mouse_mode.COMMAND) messages.new_command(); +} + +game.get_input_mode = () => input_mode; +game.get_ui_state = () => ui_state; + +game.can_target = () => + input_mode === enums.mouse_mode.TARGET || + input_mode === enums.mouse_mode.TARGET_DIR || + input_mode === enums.mouse_mode.TARGET_PATH; +game.can_move = () => { + // XX just looking + return ( + input_mode === enums.mouse_mode.COMMAND || ui_state === enums.ui.VIEW_MAP + ); +}; +game.can_describe = () => + input_mode === enums.mouse_mode.TARGET || + input_mode === enums.mouse_mode.TARGET_DIR || + input_mode === enums.mouse_mode.TARGET_PATH || + input_mode === enums.mouse_mode.COMMAND || + ui_state === enums.ui.VIEW_MAP; + +function handle_set_input_mode(data) { + set_input_mode(data.mode); +} + +function handle_delay(data) { + client.delay(data.t); +} + +let _game_version; +function handle_version(data) { + _game_version = data; + document.title = data.text; +} + +function glyph_mode_font_init() { + if (options.get("tile_display_mode") === "tiles") return; + + let glyph_font, glyph_size; + glyph_size = options.get("glyph_mode_font_size"); + glyph_font = options.get("glyph_mode_font"); + + if (!document.fonts.check(`${glyph_size}px ${glyph_font}`)) + glyph_font = "monospace"; + + const renderer_settings = { + glyph_mode_font_size: glyph_size, + glyph_mode_font: glyph_font, + }; + $.extend(dungeon_renderer, renderer_settings); +} + +function init_custom_text_colours() { + const root = document.querySelector(":root"); + + // Reset colours first + for (let i = 0; i < 16; i++) root.style.removeProperty(`--color-${i}`); + + // Load custom replacements + const colours = options.get("custom_text_colours"); + for (const i in colours) { + root.style.setProperty( + `--color-${colours[i].index}`, + "rgba(" + + colours[i].r + + ", " + + colours[i].g + + ", " + + colours[i].b + + ", 255)" + ); + } +} + +function is_mobile() { + return "ontouchstart" in document.documentElement; +} + +function handle_mobile_input(e) { + e.target.value = e.target.defaultValue; + comm.send_message("input", { text: e.originalEvent.data }); +} + +function handle_mobile_keydown(e) { + // translate backspace/delete to esc -- the backspace key is almost + // entirely unused outside of text input, and the lack of esc on a + // mobile keyboard is really painful. Text input doesn't go through + // this input, so that case is fine. (One minor case is the macro + // edit menu, but there's at least an alternate way to clear a + // binding.) + if (e.which === 8 || e.which === 46) { + comm.send_message("key", { keycode: 27 }); + e.preventDefault(); + return false; + } +} + +function mobile_input_focus_style(focused) { + // manually manage the style so that it doesn't blink when the + // focusout handler is doing its thing + const mi = $("#mobile_input input"); + if (focused) { + mi.attr("placeholder", "Tap here to close keyboard"); + mi.css("background", "rgba(100, 100, 100, 0.5)"); + } else { + mi.attr("placeholder", "Tap here for keyboard input"); + mi.css("background", "rgba(0, 0, 0, 0.5)"); + } +} + +function mobile_input_focused() { + return $("#mobile_input input").is(":focus"); +} + +function mobile_input_focusout(ev) { + const $mi = $("#mobile_input input"); + if (ev.relatedTarget && $(ev.relatedTarget).is("input")) { + // ok, we'll allow it, but we want it back later. As long as focus + // goes from input to input, the keyboard seems to stay open. + $(ev.relatedTarget) + .off("focusout") + .on("focusout", (ev) => { + $mi[0].focus(); + $mi.off("focusout").on("focusout", mobile_input_focusout); + mobile_input_focus_style(true); + $(ev.relatedTarget).off("focusout"); + }); + $mi.off("focusout"); + mobile_input_focus_style(false); + } else { + // force focus to stay on the mobile input. For iOS purposes, this + // is the only thing I've tried that works. This *can't* happen in + // a timeout, or the temporary loss of focus is enough to close + // the keyboard. + // Bug: tapping chat close hides the keyboard + $mi[0].focus(); + } +} + +function mobile_input_force_defocus() { + const $mi = $("#mobile_input input"); + // remove any event handlers first + $mi.off("focusout"); + mobile_input_focus_style(false); + $mi.blur(); +} + +function mobile_input_click(ev) { + const $mi = $("#mobile_input input"); + if (mobile_input_focused()) { + mobile_input_force_defocus(); + ev.preventDefault(); + } else { + $mi.off("focusout").on("focusout", mobile_input_focusout); + mobile_input_focus_style(true); + $mi.focus(); + ev.preventDefault(); + } +} + +$(document).ready(() => { + $(window) + .off("resize.game") + .on("resize.game", () => { + if (layout_parameters) { + const params = $.extend({}, layout_parameters); + layout(params); + } + $("#action-panel").triggerHandler("update"); + }); +}); + +comm.register_handlers({ + delay: handle_delay, + version: handle_version, + layout: handle_set_layout, + ui_state: handle_set_ui_state, + ui_cutoff: handle_set_ui_cutoff, + input_mode: handle_set_input_mode, +}); + +// ugly circular reference breaking +mouse_control.game = game; diff --git a/crawl-ref/source/webserver/client/game/src/key_conversion.js b/crawl-ref/source/webserver/client/game/src/key_conversion.js new file mode 100644 index 00000000000..000b187ef46 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/key_conversion.js @@ -0,0 +1 @@ +export default window.DCSS.key_conversion; diff --git a/crawl-ref/source/webserver/client/game/src/main.js b/crawl-ref/source/webserver/client/game/src/main.js new file mode 100644 index 00000000000..e629006152d --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/main.js @@ -0,0 +1,14 @@ +// Bundling styles didn't work, because there's no good way to *unload* them +// when we switch game client. This could probably be handled properly but for +// now including them with plain old html is the path of least resistance. +// import "./style.css"; +// import "./simplebar.css"; +import "./game"; + +import $ from "jquery"; +import client from "./client"; + +// Game initialisation (moved from inline script in html template) +$(document).trigger("game_preinit"); +$(document).trigger("game_init"); +client.uninhibit_messages(); diff --git a/crawl-ref/source/webserver/client/game/src/map_knowledge.js b/crawl-ref/source/webserver/client/game/src/map_knowledge.js new file mode 100644 index 00000000000..d3a8185a6ab --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/map_knowledge.js @@ -0,0 +1,189 @@ +import $ from "jquery"; +import enums from "./enums"; +import util from "./util"; + +let k, player_on_level, monster_table, dirty_locs, bounds, bounds_changed; + +function init() { + k = new Array(65536); + monster_table = {}; + dirty_locs = []; + bounds = null; + bounds_changed = false; +} + +$(document).bind("game_init", init); + +function get(x, y) { + const key = util.make_key(x, y); + + while (key >= k.length) { + k = k.concat(new Array(k.length)); + } + + let val = k[key]; + if (val === undefined) { + val = { x: x, y: y }; + k[key] = val; + } + return val; +} + +function clear() { + k = new Array(65536); + monster_table = {}; + bounds = null; +} + +function visible(cell) { + if (cell.t) { + cell.t.bg = enums.prepare_bg_flags(cell.t.bg || 0); + return !cell.t.bg.UNSEEN && !cell.t.bg.MM_UNSEEN; + } + return false; +} + +function touch(x, y) { + const pos = y === undefined ? x : { x, y }; + const cell = get(pos.x, pos.y); + if (!cell.dirty) { + dirty_locs.push(pos); + cell.dirty = true; + } +} + +function merge_objects(current, diff) { + if (!current) return diff; + + for (const prop in diff) current[prop] = diff[prop]; + + return current; +} + +function set_monster_defaults(mon) { + mon.att = mon.att || 0; +} + +function merge_monster(old_mon, mon) { + if (old_mon?.refs) { + old_mon.refs--; + } + + if (!mon) { + return null; + } + + const id = mon.id; + + let last = monster_table[id]; + if (!last) { + if (old_mon) last = merge_objects(merge_objects({}, old_mon), mon); + else { + last = mon; + set_monster_defaults(last); + } + } else { + merge_objects(last, mon); + } + + if (id) { + last.refs = last.refs || 0; + last.refs++; + monster_table[id] = last; + } + + return last; +} + +function clean_monster_table() { + for (const id in monster_table) { + if (!monster_table[id].refs) delete monster_table[id]; + } +} + +let merge_last_x, merge_last_y; + +function merge(val) { + if (val === undefined) return; + + let x, y; + if (val.x === undefined) x = merge_last_x + 1; + else x = val.x; + if (val.y === undefined) y = merge_last_y; + else y = val.y; + merge_last_x = x; + merge_last_y = y; + + const entry = get(x, y); + + for (const prop in val) { + if (prop === "mon") { + entry[prop] = merge_monster(entry[prop], val[prop]); + } else if (prop === "t") { + entry[prop] = merge_objects(entry[prop], val[prop]); + + // The transparency flag is linked to the doll; + // if the doll changes, it is reset + if (val[prop].doll && val[prop].trans === undefined) + entry[prop].trans = false; + } else entry[prop] = val[prop]; + } + + touch(x, y); + + if (bounds) { + if (bounds.left > x) { + bounds.left = x; + bounds_changed = true; + } + if (bounds.right < x) { + bounds.right = x; + bounds_changed = true; + } + if (bounds.top > y) { + bounds.top = y; + bounds_changed = true; + } + if (bounds.bottom < y) { + bounds.bottom = y; + bounds_changed = true; + } + } else { + bounds = { + left: x, + top: y, + right: x, + bottom: y, + }; + } +} + +function merge_diff(vals) { + $.each(vals, (_i, val) => { + merge(val); + }); + + clean_monster_table(); +} + +export default { + get: get, + merge: merge_diff, + clear: clear, + touch: touch, + visible: visible, + player_on_level: () => player_on_level, + set_player_on_level: (v) => { + player_on_level = v; + }, + dirty: () => dirty_locs, + reset_dirty: () => { + dirty_locs = []; + }, + bounds: () => bounds, + reset_bounds_changed: () => { + const bc = bounds_changed; + bounds_changed = false; + return bc; + }, +}; diff --git a/crawl-ref/source/webserver/client/game/src/menu.js b/crawl-ref/source/webserver/client/game/src/menu.js new file mode 100644 index 00000000000..4bf4f405060 --- /dev/null +++ b/crawl-ref/source/webserver/client/game/src/menu.js @@ -0,0 +1,989 @@ +import $ from "jquery"; +import cr from "./cell_renderer"; +import client from "./client"; +import comm from "./comm"; +import enums from "./enums"; +import options from "./options"; +import scroller from "./scroller"; +import ui from "./ui"; +import util from "./util"; + +// Helpers + +function item_selectable(item) { + // TODO: the logic on the c++ side is somewhat different here + return ( + item.level === 2 && + // in the use item menu, selecting a non-hotkeyed item triggers + // relettering on the server + (menu.tag === "use_item" || item.hotkeys?.length) + ); +} + +function item_text(item) { + return item.text; +} + +function item_colour(item) { + if (item.colour === undefined) return 7; + return item.colour; +} + +function menu_title_indent() { + if ( + !options.get("tile_menu_icons") || + options.get("tile_display_mode") !== "tiles" || + !(menu.tag === "ability" || menu.tag === "spell") + ) + return 0; + return 32 + 2; // menu
    has a 2px margin +} + +function set_item_contents(item, elem) { + elem.html(util.formatted_string_to_html(item_text(item))); + elem.css("min-height", "0.5em"); + const col = item_colour(item); + elem.removeClass(); + elem.addClass(`level${item.level}`); + elem.addClass(`fg${col}`); + + if (item.level < 2) elem.css("padding-left", `${menu_title_indent()}px`); + + if (item_selectable(item)) { + elem.addClass("selectable"); + elem.off("click.menu_item").off("contextmenu.menu_item"); + elem + .on("click.menu_item", item_click_handler) + .on("contextmenu.menu_item", item_click_handler); + } + + if ( + item.tiles && + item.tiles.length > 0 && + options.get("tile_display_mode") === "tiles" + ) { + const renderer = new cr.DungeonCellRenderer(); + const canvas = $(""); + util.init_canvas(canvas[0], renderer.cell_width, renderer.cell_height); + canvas.css("vertical-align", "middle"); + renderer.init(canvas[0]); + + $.each(item.tiles, function () { + renderer.draw_from_texture( + this.t, + 0, + 0, + this.tex, + 0, + 0, + this.ymax, + false + ); + }); + + elem.prepend(canvas); + } +} + +let menu_stack = []; +let menu = null; +let update_server_scroll_timeout = null; +let menu_close_timeout = null; +let mouse_hover_suppressed = null; + +function add_hover_class(item) { + if (item < 0 || item >= menu.items.length) return; + menu.items[item].elem.addClass("hovered"); +} + +function remove_hover_class(item) { + if (item < 0 || item >= menu.items.length) return; + menu.items[item].elem.removeClass("hovered"); +} + +function mouse_set_hovered(index) { + if (mouse_hover_suppressed) return; + if ( + index >= 0 && + (index < menu.first_part_visible || index > menu.last_part_visible) + ) { + return; + } + set_hovered(index, false, true); +} + +function clear_suppress() { + mouse_hover_suppressed = null; +} + +function suppress_mouse_hover() { + // ugh -- keep mouseenter from triggering, is there a better way? + if (mouse_hover_suppressed) clearTimeout(mouse_hover_suppressed); + mouse_hover_suppressed = setTimeout(clear_suppress, 200); +} + +function set_hovered(index, snap = true, from_mouse = false) { + if (index >= menu.items.length) index = Math.max(0, menu.items.length - 1); + if ( + index === menu.last_hovered && + (index < 0 || item_selectable(menu.items[index])) + ) { + // just make sure the hover class is set correctly + add_hover_class(menu.last_hovered); + return; + } + remove_hover_class(menu.last_hovered); + if (index < 0 || item_selectable(menu.items[index])) { + menu.last_hovered = index; + add_hover_class(menu.last_hovered); + if (menu.last_hovered >= 0 && snap === true) + snap_in_page(menu.last_hovered); + comm.send_message("menu_hover", { + hover: menu.last_hovered, + mouse: from_mouse, + }); + } +} + +function menu_cleanup() { + menu_stack = []; + menu = null; + if (update_server_scroll_timeout) { + clearTimeout(update_server_scroll_timeout); + update_server_scroll_timeout = null; + } + if (menu_close_timeout) { + clearTimeout(menu_close_timeout); + menu_close_timeout = null; + } + mouse_hover_suppressed = null; +} + +function display_menu() { + const menu_div = $(".templates > .menu").clone(); + menu_div.addClass(`menu_${menu.tag}`); + menu.elem = menu_div; + + if (menu.type === "crt") { + // Custom-drawn CRT menu + menu_div.removeClass("menu").addClass("menu_txt"); + ui.show_popup(menu_div, menu["ui-centred"]); + return; + } + + // Normal menu + menu_div.prepend("