Writing a lua Group Wheel - RhythmLunatic/stepmania GitHub Wiki

https://arcofswords.s-ul.eu/yJPXpCVb.gif

How to write a lua Group Wheel

No, this does not cover handling songs! This is meant to be used with the built in SongWheel!

Step 0. Prerequisites

First off, we need Kyzentun's item_scroller.lua. He has written a general tutorial about it here, but the custom wheel tutorial is more about the whole input handling and groups part.

Put item_scroller.lua in your Scripts folder like normal.

Since this is a group select, you'd want these in your metrics.ini:

[MusicWheel]
UseSectionsWithPreferredGroup=true
OnlyShowActiveSection=true

Step 1. Creating the wheel

Tip: If you are creating a custom songwheel instead of a groupwheel, you cannot use ScreenSelectMusic derivatives because it would conflict with the built in wheel. So define [ScreenSelectMusicCustom] with fallback="ScreenWithMenuElements" in metrics.ini and use it instead. Yes, this means you have to write everything the built in wheel would do, good luck.

Create a new file in BGAnimations/ScreenSelectMusic overlay named anything you want... I named mine "GroupSelect.lua".

--- since this is a group wheel, we need an array of the groups currently loaded in StepMania. So add an array. This will be used as the 'info set' for the wheel.
local song_groups = SONGMAN:GetSongGroupNames();

-- Next up, we create our wheel.

-- This spawns an item scroller class.
local scroller = setmetatable({disable_wrapping= false}, item_scroller_mt)
local numWheelItems = 15 --Number of rotating items to have in the wheel. This does not have anything to do with the size of the info set.

-- And here is our massive wheel frame.
local item_mt= {
  __index= {
	-- create_actors must return an actor.  The name field is a convenience.
        create_actors= function(self, params)
	  self.name= params.name
                --This is the equivalent to Graphics/MusicWheelItem SectionExpanded NormalPart (or MusicWheelItem Song NormalPart if you're making a song wheel)
		return Def.ActorFrame{
			InitCommand= function(subself)
				-- Setting self.container to point to the actor gives a convenient
				-- handle for manipulating the actor.
		  		self.container= subself
		  		subself:SetDrawByZPosition(true);
			end;
                        --By giving these actors names they can be individually accessed when this ActorFrame is passed in in the set and transform functions below.
			--A text actor for the group names to be displayed.
			Def.BitmapText{
				Name= "text",
				Font= "Common Normal",
				InitCommand=cmd(addy,100);
			};
			--And probably more important, the banner for the group icons to be displayed.
			Def.Sprite{
				Name="banner";
			};
		};
	end,
        -- This is the equivalent to your ItemTransformFunction, but unlike ItemTransformFunction which updates per frame this one uses tweens.
        -- This particular example function acts like a cover flow wheel.
        -- self.container is an instance of the ActorFrame defined above.
	-- item_index is the index in the list, ranging from 1 to num_items.
	-- is_focus is only useful if the disable_wrapping flag in the scroller is
	-- set to false.
	transform= function(self, item_index, num_items, is_focus)
                local offsetFromCenter = item_index-math.floor(numWheelItems/2)
		self.container:stoptweening();
		if math.abs(offsetFromCenter) < 4 then
			self.container:decelerate(.5);
			self.container:visible(true);
		else
			self.container:visible(false);
		end;
		self.container:x(offsetFromCenter*350)
		self.container:rotationy(offsetFromCenter*-45);
		self.container:zoom(math.cos(offsetFromCenter*math.pi/6)*.8)
		-- This is for debug testing.
		--[[if offsetFromCenter == 0 then
			self.container:diffuse(Color("Red"));
		else
			self.container:diffuse(Color("White"));
		end;]]
	end,
	-- info is one entry in the info set that is passed to the scroller.
        -- So in this example, something from "song_groups" is being passed in as the 'info' argument.
        -- Remember SetMessageCommand when used in Song NormalPart? This is that.
        -- self.container is an instance of the ActorFrame defined above. Because the actors inside the ActorFrame were given names, they can be accessed using self.container:GetChild().
	set= function(self, info)
                --"text" is only used if there's no banner in this wheel.
                -- Pro Tip: Want to hide numbered prefixes? Use this:
                -- string.gsub(info,"^%d%d? ?%- ?", "")
                -- It will make a string like "08-PRIME 2" -> "PRIME 2"
                self.container:GetChild("text"):settext(info)
		local banner = SONGMAN:GetSongGroupBannerPath(info);
		if banner == "" then
                        self.container:GetChild("banner"):Load(THEME:GetPathG("common","fallback banner.png"));
			self.container:GetChild("text"):visible(true);
  		else
                        self.container:GetChild("banner"):Load(banner);
  			self.container:GetChild("text"):visible(false);
		end;
	end,
}}

Step 2. Input handling

We've defined the wheel. Now we have to make it so the wheel responds to our inputs.

So paste this in right below the wheel...


--This function runs when you press start and changes the current song group. Since this is a group wheel.
--We'll define it here because I said so.
local function CloseWheel()
        --Get the current selected group. get_info_at_focus_pos() gets whatever info is set to the currently focused wheel item in the set, if you couldn't already tell. In this case it's the name of the current focused group.
        --In simpler terms, think of it like song_groups[current_pos], where current_pos is a hypothetical variable that has the current position of the wheel (The wheel does not actually have positions, this is an example).
	local currentGroup = scroller:get_info_at_focus_pos();
        --One of the benefits of a custom wheel is being able to transform a single item in the wheel instead of all of them. This gets the current focused actor in the wheel's set of wheel items.
	local curItem = scroller:get_actor_item_at_focus_pos();
        --This transform function zooms in the actor.
	curItem.container:GetChild("banner"):accelerate(.3):zoom(2):diffusealpha(0);

        --Since this is for a group wheel, this sets the new group.
	SCREENMAN:GetTopScreen():GetChild('MusicWheel'):SetOpenSection(currentGroup);
        --The built in wheel needs to be told the group has been changed, for whatever reason. This function does it.
	SCREENMAN:GetTopScreen():PostScreenMessage( 'SM_SongChanged', 0.5 );

        --Set the input redirection back off so they can actually scroll the regular wheel.
        SCREENMAN:set_input_redirected(PLAYER_1, false);
        SCREENMAN:set_input_redirected(PLAYER_2, false);
        --We need to broadcast this since the actorframe the wheel holds isn't in the scope of CloseWheel() and we want the wheel to hide after pressing center... We'll get back to it later.
        MESSAGEMAN:Broadcast("StartSelectingSong");
end;

--And now, our input handler for the wheel we wrote, so we can actually move the wheel.
local isSelectingDifficulty = false; --You'll need this for later if you're using a TwoPartSelect, because you don't want to open the wheel while selecting a difficulty.

local function inputs(event)
	
        local pn= event.PlayerNumber
	local button = event.button
	-- If the PlayerNumber isn't set, the button isn't mapped.  Ignore it.
	if not pn then return end
	-- If it's a release, ignore it.
	if event.type == "InputEventType_Release" then return end
	
	if SCREENMAN:get_input_redirected(pn) then 
		if button == "Center" or button == "Start" then
			CloseWheel()
		elseif button == "DownLeft" or button == "Left" then
			scroller:scroll_by_amount(-1);
			SOUND:PlayOnce(THEME:GetPathS("MusicWheel", "change"), true);
			MESSAGEMAN:Broadcast("PreviousGroup"); --If you have arrows or graphics on the screen and you only want them to respond when left or right is pressed.
		elseif button == "DownRight" or button == "Right" then
			scroller:scroll_by_amount(1);
			SOUND:PlayOnce(THEME:GetPathS("MusicWheel", "change"), true);
			MESSAGEMAN:Broadcast("NextGroup");
		elseif button == "Back" then
                        --Because we've redirected input, we need to handle the back button ourselves instead of SM handling it.
                        --You can do whatever you want here though, like closing the wheel without picking a group.
			SCREENMAN:GetTopScreen():StartTransitioningScreen("SM_GoToPrevScreen");
                        SCREENMAN:set_input_redirected(PLAYER_1, false);
                        SCREENMAN:set_input_redirected(PLAYER_2, false); 
		else
                        --Inputs not working? Uncomment this to check what they are.
			--SCREENMAN:SystemMessage(button);
		end;
	end;
end;

Step 3. Insert the custom wheel in an ActorFrame, and handle opening the wheel

You are most likely wondering why opening the wheel is being handled separately from the input code above. That's because we need to manipulate the ActorFrame the wheel is in to hide/show the wheel and doing it from the inputs() function is more difficult. By using CodeMessageCommand, self (this ActorFrame) is passed in as an argument and can be manipulated easily.

The CodeNames section of my [ScreenSelectMusic] section in metrics.ini looks like this. So define yours in metrics.ini too.

CodeNames="GroupSelectPad1,GroupSelectPad2,GroupSelectButton1,GroupSelectButton2"
CodeGroupSelectPad1="UpLeft"
CodeGroupSelectPad2="UpRight"
#Need additonal ones for the menu buttons...
CodeGroupSelectButton1="MenuUp"
CodeGroupSelectButton2="MenuDown"

Let's define the ActorFrame now.

local t = Def.ActorFrame{
    --Make the wheel invisible by default.
    InitCommand=cmd(diffusealpha,0);

    OnCommand=function(self)
        --Remember when we created the array song_groups? Here we finally use it.
	scroller:set_info_set(song_groups, 1);

        --Scroll the wheel to the correct song group.
	local curGroup = GAMESTATE:GetCurrentSong():GetGroupName();
	for key,value in pairs(song_groups) do
	    if curGroup == value then
		scroller:scroll_by_amount(key-1)
	    end
	end;
        --Add the input callback so our custom inputs() function works.
        SCREENMAN:GetTopScreen():AddInputCallback(inputs);

	-- I got sick of input locking when I reloaded the screen, since the wheel isn't open when you reload the screen.
        -- You'll probably want this somewhere in an ScreenOptionsService OnCommand so you're not completely stuck and
        -- forced to close the game if you press the operator button while the group select is active.
	SCREENMAN:set_input_redirected(PLAYER_1, false);
	SCREENMAN:set_input_redirected(PLAYER_2, false);
    end;

    --TwoPartSelect handlers.
    SongChosenMessageCommand=function(self)
        isPickingDifficulty = true;
    end;
    TwoPartConfirmCanceledMessageCommand=cmd(sleep,.1;queuecommand,"PickingSong");
    SongUnchosenMessageCommand=cmd(sleep,.1;queuecommand,"PickingSong");
    PickingSongCommand=function(self)
        isPickingDifficulty = false;
    end;

    --And now, handle opening the wheel.
    CodeMessageCommand=function(self,param)
        local codeName = param.Name -- code name, matches the one in metrics
        if codeName == "GroupSelectPad1" or codeName == "GroupSelectPad2" or codeName == "GroupSelectButton1" or codeName == "GroupSelectButton2" then
            if isPickingDifficulty then return end; --Don't want to open the group select if they're picking the difficulty.
            
            MESSAGEMAN:Broadcast("StartSelectingGroup");
            --No need to check if both players are present.
            SCREENMAN:set_input_redirected(PLAYER_1, true);
            SCREENMAN:set_input_redirected(PLAYER_2, true);
            --Remember how when you close the wheel the item gets zoomed in? This zooms it back out.
            local curItem = scroller:get_actor_item_at_focus_pos();
            curItem.container:GetChild("banner"):stoptweening():zoom(1):diffusealpha(1);

            --Show the ActorFrame that holds the wheel.
            self:stoptweening():linear(.5):diffusealpha(1);
            --Optional. Mute the music currently playing.
            --SOUND:DimMusic(0,math.huge);

            local musicWheel = SCREENMAN:GetTopScreen():GetChild('MusicWheel');
            musicWheel:Move(0); --Work around a StepMania bug. If the input is redirected while scrolling through the built in music wheel, it will continue to scroll.
	end;
    end;

    --Remember when I said the ActorFrame isn't in the scope of CloseWheel() and we'd get back to it? This is it.
    --When CloseWheel() is activated it broadcasts StartSelectingSong, this recieves it and disappears the wheel.
    StartSelectingSongMessageCommand=function(self)
        self:stoptweening():linear(.5):diffusealpha(0);
    end;
}

And now we finally add the wheel.

t[#t+1] = scroller:create_actors("foo", numWheelItems, item_mt, SCREEN_CENTER_X, SCREEN_CENTER_Y);

--Don't forget this at the end of your lua file.
return t;

Since this is in GroupSelect.lua, do LoadActor("GroupSelect") in ScreenSelectMusic overlay and it should work.

Licensing

Since someone noted that the wiki is CC BY SA and it doesn't exactly make sense in this context, the above code is MIT. Do whatever you want as long as you credit me.

Copyright © 2020 Rhythm Lunatic

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.