Porting Web Game to Android

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!


Posted

in

,

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *