[](#license)
diff --git a/assets/alyxlib_logo_256x256.png b/assets/alyxlib_logo_256x256.png
new file mode 100644
index 0000000..7d36a5a
Binary files /dev/null and b/assets/alyxlib_logo_256x256.png differ
diff --git a/deployment_manifest.json b/deployment_manifest.json
new file mode 100644
index 0000000..fe064bb
--- /dev/null
+++ b/deployment_manifest.json
@@ -0,0 +1,140 @@
+{
+ "Categories": {
+
+ "vscript": [
+ {
+ "type": "symlink",
+ "description": "AlyxLib library symlink",
+ "source": "{AlyxLib}/scripts/vscripts/alyxlib",
+ "destination": "{AddonContent}/scripts/vscripts/alyxlib",
+ "remove": true
+ },
+ {
+ "type": "symlink",
+ "description": "gameinit.lua symlink",
+ "source": "{AlyxLib}/scripts/vscripts/game/gameinit.lua",
+ "destination": "{AddonContent}/scripts/vscripts/game/gameinit.lua",
+ "remove": true
+ },
+ {
+ "type": "copy",
+ "description": "Addon main init.lua file",
+ "source": "{AlyxLib}/templates/script_init_main.txt",
+ "destination": "{AddonContent}/scripts/vscripts/{ModName}/init.lua"
+ },
+ {
+ "type": "template",
+ "description": "Workshop mod init file",
+ "source": "{AlyxLib}/templates/script_init_workshop.txt",
+ "destination": "{AddonContent}/scripts/vscripts/mods/init/0000000000.lua",
+ "replacements": "{ModName}",
+ "rules": [
+ {
+ "type": "FileNameDoesNotExist",
+ "target": "destination",
+ "value": "rx:^\\d+\\.lua$",
+ "description": "Possible workshop init file already exists so a new one will not be created."
+ }
+ ]
+ },
+ {
+ "type": "template",
+ "description": "Local mod init file",
+ "source": "{AlyxLib}/templates/script_init_local.txt",
+ "destination": "{AddonContent}/scripts/vscripts/mods/init/{AddonFolderName}.lua",
+ "replacements": "{ModName}"
+ },
+ {
+ "type": "symlink",
+ "description": "Scripts symlink in addon game folder",
+ "source": "{AddonContent}/scripts",
+ "destination": "{AddonGame}/scripts"
+ }
+ ],
+
+ "editor-vscode": [
+ {
+ "type": "symlink",
+ "description": "AlyxLib snippets symlink for VSCode",
+ "source": "{AlyxLib}/.vscode/alyxlib.code-snippets",
+ "destination": "{AddonContent}/.vscode/alyxlib.code-snippets"
+ },
+ {
+ "type": "symlink",
+ "description": "VScript snippets symlink for VSCode",
+ "source": "{AlyxLib}/.vscode/vlua_snippets.code-snippets",
+ "destination": "{AddonContent}/.vscode/vlua_snippets.code-snippets"
+ },
+ {
+ "type": "copy",
+ "description": "AlyxLib VSCode settings file",
+ "source": "{AlyxLib}/templates/vscode_settings.txt",
+ "destination": "{AddonContent}/.vscode/settings.json",
+ "rules": [
+ {
+ "type": "FileDoesNotExist",
+ "target": "destination",
+ "description": "VSCode settings file already exists, so it will not be replaced"
+ }
+ ]
+ }
+ ],
+
+ "panorama": [
+ {
+ "type": "symlink",
+ "description": "Panorama Lua integration symlink",
+ "source": "{AlyxLib}/panorama/scripts/custom_game/panorama_lua.js",
+ "destination": "{AddonContent}/panorama/scripts/custom_game/panorama_lua.js"
+ },
+ {
+ "type": "symlink",
+ "description": "Panorama code completion symlink",
+ "source": "{AlyxLib}/panorama/scripts/custom_game/panoramadoc.js",
+ "destination": "{AddonContent}/panorama/scripts/custom_game/panoramadoc.js"
+ }
+ ],
+
+ "sounds": [
+ {
+ "type": "copy",
+ "description": "Addon soundevents file",
+ "source": "{AlyxLib}/templates/soundevents.txt",
+ "destination": "{AddonContent}/soundevents/{AddonFolderName}_soundevents.vsndevts"
+ },
+ {
+ "type": "delete",
+ "description": "Delete default soundevents file",
+ "source": "{AddonContent}/soundevents/addon_template_soundevents.vsndevts",
+ "rules": [
+ {
+ "type": "Hash",
+ "value": "768e1cb207576e41b92718e0559f876095618a23f7a116a829a7b5d578591eeb",
+ "description": "Default soundevents file has been modified, so it won't be deleted"
+ }
+ ]
+ },
+
+ {
+ "type": "template",
+ "description": "Addon soundevents manifest file",
+ "source": "{AlyxLib}/templates/resource_manifest.txt",
+ "destination": "{AddonContent}/resourcemanifests/{AddonFolderName}_addon_resources.vrman",
+ "replacements": "{AddonFolderName}"
+ },
+ {
+ "type": "delete",
+ "description": "Delete default soundevents manifest file",
+ "source": "{AddonContent}/resourcemanifests/addon_template_addon_resources.vrman",
+ "rules": [
+ {
+ "type": "Hash",
+ "value": "495d7301afadbed3eece2d16250608b4e4c9529fd3d34a8f93fbf61479c6ab13",
+ "description": "Default soundevents manifest file has been modified, so it won't be deleted"
+ }
+ ]
+ }
+ ]
+
+ }
+}
\ No newline at end of file
diff --git a/panorama/layout/custom_game/alyxlib_debug_menu.xml b/panorama/layout/custom_game/alyxlib_debug_menu.xml
new file mode 100644
index 0000000..3551557
--- /dev/null
+++ b/panorama/layout/custom_game/alyxlib_debug_menu.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/panorama/scripts/custom_game/alyxlib_debug_menu.js b/panorama/scripts/custom_game/alyxlib_debug_menu.js
new file mode 100644
index 0000000..bce0f99
--- /dev/null
+++ b/panorama/scripts/custom_game/alyxlib_debug_menu.js
@@ -0,0 +1,1220 @@
+///
+"use strict";
+
+///TODO: Add pop up for warnings and errors
+
+let panelReady = false;
+
+/**
+ * Fires a Panorama output with the given name and arguments.
+ * The output is routed to the panel's input 'RunScriptCode'.
+ *
+ * @param {string} outputName - The name of the output to fire.
+ * @param {...*} args - The arguments to pass to the output.
+ */
+function FireOutput(outputName, ...args) {
+ if (args === undefined) args = [];
+ const formattedArgs = args.map(arg =>
+ typeof arg === "string" ? `'${arg}'` : String(arg)
+ );
+ const callString = `${outputName}(${formattedArgs.join(",")})`;
+ $.DispatchEvent("ClientUI_FireOutputStr", 0, callString);
+
+ // $.Msg(callString);
+}
+
+/**
+ * Maps category id to category object.
+ * @type {Category[]}
+ */
+let categories = [];
+
+const numberOfVisibleCategories = 5;
+
+let categoryBarCycleIndex = 0;
+
+/**
+ * @type {Category}
+ */
+let currentlySelectedCategory = null;
+
+/**
+ * @type {Panel}
+ */
+let currentlyActiveButton = null;
+
+function TurnButtonIntoDebugMenuButton(button, callback)
+{
+ if (button == null) return;
+
+ button.SetPanelEvent("onmouseover", () => currentlyActiveButton = button);
+ button.SetPanelEvent("onmouseout", () => {
+ if (currentlyActiveButton == button) currentlyActiveButton = null;
+ });
+
+ if (callback !== null && callback !== undefined)
+ if (button.paneltype == "HLVR_SettingsSlider")
+ button.SetPanelEvent("onvaluechanged", callback);
+ else
+ button.SetPanelEvent("onactivate", callback);
+}
+
+/**
+ * Creates a new debug menu button.
+ * @param {Panel} parent Panel that the button will be added to.
+ * @param {function} callback Function to call when the button is pressed.
+ * @param {string} _class Class to apply to the button.
+ * @param {string} [id] Unique ID for the button.
+ * @returns {Panel} The newly created button.
+ */
+function CreateDebugMenuButton(parent, callback, _class, id)
+{
+ let button = $.CreatePanel("Button", parent, id);
+ button.AddClass(_class);
+
+ TurnButtonIntoDebugMenuButton(button, callback);
+
+ return button;
+}
+
+/**
+ * Create a panel, optionally with a set of classes.
+ * @param {string} type Type of panel (e.g. Panel, Button).
+ * @param {Panel} parent Parent of this new panel.
+ * @param {string?} id Id of this new panel.
+ * @param {string?} classes Classes for this panel.
+ * @returns {Panel}
+ */
+function CreatePanel(type, parent, id, classes)
+{
+ id = id || '';
+ const panel = $.CreatePanel(type, parent, id);
+ if (classes !== undefined) {
+ for (let _class of classes.split(' ')) {
+ panel.AddClass(_class);
+ }
+ }
+ return panel;
+}
+
+/**
+ * @interface
+ * @typedef {Object} SubMenuItem
+ * @property {(text: string) => void} SetText
+ * @property {(panel: Panel) => void} AddToPanel
+ */
+
+class Category
+{
+ constructor(id, name)
+ {
+ this.id = id;
+ /**
+ * @type {string}
+ */
+ this.name = name;
+
+ /**
+ * @type {SubMenuItem[]}
+ */
+ this.items = [];
+
+ // Main submenu panel
+ this.panel = null;
+ // Content panel where items are added
+ this.content = null;
+ // Root panel that this category is attached to
+ this.root = null;
+
+ // Create content panels
+ this.panel = $.CreatePanel("Panel", $("#CategoriesContainer"), this.id);
+ this.panel.AddClass("submenu");
+ this.panel.AddClass("scroll");
+ this.content = $.CreatePanel("Panel", this.panel, `${this.id}_content`);
+ this.content.AddClass("content");
+
+ // Create category button
+ this.button = CreateDebugMenuButton($("#CategoryBar"), () => SetCategoryVisible(this.id), "CategoryButton", `${this.id}_button`);
+
+ // Animate this new tab if being added after the menu is open
+ if (panelReady)
+ this.button.AddClass("flash");
+
+ let label = $.CreatePanel("Label", this.button, `${this.id}_label`);
+ label.text = this.name;
+
+ // Scale text size to fit button
+
+ // Width of CategoryButton
+ let containerWidth = 150;
+ // Good factor for AlyxLib text
+ let baseFactor = 5;
+ // Calculate a scaled factor that grows slowly
+ let factor = baseFactor * Math.max(label.text.length / 17, 1); // never less than 1, so no shrinking below base
+ // Clamp to avoid too small or too big
+ factor = Math.min(Math.max(factor, baseFactor), 10);
+ label.style.fontSize = `${containerWidth / factor}px`;
+ }
+
+ /**
+ * Deletes this category and all of its items.
+ */
+ Delete()
+ {
+ this.panel.DeleteAsync(0);
+ this.button.DeleteAsync(0);
+ }
+
+ SetVisible(visible)
+ {
+ if (visible)
+ {
+ this.panel.AddClass("Visible");
+ this.button.AddClass("Selected");
+ }
+ else
+ {
+ this.panel.RemoveClass("Visible");
+ this.button.RemoveClass("Selected");
+ }
+ }
+
+ SetBarButtonVisible(visible)
+ {
+ if (visible)
+ this.button.visible = true;
+ else
+ this.button.visible = false;
+ }
+
+ SetItemText(id, text)
+ {
+ // Find item with id in this.options
+ let combinedId = `${this.id}_${id}`;
+ let item = this.items.find(o => o.id === combinedId);
+ if (item === undefined)
+ {
+ $.Msg(`Item ${id} does not exist!`);
+ return;
+ }
+
+ if (!item.SetText) {
+ $.Msg(`Item ${id} does not support SetItemText!`);
+ return;
+ }
+
+ // text-transform: uppercase; doesn't affect js set text?
+ text = text.toLocaleUpperCase();
+
+ item.SetText(text);
+ }
+
+ AddButton(id, text)
+ {
+ let button = new SubMenuButton(`${this.id}_${id}`, text, () => {
+ FireOutput("_DebugMenuCallbackButton", id);
+ });
+ button.AddToPanel(this.content);
+ this.items.push(button);
+ }
+
+ AddToggle(id, text, startsOn)
+ {
+ let toggle = new SubMenuToggle(`${this.id}_${id}`, text, startsOn, (on) => {
+ FireOutput("_DebugMenuCallbackToggle", id, on);
+ });
+ toggle.AddToPanel(this.content);
+ this.items.push(toggle);
+ }
+
+ AddLabel(id, text)
+ {
+ const label = new SubMenuLabel(`${this.id}_${id}`, text);
+ label.AddToPanel(this.content);
+
+ this.items.push(label);
+ }
+
+ /**
+ * Adds a new separator to the menu with optional text.
+ * @param {string} id Id for this separator.
+ * @param {string?} text Text displayed on the separator.
+ */
+ AddSeparator(id, text = "")
+ {
+ const separator = new SubMenuSeparator(`${this.id}_${id}`, text);
+ separator.AddToPanel(this.content);
+ this.items.push(separator);
+ }
+
+ /**
+ * **CURRENTLY NOT SUPPORTED IN DEBUG_MENU.LUA**
+ * Adds a solid header to the category.
+ * @param {string} title Text displayed in the header.
+ */
+ AddHeader(title)
+ {
+ const header = CreatePanel("Panel", this.content, null, "header");
+ const label = CreatePanel("Label", header, null);
+ label.text = "This be header";
+ }
+
+ /**
+ * Adds a new slider to this category.
+ * @param {string} id The id for this slider.
+ * @param {string} text Text to display in the slider.
+ * @param {string} convar The convar to tie this slider to.
+ * @param {number} min Minimum value this slider can have.
+ * @param {number} max Maximum value this slider can have.
+ * @param {number} value Starting value for this slider.
+ * @param {boolean} isPercentage Value is displayed as a percentage instead of raw value.
+ * @param {number} truncate Number of decimal places the value can be set to (-1 for no truncating).
+ * @param {number} increment Increment value to snap to.
+ */
+ AddSlider(id, text, convar, min, max, value, isPercentage = true, truncate = -1, increment = 0)
+ {
+
+ let slider = new SubMenuSlider(`${this.id}_${id}`, convar, text, min, max, isPercentage, (value) => {
+ FireOutput("_DebugMenuCallbackSlider", id, value);
+ }, value, truncate, increment);
+ slider.AddToPanel(this.content);
+ this.items.push(slider);
+ }
+
+ /**
+ * Adds a new value cycler to this category.
+ * @param {string} id String id for this cycle.
+ * @param {string} convar Convar to tie this cycle to (currently unsued in JS).
+ * @param {SubMenuCycleItem[]} values Text/value pairs for this cycle.
+ * @param {string?} selectedValue Starting selected value.
+ */
+ AddCycle(id, convar, values, selectedValue)
+ {
+ let cycle = new SubMenuCycle(`${this.id}_${id}`, convar, values, (index) => {
+ FireOutput("_DebugMenuCallbackCycle", id, index + 1);
+ });
+ cycle.AddToPanel(this.content);
+ cycle.SetSelectedValueNoFire(selectedValue);
+ this.items.push(cycle);
+ }
+}
+
+class SubMenuButton
+{
+ constructor(id, text, callback)
+ {
+ this.id = id;
+ this.text = text;
+ this.callback = callback;
+ }
+
+ AddToPanel(panel)
+ {
+ this.panel = CreateDebugMenuButton(panel, this.callback, "ButtonTest", this.id);
+
+ let buttonLabel = $.CreatePanel("Label", this.panel, `${this.id}_label`);
+ buttonLabel.AddClass("button_label");
+ buttonLabel.text = this.text;
+
+ let buttonBullet = $.CreatePanel("Image", this.panel, `${this.id}_bullet`);
+ buttonBullet.AddClass("button_bullet");
+ buttonBullet.SetImage("s2r://panorama/images/game_menu_ui/btn_bullet_child_page_png.vtex")
+ }
+
+ SetText(text)
+ {
+ if (this.panel === undefined) return;
+
+ this.text = text;
+ let label = this.panel.FindChildTraverse(`${this.id}_label`);
+ label.text = text;
+ }
+}
+
+class SubMenuToggle
+{
+
+ constructor(id, text, startsOn, callback)
+ {
+ this.id = id;
+ this.text = text;
+ this.callback = callback;
+
+ this.isOn = startsOn;
+ }
+
+ /**
+ * Adds this toggle button to a panel.
+ * @param {Panel} panel The panel to add this button to.
+ */
+ AddToPanel(panel)
+ {
+ if (this.panel == null)
+ {
+ this.panel = CreateDebugMenuButton(panel, () => this.Toggle(), "ButtonTest", this.id);
+
+ this.panel.AddClass("custom_switch");
+ if (!this.isOn)
+ this.panel.AddClass("switch_off");
+
+ let row = $.CreatePanel("Panel", this.panel, undefined);
+ row.AddClass("row");
+
+ let switchButton = $.CreatePanel("Panel", row, undefined);
+ switchButton.AddClass("switch_button");
+
+ // Seems too much of a hassle to change Valve's styles so keeping two different labels
+ let labelOn = $.CreatePanel("Label", switchButton, `${this.id}_label_on`);
+ labelOn.AddClass("switch_label");
+ labelOn.AddClass("switch_label_on")
+ labelOn.text = `${this.text}`;
+ let labelOff = $.CreatePanel("Label", switchButton, `${this.id}_label_off`);
+ labelOff.AddClass("switch_label");
+ labelOff.AddClass("switch_label_off")
+ labelOff.text = `${this.text}`;
+
+ let switchButtonImage = $.CreatePanel("Panel", row, undefined);
+ switchButtonImage.AddClass("switch_button_image");
+ let switchImage = $.CreatePanel("Panel", switchButtonImage, undefined);
+ switchImage.AddClass("switch_image");
+ }
+ else
+ {
+ this.panel.SetParent(panel);
+ }
+
+ this.root = panel;
+ }
+
+ /**
+ * Sets the text of the toggle button on/off labels.
+ * @param {string} text The new text.
+ */
+ SetText(text)
+ {
+ if (this.panel === null) return;
+
+ this.text = text;
+ let labels = this.panel.FindChildrenWithClassTraverse("switch_label");
+ for (const label of labels)
+ {
+ label.text = text;
+ }
+ }
+
+ /**
+ * Sets the state of this toggle button.
+ * If the toggle button has a callback, it is called with the new state.
+ * @param {boolean} on If the toggle button should be on or off.
+ */
+ SetState(on)
+ {
+ this.isOn = on;
+ if (this.isOn)
+ {
+ this.panel.AddClass("switch_on")
+ this.panel.RemoveClass("switch_off")
+ }
+ else
+ {
+ this.panel.RemoveClass("switch_on")
+ this.panel.AddClass("switch_off")
+ }
+
+ if (this.callback != null)
+ {
+ this.callback(this.isOn);
+ }
+ }
+
+ /**
+ * Toggles the state of this toggle button.
+ * If the toggle button has a callback, it is called with the new state.
+ */
+ Toggle()
+ {
+ this.SetState(!this.isOn);
+ }
+}
+
+class SubMenuSlider
+{
+ constructor(id, convar, text, min, max, isPercentage, callback, currentValue = 0, truncate = -1, increment = 0) {
+ this.id = id;
+ this.convar = convar;
+ this.text = text;
+ this.min = min;
+ this.max = max;
+ this.isPercentage = false;
+ this.callback = callback;
+
+ this.truncate = truncate;
+ this.increment = increment;
+
+ this.value = currentValue;//Clamp(currentValue || 0, this.min, this.max);
+ /**@type {Panel} */
+ this.panel = null;
+ /**@type {Slider} */
+ this.slider = null;
+ }
+
+ AddToPanel(panel) {
+ this.panel = CreatePanel("Panel", panel, this.id, "SubMenuSlider");
+ this.slider = $.CreatePanelWithProperties("Slider", this.panel, "Slider", { class:"OptionsSlider", direction:"horizontal", });
+ this.slider.min = this.min;
+ this.slider.max = this.max;
+ const container = CreatePanel("Panel", this.slider, null, "SliderContainer");
+ const row = CreatePanel("Panel", container, "SliderRow", "SliderRow");
+ const textLabel = CreatePanel("Label", row, "Title", "slider_label");
+ textLabel.text = this.text;
+ CreatePanel("Panel", row, null, "slider_divider");
+ const valueLabel = CreatePanel("Label", row, "Value", "slider_value");
+
+ this.SetValue(this.value);
+
+ // This only works in VR
+ TurnButtonIntoDebugMenuButton(this.panel, () => {
+ const pos = GetAffordancePosition();
+ if (pos !== null) {
+ // Slider does not have actualxoffset or actuallayoutwidth THANKS AGAIN VALVE
+ // These magic numbers are estimates of where the slider is in relation to the parent panel
+ const xoffset = this.panel.actualxoffset + 25;
+ const width = this.panel.actuallayoutwidth - 25;
+ const val = RemapValueClamped(pos.x,
+ xoffset,
+ width,
+ this.slider.min,
+ this.slider.max
+ );
+ // Changing value automatically fires "onvaluechanged"
+ this.slider.value = val;
+ }
+ });
+
+ this.slider.SetPanelEvent("onvaluechanged", () => {
+ let prevValue = this.value;
+ this._SetValueInternal(this.slider.value);
+ if (this.value !== prevValue)
+ this.callback(this.value);
+ });
+ }
+
+ /**
+ * Sets the slider to a given value.
+ * @param {number} value
+ */
+ SetValue(value) {
+ this._SetValueInternal(value);
+ this.slider.SetValueNoEvents(this.value);
+ // this.slider.value = this.value;
+ }
+
+ /**
+ * Sets the internal value of the slider and updates labels.
+ * The visible slider is not updated.
+ * @param {number} value
+ */
+ _SetValueInternal(value) {
+ const valueLabel = this.panel.FindChildTraverse("Value");
+ if (this.increment > 0) value = Math.round(value / this.increment) * this.increment;
+ if (this.truncate > -1) value = parseFloat(value.toFixed(this.truncate));
+ value = Clamp(value, this.min, this.max);
+ this.value = value;
+
+ if (this.isPercentage)
+ valueLabel.text = this.GetValueAsPercentage().toFixed(0);
+ else
+ valueLabel.text = this.value.toFixed(this.truncate);
+ }
+
+ /**
+ *
+ * @returns {number}
+ */
+ GetValueAsPercentage() {
+ return RemapValueClamped(this.value, this.min, this.max, 0, 100);
+ }
+
+ SetText(text) {
+ const /**@type {Label} */ title = this.panel.FindChildTraverse("Title");
+ if (title)
+ title.text = text;
+ }
+}
+
+/**
+ * @typedef {Object} SubMenuCycleItem
+ * @property {string} text
+ * @property {string?} value
+ */
+
+class SubMenuCycle
+{
+ /**
+ *
+ * @param {string} id
+ * @param {string} convar
+ * @param {SubMenuCycleItem[]} values Maximum of 6 items
+ * @param {function} callback
+ * @param {number} selectedIndex
+ */
+ constructor(id, convar, values, callback, selectedIndex) {
+ this.id = id;
+ this.convar = convar;
+ this.values = values;//values.slice(0, 6);
+ this.callback = callback;
+
+ /** @type {Panel} */
+ this.panel = null;
+
+ this.selectedIndex = selectedIndex || 0;
+ }
+
+ AddToPanel(panel) {
+ /// Recreate GameMenuOptionCyclePanel hierarchy to preserve styles, with dynamically added items
+
+ this.panel = CreatePanel("Panel", panel, this.id, "cycler");
+ const row = CreatePanel("Panel", this.panel, null, "row");
+ const btnLeft = CreateDebugMenuButton(row, () => this.CycleLeft(), "cycle_button_left", "button_left");
+ CreatePanel("Panel", btnLeft, null, "cycle_image cycle_image_left");
+ const btnRight = CreateDebugMenuButton(row, () => this.CycleRight(), "cycle_button_right", "button_right");
+ const col = CreatePanel("Panel", btnRight, null, "cycle_button_right_col");
+ for (let [index,item] of this.values.entries()) {
+ /**@type {Label} */
+ const text = CreatePanel("Label", col, "item"+index, "cycle_label");
+ text.text = item.text;
+ }
+ CreatePanel("Label", col, "custom", "cycle_label").text = "Custom";
+ const dotRow = CreatePanel("Panel", col, null, "dot_row");
+ for (let [index,item] of this.values.entries()) {
+ /**@type {Label} */
+ const dot = CreatePanel("Label", dotRow, "dot"+index, "cycle_dots");
+ dot.text = " ■ ";
+ }
+ CreatePanel("Panel", dotRow);
+ const imgCont = CreatePanel("Panel", btnRight, null, "cycle_button_right_image");
+ CreatePanel("Panel", imgCont, null, "cycle_image cycle_image_right");
+ CreatePanel("Panel", this.panel);
+
+ this.SetSelectedIndexNoFire(this.selectedIndex);
+ }
+
+ /**
+ * Cycles to the left, wrapping around to the right if below `0`.
+ */
+ CycleLeft() {
+ if (this.selectedIndex == -1) this.selectedIndex = 0;
+ this.SetSelectedIndex(this.selectedIndex - 1);
+ }
+
+ /**
+ * Cycles to the right, wrapping around to the left if above `this.values.length`.
+ */
+ CycleRight() {
+ this.SetSelectedIndex(this.selectedIndex + 1);
+ }
+
+ /**
+ * Sets the selected option without firing the callback.
+ * @param {number} index The index of the option to select, starting from 0.
+ */
+ SetSelectedIndexNoFire(index) {
+ index = (index + this.values.length) % this.values.length;
+
+ for (let i = 0; i < this.values.length; i++) {
+ if (i == index) {
+ this.SetSelectedValueNoFire(this.values[i].value);
+ return;
+ }
+ }
+ }
+
+ /**
+ * Sets the selected option.
+ * @param {number} index The index of the option to select, starting from 0.
+ */
+ SetSelectedIndex(index) {
+ this.SetSelectedIndexNoFire(index);
+ this.callback(this.selectedIndex);
+ }
+
+ SetSelectedValueNoFire(value) {
+ let foundValue = false;
+
+ // Find the matching value index
+ for (let i = 0; i < this.values.length; i++) {
+ const _value = this.values[i].value;
+ const item = this.panel.FindChildTraverse("item" + i);
+ const dot = this.panel.FindChildTraverse("dot" + ((this.values.length-1) - i));
+ if (_value === value) {
+ item.visible = true;
+ dot.SetHasClass("cycle_dots_selected", true);
+ foundValue = true;
+ this.selectedIndex = i;
+ } else {
+ item.visible = false;
+ dot.SetHasClass("cycle_dots_selected", false);
+ }
+ }
+
+ // Reveal custom value if needed
+ const custom = this.panel.FindChildTraverse("custom");
+ if (foundValue) {
+ custom.visible = false;
+ } else {
+ this.selectedIndex = -1;
+ custom.text = `Custom (${value})`;
+ custom.visible = true;
+ }
+ }
+
+ SetSelectedValue(value) {
+ this.SetSelectedValueNoFire(value);
+ this.callback(this.selectedIndex);
+ }
+
+ /**
+ * Gets the left cycle button.
+ * @returns {Button}
+ */
+ GetLeftButton() {
+ return this.panel.FindChildTraverse("button_left");
+ }
+
+ /**
+ * Gets the right cycle button.
+ * @returns {Button}
+ */
+ GetRightButton() {
+ return this.panel.FindChildTraverse("button_right");
+ }
+
+ /**
+ * Gets the currently selected item.
+ * @returns {Panel}
+ */
+ GetSelectedItem() {
+ for (let i = 0; i < this.values.length; i++) {
+ const item = this.panel.FindChildTraverse("item" + i);
+ if (item.visible) return item;
+ }
+ }
+
+ /**
+ * Gets the currently selected index.
+ * @returns {number}
+ */
+ GetSelectedIndex() {
+ return this.selectedIndex;
+ }
+}
+
+class SubMenuSeparator
+{
+ /**
+ * Creates a new sub menu separator instance.
+ * @param {string} id Id for this separator.
+ * @param {string?} text Text to display with this separator.
+ */
+ constructor(id, text = "")
+ {
+ this.id = id;
+ this.text = text;
+
+ /**@type {Panel} */
+ this.panel = null;
+ }
+
+ /**
+ * Creates all required elements as children of `panel`.
+ * @param {Panel} panel Panel to add this separator to.
+ */
+ AddToPanel(panel)
+ {
+ this.panel = CreatePanel("Panel", panel, this.id, "options_divider");
+ const label = CreatePanel("Label", this.panel, null);
+ label.text = this.text;
+ CreatePanel("Panel", this.panel, null, "horizontal_line");
+ }
+
+ /**
+ * Sets or removes the text displayed with this separator.
+ * @param {string?} text The text to display with this separator.
+ */
+ SetText(text = "")
+ {
+ this.text = text;
+ const label = this.panel.GetChild(0);
+ if (label) {
+ label.text = text;
+ }
+ }
+
+ /**
+ * Gets the text displayed with this separator.
+ * @returns {string}
+ */
+ GetText()
+ {
+ return this.text;
+ }
+}
+
+class SubMenuLabel
+{
+ /**
+ * Creates a new sub menu label instance.
+ * @param {string} id Id for this label.
+ * @param {string} text Text to display with this label.
+ */
+ constructor(id, text)
+ {
+ this.id = id;
+ this.text = text;
+
+ /**@type {Panel} */
+ this.panel = null;
+ }
+
+ /**
+ * Creates all required elements as children of `panel`.
+ * @param {Panel} panel Panel to add this label to.
+ */
+ AddToPanel(panel)
+ {
+ this.panel = CreatePanel("Label", this.content, this.id, "custom_label");
+ this.panel.text = this.text;
+ }
+
+ /**
+ * Sets or removes the text displayed with this label.
+ * @param {string?} text The text to display with this label.
+ */
+ SetText(text = "")
+ {
+ this.text = text;
+ this.panel.text = text;
+ }
+}
+
+/**
+ * Shows a specific category and hides all others.
+ * @param {string} id ID of the category to show.
+ */
+function SetCategoryVisible(id)
+{
+ for (const category of categories)
+ {
+ if (category.id == id)
+ {
+ // category.AddClass("Visible");
+ category.SetVisible(true);
+ currentlySelectedCategory = category;
+ }
+ else
+ {
+ // category.RemoveClass("Visible");
+ category.SetVisible(false);
+ }
+ }
+}
+
+
+/**
+ * Cycles the visible category in the direction given.
+ * @param {number} direction -1 to cycle left, 1 to cycle right.
+ * @see alyxlib_debug_menu.xml CategoryCycler for its usage.
+ */
+function CycleCategories(direction)
+{
+ direction = Math.sign(direction);
+ if (direction === 0) return;
+
+ const currentIndex = categories.indexOf(currentlySelectedCategory);
+ const previousCategory = categories[(currentIndex + direction + categories.length) % categories.length];
+ SetCategoryVisible(previousCategory.id);
+
+ UpdateCategoryBarVisibility();
+}
+
+/**
+ * Updates the visibility of the category bar buttons so that the selected category is
+ * within the visible range.
+ */
+function UpdateCategoryBarVisibility()
+{
+ const selectedIndex = categories.indexOf(currentlySelectedCategory);
+ if (selectedIndex < categoryBarCycleIndex) {
+ categoryBarCycleIndex = selectedIndex;
+ } else if (selectedIndex >= categoryBarCycleIndex + numberOfVisibleCategories) {
+ categoryBarCycleIndex = selectedIndex - numberOfVisibleCategories + 1;
+ }
+
+ categories.forEach((category, index) => {
+ const isVisible = index >= categoryBarCycleIndex && index < categoryBarCycleIndex + numberOfVisibleCategories;
+ category.SetBarButtonVisible(isVisible);
+ })
+}
+
+/**
+ * Creates a new category and sets it as the currently selected category if no category is currently selected.
+ *
+ * @param {string} id - The unique identifier for the category.
+ * @param {string} name - The display name for the category.
+ * @returns {Category} The newly created category.
+ */
+
+function CreateCategory(id, name)
+{
+ let category = new Category(id, name);
+
+ categories.push(category);
+
+ if (currentlySelectedCategory == null)
+ SetCategoryVisible(category.id);
+
+ return category;
+}
+
+/**
+ * Finds a category by its ID.
+ * @param {string} id - The unique identifier for the category.
+ * @returns {Category} The category with the given ID, or null if none is found.
+ */
+function GetCategory(id)
+{
+ for (const category of categories)
+ {
+ if (category.id == id) return category;
+ }
+
+ return null;
+}
+
+/**
+ * Sends the _CloseMenu command to Lua.
+ * @see alyxlib_debug_menu.xml CloseMenuButton for its usage.
+ */
+function CloseMenu()
+{
+ FireOutput("_CloseMenu");
+}
+
+/**
+ * Gets the position of the left or right 'affordance' circle for the VR finger interacting with the menu.
+ * @returns {{x:number,y:number}?}
+ */
+function GetAffordancePosition() {
+ const left = $('#vr_affordance_left');
+ if (left.visible)
+ return { x: left.actualxoffset, y: left.actualyoffset };
+
+ const right = $('#vr_affordance_left');
+ if (right.visible)
+ return { x: right.actualxoffset, y: right.actualyoffset };
+
+ return null;
+}
+
+/**
+ * Remaps a number from one range to another.
+ * @param {number} value - The input value to remap.
+ * @param {number} low1 - Lower bound of the input range.
+ * @param {number} high1 - Upper bound of the input range.
+ * @param {number} low2 - Lower bound of the output range.
+ * @param {number} high2 - Upper bound of the output range.
+ * @returns {number} The remapped value in the output range.
+ */
+function RemapValue(value, low1, high1, low2, high2) {
+ return low2 + (high2 - low2) * (value - low1) / (high1 - low1);
+}
+
+/**
+ * Remaps a number from one range to another while clamping within the range.
+ * @param {number} value - The input value to remap.
+ * @param {number} low1 - Lower bound of the input range.
+ * @param {number} high1 - Upper bound of the input range.
+ * @param {number} low2 - Lower bound of the output range.
+ * @param {number} high2 - Upper bound of the output range.
+ * @returns {number} The remapped value in the output range.
+ */
+function RemapValueClamped(value, low1, high1, low2, high2){
+ return RemapValue(Clamp(value, low1, high1), low1, high1, low2, high2);
+}
+
+/**
+ * Clamps a number between a minimum and maximum value.
+ * @param {number} value - The value to clamp.
+ * @param {number} min - The minimum allowable value.
+ * @param {number} max - The maximum allowable value.
+ * @returns {number} The clamped value.
+ */
+function Clamp(value, min, max) {
+ return Math.min(Math.max(value, min), max);
+}
+
+/**
+ * Virtually clicks the currently active button.
+ */
+function ClickHoveredButton()
+{
+ if (currentlyActiveButton !== null)
+ {
+ const button = currentlyActiveButton;
+ // $.Msg(`Pressing ${button.id} : ${button.paneltype}`);
+ $.DispatchEvent("Activated", button, "mouse");
+ }
+}
+
+/**
+ * Parses the incoming Lua command.
+ * @param {string} command
+ * @param {string[]} args
+ */
+function ParseCommand(command, args)
+{
+ command = command.toLowerCase();
+
+ switch (command)
+ {
+ case "addcategory": {
+ let id = args[0];
+ let name = args[1];
+ CreateCategory(id, name);
+ break;
+ }
+
+ case "addbutton": {
+ let category = GetCategory(args[0]);
+ if (category === null)
+ {
+ $.Msg(`Category ${args[0]} does not exist!`);
+ break;
+ }
+
+ let buttonId = args[1];
+ let buttonText = args[2];
+ category.AddButton(buttonId, buttonText);
+ break;
+ }
+
+ case "addtoggle": {
+ let category = GetCategory(args[0]);
+ if (category === null)
+ {
+ $.Msg(`Category ${args[0]} does not exist!`);
+ break;
+ }
+
+ let toggleId = args[1];
+ let toggleText = args[2] || args[1];
+ let toggleStartsOn = args[3] === "true";
+ category.AddToggle(toggleId, toggleText, toggleStartsOn);
+ break;
+ }
+
+ case "addlabel": {
+ let category = GetCategory(args[0]);
+ if (category === null)
+ {
+ $.Msg(`Category ${args[0]} does not exist!`);
+ break;
+ }
+
+ let labelId = args[1];
+ let labelText = args[2] || args[1];
+ category.AddLabel(labelId, labelText);
+ break;
+ }
+
+ case "addseparator": {
+ let category = GetCategory(args[0]);
+ if (category === null)
+ {
+ $.Msg(`Category ${args[0]} does not exist!`);
+ break;
+ }
+
+ const id = args[1];
+ const text = args[2];
+ category.AddSeparator(id, text);
+ break;
+ }
+
+ case "addslider": {
+ const category = GetCategory(args[0]);
+ if (category === null)
+ {
+ $.Msg(`Category ${args[0]} does not exist!`);
+ break;
+ }
+
+ const id = args[1];
+ const text = args[2] || args[3];
+ const convar = args[3];
+ const min = parseFloat(args[4]);
+ const max = parseFloat(args[5]);
+ const value = parseFloat(args[6]);
+ const isPercentage = args[7] == "true";
+ const truncate = parseInt(args[8]);
+ const increment = parseFloat(args[9]);
+ category.AddSlider(id, text, convar, min, max, value, isPercentage, truncate, increment);
+ break;
+ }
+
+ case "addcycle":
+ const category = GetCategory(args[0]);
+ if (category === null)
+ {
+ $.Msg(`Category ${args[0]} does not exist!`);
+ break;
+ }
+
+ const id = args[1];
+ const convar = args[2];
+ const currentValue = args[3];
+ const rawValues = args.slice(4);
+ /**@type {SubMenuCycleItem[]} */
+ const values = [];
+ for (let i = 0; i < rawValues.length; i+=2) {
+ values.push({
+ text: rawValues[i],
+ value: rawValues[i+1]
+ });
+ }
+
+ category.AddCycle(id, convar, values, currentValue);
+ break;
+
+ case "setitemtext": {
+ let category = GetCategory(args[0]);
+ if (category === null)
+ {
+ $.Msg(`Category ${args[0]} does not exist!`);
+ break;
+ }
+
+ let id = args[1];
+ let text = args[2];
+ category.SetItemText(id, text);
+ break;
+ }
+
+ case "clickhoveredbutton": {
+ ClickHoveredButton();
+ break;
+ }
+
+ case "removeallcategories": {
+ categories.forEach((category) => category.Delete());
+ categories = [];
+ break;
+ }
+
+ case "setcategoryindex": {
+ let category = GetCategory(args[0]);
+ if (category === null)
+ {
+ $.Msg(`Category ${args[0]} does not exist!`);
+ break;
+ }
+
+ const index = parseInt(args[1]);
+ const categoryBar = $("#CategoryBar");
+ const childToMove = category.button;
+ if (index <= 0){
+ // Move to front
+ let firstChild = categoryBar.GetChild(0);
+ categoryBar.MoveChildBefore(childToMove, firstChild);
+ } else {
+ // Move after the previous child
+ let prevChild = categoryBar.GetChild(index - 1);
+ categoryBar.MoveChildAfter(childToMove, prevChild)
+ }
+
+ const currentPos = categories.indexOf(category);
+ if (currentPos !== -1 && index >= 0 && index < categories.length) {
+ categories.splice(currentPos, 1);
+ categories.splice(index, 0, category);
+ }
+ }
+ }
+}
+
+let scrollHelperScheduleCancel = false;
+let scrollHelperScheduleEvent = "";
+let scrollHelperSpeed = 0.1;
+
+/**
+ * Scroll logic for the scroll helper schedule.
+ */
+function ScrollHelperSchedule() {
+ if (scrollHelperScheduleCancel || scrollHelperScheduleEvent === "" || currentlySelectedCategory === null) {
+ scrollHelperScheduleCancel = false;
+ return;
+ }
+
+ $.DispatchEvent(scrollHelperScheduleEvent, currentlySelectedCategory.panel);
+ $.Schedule(scrollHelperSpeed, ScrollHelperSchedule);
+}
+
+/**
+ * Start scrolling the category page in a direction.
+ * @param {"ScrollDown"|"ScrollUp"|string} eventName Name of the event to fire on the current category.
+ */
+function StartScrollHelper(eventName) {
+ if (scrollHelperScheduleEvent !== eventName) {
+ scrollHelperScheduleEvent = eventName;
+ $.Schedule(scrollHelperSpeed, ScrollHelperSchedule);
+ }
+}
+
+/**
+ * Stop scroll the category page.
+ */
+function StopScrollHelper() {
+ scrollHelperScheduleCancel = true;
+ scrollHelperScheduleEvent = "";
+}
+
+function ScrollHelperClick() {
+ if (currentlySelectedCategory === null) return;
+
+ switch (scrollHelperScheduleEvent){
+ case "ScrollDown":
+ $.DispatchEvent("ScrollToBottom", currentlySelectedCategory.panel);
+ break;
+
+ case "ScrollUp":
+ $.DispatchEvent("ScrollToTop", currentlySelectedCategory.panel);
+ break;
+ }
+
+}
+
+(function()
+{
+ // Modify preset layout buttons to work with controller trigger
+ TurnButtonIntoDebugMenuButton($("#CloseMenuButton"));
+ TurnButtonIntoDebugMenuButton($("#CycleCategoryLeftButton"));
+ TurnButtonIntoDebugMenuButton($("#CycleCategoryRightButton"));
+ TurnButtonIntoDebugMenuButton($("#ScrollHelperDown"));
+ TurnButtonIntoDebugMenuButton($("#ScrollHelperUp"));
+
+ // Tells Lua that the menu has been reloaded so it can repopulate the menu
+ // This helps with hot reloading panel changes
+ $.Schedule(0.1, () => FireOutput("_DebugMenuReloaded"));
+
+ // Scroll helpers for sub-menus
+ // Valve kindly didn't allow us to raytrace click panels like the main menu
+ // so this is a work around for scrolling
+ $('#ScrollHelperDown').SetPanelEvent("onmouseover", () => StartScrollHelper("ScrollDown"));
+ $('#ScrollHelperDown').SetPanelEvent("onmouseout", () => StopScrollHelper());
+ $('#ScrollHelperDown').SetPanelEvent("onactivate", ScrollHelperClick);
+ $('#ScrollHelperUp').SetPanelEvent("onmouseover", () => StartScrollHelper("ScrollUp"));
+ $('#ScrollHelperUp').SetPanelEvent("onmouseout", () => StopScrollHelper());
+ $('#ScrollHelperUp').SetPanelEvent("onactivate", ScrollHelperClick);
+
+ $.Schedule(1.0, () => panelReady = true);
+
+})();
\ No newline at end of file
diff --git a/panorama/scripts/custom_game/panorama_lua.js b/panorama/scripts/custom_game/panorama_lua.js
index ca34b6e..d9925a5 100644
--- a/panorama/scripts/custom_game/panorama_lua.js
+++ b/panorama/scripts/custom_game/panorama_lua.js
@@ -22,12 +22,13 @@ if(false)p=require("./panoramadoc");
* @param {null} _ Unused variable.
* @param {string} encoded String of data separated by a pipe.
*/
-function LuaCallback(_,encoded)
+function LuaCallback(_, encoded)
{
- $.Msg(encoded)
+ // $.Msg(encoded)
let decoded = encoded.split("|");
let panel = $.GetContextPanel();
//$.Msg(decoded[0], panel.BHasClass(decoded[0]))
+
// Check if the data being sent belongs to this panel
if (panel.BHasClass(decoded[0]))
{
@@ -58,7 +59,6 @@ function LuaCallback(_,encoded)
(function()
{
- $.Msg("panorama_lua.js loaded")
- $.RegisterForUnhandledEvent('AddStyleToEachChild',LuaCallback);
+ $.RegisterForUnhandledEvent('AddStyleToEachChild', LuaCallback);
})();
diff --git a/panorama/scripts/custom_game/panoramadoc.js b/panorama/scripts/custom_game/panoramadoc.js
index 4bbecfe..9980027 100644
--- a/panorama/scripts/custom_game/panoramadoc.js
+++ b/panorama/scripts/custom_game/panoramadoc.js
@@ -40,7 +40,7 @@
* "oncontextmenu_hide" | "oncontextmenu_show" | "oncontextmenu_query" | "oncontextmenu_refresh" |
* "ondatechanged" | "onmodalaccepted" | "onmodalcanceled" | "onmodalprompt" | "onmodalselect" |
* "onmodalsubmit" | "onselectionchange" | "onselectquery" | "onsliding" | "onsubmenuswitch" |
-* "ontooltiphide" | "ontooltipshow")} Event
+* "ontooltiphide" | "ontooltipshow")} PanoramaEvent
*/
@@ -236,13 +236,15 @@ class $ {
/**
* Global selector function used to find panels by id.
* @param {string} searchString String used to find a panel.
- * @returns {Panel}
+ * @returns {AllPanelTypes}
* @example
* let panel = $('#MyPanelID')
*/
// @ts-ignore
function $(searchString){return}
+/**@typedef {Panel|Label|Button|TextButton|RadioButton|ToggleButton|DropDown|ProgressBar|CircularProgressBar|Countdown|TextEntry|SlottedSlider|Slider|NumberEntry|Image|Carousel|Grid|Movie|HTML} AllPanelTypes */
+
/**
* Panel class.
*/
@@ -409,6 +411,12 @@ class Panel {
*/
isValid;
+ /**
+ * Unknown.
+ * @type {string}
+ */
+ paneltype;
+
/**
* Adds a class to this panel.
* @param {string} className Name of class to add.
@@ -470,7 +478,13 @@ class Panel {
GetChild(int){}
GetChildIndex(unknown){}
Children(){}
- FindChildrenWithClassTraverse(className){}
+
+ /**
+ * Find children with a given class name.
+ * @param {string} className Class to search for
+ * @returns {Panel[]} Panels with the class
+ */
+ FindChildrenWithClassTraverse(className){return}
/**
* Gets the parent panel.
@@ -485,6 +499,11 @@ class Panel {
SetParent(panel){}
FindChild(str){}
+ /**
+ *
+ * @param {string} str Id to look for.
+ * @returns {Panel?}
+ */
FindChildTraverse(str){}
FindChildInLayoutFile(str){}
FindPanelInLayoutFile(str){}
@@ -536,16 +555,24 @@ class Panel {
SetReadyForDisplay(bool){}
SetPositionInPixels(float1, float2, float3){}
Data(unknown){}
- SetPanelEvent(unknown){}
- RunScriptInPanelContext(unknown){}
- rememberchildfocus(bool){}
- paneltype(){}
-
-
+
/**
- * Label members.
+ * Sets an event handler for a Panorama UI panel.
+ * @param {Event} event The event to listen for.
+ * @param {function} callback The function to execute when the event is triggered.
+ * @example
+ * myButton.SetPanelEvent("onactivate", () => {
+ * $.Msg("Button pressed!");
+ * });
*/
+ SetPanelEvent(event, callback){}
+ RunScriptInPanelContext(unknown){}
+ rememberchildfocus(bool){}
+}
+
+class Label extends Panel
+{
/**
* Text of the panel.
* @type {string}
@@ -569,35 +596,75 @@ class Panel {
* @param {string} text
*/
SetAlreadyLocalizedText(text){}
+}
- /**
- * ToggleButton members.
- */
+class Button extends Panel
+{
+}
+
+class TextButton extends Button
+{
+ text;
+}
+
+class RadioButton extends Panel
+{
+ GetSelectedButton(){}
+ group;
+}
+class ToggleButton extends Panel
+{
/**
* Set if the button is selected or not.
* @param {boolean} selected
*/
SetSelected(selected){}
+}
-
+class DropDown extends Panel
+{
/**
- * DropDown members.
+ * Adds a panel to the drop down.
+ * @param {Panel} panel The panel to add.
*/
+ AddOption(panel){}
+ /**
+ * Checks if this drop down has an option with a given id.
+ * @param {string} id Id of the panel to search for.
+ * @returns {boolean} True if this drop down as the panel.
+ */
+ HasOption(id){}
+
+ /**
+ *
+ * @param {unknown} unknown
+ */
+ RemoveOption(unknown){}
- AddOption(){}
- HasOption(){}
- RemoveOption(){}
+ /**
+ * Removes all options from this drop down.
+ */
RemoveAllOptions(){}
+
+ /**
+ * Gets the currently selected panel.
+ * @return {Panel?} The currently selected panel.
+ */
GetSelected(){}
FindDropDownMenuChild(){}
AccessDropDownMenu(){}
-
+
/**
- * ProgressBar members.
+ * Sets a panel as selected either by object reference or id.
+ * @param {Panel|string} panel The panel or id to set as selected.
*/
+ SetSelected(panel){}
+}
+class ProgressBar extends Panel
+{
/**
* @type {number}
*/
@@ -613,11 +680,39 @@ class Panel {
*/
max;
+ // exist?
+ // hasNotches;
+ // valuePerNotch;
+}
+
+class CircularProgressBar extends Panel
+{
+ /**
+ * @type {number}
+ */
+ value;
/**
- * TextEntry members.
+ * @type {number}
+ */
+ min;
+
+ /**
+ * @type {number}
*/
+ max;
+}
+
+class Countdown extends Panel
+{
+ startTime = 0;
+ endTime = 0;
+ updateInterval = 1;
+ timeDialogVariable = 'countdown_time';
+}
+class TextEntry extends Panel
+{
SetMaxChars(){}
GetMaxCharCount(){}
GetCursorOffset(){}
@@ -625,42 +720,63 @@ class Panel {
ClearSelection(){}
SelectAll(){}
RaiseChangeEvents(){}
-
+}
- /**
- * SlottedSlider/Slider members.
- */
+class SlottedSlider extends Slider
+{
+
+}
+class Slider extends Panel
+{
+ value;
+ min;
+ max;
increment;
default;
mousedown;
- SetDirection(){}
- SetShowDefaultValue(){}
- SetRequiresSelection(){}
- SetValueNoEvents(){}
-
-
- /**
- * Image members.
- */
+ SetDirection(unknown){}
+ SetShowDefaultValue(showDefaultValue){}
+ SetRequiresSelection(requiresSelection){}
+ SetValueNoEvents(value){}
+}
- SetImage(){}
- SetScaling(){}
-
+class NumberEntry extends Panel
+{
+ value;
+ min;
+ max;
+ increment;
+}
- /**
- * Carousel members.
- */
+class Image extends Panel
+{
+ SetImage(path){}
+ SetScaling(unknown){}
+}
+class Carousel extends Panel
+{
SetSelectedChild(){}
GetFocusChild(){}
GetFocusIndex(){}
-
+}
- /**
- * Movie members.
- */
+class Grid extends Panel
+{
+ verticalcount;
+ horizontalcount;
+ focusmargin;
+ scrolldirection;
+ scrollprogress;
+ SetIgnoreFastMotion(){}
+ GetFocusedChildVisibleIndex(){}
+ ScrollPanelToLeftEdge(){}
+ MoveFocusToTopLeft(){}
+}
+class Movie extends Panel
+{
SetMovie(){}
SetControls(){}
SetTitle(){}
@@ -670,18 +786,14 @@ class Panel {
SetRepeat(){}
SetPlaybackVolume(){}
BAdjustingVolume(){}
-
-
}
-// class Label extends Panel
-// {
-// /**
-// * Text of the panel, if the panel is a Label.
-// * @type {string}
-// */
-// text;
-// }
+class HTML extends Panel
+{
+ SetURL(){}
+ RunJavascript(){}
+ SetIgnoreCursor(){}
+}
// /**
// * @alias $
diff --git a/panorama/styles/custom_game/alyxlib_debug_menu.css b/panorama/styles/custom_game/alyxlib_debug_menu.css
new file mode 100644
index 0000000..57cf28d
--- /dev/null
+++ b/panorama/styles/custom_game/alyxlib_debug_menu.css
@@ -0,0 +1,389 @@
+#background_panel.bg
+{
+ /*opacity-mask: url("s2r://panorama/images/game_menu_ui/menu_type_mask_psd.vtex");*/
+ flow-children: down;
+ width: 100%;
+ background-color: gradient( radial, 50% 50%, 0% 0%, 100% 100%, from( #553c18 ), color-stop( 0.97, #000000 ), to( #000000 ) );
+ box-shadow: inset #995e0011 2px 2px 4px 4px;
+ opacity: .9;
+}
+#background_panel .row .col_body
+{
+ /*overflow: scroll;*/
+}
+Panel.submenu
+{
+ width: 100%;
+ border-right: 1px solid #FFBE5544;
+ flow-children: down;
+ visibility: collapse;
+ height: 100%;
+}
+Panel.submenu.scroll
+{
+ overflow: squish scroll;
+}
+Panel.submenu.Visible
+{
+ visibility: visible;
+}
+Panel.submenu Panel.content
+{
+ width: 100%;
+ flow-children: down;
+ height: fit-children;
+}
+Panel.submenu.scroll Panel.content
+{
+ padding-right: 50px;
+}
+TextEntry
+{
+ width: 100%;
+ flow-children: none;
+ border: 1px solid #FFBE5588;
+ vertical-align: center;
+ text-transform: uppercase;
+ text-align: right;
+ font-size: 44px;
+ height: 100px;
+ margin-left: 24px;
+ margin-right: 24px;
+}
+TextEntry Label
+{
+ width: 100%;
+ vertical-align: center;
+ text-transform: uppercase;
+ text-align: right;
+ font-size: 44px;
+ margin-left: 24px;
+ margin-right: 24px;
+}
+
+TextEntry:hover
+{
+ color: #fff;
+ background-color: #FFBE5588;
+ transition-property: background-color;
+ transition-duration: .38s;
+ transition-timing-function: cubic-bezier( 0.785, 0.385, 0.555, 1.505 );
+
+}
+
+TextEntry:active
+{
+ color: #000000;
+ background-color: #ffd800;
+}
+
+Button.custom_button_with_subtitle.error_msg.disabled, Button.custom_button_with_subtitle:not(.error_msg)
+{
+ flow-children: down;
+ height: 44px;
+}
+Button.custom_button_with_subtitle.error_msg.disabled Label.button_label,Button.custom_button_with_subtitle:not(.error_msg) Label.button_label
+{
+ padding: 0;
+ margin: 0;
+}
+Button.custom_button_with_subtitle.error_msg.disabled Label.button_label.sub,Button.custom_button_with_subtitle:not(.error_msg) Label.button_label.sub
+{
+ padding: 0;
+ margin: 0;
+ font-size: 20px;
+}
+Button.custom_button_with_subtitle.error_msg:not(.disabled) Label.button_label.sub
+{
+ visibility: collapse;
+}
+
+#CategoryCycler
+{
+ flow-children: right;
+ width: 100%;
+ /* background-color: green; */
+}
+
+.CycleCategory
+{
+ /* margin-left: 9px; */
+ flow-children: left;
+ /* horizontal-align: right; */
+ width: 100%;
+}
+.CycleCategoryLeft
+{
+ flow-children: left;
+ horizontal-align: left;
+ overflow: clip clip;
+ /* background-color: red; */
+ width: 10%;
+}
+.CycleCategoryRight
+{
+ flow-children: left;
+ horizontal-align: right;
+ overflow: clip clip;
+ /* background-color: blue; */
+ width: 10%;
+}
+/* .cycle_image
+{
+ horizontal-align: left;
+ margin: 0px 40px 10px 10px;
+} */
+
+.CategoryBar
+{
+ flow-children: right;
+ /* background-color: yellow; */
+ width: fill-parent-flow(1);
+ height: 100%;
+ opacity: 0.5;
+}
+.CategoryButton
+{
+ width: 150px;
+ height: 100%;
+ /* background-color: red; */
+ horizontal-align: left;
+
+ border-bottom: 5px solid #FFBE55;
+ margin: 0px 5px 0px 5px;
+}
+.CategoryButton.Selected
+{
+ color: #fff;
+ background-color: #FFBE5588;
+}
+.CategoryButton Label
+{
+ text-align: left;
+ horizontal-align: center;
+ white-space: normal;
+ text-overflow: ellipsis;
+ line-height: 20px;
+ overflow: clip;
+ font-size: 30px;
+ text-align: center;
+ margin: 5px;
+ /* background-color: green; */
+}
+.CategoryButton.flash {
+ animation-name: FlashOnce;
+ animation-duration: 0.5s;
+ animation-timing-function: ease;
+ animation-iteration-count: 1;
+}
+
+@define flashColor: #FFBE5588;
+
+@keyframes 'FlashOnce'
+{
+ 0% {
+ background-color: flashColor;
+ transform: translateY(0);
+ }
+ 30% {
+ background-color: flashColor;
+ transform: translateY(-20px);
+ }
+ 50% {
+ background-color: #FFBE5544;
+ transform: translateY(5px);
+ }
+ 70% {
+ background-color: #FFBE5511;
+ transform: translateY(-8px);
+ }
+ 100% {
+ background-color: transparent;
+ transform: translateY(0);
+ }
+}
+
+Button.disabled Label
+{
+ color: #FFBE55;
+ blur: gaussian(1.5);
+ brightness: 0.75;
+ opacity: .7;
+}
+
+Button:active
+{
+ sound: "PanoUI.Click";
+}
+
+Button.custom_switch:active
+{
+ sound: "PanoUI.ToggleOption";
+}
+
+.custom_switch
+{
+ width: 100%;
+ min-height: 80px;
+ max-height: 90px;
+ /* This is confusing. Why can't I apply this style further up the heirarchy? */
+ flow-children: right;
+ horizontal-align: right;
+ text-align: right;
+ /* Hover 'Off' values */
+ background-color: #FFBE5500;
+ transition-property: background-color;
+ transition-duration: 0s;
+}
+
+.custom_switch Label
+{
+ vertical-align: center;
+ horizontal-align: right;
+ font-size: 44px;
+ text-transform: uppercase;
+ /* text-shadow: 0px 0px 64px 3 #f4ec8e33; */
+ text-align: right;
+}
+
+.custom_switch:hover
+{
+ color: #fff;
+ background-color: #FFBE5588;
+ transition-property: background-color;
+ transition-duration: .38s;
+ transition-timing-function: cubic-bezier( 0.785, 0.385, 0.555, 1.505 );
+
+}
+
+.custom_switch:active
+{
+ background-color: #FFBE55;
+ color: #000;
+ transition-property: background-color;
+ transition-duration: 0s;
+}
+
+Button
+{
+ min-height: 80px;
+}
+
+.button_bullet
+{
+ padding-right: 5px;
+}
+
+.button_label
+{
+ padding-right: 30px;
+}
+
+
+/* .submenu Label
+{
+ font-size: 33px;
+} */
+
+Label.custom_label
+{
+ text-align: center;
+ horizontal-align: center;
+ font-size: 30px;
+ padding-left: 24px;
+ padding-right: 24px;
+
+}
+
+.col_body
+{
+ /* Allows the scroll helper to show */
+ min-height: 657px;
+}
+
+.scroll_helper {
+ width: 100%;
+ height: 36px;
+ min-height: 0px;
+ flow-children:down;
+}
+.scroll_helper.down {
+ border-top: 2px solid #FFBE5577;
+}.scroll_helper.up {
+ border-bottom: 2px solid #FFBE5577;
+}
+.scroll_helper.down .chevron_arrow{
+ transform: rotateZ( 90deg );
+}
+.scroll_helper.up .chevron_arrow{
+ transform: rotateZ( -90deg );
+}
+
+.chevron_arrow{
+ margin-left: 10px;
+ margin-right: 10px;
+ background-color: #FFBE55;
+ height: 32px;
+ width: 32px;
+ horizontal-align: center;
+ vertical-align: center;
+
+ opacity-mask: url("s2r://panorama/images/upgrade_station_ui/crafting_station_chevron_mask_psd.vtex");
+}
+.ScrollDownButton:hover .chevron_arrow{
+ background-color:#fff;
+}
+
+.cycler
+{
+ width: 100%;
+
+ flow-children: right;
+ horizontal-align: right;
+ text-align: right;
+ background-color: #FFBE5500;
+ transition-property: background-color;
+ transition-duration: 0s;
+}
+
+/* Bring styles over from HLVR_SettingsSlider to custom .SubMenuSlider */
+
+.SubMenuSlider
+{
+ width: 100%;
+ flow-children: none;
+}
+
+.SubMenuSlider:hover
+{
+ color: #fff;
+ /*background-color: #FFBE5588;*/
+ transition-property: background-color;
+ transition-duration: .38s;
+ transition-timing-function: cubic-bezier( 0.785, 0.385, 0.555, 1.505 );
+}
+
+.SubMenuSlider:active
+{
+ color: #000000;
+ background-color: #ffd800;
+}
+
+.SubMenuSlider:hover .OptionsSlider #SliderTrackProgress
+{
+ width: 100%;
+ height: 85%;
+ vertical-align: center;
+ border: 1px solid #FFBE5588;
+ background-color: #FFBE5588;
+}
+
+.Background .SubMenuSlider
+{
+ opacity: .1;
+}
+
+/* Make slider value fit even with many decimal places */
+.slider_value {
+ text-overflow: shrink;
+}
\ No newline at end of file
diff --git a/scripts/vscripts/alyxlib/class.lua b/scripts/vscripts/alyxlib/class.lua
index 41579f9..76bc6e6 100644
--- a/scripts/vscripts/alyxlib/class.lua
+++ b/scripts/vscripts/alyxlib/class.lua
@@ -1,5 +1,5 @@
--[[
- v2.3.0
+ v2.3.1
https://github.com/FrostSource/alyxlib
If not using `vscripts/alyxlib/core.lua`, load this file at game start using the following line:
@@ -74,7 +74,7 @@
end
```
]]
-local version = "v2.3.0"
+local version = "v2.3.1"
require "alyxlib.storage"
require "alyxlib.globals"
@@ -301,9 +301,16 @@ end
function inherit(script, entity)
local fenv = entity or getfenv(2)
if fenv.thisEntity == nil then
- fenv = getfenv(3)
- if fenv.thisEntity == nil then
- error("Could not inherit '"..script.."' because thisEntity could not be found!")
+ -- If given exact entity, get scope of it
+ if IsEntity(entity) then
+ ---@cast entity -nil
+ fenv = entity:GetOrCreatePrivateScriptScope()
+ else
+ -- Check further up environment
+ fenv = getfenv(3)
+ if fenv.thisEntity == nil then
+ error("Could not inherit '"..tostring(script).."' because thisEntity could not be found!")
+ end
end
end
local self = fenv.thisEntity
@@ -469,15 +476,19 @@ end
---Resume the entity think function.
---@luadoc-ignore
function EntityClass:ResumeThink()
- self:SetContextThink("__EntityThink", function() return self:Think() end, 0)
- self.IsThinking = true
+ if not self:IsNull() then
+ self:SetContextThink("__EntityThink", function() return self:Think() end, 0)
+ self.IsThinking = true
+ end
end
---Pause the entity think function.
---@luadoc-ignore
function EntityClass:PauseThink()
- self:SetContextThink("__EntityThink", nil, 0)
- self.IsThinking = false
+ if not self:IsNull() then
+ self:SetContextThink("__EntityThink", nil, 0)
+ self.IsThinking = false
+ end
end
---Define a function to redirected to `output` on spawn.
diff --git a/scripts/vscripts/alyxlib/controls/haptics.lua b/scripts/vscripts/alyxlib/controls/haptics.lua
index 1e734e0..31022b5 100644
--- a/scripts/vscripts/alyxlib/controls/haptics.lua
+++ b/scripts/vscripts/alyxlib/controls/haptics.lua
@@ -1,5 +1,5 @@
--[[
- v1.0.1
+ v1.0.2
https://github.com/FrostSource/alyxlib
Haptic sequences allow for more complex vibrations than the one-shot pulses that the base API provides.
@@ -28,7 +28,7 @@ local HapticSequenceClass = {
pulseWidth_us = 0,
}
HapticSequenceClass.__index = HapticSequenceClass
-HapticSequenceClass.version = "v1.0.1"
+HapticSequenceClass.version = "v1.0.2"
---
---Start the haptic sequence on a given hand.
@@ -39,6 +39,11 @@ function HapticSequenceClass:Fire(hand)
hand = Entities:GetLocalPlayer():GetHMDAvatar():GetVRHand(hand)
end
+ if not IsValidEntity(hand) then
+ warn("Invalid hand entity for haptic sequence!")
+ return
+ end
+
local ref = {
increment = 0,
prevTime = Time(),
diff --git a/scripts/vscripts/alyxlib/controls/input.lua b/scripts/vscripts/alyxlib/controls/input.lua
index 5a13faf..00432ec 100644
--- a/scripts/vscripts/alyxlib/controls/input.lua
+++ b/scripts/vscripts/alyxlib/controls/input.lua
@@ -1,5 +1,5 @@
--[[
- v4.0.1
+ v4.0.2
https://github.com/FrostSource/alyxlib
Simplifies the tracking of digital action presses/releases and analog values.
@@ -15,7 +15,7 @@
---@class Input
Input = {}
Input.__index = Input
-Input.version = "v4.0.1"
+Input.version = "v4.0.2"
---
---If the input system should start automatically on player spawn.
@@ -119,10 +119,10 @@ InputHandPrimary = 2
InputHandSecondary = 3
---@alias InputHandKind
----| `INPUT_HAND_LEFT`
----| `INPUT_HAND_RIGHT`
----| `INPUT_HAND_PRIMARY`
----| `INPUT_HAND_SECONDARY`
+---| `InputHandLeft`
+---| `InputHandRight`
+---| `InputHandPrimary`
+---| `InputHandSecondary`
---| 0 # Left Hand.
---| 1 # Right Hand.
---| 2 # Primary Hand.
@@ -157,7 +157,7 @@ end
ListenToGameEvent("primary_hand_changed", function(data)
---@cast data GameEventPrimaryHandChanged
- updatePrimaryHandId(data.is_primary_left and 0 or 1)
+ updatePrimaryHandId(1 - data.is_primary_left)
end, nil)
---
@@ -305,8 +305,8 @@ function Input:ListenToButton(kind, hand, button, presses, callback, context)
kind = kind,
multiple_press_count = 0,
- press_time = -1,
- release_time = 0,
+ press_time = vlua.select(kind == "press", 0, -1),
+ release_time = vlua.select(kind == "release", 0, -1),
prev_press_time = 0,
}
diff --git a/scripts/vscripts/alyxlib/debug/commands.lua b/scripts/vscripts/alyxlib/debug/commands.lua
index 6db7061..8adad09 100644
--- a/scripts/vscripts/alyxlib/debug/commands.lua
+++ b/scripts/vscripts/alyxlib/debug/commands.lua
@@ -1,5 +1,5 @@
--[[
- v1.1.0
+ v1.1.1
https://github.com/FrostSource/alyxlib
If not using `vscripts/alyxlib/init.lua`, load this file at game start using the following line:
@@ -7,7 +7,7 @@
require "alyxlib.debug.commands"
]]
-local version = "v1.1.0"
+local version = "v1.1.1"
local alyxlibCommands = {}
@@ -455,16 +455,35 @@ RegisterAlyxLibCommand("ent_find_by_address", function (_, tblpart, colon, hash)
local foundEnt = Debug.FindEntityByHandleString(tblpart, colon, hash)
if foundEnt then
- Msg("Info for " .. tostring(foundEnt).."\n")
- Msg("\tClassname" .. foundEnt:GetClassname().."\n")
- Msg("\tName" .. foundEnt:GetName().."\n")
- Msg("\tParent" .. foundEnt:GetMoveParent().."\n")
- Msg("\tModel" .. foundEnt:GetModelName())
+ Msg("Info for " .. tostring(foundEnt) .."\n")
+ Msg("\tClassname: " .. foundEnt:GetClassname() .."\n")
+ Msg("\tName: " .. foundEnt:GetName().."\n")
+ Msg("\tParent: " .. (tostring(foundEnt:GetMoveParent() or "[none]")) .."\n")
+ Msg("\tModel: " .. foundEnt:GetModelName())
else
Msg("Could not find any entity matching '" .. hash .. "'")
end
end, "Prints info for an entity by its table address", 0)
+---
+---Renames the first entity found using `pattern`.
+---
+RegisterAlyxLibCommand("ent_rename", function(_, pattern, newName)
+ local ent = Debug.FindEntityByPattern(pattern)
+ if not ent then
+ warn("Could not find entity with pattern '"..pattern.."'")
+ return
+ end
+
+ if newName == nil or newName == "" then
+ Msg("Removing name from " .. Debug.EntStr(ent))
+ ent:SetEntityName("")
+ else
+ Msg("Renaming " .. Debug.EntStr(ent) .. " to '" .. newName .. "'")
+ ent:SetEntityName(newName)
+ end
+end, "Renames the first entity found using a pattern to a new name", 0)
+
local symbols = {"and","break","do","else","elseif","end","false","for","function","if","in","local","nil","not","or","repeat","return","then","true","until","while"}
-- if IsInToolsMode() then
diff --git a/scripts/vscripts/alyxlib/debug/common.lua b/scripts/vscripts/alyxlib/debug/common.lua
index 4549c9d..0962164 100644
--- a/scripts/vscripts/alyxlib/debug/common.lua
+++ b/scripts/vscripts/alyxlib/debug/common.lua
@@ -1,5 +1,5 @@
--[[
- v2.1.0
+ v2.2.0
https://github.com/FrostSource/alyxlib
Debug utility functions.
@@ -13,7 +13,7 @@ require "alyxlib.extensions.entity"
require "alyxlib.math.common"
Debug = {}
-Debug.version = "v2.1.0"
+Debug.version = "v2.2.0"
---
---Finds the first entity whose name, class or model matches `pattern`.
@@ -92,7 +92,7 @@ end
---Property patterns do not need to be functions.
---
---@param list EntityHandle[] # List of entities to print.
----@param properties string[] # List of property patterns to search for.
+---@param properties? string[] # List of property patterns to search for.
function Debug.PrintEntityList(list, properties)
if #list == 0 then
@@ -394,13 +394,20 @@ function Debug.PrintTableShallow(tbl)
print("}")
end
+---
+---Prints an ordered table as a numbered list in the console.
+---
+---@param tbl table # Table to print.
+---@param prefix? string # Optional prefix for each line.
function Debug.PrintList(tbl, prefix)
local m = 0
prefix = prefix or ""
+
+ -- Pre-determine alignment padding
for key, value in pairs(tbl) do
m = max(m, #tostring(key)+1)
end
- m = 0
+
local frmt = "%"..m.."s %s"
for key, value in pairs(tbl) do
if type(key) == "number" then
@@ -411,6 +418,20 @@ function Debug.PrintList(tbl, prefix)
end
end
+---
+---Prints all the values in a table, one value per line, without any numbering or padding.
+---
+---@param tbl table # Table to print.
+function Debug.PrintSimpleTable(tbl)
+ if type(tbl) ~= "table" then
+ return warn("Parameter 'tbl' is not a table " .. Debug.GetSourceLine(3))
+ end
+
+ for _, value in pairs(tbl) do
+ print(value)
+ end
+end
+
---
---Prints a value and its type in an easy to read format.
---
@@ -792,9 +813,20 @@ end
---@param ent EntityHandle
---@return string
function Debug.EntStr(ent)
+ if ent == nil then
+ return "[nil, nil]"
+ end
+
+ if ent:IsNull() then
+ return "[invalid, invalid]"
+ end
+
return "[" .. ent:GetClassname() .. ", " .. ent:GetName() .. "]"
end
+---@diagnostic disable-next-line: lowercase-global
+entstr = Debug.EntStr
+
---
---Dumps a list of convars and their values to the console.
---
@@ -891,4 +923,28 @@ function Debug.ToOrdinalString(n)
return n .. (suffixes[lastDigit] or "th")
end
+---
+---Get the script name and line number of a function or traceback level.
+---
+---@param f integer|function # Level or function
+---@return string
+function Debug.GetSourceLine(f)
+ return debug.getinfo(f, "S").short_src..":"..tostring(debug.getinfo(f, "l").currentline)
+end
+
+---
+---Safely calls a function while handling any errors.
+---
+---If an error occurs, a warning will be printed to the console.
+---
+---@param action function # The function to call
+---@param ... any # Optional arguments to pass
+function Debug.Try(action, ...)
+ local success, result = pcall(action, ...)
+
+ if not success then
+ warn(result)
+ end
+end
+
return Debug.version
\ No newline at end of file
diff --git a/scripts/vscripts/alyxlib/debug/controller.lua b/scripts/vscripts/alyxlib/debug/controller.lua
index 86d78fa..580ef58 100644
--- a/scripts/vscripts/alyxlib/debug/controller.lua
+++ b/scripts/vscripts/alyxlib/debug/controller.lua
@@ -1,5 +1,5 @@
--[[
- v1.0.1
+ v1.0.2
https://github.com/FrostSource/alyxlib
Allows quick debugging of VR controllers.
@@ -11,7 +11,7 @@
-- Used for button descriptions
require "alyxlib.controls.input"
-local version = "v1.0.1"
+local version = "v1.0.2"
Convars:RegisterCommand("alyxlib_start_print_controller_button_presses", function (_)
@@ -46,6 +46,7 @@ Convars:RegisterCommand("alyxlib_start_print_controller_button_presses", functio
if buttonsPressed[h][i] ~= nil then
buttonsPressed[h][i] = nil
Msg(desc .. " controller: [".. i .."] " .. Input:GetButtonDescription(i) .. " Released\n")
+ msgPrinted = true
end
end
end
diff --git a/scripts/vscripts/alyxlib/debug/debug_menu.lua b/scripts/vscripts/alyxlib/debug/debug_menu.lua
new file mode 100644
index 0000000..46cd72e
--- /dev/null
+++ b/scripts/vscripts/alyxlib/debug/debug_menu.lua
@@ -0,0 +1,852 @@
+--[[
+ v1.0.0
+ https://github.com/FrostSource/alyxlib
+
+ The debug menu allows for easier VR testing by offering a customizable in-game menu.
+]]
+
+RegisterAlyxLibCommand("alyxlib_debug_menu_show", function (name, ...)
+ DebugMenu:ShowMenu()
+end, "Forces the debug menu to show")
+
+RegisterAlyxLibConvar("alyxlib_debug_menu_hand", "1", "Hand to attach the debug menu to, 0 = Secondary : 1 = Primary")
+
+---
+---The debug menu allows for easier VR testing by offering a customizable in-game menu.
+---
+---@class DebugMenu
+DebugMenu = {}
+DebugMenu.version = "v1.0.0"
+
+---
+---A category of items in the debug menu.
+---
+---@class DebugMenuCategory
+---@field id string # The unique ID for this category.
+---@field name string # The display name for this category.
+---@field items DebugMenuItem[] # The items in this category.
+
+---
+---An item in the debug menu.
+---
+---@class DebugMenuItem
+---@field categoryId string # The ID of the category this item is in.
+---@field id string # The unique ID for this item.
+---@field text string # The text to display for this item (if applicable).
+---@field callback function # The function to call when this item is clicked.
+---@field type "button"|"toggle"|"separator"|"slider"|"cycle" # Type of menu element this item is.
+---@field default any|function # The default value sent to the menu. If this is a function the return value will be used.
+---@field min number # Minimum value of this slider.
+---@field max number # Maxmimum value of this slider.
+---@field isPercentage boolean # If true, this slider displays its value as a percentage of min/max.
+---@field convar string # The console variable associated with this element.
+---@field values {text:string,value:any}[] # Text/value pairs for this cycler.
+---@field truncate number # The number of decimal places to truncate the slider value to (-1 for no truncating).
+---@field increment number # The increment value to snap the slider value to (0 for no snapping).
+
+---The panel entity.
+---@type CPointClientUIWorldPanel
+DebugMenu.panel = nil
+
+---@type DebugMenuCategory[]
+DebugMenu.categories = {}
+
+local debugMenuOpen = false
+local handChangedListener = nil
+
+---Command to test trace button presses
+if not IsVREnabled() then
+ Convars:RegisterCommand("_debug_menu_test_button_press", function()
+ DebugMenu:ClickHoveredButton()
+ end, "", FCVAR_HIDDEN)
+end
+
+---
+---The scope of the debug menu script.
+---
+---These functions handle Panorama callbacks.
+---
+local debugPanelScriptScope = {
+ _DebugMenuCallbackButton = function(id)
+ local item = DebugMenu:GetItem(id)
+ if not item then
+ warn("Unknown item for panorama callback'"..id.."'")
+ return
+ end
+
+ if item.type ~= "button" then
+ warn("Option '"..id.."' is not a button!")
+ return
+ end
+
+ if item.callback then
+ item.callback()
+ end
+ end,
+
+ _DebugMenuCallbackToggle = function(id, on)
+ local item = DebugMenu:GetItem(id)
+ if not item then
+ warn("Unknown item for panorama callback'"..id.."'")
+ return
+ end
+
+ if item.type ~= "toggle" then
+ warn("Option '"..id.."' is not a toggle!")
+ return
+ end
+
+ -- Update default if user is tracking manually
+ if item.default ~= nil and type(item.default) ~= "function" then
+ item.default = on
+ end
+
+ if item.callback then
+ item.callback(on)
+ end
+ end,
+
+ _DebugMenuCallbackSlider = function(id, value)
+ local item = DebugMenu:GetItem(id)
+ if not item then
+ warn("Unknown item for panorama callback'"..id.."'")
+ return
+ end
+
+ if item.type ~= "slider" then
+ warn("Option '"..id.."' is not a slider!")
+ return
+ end
+
+ -- Update default if user is tracking manually
+ if item.default ~= nil and type(item.default) ~= "function" then
+ item.default = value
+ end
+
+ if item.callback then
+ item.callback(value, item)
+ end
+ end,
+
+ _DebugMenuCallbackCycle = function(id, index)
+ local item = DebugMenu:GetItem(id)
+ if not item then
+ return warn("Unknown item for panorama callback'"..id.."'")
+ end
+
+ if item.type ~= "cycle" then
+ return warn("Option '"..id.."' is not a cycle!")
+ end
+
+ local value = item.values[index]
+
+ -- Update default if user is tracking manually
+ if item.default ~= nil and type(item.default) ~= "function" then
+ item.default = value
+ end
+
+ if item.callback then
+ item.callback(index, value, item)
+ end
+ end,
+
+ _CloseMenu = function()
+ DebugMenu:CloseMenu()
+ end,
+
+ _DebugMenuReloaded = function()
+ if DebugMenu:IsOpen() then
+ DebugMenu:Refresh()
+ end
+ end
+}
+
+---
+---Updates the physical menu by attaching it to the correct hand.
+---
+function DebugMenu:UpdateMenuAttachment()
+ local hand = Convars:GetBool("alyxlib_debug_menu_hand") and Player.PrimaryHand or Player.SecondaryHand
+ if hand == Player.RightHand then
+ self.panel:SetParent(hand, "constraint1")
+ self.panel:ResetLocal()
+ self.panel:SetLocalAngles(0, 180, 0)
+ self.panel:SetLocalOrigin(Vector(4, -9, 0))
+ else
+ self.panel:SetParent(hand, "constraint1")
+ self.panel:ResetLocal()
+ self.panel:SetLocalAngles(0, 0, 0)
+ self.panel:SetLocalOrigin(Vector(4, 9, 0))
+ end
+end
+
+---
+---Creates and displays the debug menu panel on the player's chosen hand.
+---
+function DebugMenu:ShowMenu()
+
+ self.panel = SpawnEntityFromTableSynchronous("point_clientui_world_panel", {
+ targetname = "alyxlib_debug_menu",
+ dialog_layout_name = "file://{resources}/layout/custom_game/alyxlib_debug_menu.xml",
+ width = 16,--24,
+ height = 12,--16
+ panel_dpi = 64,
+ ignore_input = 0,
+ lit = 0,
+ interact_distance = 12,
+
+ vertical_align = "1",
+ -- orientation = "0",
+ horizontal_align = "1",
+ })
+
+ if not Player.HMDAvatar or IsFakeVREnabled() then
+ local localPlayer = Entities:GetLocalPlayer()
+ local eyePos = localPlayer:EyePosition()
+ local dir = localPlayer:EyeAngles():Forward()
+ local a = VectorToAngles(dir)
+ a = RotateOrientation(a, QAngle(0,-90,90))
+ self.panel:SetQAngle(a)
+ self.panel:SetOrigin(eyePos + dir * 16)
+
+ SendToConsole("bind r _debug_menu_test_button_press")
+ else
+ self:UpdateMenuAttachment()
+
+ -- Cough handpose gets in the way for close menus
+ Player:SetCoughHandEnabled(false)
+
+ -- Handle distant button presses
+ Input:ListenToButton("press",
+ Convars:GetInt("alyxlib_debug_menu_hand") == 1 and InputHandSecondary or InputHandPrimary,
+ DIGITAL_INPUT_MENU_INTERACT, 1,
+ function (params)
+ self:ClickHoveredButton()
+ end, self)
+
+ handChangedListener = ListenToPlayerEvent("primary_hand_changed", function()
+ self:UpdateMenuAttachment()
+ end)
+
+ end
+
+ self.panel:AddCSSClasses("Visible")
+
+ local scope = self.panel:GetOrCreatePrivateScriptScope()
+ vlua.tableadd(scope, debugPanelScriptScope)
+
+ self.panel:AddOutput("CustomOutput0", "!self", "RunScriptCode")
+
+ Panorama:InitPanel(self.panel, "alyxlib_debug_menu")
+
+ self.panel:Delay(function()
+ debugMenuOpen = true
+ end, 0.2)
+
+ self:SendCategoriesToPanel()
+end
+
+---
+---Closes the debug menu panel.
+---
+function DebugMenu:CloseMenu()
+ if self.panel then
+ self.panel:Kill()
+ self.panel = nil
+
+ debugMenuOpen = false
+
+ if handChangedListener ~= nil then
+ StopListeningToPlayerEvent(handChangedListener)
+ handChangedListener = nil
+ end
+
+ Input:StopListeningByContext(self)
+
+ Player:SetCoughHandEnabled(true)
+
+ if Player.HMDAvatar then
+ self:StartListeningForMenuActivation()
+ else
+ SendToConsole("unbind r")
+ end
+ end
+end
+
+---
+---Returns whether the debug menu is currently open.
+---
+---@return boolean # True if the debug menu is open
+function DebugMenu:IsOpen()
+ return self.panel ~= nil and debugMenuOpen
+end
+
+---
+---Clicks the active button on the debug menu panel (the one highlighted by the finger).
+---
+---This is handled automatically in most cases.
+---
+function DebugMenu:ClickHoveredButton()
+ if self.panel then
+ Panorama:Send(self.panel, "ClickHoveredButton")
+ end
+end
+
+---
+---Get a debug menu item by id.
+---
+---@param id string # The item ID
+---@return DebugMenuItem? # The item if it exists
+function DebugMenu:GetItem(id)
+ for _, category in ipairs(self.categories) do
+ for _, item in ipairs(category.items) do
+ if item.id == id then
+ return item
+ end
+ end
+ end
+end
+
+---
+---Get a debug menu category by id.
+---
+---@param id string # The category ID
+---@return DebugMenuCategory? # The category if it exists
+---@return number? # The index of the category in the categories table
+function DebugMenu:GetCategory(id)
+ for index, category in ipairs(self.categories) do
+ if category.id == id then
+ return category, index
+ end
+ end
+end
+
+---
+---Add a category to the debug menu.
+---
+---@param id string # The unique ID for this category
+---@param name string # The display name for this category
+function DebugMenu:AddCategory(id, name)
+ if self:GetCategory(id) then
+ warn("Category '"..id.."' already exists!")
+ return
+ end
+
+ table.insert(self.categories, {
+ id = id,
+ name = name,
+ items = {},
+ })
+end
+
+---
+---Add a separator line to a category.
+---
+---@param categoryId string # The category ID to add the separator to
+---@param separatorId? string # Optional ID for the separator if you want to modify it later
+---@param text? string # Optional title text to display on the separator
+function DebugMenu:AddSeparator(categoryId, separatorId, text)
+ local category = self:GetCategory(categoryId)
+ if not category then
+ warn("Cannot add separator: Category '"..categoryId.."' does not exist!")
+ return
+ end
+
+ table.insert(category.items, {
+ categoryId = categoryId,
+ type = "separator",
+ id = separatorId or DoUniqueString("separator"),
+ text = text or ""
+ })
+end
+
+---
+---Add a button to a category.
+---
+---@param categoryId string # The category ID to add the button to
+---@param buttonId string # The unique ID for this button
+---@param text string # The text to display on this button
+---@param command string|function # The console command or function to run when this button is pressed
+function DebugMenu:AddButton(categoryId, buttonId, text, command)
+ local category = self:GetCategory(categoryId)
+ if not category then
+ warn("Cannot add button '"..buttonId.."': Category '"..categoryId.."' does not exist!")
+ return
+ end
+
+ local callback
+ if type(command) == "string" then
+ callback = function()
+ SendToConsole(command)
+ end
+ elseif type(command) == "function" then
+ callback = command
+ end
+
+ table.insert(category.items, {
+ categoryId = categoryId,
+ id = buttonId,
+ text = text,
+ callback = callback,
+ type = "button",
+ })
+end
+
+---
+---Add a toggle to a category.
+---
+---@param categoryId string # The category ID to add the toggle to
+---@param toggleId string # The unique ID for this toggle
+---@param text string # The text to display on this toggle
+---@param command string|function # The console command or function to run when this toggle is toggled (will run with 1 if it's on, 0 if it's off)
+---@param startsOn? boolean|fun():boolean # Whether the toggle is on by default
+function DebugMenu:AddToggle(categoryId, toggleId, text, command, startsOn)
+ local category = self:GetCategory(categoryId)
+ if not category then
+ warn("Cannot add toggle '"..toggleId.."': Category '"..categoryId.."' does not exist!")
+ return
+ end
+
+ local callback
+ if type(command) == "string" then
+ startsOn = startsOn or Convars:GetBool(command) or false
+ callback = function(on)
+ SendToConsole(command .. " " .. (on and 1 or 0))
+ end
+ elseif type(command) == "function" then
+ callback = command
+ end
+
+ table.insert(category.items, {
+ categoryId = categoryId,
+ id = toggleId,
+ text = text,
+ callback = callback,
+ type = "toggle",
+ default = startsOn or false,
+ })
+end
+
+---
+---Add a center aligned label to a category.
+---
+---@param categoryId string # The category ID to add the label to
+---@param labelId string # The unique ID for this label
+---@param text string # The text to display on this label
+function DebugMenu:AddLabel(categoryId, labelId, text)
+ local category = self:GetCategory(categoryId)
+ if not category then
+ warn("Cannot add label '"..labelId.."': Category '"..categoryId.."' does not exist!")
+ return
+ end
+
+ table.insert(category.items, {
+ categoryId = categoryId,
+ id = labelId,
+ text = text,
+ type = "label",
+ })
+end
+
+---
+---Add value slider to a category.
+---
+---@param categoryId string # The ID of the category to add this slider to
+---@param sliderId string # A unique ID for this slider
+---@param text string # Display text for the slider
+---@param min number # Minimum allowed value
+---@param max number # Maximum allowed value
+---@param isPercentage boolean # If true, value will be displayed as a percentage (0-100)
+---@param command string|fun(value:number,slider:DebugMenuItem) # Convar name or callback function
+---@param truncate? number # Number of decimal places (0 = integer, -1 = no truncating)
+---@param increment? number # Snap increment (0 disables snapping)
+---@param defaultValue? number|fun():number # Starting value. Set nil to use the convar value whenever the menu opens
+function DebugMenu:AddSlider(categoryId, sliderId, text, min, max, isPercentage, command, truncate, increment, defaultValue)
+ local category = self:GetCategory(categoryId)
+ if not category then
+ warn("Cannot add toggle '"..sliderId.."': Category '"..categoryId.."' does not exist!")
+ return
+ end
+
+ local callback
+ local convar = ""
+ if type(command) == "string" then
+ if command == "" then
+ error("Command must not be a blank string", 2)
+ end
+ convar = command
+
+ ---@param value number
+ ---@param slider DebugMenuItem
+ callback = function(value, slider)
+ Convars:SetStr(slider.convar, tostring(value))
+ end
+ elseif type(command) == "function" then
+ callback = command
+ end
+
+ table.insert(category.items, {
+ categoryId = categoryId,
+ id = sliderId,
+ text = text,
+ callback = callback,
+ type = "slider",
+ default = defaultValue,
+ min = min,
+ max = max,
+ convar = convar,
+ isPercentage = isPercentage or false,
+ truncate = truncate or -1,
+ increment = increment or 0
+ })
+end
+
+---
+---Add a value cycler to a category.
+---
+---Cyclers allow users to choose from a set of values.
+---
+---@param categoryId string # The id of the category to add this cycle to
+---@param cycleId string # The unique id for this new cycle
+---@param values {text:string,value:any}[] # List of text/value pairs for this cycle
+---@param command string|fun(index:number, item:{text:string,value:any?}, cycle:DebugMenuItem) # Convar name or function callback
+---@param defaultValue? any|fun():any # Value for this cycle to start with
+function DebugMenu:AddCycle(categoryId, cycleId, values, command, defaultValue)
+ local category = self:GetCategory(categoryId)
+ if not category then
+ warn("Cannot add toggle '"..cycleId.."': Category '"..categoryId.."' does not exist!")
+ return
+ end
+
+ if type(values) ~= "table" or #values == 0 then
+ error("Cycle values must be a table with at least 1 value", 2)
+ end
+
+ for k,v in ipairs(values) do
+ v.value = tostring(v.value or (k - 1))
+ end
+
+ local callback
+ local convar = ""
+ if type(command) == "string" then
+ if command == "" then
+ error("Command must not be a blank string", 2)
+ end
+ convar = command
+
+ ---@param index number
+ ---@param item {text:string,value:any?}
+ ---@param cycle DebugMenuItem
+ callback = function(index, item, cycle)
+ Convars:SetStr(cycle.convar, tostring(item.value))
+ end
+ elseif type(command) == "function" then
+ callback = command
+ end
+
+ table.insert(category.items, {
+ categoryId = categoryId,
+ id = cycleId,
+ callback = callback,
+ type = "cycle",
+ values = values,
+ default = defaultValue,
+ convar = convar
+ })
+end
+
+---
+---Set the text of an item.
+---
+---Only works on the following types:
+--- - button
+--- - toggle
+--- - slider
+---
+---@param categoryId string # The ID of the category that contains the item
+---@param itemId string # The ID of the item to modify
+---@param text string # The new text
+function DebugMenu:SetItemText(categoryId, itemId, text)
+ local item = self:GetItem(itemId)
+ if not item then
+ warn("Cannot set item text '"..itemId.."': Item does not exist!")
+ return
+ end
+
+ item.text = text
+
+ if self.panel then
+ Panorama:Send(self.panel, "SetItemText", categoryId, itemId, text)
+ end
+end
+
+---
+---Sets the index of a category in the debug menu.
+---Categories are ordered by their index, starting from 1.
+---
+---This is an advanced function and should be used with caution.
+---
+---@param categoryId string # Id of the category to change.
+---@param index number # New index for the category.
+function DebugMenu:SetCategoryIndex(categoryId, index)
+ local category, currentIndex = self:GetCategory(categoryId)
+ if not category then
+ warn("Cannot set category index '"..categoryId.."': Category does not exist!")
+ return
+ end
+
+ index = math.max(1, math.min(index, #self.categories)) -- Clamp index to valid range
+
+ table.remove(self.categories, currentIndex)
+ table.insert(self.categories, index, category)
+
+ if self.panel then
+ Panorama:Send(self.panel, "SetCategoryIndex", categoryId, index-1)
+ end
+end
+
+---Resolves the default value of an element by running any value getter functions.
+---@param default any|fun():any # The default value to resolve
+---@return any # The resolved value
+local function resolveDefault(default)
+ if type(default) == "function" then
+ return default()
+ end
+ return default
+end
+
+---
+---Sends a category and all its elements to the panel.
+---
+---This should only be used if modifying the menu in a non-standard way.
+---
+---@param category DebugMenuCategory # The category to send
+function DebugMenu:SendCategoryToPanel(category)
+ if not self.panel then
+ return
+ end
+
+ local panel = self.panel
+
+ Panorama:Send(panel, "AddCategory", category.id, category.name)
+
+ for _, item in ipairs(category.items) do
+ if item.type == "toggle" then
+ Panorama:Send(panel, "AddToggle", item.categoryId, item.id, item.text, resolveDefault(item.default))
+
+ elseif item.type == "button" then
+ Panorama:Send(panel, "AddButton", item.categoryId, item.id, item.text)
+
+ elseif item.type == "label" then
+ Panorama:Send(panel, "AddLabel", item.categoryId, item.id, item.text)
+
+ elseif item.type == "separator" then
+ Panorama:Send(panel, "AddSeparator", item.categoryId, item.id, item.text)
+
+ elseif item.type == "slider" then
+ local default = resolveDefault(item.default)
+ if default == nil then
+ default = Convars:GetFloat(item.convar) or item.min
+ end
+ Panorama:Send(panel, "AddSlider", item.categoryId, item.id, item.text, item.convar, item.min, item.max, default, item.isPercentage, item.truncate, item.increment)
+
+ elseif item.type == "cycle" then
+ -- Flatten values into an array of text
+ local values = {}
+ local index = 1
+ for i = 1, #item.values do
+ values[index] = item.values[i].text
+ values[index+1] = item.values[i].value or (i - 1)
+ index = index + 2
+ end
+
+ local default = resolveDefault(item.default)
+ -- Use convar value if default isn't set
+ if default == nil and item.convar ~= "" then
+ default = Convars:GetStr(item.convar)
+ end
+
+ Panorama:Send(panel, "AddCycle", item.categoryId, item.id, item.convar, default, values)
+ else
+ warn("Unknown item type '"..item.type.."'")
+ end
+ end
+end
+
+---
+---Forces the debug menu panel to add all categories and items.
+---
+---This should only be used if modifying the menu in a non-standard way.
+---
+function DebugMenu:SendCategoriesToPanel()
+ if not self.panel then
+ return
+ end
+
+ for categoryId, category in pairs(DebugMenu.categories) do
+ self:SendCategoryToPanel(category)
+ end
+end
+
+---
+---Clears all categories and items from the debug menu panel.
+---
+function DebugMenu:ClearMenu()
+ if not self.panel then
+ return
+ end
+
+ Panorama:Send(self.panel, "RemoveAllCategories")
+end
+
+---
+---Forces the debug menu panel to refresh by removing and re-adding all categories and items.
+---
+function DebugMenu:Refresh()
+ if self.panel then
+ self:ClearMenu()
+ self:SendCategoriesToPanel()
+ end
+end
+
+---
+---Starts listening for the debug menu activation button.
+---
+function DebugMenu:StartListeningForMenuActivation()
+ if Player.HMDAvatar == nil then return end
+
+ local buttonPressesToActivate = 3
+ local buttonPresses = 0
+ local timeToResetBetweenPresses = 0.6
+ local buttonPressed = false
+ local timeSinceLastButtonPress = 0
+
+ Player:SetContextThink("debug_menu_activate", function()
+ if Time() - timeSinceLastButtonPress > timeToResetBetweenPresses then
+ buttonPresses = 0
+ timeSinceLastButtonPress = math.huge
+ end
+
+ local hand = Convars:GetBool("alyxlib_debug_menu_hand") and Player.SecondaryHand or Player.PrimaryHand
+
+ if Player:IsDigitalActionOnForHand(hand.Literal, DIGITAL_INPUT_TOGGLE_MENU) then
+ if not buttonPressed then
+ buttonPressed = true
+ timeSinceLastButtonPress = Time()
+ buttonPresses = buttonPresses + 1
+
+ if buttonPresses >= buttonPressesToActivate then
+ self:ShowMenu()
+ buttonPresses = 0
+ -- Stop think
+ return nil
+ end
+ end
+ else
+ if buttonPressed then
+ buttonPressed = false
+ end
+ end
+ return 0
+ end, 0)
+end
+
+function DebugMenu:StopListeningForMenuActivation()
+ Player:SetContextThink("debug_menu_activate", nil, 0)
+end
+
+if Convars:GetInt("developer") > 0 then
+ local listenFunc = ListenToPlayerEvent or ListenToGameEvent
+ listenFunc("vr_player_ready", function()
+ -- Kill existing panel on load to avoid missing logic errors
+ local panel = Entities:FindByName(nil, "alyxlib_debug_menu")
+ if panel then
+ panel:Kill()
+ end
+
+ Player:Delay(function()
+ DebugMenu:StartListeningForMenuActivation()
+ end, 0.2)
+ end, nil)
+end
+
+--[[
+ Default AlyxLib tab
+]]
+---@TODO Move to its own file
+
+local categoryId = "alyxlib"
+
+DebugMenu:AddCategory(categoryId, "AlyxLib")
+
+DebugMenu:AddSeparator(categoryId, nil, "Basic")
+
+DebugMenu:AddToggle(categoryId, "noclip_vr", "NoClip VR", "noclip_vr", function ()
+ return Convars:GetBool("noclip_vr_enabled")
+end)
+DebugMenu:AddLabel(categoryId, "noclip_vr_label", "Hold movement trigger to boost")
+DebugMenu:AddSlider(categoryId, "noclip_vr_speed", "NoClip VR Speed", 0.5, 10, false, "noclip_vr_speed", 2)
+DebugMenu:AddSlider(categoryId, "noclip_vr_boost_speed", "NoClip VR Boost Speed", 0.5, 10, false, "noclip_vr_boost_speed", 2)
+
+DebugMenu:AddToggle(categoryId, "buddha", "Buddha Mode", "buddha")
+
+DebugMenu:AddToggle(categoryId, "lefthanded", "Left Handed", "hlvr_left_hand_primary")
+
+DebugMenu:AddToggle(categoryId, "gameinstructor", "Game Instructor Hints",
+function(on)
+ Convars:SetBool("gameinstructor_enable", on)
+ Convars:SetBool("sv_gameinstructor_disable", not on)
+end,
+function()
+ return Convars:GetBool("gameinstructor_enable") and not Convars:GetBool("sv_gameinstructor_disable")
+end)
+
+DebugMenu:AddSeparator(categoryId, nil, "Equipment")
+
+DebugMenu:AddButton(categoryId, "giveammo", "Give 999 Ammo", function()
+ SendToConsole("hlvr_setresources 999 999 999 " .. Player:GetResin())
+end)
+
+DebugMenu:AddButton(categoryId, "giveresin", "Give 999 Resin", function()
+ SendToConsole("hlvr_addresources 0 0 0 " .. (999 - Player:GetResin()))
+end)
+
+DebugMenu:AddSeparator(categoryId, nil, "Session")
+
+local isRecordingDemo = false
+local currentDemo = ""
+
+DebugMenu:AddLabel(categoryId, "demo_recording_label", "Last Demo: N/A")
+
+DebugMenu:AddButton(categoryId, "demo_recording", "Start Recording Demo", function()
+ if isRecordingDemo then
+ SendToConsole("stop")
+ currentDemo = ""
+ isRecordingDemo = false
+ DebugMenu:SetItemText(categoryId, "demo_recording", "Start Recording Demo")
+ else
+ local localtime = LocalTime()
+ -- remove all whitespace and slashes
+ local sanitizedMap = GetMapName():gsub("%s+", ""):gsub("/", "_")
+ currentDemo = "demo_" .. sanitizedMap .. "_" .. localtime.Hours .. "-" .. localtime.Minutes .. "-" .. localtime.Seconds
+ SendToConsole("record " .. currentDemo)
+ DebugMenu:SetItemText(categoryId, "demo_recording_label", "Last Demo: " .. currentDemo .. ".dem")
+ isRecordingDemo = true
+ DebugMenu:SetItemText(categoryId, "demo_recording", "Stop Recording Demo")
+ end
+end)
+
+DebugMenu:AddSeparator(categoryId)
+
+DebugMenu:AddButton(categoryId, "enableextras", "Enable Extras Tab...", function()
+ if package.loaded["alyxlib.debug.debug_menu_extras"] == nil then
+ require "alyxlib.debug.debug_menu_extras"
+ -- Update the panel immediately
+ local id = "alyxlib_extras"
+ DebugMenu:SendCategoryToPanel(DebugMenu:GetCategory(id))
+ DebugMenu:SetCategoryIndex(id, 2)
+ ---@TODO Allow disabling extras tab
+ DebugMenu:SetItemText(categoryId, "enableextras", "Extras Tab Enabled!")
+ end
+end)
+
+return DebugMenu.version
\ No newline at end of file
diff --git a/scripts/vscripts/alyxlib/debug/debug_menu_extras.lua b/scripts/vscripts/alyxlib/debug/debug_menu_extras.lua
new file mode 100644
index 0000000..e4b9dac
--- /dev/null
+++ b/scripts/vscripts/alyxlib/debug/debug_menu_extras.lua
@@ -0,0 +1,38 @@
+--[[
+ v1.0.0
+ https://github.com/FrostSource/alyxlib
+
+ Extra debug menu category which can be enabled via the AlyxLib debug menu tab.
+]]
+
+local version = "v1.0.0"
+
+require "alyxlib.debug.debug_menu"
+
+local categoryId = "alyxlib_extras"
+
+DebugMenu:AddCategory(categoryId, "AlyxLib Extras")
+
+DebugMenu:AddSeparator(categoryId, nil, "Display")
+
+DebugMenu:AddToggle(categoryId, "showtriggers", "Show Triggers", "showtriggers")
+
+DebugMenu:AddToggle(categoryId, "luxels", "Mat Luxels", "mat_luxels")
+
+DebugMenu:AddToggle(categoryId, "fullbright", "Fullbright", "mat_fullbright")
+
+DebugMenu:AddToggle(categoryId, "visibility", "Vis", "vis_enable")
+
+DebugMenu:AddToggle(categoryId, categoryId.."_visfreeze", "Freeze Vis", function(on)
+ if on then
+ SendToConsole("vis_debug_show 1")
+ Player:Delay(function()
+ SendToConsole("vis_debug_lock 1")
+ end)
+ else
+ SendToConsole("vis_debug_show 0")
+ SendToConsole("vis_debug_lock 0")
+ end
+end)
+
+return version
\ No newline at end of file
diff --git a/scripts/vscripts/alyxlib/debug/vr.lua b/scripts/vscripts/alyxlib/debug/vr.lua
index d5cdd13..9a5be9d 100644
--- a/scripts/vscripts/alyxlib/debug/vr.lua
+++ b/scripts/vscripts/alyxlib/debug/vr.lua
@@ -1,5 +1,5 @@
--[[
- v1.0.1
+ v1.1.0
https://github.com/FrostSource/alyxlib
Adds console commands to help debugging VR specific features.
@@ -9,7 +9,7 @@
require "alyxlib.debug.vr"
]]
-local version = "v1.0.1"
+local version = "v1.1.0"
---Get a hand entity from its name.
---@param handName string
@@ -102,8 +102,8 @@ Convars:RegisterCommand("add_hand_attachment", function (_, classname, handName)
hand:AddHandAttachment(ent)
end, "", 0)
-
-local noclipVREnabled = false
+---Hidden convar state that can be retrieved by other scripts
+RegisterAlyxLibConvar("noclip_vr_enabled", "0", "True if noclip_vr is enabled (readonly)", FCVAR_HIDDEN)
---Tracks initial button press to prevent repeated logic execution while held
local quickTurnFlag = false
@@ -167,11 +167,13 @@ RegisterAlyxLibConvar("noclip_vr_boost_speed", "5", "Speed of the VR noclip move
local movetype = Convars:GetInt('hlvr_movetype_default')
RegisterAlyxLibCommand("noclip_vr", function (_, on)
+ local noclipVREnabled
if on == nil then
- noclipVREnabled = not noclipVREnabled
+ noclipVREnabled = not Convars:GetBool("noclip_vr_enabled")
else
noclipVREnabled = truthy(on)
end
+ Convars:SetBool("noclip_vr_enabled", noclipVREnabled)
if noclipVREnabled then
movetype = Convars:GetInt("hlvr_movetype_default")
diff --git a/scripts/vscripts/alyxlib/extensions/entity.lua b/scripts/vscripts/alyxlib/extensions/entity.lua
index 90c93dc..5f97d71 100644
--- a/scripts/vscripts/alyxlib/extensions/entity.lua
+++ b/scripts/vscripts/alyxlib/extensions/entity.lua
@@ -1,5 +1,5 @@
--[[
- v2.6.1
+ v2.7.1
https://github.com/FrostSource/alyxlib
Provides base entity extension methods.
@@ -9,7 +9,7 @@
require "alyxlib.extensions.entity"
]]
-local version = "v2.6.1"
+local version = "v2.7.1"
---
---Get the entities parented to this entity. Including children of children.
@@ -20,15 +20,89 @@ local version = "v2.6.1"
---@return EntityHandle[]
function CBaseEntity:GetChildrenMemSafe()
local childrenArray = {}
- local child = self:FirstMoveChild()
- while child do
- table.insert(childrenArray, child)
- vlua.extend(childrenArray, child:GetChildrenMemSafe())
- child = child:NextMovePeer()
+ for child in self:IterateChildren() do
+ table.insert(childrenArray, 1, child)
end
return childrenArray
end
+---
+---Returns a `function` that iterates over all children of this entity.
+---The `function` returns the next child every time it is called until no more children exist,
+---in which case `nil` is returned.
+---
+---Useful in `for` loops:
+---
+--- for child in thisEntity:IterateChildren() do
+--- print(Debug.EntStr(child))
+--- end
+---
+---This function is memory safe.
+---
+---@return fun(...:any):EntityHandle # The new iterator function
+function CBaseEntity:IterateChildren()
+ local function traverse(entity)
+ coroutine.yield(entity)
+ local child = entity:FirstMoveChild()
+ while child do
+ traverse(child)
+ child = child:NextMovePeer()
+ end
+ end
+
+ return coroutine.wrap(function()
+ local child = self:FirstMoveChild()
+ while child do
+ traverse(child)
+ child = child:NextMovePeer()
+ end
+ end)
+end
+
+---
+---Returns a `function` that iterates over all children of this entity in **breadth-first order**.
+---The `function` returns the next child every time it is called until no more children exist,
+---in which case `nil` is returned.
+---
+---Useful in `for` loops:
+---
+--- for child in thisEntity:IterateChildrenBreadthFirst() do
+--- print(Debug.EntStr(child))
+--- end
+---
+---Unlike [IterateChildren](lua://CBaseEntity.IterateChildren), this visits all immediate children first,
+---then their children, and so on.
+---
+---This function is memory safe.
+---
+---@return fun(...:any):EntityHandle # The new iterator function
+function CBaseEntity:IterateChildrenBreadthFirst()
+ return coroutine.wrap(function()
+ local queue = {}
+
+ -- start with direct children
+ local child = self:FirstMoveChild()
+ while child do
+ table.insert(queue, child)
+ child = child:NextMovePeer()
+ end
+
+ -- process the queue
+ while #queue > 0 do
+ local entity = table.remove(queue, 1) -- pop front
+ coroutine.yield(entity)
+
+ -- enqueue this entity's direct children
+ local subchild = entity:FirstMoveChild()
+ while subchild do
+ table.insert(queue, subchild)
+ subchild = subchild:NextMovePeer()
+ end
+ end
+ end)
+end
+
+
---
---Get the top level entities parented to this entity. Not children of children.
---
@@ -45,6 +119,52 @@ function CBaseEntity:GetTopChildren()
return children
end
+---
+---Get the first child in the hierarchy that has targetname or classname.
+---
+---@param name string # The name or classname to look for, supports wildcard '*'
+---@return EntityHandle?
+function CBaseEntity:GetChild(name)
+ local usePattern = name:find("%*") ~= nil
+ local pattern
+
+ ---@TODO Consider moving wildcard logic to a utility function
+ if usePattern then
+ -- Escape pattern special characters, then replace '*' with '.*'
+ pattern = "^" .. name
+ :gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1")
+ :gsub("%*", ".*") .. "$"
+ end
+
+ local child = self:FirstMoveChild()
+ while IsValidEntity(child) do
+ local childName = child:GetName()
+ local className = child:GetClassname()
+
+ local match = false
+ if usePattern then
+ match = childName:match(pattern) or className:match(pattern)
+ else
+ match = childName == name or className == name
+ end
+
+ if match then
+ return child
+ end
+
+ -- Check children recursively
+ local result = child:GetChild(name)
+ if result then
+ return result
+ end
+
+ child = child:NextMovePeer()
+ end
+
+ return nil
+end
+
+
---
---Send an input to this entity.
---
@@ -58,39 +178,45 @@ function CBaseEntity:EntFire(action, value, delay, activator, caller)
end
---
----Get the first child in this entity's hierarchy with a given classname.
+---Get the first child in this entity's hierarchy with a given `classname`.
+---Searches using **breadth-first traversal**, so it finds the closest matching child first.
---
---This function is memory safe.
---
----@param classname string # Classname to find.
----@return EntityHandle|nil # The child found.
+---@param classname string # Classname to search for.
+---@return EntityHandle? # The first matching child found, or `nil` if none exists.
function CBaseEntity:GetFirstChildWithClassname(classname)
- for _, child in ipairs(self:GetChildrenMemSafe()) do
+ for child in self:IterateChildrenBreadthFirst() do
if child:GetClassname() == classname then
return child
end
end
+
return nil
end
---
----Get the first child in this entity's hierarchy with a given target name.
+---Get the first child in this entity's hierarchy with a given `name`.
+---Searches using **breadth-first traversal**, so it finds the closest matching child first.
---
----@param name string # Targetname to find.
----@return EntityHandle|nil # The child found.
+---This function is memory safe.
+---
+---@param name string # Targetname to search for.
+---@return EntityHandle? # The first matching child found, or `nil` if none exists.
function CBaseEntity:GetFirstChildWithName(name)
- for _, child in ipairs(self:GetChildren()) do
+ for child in self:IterateChildrenBreadthFirst() do
if child:GetName() == name then
return child
end
end
+
return nil
end
---
---Set entity pitch, yaw, roll from a `QAngle`.
---
----@param qangle QAngle
+---@param qangle QAngle # The rotation to set (pitch, yaw, roll).
function CBaseEntity:SetQAngle(qangle)
self:SetAngles(qangle.x, qangle.y, qangle.z)
end
@@ -98,7 +224,7 @@ end
---
---Set entity local pitch, yaw, roll from a `QAngle`.
---
----@param qangle QAngle
+---@param qangle QAngle # The rotation to set (pitch, yaw, roll).
function CBaseEntity:SetLocalQAngle(qangle)
self:SetLocalAngles(qangle.x, qangle.y, qangle.z)
end
@@ -106,16 +232,16 @@ end
---
---Set entity pitch, yaw or roll. Supply `nil` for any parameter to leave it unchanged.
---
----@param pitch number|nil
----@param yaw number|nil
----@param roll number|nil
+---@param pitch? number # Pitch angle, or nil to leave unchanged.
+---@param yaw? number # Pitch angle, or nil to leave unchanged.
+---@param roll? number # Pitch angle, or nil to leave unchanged.
function CBaseEntity:SetAngle(pitch, yaw, roll)
local angles = self:GetAngles()
self:SetAngles(pitch or angles.x, yaw or angles.y, roll or angles.z)
end
---
----Resets local origin and angle to [0,0,0]
+---Resets local origin and angle to [0,0,0].
---
function CBaseEntity:ResetLocal()
self:SetLocalOrigin(Vector())
@@ -125,7 +251,7 @@ end
---
---Get the bounding size of the entity.
---
----@return Vector
+---@return Vector # Bounding size of the entity as a Vector.
function CBaseEntity:GetSize()
return self:GetBoundingMaxs() - self:GetBoundingMins()
end
@@ -134,24 +260,25 @@ end
---Get the biggest bounding box axis of the entity.
---This will be `size.x`, `size.y` or `size.z`.
---
----@return number
+---@return number # The largest bounding value.
function CBaseEntity:GetBiggestBounding()
local size = self:GetSize()
return math.max(size.x, size.y, size.z)
end
---
----Get the radius of the entity bounding box. This is half the size of the sphere.
+---Get the radius of the entity's bounding box.
+---This is half the size of the bounding box along its largest axis.
---
----@return number
+---@return number # The bounding radius value.
function CBaseEntity:GetRadius()
return self:GetSize():Length() * 0.5
end
---
----Get the volume of the entity bounds in inches cubed.
+---Get the volume of the entity bounds in cubic inches.
---
----@return number
+---@return number # The volume of the entity bounds.
function CBaseEntity:GetVolume()
local size = self:GetSize() * self:GetAbsScale()
return size.x * size.y * size.z
@@ -160,8 +287,8 @@ end
---
---Get each corner of the entity's bounding box.
---
----@param rotated? boolean # If the corners should be rotated with the entity angle.
----@return Vector[]
+---@param rotated? boolean # If true, corners are rotated by the entity's angles.
+---@return Vector[] # List of 8 corner positions.
function CBaseEntity:GetBoundingCorners(rotated)
local bounds = self:GetBounds()
local origin = self:GetOrigin()
@@ -228,8 +355,8 @@ end
---
---Delay some code using this entity.
---
----@param func fun()
----@param delay number?
+---@param func fun() # The function to delay.
+---@param delay? number # Optional delay in seconds (default 0).
function CBaseEntity:Delay(func, delay)
self:SetContextThink(DoUniqueString("delay"), function() func() end, delay or 0)
end
@@ -237,12 +364,12 @@ end
---
---Get all parents in the hierarchy upwards.
---
----@return EntityHandle[]
+---@return EntityHandle[] # List of parent entities, from immediate parent up to the root.
function CBaseEntity:GetParents()
local parents = {}
local parent = self:GetMoveParent()
while parent ~= nil do
- parents[#parents+1] = parent
+ table.insert(parents, parent)
parent = parent:GetMoveParent()
end
return parents
@@ -261,20 +388,20 @@ function CBaseEntity:DoNotDrop(enabled)
end
---
----Get all criteria as a table.
+---Get all criteria on this entity as a table.
---
----@return CriteriaTable
+---@return CriteriaTable # A table of criteria key-value pairs.
function CBaseEntity:GetCriteria()
local c = {}
self:GatherCriteria(c)
return c
end
-
---
----Get all entities which are owned by this entity
+---Get all entities owned by this entity.
---
---**Note:** This searches all entities in the map and should be used sparingly.
----@return EntityHandle[]
+---
+---@return EntityHandle[] # List of owned entities.
function CBaseEntity:GetOwnedEntities()
local ents = {}
local ent = Entities:First()
@@ -288,9 +415,9 @@ function CBaseEntity:GetOwnedEntities()
end
---
----Set the alpha modulation of this entity, plus any children that can have their alpha set.
+---Set the alpha modulation of this entity, plus any children that support [SetRenderAlpha](lua://CBaseModelEntity.SetRenderAlpha).
---
----@param alpha integer
+---@param alpha integer # Alpha value (0 = fully transparent, 255 = fully opaque).
function CBaseModelEntity:SetRenderAlphaAll(alpha)
for _, child in ipairs(self:GetChildren()) do
if child.SetRenderAlpha then
@@ -301,9 +428,9 @@ function CBaseModelEntity:SetRenderAlphaAll(alpha)
end
---
----Center the entity at a new position.
+---Moves the entity so that its center is at the given position.
---
----@param position Vector
+---@param position Vector # The new center position.
function CBaseEntity:SetCenter(position)
local center = self:GetCenter()
local origin = self:GetOrigin()
@@ -316,10 +443,10 @@ end
---
----Set the origin of this entity around one of its attachment points.
+---Set the entity's origin so that the specified attachment point aligns with the given world position.
---
----@param position Vector
----@param attachment string
+---@param position Vector # The target world position for the attachment point.
+---@param attachment string # The name of the attachment point to align.
function CBaseAnimating:SetOriginByAttachment(position, attachment)
local offset = self:GetAttachmentOrigin(self:ScriptLookupAttachment(attachment))
local origin = self:GetOrigin()
@@ -368,10 +495,11 @@ end
---Quickly start a think function on the entity with a random name and no delay.
---
---@param func fun(...):number? # The think function.
+---@param delay? number # Delay before starting the think.
---@return string # The name of the think for stopping later if desired.
-function CBaseEntity:QuickThink(func)
+function CBaseEntity:QuickThink(func, delay)
local name = DoUniqueString("QuickThink")
- self:SetContextThink(name, func, 0)
+ self:SetContextThink(name, func, delay or 0)
return name
end
@@ -417,30 +545,46 @@ function CEntityInstance:RedirectOutputFunc(output, func, entity)
end
---
----Gets the position in front of the entity's eyes at the specified position.
+---Gets the position in front of the entity’s eyes at the specified distance.
---
----@param distance number
----@return Vector
+---@param distance number # How far in front of the eyes to get the position.
+---@return Vector # The world position in front of the eyes.
function CBaseEntity:DistanceFromEyes(distance)
return self:EyePosition() + self:EyeAngles():Forward() * distance
end
---
----Gets the origin of a named attachment.
+---Gets the world origin position of a named attachment point.
---
----@param name string
----@return Vector
+---@param name string # The name of the attachment.
+---@return Vector # The world position of the attachment.
function CBaseAnimating:GetAttachmentNameOrigin(name)
return self:GetAttachmentOrigin(self:ScriptLookupAttachment(name))
end
---
----Gets the angles of a named attachment.
+---Gets the world angles (rotation) of a named attachment point.
---
----@param name string
----@return Vector
+---@param name string # The name of the attachment.
+---@return Vector # The world rotation angles of the attachment.
function CBaseAnimating:GetAttachmentNameAngles(name)
return self:GetAttachmentAngles(self:ScriptLookupAttachment(name))
end
+---
+---Gets the forward direction vector of a named attachment.
+---
+---@param name string # The name of the attachment.
+---@return Vector # The forward unit vector of the attachment in world space.
+function CBaseAnimating:GetAttachmentNameForward(name)
+ return self:GetAttachmentForward(self:ScriptLookupAttachment(name))
+end
+
+---
+---Unparents this entity if it is parented.
+---
+function CBaseEntity:ClearParent()
+ self:SetParent(nil, nil)
+end
+
return version
\ No newline at end of file
diff --git a/scripts/vscripts/alyxlib/globals.lua b/scripts/vscripts/alyxlib/globals.lua
index a4a54ab..c7acdaa 100644
--- a/scripts/vscripts/alyxlib/globals.lua
+++ b/scripts/vscripts/alyxlib/globals.lua
@@ -1,5 +1,5 @@
--[[
- v2.6.0
+ v2.7.0
https://github.com/FrostSource/alyxlib
Provides common global functions used throughout extravaganza libraries.
@@ -14,7 +14,7 @@
-- These are expected by globals
require 'alyxlib.utils.common'
-local _version = "v2.6.0"
+local _version = "v2.7.0"
---
---A registered AlyxLib addon.
@@ -261,6 +261,14 @@ function IsVREnabled()
return GlobalSys:CommandLineCheck('-vr')
end
+---
+---Gets if the game was started with `+vr_enable_fake_vr 1`.
+---
+---@return boolean
+function IsFakeVREnabled()
+ return GlobalSys:CommandLineInt("+vr_enable_fake_vr", 0) == 1
+end
+
---
---Prints all arguments with spaces between instead of tabs.
---
diff --git a/scripts/vscripts/alyxlib/helpers/animation.lua b/scripts/vscripts/alyxlib/helpers/animation.lua
index 266993c..7795e75 100644
--- a/scripts/vscripts/alyxlib/helpers/animation.lua
+++ b/scripts/vscripts/alyxlib/helpers/animation.lua
@@ -1,5 +1,5 @@
--[[
- v1.1.0
+ v1.2.0
https://github.com/FrostSource/alyxlib
Provides functionality for animating getter/setter entity methods using curves.
@@ -15,7 +15,7 @@
---@diagnostic disable: undefined-field
---@class Animation
Animation = {}
-Animation.version = "v1.1.0"
+Animation.version = "v1.2.0"
local localBounceOut = function(a, b, t)
local normalizedT = t
@@ -132,6 +132,32 @@ Animation.Curves = {
end
end,
+ -- Smooth start and end, fast middle
+ easeInOutQuad = function(a, b, t)
+ if t < 0.5 then
+ return a + (b - a) * (2 * t * t)
+ else
+ return a + (b - a) * (1 - 2 * (1 - t) * (1 - t))
+ end
+ end,
+
+ -- Sharp snap with slight overshoot
+ easeOutBack = function(a, b, t)
+ local c1 = 1.70158
+ local c3 = c1 + 1
+ return a + (b - a) * (c3 * (t - 1)^3 + c1 * (t - 1)^2 + 1)
+ end,
+
+ -- Fast start, gentle slow finish
+ easeOutQuart = function(a, b, t)
+ return a + (b - a) * (1 - (1 - t)^4)
+ end,
+
+ -- Quick start, smooth deceleration
+ easeOutCubic = function(a, b, t)
+ return a + (b - a) * (1 - (1 - t)^3)
+ end,
+
}
---
@@ -148,24 +174,28 @@ Animation.Curves = {
---@return fun(time:number):boolean # New animation function
function Animation:CreateAnimation(entity, getter, setter, targetValue, curveFunc)
local startValue = getter(entity)
+ local adjustedTarget = targetValue
local special = nil
if IsVector(targetValue) then
special = Vector
elseif IsQAngle(targetValue) then
special = QAngle
+ adjustedTarget = QAngle(
+ startValue.x + AngleDiff(targetValue.x, startValue.x),
+ startValue.y + AngleDiff(targetValue.y, startValue.y),
+ startValue.z + AngleDiff(targetValue.z, startValue.z)
+ )
end
return function(t)
local currentValue = getter(entity)
if not special then
- currentValue = curveFunc(startValue, targetValue, t)
+ currentValue = curveFunc(startValue, adjustedTarget, t)
else
- currentValue = special(
- curveFunc(startValue.x, targetValue.x, t),
- curveFunc(startValue.y, targetValue.y, t),
- curveFunc(startValue.z, targetValue.z, t)
- )
+ currentValue.x = curveFunc(startValue.x, adjustedTarget.x, t)
+ currentValue.y = curveFunc(startValue.y, adjustedTarget.y, t)
+ currentValue.z = curveFunc(startValue.z, adjustedTarget.z, t)
end
setter(entity, currentValue)
diff --git a/scripts/vscripts/alyxlib/helpers/easyconvars.lua b/scripts/vscripts/alyxlib/helpers/easyconvars.lua
index f63a7fc..ae7d0ac 100644
--- a/scripts/vscripts/alyxlib/helpers/easyconvars.lua
+++ b/scripts/vscripts/alyxlib/helpers/easyconvars.lua
@@ -1,5 +1,5 @@
--[[
- v1.2.0
+ v2.0.0
https://github.com/FrostSource/alyxlib
Allows for quick creation of convars which support persistence saving, checking GlobalSys for default values, and callbacks on value change.
@@ -16,7 +16,7 @@ require "alyxlib.globals"
---
---@class EasyConvars
EasyConvars = {}
-EasyConvars.version = "v1.2.0"
+EasyConvars.version = "v2.0.0"
---Data of a registered convar.
---@class EasyConvarsRegisteredData
@@ -24,6 +24,7 @@ EasyConvars.version = "v1.2.0"
---@field name string # Name of the convar
---@field desc? string # Description of the convar/command. Is displayed below the current value when called without a parameter.
---@field value string # Raw value of the convar.
+---@field prevValue string # Previous raw value of the convar.
---@field callback? fun(val:string, ...):any? # Optional callback function whenever the convar is changed.
---@field initializer? fun():any # Optional initializer function which will set the default value on player spawn.
---@field persistent boolean # If the value is saved to player on change.
@@ -44,7 +45,7 @@ EASYCONVARS_COMMAND = "command"
---Toggles have a value of "1" or "0" and display their state in the console.
EASYCONVARS_TOGGLE = "toggle"
----The table of all registered convars.
+---The table of all registered convars, (name -> data)
---@type table
EasyConvars.registered = {}
@@ -83,7 +84,9 @@ local function callCallback(registeredData, value, ...)
local result = nil
if registeredData.type == EASYCONVARS_COMMAND then
- result = registeredData.callback(value, ...)
+ if type(registeredData.callback) == "function" then
+ result = registeredData.callback(value, ...)
+ end
else
if registeredData.type == EASYCONVARS_TOGGLE and value == nil then
registeredData.value = valueToBoolStr(not truthy(registeredData.value))
@@ -91,22 +94,17 @@ local function callCallback(registeredData, value, ...)
registeredData.value = convertToSafeVal(value)
end
- result = registeredData.callback(registeredData.value, oldValue)
+ if type(registeredData.callback) == "function" then
+ result = registeredData.callback(registeredData.value, oldValue)
+ end
end
if result ~= nil then
registeredData.value = convertToSafeVal(result)
end
- EasyConvars:Save(registeredData.name)
-end
-
----Default display function for convars
----@param reg EasyConvarsRegisteredData
-local function defaultDisplayFuncConvar(reg)
- Msg(reg.name .. " = " .. tostring(reg.value) .. "\n")
- if reg.desc ~= nil and reg.desc ~= "" then
- Msg(reg.desc .. "\n")
+ if registeredData.persistent then
+ EasyConvars:Save(registeredData.name)
end
end
@@ -116,6 +114,60 @@ local function defaultDisplayFuncToggle(reg)
Msg(reg.name .. (truthy(reg.value) and " ON" or " OFF") .. "\n")
end
+---Standard callback for all command and toggle cvars.
+---@param name string # Name of the command called
+---@param ... string # Values given by the user through the console
+local function commandCallback(name, ...)
+ local cvar = EasyConvars.registered[name]
+ if cvar == nil then
+ return warn("Could not find registered data for cvar "..name)
+ end
+
+ local prevVal = cvar.value
+ local args = {...}
+
+ callCallback(cvar, args[1], vlua.slice(args, 1))
+
+ if prevVal ~= cvar.value then
+ cvar.wasChangedByUser = true
+ end
+
+ -- Display the new toggled state ON/OFF
+ if cvar.type == EASYCONVARS_TOGGLE then
+ if type(cvar.displayFunc) == "function" then
+ cvar.displayFunc(cvar)
+ end
+ end
+end
+
+---Sets the value of an EasyConvar.
+---@param registeredData EasyConvarsRegisteredData
+---@param value string
+---@param ... any
+local function setCvarValue(registeredData, value, ...)
+ if registeredData.type == EASYCONVARS_CONVAR then
+ Convars:SetStr(registeredData.name, convertToSafeVal(value))
+ else
+ callCallback(registeredData, value, ...)
+ end
+end
+
+---Listener for all FCVAR_NOTIFY changes.
+---@param params GameEventServerCvar
+ListenToGameEvent("server_cvar", function (params)
+ local cvar = EasyConvars.registered[params.cvarname]
+ if cvar == nil then return end
+
+ callCallback(cvar, params.cvarvalue, cvar.prevValue)
+
+ cvar.prevValue = cvar.value
+ cvar.value = params.cvarvalue
+
+ if cvar.prevValue ~= cvar.value then
+ cvar.wasChangedByUser = true
+ end
+end, nil)
+
---Creates a convar of any type.
---
---For simple creation use one of the following:
@@ -129,23 +181,19 @@ end
---@param onUpdate? (fun(val:string, ...):any?)|(fun(newVal:string, oldVal:string):any) # Optional callback function.
---@param helpText? string # Description of the convar
---@param flags? CVarFlags|integer # Flag for the convar
----@param displayFunc? fun(reg: EasyConvarsRegisteredData) # The function called when the convar is called without any parameters. By default it just prints the value.
----@overload fun(name: string, defaultValue: any, onUpdate: fun(newVal: string, oldVal: string):any?, helpText: string, flags: integer, displayFunc: function)
--- function EasyConvars:Register(name, default, onUpdate, helpText, flags, displayFunc, postUpdate, commandOnly)
-function EasyConvars:Register(ctype, name, defaultValue, onUpdate, helpText, flags, displayFunc)
+---@return EasyConvarsRegisteredData # The new registered cvar data
+function EasyConvars:Register(ctype, name, defaultValue, onUpdate, helpText, flags)
- -- GlobalSys:CommandLineStr("-"..name, GlobalSys:CommandLineCheck("-"..name) and "1" or tostring(default or "0"))
local launchVal = GlobalSys:CommandLineStr("-"..name, GlobalSys:CommandLineCheck("-"..name) and "1" or nil)
self.registered[name] = {
type = ctype,
callback = onUpdate,
value = launchVal,
+ prevValue = launchVal,
persistent = false,
- -- isCommand = commandOnly == true,
name = name,
desc = helpText,
wasChangedByUser = false,
- displayFunc = displayFunc or vlua.select(ctype == EASYCONVARS_CONVAR, defaultDisplayFuncConvar, defaultDisplayFuncToggle),
}
local reg = self.registered[name]
@@ -167,36 +215,18 @@ function EasyConvars:Register(ctype, name, defaultValue, onUpdate, helpText, fla
helpText = helpText or ""
flags = flags or 0
- -- reg.callback = onUpdate
-
- Convars:RegisterCommand(name, function (_, ...)
- local args = {...}
-
- -- Display current value
- if reg.type == EASYCONVARS_CONVAR and #args == 0 then
- if type(reg.displayFunc) == "function" then
- reg.displayFunc(reg)
- end
- -- Early exit
- return
- end
-
- local prevVal = reg.value
- local prevTruthy = truthy(reg.value)
-
- callCallback(reg, args[1], ...)
+ -- Notify is required to listen for value changes
+ if bit.band(flags, FCVAR_NOTIFY) == 0 then
+ flags = bit.bor(flags, FCVAR_NOTIFY)
+ end
- if prevVal ~= reg.value then
- reg.wasChangedByUser = true
- end
+ if ctype == EASYCONVARS_COMMAND or ctype == EASYCONVARS_TOGGLE then
+ Convars:RegisterCommand(name, commandCallback, helpText, flags)
+ else
+ Convars:RegisterConvar(name, reg.value, helpText, flags)
+ end
- -- Display the new toggled state
- if reg.type == EASYCONVARS_TOGGLE then
- if type(reg.displayFunc) == "function" then
- reg.displayFunc(reg)
- end
- end
- end, helpText, flags)
+ return reg
end
---
@@ -230,7 +260,6 @@ end
---@param name string # The name of the convar to save.
function EasyConvars:Save(name)
if not self.registered[name] then return end
- if self.registered[name].persistent == false then return end
local saver = Player or GetListenServerHost()
if not saver then
@@ -251,7 +280,8 @@ end
---@param name string # The name of the convar to load.
---@return boolean # Returns true if the convar was loaded successfully.
function EasyConvars:Load(name)
- if not self.registered[name] then return false end
+ local cvar = self.registered[name]
+ if not cvar then return false end
local loader = Player or GetListenServerHost()
if not loader then
@@ -261,16 +291,15 @@ function EasyConvars:Load(name)
self._isLoading = true
- local val = loader:LoadString("easyconvar_"..name, nil)
- if val ~= nil then
- self.registered[name].persistent = true
- -- If it has a callback, execute to run any necessary code
- callCallback(self.registered[name], val)
+ local loadedValue = loader:LoadString("easyconvar_"..name, nil)
+ if loadedValue ~= nil then
+ cvar.persistent = true
+ setCvarValue(cvar, loadedValue)
end
self._isLoading = false
- return val ~= nil
+ return loadedValue ~= nil
end
---
@@ -301,11 +330,10 @@ end
---@param name string # Name of the convar
---@param defaultValue? "0"|any|fun():any # Default value of the convar, or an initializer function
---@param helpText? string # Description of the convar
----@param flags? `nil`|CVarFlags|integer # Flags for the convar
+---@param flags? CVarFlags|integer # Flags for the convar
---@param postUpdate? fun(newVal:string, oldVal:string): any # Update function called after the value has been changed
----@param displayFunc? fun(reg: EasyConvarsRegisteredData) # Optional custom display function
-function EasyConvars:RegisterConvar(name, defaultValue, helpText, flags, postUpdate, displayFunc)
- self:Register(EASYCONVARS_CONVAR,name, defaultValue, postUpdate, helpText, flags, displayFunc)
+function EasyConvars:RegisterConvar(name, defaultValue, helpText, flags, postUpdate)
+ self:Register(EASYCONVARS_CONVAR,name, defaultValue, postUpdate, helpText, flags)
end
---
@@ -316,12 +344,14 @@ end
---@param helpText? string # Description of the command
---@param flags? CVarFlags|integer # Flags for the command
function EasyConvars:RegisterCommand(name, callback, helpText, flags)
- self:Register(EASYCONVARS_COMMAND, name, nil, callback, helpText, flags, nil)
+ self:Register(EASYCONVARS_COMMAND, name, nil, callback, helpText, flags)
end
---
---Registers a new toggle convar.
---
+---This is a command that has an on/off state like `god` or `notarget`.
+---
---@param name string # Name of the convar
---@param defaultValue? "0"|any|fun():any # Default value of the convar
---@param helpText? string # Description of the convar
@@ -329,7 +359,17 @@ end
---@param postUpdate? fun(newVal:string, oldVal:string): any # Update function called after the value has been changed
---@param displayFunc? fun(reg: EasyConvarsRegisteredData) # Optional custom display function
function EasyConvars:RegisterToggle(name, defaultValue, helpText, flags, postUpdate, displayFunc)
- EasyConvars:Register(EASYCONVARS_TOGGLE, name, defaultValue, postUpdate, helpText, flags, displayFunc)
+ local cvar = EasyConvars:Register(EASYCONVARS_TOGGLE, name, defaultValue, postUpdate, helpText, flags)
+ cvar.displayFunc = displayFunc or defaultDisplayFuncToggle
+end
+
+---
+---Gets the [EasyConvarsRegisteredData](lua://EasyConvarsRegisteredData) table for a given cvar name.
+---
+---@param name string # The name of the registered EasyConvar to get the data for
+---@return EasyConvarsRegisteredData? # The data associated with `name`
+function EasyConvars:GetConvarData(name)
+ return self.registered[name]
end
---
@@ -338,8 +378,14 @@ end
---@param name string # Name of the convar
---@return string? # The value of the convar as a string or nil if convar does not exist
function EasyConvars:GetStr(name)
- if not self.registered[name] then return nil end
- return self.registered[name].value
+ local cvar = self.registered[name]
+ if not cvar then return nil end
+ -- return self.registered[name].value
+ if cvar.type == EASYCONVARS_CONVAR then
+ return Convars:GetStr(name)
+ else
+ return cvar.value
+ end
end
---
@@ -380,7 +426,7 @@ end
function EasyConvars:SetStr(name, value)
local reg = self.registered[name]
if not reg then return nil end
- callCallback(reg, value)
+ setCvarValue(reg, value)
end
---
@@ -391,7 +437,7 @@ end
function EasyConvars:SetBool(name, value)
local reg = self.registered[name]
if not reg then return nil end
- callCallback(reg, valueToBoolStr(value))
+ setCvarValue(reg, valueToBoolStr(value))
end
---
@@ -402,7 +448,7 @@ end
function EasyConvars:SetFloat(name, value)
local reg = self.registered[name]
if not reg then return nil end
- callCallback(reg, tostring(value))
+ setCvarValue(reg, tostring(value))
end
---
@@ -413,7 +459,7 @@ end
function EasyConvars:SetInt(name, value)
local reg = self.registered[name]
if not reg then return nil end
- callCallback(reg, tostring(math.floor(value)))
+ setCvarValue(reg, tostring(math.floor(value)))
end
---
@@ -460,7 +506,7 @@ function EasyConvars:SetIfUnchanged(name, value)
local reg = self.registered[name]
if not reg then return end
if not reg.wasChangedByUser then
- callCallback(reg, value)
+ setCvarValue(reg, value)
end
end
@@ -489,7 +535,7 @@ listener("player_activate", function (params)
data.wasChangedByUser = true
devprints2("EasyConvars", name, "initializer won't be used because it has a user value of", tostring(data.value))
else
- data.value = convertToSafeVal(data.initializer())
+ setCvarValue(data, convertToSafeVal(data.initializer()))
data.defaultValue = data.value
devprints2("EasyConvars", name, "initializer value was", data.value)
end
diff --git a/scripts/vscripts/alyxlib/init.lua b/scripts/vscripts/alyxlib/init.lua
index 5a19946..4b5439b 100644
--- a/scripts/vscripts/alyxlib/init.lua
+++ b/scripts/vscripts/alyxlib/init.lua
@@ -1,5 +1,5 @@
--[[
- v1.2.1
+ v1.2.2
https://github.com/FrostSource/alyxlib
The main initializer script loads any standard libraries that it can find.
@@ -11,12 +11,12 @@
]]
-- Version of this file
-local version = "v1.2.1"
+local version = "v1.2.2"
---ID of AlyxLib in the Steam workshop
ALYXLIB_WORKSHOP_ID = "3329679071"
---The current version of AlyxLib as a whole
-ALYXLIB_VERSION = "v1.3.1"
+ALYXLIB_VERSION = "v2.0.0"
print("Initializing AlyxLib system ".. ALYXLIB_VERSION .." ...")
@@ -35,6 +35,7 @@ end
---Loads a library if it exists and prints version.
---@param path string # The path to the library
+---@param required? boolean # If not true, any errors will be suppressed
local function alyxlib_require(path, required)
if required then
print_version(path, require(path))
@@ -92,6 +93,7 @@ if IsVREnabled() then
else
alyxlib_require "alyxlib.debug.novr"
end
+alyxlib_require "alyxlib.debug.debug_menu"
-- Common third-party libraries
diff --git a/scripts/vscripts/alyxlib/math/weighted_random.lua b/scripts/vscripts/alyxlib/math/weighted_random.lua
index 6b0edb0..5f7cc32 100644
--- a/scripts/vscripts/alyxlib/math/weighted_random.lua
+++ b/scripts/vscripts/alyxlib/math/weighted_random.lua
@@ -1,5 +1,5 @@
--[[
- v1.2.3
+ v1.2.4
https://github.com/FrostSource/alyxlib
Weighted random allows you to assign chances to tables keys.
@@ -9,7 +9,7 @@
require "alyxlib.math.weighted_random"
]]
-local version = "v1.2.3"
+local version = "v1.2.4"
---
---A list of tables with associated weights.
@@ -117,7 +117,7 @@ end
---
---Pick a random table from the list of weighted tables.
---
----@return WeightedRandomItem # The chosen table.
+---@return WeightedRandomItem|table # The chosen table.
function WR:Random()
local weight_sum = self:TotalWeight()
local weight_remaining
diff --git a/scripts/vscripts/alyxlib/panorama/core.lua b/scripts/vscripts/alyxlib/panorama/core.lua
index 250234c..86eb7d7 100644
--- a/scripts/vscripts/alyxlib/panorama/core.lua
+++ b/scripts/vscripts/alyxlib/panorama/core.lua
@@ -1,12 +1,12 @@
--[[
- v1.1.0
+ v1.1.1
https://github.com/FrostSource/alyxlib
Panorama core library.
]]
Panorama = {}
-Panorama.version = "v1.1.0"
+Panorama.version = "v1.1.1"
---Filters text string to replace problematic characters.
---@param text string
@@ -56,15 +56,18 @@ function Panorama:Send(panelEntity, ...)
local dataString = id .. "|"
local data = {...}
local i = 1
- local dataLength = #data
+
+ local flattenedData = {}
-- Flatten nested tables into data
- while i <= dataLength do
- if type(data[i]) == "table" then
- data = vlua.extend(vlua.slice(data, 1, dataLength), data[i])
+ for _, value in ipairs(data) do
+ if type(value) == "table" then
+ data = vlua.extend(flattenedData, value)
+ else
+ table.insert(flattenedData, value)
end
- i = i + 1
end
+ local dataLength = #flattenedData
-- Put all values into a single pipe separated string
for index, value in ipairs(data) do
diff --git a/scripts/vscripts/alyxlib/player/core.lua b/scripts/vscripts/alyxlib/player/core.lua
index 37ae577..5739697 100644
--- a/scripts/vscripts/alyxlib/player/core.lua
+++ b/scripts/vscripts/alyxlib/player/core.lua
@@ -1,106 +1,20 @@
--[[
- v4.2.0
+ v4.2.2
https://github.com/FrostSource/alyxlib
Player script allows for more advanced player manipulation and easier
entity access for player related entities by extending the player class.
- If not using `vscripts/alyxlib/core.lua`, load this file at game start using the following line:
+ If not using `vscripts/alyxlib/init.lua`, load this file at game start using the following line:
- ```lua
require "alyxlib.player.core"
- ```
-
- This module returns the version string.
-
- ======================================== Usage ========================================
-
- Common method for referencing the player and related entities is:
-
- ```lua
- local player = Entities:GetLocalPlayer()
- local hmd_avatar = player:GetHMDAvatar()
- local left_hand = hmd_avatar:GetVRHand(0)
- local right_hand = hmd_avatar:GetVRHand(1)
- ```
-
- This script simplifies the above code significantly by automatically
- caching player entity handles when the player spawns and introducing
- the global variable 'Player' which references the base player entity:
-
- ```lua
- local player = Player
- local hmd_avatar = Player.HMDAvatar
- local left_hand = Player.LeftHand
- local right_hand = Player.RightHand
- ```
-
- Since this script extends the player entity class directly you can mix and match your scripting style
- without worrying that you're referencing the wrong player table.
-
- ```lua
- Entities:GetLocalPlayer() == Player
- ```
-
- ======================================== Player Callbacks ========================================
-
- Many game events related to the player are used to track player activity and callbacks can be
- registered to hook into them the same way you would a game event:
-
- ```lua
- RegisterPlayerEventCallback("vr_player_ready", function(params)
- ---@cast params PLAYER_EVENT_VR_PLAYER_READY
- if params.game_loaded then
- -- Load params
- end
- end)
- ```
-
- Although most of the player events are named after the same game events, the data that is passed to
- the callback is pre-processed and extended to provide better context for the event:
-
- ```lua
- ---@param params PLAYER_EVENT_ITEM_PICKUP
- RegisterPlayerEventCallback("item_pickup", function(params)
- if params.hand == Player.PrimaryHand and params.item_name == "@gun" then
- params.item:DoNotDrop(true)
- end
- end)
- ```
-
- ======================================== Tracking Items ========================================
-
- The script attempts to track player items, both inventory and physically held objects.
- These can be accessed through several new player tables and variables.
-
- Below are a few of the new variables that point an entity handle that the player has interacted with:
-
- ```lua
- Player.PrimaryHand.WristItem
- Player.PrimaryHand.ItemHeld
- Player.PrimaryHand.LastItemDropped
- ```
-
- The player might not be holding anything so remember to nil check:
-
- ```lua
- local item = Player.PrimaryHand.ItemHeld
- if item then
- local primary_held_name = item:GetName()
- end
- ```
-
- The `Player.Items` table keeps track of the ammo and resin the player has in the backpack.
- One addition value tracked is `resin_found` which is the amount of resin the player has
- collected regardless of removing from backpack or spending on upgrades.
-
]]
require "alyxlib.utils.common"
require "alyxlib.globals"
require "alyxlib.extensions.entity"
require "alyxlib.storage"
-local version = "v4.2.0"
+local version = "v4.2.2"
-----------------------------
-- Class extension members --
@@ -145,6 +59,9 @@ CBasePlayer.LastItemGrabbed = nil
---**The classname of the last entity grabbed by the player. In case the entity no longer exists.**
---@type string
CBasePlayer.LastClassGrabbed = ""
+---**The player hand that last grabbed an object.**
+---@type CPropVRHand
+CBasePlayer.LastGrabHand = nil
---@alias PLAYER_WEAPON_HAND "hand_use_controller"
---@alias PLAYER_WEAPON_ENERGYGUN "hlvr_weapon_energygun"
@@ -957,11 +874,16 @@ function CBasePlayer:UpdateWeaponsExistence()
end
end
- for i = #weapons.genericpistols, 1, -1 do
- local generic = weapons.genericpistols[i]
- local swt = Entities:FindByName(nil, "wpnswitch_" .. generic:GetName())
- if not swt then
- table.remove(weapons.genericpistols, i)
+ if #weapons.genericpistols > 0 then
+ for i = #weapons.genericpistols, 1, -1 do
+ local generic = weapons.genericpistols[i]
+ -- Server change seems to destroy weapons before this is run
+ if IsValidEntity(generic) then
+ local swt = Entities:FindByName(nil, "wpnswitch_" .. generic:GetName())
+ if not swt then
+ table.remove(weapons.genericpistols, i)
+ end
+ end
end
end
end
@@ -1038,6 +960,27 @@ function CBasePlayer:SetAnchorOriginAroundPlayer(pos)
self.HMDAnchor:SetAbsOrigin(pos + (self.HMDAnchor:GetAbsOrigin() - self:GetAbsOrigin()))
end
+---
+---Sets the enabled state of the cough handpose attached to the HMD avatar.
+---
+---@param enabled boolean # True if the cough handpose should be enabled
+function CBasePlayer:SetCoughHandEnabled(enabled)
+ if not self.HMDAvatar then
+ return
+ end
+
+ for _, child in ipairs(self.HMDAvatar:GetChildrenMemSafe()) do
+ if child:GetModelName() == "models/props/handposes/handpose_cough.vmdl" then
+ if enabled then
+ child:EntFire("Enable")
+ else
+ child:EntFire("Disable")
+ end
+ break
+ end
+ end
+end
+
-- Other player libraries
require "alyxlib.player.hands"
require "alyxlib.player.events"
diff --git a/scripts/vscripts/alyxlib/player/events.lua b/scripts/vscripts/alyxlib/player/events.lua
index ed7046f..16fee2b 100644
--- a/scripts/vscripts/alyxlib/player/events.lua
+++ b/scripts/vscripts/alyxlib/player/events.lua
@@ -1,11 +1,11 @@
--[[
- v2.1.3
+ v2.2.0
https://github.com/FrostSource/alyxlib
]]
-local version = "v2.1.3"
+local version = "v2.2.0"
---@class __PlayerRegisteredEventData
---@field callback function
@@ -107,6 +107,10 @@ local player_weapon_to_ammotype =
---
local function savePlayerData()
Storage.SaveTable(Player, "PlayerItems", Player.Items)
+
+ -- Weapons aren't re-equipped on load so we need to save this
+ Storage.SaveString(Player, "PlayerCurrentlyEquipped", Player.CurrentlyEquipped)
+
if Player and Player.LeftHand then
Storage.SaveEntity(Player, "LeftWristItem", Player.LeftHand.WristItem)
end
@@ -117,6 +121,15 @@ end
local function loadPlayerData()
Player.Items = Storage.LoadTable(Player, "PlayerItems", Player.Items)
+
+ Player.CurrentlyEquipped = Storage.LoadString(Player, "PlayerCurrentlyEquipped", Player.CurrentlyEquipped)
+
+ if Player and Player.LeftHand then
+ Player.LeftHand.WristItem = Storage.LoadEntity(Player, "LeftWristItem", Player.LeftHand.WristItem)
+ end
+ if Player and Player.RightHand then
+ Player.RightHand.WristItem = Storage.LoadEntity(Player, "RightWristItem", Player.RightHand.WristItem)
+ end
end
---Callback logic for every player event.
@@ -267,6 +280,8 @@ local function listenEventItemPickup(data)
local hand = Player.Hands[handId + 1]
local otherhand = Player.Hands[(1 - handId) + 1]
+ Player.LastGrabHand = hand
+
---@type EntityHandle
local ent_held
@@ -648,6 +663,8 @@ end
ListenToGameEvent("player_drop_resin_in_backpack", listenEventPlayerDropResinInBackpack, nil)
---@class PlayerEventWeaponSwitch : GameEventWeaponSwitch
+---@field item EntityHandle|nil # The handle of the weapon being switched to or nil if no weapon.
+---@field item_class string # Classname of the entity that was switched to.
---Track weapon equipped
---@param data GameEventWeaponSwitch
@@ -658,10 +675,13 @@ local function listenEventWeaponSwitch(data)
Player.PreviouslyEquipped = Player.CurrentlyEquipped
+ ---@type EntityHandle
+ local weaponHandle = nil
+
if data.item == "hand_use_controller" then
Player.CurrentlyEquipped = PLAYER_WEAPON_HAND
else
- local weaponHandle = Entities:FindBestMatching("", data.item, Player.PrimaryHand:GetPalmPosition(), 64)
+ weaponHandle = Entities:FindBestMatching("", data.item, Player.PrimaryHand:GetPalmPosition(), 64)
if data.item == "hlvr_weapon_energygun" then
Player.CurrentlyEquipped = PLAYER_WEAPON_ENERGYGUN
Player.Items.weapons.energygun = weaponHandle
@@ -686,10 +706,18 @@ local function listenEventWeaponSwitch(data)
Player:UpdateWeaponsExistence()
+ -- This event can fire before the player has a hand
+ if Player.PrimaryHand then
+ Player.PrimaryHand.ItemHeld = weaponHandle
+ end
+
savePlayerData()
-- Registered callback
- eventCallback(data.game_event_name, data)
+ local newdata = vlua.clone(data)--[[@as PlayerEventWeaponSwitch]]
+ newdata.item = weaponHandle
+ newdata.item_class = data.item
+ eventCallback(data.game_event_name, newdata)
end
ListenToGameEvent("weapon_switch", listenEventWeaponSwitch, nil)
diff --git a/scripts/vscripts/alyxlib/player/hands.lua b/scripts/vscripts/alyxlib/player/hands.lua
index 3ac48ac..d4f57cf 100644
--- a/scripts/vscripts/alyxlib/player/hands.lua
+++ b/scripts/vscripts/alyxlib/player/hands.lua
@@ -1,12 +1,12 @@
--[[
- v1.2.0
+ v1.3.0
https://github.com/FrostSource/alyxlib
Code for player hands.
]]
-local version = "v1.2.0"
+local version = "v1.3.0"
---Merge an existing prop with this hand.
---@param prop EntityHandle|string # The prop handle or targetname.
@@ -45,6 +45,20 @@ function CPropVRHand:Drop()
end
end
+---Grab the entity
+---@param ent EntityHandle|string
+function CPropVRHand:Grab(ent)
+ if type(ent) == "string" then
+ local name = ent
+ ent = Entities:FindByName(nil, name)
+ if ent == nil then
+ return warn("Could not find entity to grab with name " .. name)
+ end
+ end
+
+ ent:Grab(self)
+end
+
---Get the rendered glove entity for this hand, i.e. the first `hlvr_prop_renderable_glove` class.
---@return EntityHandle|nil
function CPropVRHand:GetGlove()
@@ -82,6 +96,19 @@ function CPropVRHand:GetPalmPosition()
end
end
+---
+---Gets the 'hand_use_controller' entity associated with this hand.
+---
+---@return EntityHandle
+function CPropVRHand:GetHandUseController()
+ for _, controller in ipairs(Entities:FindAllByClassname("hand_use_controller")) do
+ if controller:GetOwner() == self then
+ return controller
+ end
+---@diagnostic disable-next-line: missing-return
+ end
+end
+
---Forces the player to drop this entity if held.
---@param self CBaseEntity
function CBaseEntity:Drop()
diff --git a/scripts/vscripts/alyxlib/precache.lua b/scripts/vscripts/alyxlib/precache.lua
index bd1b160..d728826 100644
--- a/scripts/vscripts/alyxlib/precache.lua
+++ b/scripts/vscripts/alyxlib/precache.lua
@@ -1,5 +1,5 @@
--[[
- v1.0.1
+ v1.0.2
https://github.com/FrostSource/alyxlib
Precaching can only be done with an entity attached script, so this script collects a list of assets to be automatically
@@ -31,7 +31,7 @@ if thisEntity then
return
end
-local version = "v1.0.1"
+local version = "v1.0.2"
require "alyxlib.player.core"
@@ -72,7 +72,7 @@ function _PrecacheGlobalItems(context)
if #AlyxLibGlobalPrecacheList > 0 then
devprints("Globally precaching", #AlyxLibGlobalPrecacheList, "resources...")
for _, item in ipairs(AlyxLibGlobalPrecacheList) do
- devprints("\nPrecaching", item.type, item.path)
+ devprints("\tPrecaching", item.type, item.path)
if item.type == "model" then
PrecacheModel(item.path, context)
elseif item.type == "entity" then
diff --git a/templates/gitignore.txt b/templates/gitignore.txt
new file mode 100644
index 0000000..e736d30
--- /dev/null
+++ b/templates/gitignore.txt
@@ -0,0 +1,14 @@
+# Source 2 ignores
+
+/_bakeresourcecache/
+*bakeresourcecache.vpk
+__pycache__
+
+# AlyxLib ignores
+
+scripts/vscripts/alyxlib
+scripts/vscripts/game/gameinit.lua
+.vscode/alyxlib.code-snippets
+.vscode/vlua_snippets.code-snippets
+panorama/scripts/custom_game/panorama_lua.js
+panorama/scripts/custom_game/panoramadoc.js
\ No newline at end of file
diff --git a/templates/resource_manifest.txt b/templates/resource_manifest.txt
new file mode 100644
index 0000000..6e99652
--- /dev/null
+++ b/templates/resource_manifest.txt
@@ -0,0 +1,9 @@
+
+{{
+ resourceManifest =
+ [
+ [
+ "soundevents/{0}_soundevents.vsndevts",
+ ],
+ ]
+}}
\ No newline at end of file
diff --git a/templates/script_init_local.txt b/templates/script_init_local.txt
new file mode 100644
index 0000000..2a6c868
--- /dev/null
+++ b/templates/script_init_local.txt
@@ -0,0 +1,2 @@
+-- This file was automatically generated by AlyxLib.
+require("{0}.init")
\ No newline at end of file
diff --git a/templates/script_init_main.txt b/templates/script_init_main.txt
new file mode 100644
index 0000000..e451170
--- /dev/null
+++ b/templates/script_init_main.txt
@@ -0,0 +1,10 @@
+-- This file was automatically generated by AlyxLib.
+
+-- alyxlib can only run on server
+if IsServer() then
+ -- Load alyxlib before using it, in case this mod loads before the alyxlib mod.
+ require("alyxlib.init")
+
+ -- execute code or load mod libraries here
+
+end
\ No newline at end of file
diff --git a/templates/script_init_workshop.txt b/templates/script_init_workshop.txt
new file mode 100644
index 0000000..9cbf785
--- /dev/null
+++ b/templates/script_init_workshop.txt
@@ -0,0 +1,3 @@
+-- This file was automatically generated by AlyxLib.
+-- Rename this file to the ID of your workshop item after upload.
+require("{0}.init")
\ No newline at end of file
diff --git a/templates/soundevents.txt b/templates/soundevents.txt
new file mode 100644
index 0000000..6df5591
--- /dev/null
+++ b/templates/soundevents.txt
@@ -0,0 +1,3 @@
+
+{
+}
diff --git a/templates/test_delete.txt b/templates/test_delete.txt
new file mode 100644
index 0000000..bed55ec
--- /dev/null
+++ b/templates/test_delete.txt
@@ -0,0 +1,2 @@
+-- This file was automatically generated by AlyxLib.
+require("test_addon_name.init")
\ No newline at end of file
diff --git a/templates/vscode_settings.txt b/templates/vscode_settings.txt
new file mode 100644
index 0000000..4510211
--- /dev/null
+++ b/templates/vscode_settings.txt
@@ -0,0 +1,28 @@
+{
+ "Lua.workspace.library": [
+ "${addons}/HLA-VScript/module/library"
+ ],
+ "Lua.runtime.version": "LuaJIT",
+ "Lua.runtime.builtin": {
+ "coroutine": "enable",
+ "debug": "enable",
+ "io": "disable",
+ "math": "enable",
+ "os": "disable",
+ "package": "enable",
+ "string": "enable",
+ "table": "enable",
+ "utf8": "disable",
+ "bit": "enable",
+ "bit32": "disable",
+ "jit": "disable"
+ },
+ "Lua.type.weakUnionCheck": true,
+ "Lua.type.weakNilCheck": true,
+ "Lua.diagnostics.disable": [
+ "inject-field"
+ ],
+ "Lua.workspace.checkThirdParty": false,
+ "Lua.diagnostics.ignoredFiles": "Enable",
+ "Lua.workspace.useGitIgnore": false
+}
\ No newline at end of file
diff --git a/version.json b/version.json
index b74ddb0..a06dd3f 100644
--- a/version.json
+++ b/version.json
@@ -1,3 +1,3 @@
{
- "version": "1.1.0"
+ "version": "2.0.0"
}
\ No newline at end of file
diff --git a/workshop_changelog b/workshop_changelog
index 2d47d8b..bb23126 100644
--- a/workshop_changelog
+++ b/workshop_changelog
@@ -1,3 +1,15 @@
+Update v2.0.0 (Major update)
+
+New:
+
+Added a new in-game menu. Addons can create their own tabs to allow in-game customization.
+
+The menu can be enabled by adding "-dev" to the Half-Life: Alyx launch parameters in Steam. After loading into a map, press the pause menu button 3 times in a row to open the AlyxLib menu.
+
+Any AlyxLib addons using EasyConvars will need to be updated to the new standards to be compatible.
+
+There are also many various improvements and fixes.
+
Update v1.3.1 (Patch update)
General: