跪拜 Guibai
← Back to the summary

One Cocos Build, Two Distribution Routes: Interactive Movies on Web H5 and Steam via Electron

Shipping an Interactive Movie Game to Web H5 and Windows/Steam from One Cocos Codebase

Version note: Cocos Creator 2.4.15 (2.x series); Electron 27+; electron-builder 24+. The whole text is based on 2.4.15; 3.x differences are called out where relevant. Content type: advanced solution + pitfall log. Prerequisites: basic Cocos Creator build process and Node.js.

1. Why Cocos + Electron

Interactive movie / FMV games are characterized by pre-rendered video + branch choices + lightweight interaction: assets are mostly video, logic is not complex, but distribution channels are varied — you may want to put it on an official site/CDN for direct browser playback, and also release a Windows version on Steam.

Two seemingly different paths can converge into the same artifact:

Core conclusion: Cocos only needs to produce one web-mobile static directory, and Electron only needs to open it as a webpage on the desktop. There is one codebase; the only difference is who serves this webpage.

flowchart LR
    A[Cocos Creator project] -->|build Web Mobile| B[web-mobile static directory]
    B -->|deploy to server/CDN| C[Web H5 version]
    B -->|Electron local HTTP loading| D[Windows desktop app]
    D -->|submit| E[Steam store Windows version]

2. Cocos side: Build Web Mobile

In Cocos Creator (2.4.15 here), open Project → Build... and choose Web Mobile:

web-mobile/
├── index.html          # entry
├── main.js             # startup script
├── src/                # engine and settings (settings.json, etc.)
├── assets/             # atlases, scripts, config
└── videos/             # your movie videos, put here (recommended as a separate directory)

Key constraint: Cocos loads src/settings.json and other files with fetch/XHR on startup. This means web-mobile cannot be opened directly with the file:// protocol (the browser blocks cross-origin requests when origin is null, causing a blank screen). This directly determines the Electron loading method in Section 3.

3. Electron side: three options for loading local H5

Option A: win.loadFile() (file://) — ⚠️ not recommended for Cocos

mainWindow.loadFile(path.join(__dirname, 'web-mobile/index.html'));

This is the simplest, but loadFile is still the file:// protocol. Cocos's fetch resource loading is blocked by CORS under file://. This option is basically unusable for Cocos output, and is only suitable for pages that are purely static and have no fetch.

Option B: local HTTP server (recommended)

Start a minimal static server in the main process and load it with loadURL('http://127.0.0.1:PORT'). This completely avoids the file:// restriction, and the H5 behavior is identical on desktop.

// main.js
const { app, BrowserWindow } = require('electron');
const http = require('http');
const fs = require('fs');
const path = require('path');
const mime = { '.html':'text/html', '.js':'application/javascript',
  '.json':'application/json', '.mp4':'video/mp4', '.png':'image/png',
  '.wasm':'application/wasm' };

// Locate the web-mobile directory: in dev use the in-project path, after packaging read from resources
function getWebRoot() {
  const dev = path.join(__dirname, 'web-mobile');
  const packed = path.join(process.resourcesPath, 'web-mobile');
  return fs.existsSync(packed) ? packed : dev;
}

function startServer(root) {
  return new Promise(resolve => {
    const server = http.createServer((req, res) => {
      let urlPath = decodeURIComponent(req.url.split('?')[0]);
      if (urlPath === '/') urlPath = '/index.html';
      const file = path.join(root, urlPath);
      if (!file.startsWith(root) || !fs.existsSync(file)) {
        res.writeHead(404); return res.end('Not Found');
      }
      res.writeHead(200, { 'Content-Type': mime[path.extname(file)] || 'application/octet-stream' });
      fs.createReadStream(file).pipe(res);
    });
    server.listen(0, '127.0.0.1', () => resolve(server));
  });
}

async function createWindow() {
  const root = getWebRoot();
  const server = await startServer(root);
  const { port } = server.address();

  const win = new BrowserWindow({
    width: 1280, height: 720,
    webPreferences: { contextIsolation: true, nodeIntegration: false }
  });
  // Interactive movies need autoplay: disable the autoplay gesture restriction
  app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
  win.loadURL(`http://127.0.0.1:${port}/index.html`);
}
app.whenReady().then(createWindow);

3.1 Tell Cocos the local server address (video loading address handling, key point)

Option B is used here. Under this option, video files are served as standalone files by the local server, not through Cocos's asset system. In Cocos 2.4.15, the VideoPlayer component needs an absolute URL based on the local server address to load video (resourceType = REMOTE + remoteURL), otherwise the file cannot be found, or it falls back to asset loading and fails after packaging on desktop.

Because the local server port is dynamically allocated (server.listen(0)), the actual address must be passed to the renderer (Cocos) at runtime. Two recommended ways:

Method 1: inject a global variable through preload (recommended, cleanest)

// preload.js
const base = process.argv.find(a => a.startsWith('--base='))?.split('=')[1];
window.__LOCAL_BASE__ = base || '';

The main process passes the address as an argument when creating the window:

const win = new BrowserWindow({
  width: 1280, height: 720,
  webPreferences: {
    contextIsolation: true, nodeIntegration: false,
    preload: path.join(__dirname, 'preload.js'),
    additionalArguments: [`--base=http://127.0.0.1:${port}`]
  }
});

Method 2: pass it through a URL query

win.loadURL(`http://127.0.0.1:${port}/index.html?base=http://127.0.0.1:${port}`);

On the Cocos side (2.4.15 TypeScript), join the address when initializing the video:

// script on a video node
import { _decorator, Component, VideoPlayer } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('MoviePlayer')
export class MoviePlayer extends Component {
    @property(VideoPlayer) videoPlayer: VideoPlayer = null;

    onLoad() {
        const base = (window as any).__LOCAL_BASE__ || '';   // local server base; empty when deployed as H5
        const url = `${base}videos/ep1.mp4`;                 // the relative path also works on H5
        this.videoPlayer.resourceType = VideoPlayer.ResourceType.REMOTE;
        this.videoPlayer.remoteURL = url;
        this.videoPlayer.play();
    }
}

Key points:

Option C: custom protocol app://

Register a private protocol with protocol.registerFileProtocol. Security is slightly better, but each path must be handled individually, so the complexity is higher than Option B. For serving an entire directory, as in an interactive movie, Option B is more convenient.

Conclusion: use Option B. It makes the desktop and web runtime environments fully equivalent and makes troubleshooting the simplest.

4. Implementation points for interactive movies inside Cocos

  1. Video playback (Cocos 2.4.15): use the VideoPlayer component, set resourceType to REMOTE, and build remoteURL from local server base + relative path (see 3.1). Put video files in web-mobile/videos/ as standalone files served by the local server; do not attach them to components as clip resources, to avoid packing them into assets/asar and preventing streaming on desktop. When deployed as H5, the base is empty and the relative path videos/ep1.mp4 still works; the code is identical on both ends.
  2. Branch choices: use ordinary UI buttons plus scripts to control node visibility and scene transitions; this is the same as a normal Cocos project.
  3. Saving: prefer cc.sys.localStorage; on the Electron desktop side you can also write the save JSON to app.getPath('userData') for cross-session persistence (expose an interface through preload).
  4. First frame and black screen: interactive movies are sensitive to click and play experience; recommend initializing video after the first user gesture, to avoid black screens caused by browser/Electron autoplay policies.

5. Resource strategy: video and the asar trade-off

Interactive movie videos are often hundreds of MB. asar packaging (the default) compresses files into a single archive, which causes:

Recommended approach:

// package.json (key electron-builder fields)
"build": {
  "appId": "com.yourstudio.yourmovie",
  "productName": "Your Interactive Movie",
  "directories": { "output": "dist" },
  "files": ["main.js", "preload.js"],
  "extraResources": [
    { "from": "build/web-mobile", "to": "web-mobile" }
  ],
  "win": {
    "target": ["nsis", "zip"],
    "icon": "build/icon.ico"
  }
}

Put web-mobile (including videos/) entirely into extraResources, outside asar, and let the local server read it with streaming fs calls; this gives the best performance and compatibility. For H5 web deployment, upload the whole web-mobile directory in the same way, and the path semantics are identical.

6. Steam launch points

  1. Entry: the Steam launch option points to the .exe packaged by Electron; no game logic changes are required.
  2. steam_appid.txt: put steam_appid.txt (containing your AppID) in the project root during development for local debugging.
  3. Steamworks integration (optional): for achievements, cloud saves, and friends, you can use Greenworks (a Node wrapper for the Steamworks SDK) from the Electron main/renderer process. A pure narrative interactive movie can also skip integration for now.
  4. Build upload: configure the Depot and Build in the Steamworks console, then use the SDK's ContentBuilder (SteamCMD) to upload the dist/ output (including resources/web-mobile). Two issues need to be solved: how to obtain the key parameters and how to automate multiple branches.

6.1 How to get the key parameters

Parameter Meaning Where to get it
AppID Unique application identifier In the Steamworks console, the appid=xxxx in the URL of the Application Management home page, or the Technical Tools page
DepotID Content repository ID Create and view it under Steamworks console SteamPipe → Depot configuration
Release branch (Branch) Release channel (default/beta/test) Configure in Steamworks console SteamPipe → Branches; password-protected branches are supported
ContentBuilder path Upload tool In the Steamworks SDK under tools/ContentBuilder/ (contains steamcmd.exe and sample scripts)
Release account Steam account with release permission Authorize in the console's User Management; first login requires a Steam Guard one-time code

6.2 Automated multi-branch packaging and build configuration

Steam's multiple branches are distinguished by the SetLive field in app_build_xxxx.vdf. Maintain one app_build configuration per branch (only SetLive differs), then combine them with a single upload script.

depot_build_12345.vdf (content manifest, shared by all branches):

"DepotBuildConfig"
{
    "DepotID" "12345"
    "ContentRoot" "../../dist/win-unpacked"
    "FileExclusion" "*.pdb"
    "IncludeAllRootFiles" "1"
}

app_build_12345_beta.vdf (beta branch release):

"AppBuild"
{
    "AppID" "12345"
    "Desc" "Interactive movie beta"
    "BuildOutput" "../../output"
    "Depots"
    {
        "12345" "depot_build_12345.vdf"
    }
    "SetLive" "beta"
}

(For the default branch, change SetLive to default; same for test.)

Automation chain: use an npm script to string together Cocos command-line build → electron-builder package → SteamCMD upload into one command, and use the STEAM_BRANCH environment variable to choose the branch:

"scripts": {
  "build:h5": "CocosCreator --project . --build platform=web-mobile",
  "build:win": "electron-builder --win nsis",
  "steam:upload": "node scripts/steam-upload.js",
  "release": "npm run build:h5 && npm run build:win && npm run steam:upload"
}

scripts/steam-upload.js selects the corresponding vdf based on the branch and calls SteamCMD:

const { execFileSync } = require('child_process');
const branch = process.env.STEAM_BRANCH || 'default';
const vdf = `scripts/app_build_12345_${branch}.vdf`;
execFileSync('tools/ContentBuilder/builder/steamcmd.exe', [
  '+login', process.env.STEAM_USER, process.env.STEAM_PASS,
  '+run_app_build', `../${vdf}`,
  '+quit'
], { stdio: 'inherit' });

Run it with: STEAM_BRANCH=beta STEAM_USER=xxx STEAM_PASS=yyy npm run release.

Advanced: connect release to GitHub Actions / Jenkins so that when a tag is pushed (for example beta-v1.2), the branch is uploaded automatically, achieving one commit, automatic build and Steam upload.

  1. Size and download: interactive movies are large; it is recommended to configure depot sub-packages and on-demand downloads in the Steam console.

Reference (visual Steam console launch flow): beyond the technical solution, there is a complete illustrated tutorial for Steam store onboarding and console operations (account/tax verification, getting AppID and DepotID, using ContentBuilder to submit builds, generating versions and selecting branches, setting the startup .exe and supported systems, release and pricing, switching test branches, enabling cloud saves, applying for serial numbers, etc.): https://blog.csdn.net/qq_30100043/article/details/158468223. That post complements this one: this text focuses on Electron engineering packaging and multi-branch automated upload, while that one focuses on visual Steamworks console operations; together they cover the full path from packaging to launch.

7. Common problems and pitfalls

Problem Root cause Solution
Blank screen on desktop Loaded with file://; Cocos's fetch is blocked by CORS Switch to the local HTTP server (Option B)
Video cannot play Video is packed into asar and cannot be streamed Externalize with extraResources, keep it out of asar
Autoplay blocked Browser/Electron requires a user gesture by default autoplay-policy=no-user-gesture-required
Development/packaged path mismatch The meaning of __dirname changes inside asar Use process.resourcesPath to locate extraResources
Web and desktop behavior differ Video uses absolute/remote paths Use relative paths consistently for resources
Windows decoding errors H5 depends on the browser decoder; desktop uses the system decoder Output H.264 MP4 uniformly for the best compatibility

8. Pre-release checklist (template)

9. Summary

The Cocos + Electron combination lets interactive movie games build once and distribute to two ends: Cocos produces the web-mobile static directory, the H5 route deploys it directly, and the Windows/Steam route uses Electron to start a local server that loads the same directory. The three key technical points are: reject file:// and use a local HTTP service; put videos and other resources in extraResources outside asar; use relative paths uniformly. With these in place, the same codebase can cover both web and Steam at low cost. The video load address is dynamically constructed by Cocos at runtime from the local server base in remoteURL; in H5 deployment the base is empty and falls back to a relative path, so the two-end logic is identical. In addition, Android and iOS can load the same H5 link through the system WebView (or open web-mobile as a local resource through WebView), extending the boundary of one codebase, multi-end distribution. Welcome to try it at http://www.huyingpai.com/#/home, and the Steam link is: https://store.steampowered.com/app/5129220/_/