diff --git a/main.lua b/main.lua index db65fd69d..a5a79c6e3 100644 --- a/main.lua +++ b/main.lua @@ -27,6 +27,8 @@ Ease = require("src.lib.easing") SemVer = require("src.lib.semver") require("src.lib.stable_sort") +NativeFS = require("src.lib.nativefs") + Class = require("src.utils.class") require("src.utils.graphics") @@ -76,7 +78,8 @@ MainMenuModList = require("src.engine.menu.mainmenumodlist") MainMenuModCreate = require("src.engine.menu.mainmenumodcreate") MainMenuModConfig = require("src.engine.menu.mainmenumodconfig") MainMenuModError = require("src.engine.menu.mainmenumoderror") -MainMenuFileSelect = require("src.engine.menu.mainmenufileselect") +MainMenuFileSelectDark = require("src.engine.menu.mainmenufileselectdark") +MainMenuCompletionSelect = require("src.engine.menu.mainmenucompletionselect") MainMenuFileName = require("src.engine.menu.mainmenufilename") MainMenuDefaultName = require("src.engine.menu.mainmenudefaultname") MainMenuControls = require("src.engine.menu.mainmenucontrols") @@ -90,6 +93,7 @@ ModButton = require("src.engine.menu.objects.modbutton") DLCButton = require("src.engine.menu.objects.DLCbutton") ModCreateButton = require("src.engine.menu.objects.modcreatebutton") FileButton = require("src.engine.menu.objects.filebutton") +DarkFileButton = require("src.engine.menu.objects.darkfilebutton") FileNamer = require("src.engine.menu.objects.filenamer") DarkTransitionLine = require("src.engine.game.darktransition.darktransitionline") diff --git a/mods/dpr_main/libraries/deltaruneloader/lib.json b/mods/dpr_main/libraries/deltaruneloader/lib.json new file mode 100644 index 000000000..6a81cbb78 --- /dev/null +++ b/mods/dpr_main/libraries/deltaruneloader/lib.json @@ -0,0 +1,8 @@ +{ + "id": "deltarune-loader", + "authors": [ + "Sylvi" + ], + "version": "v1.0.0", + "engineVer": "v0.7.0" +} \ No newline at end of file diff --git a/mods/dpr_main/libraries/deltaruneloader/lib.lua b/mods/dpr_main/libraries/deltaruneloader/lib.lua new file mode 100644 index 000000000..d056a4886 --- /dev/null +++ b/mods/dpr_main/libraries/deltaruneloader/lib.lua @@ -0,0 +1,7 @@ +local lib = {} + +function lib:init() + DeltaruneLoader.init() +end + +return lib \ No newline at end of file diff --git a/mods/dpr_main/libraries/deltaruneloader/scripts/globals/DeltaruneConsts.lua b/mods/dpr_main/libraries/deltaruneloader/scripts/globals/DeltaruneConsts.lua new file mode 100644 index 000000000..d694433b5 --- /dev/null +++ b/mods/dpr_main/libraries/deltaruneloader/scripts/globals/DeltaruneConsts.lua @@ -0,0 +1,231 @@ +local DeltaruneConsts = {} + +DeltaruneConsts.ITEM_IDS = { + [ 1] = "dark_candy", + [ 2] = "revivemint", + [ 3] = "glowshard", + [ 4] = "manual", + [ 5] = "brokencake", + [ 6] = "top_cake", + [ 7] = "spincake", + [ 8] = "darkburger", + [ 9] = "lancercookie", + [10] = "gigasalad", + [11] = "clubssandwich", + [12] = "heartsdonut", + [13] = "chocdiamond", + [14] = "favwich", + [15] = "rouxlsroux", + [16] = "cd_bagel", + [17] = "mannequin", + [18] = "kris_tea", + [19] = "noelle_tea", + [20] = "ralsei_tea", + [21] = "susie_tea", + [22] = "dd_burger", + [23] = "lightcandy", + [24] = "butjuice", + [25] = "spagetticode", + [26] = "javacookie", + [27] = "tensionbit", + [28] = "tensiongem", + [29] = "tensionmax", + [30] = "revivedust", + [31] = "revivebrite", + [32] = "s_poison", + [33] = "dogdollar" +} + +DeltaruneConsts.KEY_ITEM_IDS = { + [ 1] = "cell_phone", + [ 2] = "egg", + [ 3] = "brokencake", + [ 4] = "broken_key_a", + [ 5] = "door_key", + [ 6] = "broken_key_b", + [ 7] = "broken_key_c", + [ 8] = "lancer", + [ 9] = "rouxls_kaard", + [10] = "emptydisk", + [11] = "loadeddisk", + [12] = "keygen", + [13] = "shadowcrystal", + [14] = "starwalker", + [15] = "purecrystal" +} + +DeltaruneConsts.WEAPON_IDS = { + [ 1] = "wood_blade", + [ 2] = "mane_ax", + [ 3] = "red_scarf", + [ 4] = "everybodyweapon", + [ 5] = "spookysword", + [ 6] = "brave_ax", + [ 7] = "devilsknife", + [ 8] = "trefoil", + [ 9] = "ragger", + [10] = "daintyscarf", + [11] = "twistedswd", + [12] = "snowring", + [13] = "thornring", + [14] = "bounceblade", + [15] = "cheerscarf", + [16] = "mechasaber", + [17] = "autoaxe", + [18] = "fiberscarf", + [19] = "ragger2", + [20] = "brokenswd", + [21] = "puppetscarf", + [22] = "freezering" +} + +DeltaruneConsts.ARMOR_IDS = { + [ 1] = "amber_card", + [ 2] = "dice_brace", + [ 3] = "pink_ribbon", + [ 4] = "white_ribbon", + [ 5] = "ironshackle", + [ 6] = "mousetoken", + [ 7] = "jevilstail", + [ 8] = "silver_card", + [ 9] = "twinribbon", + [10] = "glowwrist", + [11] = "chainmail", + [12] = "bshotbowtie", + [13] = "spikeband", + [14] = "silver_watch", + [15] = "tensionbow", + [16] = "mannequin", + [17] = "darkgoldband", + [18] = "skymantle", + [19] = "spikeshackle", + [20] = "frayedbowtie", + [21] = "dealmaker", + [22] = "royalpin" +} + +DeltaruneConsts.LIGHT_ITEM_IDS = { + [ 1] = "light/hot_chocolate", + [ 2] = "light/pencil", + [ 3] = "light/bandage", + [ 4] = "light/bouquet", + [ 5] = "light/ball_of_junk", + [ 6] = "light/halloween_pencil", + [ 7] = "light/lucky_pencil", + [ 8] = "light/egg", + [ 9] = "light/cards", + [10] = "light/box_of_heart_candy", + [11] = "light/glass", + [12] = "light/eraser", + [13] = "light/mech_pencil", + [14] = "light/wristwatch" +} + +DeltaruneConsts.ROOM_IDS = { + [ 3] = "Queen's Mansion - Rooftop", + [ 27] = "Kris's Room", + [ 64] = "Castle Town", + [ 70] = "Castle Town", + [ 71] = "My Castle Town", + [ 87] = "Cyber Field - Entrance", + [ 92] = "Cyber Field - Arcade Machine", + [ 98] = "Cyber Field - Music Shop", + [121] = "Cyber City - Entrance", + [124] = "Cyber City - First Alleyway", + [130] = "Cyber City - Music Shop", + [135] = "Cyber City - Mouse Alley", + [137] = "Cyber City - Second Alleyway", + [142] = "Cyber City - Heights", + [161] = "Queen's Mansion - Guest Hall", + [166] = "Queen's Mansion - Entrance", + [180] = "Queen's Mansion - Basement", + [196] = "Queen's Mansion - 3F", + [202] = "Queen's Mansion - Acid Tunnel", + [205] = "Queen's Mansion - 4F", + [282] = "Kris's Room", + [315] = "??????", + [320] = "Eye Puzzle", + [325] = "Castle Town", + [329] = "Field - Great Door", + [336] = "Field - Maze of Death", + [339] = "Field - Seam's Shop", + [348] = "Field - Great Board", + [351] = "Field - Great Board 2", + [353] = "Forest - Entrance", + [362] = "Forest - Bake Sale", + [370] = "Forest - Before Maze", + [376] = "Forest - After Maze", + [377] = "Forest - Thrashing Room", + [387] = "Card Castle - Prison", + [391] = "Card Castle - ???", + [394] = "Card Castle - 1F", + [403] = "Card Castle - 5F", + [406] = "Card Castle - Throne" +} + +DeltaruneConsts.TEAM_NAMES = { + [0] = "Guys", + [1] = "$!$? Squad", + [2] = "Lancer Fan Club", + [3] = "Fun Gang" +} + +DeltaruneConsts.TITLE_NAMES = { + -- Kris + [ 0] = "Human", + [ 1] = "Leader", + [ 2] = "Bed Inspector", + [ 3] = "Tactician", + [ 4] = "Moss Finder", + [ 5] = "Leader", + -- Susie + [100] = "Mean Girl", + [101] = "Dark Knight", + [102] = "Healing Master", + [103] = "Moss Enjoyer", + -- Ralsei + [200] = "Lonely Prince", + [201] = "Prickly Prince", + [202] = "Fluffy Prince", + [203] = "Dark Prince", + [204] = "Hug Prince", + [205] = "Pose Prince", + [206] = "Rude Prince", + [207] = "Blank Prince", + -- Noelle + [300] = "Snowcaster", + [301] = "Frostmancer", + [302] = "Ice Trancer", + [303] = "Moss Neutral" +} + +DeltaruneConsts.TITLE_DESCRIPTIONS = { + -- Kris + [ 0] = "Body contains a\nhuman SOUL.", + [ 1] = "Commands the party\nwith various ACTs.", + [ 2] = "Inspects all beds\ninexplicably.", + [ 3] = "Commands the party\nby ACTs. Sometimes.", + [ 4] = "Basic moss-finding\nabilities.", + [ 5] = "Commands.", + -- Susie + [100] = "Won't do anything\nbut fight.", + [101] = "Does damage using\ndark energy.", + [102] = "Can use ultimate\nhealing. (Losers!)", + [103] = "Supports those\nthat find moss.", + -- Ralsei + [200] = "Dark-World being.\nHas no subjects.", + [201] = "Deals damage with\nhis rugged scarf.", + [202] = "Weak, but has nice\nhealing powers.", + [203] = "Dark-World being.\nHas friends now.", + [204] = "Receives and\ngives many hugs.", + [205] = "Poses for photos\nat times.", + [206] = "Friends with a\nrude gesturer.", + [207] = "Doesn't even\nhave a photo.", + -- Noelle + [300] = "Might be able to\nuse some cool moves.", + [301] = "Freezes the enemy.", + [302] = "Receives pain to\nbecome stronger.", + [303] = "Neither chaotic nor\nlawful to moss." +} + +return DeltaruneConsts \ No newline at end of file diff --git a/mods/dpr_main/libraries/deltaruneloader/scripts/globals/DeltaruneLoader.lua b/mods/dpr_main/libraries/deltaruneloader/scripts/globals/DeltaruneLoader.lua new file mode 100644 index 000000000..58c637fee --- /dev/null +++ b/mods/dpr_main/libraries/deltaruneloader/scripts/globals/DeltaruneLoader.lua @@ -0,0 +1,101 @@ +local DeltaruneLoader = {} +local self = DeltaruneLoader + +DeltaruneLoader.CHAPTERS = 2 + + +function DeltaruneLoader.init() + self.saves = {} + + for i = 1, self.CHAPTERS do + self.saves[i] = {} + end + + self.path = self.getSaveDirectory() + + if not self.path then + print("[DeltaruneLoader] Unsupported OS: "..love.system.getOS()) + return + end +end + +function DeltaruneLoader.load(filter) + if not self.path then + return false + end + + filter = filter or {} + + local function loadFile(chapter, slot, completed) + if self.saves[chapter] and self.saves[chapter][slot] then + return -- Already loaded + end + + local file = io.open(self.path.."/filech"..chapter.."_"..(slot-1), "r") + + if file then + local data_str = file:read("*all") + file:close() + + local data = Utils.split(data_str, "\n") + + for i = 1, #data do + data[i] = string.gsub(data[i], "^%s*(.-)%s*$", "%1") + end + + local slot_id = slot + if completed then + slot_id = slot_id - 3 + end + + local save = DeltaruneSave(chapter, slot_id, completed) + save:parseData(data) + + self.saves[chapter] = self.saves[chapter] or {} + self.saves[chapter][slot] = save + end + end + + for chapter = 1, self.CHAPTERS do + if filter.chapter == nil or filter.chapter == chapter then + if filter.completed == nil or filter.completed == false then + for slot = 1, 3 do + if filter.slot == nil or filter.slot == slot then + loadFile(chapter, slot, false) + end + end + end + + if filter.completed == nil or filter.completed == true then + for slot = 4, 6 do + if filter.slot == nil or filter.slot == (slot - 3) then + loadFile(chapter, slot, true) + end + end + end + end + end +end + +function DeltaruneLoader.getSave(chapter, slot) + return self.saves[chapter] and self.saves[chapter][slot] +end + +function DeltaruneLoader.getCompletion(chapter, slot) + return self.saves[chapter] and self.saves[chapter][slot + 3] +end + +function DeltaruneLoader.getSaveDirectory() + local os_name = love.system.getOS() + + if os_name == "Windows" then + return string.gsub(os.getenv("LOCALAPPDATA"), "\\", "/") .. "/Deltarune/" + elseif os_name == "Linux" then + return os.getenv("HOME") .. "/.local/share/Steam/steamapps/compatdata/1690940/pfx/drive_c/users/steamuser/AppData/Local/DELTARUNE/" + elseif os_name == "OS X" then + return os.getenv("HOME") .. "/Library/Application Support/com.tobyfox.deltarune/" + end +end + + +return DeltaruneLoader diff --git a/mods/dpr_main/libraries/deltaruneloader/scripts/globals/DeltaruneSave.lua b/mods/dpr_main/libraries/deltaruneloader/scripts/globals/DeltaruneSave.lua new file mode 100644 index 000000000..ba5c2ab81 --- /dev/null +++ b/mods/dpr_main/libraries/deltaruneloader/scripts/globals/DeltaruneSave.lua @@ -0,0 +1,418 @@ +---@class DeltaruneSave : Class +local DeltaruneSave = Class() + +function DeltaruneSave:init(chapter, slot, completed) + self.chapter = chapter or 2 + self.slot = slot or 1 + self.completed = completed or false + + self.data = {} + + self.name = "" + self.money = 0 + + self.plot = 0 + self.room_id = 0 + self.room_name = "" + self.playtime = 0 + + self.inventory = { + ["items"] = {}, + ["key_items"] = {}, + ["weapons"] = {}, + ["armors"] = {}, + ["storage"] = {} + } + + self.equipment = { + ["kris"] = {weapon = nil, armor = {}}, + ["susie"] = {weapon = nil, armor = {}}, + ["ralsei"] = {weapon = nil, armor = {}}, + ["noelle"] = {weapon = nil, armor = {}}, + } + + self.title_ids = { + ["kris"] = 0, + ["susie"] = 100, + ["ralsei"] = 200, + ["noelle"] = 300 + } + self.title_lv = 1 + + self.lw_items = {} + self.lw_weapon = nil + self.lw_armor = nil + + self.vessel_name = "" + + self.team_name_id = 0 + self.team_name = DeltaruneConsts.TEAM_NAMES[0] + + self.shadow_crystals = 0 + self.beat_jevil = false + self.beat_spamton = false + + self.starwalker = false + + self.snowgrave = false + self.failed_snowgrave = false + + self.eggs = {[1] = false, [2] = false} + + if self.chapter == 1 then + self.flag_start = 317 + self.flag_count = 9999 + elseif self.chapter >= 2 then + self.flag_start = 553 + self.flag_count = 2500 + end +end + +function DeltaruneSave:getData(index, as) + local value = self.data[index] + + if value == nil then + return nil + end + + if as == "number" then + return tonumber(value) or 0 + elseif as == "boolean" then + return value ~= "0" and value ~= "" + elseif as == "string" then + return value + else + return tonumber(value) or value + end +end + +function DeltaruneSave:checkData(...) + local index, compare = ... + if arg.n == 1 then + compare = true + end + return self:getData(index, type(compare)) == compare +end + +function DeltaruneSave:getFlag(id, as) + return self:getData(self.flag_start + id, as) +end + +function DeltaruneSave:checkFlag(...) + local id, compare = ... + if arg.n == 1 then + compare = true + end + return self:getData(self.flag_start + id, type(compare)) == compare +end + +function DeltaruneSave:getRoomName() + return DeltaruneConsts.ROOM_IDS[self.room_id] or (self.chapter >= 2 and "Dark World?" or " ") +end + +function DeltaruneSave:getTeamName() + return DeltaruneConsts.TEAM_NAMES[self.team_name_id] or "Guys" +end + +function DeltaruneSave:getTitleName(character) + return DeltaruneConsts.TITLE_NAMES[self.title_ids[character]] +end + +function DeltaruneSave:getTitleDescription(character) + return DeltaruneConsts.TITLE_DESCRIPTIONS[self.title_ids[character]] +end + +function DeltaruneSave:getTitle(character) + return DeltaruneConsts.TITLE_NAMES[self.title_ids[character]] .. "\n" .. DeltaruneConsts.TITLE_DESCRIPTIONS[self.title_ids[character]] +end + +function DeltaruneSave:parseData(data) + self.data = data + + self.name = data[1] + self.money = tonumber(data[11]) + + if self.chapter == 1 then + self.plot = tonumber(data[10316]) + self.room_id = tonumber(data[10317]) + self.playtime = tonumber(data[10318]) + if self.room_id < 280 then + self.room_id = self.room_id + 280 + end + elseif self.chapter >= 2 then + self.plot = tonumber(data[3053]) + self.room_id = tonumber(data[3054]) + self.playtime = tonumber(data[3055]) + end + + self.room_name = DeltaruneConsts.ROOM_IDS[self.room_id] or (self.chapter >= 2 and "Dark World?" or " ") + self.playtime = self.playtime / 30 + + local function parseEquipment(chara, index) + self.equipment[chara].weapon = DeltaruneConsts.WEAPON_IDS[tonumber(data[index])] + self.equipment[chara].armor[1] = DeltaruneConsts.ARMOR_IDS[tonumber(data[index + 1])] + self.equipment[chara].armor[2] = DeltaruneConsts.ARMOR_IDS[tonumber(data[index + 2])] + end + + if self.chapter == 1 then + parseEquipment("kris", 77) + parseEquipment("susie", 131) + parseEquipment("ralsei", 185) + self.lw_weapon = DeltaruneConsts.WEAPON_IDS[tonumber(data[290])] + self.lw_armor = DeltaruneConsts.ARMOR_IDS[tonumber(data[291])] + elseif self.chapter >= 2 then + parseEquipment("kris", 85) + parseEquipment("susie", 147) + parseEquipment("ralsei", 209) + parseEquipment("noelle", 271) + self.lw_weapon = DeltaruneConsts.WEAPON_IDS[tonumber(data[526])] + self.lw_armor = DeltaruneConsts.ARMOR_IDS[tonumber(data[527])] + end + + local function parseItems(start, num, inc, tbl, lookup) + for i = 1, num do + local item_id = data[start + (i - 1) * inc] + + tbl[i] = lookup[tonumber(item_id)] + end + end + + if self.chapter == 1 then + parseItems(236, 12, 4, self.inventory.items, DeltaruneConsts.ITEM_IDS) + parseItems(237, 12, 4, self.inventory.key_items, DeltaruneConsts.KEY_ITEM_IDS) + parseItems(238, 12, 4, self.inventory.weapons, DeltaruneConsts.WEAPON_IDS) + parseItems(239, 12, 4, self.inventory.armors, DeltaruneConsts.ARMOR_IDS) + parseItems(301, 8, 2, self.lw_items, DeltaruneConsts.LIGHT_ITEM_IDS) + elseif self.chapter >= 2 then + parseItems(330, 12, 2, self.inventory.items, DeltaruneConsts.ITEM_IDS) + parseItems(331, 12, 2, self.inventory.key_items, DeltaruneConsts.KEY_ITEM_IDS) + parseItems(356, 48, 2, self.inventory.weapons, DeltaruneConsts.WEAPON_IDS) + parseItems(357, 48, 2, self.inventory.armors, DeltaruneConsts.ARMOR_IDS) + parseItems(452, 72, 1, self.inventory.storage, DeltaruneConsts.ITEM_IDS) + parseItems(537, 8, 2, self.lw_items, DeltaruneConsts.LIGHT_ITEM_IDS) + end + + if self.chapter == 1 then + + if self:getFlag(252, "boolean") then + self.title_ids["kris"] = 2 -- Bed Inspector + elseif self.plot >= 30 then + self.title_ids["kris"] = 1 -- Leader + else + self.title_ids["kris"] = 0 -- Human + end + + if self.plot >= 154 then + self.title_ids["susie"] = 101 -- Dark Knight + else + self.title_ids["susie"] = 100 -- Mean Girl + end + + self.title_ids["ralsei"] = 200 -- Lonely Prince + + elseif self.chapter >= 2 then + local snowgrave_active = not self:getFlag(916, "boolean") and self:getFlag(915, "number") > 0 + + if snowgrave_active then + self.title_ids["kris"] = 5 -- Leader (Snowgrave) + elseif self:getFlag(920, "boolean") then + self.title_ids["kris"] = 4 -- Moss Finder + elseif self:getFlag(252, "boolean") then + self.title_ids["kris"] = 2 -- Bed Inspector + elseif self.plot >= 60 then + self.title_ids["kris"] = 3 -- Tactician + else + self.title_ids["kris"] = 1 -- Leader + end + + if self:getFlag(922, "boolean") then + self.title_ids["susie"] = 103 -- Moss Enjoyer + elseif self.plot >= 95 then + self.title_ids["susie"] = 102 -- Healing Master + else + self.title_ids["susie"] = 101 -- Dark Knight + end + + local photo_gesture = self:getFlag(325, "number") + if photo_gesture == 1 then + self.title_ids["ralsei"] = 204 -- Hug Prince + elseif photo_gesture == 2 then + self.title_ids["ralsei"] = 205 -- Pose Prince + elseif photo_gesture == 3 then + self.title_ids["ralsei"] = 206 -- Rude Prince + elseif photo_gesture == 4 then + self.title_ids["ralsei"] = 207 -- Blank Prince + else + self.title_ids["ralsei"] = 203 -- Dark Prince + end + + if self:getFlag(921, "boolean") and not snowgrave_active then + self.title_ids["noelle"] = 303 -- Moss Neutral + elseif self:getFlag(925, "number") > 0 then + self.title_ids["noelle"] = 301 -- Frostmancer + else + self.title_ids["noelle"] = 300 -- Snowcaster + end + end + + self.title_lv = self.chapter + if self.chapter == 2 and self.plot >= 200 then + self.title_lv = 3 + end + + -- Extra variables + + self.vessel_name = data[2] + + self.team_name_id = self:getFlag(214, "number") + self.team_name = DeltaruneConsts.TEAM_NAMES[self.team_name_id] or self.team_name + + self.eggs[1] = self:getFlag(263, "number") >= 2 + self.eggs[2] = self:getFlag(439, "boolean") + + if self:getFlag(241, "number") >= 6 then + self.beat_jevil = true + self.shadow_crystals = self.shadow_crystals + 1 + end + if self:getFlag(309, "number") >= 9 then + self.beat_spamton = true + self.shadow_crystals = self.shadow_crystals + 1 + end + + self.starwalker = self:getFlag(254, "boolean") + + self.snowgrave = self:getFlag(915, "number") >= 6 + self.failed_snowgrave = self:getFlag(916, "boolean") +end + +function DeltaruneSave:load() + Game.save_name = self.name + Game.money = self.money + + local function safeGetItem(id) + if not id then + return nil + end + + if not Registry.getItem(id) then + print("[DeltaruneLoader] Loaded invalid item: "..id) + return nil + end + + return id + end + + local function clearStorages(inventory, storages) + for _,storage_id in ipairs(storages) do + local storage = inventory:getStorage(storage_id) + for i = 1, storage.max do + if inventory.stored_items[storage[i]] then + inventory.stored_items[storage[i]] = nil + end + storage[i] = nil + end + end + end + + local function loadStorage(inventory, storage_id, data) + local storage = inventory:getStorage(storage_id) + for i = 1, storage.max do + local item = safeGetItem(data[i]) + if storage.sorted then + if item then + inventory:addItemTo(storage, item) + end + else + inventory:setItem(storage, i, item) + end + end + end + + local loaded_party = {"kris", "susie", "ralsei"} + if self.chapter >= 2 then + table.insert(loaded_party, "noelle") + end + + local inventory = Game.inventory + if Game:isLight() then + clearStorages(inventory, {"items"}) + loadStorage(inventory, "items", self.lw_items) + + local kris = Game:getPartyMember("kris") + kris:setWeapon(safeGetItem(self.lw_weapon)) + kris:setArmor(1, safeGetItem(self.lw_armor)) + + for _,party_id in ipairs(loaded_party) do + local chara = Game:getPartyMember(party_id) + + local weapon = chara:getWeapon() + if weapon then + weapon.dark_item = safeGetItem(self.equipment[party_id].weapon) + end + local armor = chara:getArmor(1) + if armor then + if armor:includes(LightEquipItem) then + armor:setArmor(1, safeGetItem(self.equipment[party_id].armor[1])) + armor:setArmor(2, safeGetItem(self.equipment[party_id].armor[2])) + else + armor.dark_item = safeGetItem(self.equipment[party_id].armor[1]) + end + end + end + + inventory = Game.inventory:getDarkInventory() + else + for _,party_id in ipairs(loaded_party) do + local chara = Game:getPartyMember(party_id) + + chara:setWeapon(safeGetItem(self.equipment[party_id].weapon)) + chara:setArmor(1, safeGetItem(self.equipment[party_id].armor[1])) + chara:setArmor(2, safeGetItem(self.equipment[party_id].armor[2])) + end + end + + for _,party_id in ipairs(loaded_party) do + local chara = Game:getPartyMember(party_id) + + chara.title = self:getTitle(party_id) + end + + if self.chapter == 1 then + clearStorages(inventory, {"items", "weapons", "armors"}) + elseif self.chapter >= 2 then + clearStorages(inventory, {"items", "weapons", "armors", "storage"}) + end + + loadStorage(inventory, "items", self.inventory.items) + loadStorage(inventory, "weapons", self.inventory.weapons) + loadStorage(inventory, "armors", self.inventory.armors) + if self.chapter >= 2 then + loadStorage(inventory, "storage", self.inventory.storage) + end + + if self.shadow_crystals > 0 and not inventory:hasItem("shadowcrystal") then + inventory:addItem("shadowcrystal") + end + Game:setFlag("shadow_crystals", self.shadow_crystals) + + if self.chapter >= 2 then + local noelle = Game:getPartyMember("noelle") + noelle:setFlag("iceshocks_used", self:getFlag(925, "number")) + noelle:setFlag("boldness", math.min(-12 + (self.plot - 70) * 30, 100)) + + if self.snowgrave then + local has_snowgrave = false + for _,spell in ipairs(noelle:getSpells()) do + if spell.id == "snowgrave" then + has_snowgrave = true + break + end + end + if not has_snowgrave then + noelle:addSpell("snowgrave") + end + end + end +end + +return DeltaruneSave \ No newline at end of file diff --git a/mods/dpr_main/mod.lua b/mods/dpr_main/mod.lua index 14827f84f..eecf909ed 100644 --- a/mods/dpr_main/mod.lua +++ b/mods/dpr_main/mod.lua @@ -6,9 +6,19 @@ function Mod:init() self.border_shaders = {} self:setMusicPitches() + + if DELTARUNE_SAVE_ID then + DeltaruneLoader.load({chapter = 2, completed = true, slot = DELTARUNE_SAVE_ID}) + end end function Mod:postInit(new_file) + if DELTARUNE_SAVE_ID then + local save = DeltaruneLoader.getCompletion(2,DELTARUNE_SAVE_ID) + self:loadDeltaruneFile(save) + Game.save_id = DELTARUNE_SAVE_ID + DELTARUNE_SAVE_ID = nil + end local items_list = { { result = "soulmantle", @@ -173,3 +183,14 @@ function Mod:onMapMusic(map, music) return "deltarune/cybercity_alt" end end + +---@param file DeltaruneSave +function Mod:loadDeltaruneFile(file) + -- TODO: Load items into custom storages, and + -- give the player access to that stuff much later in the game. + file:load() + if file.failed_snowgrave then + elseif file.snowgrave then + Game:setFlag("POST_SNOWGRAVE", true) + end +end diff --git a/src/engine/menu/mainmenu.lua b/src/engine/menu/mainmenu.lua index 275146731..5c8c68f72 100644 --- a/src/engine/menu/mainmenu.lua +++ b/src/engine/menu/mainmenu.lua @@ -46,7 +46,8 @@ function MainMenu:enter() self.mod_create = MainMenuModCreate(self) self.mod_config = MainMenuModConfig(self) self.mod_error = MainMenuModError(self) - self.file_select = MainMenuFileSelect(self) + self.file_select = MainMenuFileSelectDark(self) + self.completion_select = MainMenuCompletionSelect(self) self.file_name_screen = MainMenuFileName(self) self.default_name_screen = MainMenuDefaultName(self) self.controls = MainMenuControls(self) @@ -66,6 +67,7 @@ function MainMenu:enter() self.state_manager:addState("MODCONFIG", self.mod_config) self.state_manager:addState("MODERROR", self.mod_error) self.state_manager:addState("FILESELECT", self.file_select) + self.state_manager:addState("COMPLETIONSELECT", self.completion_select) self.state_manager:addState("FILENAME", self.file_name_screen) self.state_manager:addState("DEFAULTNAME", self.default_name_screen) self.state_manager:addState("CONTROLS", self.controls) diff --git a/src/engine/menu/mainmenucompletionselect.lua b/src/engine/menu/mainmenucompletionselect.lua new file mode 100644 index 000000000..221dfc786 --- /dev/null +++ b/src/engine/menu/mainmenucompletionselect.lua @@ -0,0 +1,484 @@ +-- This is a giant, horrific hack to load saves + +---@class MainMenuCompletionSelect : StateClass +--- +---@field menu MainMenu +--- +---@overload fun(menu:MainMenu) : MainMenuCompletionSelect +local MainMenuCompletionSelect, super = Class(StateClass) + +function MainMenuCompletionSelect:init(menu) + self.menu = menu +end + +function MainMenuCompletionSelect:registerEvents() + self:registerEvent("enter", self.onEnter) + self:registerEvent("leave", self.onLeave) + self:registerEvent("keypressed", self.onKeyPressed) + self:registerEvent("update", self.update) + self:registerEvent("draw", self.draw) +end + +function MainMenuCompletionSelect:mkIter(input) + if type(input) == "table" or type(input) == "userdata" then + assert(input.lines, "Invalid argument #1 (Expected file or function)") + local file = input + if not file:isOpen() then + file:open("r") + end + input = file:lines() + elseif type(input) == "string" then + local lines = Utils.split(input, "\n", false) + local index = 0 + input = function() + index = index + 1 + return lines[index] + end + else + assert(type(input) == "function", type(input)) + end + return input +end + +function MainMenuCompletionSelect:parseIni(input) + input = self:mkIter(input) + local data = {} + local current_subkey + for line in input do + local key, value = Utils.unpack(Utils.splitFast(line, "=")) + if line[1] == "[" then + current_subkey = {} + data[line:sub(2, #line - 1)] = current_subkey + elseif key and value then + if value[1] == '"' then + value = value:sub(2, #value - 1) + end + if tonumber(value) then value = tonumber(value) end + ---@diagnostic disable-next-line: cast-local-type + if tonumber(key) then key = tonumber(key) end + current_subkey[key] = value + end + end + return data +end + +local function getSaveDirectory() + local os_name = love.system.getOS() + if os_name == "Windows" then + return string.gsub(os.getenv("LOCALAPPDATA"), "\\", "/") .. "/Deltarune/" + elseif os_name == "Linux" then + return os.getenv("HOME") .. "/.local/share/Steam/steamapps/compatdata/1690940/pfx/drive_c/users/steamuser/AppData/Local/DELTARUNE/" + elseif os_name == "OS X" then + return os.getenv("HOME") .. "/Library/Application Support/com.tobyfox.deltarune/" + end +end + +------------------------------------------------------------------------------- +-- Callbacks +------------------------------------------------------------------------------- + +function MainMenuCompletionSelect:onEnter(old_state) + self.dr_ini = {} + (function () + local path = getSaveDirectory() + self.dr_ini = self:parseIni(NativeFS.newFile(path .. "dr.ini")) + end)() + if old_state == "FILENAME" then + self.container.visible = true + self.container.active = true + return + end + + self.mod = self.menu.selected_mod + + self.container = self.menu.stage:addChild(Object()) + self.container:setLayer(50) + + -- SELECT, COPY, ERASE, TRANSITIONING + self.state = "SELECT" + + self.result_text = nil + self.result_timer = 0 + + self.focused_button = nil + self.copied_button = nil + self.erase_stage = 1 + + self.selected_x = 1 + self.selected_y = 1 + + self.files = {} + for i = 1, 3 do + local data = Kristal.loadData("file_" .. i, self.mod.id) + local button = FileButton(self, i, self:getCompletionFile(i), 110, 110 + 90 * (i - 1), 422, 82) + if i == 1 then + button.selected = true + end + table.insert(self.files, button) + self.container:addChild(button) + end + + self.bottom_row_heart = { 80, 250, 440 } +end + +function MainMenuCompletionSelect:getCompletionFile(slot) + local dr_save = self.dr_ini["G_2_"..(2+slot)] + if dr_save then + return {name = dr_save.Name, room_name = "Living Room?", playtime = (dr_save.Time/30)} + end +end + +function MainMenuCompletionSelect:onLeave(new_state) + if new_state == "FILENAME" then + self.container.visible = false + self.container.active = false + else + self.container:remove() + self.container = nil + end +end + +function MainMenuCompletionSelect:onKeyPressed(key, is_repeat) + if is_repeat or self.state == "TRANSITIONING" then + return true + end + if self.focused_button then + local button = self.focused_button + if Input.is("cancel", key) then + button:setColor(1, 1, 1) + button:setChoices() + if self.state == "COPY" then + self.selected_y = self.copied_button.id + self.copied_button:setColor(1, 1, 1) + self.copied_button = nil + self:updateSelected() + elseif self.state == "ERASE" then + self.erase_stage = 1 + end + self.focused_button = nil + Assets.stopAndPlaySound("ui_cancel") + return true + end + if Input.is("left", key) and button.selected_choice == 2 then + button.selected_choice = 1 + Assets.stopAndPlaySound("ui_move") + end + if Input.is("right", key) and button.selected_choice == 1 then + button.selected_choice = 2 + Assets.stopAndPlaySound("ui_move") + end + if Input.is("confirm", key) then + if self.state == "SELECT" then + Assets.stopAndPlaySound("ui_select") + if button.selected_choice == 1 then + local skip_naming = button.data ~= nil + + if skip_naming then + self:setState("TRANSITIONING") + local save_name = nil + if not button.data and Kristal.Config["skipNameEntry"] and Kristal.Config["defaultName"] ~= "" then + save_name = string.sub(Kristal.Config["defaultName"], 1, self.mod["nameLimit"] or 12) + end + Kristal.loadMod(self.mod.id, -1, save_name) + else + self.menu:setState("FILENAME") + + button:setChoices() + self.focused_button = nil + Assets.playSound("ui_cant_select") + end + elseif button.selected_choice == 2 then + button:setChoices() + self.focused_button = nil + end + elseif self.state == "ERASE" then + if button.selected_choice == 1 and self.erase_stage == 1 then + Assets.stopAndPlaySound("ui_select") + button:setColor(1, 0, 0) + button:setChoices({ "Yes!", "No!" }, "Really erase it?") + self.erase_stage = 2 + else + local result + if button.selected_choice == 1 and self.erase_stage == 2 then + Assets.stopAndPlaySound("ui_spooky_action") + Kristal.eraseData("file_" .. button.id, self.mod.id) + button:setData(nil) + result = "Erase complete." + else + Assets.stopAndPlaySound("ui_select") + end + button:setChoices() + button:setColor(1, 1, 1) + self.focused_button = nil + self.erase_stage = 1 + + self:setState("SELECT", result) + self.selected_x = 2 + self.selected_y = 4 + self:updateSelected() + end + elseif self.state == "COPY" then + if button.selected_choice == 1 then + Assets.stopAndPlaySound("ui_spooky_action") + local data = Kristal.loadData("file_" .. self.copied_button.id, self.mod.id) + Kristal.saveData("file_" .. button.id, data, self.mod.id) + button:setData(data) + button:setChoices() + self:setState("SELECT", "Copy complete.") + self.copied_button:setColor(1, 1, 1) + self.copied_button = nil + self.focused_button = nil + self.selected_x = 1 + self.selected_y = 4 + self:updateSelected() + elseif button.selected_choice == 2 then + Assets.stopAndPlaySound("ui_select") + button:setChoices() + self:setState("SELECT") + self.copied_button:setColor(1, 1, 1) + self.copied_button = nil + self.focused_button = nil + self.selected_x = 1 + self.selected_y = 4 + self:updateSelected() + end + end + end + elseif self.state == "SELECT" then + if Input.is("cancel", key) then + self.menu:setState("FILESELECT") + Assets.stopAndPlaySound("ui_cancel") + return true + end + if Input.is("confirm", key) then + Assets.stopAndPlaySound("ui_select") + if self.selected_y <= 3 then + if self:getSelectedFile().data then + -- TODO: Handle this variable in modland + DELTARUNE_SAVE_ID = self.selected_y + Kristal.loadMod(self.mod.id, -1, save_name) + else + Assets.playSound("ui_cant_select") + end + elseif self.selected_y == 4 then + self.menu:setState("FILESELECT") + end + return true + end + local last_x, last_y = self.selected_x, self.selected_y + if Input.is("up", key) then self.selected_y = self.selected_y - 1 end + if Input.is("down", key) then self.selected_y = self.selected_y + 1 end + if Input.is("left", key) then self.selected_x = self.selected_x - 1 end + if Input.is("right", key) then self.selected_x = self.selected_x + 1 end + self.selected_y = Utils.clamp(self.selected_y, 1, 4) + if self.selected_y <= 3 then + self.selected_x = 1 + else + self.selected_x = 1 + end + if last_x ~= self.selected_x or last_y ~= self.selected_y then + Assets.stopAndPlaySound("ui_move") + self:updateSelected() + end + elseif self.state == "COPY" then + if Input.is("cancel", key) then + Assets.stopAndPlaySound("ui_cancel") + if self.copied_button then + self.selected_y = self.copied_button.id + self.copied_button:setColor(1, 1, 1) + self.copied_button = nil + self:updateSelected() + else + self:setState("SELECT") + self.selected_x = 1 + self.selected_y = 4 + self:updateSelected() + end + return true + end + if Input.is("confirm", key) then + if self.selected_y <= 3 then + if not self.copied_button then + local button = self:getSelectedFile() + if button.data then + Assets.stopAndPlaySound("ui_select") + self.copied_button = self:getSelectedFile() + self.copied_button:setColor(1, 1, 0.5) + self.selected_y = 1 + self:updateSelected() + else + Assets.stopAndPlaySound("ui_cancel") + self:setResultText("It can't be copied.") + end + else + local selected = self:getSelectedFile() + if selected == self.copied_button then + Assets.stopAndPlaySound("ui_cancel") + self:setResultText("You can't copy there.") + elseif selected.data then + Assets.stopAndPlaySound("ui_select") + self.focused_button = selected + self.focused_button:setChoices({ "Yes", "No" }, "Copy over this file?") + else + Assets.stopAndPlaySound("ui_spooky_action") + local data = Kristal.loadData("file_" .. self.copied_button.id, self.mod.id) + Kristal.saveData("file_" .. selected.id, data, self.mod.id) + selected:setData(data) + self:setState("SELECT", "Copy complete.") + self.copied_button:setColor(1, 1, 1) + self.copied_button = nil + self.selected_x = 1 + self.selected_y = 4 + self:updateSelected() + end + end + elseif self.selected_y == 4 then + Assets.stopAndPlaySound("ui_select") + self:setState("SELECT") + if self.copied_button then + self.copied_button:setColor(1, 1, 1) + self.copied_button = nil + end + self.selected_x = 1 + self.selected_y = 4 + self:updateSelected() + end + return true + end + local last_x, last_y = self.selected_x, self.selected_y + if Input.is("up", key) then self.selected_y = self.selected_y - 1 end + if Input.is("down", key) then self.selected_y = self.selected_y + 1 end + self.selected_x = 1 + self.selected_y = Utils.clamp(self.selected_y, 1, 4) + if last_x ~= self.selected_x or last_y ~= self.selected_y then + Assets.stopAndPlaySound("ui_move") + self:updateSelected() + end + elseif self.state == "ERASE" then + if Input.is("cancel", key) then + Assets.stopAndPlaySound("ui_cancel") + self:setState("SELECT") + self.selected_x = 2 + self.selected_y = 4 + self:updateSelected() + return true + end + if Input.is("confirm", key) then + if self.selected_y <= 3 then + local button = self:getSelectedFile() + if button.data then + self.focused_button = button + self.focused_button:setChoices({ "Yes", "No" }, "Erase this file?") + Assets.stopAndPlaySound("ui_select") + else + self:setResultText("There's nothing to erase.") + Assets.stopAndPlaySound("ui_cancel") + end + elseif self.selected_y == 4 then + Assets.stopAndPlaySound("ui_select") + self:setState("SELECT") + self.selected_x = 2 + self.selected_y = 4 + self:updateSelected() + end + return true + end + local last_x, last_y = self.selected_x, self.selected_y + if Input.is("up", key) then self.selected_y = self.selected_y - 1 end + if Input.is("down", key) then self.selected_y = self.selected_y + 1 end + self.selected_x = 1 + self.selected_y = Utils.clamp(self.selected_y, 1, 4) + if last_x ~= self.selected_x or last_y ~= self.selected_y then + Assets.stopAndPlaySound("ui_move") + self:updateSelected() + end + end + + return true +end + +function MainMenuCompletionSelect:update() + if self.result_timer > 0 then + self.result_timer = Utils.approach(self.result_timer, 0, DT) + if self.result_timer == 0 then + self.result_text = nil + end + end + + self:updateSelected() + + self.menu.heart_target_x, self.menu.heart_target_y = self:getHeartPos() +end + +function MainMenuCompletionSelect:draw() + local mod_name = string.upper(self.mod.name or self.mod.id) + Draw.printShadow(mod_name, 16, 8) + + Draw.printShadow(self:getTitle(), 80, 60) + + local function setColor(x, y) + if self.selected_x == x and self.selected_y == y then + Draw.setColor(1, 1, 1) + else + Draw.setColor(0.6, 0.6, 0.7) + end + end + + if self.state == "SELECT" or self.state == "TRANSITIONING" then + setColor(1, 4) + Draw.printShadow("Don't use DELTARUNE file", 108, 380) + else + setColor(1, 4) + Draw.printShadow("Cancel", 110, 380) + end + + Draw.setColor(1, 1, 1) +end + +------------------------------------------------------------------------------- +-- Class Methods +------------------------------------------------------------------------------- + +function MainMenuCompletionSelect:getTitle() + if self.result_text then + return self.result_text + end + return "Start Dark Place from a DELTARUNE file." +end + +function MainMenuCompletionSelect:setState(state, result_text) + self:setResultText(result_text) + self.state = state +end + +function MainMenuCompletionSelect:setResultText(text) + self.result_text = text + self.result_timer = 3 +end + +function MainMenuCompletionSelect:updateSelected() + for i, file in ipairs(self.files) do + if i == self.selected_y or (self.state == "COPY" and self.copied_button == file) then + file.selected = true + else + file.selected = false + end + end +end + +function MainMenuCompletionSelect:getSelectedFile() + return self.files[self.selected_y] +end + +function MainMenuCompletionSelect:getHeartPos() + if self.selected_y <= 3 then + local button = self:getSelectedFile() + local hx, hy = button:getHeartPos() + local x, y = button:getRelativePos(hx, hy) + return x + 9, y + 9 + elseif self.selected_y == 4 then + return self.bottom_row_heart[self.selected_x] + 9, 390 + 9 + end +end + +return MainMenuCompletionSelect diff --git a/src/engine/menu/mainmenufileselect.lua b/src/engine/menu/mainmenufileselectdark.lua similarity index 99% rename from src/engine/menu/mainmenufileselect.lua rename to src/engine/menu/mainmenufileselectdark.lua index 2d20579a0..041c1b200 100644 --- a/src/engine/menu/mainmenufileselect.lua +++ b/src/engine/menu/mainmenufileselectdark.lua @@ -1,8 +1,8 @@ ----@class MainMenuFileSelect : StateClass +---@class MainMenuFileSelectDark : StateClass --- ---@field menu MainMenu --- ----@overload fun(menu:MainMenu) : MainMenuFileSelect +---@overload fun(menu:MainMenu) : MainMenuFileSelectDark local MainMenuFileSelect, super = Class(StateClass) function MainMenuFileSelect:init(menu) @@ -64,7 +64,7 @@ function MainMenuFileSelect:onEnter(old_state) for i = 0, 1 do for k = 0, 1 do local data = Kristal.loadData("file_" .. file, self.mod.id) - local button = FileButton(self, file, data, 28 + SCREEN_WIDTH/2 * (k), 110 + 160 * (i), 264, 112) + local button = DarkFileButton(self, file, data, 28 + SCREEN_WIDTH/2 * (k), 110 + 160 * (i), 264, 112) if i == 1 then button.selected = true diff --git a/src/engine/menu/objects/darkfilebutton.lua b/src/engine/menu/objects/darkfilebutton.lua new file mode 100644 index 000000000..7187c180c --- /dev/null +++ b/src/engine/menu/objects/darkfilebutton.lua @@ -0,0 +1,164 @@ +---@class FileButton : Object +---@overload fun(...) : FileButton +local FileButton, super = Class(Object) + +function FileButton:init(list, id, data, x, y, width, height) + super.init(self, x, y, width, height) + + self.list = list + self.data = data + self.id = id or 1 + + self:setData(data) + + self.selected = false + + self.font = Assets.getFont("main") + self.subfont = Assets.getFont("main", 16) + + self.prompt = nil + self.choices = nil + self.selected_choice = 1 +end + +function FileButton:setData(data) + self.data = data + + self.name = data and data.name or "[EMPTY]" + self.area = data and data.room_name or "------------" + + if data and data.playtime then + local hours = math.floor(data.playtime / 3600) + local minutes = math.floor(data.playtime / 60 % 60) + local seconds = math.floor(data.playtime % 60) + self.time = string.format("%d:%02d:%02d", hours, minutes, seconds) + else + -- Don't ask why it's not "--:--:--" -- ask Toby + self.time = "--:--" + end +end + +function FileButton:setChoices(choices, prompt) + self.prompt = prompt + self.choices = choices + self.selected_choice = 1 +end + +function FileButton:getDrawColor() + local r, g, b, a = super.getDrawColor(self) + if not self.selected then + return r * 0.6, g * 0.6, b * 0.7, a + else + return r, g, b, a + end +end + +function FileButton:getHeartPos() + if not self.choices then + if self.selected_x == 1 then + return self.width/2 - 16, -10 + else + return self.width/2 - 16, -10 + end + else + if self.selected_choice == 1 then + return 0, 69 - 6 + else + return 152, 69 - 6 + end + end +end + +function FileButton:drawCoolRectangle(x, y, w, h) + -- Make sure the line is a single pixel wide + love.graphics.setLineWidth(1) + love.graphics.setLineStyle("rough") + -- Set the color + Draw.setColor(self:getDrawColor()) + -- Draw the rectangles + love.graphics.rectangle("line", x, y, w + 1, h + 1) + -- Increase the width and height by one instead of two to produce the broken effect + love.graphics.rectangle("line", x - 1, y - 1, w + 2, h + 2) + love.graphics.rectangle("line", x - 2, y - 2, w + 5, h + 5) + -- Here too + love.graphics.rectangle("line", x - 3, y - 3, w + 6, h + 6) +end + +function FileButton:draw() + -- Draw the transparent background + Draw.setColor(0, 0, 0, 0.5) + love.graphics.rectangle("fill", 0, 0, self.width, self.height) + + -- Draw the rectangle outline + self:drawCoolRectangle(0, 0, self.width, self.height) + + -- Draw text inside the button rectangle + Draw.pushScissor() + Draw.scissor(0, 0, self.width, self.height) + + if not self.prompt then + -- Draw the name shadow + Draw.setColor(0, 0, 0) + local name_x = Utils.clamp((self.width-self.font:getWidth(self.name))/2 + 2, 6, self.width - 6) + local name_sx = self.font:getWidth(self.name) <= 256 and 1 or 256/self.font:getWidth(self.name) + love.graphics.print(self.name, name_x + 2, 2 + 2, 0, name_sx, 1) + -- Draw the name + Draw.setColor(self:getDrawColor()) + love.graphics.print(self.name, name_x, 2, 0, name_sx, 1) + + -- Draw the time shadow + local time_x = Utils.clamp((self.width-self.font:getWidth(self.time))/2 + 2, 6, self.width - 6) + local time_sx = self.font:getWidth(self.time) <= 256 and 1 or 256/self.font:getWidth(self.time) + 2 + Draw.setColor(0, 0, 0) + love.graphics.print(self.time, time_x + 2, 76 + 2, 0, time_sx, 1) + -- Draw the time + Draw.setColor(self:getDrawColor()) + love.graphics.print(self.time, time_x, 76, 0, time_sx, 1) + else + -- Draw the prompt shadow + local prompt_x = Utils.clamp((self.width-self.font:getWidth(self.prompt))/2 + 2, 6, self.width - 6) + local prompt_sx = self.font:getWidth(self.prompt) <= 256 and 1 or 256/self.font:getWidth(self.prompt) + Draw.setColor(0, 0, 0) + love.graphics.print(self.prompt, prompt_x + 2, 2 + 2, 0, prompt_sx, 1) + -- Draw the prompt + Draw.setColor(self:getDrawColor()) + love.graphics.print(self.prompt, prompt_x, 2, 0, prompt_sx, 1) + end + + if not self.choices then + -- Draw the area shadow + local area_x = Utils.clamp((self.width-self.font:getWidth(self.area))/2 + 2, 6, self.width - 6) + local area_sx = self.font:getWidth(self.area) <= 256 and 1 or 256/self.font:getWidth(self.area) + Draw.setColor(0, 0, 0) + love.graphics.print(self.area, area_x + 2, 38 + 2, 0, area_sx, 1) + -- Draw the area + Draw.setColor(self:getDrawColor()) + love.graphics.print(self.area, area_x, 38, 0, area_sx, 1) + else + -- Draw the shadow for choice 1 + Draw.setColor(0, 0, 0) + love.graphics.print(self.choices[1], 34+2, 38+2) + -- Draw choice 1 + if self.selected_choice == 1 then + Draw.setColor(1, 1, 1) + else + Draw.setColor(0.6, 0.6, 0.7) + end + love.graphics.print(self.choices[1], 34, 38) + + -- Draw the shadow for choice 2 + Draw.setColor(0, 0, 0) + love.graphics.print(self.choices[2], 186+2, 38+2) + -- Draw choice 2s + if self.selected_choice == 2 then + Draw.setColor(1, 1, 1) + else + Draw.setColor(0.6, 0.6, 0.7) + end + love.graphics.print(self.choices[2], 186, 38) + end + + Draw.popScissor() +end + +return FileButton \ No newline at end of file diff --git a/src/engine/menu/objects/filebutton.lua b/src/engine/menu/objects/filebutton.lua index 7187c180c..8926b48b7 100644 --- a/src/engine/menu/objects/filebutton.lua +++ b/src/engine/menu/objects/filebutton.lua @@ -55,16 +55,12 @@ end function FileButton:getHeartPos() if not self.choices then - if self.selected_x == 1 then - return self.width/2 - 16, -10 - else - return self.width/2 - 16, -10 - end + return 20, self.height / 2 - 9 else if self.selected_choice == 1 then - return 0, 69 - 6 + return 40, 52 else - return 152, 69 - 6 + return 220, 52 end end end @@ -99,63 +95,56 @@ function FileButton:draw() if not self.prompt then -- Draw the name shadow Draw.setColor(0, 0, 0) - local name_x = Utils.clamp((self.width-self.font:getWidth(self.name))/2 + 2, 6, self.width - 6) - local name_sx = self.font:getWidth(self.name) <= 256 and 1 or 256/self.font:getWidth(self.name) - love.graphics.print(self.name, name_x + 2, 2 + 2, 0, name_sx, 1) + love.graphics.print(self.name, 50 + 2, 10 + 2) -- Draw the name Draw.setColor(self:getDrawColor()) - love.graphics.print(self.name, name_x, 2, 0, name_sx, 1) + love.graphics.print(self.name, 50, 10) -- Draw the time shadow - local time_x = Utils.clamp((self.width-self.font:getWidth(self.time))/2 + 2, 6, self.width - 6) - local time_sx = self.font:getWidth(self.time) <= 256 and 1 or 256/self.font:getWidth(self.time) + 2 + local time_x = self.width-64-self.font:getWidth(self.time) + 2 Draw.setColor(0, 0, 0) - love.graphics.print(self.time, time_x + 2, 76 + 2, 0, time_sx, 1) + love.graphics.print(self.time, time_x + 2, 10 + 2) -- Draw the time Draw.setColor(self:getDrawColor()) - love.graphics.print(self.time, time_x, 76, 0, time_sx, 1) + love.graphics.print(self.time, time_x, 10) else -- Draw the prompt shadow - local prompt_x = Utils.clamp((self.width-self.font:getWidth(self.prompt))/2 + 2, 6, self.width - 6) - local prompt_sx = self.font:getWidth(self.prompt) <= 256 and 1 or 256/self.font:getWidth(self.prompt) Draw.setColor(0, 0, 0) - love.graphics.print(self.prompt, prompt_x + 2, 2 + 2, 0, prompt_sx, 1) + love.graphics.print(self.prompt, 50 + 2, 10 + 2) -- Draw the prompt Draw.setColor(self:getDrawColor()) - love.graphics.print(self.prompt, prompt_x, 2, 0, prompt_sx, 1) + love.graphics.print(self.prompt, 50, 10) end if not self.choices then -- Draw the area shadow - local area_x = Utils.clamp((self.width-self.font:getWidth(self.area))/2 + 2, 6, self.width - 6) - local area_sx = self.font:getWidth(self.area) <= 256 and 1 or 256/self.font:getWidth(self.area) Draw.setColor(0, 0, 0) - love.graphics.print(self.area, area_x + 2, 38 + 2, 0, area_sx, 1) + love.graphics.print(self.area, 50 + 2, 44 + 2) -- Draw the area Draw.setColor(self:getDrawColor()) - love.graphics.print(self.area, area_x, 38, 0, area_sx, 1) + love.graphics.print(self.area, 50, 44) else -- Draw the shadow for choice 1 Draw.setColor(0, 0, 0) - love.graphics.print(self.choices[1], 34+2, 38+2) + love.graphics.print(self.choices[1], 70+2, 44+2) -- Draw choice 1 if self.selected_choice == 1 then Draw.setColor(1, 1, 1) else Draw.setColor(0.6, 0.6, 0.7) end - love.graphics.print(self.choices[1], 34, 38) + love.graphics.print(self.choices[1], 70, 44) -- Draw the shadow for choice 2 Draw.setColor(0, 0, 0) - love.graphics.print(self.choices[2], 186+2, 38+2) - -- Draw choice 2s + love.graphics.print(self.choices[2], 250+2, 44+2) + -- Draw choice 2 if self.selected_choice == 2 then Draw.setColor(1, 1, 1) else Draw.setColor(0.6, 0.6, 0.7) end - love.graphics.print(self.choices[2], 186, 38) + love.graphics.print(self.choices[2], 250, 44) end Draw.popScissor() diff --git a/src/engine/statevars.lua b/src/engine/statevars.lua index c31b80517..5a84f14fd 100644 --- a/src/engine/statevars.lua +++ b/src/engine/statevars.lua @@ -40,5 +40,9 @@ NOCLIP = false REGISTRY_LOADED = false +--- Which Deltarune save file should be loaded upon mod startup. +---@type 1|2|3|nil +DELTARUNE_SAVE_ID = nil + ---@type string? COROUTINE_TRACEBACK = nil diff --git a/src/lib/nativefs.lua b/src/lib/nativefs.lua new file mode 100644 index 000000000..e9aed56e1 --- /dev/null +++ b/src/lib/nativefs.lua @@ -0,0 +1,451 @@ +-- This is a modified version of https://github.com/zorggn/nativefs/, with additional type hints. +-- Maybe we should upstream this + +local ffi, bit = require('ffi'), require('bit') +local C = ffi.C + +---@class NativeFS.File +local File = { + getBuffer = function(self) return self._bufferMode, self._bufferSize end, + getFilename = function(self) return self._name end, + getMode = function(self) return self._mode end, + isOpen = function(self) return self._mode ~= 'c' and self._handle ~= nil end, +} + +local fopen, getcwd, chdir, unlink, mkdir, rmdir +local BUFFERMODE, MODEMAP +local ByteArray = ffi.typeof('unsigned char[?]') +local function _ptr(p) return p ~= nil and p or nil end -- NULL pointer to nil + +function File:open(mode) + if self._mode ~= 'c' then return false, "File " .. self._name .. " is already open" end + if not MODEMAP[mode] then return false, "Invalid open mode for " .. self._name .. ": " .. mode end + + local handle = _ptr(fopen(self._name, MODEMAP[mode])) + if not handle then return false, "Could not open " .. self._name .. " in mode " .. mode end + + self._handle, self._mode = ffi.gc(handle, C.fclose), mode + self:setBuffer(self._bufferMode, self._bufferSize) + + return true +end + +function File:close() + if self._mode == 'c' then return false, "File is not open" end + C.fclose(ffi.gc(self._handle, nil)) + self._handle, self._mode = nil, 'c' + return true +end + +function File:setBuffer(mode, size) + local bufferMode = BUFFERMODE[mode] + if not bufferMode then + return false, "Invalid buffer mode " .. mode .. " (expected 'none', 'full', or 'line')" + end + + if mode == 'none' then + size = math.max(0, size or 0) + else + size = math.max(2, size or 2) -- Windows requires buffer to be at least 2 bytes + end + + local success = self._mode == 'c' or C.setvbuf(self._handle, nil, bufferMode, size) == 0 + if not success then + self._bufferMode, self._bufferSize = 'none', 0 + return false, "Could not set buffer mode" + end + + self._bufferMode, self._bufferSize = mode, size + return true +end + +function File:getSize() + -- NOTE: The correct way to do this would be a stat() call, which requires a + -- lot more (system-specific) code. This is a shortcut that requires the file + -- to be readable. + local mustOpen = not self:isOpen() + if mustOpen and not self:open('r') then return 0 end + + local pos = mustOpen and 0 or self:tell() + C.fseek(self._handle, 0, 2) + local size = self:tell() + if mustOpen then + self:close() + else + self:seek(pos) + end + return size +end + +function File:read(containerOrBytes, bytes) + if self._mode ~= 'r' then return nil, 0 end + + local container = bytes ~= nil and containerOrBytes or 'string' + if container ~= 'string' and container ~= 'data' then + error("Invalid container type: " .. container) + end + + bytes = not bytes and containerOrBytes or 'all' + bytes = bytes == 'all' and self:getSize() - self:tell() or math.min(self:getSize() - self:tell(), bytes) + + if bytes <= 0 then + local data = container == 'string' and '' or love.data.newFileData('', self._name) + return data, 0 + end + + local data = love.data.newByteData(bytes) + local r = tonumber(C.fread(data:getFFIPointer(), 1, bytes, self._handle)) + + local str = data:getString() + data:release() + data = container == 'data' and love.filesystem.newFileData(str, self._name) or str + return data, r +end + +function File:lines() + if self._mode ~= 'r' then error("File is not opened for reading") end + + local BUFFERSIZE = 4096 + local buffer = ByteArray(BUFFERSIZE) + local bytesRead = tonumber(C.fread(buffer, 1, BUFFERSIZE, self._handle)) + + local bufferPos = 0 + local offset = self:tell() + return function() + local line = {} + self:seek(offset) + + while bytesRead > 0 do + for i = bufferPos, bytesRead - 1 do + if buffer[i] == 10 then -- end of line + bufferPos = i + 1 + return table.concat(line) + end + + if buffer[i] ~= 13 then -- ignore CR + table.insert(line, string.char(buffer[i])) + end + end + + bytesRead = tonumber(C.fread(buffer, 1, BUFFERSIZE, self._handle)) + offset, bufferPos = offset + bytesRead, 0 + end + + return line[1] and table.concat(line) or nil + end +end + +function File:write(data, size) + if self._mode ~= 'w' and self._mode ~= 'a' then + return false, "File " .. self._name .. " not opened for writing" + end + + local toWrite, writeSize + if type(data) == 'string' then + writeSize = (size == nil or size == 'all') and #data or size + toWrite = data + else + writeSize = (size == nil or size == 'all') and data:getSize() or size + toWrite = data:getFFIPointer() + end + + if tonumber(C.fwrite(toWrite, 1, writeSize, self._handle)) ~= writeSize then + return false, "Could not write data" + end + return true +end + +function File:seek(pos) + return self._handle and C.fseek(self._handle, pos, 0) == 0 +end + +function File:tell() + if not self._handle then return nil, "Invalid position" end + return tonumber(C.ftell(self._handle)) +end + +function File:flush() + if self._mode ~= 'w' and self._mode ~= 'a' then + return nil, "File is not opened for writing" + end + return C.fflush(self._handle) == 0 +end + +function File:isEOF() + return not self:isOpen() or C.feof(self._handle) ~= 0 or self:tell() == self:getSize() +end + +function File:release() + if self._mode ~= 'c' then self:close() end + self._handle = nil +end + +function File:type() return 'File' end + +function File:typeOf(t) return t == 'File' end + +File.__index = File + +----------------------------------------------------------------------------- + +---@class NativeFS +local nativefs = {} +local loveC = ffi.os == 'Windows' and ffi.load('love') or C + +---@return NativeFS.File +function nativefs.newFile(name) + return setmetatable({ + _name = name, + _mode = 'c', + _handle = nil, + _bufferSize = 0, + _bufferMode = 'none' + }, File) +end + +---@return love.FileData +function nativefs.newFileData(filepath) + local f = nativefs.newFile(filepath) + local ok, err = f:open('r') + if not ok then return nil, err end + + local data, err = f:read('data', 'all') + f:close() + return data, err +end + +function nativefs.mount(archive, mountPoint, appendToPath) + return loveC.PHYSFS_mount(archive, mountPoint, appendToPath and 1 or 0) ~= 0 +end + +function nativefs.unmount(archive) + return loveC.PHYSFS_unmount(archive) ~= 0 +end + +function nativefs.read(containerOrName, nameOrSize, sizeOrNil) + local container, name, size + if sizeOrNil then + container, name, size = containerOrName, nameOrSize, sizeOrNil + elseif not nameOrSize then + container, name, size = 'string', containerOrName, 'all' + else + if type(nameOrSize) == 'number' or nameOrSize == 'all' then + container, name, size = 'string', containerOrName, nameOrSize + else + container, name, size = containerOrName, nameOrSize, 'all' + end + end + + local file = nativefs.newFile(name) + local ok, err = file:open('r') + if not ok then return nil, err end + + local data, size = file:read(container, size) + file:close() + return data, size +end + +local function writeFile(mode, name, data, size) + local file = nativefs.newFile(name) + local ok, err = file:open(mode) + if not ok then return nil, err end + + ok, err = file:write(data, size or 'all') + file:close() + return ok, err +end + +function nativefs.write(name, data, size) + return writeFile('w', name, data, size) +end + +function nativefs.append(name, data, size) + return writeFile('a', name, data, size) +end + +function nativefs.lines(name) + local f = nativefs.newFile(name) + local ok, err = f:open('r') + if not ok then return nil, err end + return f:lines() +end + +function nativefs.load(name) + local chunk, err = nativefs.read(name) + if not chunk then return nil, err end + return loadstring(chunk, name) +end + +function nativefs.getWorkingDirectory() + return getcwd() +end + +function nativefs.setWorkingDirectory(path) + if not chdir(path) then return false, "Could not set working directory" end + return true +end + +function nativefs.getDriveList() + if ffi.os ~= 'Windows' then return { '/' } end + local drives, bits = {}, C.GetLogicalDrives() + for i = 0, 25 do + if bit.band(bits, 2 ^ i) > 0 then + table.insert(drives, string.char(65 + i) .. ':/') + end + end + return drives +end + +function nativefs.createDirectory(path) + local current = '' + for dir in path:gmatch('[^/\\]+') do + current = (current == '' and current or current .. '/') .. dir + local info = nativefs.getInfo(current, 'directory') + if not info and not mkdir(current) then return false, "Could not create directory " .. current end + end + return true +end + +function nativefs.remove(name) + local info = nativefs.getInfo(name) + if not info then return false, "Could not remove " .. name end + if info.type == 'directory' then + if not rmdir(name) then return false, "Could not remove directory " .. name end + return true + end + if not unlink(name) then return false, "Could not remove file " .. name end + return true +end + +local function withTempMount(dir, fn, ...) + local mountPoint = _ptr(loveC.PHYSFS_getMountPoint(dir)) + if mountPoint then return fn(ffi.string(mountPoint), ...) end + if not nativefs.mount(dir, '__nativefs__temp__') then return false, "Could not mount " .. dir end + local a, b = fn('__nativefs__temp__', ...) + nativefs.unmount(dir) + return a, b +end + +function nativefs.getDirectoryItems(dir) + local result, err = withTempMount(dir, love.filesystem.getDirectoryItems) + return result or {} +end + +local function getDirectoryItemsInfo(path, filtertype) + local items = {} + local files = love.filesystem.getDirectoryItems(path) + for i = 1, #files do + local filepath = string.format('%s/%s', path, files[i]) + local info = love.filesystem.getInfo(filepath, filtertype) + if info then + info.name = files[i] + table.insert(items, info) + end + end + return items +end + +function nativefs.getDirectoryItemsInfo(path, filtertype) + local result, err = withTempMount(path, getDirectoryItemsInfo, filtertype) + return result or {} +end + +local function getInfo(path, file, filtertype) + local filepath = string.format('%s/%s', path, file) + return love.filesystem.getInfo(filepath, filtertype) +end + +function nativefs.getInfo(path, filtertype) + local dir = path:match("(.*[\\/]).*$") or './' + local file = love.path.leaf(path) + local result, err = withTempMount(dir, getInfo, file, filtertype) + return result or nil +end + +----------------------------------------------------------------------------- + +MODEMAP = { r = 'rb', w = 'wb', a = 'ab' } +local MAX_PATH = 4096 + +ffi.cdef([[ + int PHYSFS_mount(const char* dir, const char* mountPoint, int appendToPath); + int PHYSFS_unmount(const char* dir); + const char* PHYSFS_getMountPoint(const char* dir); + + typedef struct FILE FILE; + + FILE* fopen(const char* path, const char* mode); + size_t fread(void* ptr, size_t size, size_t nmemb, FILE* stream); + size_t fwrite(const void* ptr, size_t size, size_t nmemb, FILE* stream); + int fclose(FILE* stream); + int fflush(FILE* stream); + size_t fseek(FILE* stream, size_t offset, int whence); + size_t ftell(FILE* stream); + int setvbuf(FILE* stream, char* buffer, int mode, size_t size); + int feof(FILE* stream); +]]) + +if ffi.os == 'Windows' then + ffi.cdef([[ + int MultiByteToWideChar(unsigned int cp, uint32_t flags, const char* mb, int cmb, const wchar_t* wc, int cwc); + int WideCharToMultiByte(unsigned int cp, uint32_t flags, const wchar_t* wc, int cwc, const char* mb, + int cmb, const char* def, int* used); + int GetLogicalDrives(void); + int CreateDirectoryW(const wchar_t* path, void*); + int _wchdir(const wchar_t* path); + wchar_t* _wgetcwd(wchar_t* buffer, int maxlen); + FILE* _wfopen(const wchar_t* path, const wchar_t* mode); + int _wunlink(const wchar_t* path); + int _wrmdir(const wchar_t* path); + ]]) + + BUFFERMODE = { full = 0, line = 64, none = 4 } + + local function towidestring(str) + local size = C.MultiByteToWideChar(65001, 0, str, #str, nil, 0) + local buf = ffi.new('wchar_t[?]', size + 1) + C.MultiByteToWideChar(65001, 0, str, #str, buf, size) + return buf + end + + local function toutf8string(wstr) + local size = C.WideCharToMultiByte(65001, 0, wstr, -1, nil, 0, nil, nil) + local buf = ffi.new('char[?]', size + 1) + C.WideCharToMultiByte(65001, 0, wstr, -1, buf, size, nil, nil) + return ffi.string(buf) + end + + local nameBuffer = ffi.new('wchar_t[?]', MAX_PATH + 1) + + fopen = function(path, mode) return C._wfopen(towidestring(path), towidestring(mode)) end + getcwd = function() return toutf8string(C._wgetcwd(nameBuffer, MAX_PATH)) end + chdir = function(path) return C._wchdir(towidestring(path)) == 0 end + unlink = function(path) return C._wunlink(towidestring(path)) == 0 end + mkdir = function(path) return C.CreateDirectoryW(towidestring(path), nil) ~= 0 end + rmdir = function(path) return C._wrmdir(towidestring(path)) == 0 end +else + BUFFERMODE = { full = 0, line = 1, none = 2 } + + ffi.cdef([[ + char* getcwd(char *buffer, int maxlen); + int chdir(const char* path); + int unlink(const char* path); + int mkdir(const char* path, int mode); + int rmdir(const char* path); + ]]) + + local nameBuffer = ByteArray(MAX_PATH) + + fopen = C.fopen + unlink = function(path) return ffi.C.unlink(path) == 0 end + chdir = function(path) return ffi.C.chdir(path) == 0 end + mkdir = function(path) return ffi.C.mkdir(path, 0x1ed) == 0 end + rmdir = function(path) return ffi.C.rmdir(path) == 0 end + + getcwd = function() + local cwd = _ptr(C.getcwd(nameBuffer, MAX_PATH)) + return cwd and ffi.string(cwd) or nil + end +end + +return nativefs \ No newline at end of file