Jump to content

[Tutorial] Custom User Interfaces


Valericoe
 Share

Recommended Posts

Hey Everyone,

Today I'll be detailing the creation of a custom user interface.

1. Setting up the debugging environment (Optional)

Placing UI elements takes some trial and error. I recommend running a dedicated server if you're making a clientside mod or adding a keybind to reload the latest save.

Placing something like this in your modmain.lua allows you to press CTRL+R in-game to reload your latest save. (If this is not working, set your server max player count to 1)

local _g = GLOBAL
local require = _g.require

AddSimPostInit(function()
	_g.TheInput:AddKeyHandler(
	function(key, down)
		if not down then return end -- Only trigger on key press
		-- Require CTRL for any debug keybinds
		if _g.TheInput:IsKeyDown(_g.KEY_CTRL) then
			 -- Load latest save and run latest scripts
			if key == _g.KEY_R then
				if _g.TheWorld.ismastersim then
					_g.c_reset()
				else
					_g.TheNet:SendRemoteExecute("c_reset()")
				end
			end
		end
	end)
end)

2. Displaying and Closing the UI

In order to display a screen for a client, we need to 'push' it to TheFrontEnd.

  • If we want a persistent widget, we can use AddChild

Here we've added a keybind to T that determines if we have an active screen and if that screen is the player HUD. We then push the "spawner.lua" screen with the player as an argument. Replace "spawner" with your screen name.

It also calls OnClose() if T has been pressed while the screen is active.

local _g = GLOBAL
local require = _g.require

AddSimPostInit(function()
	_g.TheInput:AddKeyHandler(
	function(key, down)
        -- Only trigger on key down
		if not down then return end
		-- Push our screen
		if key == _g.KEY_T then
			local screen = TheFrontEnd:GetActiveScreen()
			-- End if we can't find the screen name (e.g. asleep)
			if not screen or not screen.name then return true end
			-- If the hud exists, open the UI
			if screen.name:find("HUD") then
				-- We want to pass in the (clientside) player entity
				TheFrontEnd:PushScreen(require("screens/spawner")(_g.ThePlayer))
				return true
			else
				-- If the screen is already open, close it
				if screen.name == "spawner" then
					screen:OnClose()
				end
			end
		end
		-- Require CTRL for any debug keybinds
		if _g.TheInput:IsKeyDown(_g.KEY_CTRL) then
			 -- Load latest save and run latest scripts
			if key == _g.KEY_R then
				if _g.TheWorld.ismastersim then
					_g.c_reset()
				else
					_g.TheNet:SendRemoteExecute("c_reset()")
				end
			end
		end
	end)
end)

3. Creating the Screen Skeleton

We need to import some widgets from the base game. These abstract much of the details away while giving us extensive functionality.

local Screen = require "widgets/screen"
local Widget = require "widgets/widget"
local Templates = require "widgets/templates"
local Text = require "widgets/text"
local ImageButton = require "widgets/imagebutton"

Next we create the screen class and register the screen. Replace "spawner" with your screen name.

The screen at this moment is just a translucent black square that covers the entire screen. This creates an effect of dimming the game behind the UI.

local Spawner = Class(Screen, function(self, inst)
  -- Player instance
  self.inst = inst
   -- Any 'DoTaskInTime's or 'DoPeriodicTask's should be assigned in here
   -- These are cancelled upon close to prevent stale components
  self.tasks = {}

  -- If you want to maintain state look into GetModConfigData, Replicas, or TheSim:SetPersistentString

  -- Register screen name
  Screen._ctor(self, "spawner")
    
  -- Darken the game
  -- We're using the DST global assets
  self.black = self:AddChild(Image("images/global.xml", "square.tex"))
  self.black:SetVRegPoint(ANCHOR_MIDDLE)
  self.black:SetHRegPoint(ANCHOR_MIDDLE)
  self.black:SetVAnchor(ANCHOR_MIDDLE)
  self.black:SetHAnchor(ANCHOR_MIDDLE)
  self.black:SetScaleMode(SCALEMODE_FILLSCREEN)
  self.black:SetTint(0, 0, 0, .5)
end

Next we're going to add the OnClose function.

function Spawner:OnClose()
  -- Cancel any started tasks
  -- This prevents stale components
	for k,v in pairs(self.tasks) do
		if v then
			v:Cancel()
		end
	end
  local screen = TheFrontEnd:GetActiveScreen()
  -- Don't pop the HUD
  if screen and screen.name:find("HUD") == nil then
    -- Remove our screen
    TheFrontEnd:PopScreen()
  end
  TheFrontEnd:GetSound():PlaySound("dontstarve/HUD/click_move")
end

Next we're adding some logic to temporarily intercept keypresses to detect ESC.

function Spawner:OnControl(control, down)
  -- Sends clicks to the screen
  if Spawner._base.OnControl(self, control, down) then
    return true
  end
  -- Close UI on ESC
  if down and (control == CONTROL_PAUSE or control == CONTROL_CANCEL) then
    self:OnClose()
    return true
  end
end

Finally, make sure to return the class you just created.

return Spawner

 

Test your UI by pressing T a few times in-game. Your screen should darken on the first press, then return to normal on a second press. If your screen is getting progressively darker that likely means you're not closing the screen.

4. Adding Elements

Now that we have a basic screen we're going to add some elements.

The most important one is the ROOT, all future elements should be a child of this widget. Children of the ROOT will be positioned relative to it.

  -- Set the inital position for all our future elements
  self.proot = self:AddChild(Widget("ROOT"))
  self.proot:SetVAnchor(ANCHOR_MIDDLE)
  self.proot:SetHAnchor(ANCHOR_MIDDLE)
  -- 20 'pixels' to the right - This scales with the window size
  self.proot:SetPosition(20, 0, 0)
  self.proot:SetScaleMode(SCALEMODE_PROPORTIONAL)

Some images are split into smaller images which may be reassembled with a template.

  -- We're using a template for our background
  -- In this case we're calling a function to assemble the pieces of "images/dialogcurly_9slice.xml"
  -- The offsets center it above the player's inventory
  self.bg = self.proot:AddChild(Templates.CurlyWindow(500, 450, 1, 1, 68, -40))

20200505122447-1.jpg

Adding golden header text

  self.title = self.proot:AddChild(Text(NEWFONT_OUTLINE, 40, "Spawn Disaster", {unpack(GOLD)}))
  self.title:SetPosition(0, 250)

Adding animations

Since positioning can be difficult to determine, we've added Text elements that update their position 10x per second.

  self.animationUp = self.proot:AddChild(Text(NEWFONT_OUTLINE, 30, "Y: ", {unpack(RED)}))
  self.animationUp:SetPosition(-520, -350)
  -- Assign the task to the client
  self.tasks[#self.tasks + 1] = self.inst:DoPeriodicTask(.1, function()
    local pos = self.animationUp:GetPosition()
    self.animationUp:SetPosition(pos.x, pos.y > 350 and -350 or pos.y + 5)
    self.animationUp:SetString("Y: " .. pos.y)
  end)

  self.animationRight = self.proot:AddChild(Text(NEWFONT_OUTLINE, 30, "X: ", {unpack(RED)}))
  self.animationRight:SetPosition(-600, -290)
  -- Assign the task to the client
  self.tasks[#self.tasks + 1] = self.inst:DoPeriodicTask(.1, function()
    local pos = self.animationRight:GetPosition()
    self.animationRight:SetPosition(pos.x > 600 and -600 or pos.x + 5, pos.y)
    self.animationRight:SetString("X: " .. pos.x)
  end)

image.png

In this section we'll be adding cards that are defined in disasters.lua. (attached)

We're using the ImageButton class to add functionality upon click and display an icon with the button text. The default position isn't quite centered, so we center the internal elements accordingly.

Images are added by specifying the atlas and tex, note that the tex doesn't include "/images".

  local function createDisasterCard(index, x, y, scale)
    local disaster_card = {}
    disaster_card.fill = self.disasters:AddChild(Image("images/fepanel_fills.xml", "panel_fill_tall.tex"))
    disaster_card.fill:SetPosition(x, y)
    disaster_card.fill:SetScale(.25 * scale, .5 * scale)

    disaster_card.title = self.disasters:AddChild(Text(NEWFONT_OUTLINE, 40,  disasters[index].name or " "))
    disaster_card.title:SetPosition(x, y + 170)
    disaster_card.title:SetScale(scale)

    disaster_card.description = self.disasters:AddChild(Text(NEWFONT_OUTLINE, 25, disasters[index].description or " "))
    disaster_card.description:SetPosition(x, y)
    disaster_card.description:SetScale(scale)
    disaster_card.description:SetRegionSize(175, 350)
    disaster_card.description:EnableWordWrap(true)
    disaster_card.description:EnableWhitespaceWrap(true)

    disaster_card.button = self.disasters:AddChild(ImageButton("images/frontend.xml", "button_long.tex", "button_long_highlight.tex", "button_long_disabled.tex", nil, nil, {1,1}, {0,0}))
    disaster_card.button:SetPosition(x + 5, y - 170)
    disaster_card.button:SetScale(.6 * scale)
    disaster_card.button:SetText("Do it!")
    disaster_card.button.text:SetPosition(-5, 4)
    disaster_card.button:SetFont(BUTTONFONT)
    disaster_card.button.icon = disaster_card.button:AddChild(Image(disasters[index].iconAtlas or "images/global.xml", disasters[index].iconTex or "square.tex"))
    disaster_card.button.icon:SetPosition(-100, 10) -- Left of text
    disaster_card.button.icon:SetScale(scale)
    -- Pass clicks through to the button
    disaster_card.button.icon:SetClickable(false)
    disaster_card.button:SetOnClick(function()
      -- RPC to server
      local modName = KnownModIndex:GetModActualName("Custom UI")
      SendModRPCToServer(GetModRPC(modName, "SpawnDisaster"), index)
    end)
  end

We then use this function to create UI elements based on disasters.lua (attached).

local disasters = require "screens/disasters"
  self.disasters = self.proot:AddChild(Widget("ROOT"))
  self.disasters:SetPosition(-100, 0)
  self.disasters.cards = {}
  for i=1, #disasters do
    self.disasters.cards[i] = createDisasterCard(i, (i - 2) * 220, 0, 1)
  end

The SpawnDisaster RPC is defined in modmain.lua as so:

local disasters = require "screens/disasters"
local modName = _g.KnownModIndex:GetModActualName("Custom UI")
AddModRPCHandler(modName, "SpawnDisaster", function(player, index)
	if index <= #disasters then
	  print("Spawning Disaster " .. (disasters[index].name or "NIL") .. " by " .. (player.name or "NIL"))
		-- Last index is random
		disasters[index == #disasters and math.random(#disasters - 1) or index].activate(player)
	end
end)

image.png

Draw the rest of the owl in disasters.lua (or download it).

Done!

 

If you want something specific, look at the game screen for references. Often Klei builds widgets that are convenient to work with.

 

Please let me know if there's anything I can improve. Best of luck with your user interfaces!

Thanks,

Val

disasters.lua

customUI.zip

Edited by Valericoe
  • Like 8
  • GL Happy 1
Link to comment
Share on other sites

On 10/3/2020 at 3:14 PM, thomas4846 said:

Damn is this still working this looks amazing

Yup, still works.

 

On 12/1/2020 at 3:31 PM, TheSkylarr said:

How do I go about making custom inventory interfaces? I need to have a custom inventory widget for 5 specific items, that are fixed to nothing but those items.

I think the bundling wrap code is a good place to start.

Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
 Share

×
  • Create New...