This post will quickly show you how to run any standalone web based game on Android
I recently released my game Sippy Disco on Steam and wanted to do a quick port to Android.
I had originally intended to develop a wrapper with Avalonia for all platforms, it was working well on Windows and Mac but ran into some issues getting the web browser to view the local files correctly. I also tried for a brief minute to use Uno Platform but figured I might as well go with Android Studio if I was already doing a new build.
Setting Up the Project
In Android Studio, I created a new project with the “Empty Activity”.
You can use Java or Kotlin, but in my example, I’m using Kotlin and Composition views.
The new project has a file called MainActivity that most of the code will go into.
First we need to add the game files to the project. Copy your entire web game into the assets folder or make one if it does not exist. In the project directory, it will be “app/src/main/assets”
Creating the WebView
Next, we can create the web view with a few extra options:
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebViewScreen() {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = WebViewClient()
settings.loadWithOverviewMode = false
settings.useWideViewPort = false
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.setSupportZoom(false)
overScrollMode = WebView.OVER_SCROLL_NEVER
setInitialScale((context as MainActivity).getScale())
addJavascriptInterface(JsObject(context as MainActivity), "JsObject")
}
},
update = { webView ->
(webView.context as MainActivity).webView = webView
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(webView.context))
.build()
webView.webViewClient = LocalContentWebViewClient(assetLoader)
webView.loadUrl("https://appassets.androidplatform.net/assets/index.html")
}
)
}
The factory section creates the web view and sets some important properties
- loadWithOverviewMode=false & useWideViewPort=false prevents the game from being scaled unnecessarily
- domStorageEnabled=true allows the WebView to access the files you added to the assets folder
- mediaPlaybackRequiresUserGesture = false allows the game to use things like the audio api without having to click on the WebView first
- setSupportZoom(false) prevents the user from zooming in on the WebView
- overScrollMode = WebView.OVER_SCROLL_NEVER prevents scrolling on the webview
We’ll cover the other options in a minute since those require other custom functions.
Before we load the content, we need to create a WebViewClient
private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(request.url)
}
}
In the update section, we can load our game from the assets folder. We use the WebViewAssetLoader and set the path to the assets folder and then load the url.
Scaling the WebView
I designed Sippy Disco to work on large screens and high-resolutions, but on a high-res phone with a small screen, the buttons are very tiny and not very accessible. We can manually set the WebView scaling to any percentage of the original we would like using setInitialScale, but this needs to be flexible based on the device resolution. For instance, Chrome Books have large screens and low resolutions.
To fix this, I wrote a couple of helper functions. First I get the longest size of the device screen:
private fun getScreenLength() : Int {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val windowMetrics = windowManager.currentWindowMetrics
val rect = windowMetrics.bounds
var length = rect.bottom
if(length < rect.right) {
length = rect.right
}
length
} else {
var length = resources.displayMetrics.heightPixels
if(length < resources.displayMetrics.widthPixels) {
length = resources.displayMetrics.widthPixels
}
length
}
}
The above function returns the longest screen size in pixels. Next we can get the preferred scaling based on the screen size:
fun getScale() : Int {
return when(getScreenLength()) {
in 0..1920 -> 100
in 1921..2560 -> 125
in 2561..3840 -> 150
else -> 200
}
}
In my code, I provide 3 levels to keep it simple. I tested this on a variety of devices and this works pretty well.
Communicating with the WebView
There are probably at least a few functions you will need to communicate between the game and the Android App such as closing the app and achievements.
private class JsObject(private val mainActivity: MainActivity) {
@JavascriptInterface
fun receiveMessage(data: String) {
Log.i("JsObject", "postMessage data=$data")
//handle data here
when (data) {
"quit" -> {
// quit the game
mainActivity.finishAndRemoveTask()
}
}
}
}
The above code allows the JavaScript code to communicate to your app. In my example, when the JavaScript sends the “quit” string, I close the app. I also use this for things like achievements, but we can cover that later 😉
To enable this in the WebView, just add this line to the initialization:
addJavascriptInterface(JsObject(context as MainActivity), "JsObject")
Finally, if you want to send a message from JavaScript, just run this code:
if(typeof JsObject !== 'undefined') {
JsObject.receiveMessage('Your message here');
}
And if you need to send code to the Javascript, you can use the evaluateJavascript function to run any code:
webView.evaluateJavascript("Game.GoBack()", null)
Putting it all together
Now we just need to initialize everything in the Create method of the MainActivity
lateinit var webView: WebView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val windowInsetsController =
WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
setContent {
SippyDiscoTheme {
// A surface container using the 'background' color from
// the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.primary
) {
WebViewScreen()
}
}
}
}
The above first makes the app Fullscreen by hiding the system bars, then initializes our WebView!
Leave a Reply