diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..716dbfe --- /dev/null +++ b/.gitignore @@ -0,0 +1,88 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/lua,visualstudiocode,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=lua,visualstudiocode,windows + +### Lua ### +# Compiled Lua sources +luac.out + +# luarocks build files +*.src.rock +*.zip +*.tar.gz + +# Object files +*.o +*.os +*.ko +*.obj +*.elf + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo +*.def +*.exp + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +[Bb]uild + +# End of https://www.toptal.com/developers/gitignore/api/lua,visualstudiocode,windows diff --git a/MWSE/mods/celediel/MoreAttentiveGuards/combat.lua b/MWSE/mods/celediel/MoreAttentiveGuards/combat.lua new file mode 100644 index 0000000..4126d4c --- /dev/null +++ b/MWSE/mods/celediel/MoreAttentiveGuards/combat.lua @@ -0,0 +1,68 @@ +local common = require("celediel.MoreAttentiveGuards.common") +local config = require("celediel.MoreAttentiveGuards.config").getConfig() + +local this = {} + +-- {{{ helper functions + +local function log(...) if config.debug then common.log(...) end end + +local function alertGuards(aggressor, cell) + -- a wanted player gets no help + if tes3.mobilePlayer.bounty > 0 then + log("Player is wanted, ignoring combat.") + return + end + + log("Checking for guards in cell %s to bring justice to %s", cell.name or cell.id, aggressor.object.name) + local playerPos = tes3.mobilePlayer.position + + for npc in cell:iterateReferences(tes3.objectType.npc) do + local distance = playerPos:distance(npc.position) + if npc.object.isGuard and distance <= config.combatDistance then + log("Alerting %s, %s units away, to the combat!", npc.object.name, distance) + + if config.combatDialogue then + local response = common.guardDialogue(npc, table.choice(common.dialogues[config.language].join_combat), + aggressor) + log(response) + end + + npc.mobile:startCombat(aggressor) + end + end +end + +-- }}} + +-- {{{ returned event functions + +this.onCombatStart = function(e) + if not config.combatEnable then return end + + -- if player initiates combat or combat is not against player, do nothing + if e.actor == tes3.mobilePlayer or e.target ~= tes3.mobilePlayer then return end + + -- inCombat is true after player has taken combat actions + -- or after combat has gone on awhile, but hopefully the guards will already be attacking by then + -- should be fine in cities, but will prevent players from provoking NPCs + -- in the wilderness and leading them into town + if tes3.mobilePlayer.inCombat then + log("Player is in combat, not sure who started it, so not helping.") + return + end + + local cell = tes3.getPlayerCell() + + if cell.isInterior and not cell.behavesAsExterior then + alertGuards(e.actor, cell) + else + for _, extCell in pairs(tes3.getActiveCells()) do alertGuards(e.actor, extCell) end + end +end + +-- }}} + +return this + +-- vim:fdm=marker diff --git a/MWSE/mods/celediel/MoreAttentiveGuards/common.lua b/MWSE/mods/celediel/MoreAttentiveGuards/common.lua new file mode 100644 index 0000000..5e5b1fe --- /dev/null +++ b/MWSE/mods/celediel/MoreAttentiveGuards/common.lua @@ -0,0 +1,58 @@ +local this = {} + +-- {{{ mod info and such + +this.modName = "More Attentive Guards" -- or something +this.author = "Celediel" +this.version = "0.0.1" +this.modInfo = [[Guards with some actual spacial awareness! +Guards who catch you sneaking will follow you for a bit of +time, and will also come to the player's rescue if attacked unprovoked.]] +this.dialogues = require("celediel.MoreAttentiveGuards.dialogues") +this.configString = string.gsub(this.modName, "%s+", "") + +-- }}} + +-- {{{ NPC stuff or whatever + +this.basicIdles = {60, 20, 20, 20, 0, 0, 0, 0} + +-- }}} + +-- {{{ functions + +this.log = function(...) mwse.log("[%s] %s", this.modName, string.format(...)) end + +-- https://en.uesp.net/wiki/Tes3Mod:AIWander told me some things about idles +this.generateIdles = function() + local idles = {} + -- idles[1] = 0 -- ? idle 1 is not used? + for i = 1, 4 do idles[i] = math.random(0, 60) end + idles[5] = 0 -- ? Idle6: Rubbing hands together and showing wares + for i = 6, 8 do idles[i] = math.random(0, 60) end + return idles +end + +this.guardDialogue = function(npc, str, target) + -- target of the dialogue, either an NPC/Creature, or the player's class or race + -- this is what %s is replaced with in the dialogue string; npc/creature for combat, player for sneak + local targetOrPlayer + if target == tes3.mobilePlayer then + targetOrPlayer = math.random() >= 0.5 and target.object.class.name or target.object.race.name + else + targetOrPlayer = target.object.name + end + + local name = npc.object.name + local message = string.format(str, targetOrPlayer) + local output = string.format("%s: %s", name, message) + + tes3.messageBox(output) + return output +end + +-- }}} + +return this + +-- vim:fdm=marker diff --git a/MWSE/mods/celediel/MoreAttentiveGuards/config.lua b/MWSE/mods/celediel/MoreAttentiveGuards/config.lua new file mode 100644 index 0000000..482df06 --- /dev/null +++ b/MWSE/mods/celediel/MoreAttentiveGuards/config.lua @@ -0,0 +1,26 @@ +local common = require("celediel.MoreAttentiveGuards.common") +local this = {} + +local currentConfig + +this.default = { + -- common + language = "english", + debug = false, + -- sneak + sneakEnable = true, + sneakDialogue = true, + sneakDialogueTimer = 5, + sneakDialogueChance = 67, + -- combat + combatEnable = true, + combatDistance = 850, + combatDialogue = true +} + +function this.getConfig() + currentConfig = currentConfig or mwse.loadConfig(common.configString, this.default) + return currentConfig +end + +return this diff --git a/MWSE/mods/celediel/MoreAttentiveGuards/dialogues.lua b/MWSE/mods/celediel/MoreAttentiveGuards/dialogues.lua new file mode 100644 index 0000000..adebe83 --- /dev/null +++ b/MWSE/mods/celediel/MoreAttentiveGuards/dialogues.lua @@ -0,0 +1,35 @@ +return { + ["english"] = { + -- guards might say this every so often to players who are sneaking + -- %s is replaced with race or class + ["sneaking"] = { + "Why are you sneaking around like a s'wit?", + "What are you up to, %s...?", + "What are you up to...?", + "I'm watching you, %s...", + "I'm watching you...", + "I've got my eye on you, %s...", + "I've got my eye on you..." + }, + -- guards say this when players stop sneaking while being followed + -- %s is replaced with race or class + ["stop_sneaking"] = { + "That's what I thought.", + "That's better, %s.", + }, + -- guards say this when they're satisfied that the player is not doing anything illegal + -- %s is replaced with race or class + ["stop_following"] = { + "I don't have time for this...", + "Sneaking around for no reason, are we...?", + "Sneaking around for no reason, are we...? Alright then, %s.", + }, + -- guards say this when coming to player's rescue when they're attacked unprovoked + -- %s is replaced with the name of the npc or creature attacking the player + ["join_combat"] = { + "Not today %s!", + "You n'wah!", + "Stop right there, criminal scum!" + } + } +} diff --git a/MWSE/mods/celediel/MoreAttentiveGuards/main.lua b/MWSE/mods/celediel/MoreAttentiveGuards/main.lua new file mode 100644 index 0000000..82562e6 --- /dev/null +++ b/MWSE/mods/celediel/MoreAttentiveGuards/main.lua @@ -0,0 +1,15 @@ +local sneak = require("celediel.MoreAttentiveGuards.sneak") +local combat = require("celediel.MoreAttentiveGuards.combat") +local common = require("celediel.MoreAttentiveGuards.common") + +local eventPattern = "on(%u)" + +local function onInitialized() + -- in order for this to work, all functions in returned table must be named onEventName + for name, func in pairs(sneak) do event.register(name:gsub(eventPattern, string.lower), func) end + for name, func in pairs(combat) do event.register(name:gsub(eventPattern, string.lower), func) end + common.log("%s initialized", common.modName) +end + +event.register("initialized", onInitialized) +event.register("modConfigReady", function() mwse.mcm.register(require("celediel.MoreAttentiveGuards.mcm")) end) diff --git a/MWSE/mods/celediel/MoreAttentiveGuards/mcm.lua b/MWSE/mods/celediel/MoreAttentiveGuards/mcm.lua new file mode 100644 index 0000000..bf871d8 --- /dev/null +++ b/MWSE/mods/celediel/MoreAttentiveGuards/mcm.lua @@ -0,0 +1,109 @@ +local config = require("celediel.MoreAttentiveGuards.config").getConfig() +local common = require("celediel.MoreAttentiveGuards.common") + +local function createTableVar(id) return mwse.mcm.createTableVariable({id = id, table = config}) end + +local function createLanguageOptions() + local options = {} + -- I guess I don't know how ipairs works + local i = 1 + for name, _ in pairs(common.dialogues) do + options[i] = {label = name:gsub("^%l", string.upper), value = name} + i = i + 1 -- wtf lua + end + return options +end + +local template = mwse.mcm.createTemplate(common.modName) +template:saveOnClose(common.configString, config) + +local page = template:createSideBarPage({ + label = "Sidebar page", + description = string.format("%s v%s by %s\n\n%s\n\n", common.modName, common.version, common.author, common.modInfo) +}) + +local mainCategory = page:createCategory(common.modName) + +-- {{{ general settings + +local generalCategory = mainCategory:createCategory("Common settings") + +generalCategory:createDropdown({label = "Language", options = createLanguageOptions(), variable = createTableVar("language")}) + +generalCategory:createYesNoButton({ + label = "Debug mode", + description = "Print debug messages to the log.", + variable = createTableVar("debug") +}) + +-- }}} + +-- {{{ sneak settings + +local sneakCategory = mainCategory:createCategory("Sneak Settings") + +sneakCategory:createYesNoButton({ + label = "Enable sneak module", + description = "Guards who catch you sneaking will follow you for a bit of time.", + variable = createTableVar("sneakEnable") +}) + +sneakCategory:createYesNoButton({ + label = "Sneak dialogue", + description = "Guards sometimes say things to you when you sneak.", + variable = createTableVar("sneakDialogue") +}) + +sneakCategory:createSlider({ + label = "Sneak dialogue chance", + description = "Percent chance a guard will say something each time the dialogue timer fires.", + min = 0, + max = 100, + step = 1, + jump = 5, + variable = createTableVar("sneakDialogueChance") +}) + +sneakCategory:createSlider({ + label = "Sneak dialogue timer", + description = "Roll for dialogue every x seconds while following.", + min = 0, + max = 60, + step = 1, + jump = 5, + variable = createTableVar("sneakDialogueTimer") +}) + +-- }}} + +-- {{{ combat settings + +local combatCategory = mainCategory:createCategory("Combat Settings") + +combatCategory:createYesNoButton({ + label = "Enable combat module", + description = "Guards will come to the rescue of a player who is attacked unprovoked.", + variable = createTableVar("combatEnable") +}) + +combatCategory:createSlider({ + label = "Guard alert range", + description = "How far away guards are alerted to combat against the player", + min = 1, + max = 20000, + step = 10, + jump = 50, + variable = createTableVar("combatDistance") +}) + +combatCategory:createYesNoButton({ + label = "Enable combat dialogue", + description = "Guards have things to say when they come to the rescue of a player who is attacked unprovoked.", + variable = createTableVar("combatDialogue") +}) + +-- }}} + +return template + +-- vim:fdm=marker diff --git a/MWSE/mods/celediel/MoreAttentiveGuards/sneak.lua b/MWSE/mods/celediel/MoreAttentiveGuards/sneak.lua new file mode 100644 index 0000000..0b51020 --- /dev/null +++ b/MWSE/mods/celediel/MoreAttentiveGuards/sneak.lua @@ -0,0 +1,164 @@ +local common = require("celediel.MoreAttentiveGuards.common") +local config = require("celediel.MoreAttentiveGuards.config").getConfig() +local this = {} + +-- {{{ variables and such +-- only one guard following at a time makes sense +-- other guards are like "oh they've got this, I don't need to help" +local follower +local followTimer +local dialogueTimer +local isFollowing = false +local ogPosition +-- }}} + +-- {{{ helper functions + +local function log(...) if config.debug then common.log(...) end end + +local function calculateFollowTime() + -- Modified formula from RubberMan's "Inquisitive Guards" + -- https://www.nexusmods.com/morrowind/mods/46538 + local sneak = tes3.mobilePlayer.sneak.value and tes3.mobilePlayer.sneak.value or tes3.mobilePlayer.sneak.base + local max = tes3.hasCodePatchFeature(110) and (sneak <= 100 and 101 or 0) or 101 + local value = (max - sneak) / 3 + -- round to nearest integer + return math.fmod(value, 1) >= 0.5 and math.ceil(value) or math.floor(value) +end + +-- }}} + +-- {{{ timer functions + +local function startDialogue() + if not follower then return end + + local dialogue = table.choice(common.dialogues[config.language].sneaking) + local roll = math.random(0, 100) + + log("Dialogue roll = %s > %s", config.sneakDialogueChance, roll) + if config.sneakDialogueChance > roll then + local response = common.guardDialogue(follower, dialogue, tes3.mobilePlayer) + log(response) + end +end + +-- * NPC travels back to where they were before following player, then wanders +-- * tries to guess how long it'll take NPC to get back to their original position +local function stopFollowing(onTimer) + if not follower or not isFollowing then return end + isFollowing = false + + local function startWander() + log("%s has probably reached their original destination, resuming wander...", follower.object.name) + tes3.setAIWander({reference = follower, range = 2000, reset = false, idles = common.generateIdles()}) + + follower = nil + end + + local function startTravel() + -- I couldn't think of a better way to "know" when they've reach their destination + -- so I set a timer based on the distance from the original position. It's okay-ish. + local distance = ogPosition:distance(follower.position) + local duration = math.ceil(distance / 95) + + -- duration of 0 is bad for timers, so + duration = duration > 0 and duration or 1 + + log("%s has decided that %s isn't doing anything suspicious, heading back to %s... " .. + "(which is %s distance units away... it'll probably take %s seconds to get there)", + follower.object.name, tes3.player.object.name, ogPosition, distance, duration) + + -- send a dialogue to let player know guard doesn't care any more + if onTimer then + local response = common.guardDialogue(follower, + table.choice(common.dialogues[config.language].stop_following), + tes3.mobilePlayer) + log(response) + end + + if dialogueTimer and dialogueTimer.state == timer.active then dialogueTimer:cancel() end + + tes3.setAITravel({reference = follower, destination = ogPosition}) + ogPosition = nil + + timer.start({duration = duration, iterations = 1, callback = startWander}) + end + + timer.delayOneFrame(startTravel) +end + +local function startFollowing() + if not follower or isFollowing then return end + + local function startFollow() + local followTime = calculateFollowTime() + log("%s starting to follow %s for %s time units", follower.object.name, tes3.player.object.name, followTime) + + tes3.setAIFollow({reference = follower, target = tes3.mobilePlayer}) + + followTimer = timer.start({duration = followTime, callback = stopFollowing}) + + if config.sneakDialogue then + startDialogue() + dialogueTimer = timer.start({ + duration = config.sneakDialogueTimer, + iterations = -1, + callback = startDialogue + }) + end + + isFollowing = true + end + + ogPosition = follower.position:copy() + + timer.delayOneFrame(startFollow) +end + +local function abortFollow() + local response = common.guardDialogue(follower, table.choice(common.dialogues[config.language].stop_sneaking), + tes3.mobilePlayer) + log(response) + stopFollowing(false) +end + +-- }}} + +-- {{{ returned event functions + +this.onDetectSneak = function(e) + if not config.sneakEnable then return end + if e.target ~= tes3.mobilePlayer or not tes3.mobilePlayer.isSneaking or not e.detector.object.isGuard then return end + + if not isFollowing then + log("%s is checking for %s %ssuccessfully", e.detector.object and e.detector.object.name or "no one", + e.target.object and e.target.object.name or "no one", e.isDetected and "" or "un") + end + + if e.isDetected and not follower and not isFollowing then + follower = e.detector + -- follow for a time + startFollowing() + -- else -- uncomment this for extreme debug messages + -- log("Not following because detection = %s or follower = %s or isFollowing = %s", e.isDetected, follower, + -- isFollowing) + end +end + +this.onCalcMoveSpeed = function(e) + if e.mobile ~= tes3.mobilePlayer or tes3.mobilePlayer.isSneaking then return end + + if follower and followTimer and followTimer.state == timer.active then + log("Player not sneaking, aborting follow") + + followTimer:cancel() + abortFollow() + end +end + +-- }}} + +return this + +-- vim: fdm=marker diff --git a/README.md b/README.md new file mode 100644 index 0000000..87ebfc2 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# More Attentive Guards for Morrowind # + +## About ## + +An MWSE-Lua mod that replicates the features of +[Inquisitive Guards](https://www.nexusmods.com/morrowind/mods/46538) by RubberMan +and +[Protective Guards](http://download.fliggerty.com/download-110-20) by Fliggerty + +* Guards that catch the player sneaking about in town will follow them for a length of time dependent on their sneak skill. +* Guards will come to the aid of an unwanted player who is attacked unprovoked +* MCM Menu for configuration of many options + +### Dialogue ### + +* Guards will say things: + * While the player is sneaking, on a chance based timer + * When the player stops sneaking while being followed + * When the guard gets bored of following the player + * When coming to the aid of an unwanted player who is attacked unprovoked + +Open dialogues.lua to edit the dialogues. I'm no writer so they're probably bad, but the framework is there for the dialogues to be easily edited or translated. + +To add a new language add something like this to dialogues.lua: + +``` +["your language"] = { + -- guards might say this every so often to players who are sneaking + -- %s is replaced with race or class + ["sneaking"] = { + "dialogue goes here", + }, + -- guards say this when players stop sneaking while being followed + -- %s is replaced with race or class + ["stop_sneaking"] = { + "dialogue goes here", + }, + -- guards say this when they're satisfied that the player is not doing anything illegal + -- %s is replaced with race or class + ["stop_following"] = { + "dialogue goes here", + }, + -- guards say this when coming to player's rescue when they're attacked unprovoked + -- %s is replaced with the name of the npc or creature attacking the player + ["join_combat"] = { + "dialogue goes here", + } +} +``` + +The MCM dropdown will automatically be populated with the configured language, and once selected, those dialogues will be used. + + +## Requirements ## +MWSE 2.1 nightly @ [github](https://github.com/MWSE/MWSE) + +## Credits ## + +* MWSE Team for MWSE with Lua support +* Lua is way different than MWScript, but I did glance at the scripts from +both Inquisitive Guards and Protective Guards for inspiration + +## License ## + +MIT License. See LICENSE file.