diff --git a/.gitignore b/.gitignore index 7067ef5..39a0086 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__ /test_release/ __test* *.bak +check_lua_versions.sh diff --git a/README.md b/README.md index 7a13437..8801263 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,10 @@

- - - AlyxLib Logo + AlyxLib Logo

-  -
[![License](https://img.shields.io/badge/License-MIT-04663E)](#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: