diff --git a/CHANGELOG b/CHANGELOG index c0b0bf0..801cdd9 100644 Binary files a/CHANGELOG and b/CHANGELOG differ diff --git a/config-definitions.json b/config-definitions.json index d2b5376..91af2dd 100644 --- a/config-definitions.json +++ b/config-definitions.json @@ -97,5 +97,14 @@ "label": "Show debug information (sometimes in the middle of the screen when you're playing)", "type": "bool", "default": false + }, + + "separator_g": {}, + "Experimental features": { "type": "label" }, + + "songselect_showEffectRadar": { + "label": "Show Effect Radar for compatible songs (VERY WIP)", + "type": "bool", + "default": false } } diff --git a/docs/lua_api/IR.lua b/docs/lua_api/IR.lua index 2deea4a..1a9c0cf 100644 --- a/docs/lua_api/IR.lua +++ b/docs/lua_api/IR.lua @@ -23,47 +23,53 @@ IRData = {} ---@field serverTime integer ---@field serverName string ---@field irVersion string -IRHeartbeatResponseBody = {} ---@class IRRecordResponseBody ---@field record ServerScore -IRRecordResponseBody = {} ----@class IRLeaderboardResponseBody ----@field scores ServerScore[] -IRLeaderboardResponseBody = {} +---@alias IRLeaderboardResponseBody ServerScore[] ---@class IRResponse ---@field statusCode integer ---@field description string ----@field body nil|IRHeartbeatResponseBody|IRRecordResponseBody|IRLeaderboardResponseBody -IRResponse = {} + +---@class IRHeartbeatResponse : IRResponse +---@field body IRHeartbeatResponseBody + +---@class IRChartTrackedResponse : IRResponse +---@field body {} + +---@class IRRecordResponse : IRResponse +---@field body IRRecordResponseBody + +---@class IRLeaderboardResponse : IRResponse +---@field body ServerScore[] -- Performs a Heartbeat request. ----@param callback fun(res: IRResponse) # Callback function receives IRResponse as it's first parameter +---@param callback fun(res: IRHeartbeatResponse) # Callback function receives IRResponse as it's first parameter local function Heartbeat(callback) end -- Performs a Chart Tracked request for the chart with the provided hash. ---@param hash string # song hash ----@param callback fun(res: IRResponse) # Callback function receives IRResponse as it's first parameter +---@param callback fun(res: IRChartTrackedResponse) # Callback function receives IRResponse as it's first parameter local function ChartTracked(hash, callback) end -- Performs a Record request for the chart with the provided hash. ---@param hash string # song hash ----@param callback fun(res: IRResponse) # Callback function receives IRResponse as it's first parameter +---@param callback fun(res: IRRecordResponse) # Callback function receives IRResponse as it's first parameter local function Record(hash, callback) end -- Performs a Leaderboard request for the chart with the provided hash, with parameters mode and n. ---@param hash string # song hash ---@param mode "best"|"rivals" # request leaderboard mode ---@param n integer # limit the number of requested scores ----@param callback fun(res: IRResponse) # Callback function receives IRResponse as it's first parameter +---@param callback fun(res: IRLeaderboardResponse) # Callback function receives IRResponse as it's first parameter local function Leaderboard(hash, mode, n, callback) end ----@type table +---@class IR IR = { Heartbeat = Heartbeat, ChartTracked = ChartTracked, Record = Record, Leaderboard = Leaderboard -} \ No newline at end of file +} diff --git a/docs/lua_api/gfx.lua b/docs/lua_api/gfx.lua index 2541f20..02a8071 100644 --- a/docs/lua_api/gfx.lua +++ b/docs/lua_api/gfx.lua @@ -267,7 +267,7 @@ LoadSharedSkinTexture = function(name, path) end -- Loads a font fromt the specified filename -- Sets it as the current font if it is already loaded ----@param name? string +---@param name string ---@param filename string LoadFont = function(name, filename) end @@ -280,11 +280,10 @@ LoadFont = function(name, filename) end ---@return any # returns `placeholder` until the image is loaded LoadImageJob = function(filepath, placeholder, w, h) end --- Loads a font from `skins//textures/` +-- Loads a font from `skins//fonts/` -- Sets it as the current font if it is already loaded ----@param name? string ----@param filename string -LoadSkinFont = function(name, filename) end +---@param name string +LoadSkinFont = function(name) end -- Loads an image outside of the main thread to prevent rendering lock-up -- Image will be loaded at original size unless `w` and `h` are provided @@ -472,7 +471,7 @@ UpdateImagePattern = function(pattern, sx, sy, ix, iy, angle, alpha) end ---@param size? integer UpdateLabel = function(label, text, size) end ----@type table +---@class gfx gfx = { BLEND_ZERO = 1, BLEND_ONE = 2, @@ -595,4 +594,4 @@ gfx = { Translate = Translate, UpdateImagePattern = UpdateImagePattern, UpdateLabel = UpdateLabel, -}; \ No newline at end of file +}; diff --git a/docs/lua_api/shadedmesh.lua b/docs/lua_api/shadedmesh.lua index a6a4855..3edd500 100644 --- a/docs/lua_api/shadedmesh.lua +++ b/docs/lua_api/shadedmesh.lua @@ -1,117 +1,4 @@ --- Adds a texture that was loaded with `gfx.LoadSharedTexture` to the material that can be used in the shader code ----@param uniformName string ----@param textureName string -AddSharedTexture = function(uniformName, textureName) end - --- Adds a texture to the material that can be used in the shader code ----@param uniformName string ----@param path string # prepended with `skins//textures/` -AddSkinTexture = function(uniformName, path) end - --- Adds a texture to the material that can be used in the shader code ----@param uniformName string ----@param path string -AddTexture = function(uniformName, path) end - --- Gets the translation of the mesh ----@return number x, number y, number z -GetPosition = function() end - --- Gets the rotation (in degrees) of the mesh ----@return number roll, number yaw, number pitch -GetRotation = function() end - --- Gets the scale of the mesh ----@return number x, number y, number z -GetScale = function() end - --- Sets the blending mode ----@param mode integer # options also available as fields of the object prefixed with `BLEND` --- `Normal` = 0 (default) --- `Additive` = 1 --- `Multiply` = 2 -SetBlendMode = function(mode) end - --- Sets the geometry data ----@param data table # array of vertices in clockwise order starting from the top left e.g. --- ``` --- { --- { { 0, 0 }, { 0, 0 } }, --- { { 50, 0 }, { 1, 0 } }, --- { { 50, 50 }, { 1, 1 } }, --- { { 0, 50 }, { 0, 1 } }, --- } --- ``` -SetData = function(data) end - --- Sets the material is opaque or non-opaque (default) ----@param opaque boolean -SetOpaque = function(opaque) end - --- Sets the value of the specified uniform ----@param uniformName string ----@param value number # `float` -SetParam = function(uniformName, value) end - --- Sets the value of the specified 2d vector uniform ----@param uniformName string ----@param x number # `float` ----@param y number # `float` -SetParamVec2 = function(uniformName, x, y) end - --- Sets the value of the specified 3d vector uniform ----@param uniformName string ----@param x number # `float` ----@param y number # `float` ----@param z number # `float` -SetParamVec3 = function(uniformName, x, y, z) end - --- Sets the value of the specified 4d vector uniform ----@param uniformName string ----@param x number # `float` ----@param y number # `float` ----@param z number # `float` ----@param w number # `float` -SetParamVec4 = function(uniformName, x, y, z, w) end - --- Sets the translation for the mesh --- Relative to the screen for `ShadedMesh` --- Relative to the center of the crit line for `ShadedMeshOnTrack` ----@param x number ----@param y number ----@param z? number # Default `0` -SetPosition = function(x, y, z) end - --- Sets the format for geometry data provided by `SetData` ----@param type integer # options also available as fields of the object prefixed with `PRIM` --- `TriangleList` = 0 (default) --- `TriangleStrip` = 1 --- `TriangleFan` = 2 --- `LineList` = 3 --- `LineStrip` = 4 --- `PointList` = 5 -SetPrimitiveType = function(type) end - --- Sets the rotation (in degrees) of the mesh --- **WARNING:** For `ShadedMesh`, pitch and yaw may clip, rendering portions or the entire mesh invisible ----@param roll number ----@param yaw? number # Default `0` ----@param pitch? number # Default `0` -SetRotation = function(roll, yaw, pitch) end - --- Sets the scale of the mesh ----@param x number ----@param y number ----@param z? number # Default `0` -SetScale = function(x, y, z) end - --- Sets the wireframe mode of the object (does not render texture) --- Useful for debugging models or geometry shaders ----@param useWireframe boolean -SetWireframe = function(useWireframe) end - --- Renders the `ShadedMesh` object -Draw = function() end +---@diagnostic disable:missing-return ---@class ShadedMesh ShadedMesh = { @@ -125,57 +12,148 @@ ShadedMesh = { PRIM_LINELIST = 3, PRIM_LINESTRIP = 4, PRIM_POINTLIST = 5, - - AddSharedTexture = AddSharedTexture, - AddSkinTexture = AddSkinTexture, - AddTexture = AddTexture, - Draw = Draw, - GetPosition = GetPosition, - GetRotation = GetRotation, - GetScale = GetScale, - SetBlendMode = SetBlendMode, - SetData = SetData, - SetOpaque = SetOpaque, - SetParam = SetParam, - SetParamVec2 = SetParamVec2, - SetParamVec3 = SetParamVec3, - SetParamVec4 = SetParamVec4, - SetPosition = SetPosition, - SetPrimitiveType = SetPrimitiveType, - SetRotation = SetRotation, - SetScale = SetScale, - SetWireframe = SetWireframe, }; --- Gets the length of the mesh ----@return number length -GetLength = function() end +-- Adds a texture that was loaded with `gfx.LoadSharedTexture` to the material that can be used in the shader code +---@param uniformName string +---@param textureName string +function ShadedMesh:AddSharedTexture(uniformName, textureName) end --- Sets the y-scale of the mesh based on its length --- Useful for creating fake buttons which may have variable length based on duration ----@param length number -ScaleToLength = function(length) end +-- Adds a texture to the material that can be used in the shader code +---@param uniformName string +---@param path string # prepended with `skins//textures/` +function ShadedMesh:AddSkinTexture(uniformName, path) end --- Stops meshes beyond the track from being rendered if `doClip` ----@param doClip boolean -SetClipWithTrack = function(doClip) end +-- Adds a texture to the material that can be used in the shader code +---@param uniformName string +---@param path string +function ShadedMesh:AddTexture(uniformName, path) end --- Sets the length (in the y-direction relative to the track) of the mesh ----@param length number # Optional constants: `BUTTON_TEXTURE_LENGTH`, `FXBUTTON_TEXTURE_LENGTH`, and `TRACK_LENGTH` -SetLength = function(length) end +-- Gets the translation of the mesh +---@return number x, number y, number z +function ShadedMesh:GetPosition() end + +-- Gets the rotation (in degrees) of the mesh +---@return number roll, number yaw, number pitch +function ShadedMesh:GetRotation() end + +-- Gets the scale of the mesh +---@return number x, number y, number z +function ShadedMesh:GetScale() end + +-- Sets the blending mode +---@param mode integer # options also available as fields of the object prefixed with `BLEND` +-- `Normal` = 0 (default) +-- `Additive` = 1 +-- `Multiply` = 2 +function ShadedMesh:SetBlendMode(mode) end + +-- Sets the geometry data +---@param data table # array of vertices in clockwise order starting from the top left e.g. +-- ``` +-- { +-- { { 0, 0 }, { 0, 0 } }, +-- { { 50, 0 }, { 1, 0 } }, +-- { { 50, 50 }, { 1, 1 } }, +-- { { 0, 50 }, { 0, 1 } }, +-- } +-- ``` +function ShadedMesh:SetData(data) end + +-- Sets the material is opaque or non-opaque (default) +---@param opaque boolean +function ShadedMesh:SetOpaque(opaque) end + +-- Sets the value of the specified uniform +---@param uniformName string +---@param value number # `float` +function ShadedMesh:SetParam(uniformName, value) end + +-- Sets the value of the specified 2d vector uniform +---@param uniformName string +---@param x number # `float` +---@param y number # `float` +function ShadedMesh:SetParamVec2(uniformName, x, y) end + +-- Sets the value of the specified 3d vector uniform +---@param uniformName string +---@param x number # `float` +---@param y number # `float` +---@param z number # `float` +function ShadedMesh:SetParamVec3(uniformName, x, y, z) end + +-- Sets the value of the specified 4d vector uniform +---@param uniformName string +---@param x number # `float` +---@param y number # `float` +---@param z number # `float` +---@param w number # `float` +function ShadedMesh:SetParamVec4(uniformName, x, y, z, w) end + +-- Sets the translation for the mesh +-- Relative to the screen for `ShadedMesh` +-- Relative to the center of the crit line for `ShadedMeshOnTrack` +---@param x number +---@param y number +---@param z? number # Default `0` +function ShadedMesh:SetPosition(x, y, z) end + +-- Sets the format for geometry data provided by `SetData` +---@param type integer # options also available as fields of the object prefixed with `PRIM` +-- `TriangleList` = 0 (default) +-- `TriangleStrip` = 1 +-- `TriangleFan` = 2 +-- `LineList` = 3 +-- `LineStrip` = 4 +-- `PointList` = 5 +function ShadedMesh:SetPrimitiveType(type) end + +-- Sets the rotation (in degrees) of the mesh +-- **WARNING:** For `ShadedMesh`, pitch and yaw may clip, rendering portions or the entire mesh invisible +---@param roll number +---@param yaw? number # Default `0` +---@param pitch? number # Default `0` +function ShadedMesh:SetRotation(roll, yaw, pitch) end + +-- Sets the scale of the mesh +---@param x number +---@param y number +---@param z? number # Default `0` +function ShadedMesh:SetScale(x, y, z) end + +-- Sets the wireframe mode of the object (does not render texture) +-- Useful for debugging models or geometry shaders +---@param useWireframe boolean +function ShadedMesh:SetWireframe(useWireframe) end + +-- Renders the `ShadedMesh` object +function ShadedMesh:Draw() end --- Uses an existing game mesh ----@param meshName string # Options: `'button'`, `'fxbutton'`, and `'track'` -UseGameMesh = function(meshName) end ---@class ShadedMeshOnTrack : ShadedMesh ---@field BUTTON_TEXTURE_LENGTH number ---@field FXBUTTON_TEXTURE_LENGTH number ---@field TRACK_LENGTH number ShadedMeshOnTrack = { - GetLength = GetLength, - UseGameMesh = UseGameMesh, - ScaleToLength = ScaleToLength, - SetClipWithTrack = SetClipWithTrack, - SetLength = SetLength, -}; \ No newline at end of file +}; + +-- Gets the length of the mesh +---@return number length +function ShadedMeshOnTrack:GetLength() end + +-- Sets the y-scale of the mesh based on its length +-- Useful for creating fake buttons which may have variable length based on duration +---@param length number +function ShadedMeshOnTrack:ScaleToLength(length) end + +-- Stops meshes beyond the track from being rendered if `doClip` +---@param doClip boolean +function ShadedMeshOnTrack:SetClipWithTrack(doClip) end + +-- Sets the length (in the y-direction relative to the track) of the mesh +---@param length number # Optional constants: `BUTTON_TEXTURE_LENGTH`, `FXBUTTON_TEXTURE_LENGTH`, and `TRACK_LENGTH` +function ShadedMeshOnTrack:SetLength(length) end + +-- Uses an existing game mesh +---@param meshName string # Options: `'button'`, `'fxbutton'`, and `'track'` +function ShadedMeshOnTrack:UseGameMesh(meshName) end diff --git a/docs/lua_api/songwheel.lua b/docs/lua_api/songwheel.lua index c86b9be..372956c 100644 --- a/docs/lua_api/songwheel.lua +++ b/docs/lua_api/songwheel.lua @@ -1,3 +1,4 @@ +---@diagnostic disable: lowercase-global -- songwheel `songwheel` table ---@class SongWheelScore @@ -35,7 +36,7 @@ SongWheelDifficulty = {} ---@class SongWheelSong ---@field artist string # Chart artist ---@field difficulties SongWheelDifficulty[] # Array of difficulties for the current song ----@field bpm number # Chart BPM +---@field bpm string # Chart BPM ---@field id integer # Song id, unique static identifier ---@field path string # Full filepath to the chart folder on the disk ---@field title string # Chart title diff --git a/fonts/contb.ttf b/fonts/contb.ttf new file mode 100644 index 0000000..45ab0e2 Binary files /dev/null and b/fonts/contb.ttf differ diff --git a/fonts/contl.ttf b/fonts/contl.ttf new file mode 100644 index 0000000..e94f542 Binary files /dev/null and b/fonts/contl.ttf differ diff --git a/fonts/contm.ttf b/fonts/contm.ttf new file mode 100644 index 0000000..08c8df8 Binary files /dev/null and b/fonts/contm.ttf differ diff --git a/scripts/api/color.lua b/scripts/api/color.lua new file mode 100644 index 0000000..5424e70 --- /dev/null +++ b/scripts/api/color.lua @@ -0,0 +1,74 @@ +local util = require("common.util") + +---@class CColorRGBA +ColorRGBA = { + ---Create a new Color instance + ---@param r integer # red or monochrome value + ---@param g? integer # green value + ---@param b? integer # blue value + ---@param a? integer # alpha value, default 255 + ---@return ColorRGBA + new = function (r, g , b, a) + ---@class ColorRGBA : CColorRGBA + ---@field r integer + ---@field g integer + ---@field b integer + ---@field a integer + local o = { + r = r or 0, + g = g or r, + b = b or r, + a = a or 255, + } + + setmetatable(o, ColorRGBA) + return o + end, + + ---Mix two colors + ---@param color1 ColorRGBA + ---@param color2 ColorRGBA + ---@param factor number + ---@return ColorRGBA + mix = function (color1, color2, factor) + local r = math.floor(util.mix(color1.r, color2.r, factor)) + local g = math.floor(util.mix(color1.g, color2.g, factor)) + local b = math.floor(util.mix(color1.b, color2.b, factor)) + local a = math.floor(util.mix(color1.a, color2.a, factor)) + return ColorRGBA.new(r, g, b, a) + end +} + +ColorRGBA.__index = ColorRGBA +ColorRGBA.BLACK = ColorRGBA.new(0) +ColorRGBA.GREY = ColorRGBA.new(128) +ColorRGBA.WHITE = ColorRGBA.new(255) +ColorRGBA.RED = ColorRGBA.new(255, 0, 0) +ColorRGBA.GREEN = ColorRGBA.new(0, 255, 0) +ColorRGBA.BLUE = ColorRGBA.new(0, 0, 255) +ColorRGBA.YELLOW = ColorRGBA.new(255, 255, 0) +ColorRGBA.CYAN = ColorRGBA.new(0, 255, 255) +ColorRGBA.MAGENTA = ColorRGBA.new(255, 0, 255) + +---Split to components +---@return integer # red +---@return integer # green +---@return integer # blue +---@return integer # alpha +function ColorRGBA:components() + ---@cast self ColorRGBA + + return self.r, self.g, self.b, self.a +end + +---Split to components scaled to [0.0, 1.0] +---@return number # red +---@return number # green +---@return number # blue +---@return number # alpha +function ColorRGBA:componentsFloat() + ---@cast self ColorRGBA + local scale = 255 + + return self.r / scale, self.g / scale, self.b / scale, self.a / scale +end diff --git a/scripts/api/point2d.lua b/scripts/api/point2d.lua new file mode 100644 index 0000000..84cfdf1 --- /dev/null +++ b/scripts/api/point2d.lua @@ -0,0 +1,28 @@ +---@class CPoint2D +Point2D = { + ---Create a Point2D instance + ---@param x? number # default 0.0 + ---@param y? number # default 0.0 + ---@return Point2D + new = function(x, y) + ---@class Point2D : CPoint2D + ---@field x number + ---@field y number + local o = { + x = x + .0 or .0, + y = y + .0 or .0, + } + + setmetatable(o, Point2D) + return o + end +} + +Point2D.__index = Point2D +Point2D.ZERO = Point2D.new(0, 0) + +function Point2D:coords() + ---@cast self Point2D + + return self.x, self.y +end diff --git a/scripts/common/util.lua b/scripts/common/util.lua index e7ab595..fe162be 100644 --- a/scripts/common/util.lua +++ b/scripts/common/util.lua @@ -59,6 +59,10 @@ local function lerp(x, x0, y0, x1, y1) return y0 + (x - x0) * (y1 - y0) / (x1 - x0) end +local function mix(x, y, a) + return (1 - a) * x + a * y +end + --modulo operation for index value local function modIndex(index, mod) return (index - 1) % mod + 1 @@ -75,6 +79,41 @@ local function firstAlphaNum(s) return ''; end +local function dump(o) + if type(o) == 'table' then + local s = '{ ' + for k,v in pairs(o) do + if type(k) ~= 'number' then k = '"'..k..'"' end + s = s .. '['..k..'] = ' .. dump(v) .. ',' + end + return s .. '} ' + else + return tostring(o) + end +end + +local function all(t, predicate) + predicate = predicate or function(e) return e end + + for _, e in ipairs(t) do + if not predicate(e) then + return false + end + end + return true +end + +local function any(t, predicate) + predicate = predicate or function(e) return e end + + for _, e in ipairs(t) do + if predicate(e) then + return true + end + end + return false +end + return { split = split, filter = filter, @@ -84,6 +123,10 @@ return { roundToZero = roundToZero, areaOverlap = areaOverlap, lerp = lerp, + mix = mix, modIndex = modIndex, firstAlphaNum = firstAlphaNum, + dump = dump, + all = all, + any = any } diff --git a/scripts/components/radar.lua b/scripts/components/radar.lua new file mode 100644 index 0000000..aa68e6c --- /dev/null +++ b/scripts/components/radar.lua @@ -0,0 +1,619 @@ +--[[ +S2 song attribute radar component +Original code thanks to RealFD, he's a real homie +]] + +require("common.globals") +require("api.point2d") +require("api.color") + +local Dim = require("common.dimensions") +local Util = require("common.util") + +Dim.updateResolution() + +local RADAR_PURPLE = ColorRGBA.new(238, 130, 238) +local RADAR_MAGENTA = ColorRGBA.new(191, 70, 235) +local RADAR_GREEN = ColorRGBA.new(0, 255, 100) + +local maxScaleFactor = 1.8 + +---@param p1 Point2D +---@param p2 Point2D +---@param width number +---@param color ColorRGBA +local function drawLine(p1, p2, width, color) + gfx.BeginPath() + gfx.MoveTo(p1:coords()) + gfx.LineTo(p2:coords()) + gfx.StrokeColor(color:components()) + gfx.StrokeWidth(width) + gfx.Stroke() +end + +---@param pos Point2D +---@param text string +---@param outlineWidth number +---@param color ColorRGBA +local function renderOutlinedText(pos, text, outlineWidth, color) + local x, y = pos:coords() + + local dimColor = color:mix(ColorRGBA.BLACK, 0.8) + gfx.FillColor(dimColor:components()); + gfx.Text(text, x - outlineWidth, y + outlineWidth); + gfx.Text(text, x - outlineWidth, y - outlineWidth); + gfx.Text(text, x + outlineWidth, y + outlineWidth); + gfx.Text(text, x + outlineWidth, y - outlineWidth); + + gfx.FillColor(color:components()); + gfx.Text(text, x, y); +end + +---@param pos Point2D +---@param graphdata table +local function drawDebugText(pos, graphdata) + local color = ColorRGBA.WHITE + gfx.Save() + gfx.TextAlign(gfx.TEXT_ALIGN_LEFT + gfx.TEXT_ALIGN_CENTER) + --renderOutlinedText(x, 20, '"' .. txtFilePath .. '"', 1, 255, 255, 255) + renderOutlinedText(pos, "NOTES = " .. graphdata.notes, 1, color) + renderOutlinedText(pos, "PEAK = " .. graphdata.peak, 1, color) + renderOutlinedText(pos, "TSUMAMI = " .. graphdata.tsumami, 1, color) + renderOutlinedText(pos, "TRICKY = " .. graphdata.tricky, 1, color) + renderOutlinedText(pos, "ONE-HAND = " .. graphdata.onehand, 1, color) + renderOutlinedText(pos, "HAND-TRIP = " .. graphdata.handtrip, 1, color) + --renderOutlinedText(pos, "NOTES (Relative) = " .. graphdata.notes_relative, 1, color) + --renderOutlinedText(pos, "TOTAL-MESURES = " .. graphdata.measures, 1, color) + gfx.Restore() +end + +---@class CRadarAttributes +RadarAttributes = { + ---Create RadarAttributes instance + ---@param text? string # default "" + ---@param offset? Point2D # default (0, 0) + ---@param color? ColorRGBA # default BLACK + ---@param align? integer # gfx.TEXT_ALIGN_<...> values, default gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_BASELINE + ---@return RadarAttributes + new = function (text, offset, color, align) + ---@class RadarAttributes + ---@field text string + ---@field offset Point2D + ---@field color ColorRGBA + local o = { + text = text or "", + offset = offset or Point2D.ZERO, + color = color or ColorRGBA.BLACK, + align = align or gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_BASELINE + } + + setmetatable(o, RadarAttributes) + return o + end +} +RadarAttributes.__index = RadarAttributes + + +---@class CRadar +Radar = { + ---@type RadarAttributes[][] + ATTRIBUTES = { + {RadarAttributes.new("notes", Point2D.new(0, 0), ColorRGBA.CYAN, gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_BOTTOM),}, + {RadarAttributes.new("peak", Point2D.new(0, 0), ColorRGBA.RED, gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_BOTTOM), }, + {RadarAttributes.new("tsumami", Point2D.new(0, 0), RADAR_PURPLE, gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_TOP),}, + {RadarAttributes.new("tricky", Point2D.new(0, 0), ColorRGBA.YELLOW, gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_TOP),}, + { + RadarAttributes.new("hand", Point2D.new(0, 0), RADAR_MAGENTA, gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_TOP), + RadarAttributes.new("trip", Point2D.new(5, 16), RADAR_MAGENTA, gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_TOP), + }, + { + RadarAttributes.new("one", Point2D.new(6, -16), RADAR_GREEN, gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_BOTTOM), + RadarAttributes.new("hand", Point2D.new(0, 0), RADAR_GREEN, gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_BOTTOM), + } + }, + RADIUS = 100.0, + + ---Create Radar instance + ---@param pos Point2D + ---@param radius? number + ---@return Radar + new = function (pos, radius) + ---@class Radar : CRadar + local o = { + _graphdata = { + notes = 0, + peak = 0, + tsumami = 0, + tricky = 0, + handtrip = 0, + onehand = 0, + }, + _hexagonMesh = gfx.CreateShadedMesh("radar"), + _outlineVertices = {}, + _attributePositions = {}, ---@type Point2D[][] + _angleStep = (2 * math.pi) / #Radar.ATTRIBUTES, -- 360° / no. attributes, in radians + _initRotation = math.pi / 2, -- 90°, in radians + pos = pos or Point2D.ZERO, + scale = radius and radius / Radar.RADIUS or 1.0, + } + + local sides = #Radar.ATTRIBUTES + + local outlineRadius = Radar.RADIUS + local attributeRadius = Radar.RADIUS + 30 + + for i = 0, sides - 1 do + local attrIdx = i + 1 + local angle = i * o._angleStep - o._initRotation + local cosAngle = math.cos(angle) + local sinAngle = math.sin(angle) + -- cache outline vertices + table.insert(o._outlineVertices, Point2D.new(outlineRadius * cosAngle, outlineRadius * sinAngle)) + -- cache attribute positions + table.insert(o._attributePositions, {}) + for j = 1, #Radar.ATTRIBUTES[attrIdx] do + local attr = Radar.ATTRIBUTES[attrIdx][j] + local attributePos = Point2D.new(attributeRadius * cosAngle, attributeRadius * sinAngle) + attributePos.x = attributePos.x + attr.offset.x + attributePos.y = attributePos.y + attr.offset.y + table.insert(o._attributePositions[attrIdx], j, attributePos) + end + end + + setmetatable(o, Radar) + return o + end, +} +Radar.__index = Radar + +---@param w number +---@param color ColorRGBA +function Radar:drawOutline(w, color) + ---@cast self Radar + + for i = 1, #self._outlineVertices do + local j = i % #self._outlineVertices + 1 + drawLine(self._outlineVertices[i], self._outlineVertices[j], w, color) + end +end + +---@param color ColorRGBA +---@param ticks? integer +function Radar:drawRadialTicks(color, ticks) + ---@cast self Radar + + ticks = ticks or 3 + + gfx.Save() + gfx.StrokeColor(color:components()) + + for i, vertex in ipairs(self._outlineVertices) do + gfx.BeginPath() + gfx.MoveTo(0, 0) + gfx.LineTo(vertex.x, vertex.y) + gfx.Stroke() + + local lineLength = math.sqrt(vertex.x * vertex.x + vertex.y * vertex.y) + local tinyLineLength = 10 + + local tinyLineAngle = math.atan(vertex.y / vertex.x) + if vertex.x < 0 then + tinyLineAngle = tinyLineAngle + math.pi + end + + local halfTinyLineLength = tinyLineLength / 2 + + for j = 1, ticks do + local distanceFromCenter = j * lineLength / (ticks + 1) -- Adjusted for 3 middle lines + + local offsetX = distanceFromCenter * (vertex.x / lineLength) + local offsetY = distanceFromCenter * (vertex.y / lineLength) + + local endX = halfTinyLineLength * math.cos(tinyLineAngle - math.pi / 2) -- Rotate by -90 degrees + local endY = halfTinyLineLength * math.sin(tinyLineAngle - math.pi / 2) -- Rotate by -90 degrees + + local offsetX2 = halfTinyLineLength * math.cos(tinyLineAngle + math.pi / 2) + local offsetY2 = halfTinyLineLength * math.sin(tinyLineAngle + math.pi / 2) + + gfx.BeginPath() + gfx.MoveTo(offsetX - offsetX2, offsetY - offsetY2) + gfx.LineTo(endX + offsetX + offsetX2 + offsetX2, endY + offsetY + offsetY2 + offsetY2) + gfx.Stroke() + end + end + + gfx.Restore() +end + +---@param fillColor ColorRGBA +function Radar:drawBackground(fillColor) + ---@cast self Radar + + gfx.Save() + + gfx.BeginPath() + gfx.MoveTo(self._outlineVertices[1].x, self._outlineVertices[1].y) + for i = 2, #self._outlineVertices do + gfx.LineTo(self._outlineVertices[i].x, self._outlineVertices[i].y) + end + gfx.ClosePath() + + gfx.FillColor(fillColor:components()) + gfx.Fill() + + gfx.Restore() +end + +function Radar:drawAttributes() + ---@cast self Radar + + gfx.Save() + + gfx.LoadSkinFont("contb.ttf") + gfx.FontSize(21) + for i = 1, #self._attributePositions do + local attrPos = self._attributePositions[i] + for j = 1, #attrPos do + local pos = attrPos[j] + local attr = Radar.ATTRIBUTES[i][j] + gfx.TextAlign(attr.align) + renderOutlinedText(pos, string.upper(attr.text), 1, attr.color) + end + end + + gfx.Restore() +end + +---Draw shaded radar mesh +--- +---Bug: ForceRender resets every transformation, you need to re-setup view transform afterwards. +---ForceRender also resets the gfx stack, USC will crash if you try to call gfx.Restore(), +---make sure the gfx stack is clean before calling radar:drawRadarMesh() +function Radar:drawRadarMesh() + ---@cast self Radar + + local scaleFact = { + self._graphdata.notes, + self._graphdata.peak, + self._graphdata.tsumami, + self._graphdata.tricky, + self._graphdata.handtrip, + self._graphdata.onehand, + } + + local colorMax = ColorRGBA.new(255, 12, 48, 230) -- magenta-ish + local colorCenter = ColorRGBA.new(112, 119, 255, 230) -- light blue-ish purple + + -- Calculate the maximum size based on the constraint + local maxSize = self.RADIUS * self.scale + local maxLineLength = maxSize * maxScaleFactor + self._hexagonMesh:SetParam("maxSize", maxLineLength + .0) + + -- Set the color of the hexagon + self._hexagonMesh:SetParamVec4("colorMax", colorMax:componentsFloat()) + self._hexagonMesh:SetParamVec4("colorCenter", colorCenter:componentsFloat()) + + -- Set the primitive type to triangles + self._hexagonMesh:SetPrimitiveType(self._hexagonMesh.PRIM_TRIFAN) + + -- Calculate the vertices of the hexagon + local sides = #Radar.ATTRIBUTES + + local vertices = {} + table.insert(vertices, {{0, 0}, {0, 0}}) + for i = 0, sides do + local j = i % sides + 1 + local angle = i * self._angleStep - self._initRotation + + --local angle = math.rad(60 * (i-1)) + rotationAngle + local scale = scaleFact[j] + local lineLength = maxSize * scale + local px = lineLength * math.cos(angle) + local py = lineLength * math.sin(angle) + table.insert(vertices, {{px, py}, {0, 0}}) + end + + -- Set the hexagon's vertices + self._hexagonMesh:SetData(vertices) + self._hexagonMesh:Draw() + + -- YOU! You are the reason for all my pain! + gfx.ForceRender() +end + +--NOTE: THIS IS BUGGY, ForceRender fucks up so many things, call the individual draw functions at top level +function Radar:drawGraph() + ---@cast self Radar + + game.Log("Radar:drawGraph() SHOULD NOT BE CALLED", game.LOGGER_WARNING) + + gfx.Save() + gfx.Reset() + gfx.ResetScissor() + + Dim.updateResolution() + Dim.transformToScreenSpace() + + gfx.FontSize(28) + gfx.Translate(self.pos.x, self.pos.y) + gfx.Scale(self.scale, self.scale) + + local strokeColor = ColorRGBA.new(255, 255, 255, 100) + local fillColor = ColorRGBA.new(0, 0, 0, 191) + + self:drawBackground(fillColor) + self:drawOutline(3, strokeColor) + self:drawRadarMesh() + self:drawRadialTicks(strokeColor) + self:drawAttributes() + + local pos = Point2D.new(self.pos:coords()) + pos.y = pos.y - self.RADIUS + --drawDebugText(pos, self._graphdata) + + gfx.Restore() + + --NOTE: Bug workaround: forcerender resets every transformation, re-setup view transform + Dim.transformToScreenSpace() +end + +---Compute radar attribute values from ksh +---@param info string # chart directory path +---@param dif string # chart name without extension +function Radar:updateGraph(info, dif) + ---@cast self Radar + + --local pattern = "(.*[\\/])" + --local extractedSubstring = info:match(pattern) + --local txtFilePath = extractedSubstring .. "radar\\" .. dif .. ".txt" + + --local song = io.open(txtFilePath, "r") + local fullPath = info.."/"..dif..".ksh" + local song = io.open(fullPath) + game.Log('Reading chart data from "'..fullPath..'"', game.LOGGER_DEBUG) + game.Log(song and "file open" or "file not found", game.LOGGER_DEBUG) + if song then + local chartData = song:read("*all") + song:close() + + local notesCount, knobCount, oneHandCount, handTripCount = 0, 0, 0, 0 + local chartLineCount = 0 + local notesValue = 0 + local peakValue = 0 + local tsumamiValue = 0 + local trickyValue = 0 + local totalMeasures = 0 + + local lastNotes = {} + local lastFx = {} + local measureLength = 0 + + ---@cast chartData string + for line in chartData:gmatch("[^\r\n]+") do + -- || + + --game.Log(line, game.LOGGER_DEBUG) + + local patternBt = "([012][012][012][012])" + local patternFx = "([012ABDFGHIJKLPQSTUVWX][012ABDFGHIJKLPQSTUVWX])" + local patternLaser = "([%-:%dA-Za-o][%-:%dA-Za-o])" + local patternLaneSpin = "([@S][%(%)<>]%d+)" -- optional + local pattern = patternBt.."|"..patternFx.."|"..patternLaser + + -- match line format + + local noteType, fxType, laserType = line:match(pattern) + local laneSpin = line:match(patternLaneSpin) + + if noteType and fxType and laserType then + chartLineCount = chartLineCount + 1 + + -- convert strings to array, to be easily indexable + + noteType = {noteType:match("([012])([012])([012])([012])")} + fxType = {fxType:match("([012ABDFGHIJKLPQSTUVWX])([012ABDFGHIJKLPQSTUVWX])")} + laserType = {laserType:match("([%-:%dA-Za-o])([%-:%dA-Za-o])")} + + ---@cast noteType string[] + ---@cast fxType string[] + ---@cast laserType string[] + + -- parse notes + + local function isNewNote(idx, note) + if note == "2" and lastNotes[idx] ~= note then + -- a new hold note + return true + end + if note == "1" then + -- a chip + return true + end + end + for noteIdx, note in ipairs(noteType) do + if isNewNote(noteIdx, note) then + notesCount = notesCount + 1 + end + end + + -- parse fx + + local function isNewFx(idx, fx) + if fx:match("[1ABDFGHIJKLPQSTUVWX]") and lastFx[idx] ~= fx then + -- a new hold note + return true + end + if fx == "2" then + -- a chip + return true + end + end + for fxIdx, fx in ipairs(fxType) do + if isNewFx(fxIdx, fx) then + notesCount = notesCount + 1 + end + end + + -- parse laser + + for _, laser in ipairs(laserType) do + if laser ~= "-" then + knobCount = knobCount + 1 + end + end + + -- figure out one-handed notes (there's a BT or FX while a hand is manipulating a knob) + -- also try to figure out cross-handed notes (one-handed notes, but on the same side as knob) + + local function countBtFx() + local count = 0 + for noteIdx, note in ipairs(noteType) do + if isNewNote(noteIdx, note) then + count = count + 1 + end + end + for fxIdx, fx in ipairs(fxType) do + if isNewFx(fxIdx, fx) then + count = count + 1 + end + end + return count + end + ---@param side "left"|"right" + local function countSide(side) + local count = 0 + local notes = {} + local fx = "" + if side == "left" then + notes = {noteType[1], noteType[2]} + fx = fxType[1] + if isNewFx(1, fx) then + count = count + 1 + end + elseif side == "right" then + notes = {noteType[3], noteType[4]} + fx = fxType[2] + if isNewFx(2, fx) then + count = count + 1 + end + else + game.Log("countSide: Invalid side parameter", game.LOGGER_ERROR) + return 0 + end + for noteIdx, note in ipairs(notes) do + if isNewNote(noteIdx, note) then + count = count + 1 + end + end + return count + end + if laserType[1] ~= "-" and laserType[2] == "-" then + oneHandCount = oneHandCount + countBtFx() + handTripCount = handTripCount + countSide("left") + end + if laserType[1] == "-" and laserType[2] ~= "-" then + oneHandCount = oneHandCount + countBtFx() + handTripCount = handTripCount + countSide("right") + end + + lastNotes = noteType + lastFx = fxType + + measureLength = measureLength + 1 + end + + if line == "--" then + -- end of measure + measureLength = math.max(1, measureLength) + + local relativeMeasureLength = measureLength / 192 + + -- calculate peak density + local peak = (notesCount / 6) / relativeMeasureLength + peakValue = math.max(peakValue, peak) + --[[ + local debuglog = { + measureLength = measureLength, + notesCount = notesCount, + relativeMeasureLength = relativeMeasureLength, + peak = peak, + } + for k, v in pairs(debuglog) do + game.Log(k..": "..v, game.LOGGER_DEBUG) + end + ]] + + -- cumulate "time" spent operating the knobs + local tsumami = (knobCount / 2) / relativeMeasureLength + tsumamiValue = tsumamiValue + tsumami + + measureLength = 0 + notesCount = 0 + + -- cumulate peak values (used to average notes over the length of the song) + notesValue = notesValue + peak + + totalMeasures = totalMeasures + 1 + end + + local beat = line:match("beat=(%d+/%d+)") + if beat then + beat = {beat:match("(%d+)/(%d+)")} + end + + --BUG: This is not correct, it needs to account for effect length + local function isTricky() + local tricks = { + "beat", + "stop", + "zoom_top", + "zoom_bottom", + "zoom_side", + "center_split", + } + return Util.any(tricks, function(e) return line:match("e") end) + end + if laneSpin or isTricky() then + trickyValue = trickyValue + 1 + end + end + + local graphValues = { + notes = notesValue / totalMeasures, + peak = peakValue, + tsumami = tsumamiValue / totalMeasures, + tricky = trickyValue, + handtrip = handTripCount, + onehand = oneHandCount, + } + + game.Log("graphValues", game.LOGGER_DEBUG) + for k,v in pairs(graphValues) do + game.Log(k..": "..v, game.LOGGER_DEBUG) + end + + local calibration = { + notes = 10, + peak = 48, + tsumami = 20000, + tricky = 128, + handtrip = 300, + onehand = 300, + } + + for key, factor in pairs(calibration) do + -- Apply the scaling factor to each value + self._graphdata[key] = graphValues[key] / factor + + -- Limit to maximum scale factor + self._graphdata[key] = math.min(self._graphdata[key], maxScaleFactor) + end + + game.Log("_graphdata", game.LOGGER_DEBUG) + for k,v in pairs(self._graphdata) do + game.Log(k..": "..v, game.LOGGER_DEBUG) + end + end +end + +return Radar diff --git a/scripts/songselect/songwheel.lua b/scripts/songselect/songwheel.lua index 01e7c60..030f687 100644 --- a/scripts/songselect/songwheel.lua +++ b/scripts/songselect/songwheel.lua @@ -9,6 +9,9 @@ local Numbers = require('components.numbers') local VolforceCalc = require('components.volforceCalc') +require("api.point2d") +require("components.radar") + local dataPanelImage = gfx.CreateSkinImage("song_select/data_bg_overlay.png", 1) local dataGlowOverlayImage = gfx.CreateSkinImage("song_select/data_panel/data_glow_overlay.png", 1) local gradeBgImage = gfx.CreateSkinImage("song_select/data_panel/grade_bg.png", 1) @@ -39,8 +42,6 @@ local searchInfoPanelImage = gfx.CreateSkinImage("song_select/search_info_panel. local defaultJacketImage = gfx.CreateSkinImage("song_select/loading.png", 0) - - local difficultyLabelImages = { gfx.CreateSkinImage("song_select/plate/difficulty_labels/novice.png", 1), gfx.CreateSkinImage("song_select/plate/difficulty_labels/advanced.png", 1), @@ -113,6 +114,8 @@ game.LoadSkinSample('song_wheel/diff_change.wav') local scoreNumbers = Numbers.load_number_image("score_num") local difficultyNumbers = Numbers.load_number_image("diff_num") +local songselect_showEffectRadar = game.GetSkinSetting("songselect_showEffectRadar") or false + local LEADERBOARD_PLACE_NAMES = { '1st', '2nd', @@ -125,13 +128,16 @@ local songPlateHeight = 172 local selectedIndex = 1 local selectedDifficulty = 1 +local radar = Radar.new(Point2D.new(0, 0)) +local updateRadar = true + local jacketCache = {} local top50diffs = {} local irRequestStatus = 1 -- 0=unused, 1=not requested, 2=loading, others are status codes local irRequestTimeout = 2 -local irLeaderboard = {} +local irLeaderboard = {} ---@type ServerScore[]|{} local irLeaderboardsCache = {} local transitionScrollScale = 0 @@ -158,6 +164,7 @@ local transitionSearchInfoEnterScale = 0 local transitionSearchBackgroundAlpha = 0 local transitionSearchbarOffsetY = 0 local transitionSearchInfoOffsetY = 0 +local transitionSearchBackgroundInfoAlpha = Easing.inOutQuad(transitionSearchInfoEnterScale) local transitionLaserScale = 0 local transitionLaserY = 0 @@ -195,32 +202,32 @@ local resolutionChange = function(x, y) game.Log('resX:' .. resX .. ' // resY:' .. resY .. ' // fullX:' .. fullX .. ' // fullY:' .. fullY, game.LOGGER_ERROR) end -function getCorrectedIndex(from, offset) - total = #songwheel.songs +local function getCorrectedIndex(from, offset) + local total = #songwheel.songs if (math.abs(offset) > total) then - if (offset < 0) then + if (offset < 0) then offset = offset + total*math.floor(math.abs(offset)/total) else offset = offset - total*math.floor(math.abs(offset)/total) end end - index = from + offset + local index = from + offset - if index < 1 then - index = total + (from+offset) -- this only happens if the offset is negative - end + if index < 1 then + index = total + (from+offset) -- this only happens if the offset is negative + end - if index > total then - indexesUntilEnd = total - from - index = offset - indexesUntilEnd -- this only happens if the offset is positive - end + if index > total then + local indexesUntilEnd = total - from + index = offset - indexesUntilEnd -- this only happens if the offset is positive + end - return index + return index end -function getJacketImage(song) +local function getJacketImage(song) if not jacketCache[song.id] or jacketCache[song.id]==defaultJacketImage then jacketCache[song.id] = gfx.LoadImageJob(song.difficulties[ math.min(selectedDifficulty, #song.difficulties) @@ -230,12 +237,12 @@ function getJacketImage(song) return jacketCache[song.id] end -function getGradeImageForScore(score) +local function getGradeImageForScore(score) local gradeImage = gradeImages.none local bestGradeCutoff = 0 for gradeName, scoreCutoff in pairs(gradeCutoffs) do if scoreCutoff <= score then - if scoreCutoff > bestGradeCutoff then + if scoreCutoff > bestGradeCutoff then gradeImage = gradeImages[gradeName] bestGradeCutoff = scoreCutoff end @@ -245,31 +252,30 @@ function getGradeImageForScore(score) return gradeImage end -function drawLaserAnim() +local function drawLaserAnim() gfx.Save() gfx.BeginPath() - + gfx.Scissor(0, transitionLaserY, desw, 100) - + gfx.ImageRect(0, 0, desw, desh, laserAnimBaseImage, 1, 0) - + gfx.Restore() end -function drawBackground(deltaTime) +local function drawBackground(deltaTime) Background.draw(deltaTime) - + local song = songwheel.songs[selectedIndex] local diff = song and song.difficulties[selectedDifficulty] or false if (not isFilterWheelActive and transitionLeaveReappearTimer == 0) then -- If the score for song exists - if song and diff then + if song and diff then local jacketImage = getJacketImage(song) gfx.BeginPath() gfx.ImageRect(transitionJacketBgScrollPosX, 0, 900, 900, jacketImage or defaultJacketImage, transitionJacketBgScrollAlpha, 0) - gfx.BeginPath() gfx.FillColor(0,0,0,math.floor(transitionJacketBgScrollAlpha*64)) gfx.Rect(0,0,900,900) @@ -283,7 +289,7 @@ function drawBackground(deltaTime) drawLaserAnim() - if song and diff and (not isFilterWheelActive and transitionLeaveReappearTimer == 0) then + if song and diff and (not isFilterWheelActive and transitionLeaveReappearTimer == 0) then gfx.BeginPath() gfx.ImageRect(0, 0, desw, desh, dataGlowOverlayImage, transitionAfterscrollDataOverlayAlpha, 0) gfx.BeginPath() @@ -300,7 +306,9 @@ function drawBackground(deltaTime) end -function drawSong(song, y) +---@param song SongWheelSong +---@param y number +local function drawSong(song, y) if (not song) then return end local songX = desw/2+28 @@ -311,14 +319,14 @@ function drawSong(song, y) end local bestScore - if selectedSongDifficulty.scores then + if selectedSongDifficulty.scores then bestScore = selectedSongDifficulty.scores[1] end -- Draw the bg for the song plate gfx.BeginPath() gfx.ImageRect(songX, y, 515, 172, songPlateBg, 1, 0) - + -- Draw jacket local jacketImage = getJacketImage(song) gfx.BeginPath() @@ -327,7 +335,7 @@ function drawSong(song, y) -- Draw the overlay for the song plate (that bottom black bar) gfx.BeginPath() gfx.ImageRect(songX, y, 515, 172, songPlateBottomBarOverlayImage, 1, 0) - + -- Draw the difficulty notch background gfx.BeginPath() local diffIndex = Charting.GetDisplayDifficulty(selectedSongDifficulty.jacketPath, selectedSongDifficulty.difficulty) @@ -347,7 +355,7 @@ function drawSong(song, y) if selectedSongDifficulty.topBadge then badgeImage = badgeImages[selectedSongDifficulty.topBadge+1] end - + local badgeAlpha = 1 if (selectedSongDifficulty.topBadge >= 3) then badgeAlpha = transitionFlashAlpha -- If hard clear or above, flash the badge @@ -360,7 +368,7 @@ function drawSong(song, y) local gradeImage = gradeImages.none local gradeAlpha = 1 - if bestScore then + if bestScore then gradeImage = getGradeImageForScore(bestScore.score) if (bestScore.score >= gradeCutoffs.S) then @@ -372,13 +380,13 @@ function drawSong(song, y) gfx.ImageRect(songX+391, y+47, 60, 60, gradeImage, gradeAlpha, 0) -- Draw top 50 label if applicable - if (top50diffs[selectedSongDifficulty.id]) then + if (top50diffs[selectedSongDifficulty.hash]) then gfx.BeginPath() gfx.ImageRect(songX+82, y+109, 506*0.85, 26*0.85, top50OverlayImage, 1, 0) end end -function drawSongList() +local function drawSongList() gfx.GlobalAlpha(1-transitionLeaveScale) local numOfSongsAround = 7 -- How many songs should be up and how many should be down of the selected one @@ -391,7 +399,7 @@ function drawSongList() drawSong(songwheel.songs[songIndex], desh/2-songPlateHeight/2-songPlateHeight*i + yOffset) i=i+1 end - + -- Draw the selected song drawSong(songwheel.songs[selectedIndex], desh/2-songPlateHeight/2 + yOffset) @@ -405,128 +413,8 @@ function drawSongList() gfx.GlobalAlpha(1) end -function drawData() -- Draws the song data on the left panel - - if isFilterWheelActive or transitionLeaveReappearTimer ~= 0 then return false end - - local song = songwheel.songs[selectedIndex] - local diff = song and song.difficulties[selectedDifficulty] or false - local bestScore = diff and diff.scores[1] - - if not song then return false end - - local jacketImage = getJacketImage(song) - gfx.BeginPath() - gfx.ImageRect(96, 324, 348, 348, jacketImage or defaultJacketImage, 1, 0) - - if (top50diffs[diff.id]) then - gfx.BeginPath() - gfx.ImageRect(96, 529, 410*0.85, 168*0.85, top50JacketOverlayImage, 1, 0) - end - - gfx.Save() - -- Draw best score - gfx.BeginPath() - - local scoreNumber = 0 - if bestScore then - scoreNumber = bestScore.score - end - - Numbers.draw_number(100, 793, 1.0, math.floor(scoreNumber / 10000), 4, scoreNumbers, true, 0.3, 1.12) - Numbers.draw_number(253, 798, 1.0, scoreNumber, 4, scoreNumbers, true, 0.22, 1.12) - - -- Draw grade - local gradeImage = gradeImages.none - local gradeAlpha = transitionAfterscrollGradeAlpha - if bestScore then - gradeImage = getGradeImageForScore(bestScore.score) - - if (transitionAfterscrollGradeAlpha == 1 and bestScore.score >= gradeCutoffs.S) then - gradeAlpha = transitionFlashAlpha -- If S, flash the badge - end - end - - gfx.BeginPath() - gfx.ImageRect(360, 773, 45, 45, gradeImage, gradeAlpha, 0) - - -- Draw badge - badgeImage = badgeImages[diff.topBadge+1] - - local badgeAlpha = transitionAfterscrollBadgeAlpha - if (transitionAfterscrollBadgeAlpha == 1 and diff.topBadge >= 3) then - badgeAlpha = transitionFlashAlpha -- If hard clear or above, flash the badge, but only after the initial transition - end - - gfx.BeginPath() - gfx.ImageRect(425, 724, 93/1.1, 81/1.1, badgeImage, badgeAlpha, 0) - - gfx.Restore() - - -- Draw BPM - gfx.BeginPath() - gfx.FontSize(24) - gfx.TextAlign(gfx.TEXT_ALIGN_LEFT + gfx.TEXT_ALIGN_MIDDLE) - gfx.Save() - gfx.LoadSkinFont('Digital-Serial-Bold.ttf') - gfx.GlobalAlpha(transitionAfterscrollDataOverlayAlpha) -- TODO: split this out - gfx.Text(song.bpm, 85, 920) - gfx.Restore() - - -- Draw song title - gfx.FontSize(28) - gfx.GlobalAlpha(transitionAfterscrollTextSongTitle) - gfx.Text(song.title, 30+(1-transitionAfterscrollTextSongTitle)*20, 955) - - -- Draw artist - gfx.GlobalAlpha(transitionAfterscrollTextSongArtist) - gfx.Text(song.artist, 30+(1-transitionAfterscrollTextSongArtist)*30, 997) - - gfx.GlobalAlpha(1) - - -- Draw difficulties - local DIFF_X_START = 98.5 - local DIFF_GAP = 114.8 - gfx.GlobalAlpha(transitionAfterscrollDifficultiesAlpha) - for i, diff in ipairs(song.difficulties) do - gfx.BeginPath() - - local index = diff.difficulty+1 - - if i == selectedDifficulty then - gfx.ImageRect(DIFF_X_START+(index-1)*DIFF_GAP-(163*0.8)/2, 1028, 163*0.8, 163*0.8, diffCursorImage, 1, 0) - end - - Numbers.draw_number(85+(index-1)*DIFF_GAP, 1085, 1.0, diff.level, 2, difficultyNumbers, false, 0.8, 1) - - local diffLabelImage = difficultyLabelUnderImages[ - Charting.GetDisplayDifficulty(diff.jacketPath, diff.difficulty) - ] - local tw, th = gfx.ImageSize(diffLabelImage) - tw=tw*0.9 - th=th*0.9 - gfx.BeginPath() - gfx.ImageRect(DIFF_X_START+(index-1)*DIFF_GAP-tw/2, 1050, tw, th, diffLabelImage, 1, 0) - end - gfx.GlobalAlpha(1) - - - -- Scoreboard - - drawLocalLeaderboard(diff) - drawIrLeaderboard() - - gfx.FontSize(22) - gfx.TextAlign(gfx.TEXT_ALIGN_LEFT + gfx.TEXT_ALIGN_MIDDLE) - gfx.GlobalAlpha(transitionAfterscrollDataOverlayAlpha) - gfx.Text(diff.effector, 270, 1180) -- effected by - gfx.Text(diff.illustrator, 270, 1210) -- illustrated by - gfx.GlobalAlpha(1) - -end - ---@param diff SongWheelDifficulty -function drawLocalLeaderboard(diff) +local function drawLocalLeaderboard(diff) gfx.LoadSkinFont('Digital-Serial-Bold.ttf') gfx.FontSize(26) @@ -568,31 +456,32 @@ function drawLocalLeaderboard(diff) gfx.Text(username or "-", sbBarContentLeftX, scoreBoardY + sbBarHeight/2 + i*sbBarHeight) gfx.BeginPath() - gfx.Text(score or "- - - - - - - -", sbBarContentRightX, scoreBoardY + sbBarHeight/2 + i*sbBarHeight) + local scoreText = score and tostring(score) or "- - - - - - - -" + gfx.Text(scoreText, sbBarContentRightX, scoreBoardY + sbBarHeight/2 + i*sbBarHeight) end end -function drawIrLeaderboard() +local function drawIrLeaderboard() if not IRData.Active then return end gfx.LoadSkinFont('Digital-Serial-Bold.ttf') gfx.FontSize(26) - + local scoreBoardX = 75 local scoreBoardY = 1500 - + local sbBarWidth = 336*1.2 local sbBarHeight = 33 - + local sbBarContentLeftX = scoreBoardX + 80 local sbBarContentRightX = scoreBoardX + sbBarWidth/2 + 30 -- Draw the header gfx.BeginPath() gfx.ImageRect(scoreBoardX, scoreBoardY, sbBarWidth, sbBarHeight, scoreBoardBarBgImage, 1, 0) - + gfx.TextAlign(gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_MIDDLE) gfx.BeginPath() @@ -628,8 +517,8 @@ function drawIrLeaderboard() -- Becuase the scores are in "random order", we have to do this for index, irScore in ipairs(irLeaderboard) do -- local irScore = irLeaderboard[i] - - if irScore then + + if irScore then local rank = index gfx.TextAlign(gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_MIDDLE) gfx.BeginPath() @@ -638,7 +527,7 @@ function drawIrLeaderboard() gfx.TextAlign(gfx.TEXT_ALIGN_LEFT + gfx.TEXT_ALIGN_MIDDLE) gfx.BeginPath() gfx.Text(string.upper(irScore.username), sbBarContentLeftX, scoreBoardY + sbBarHeight/2 + rank*sbBarHeight) - + gfx.BeginPath() gfx.Text(string.format("%d", irScore.score), sbBarContentRightX, scoreBoardY + sbBarHeight/2 + rank*sbBarHeight) @@ -649,7 +538,130 @@ function drawIrLeaderboard() end end -function drawFilterInfo(deltatime) +local function drawData() -- Draws the song data on the left panel + + if isFilterWheelActive or transitionLeaveReappearTimer ~= 0 then return false end + + local song = songwheel.songs[selectedIndex] + local diff = song and song.difficulties[selectedDifficulty] or false + local bestScore = diff and diff.scores[1] + + if not song then return false end + + local jacketImage = getJacketImage(song) + gfx.BeginPath() + gfx.ImageRect(96, 324, 348, 348, jacketImage or defaultJacketImage, 1, 0) + + if (top50diffs[diff.hash]) then + gfx.BeginPath() + gfx.ImageRect(96, 529, 410*0.85, 168*0.85, top50JacketOverlayImage, 1, 0) + end + + -- Draw best score + gfx.Save() + gfx.BeginPath() + + local scoreNumber = 0 + if bestScore then + scoreNumber = bestScore.score + end + + Numbers.draw_number(100, 793, 1.0, math.floor(scoreNumber / 10000), 4, scoreNumbers, true, 0.3, 1.12) + Numbers.draw_number(253, 798, 1.0, scoreNumber, 4, scoreNumbers, true, 0.22, 1.12) + + -- Draw grade + local gradeImage = gradeImages.none + local gradeAlpha = transitionAfterscrollGradeAlpha + if bestScore then + gradeImage = getGradeImageForScore(bestScore.score) + + if (transitionAfterscrollGradeAlpha == 1 and bestScore.score >= gradeCutoffs.S) then + gradeAlpha = transitionFlashAlpha -- If S, flash the badge + end + end + + gfx.BeginPath() + gfx.ImageRect(360, 773, 45, 45, gradeImage, gradeAlpha, 0) + + -- Draw badge + local badgeImage = badgeImages[diff.topBadge+1] + + local badgeAlpha = transitionAfterscrollBadgeAlpha + if (transitionAfterscrollBadgeAlpha == 1 and diff.topBadge >= 3) then + badgeAlpha = transitionFlashAlpha -- If hard clear or above, flash the badge, but only after the initial transition + end + + gfx.BeginPath() + gfx.ImageRect(425, 724, 93/1.1, 81/1.1, badgeImage, badgeAlpha, 0) + + gfx.Restore() + + -- Draw BPM + gfx.Save() + gfx.BeginPath() + gfx.FontSize(24) + gfx.TextAlign(gfx.TEXT_ALIGN_LEFT + gfx.TEXT_ALIGN_MIDDLE) + gfx.LoadSkinFont('Digital-Serial-Bold.ttf') + gfx.GlobalAlpha(transitionAfterscrollDataOverlayAlpha) -- TODO: split this out + gfx.Text(song.bpm, 85, 920) + gfx.Restore() + + -- Draw song title + gfx.Save() + gfx.FontSize(28) + gfx.GlobalAlpha(transitionAfterscrollTextSongTitle) + gfx.Text(song.title, 30+(1-transitionAfterscrollTextSongTitle)*20, 955) + gfx.Restore() + + -- Draw artist + gfx.Save() + gfx.GlobalAlpha(transitionAfterscrollTextSongArtist) + gfx.Text(song.artist, 30+(1-transitionAfterscrollTextSongArtist)*30, 997) + gfx.Restore() + + -- Draw difficulties + local DIFF_X_START = 98.5 + local DIFF_GAP = 114.8 + gfx.Save() + gfx.GlobalAlpha(transitionAfterscrollDifficultiesAlpha) + for i, diff in ipairs(song.difficulties) do + gfx.BeginPath() + + local index = diff.difficulty+1 + + if i == selectedDifficulty then + gfx.ImageRect(DIFF_X_START+(index-1)*DIFF_GAP-(163*0.8)/2, 1028, 163*0.8, 163*0.8, diffCursorImage, 1, 0) + end + + Numbers.draw_number(85+(index-1)*DIFF_GAP, 1085, 1.0, diff.level, 2, difficultyNumbers, false, 0.8, 1) + + local diffLabelImage = difficultyLabelUnderImages[ + Charting.GetDisplayDifficulty(diff.jacketPath, diff.difficulty) + ] + local tw, th = gfx.ImageSize(diffLabelImage) + tw=tw*0.9 + th=th*0.9 + gfx.BeginPath() + gfx.ImageRect(DIFF_X_START+(index-1)*DIFF_GAP-tw/2, 1050, tw, th, diffLabelImage, 1, 0) + end + gfx.Restore() + + -- Scoreboard + + drawLocalLeaderboard(diff) + drawIrLeaderboard() + + gfx.Save() + gfx.FontSize(22) + gfx.TextAlign(gfx.TEXT_ALIGN_LEFT + gfx.TEXT_ALIGN_MIDDLE) + gfx.GlobalAlpha(transitionAfterscrollDataOverlayAlpha) + gfx.Text(diff.effector, 270, 1180) -- effected by + gfx.Text(diff.illustrator, 270, 1210) -- illustrated by + gfx.Restore() + +end + +local function drawFilterInfo(deltatime) gfx.LoadSkinFont('NotoSans-Regular.ttf') if (songwheel.searchInputActive) then @@ -658,17 +670,17 @@ function drawFilterInfo(deltatime) gfx.BeginPath() gfx.ImageRect(5, 95, 417*0.85, 163*0.85, filterInfoBgImage, 1, 0) - + local folderLabel = game.GetSkinSetting('_songWheelActiveFolderLabel') local subFolderLabel = game.GetSkinSetting('_songWheelActiveSubFolderLabel') local sortOptionLabel = game.GetSkinSetting('_songWheelActiveSortOptionLabel') - + gfx.FontSize(24) gfx.TextAlign(gfx.TEXT_ALIGN_CENTER + gfx.TEXT_ALIGN_MIDDLE) - + gfx.BeginPath() gfx.Text(folderLabel or '', 167, 131) - + gfx.BeginPath() gfx.Text(subFolderLabel or '', 195, 166) @@ -679,7 +691,7 @@ function drawFilterInfo(deltatime) gfx.Text(sortOptionLabel or '', desw-150, 130) end -function drawCursor() +local function drawCursor() if isFilterWheelActive or transitionLeaveScale ~= 0 then return false end gfx.BeginPath() @@ -690,7 +702,7 @@ function drawCursor() gfx.ImageRect(desw / 2 - 14, desh / 2 - 213 / 2, 555, 213, cursorImage, 1, 0) end -function drawSearch() +local function drawSearch() if (not songwheel.searchInputActive and searchPreviousActiveState) then searchPreviousActiveState = false game.PlaySample('sort_wheel/enter.wav') @@ -698,7 +710,7 @@ function drawSearch() searchPreviousActiveState = true game.PlaySample('sort_wheel/leave.wav') end - + if (songwheel.searchText ~= '' and searchInfoPreviousActiveState == true) then searchInfoPreviousActiveState = false elseif (songwheel.searchText == '' and searchInfoPreviousActiveState == false) then @@ -724,7 +736,7 @@ function drawSearch() local infoXPos = 0 local infoYStartPos = desh - sh - 772 + 242 local infoYPos = infoYStartPos + transitionSearchInfoOffsetY - + if (game.GetSkinSetting('gameplay_showSearchControls')) then gfx.ImageRect(infoXPos, infoYPos, sw, sh, searchInfoPanelImage, transitionSearchBackgroundInfoAlpha, 0) end @@ -760,7 +772,7 @@ function drawSearch() gfx.Text(songwheel.searchText, xPos + 160, yPos + 83.2) end -function drawScrollbar() +local function drawScrollbar() if isFilterWheelActive or transitionLeaveScale ~= 0 then return end -- Scrollbar Background @@ -803,7 +815,33 @@ function drawScrollbar() end end -function refreshIrLeaderboard(deltaTime) +---Called on IR Leaderboard fetch complete +---@param res IRLeaderboardResponse +local function onIrLeaderboardFetched(res) + irRequestStatus = res.statusCode + + local song = songwheel.songs[selectedIndex] + local diff = song and song.difficulties[selectedDifficulty] or false + + if res.statusCode == IRData.States.Success then + local tempIrLB = res.body + + table.sort(tempIrLB, function (a,b) + return a.score > b.score + end) + + irLeaderboard = tempIrLB + irLeaderboardsCache[diff.hash] = irLeaderboard + else + local httpStatus = (res.statusCode // 10) * 100 + res.statusCode % 10 -- convert to 100 range + game.Log("IR error (" .. httpStatus .. "): " .. res.description, game.LOGGER_ERROR) + if res.body then + game.Log(common.dump(res.body), game.LOGGER_ERROR) + end + end +end + +local function refreshIrLeaderboard(deltaTime) if not IRData.Active then return end @@ -832,56 +870,10 @@ function refreshIrLeaderboard(deltaTime) end irRequestStatus = 2 -- Loading - -- onIrLeaderboardFetched({ - -- statusCode = 20, - -- body = {} - -- }) IR.Leaderboard(diff.hash, 'best', 4, onIrLeaderboardFetched) end -function dump(o) - if type(o) == 'table' then - local s = '{ ' - for k,v in pairs(o) do - if type(k) ~= 'number' then k = '"'..k..'"' end - s = s .. '['..k..'] = ' .. dump(v) .. ',' - end - return s .. '} ' - else - return tostring(o) - end -end - -function onIrLeaderboardFetched(res) - irRequestStatus = res.statusCode - - local song = songwheel.songs[selectedIndex] - local diff = song and song.difficulties[selectedDifficulty] or false - game.Log(diff.hash, game.LOGGER_ERROR) - - if res.statusCode == IRData.States.Success then - game.Log('Raw IR reposonse: ' .. dump(res.body), game.LOGGER_ERROR) - local tempIrLB = res.body - - table.sort(tempIrLB, function (a,b) - -- game.Log(a.score .. ' ?? ' .. b.score, game.LOGGER_ERROR) - return a.score > b.score - end) - - -- for i, tempScore in ipairs(tempIrLeaderboard) do - -- irLeaderboard[tempScore.ranking] = tempScore - -- end - - irLeaderboard = tempIrLB - irLeaderboardsCache[diff.hash] = irLeaderboard - - game.Log(dump(irLeaderboard), game.LOGGER_ERROR) - else - game.Log("IR error " .. res.statusCode, game.LOGGER_ERROR) - end -end - -function tickTransitions(deltaTime) +local function tickTransitions(deltaTime) if transitionScrollScale < 1 then transitionScrollScale = transitionScrollScale + deltaTime / 0.1 -- transition should last for that time in seconds else @@ -897,7 +889,7 @@ function tickTransitions(deltaTime) transitionAfterscrollScale = 1 end - if scrollingUp then + if scrollingUp then transitionScrollOffsetY = Easing.inQuad(1-transitionScrollScale) * songPlateHeight else transitionScrollOffsetY = Easing.inQuad(1-transitionScrollScale) * -songPlateHeight @@ -942,8 +934,6 @@ function tickTransitions(deltaTime) end end - transitionSearchBackgroundInfoAlpha = Easing.inOutQuad(transitionSearchInfoEnterScale) - -- Grade alpha if transitionAfterscrollScale >= 0.03 and transitionAfterscrollScale < 0.033 then transitionAfterscrollGradeAlpha = 0.5 @@ -952,7 +942,7 @@ function tickTransitions(deltaTime) else transitionAfterscrollGradeAlpha = 0 end - + -- Badge alpha if transitionAfterscrollScale >= 0.032 and transitionAfterscrollScale < 0.035 then transitionAfterscrollBadgeAlpha = 0.5 @@ -974,7 +964,7 @@ function tickTransitions(deltaTime) else transitionAfterscrollTextSongArtist = 1 end - + -- Difficulties alpha if transitionAfterscrollScale < 0.025 then transitionAfterscrollDifficultiesAlpha = math.min(1, transitionAfterscrollScale / 0.025) @@ -994,7 +984,7 @@ function tickTransitions(deltaTime) elseif transitionJacketBgScrollScale >= 0.05 and transitionJacketBgScrollScale < 0.1 then transitionJacketBgScrollAlpha = math.min(1, (transitionJacketBgScrollScale-0.05) / 0.05) elseif transitionJacketBgScrollScale >= 0.8 and transitionJacketBgScrollScale < 1 then - transitionJacketBgScrollAlpha = math.max(0, + transitionJacketBgScrollAlpha = math.max(0, math.min(1, 1-((transitionJacketBgScrollScale-0.8) / 0.05)) ) else @@ -1011,25 +1001,17 @@ function tickTransitions(deltaTime) end transitionLaserY = desh - math.min(transitionLaserScale * 2 * desh, desh) - + -- Flash transition if transitionFlashScale < 1 then + ---@type number|string local songBpm = 120 + if (songwheel.songs[selectedIndex] and game.GetSkinSetting('animations_affectWithBPM')) then - songBpm = songwheel.songs[selectedIndex].bpm - - -- Is a variable BPM - if (type(songBpm) == "string") then - local s = common.split(songBpm, '-') - songBpm = tonumber(s[1]) -- Lowest bpm value - end - end - - -- If the original songBpm is "2021.04.01" for example, the above code can produce `nil` in the songBpm - -- since it cannot parse the number out of that string. Here we implement a fallback, to not crash - -- USC on whacky charts. Whacky charters, quit using batshit insane bpm values. It makes me angery >:( - if (songBpm == nil) then - songBpm = 120 + local songBpmStr = songwheel.songs[selectedIndex].bpm + local songBpmStrs = common.split(songBpmStr, '-') + local minBpm = tonumber(songBpmStrs[1]) -- Lowest bpm value + songBpm = minBpm or songBpm end transitionFlashScale = transitionFlashScale + deltaTime / (60/songBpm) -- transition should last for that time in seconds @@ -1069,7 +1051,41 @@ function tickTransitions(deltaTime) end end -draw_songwheel = function(deltaTime) +---This function is basically a workaround for the ForceRender call +local function drawRadar() + if not songselect_showEffectRadar then return end + if isFilterWheelActive or transitionLeaveScale ~= 0 then return end + + local x, y = 375, 650 + local scale = 0.666 + + gfx.FontSize(28) + gfx.Translate(x, y) + gfx.Scale(scale, scale) + + local strokeColor = ColorRGBA.new(255, 255, 255, 128) + local fillColor = ColorRGBA.new(0, 0, 0, 191) + + gfx.ResetScissor() + radar:drawBackground(fillColor) + radar:drawOutline(3, strokeColor) + + --Bug: ForceRender resets every transformation, need to re-setup view transform afterwards + --ForceRender also resets gfx stack, USC will crash if you try to call gfx.Restore(), + --make sure the gfx stack is clean before calling radar:drawRadarMesh() + radar:drawRadarMesh() + + Dim.transformToScreenSpace() + + gfx.Save() + gfx.Translate(x, y) + gfx.Scale(scale, scale) + radar:drawRadialTicks(strokeColor) + radar:drawAttributes() + gfx.Restore() +end + +local draw_songwheel = function(deltaTime) drawBackground(deltaTime) drawSongList() @@ -1077,6 +1093,9 @@ draw_songwheel = function(deltaTime) isFilterWheelActive = game.GetSkinSetting('_songWheelOverlayActive') == 1 drawData() + + drawRadar() + drawCursor() drawFilterInfo(deltaTime) @@ -1091,13 +1110,14 @@ draw_songwheel = function(deltaTime) local debugScrollingUp= "FALSE" if scrollingUp then debugScrollingUp = "TRUE" end - if game.GetSkinSetting('debug_showInformation') then + if game.GetSkinSetting('debug_showInformation') then gfx.Text('S_I: ' .. selectedIndex .. ' // S_D: ' .. selectedDifficulty .. ' // S_UP: ' .. debugScrollingUp .. ' // AC_TS: ' .. transitionAfterscrollScale .. ' // L_TS: ' .. transitionLeaveScale .. ' // IR_CODE: ' .. irRequestStatus .. ' // IR_T: ' .. irRequestTimeout, 8, 8) end gfx.ResetTransform() end +---@diagnostic disable-next-line:lowercase-global render = function (deltaTime) tickTransitions(deltaTime) @@ -1105,6 +1125,13 @@ render = function (deltaTime) Sound.stopMusic() + if updateRadar then + local difficultyNames = {"nov","adv","exh","mxm","inf","grv","hvn","vvd","exc"} + local diff = songwheel.songs[selectedIndex].difficulties[selectedDifficulty].difficulty + 1 + radar:updateGraph(songwheel.songs[selectedIndex].path, difficultyNames[diff]) + updateRadar = false + end + Dim.updateResolution() Wallpaper.render() @@ -1116,6 +1143,7 @@ render = function (deltaTime) refreshIrLeaderboard(deltaTime) end +---@diagnostic disable-next-line:lowercase-global songs_changed = function (withAll) irLeaderboardsCache = {} -- Reset LB cache @@ -1125,29 +1153,33 @@ songs_changed = function (withAll) game.SetSkinSetting('_songWheelScrollbarTotal', #songwheel.songs) game.SetSkinSetting('_songWheelScrollbarIndex', selectedIndex) - local diffs = {} - for i = 1, #songwheel.allSongs do - local song = songwheel.allSongs[i] - for j = 1, #song.difficulties do - local diff = song.difficulties[j] - diff.force = VolforceCalc.calc(diff) - table.insert(diffs, diff) - end - end - table.sort(diffs, function (l, r) - return l.force > r.force - end) - totalForce = 0 - for i = 1, 50 do - if diffs[i] then - top50diffs[diffs[i].id] = true - totalForce = totalForce + diffs[i].force - end - end + local diffs = {} + for i = 1, #songwheel.allSongs do + local song = songwheel.allSongs[i] + for j = 1, #song.difficulties do + local diff = song.difficulties[j] + table.insert(diffs, {hash = diff.hash, force = VolforceCalc.calc(diff)}) + end + end + + table.sort(diffs, function (l, r) + return l.force > r.force + end) + + local totalForce = 0 + for i = 1, 50 do + local diff = diffs[i] + if not diff then + break + end + top50diffs[diff.hash] = true + totalForce = totalForce + diff.force + end game.SetSkinSetting('_volforce', totalForce) end +---@diagnostic disable-next-line:lowercase-global set_index = function(newIndex) transitionScrollScale = 0 transitionAfterscrollScale = 0 @@ -1157,23 +1189,30 @@ set_index = function(newIndex) game.SetSkinSetting('_songWheelScrollbarTotal', #songwheel.songs) game.SetSkinSetting('_songWheelScrollbarIndex', newIndex) - scrollingUp = false - if ((newIndex > selectedIndex and not (newIndex == #songwheel.songs and selectedIndex == 1)) or (newIndex == 1 and selectedIndex == #songwheel.songs)) then - scrollingUp = true - end + scrollingUp = false + if ((newIndex > selectedIndex and not (newIndex == #songwheel.songs and selectedIndex == 1)) or (newIndex == 1 and selectedIndex == #songwheel.songs)) then + scrollingUp = true + end - game.PlaySample('song_wheel/cursor_change.wav') + updateRadar = true + + game.PlaySample('song_wheel/cursor_change.wav') selectedIndex = newIndex end +local json = require("common.json") + +---@diagnostic disable-next-line:lowercase-global set_diff = function(newDiff) - if newDiff ~= selectedDifficulty then + if newDiff ~= selectedDifficulty then jacketCache = {} -- Clear the jacket cache for the new diff jackets game.PlaySample('song_wheel/diff_change.wav') end + updateRadar = true + selectedDifficulty = newDiff irLeaderboard = {} irRequestStatus = 1 diff --git a/shaders/radar.fs b/shaders/radar.fs new file mode 100644 index 0000000..ae5cbbd --- /dev/null +++ b/shaders/radar.fs @@ -0,0 +1,66 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : enable + +const float PI = 3.1415926535897932384626433832795; +const float PI_2 = 1.57079632679489661923; + +layout(location=1) in vec2 fsTex; +layout(location=2) in vec3 fragPos; + +layout(location=0) out vec4 target; + +uniform vec4 colorCenter; +uniform vec4 colorMax; + +uniform float maxSize; + +float lerp(float x, vec2 p0, vec2 p1) +{ + return p0.y + (p1.y - p0.y) * ((x - p0.x)/(p1.x - p0.x)); +} + +vec4 toHSV(vec4 rgb) +{ + vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + vec4 p = mix(vec4(rgb.bg, K.wz), vec4(rgb.gb, K.xy), step(rgb.b, rgb.g)); + vec4 q = mix(vec4(p.xyw, rgb.r), vec4(rgb.r, p.yzx), step(p.x, rgb.r)); + + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec4(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x, rgb.a); +} + +vec4 toRGB(vec4 hsv) +{ + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(hsv.xxx + K.xyz) * 6.0 - K.www); + return vec4(hsv.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), hsv.y), hsv.a); +} + +// x is in [0.0, 1.0] +vec4 lerpColor(float x, vec4 color1, vec4 color2) +{ + // convert RGB color space to HSV + vec4 hsv1 = toHSV(color1), hsv2 = toHSV(color2); + float hue = (mod(mod((hsv2.x-hsv1.x), 1.) + 1.5, 1.)-0.5)*x + hsv1.x; + vec4 hsv = vec4(hue, mix(hsv1.yzw, hsv2.yzw, x)); + // convert HSV color space back to RGB + return toRGB(hsv); +} + +float EaseInSine(float x) +{ + return 1.0 - cos((x * PI) / 2.0); +} + +vec4 coordToColor(vec2 pos, float maxSize) +{ + float r = length(pos); + float factor = lerp(r, vec2(0,0), vec2(maxSize,1)); + return lerpColor(EaseInSine(factor), colorCenter, colorMax); +} + +void main() +{ + target = coordToColor(fragPos.xy, maxSize); +} diff --git a/shaders/radar.vs b/shaders/radar.vs new file mode 100644 index 0000000..2826d10 --- /dev/null +++ b/shaders/radar.vs @@ -0,0 +1,22 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : enable + +layout(location=0) in vec2 inPos; +layout(location=1) in vec2 inTex; + +out gl_PerVertex +{ + vec4 gl_Position; +}; +layout(location=1) out vec2 fsTex; +layout(location=2) out vec3 fragPos; + +uniform mat4 proj; +uniform mat4 world; + +void main() +{ + fsTex = inTex; + gl_Position = proj * world * vec4(inPos.xy, 0, 1); + fragPos = vec3(inPos.xy, 0); +} diff --git a/shaders/radarVertex.fs b/shaders/radarVertex.fs new file mode 100644 index 0000000..3e1f4ce --- /dev/null +++ b/shaders/radarVertex.fs @@ -0,0 +1,11 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : enable + +layout(location=1) in vec2 fsTex; +layout(location=0) out vec4 target; +layout(location=2) in vec4 inColor; + +void main() +{ + target = inColor; +} diff --git a/shaders/radarVertex.vs b/shaders/radarVertex.vs new file mode 100644 index 0000000..3a6193b --- /dev/null +++ b/shaders/radarVertex.vs @@ -0,0 +1,94 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : enable + +const float PI = 3.1415926535897932384626433832795; +const float PI_2 = 1.57079632679489661923; + +layout(location=0) in vec2 inPos; +layout(location=1) in vec2 inTex; + +out gl_PerVertex +{ + vec4 gl_Position; +}; +layout(location=1) out vec2 fsTex; +layout(location=2) out vec4 vertexColor; + +uniform mat4 proj; +uniform mat4 world; + +uniform vec4 colorCenter; +uniform vec4 colorMax; + +uniform float maxSize; + +// Polar coordinate utility functions +float hypot(vec2 pos) +{ + return sqrt(pos.x * pos.x + pos.y * pos.y); +} + +/* +float atan2(vec2 pos) +{ + if (pos.x > 0) + { + return atan(pos.y / pos.x); + } + + if (pos.x < 0 && pos.y >= 0) + { + return atan(pos.y / pos.x) + PI; + } + + if (pos.x < 0 && pos.y < 0) + { + return atan(pos.y / pos.x) - PI; + } + + if (pos.x == 0 && pos.y > 0) + { + return PI_2; + } + + if (pos.x == 0 && pos.y < 0) + { + return -PI_2; + } + + // The following is not mathematically correct, as it's undefined + return 0.0; +} + +vec2 toPolar(vec2 cartesian) +{ + return vec2(hypot(pos), atan2(pos)); +} +*/ + +float lerp(float x, vec2 p0, vec2 p1) +{ + return p0.y + (p1.y - p0.y) * ((x - p0.x)/(p1.x - p0.x)); +} + +// x is in [0.0, 1.0] +vec4 lerpColor(float x, vec4 color1, vec4 color2) +{ + return color1 + (color2 - color1) * x; +} + +vec4 coordToColor(vec2 pos, float maxSize) +{ + float r = hypot(pos); + //float phi = atan2(pos); + + float factor = lerp(r, vec2(0,0), vec2(maxSize,1)); + + return lerpColor(factor, colorCenter, colorMax); +} + +void main() +{ + gl_Position = proj * world * vec4(inPos.xy, 0, 1); + vertexColor = coordToColor(inPos.xy, maxSize); +}