Effect Radar Implementation v1 #46

Merged
hersi merged 12 commits from feature/radar into master 2023-11-23 07:41:15 +01:00
18 changed files with 1438 additions and 449 deletions

BIN
CHANGELOG

Binary file not shown.

View File

@ -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
}
}

View File

@ -23,44 +23,50 @@ 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,

View File

@ -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/<skin>/textures/<path>`
-- Loads a font from `skins/<skin>/fonts/<name>`
-- 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,

View File

@ -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/<skin>/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/<skin>/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,
};
-- 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

View File

@ -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

BIN
fonts/contb.ttf Normal file

Binary file not shown.

BIN
fonts/contl.ttf Normal file

Binary file not shown.

BIN
fonts/contm.ttf Normal file

Binary file not shown.

74
scripts/api/color.lua Normal file
View File

@ -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

28
scripts/api/point2d.lua Normal file
View File

@ -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

View File

@ -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
}

View File

@ -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
-- <bt-lanes x 4>|<fx-lanes x 2>|<laser-lanes x 2><lane-spin (optional)>
--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

View File

@ -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,8 +202,8 @@ 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
@ -206,21 +213,21 @@ function getCorrectedIndex(from, offset)
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 > total then
indexesUntilEnd = total - from
local indexesUntilEnd = total - from
index = offset - indexesUntilEnd -- this only happens if the offset is positive
end
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,7 +237,7 @@ 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
@ -245,7 +252,7 @@ function getGradeImageForScore(score)
return gradeImage
end
function drawLaserAnim()
local function drawLaserAnim()
gfx.Save()
gfx.BeginPath()
@ -256,7 +263,7 @@ function drawLaserAnim()
gfx.Restore()
end
function drawBackground(deltaTime)
local function drawBackground(deltaTime)
Background.draw(deltaTime)
local song = songwheel.songs[selectedIndex]
@ -269,7 +276,6 @@ function drawBackground(deltaTime)
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)
@ -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
@ -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
@ -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,11 +456,12 @@ 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
@ -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
@ -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')
@ -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
@ -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
@ -1014,22 +1004,14 @@ function tickTransitions(deltaTime)
-- 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)
@ -1098,6 +1117,7 @@ draw_songwheel = function(deltaTime)
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
@ -1130,24 +1158,28 @@ songs_changed = function (withAll)
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)
table.insert(diffs, {hash = diff.hash, force = VolforceCalc.calc(diff)})
end
end
table.sort(diffs, function (l, r)
return l.force > r.force
end)
totalForce = 0
local totalForce = 0
for i = 1, 50 do
if diffs[i] then
top50diffs[diffs[i].id] = true
totalForce = totalForce + diffs[i].force
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
@ -1162,11 +1194,16 @@ set_index = function(newIndex)
scrollingUp = true
end
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
jacketCache = {} -- Clear the jacket cache for the new diff jackets
@ -1174,6 +1211,8 @@ set_diff = function(newDiff)
game.PlaySample('song_wheel/diff_change.wav')
end
updateRadar = true
selectedDifficulty = newDiff
irLeaderboard = {}
irRequestStatus = 1

66
shaders/radar.fs Normal file
View File

@ -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);
}

22
shaders/radar.vs Normal file
View File

@ -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);
}

11
shaders/radarVertex.fs Normal file
View File

@ -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;
}

94
shaders/radarVertex.vs Normal file
View File

@ -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);
}