Stretch to fit and why you really should never use it
This is the main problem I see with a lot of BYOND games. In the best case scenario, it causes graphics to look stretched an creates pixel artifacts in the outline of objects. In the worst-case scenario, on certain graphics cards, it makes the entire viewport blurry because certain graphics cards don't seem to kick into nearest-neighbor interpolation.
You can see the effect of this problem in this thread: http://www.byond.com/ forum/?post=1673451&hl=blurry#comment11919097
To avoid using this feature, you need to understand a few basic values and calculations.
icon_size: your world's icon_size determines the dimensions of a single tile in your world. This will influence the final dimensions of your view_size. It uses the format: "[TILE_WIDTH]x[TILE_HEIGHT]", or if both width and height are the same, just a single number in pixels.
view: your world/client's view size determines the number of tiles that are shown in the viewport at any one time. This does not have to perfectly match the size of your map element, as will be explained later. View is formatted as a single integer number of tiles, or a text string "[VIEW_WIDTH]x[VIEW_HEIGHT]".
map dimensions: The dimensions of the map element will be set in pixels. You can use this value to determine the ideal view size.
Inner offset: The inner offset dimension is a pixel value that's calculated by determining how far beyond the edge of the map element's dimensions the viewport will extend. This is a useful value for later, as it will allow you to calculate the position of HUD elements that should be displayed according to the edge of the screen.
Now, let's run over map elements in the interface and explain a few of their properties before we get started explaining some calculations you will need to make your game not look like crap within the viewport:
When you are first setting up your project, you need to know the icon_size for your project. Never use stretch to fit. Period. Just don't. Pixel art needs to use specific scaling ratios with integer constraints to look good, so you need to make certain you use the correct constraints. Explicitly declare the icon_size for your project in this box. That's all you need to do.
Now, let's talk about ideal resolutions a bit.
In order to calculate your ideal resolution, you need to understand common screen resolutions. Check out the steam hardware survey:
http://store.steampowered.com/hwsurvey/
The three most common resolutions are (26.43% marketshare) 1366x768 (widescreen laptop), (34.02% marketshare) 1920x1080 (1080p monitor, this is the standard these days), and (7.55% marketshare) 1600x900 (high definition widescreen laptop).
27.82% of all steam users have a 1920x1080 desktop resolution with two monitors. The largest majority of multi-monitor displays (27.8%) use this setup. No other setup is even close to the popularity of two 1080p monitors.
Now, it's also good to remember that most BYOND users are using low-end windows 8 laptops and extremely outdated desktop machines. It's good to keep this in mind, but do not tie your high-end users to your low-end users. You should not be targeting your game to the lowest common denominator, because in the end it's going to drive your power-users away from your product.
Now that that's been said, let's talk about some common equations that you need to understand to set up your game for ideal display settings.
#define floor(x) round(x)
#define ceil(x) (-round(-x))
If you don't use the above preprocessor definitions in your projects, you should start. Floor and Ceil are antonyms of one another. They deal with rounding numbers to an integer value.
Picture a number line:
BYOND's round() function is essentially a floor() function, except it allows you to provide a second argument to round to a particular decimal value. Now, a number line has two directions. Left, and right. Numbers to the left are always smaller than numbers to the right, and numbers to the right are always larger than numbers to the left. There are an infinite number of values on the number line. In between any two numbers, there are an infinite number of values that are greater than the smaller number in the sequence, and smaller than the larger number in the sequence.
Floor() will take any decimal value provided to it, and return the nearest integer to the left of the value provided, assuming it is not already an integer.
floor(0.1) = 0
floor(5.5) = 5
floor(6.1) = 6
floor(-6.1) = -7
floor(-0.5) = -1
This may seem strange to you that -6.5 rounds to -7, but that's because -6 is right of -6.5, and thus greater. The floor function always rounds to the left.
The ceil function is the opposite. It rounds to the right, and thus always rounds up.
ceil(0.1) = 1
ceil(5.5) = 6
ceil(6.1) = 7
ceil(-6.1) = -6
ceil(-0.5) = 0
Floor() and Ceil() are extremely useful functions. Get familiar with them, because if you continue your programming career beyond BYOND (lol), you are going to keep seeing them.
Important calculations:
Figuring out view dimensions from the map element's resolution:
view_width = ceil(map_width/tile_width)
view_height = ceil(map_height/tile_height)
This will result in the view potentially being larger than the map element's dimensions. This is a good thing. Don't worry. If you have HUD elements, you are going to need to account for this extra space, or your HUD elements will start disappearing off the edge of the map. I call this extra space the buffer.
Calculating the buffer sizes:
buffer_width = floor((view_width*tile_width - map_width)/2)
buffer_height = floor((view_height*tile_height - map_height)/2)
This is the minimum amount of information you need to calculate an ideal resolution.
Let's look at fine-tuning this to get around some problems in the next section.
View is too large
BYOND's viewport has some limitations. You want to keep within a certain range of tiles in the viewport at once. You ideally want to keep it below 5000 tiles in view, but keeping it even lower than that is advisable. You can tweak the maximum number of tiles in view to your specific project all you want. I recommend between 600 and 1000 tiles in the viewport any given time, but the specific needs of your game will dominate what you set those values to.
#define MAX_VIEW_TILES 800
map_zoom = 1
view_width = ceil(map_width/tile_width)
view_height = ceil(map_height/tile_height)
while(view_width*view_height>MAX_VIEW_TILES)
view_width = ceil(map_width/tile_width/++map_zoom)
view_height = ceil(map_height/tile_height/map_zoom)
buffer_width = floor((view_width*tile_width - map_width/map_zoom)/2)
buffer_height = floor((view_height*tile_height - map_height/map_zoom)/2)
The above example shows you how to calculate all the variables you need to use to work with a viewport accounting for the maximum number of tiles in the viewport. This example will increase the zoom level of the map if we exceed the maximum number of tiles and then recalculate the ideal view sizes again.
There's also another variant of this approach that I use myself. I prefer to keep the player centered in the viewport. This means that you are going to want to have a view width and height that are always odd.
map_zoom = 1
view_width = ceil(map_width/tile_width)
if(!(view_width%2)) ++view_width
view_height = ceil(map_height/tile_height)
if(!(view_height%2)) ++view_height
while(view_width*view_height>MAX_VIEW_TILES)
view_width = ceil(map_width/tile_width/++map_zoom)
if(!(view_width%2)) ++view_width
view_height = ceil(map_height/tile_height/map_zoom)
if(!(view_height%2)) ++view_height
buffer_width = floor((view_width*tile_width - map_width/map_zoom)/2)
buffer_height = floor((view_height*tile_height - map_height/map_zoom)/2)
Making it all work:
To get this all working, there are two ways you could set this up: You could perform all of the view resizing and whatnot on the client-side using Javascript, or you could perform it on the server-side. Let's show you the server-side variant first:
#define TILE_WIDTH 32
#define TILE_HEIGHT 32
#define MAX_VIEW_TILES 800
world
icon_size = 32
client
var
view_width
view_height
buffer_x
buffer_y
map_zoom
verb
onResize()
set hidden = 1
set waitfor = 0
var/sz = winget(src,"map1","size")
var/map_width = text2num(sz)
var/map_height = text2num(copytext(sz,findtext(sz,"x")+1,0))
map_zoom = 1
view_width = ceil(map_width/TILE_WIDTH)
if(!(view_width%2)) ++view_width
view_height = ceil(map_height/TILE_HEIGHT)
if(!(view_height%2)) ++view_height
while(view_width*view_height>MAX_VIEW_TILES)
view_width = ceil(map_width/TILE_WIDTH/++map_zoom)
if(!(view_width%2)) ++view_width
view_height = ceil(map_height/TILE_HEIGHT/map_zoom)
if(!(view_height%2)) ++view_height
buffer_x = floor((view_width*tile_width - map_width/map_zoom)/2)
buffer_y = floor((view_height*tile_height - map_height/map_zoom)/2)
src.view = "[view_width]x[view_height]"
winset(src,"map1","zoom=[map_zoom];")
mob
Login()
client.onResize()
return ..()
Now let's just make one small change to the interface:
You will also want to set the map up in such a way that it's anchored, and will grow with your interface appropriately if it's resized by the user. You can do this through the "Anchors" tab in the interface editor.
The client-side approach:
This approach will look fairly similar. The only difference, is we're going to move a bunch of the business end of handling resizing to the client-side. That means that you server won't have to do the work of calculating stuff. This also means that while the user is resizing the window, it will update the size of the screen on the fly rather than just when he's done like normal BYOND onResize events.
#define TILE_WIDTH 32
#define TILE_HEIGHT 32
#define MAX_VIEW_TILES 800
world
icon_size = 32
fps = 40
client
var
view_width
view_height
buffer_x
buffer_y
map_zoom
browser_loaded = 0
verb
onLoad()
set hidden = 1
browser_loaded = 1
src << output(null,"browser1:CenterWindow")
onResize(VW as num,VH as num,BX as num,BY as num,Z as num)
set hidden = 1
if(VW*VH>MAX_VIEW_TILES) return
view_width = VW
view_height = VH
buffer_x = BX
buffer_y = BY
map_zoom = Z
view = "[VW]x[VH]"
New()
spawn()
while(!browser_loaded)
src << browse('mapbrowser.html',"window=browser1")
sleep(50)
..()
mapbrowser.html:
<HTML>
<BODY>
</BODY>
<SCRIPT type="text/javascript">
var map_width;
var map_height;
var TILE_WIDTH = 32;
var TILE_HEIGHT = 32;
var MAX_VIEW_TILES = 800;
function CallVerb() {
var locstr = "byond://winset?command=" + arguments[0];
for(var count=1;count<arguments.length;count++) {
locstr += " " + encodeURIComponent(arguments[count]);
}
window.location = locstr;
}
function WinSet() {
var locstr = "byond://winset?id=" + arguments[0];
for(var count=1;count<arguments.length;count+=2) {
locstr += "&" + arguments[count] + "=" + arguments[count+1];
}
window.location = locstr;
}
function Output() {
window.location = "byond://winset?command=.output " + arguments[0] + " " + encodeURIComponent(arguments[1]);
}
window.onresize = function() {
var body = document.getElementsByTagName('body')[0];
map_width = body.clientWidth;
map_height = body.clientHeight;
var map_zoom = 1;
var view_width = Math.ceil(map_width/TILE_WIDTH);
if(!(view_width%2)) ++view_width;
var view_height = Math.ceil(map_height/TILE_HEIGHT);
if(!(view_height%2)) ++view_height;
while(view_width*view_height>MAX_VIEW_TILES) {
view_width = Math.ceil(map_width/TILE_WIDTH/++map_zoom);
if(!(view_width%2)) ++view_width;
view_height = Math.ceil(map_height/TILE_HEIGHT/map_zoom);
if(!(view_height%2)) ++view_height;
}
var buffer_x = Math.floor((view_width*TILE_WIDTH - map_width/map_zoom)/2);
var buffer_y = Math.floor((view_height*TILE_HEIGHT - map_height/map_zoom)/2);
WinSet("map1","zoom",map_zoom);
CallVerb("onResize",view_width,view_height,buffer_x,buffer_y,map_zoom);
};
window.onload = function() {
CallVerb("onLoad");
};
var isfullscreen = 0;
function ToggleFullscreen() {
if(isfullscreen) {
WinSet("default","titlebar","true","is-maximized","false","can-resize","true");
isfullscreen = 0;
} else {
WinSet("default","titlebar","false")
WinSet("default","is-maximized","true","can-resize","false");
isfullscreen = 1;
}
}
var resolution_x;
var resolution_y;
function CenterWindow() {
window.location = "byond://winget?callback=CenterWindowCallback&id=default&property=size";
}
function CenterWindowCallback(properties) {
var win_width = properties.size.x;
var win_height = properties.size.y;
resolution_x = screen.width;
resolution_y = screen.height;
WinSet("default","pos",Math.floor((resolution_x-win_width)/2) + "," + Math.floor((resolution_y-win_height)/2));
}
</SCRIPT>
</HTML>
Now, setting up your interface to work with this requires just a few modifications. First, let's add a macro:
Now, you also need to set up your main view in such a way that there is a browser element named "browser1" hidden underneath your map element. They should have the same x/y location and width/height. They should also have the exact same anchor values. This is important. Don't mess it up.
Make sure to set the map to your icon size!
That's actually all you need to do for setting up the interface to work with this approach.
Next, we're going to talk about setting up your HUD objects a bit:
Dealing with objects on the screen
Let's face it, BYOND's screen_loc variable kind of sucks. You can, however make it suck a little less if you use it to your advantage.
Let's take a look at some neat stuff nobody ever told you you could do:
screen_loc = "1:400,1:200"
You can use >tile_size pixel offsets in either direction to get pixel perfect positioning of elements without even worrying about doing all that tile/pixel math crap we always waste our time doing. Don't use the tile:pixel format. It's not worth the time/effort. Use 1:pixel format.
Did you know you could use anchors to set an object's screen location too? Check this out:
screen_loc = "WEST+0:320,NORTH+0:-400"
In this example, the screen object is 320 pixels to the left of the west-most tile in the view, and 400 pixels south from the northernmost tile in the view. Cool, right?
Here are the strings you can use as anchors:
WEST, SOUTH, NORTH, EAST, CENTER
When you use a dynamic screen size, you need to remember that objects on the screen need to be anchored to one of the above values in order to make sure that changing the screen size doesn't impact the player's ability to see interface elements. You also have to remember that my approach allows the edge of the viewport to be outside of the map's viewable area, so you also need to account for this. Here's a really quick snippet that will let you account for it all:
#define HUD_LAYER 10
hudobj
parent_type = /obj
layer = HUD_LAYER
var
client/client
anchor_x = "WEST"
anchor_y = "SOUTH"
screen_x = 0
screen_y = 0
width = TILE_WIDTH
height = TILE_HEIGHT
proc
setSize(W,H)
width = W
height = H
if(anchor_x!="WEST"||anchor_y!="SOUTH")
updatePos()
setPos(X,Y,AnchorX="WEST",AnchorY="SOUTH")
screen_x = X
anchor_x = AnchorX
screen_y = Y
anchor_y = AnchorY
updatePos()
updatePos()
var/ax
var/ay
var/ox
var/oy
switch(anchor_x)
if("WEST")
ax = "WEST+0"
ox = screen_x + client.buffer_x
if("EAST")
if(width>TILE_WIDTH)
var/tx = ceil(width/TILE_WIDTH)
ax = "EAST-[tx-1]"
ox = tx*TILE_WIDTH - width - client.buffer_x + screen_x
else
ax = "EAST+0"
ox = TILE_WIDTH - width - client.buffer_x + screen_x
if("CENTER")
ax = "CENTER+0"
ox = floor((TILE_WIDTH - width)/2) + screen_x
switch(anchor_y)
if("SOUTH")
ay = "SOUTH+0"
oy = screen_y + client.buffer_y
if("NORTH")
if(height>TILE_HEIGHT)
var/ty = ceil(height/TILE_HEIGHT)
ay = "NORTH-[ty-1]"
oy = ty*TILE_HEIGHT - height - client.buffer_y + screen_y
else
ay = "NORTH+0"
oy = TILE_HEIGHT - height - client.buffer_y + screen_y
if("CENTER")
ay = "CENTER+0"
oy = floor((TILE_HEIGHT - height)/2) + screen_y
screen_loc = "[ax]:[ox],[ay]:[oy]"
show()
updatePos()
client.screen += src
hide()
client.screen -= src
New(loc=null,client/Client,list/Params,show=1)
client = Client
for(var/v in Params)
vars[v] = Params[v]
if(show) show()
Now, all you have to do is inform the screen objects when the client's view size has changed:
client
verb
onResize(VW as num,VH as num,BX as num,BY as num,Z as num)
set hidden = 1
if(VW*VH>MAX_VIEW_TILES) return
view_width = VW
view_height = VH
buffer_x = BX
buffer_y = BY
map_zoom = Z
view = "[VW]x[VH]"
for(var/hudobj/h in screen)
h.updatePos()
Using these little guys is really simple:
client
var
hudobj/topleft
hudobj/topright
hudobj/bottomleft
hudobj/bottomright
hudobj/center
New()
spawn()
while(!browser_loaded)
src << browse('mapbrowser.html',"window=browser1")
sleep(50)
topleft = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="WEST",anchor_y="NORTH"),1)
topright = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="EAST",anchor_y="NORTH"),1)
bottomleft = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="WEST",anchor_y="SOUTH"),1)
bottomright = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="EAST",anchor_y="SOUTH"),1)
center = new/hudobj(null,src,list(icon='testicon.dmi',anchor_x="CENTER",anchor_y="CENTER"),1)
..()
Del()
//get in the habit of cleaning up circular references
topleft = null
topright = null
bottomleft = null
bottomright = null
center = null
screen = list()
..()
And that's really all there is to it! Making your interface dynamic and responsive is absolutely imperative to creating a polished product that people outside of BYOND will immediately recognize as a real game. The more presentable and user-friendly your design is, the better received your final product will be. Every bit of clunk in your interface is going to turn people away the moment they start playing your game, so learning to do simple stuff like this to make your game more accessible and responsive is only going to help you in the long run.
As always, questions and comments below! Cheers!