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 = $("