reactelectronvite

Lindo - Open Source Emulator for Dofus Touch

By Amaury CIVIER
Picture of the author
Published on
Role
Main Developer
Image de l'interface de Lundo

Implementation

Dofus Touch is a game developed by Ankama, using JavaScript for its graphics engine and packaged with Cordova as a mobile application for iOS and Android. To allow PC players to access the game, I developed an emulator using Electron that recreates the web app environment while adding features like keyboard and mouse support for interacting with the game.

For the emulator interface, the project uses React and the MUI library to implement Material Design components. Vite is employed to bundle the various parts of the application for release. One of Vite's key advantages over Webpack is its configurability, making it suitable for both Node.js (Main Process) and web (Renderer Process) contexts.

The application’s codebase is divided into five packages, each with its own Vite configuration for bundling during the build process.

I18n

Ce package contient l'entièreté des fichiers de langues de l'application. La traduction de l'application est réalisé avec typesafe-i18n qui permet de fournir une api de traduction pour un context node.js (Main-Process) et web (Renderer Process).

// Example of a translation file
const en: BaseTranslation = {
  example: {
    helloWorld: "Hello World",
  }
}
// Example of usage of the traduction inside a React Component
export const ExampleComponent = ({ onSkip, onUnlock }: UnlockFormProps) => {
  // retrieve the i18n context 
  const { LL } = useI18nContext()

  return (
    <div>
      <Typography>{LL.example.helloWorld()}</Typography>
    </div>
  )
}
// Example of usage of the traduction inside a React Component
export const exampleFunction = () => {
  return i18n.example.helloWorld();
}

Main

This package contains all the code executed in a Node.js context (without web rendering), allowing access to native system APIs such as window management, local file handling, and downloading updates.

Preload

This package contains code that is pre-rendered before the main view is loaded.

Renderer

This package includes the interface code executed in a web-rendering context.

Shared

This package contains code shared between the different packages (mainly renderer and main). The shared code primarily consists of MobX models, along with classes, interfaces, and types.

Synchronization Between Game Windows

In Electron, each rendering window and the main process run in separate contexts. One way to facilitate communication between these processes is by using theIPC (Inter-Process Communication) API. However, for managing application state, I use a library called mobx-state-tree, which provides immutable stores similar to redux. These stores are used to manage emulator options.

A challenge arises in synchronizing the stores across processes to ensure they contain identical values. If a value changes in one process's store, other processes must also reflect that change.

To address this, I developed a wrapper around the root store (the main store containing all others) in the main process. This wrapper broadcasts any modifications made to the store to all renderer processes. Additionally, I created a wrapper for the root store in the renderer processes. Whenever a local modification is made to their store, it is sent to the main store. The renderer's wrapper also retrieves the current state of the main process's root store at startup to initialize its own root store.

IPC Communication Diagram Between Main and Renderer Processes
IPC Communication Diagram Between Main and Renderer Processes
// setup-root-store.ts (main process)
export async function setupRootStore(): Promise<RootStore> {
  // create the root store instance
  const rootStore: Instance<typeof RootStoreModel> = RootStoreModel.create({}, env)

  const patchesFromRenderer: Array<string> = []

  // Return the current value of the root store when a renderer is initialized
  ipcMain.on(IPCEvents.INIT_STATE, (event) => {
    event.returnValue = JSON.stringify(getSnapshot(rootStore))
  })

  // When receiving a patch modification from a renderer
  ipcMain.on(IPCEvents.PATCH, (event, patch: IJsonPatch) => {
    // save the patches in a list 
    patchesFromRenderer.push(hash(patch))
    
    // apply the patch to the root store
    applyPatch(rootStore, patch)

    // Forward it to all of the other renderers
    webContents.getAllWebContents().forEach((contents) => {
      
      // Ignore the renderer that sent the action and chromium devtools
      if (contents.id !== event.sender.id && !contents.getURL().startsWith('devtools://')) {
        contents.send(IPCEvents.PATCH, patch)
      }
    })
  })

  // This function is execute every time there is change on the root store
  onPatch(rootStore, (patch) => {
    const patchHash = hash(patch)
    // Check if the modification as already be forward (in the case of a change coming from a renderer process)
    if (patchesFromRenderer.includes(patchHash)) {
      // If the change has already been applied, remove the change from the list and do nothing
      patchesFromRenderer.splice(patchesFromRenderer.indexOf(patchHash), 1)
      return
    }

    // Forward it to all of the other renderers
    webContents.getAllWebContents().forEach((contents) => {
      contents.send(IPCEvents.PATCH, patch)
    })
  })

  return rootStore
}
export async function setupRootStore() {
  // Retrieve the current state of the main process's root store through ipc
  const state = await window.lindoAPI.fetchInitialStateAsync()

  // Create the instance of the root store
  const rootStore: Instance<typeof RootStoreModel> = RootStoreModel.create(state, env)

  // Array that contains all the changes from the main process
  const patchesFromMain: Array<string> = []

  // Subscribe to change coming from the main process
  window.window.lindoAPI.subscribeToIPCPatch((patch: IJsonPatch) => {
    patchesFromMain.push(hash(patch))
    // Apply the change
    applyPatch(rootStore, patch)
  })

  // This function is execute every time there is change on the root store
  onPatch(rootStore, (patch) => {
    const patchHash = hash(patch)
    if (patchesFromMain.includes(patchHash)) {
      patchesFromMain.splice(patchesFromMain.indexOf(patchHash), 1)
      return
    }
    // Forward the local change to main process through ipc
    window.lindoAPI.forwardPatchToMain(patch)
  })

  return rootStore
}

Continuous Integration with GitHub Actions

To automate testing, builds, and deployments of the application across all available platforms (Windows, MacOS, and Linux), I implemented a GitHub workflow. This workflow automates all these tasks every time a new version tag (e.g., v2.2.0) is pushed to the main branch.

Workflow Source Code

Automated Deployment Process Diagram
Automated Deployment Process Diagram
Made with Next.js & Tailwind