diff --git a/.gitignore b/.gitignore index 6ec44db..48eab00 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ release/ dist/ .cache/ +.home/ # Reconstructed reproducibly from the pinned MiniDayZ Plus upstream commit. # Keep game changes as explicit patches instead of silently vendoring generated files. diff --git a/CHANGELOG.md b/CHANGELOG.md index 89835e3..0174caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable desktop-port changes are documented here. The embedded game remains MiniDayZ Plus 1.2. +## [0.2.0] - 2026-07-14 + +### Added + +- Complete desktop action bindings: WASD and arrow movement, inventory, interaction, combat, reload, weapon selection, aiming, perks, flare, talk, pause, and vehicle actions. +- Mouse aliases for aiming, cycling weapons, and interaction while preserving the game's original left-click behavior. +- An in-game `F1` controls card. +- Deterministic patching of the pinned Construct 2 event sheet by cloning the original guarded touch actions as keyboard actions. +- Automated checks for every injected binding, forced WASD mode, patch metadata, and renderer-side control installation. + +### Changed + +- Desktop builds now force the native MiniDayZ WASD movement group and remember `WASD` as the selected control scheme. +- Legacy Construct 2 service-worker caches are cleared without touching save storage, preventing older embedded game data from shadowing a desktop update. +- Windows packaging removes a redundant base `electron.exe` when cross-building, reducing the portable executable without changing runtime files. + ## [0.1.0] - 2026-07-14 ### Added diff --git a/README.md b/README.md index 47c2edf..fd24274 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A reproducible Windows desktop port of **MiniDayZ Plus 1.2**. The game runs loca ## Current release -- Desktop port: **v0.1.0** +- Desktop port: **v0.2.0** - Game build: **MiniDayZ Plus 1.2** - Platform: Windows 10/11 x64 - Package: portable `.exe` (no installer required) @@ -15,10 +15,26 @@ Download the newest executable from [GitHub Releases](https://github.com/lestx05 ## Controls and desktop behavior -- `F11`: toggle full screen -- `Alt+F4`: close the game -- Launch with `--windowed`: start in a resizable 4:3 window -- Only one instance runs at a time +| Input | Action | +| --- | --- | +| `WASD` / arrow keys | Move or drive | +| `Space` | Attack, fire, or fire from a vehicle | +| `F` | Alternate attack | +| `E` | Interact, pick up, or leave a vehicle | +| `R` | Reload | +| `Q` / middle mouse button | Cycle weapon | +| `1` / `2` / `3` | Select melee / primary firearm / pistol | +| `Tab` | Open or close inventory | +| `X` / right mouse button | Toggle aim | +| `P` | Open perks and status | +| `G` | Use the equipped flare | +| `T` | Talk | +| `Esc` | Pause and options | +| `F1` | Show or hide the in-game controls card | +| `F11` | Toggle full screen | +| `Alt+F4` | Close the game | + +The left mouse button keeps the original touch behavior for menus, inventory, and on-screen controls. Launch with `--windowed` to start in a resizable 4:3 window; only one instance runs at a time. Game saves use a stable private origin under Electron's `%APPDATA%\MiniDayZ PC` profile, so they persist between launches and desktop-port updates. @@ -34,7 +50,7 @@ pnpm install --frozen-lockfile pnpm start ``` -`pnpm start` downloads the pinned upstream MiniDayZ Plus files on first use and verifies the archive's SHA-256 checksum before extracting it. Later runs reuse the verified local copy. +`pnpm start` downloads the pinned upstream MiniDayZ Plus files on first use, verifies the archive's SHA-256 checksum, and applies the versioned desktop-control patch. Later runs reuse the verified local copy and reapply the patch idempotently. Useful commands: diff --git a/desktop/game-controls.js b/desktop/game-controls.js new file mode 100644 index 0000000..1463c79 --- /dev/null +++ b/desktop/game-controls.js @@ -0,0 +1,235 @@ +'use strict'; + +(function createDesktopControls(root, factory) { + const api = factory(); + + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + + if (root?.document) { + root.MiniDayZPCControls = api; + api.install(root); + } +}(typeof globalThis === 'undefined' ? this : globalThis, () => { + const VERSION = '0.2.0'; + const PATCH_BINDING_COUNT = 17; + const ARROW_ALIASES = Object.freeze({ + ArrowDown: { code: 'KeyS', key: 's', keyCode: 83 }, + ArrowLeft: { code: 'KeyA', key: 'a', keyCode: 65 }, + ArrowRight: { code: 'KeyD', key: 'd', keyCode: 68 }, + ArrowUp: { code: 'KeyW', key: 'w', keyCode: 87 }, + }); + const MOUSE_ALIASES = Object.freeze({ + 1: { code: 'KeyQ', key: 'q', keyCode: 81 }, + 2: { code: 'KeyX', key: 'x', keyCode: 88 }, + 3: { code: 'KeyQ', key: 'q', keyCode: 81 }, + 4: { code: 'KeyE', key: 'e', keyCode: 69 }, + }); + const PREVENTED_KEYS = new Set([ + ' ', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Tab', + ]); + const HELP_ROWS = Object.freeze([ + ['WASD / flechas', 'Moverse o conducir'], + ['Espacio', 'Atacar / disparar'], + ['F', 'Ataque alternativo'], + ['E', 'Interactuar, recoger o salir del vehículo'], + ['R', 'Recargar'], + ['Q / botón central', 'Cambiar de arma'], + ['1 / 2 / 3', 'Cuerpo a cuerpo / arma principal / pistola'], + ['Tab', 'Abrir o cerrar inventario'], + ['X / clic derecho', 'Activar o desactivar apuntado'], + ['P', 'Ventana de ventajas y estado'], + ['G', 'Usar bengala equipada'], + ['T', 'Hablar'], + ['Esc', 'Pausa y opciones'], + ['F11', 'Pantalla completa'], + ['F1', 'Mostrar u ocultar esta ayuda'], + ]); + + function isTypingTarget(target) { + const tagName = target?.tagName?.toLowerCase(); + return target?.isContentEditable || tagName === 'input' || tagName === 'select' || tagName === 'textarea'; + } + + function createKeyboardEvent(windowObject, type, binding, repeat = false) { + const event = new windowObject.KeyboardEvent(type, { + bubbles: true, + cancelable: true, + code: binding.code, + key: binding.key, + repeat, + }); + Object.defineProperties(event, { + keyCode: { configurable: true, get: () => binding.keyCode }, + which: { configurable: true, get: () => binding.keyCode }, + }); + return event; + } + + function dispatchKeyboard(windowObject, type, binding, repeat = false) { + windowObject.document.dispatchEvent(createKeyboardEvent(windowObject, type, binding, repeat)); + } + + function createHelpOverlay(documentObject) { + const overlay = documentObject.createElement('section'); + overlay.id = 'minidayz-pc-controls-help'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-label', 'Controles de MiniDayZ PC'); + Object.assign(overlay.style, { + background: 'rgba(8, 12, 10, 0.94)', + border: '1px solid #80906a', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.65)', + color: '#f1f1df', + display: 'none', + font: '14px/1.35 Arial, sans-serif', + left: '50%', + maxHeight: 'calc(100vh - 48px)', + maxWidth: 'min(620px, calc(100vw - 48px))', + overflow: 'auto', + padding: '18px 22px', + pointerEvents: 'none', + position: 'fixed', + top: '50%', + transform: 'translate(-50%, -50%)', + width: '520px', + zIndex: '2147483647', + }); + + const title = documentObject.createElement('h2'); + title.textContent = `MiniDayZ PC ${VERSION} — Controles`; + Object.assign(title.style, { fontSize: '18px', margin: '0 0 12px' }); + overlay.append(title); + + const table = documentObject.createElement('table'); + Object.assign(table.style, { borderCollapse: 'collapse', width: '100%' }); + for (const [key, action] of HELP_ROWS) { + const row = documentObject.createElement('tr'); + const keyCell = documentObject.createElement('th'); + const actionCell = documentObject.createElement('td'); + keyCell.textContent = key; + actionCell.textContent = action; + Object.assign(keyCell.style, { + color: '#d7dc8a', + padding: '3px 14px 3px 0', + textAlign: 'left', + whiteSpace: 'nowrap', + }); + Object.assign(actionCell.style, { padding: '3px 0' }); + row.append(keyCell, actionCell); + table.append(row); + } + overlay.append(table); + + const footer = documentObject.createElement('p'); + footer.textContent = 'El clic izquierdo conserva la interacción táctil original para menús e inventario.'; + Object.assign(footer.style, { color: '#b9b9aa', margin: '12px 0 0' }); + overlay.append(footer); + documentObject.body.append(overlay); + return overlay; + } + + function install(windowObject) { + if (windowObject.__MINIDAYZ_PC_CONTROLS__?.installed) { + return windowObject.__MINIDAYZ_PC_CONTROLS__; + } + + const documentObject = windowObject.document; + let overlay = null; + let overlayVisible = false; + const mouseButtonsDown = new Set(); + + function setOverlayVisible(visible) { + overlay ??= createHelpOverlay(documentObject); + overlayVisible = visible; + overlay.style.display = visible ? 'block' : 'none'; + } + + documentObject.addEventListener('keydown', (event) => { + if (event.key === 'F1') { + event.preventDefault(); + event.stopImmediatePropagation(); + setOverlayVisible(!overlayVisible); + return; + } + + if (event.key === 'Escape' && overlayVisible) { + event.preventDefault(); + event.stopImmediatePropagation(); + setOverlayVisible(false); + return; + } + + if (isTypingTarget(event.target)) { + return; + } + + const alias = ARROW_ALIASES[event.key]; + if (alias) { + event.preventDefault(); + dispatchKeyboard(windowObject, 'keydown', alias, event.repeat); + } else if (PREVENTED_KEYS.has(event.key)) { + event.preventDefault(); + } + }, true); + + documentObject.addEventListener('keyup', (event) => { + if (isTypingTarget(event.target)) { + return; + } + const alias = ARROW_ALIASES[event.key]; + if (alias) { + event.preventDefault(); + dispatchKeyboard(windowObject, 'keyup', alias); + } + }, true); + + documentObject.addEventListener('contextmenu', (event) => { + if (event.target?.closest?.('#c2canvas, #c2canvasdiv')) { + event.preventDefault(); + } + }, true); + + documentObject.addEventListener('mousedown', (event) => { + const binding = MOUSE_ALIASES[event.button]; + if (!binding || !event.target?.closest?.('#c2canvas, #c2canvasdiv')) { + return; + } + event.preventDefault(); + mouseButtonsDown.add(event.button); + dispatchKeyboard(windowObject, 'keydown', binding); + }, true); + + documentObject.addEventListener('mouseup', (event) => { + const binding = MOUSE_ALIASES[event.button]; + if (!binding || !mouseButtonsDown.delete(event.button)) { + return; + } + event.preventDefault(); + dispatchKeyboard(windowObject, 'keyup', binding); + }, true); + + const status = Object.freeze({ + bindings: PATCH_BINDING_COUNT, + installed: true, + version: VERSION, + }); + Object.defineProperty(windowObject, '__MINIDAYZ_PC_CONTROLS__', { + configurable: false, + enumerable: false, + value: status, + writable: false, + }); + return status; + } + + return Object.freeze({ + ARROW_ALIASES, + HELP_ROWS, + MOUSE_ALIASES, + PATCH_BINDING_COUNT, + VERSION, + createKeyboardEvent, + install, + }); +})); diff --git a/desktop/main.js b/desktop/main.js index b7381fc..b55bda7 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -14,6 +14,9 @@ const { GAME_SCHEME, createAssetResponse, } = require('./game-protocol'); +const { version: DESKTOP_VERSION } = require('../package.json'); + +const DESKTOP_BINDING_COUNT = 17; const GAME_URL = `${GAME_SCHEME}://${GAME_HOST}/index.html`; const GAME_ROOT = path.join(__dirname, '..', 'docs'); @@ -25,7 +28,6 @@ protocol.registerSchemesAsPrivileged([ { scheme: GAME_SCHEME, privileges: { - allowServiceWorkers: true, bypassCSP: false, corsEnabled: true, secure: true, @@ -53,7 +55,7 @@ function finishSmokeTest(error) { console.error(`[smoke-test] ${error.stack || error}`); app.exit(1); } else { - console.log('[smoke-test] Construct 2 canvas and runtime loaded successfully.'); + console.log('[smoke-test] Construct 2 runtime and MiniDayZ PC controls loaded successfully.'); app.exit(0); } } @@ -165,8 +167,14 @@ function createMainWindow() { const assetResponse = await fetch('config.xml', { cache: 'no-store' }); const assetText = await assetResponse.text(); + const controlsResponse = await fetch('.minidayz-desktop-patch.json', { cache: 'no-store' }); + const controlsPatch = controlsResponse.ok ? await controlsResponse.json() : null; return { assetLoaded: assetResponse.ok && assetText.includes('com.bistudio.minidayz.plus'), + controlsBindingCount: window.__MINIDAYZ_PC_CONTROLS__?.bindings, + controlsInstalled: window.__MINIDAYZ_PC_CONTROLS__?.installed === true, + controlsVersion: window.__MINIDAYZ_PC_CONTROLS__?.version, + controlsPatch, hasCanvas: Boolean(canvas), hasRuntime: typeof window.cr_createRuntime === 'function', hasRuntimeInstance: Boolean(canvas?.c2runtime), @@ -176,6 +184,12 @@ function createMainWindow() { if ( !result.assetLoaded + || result.controlsBindingCount !== DESKTOP_BINDING_COUNT + || !result.controlsInstalled + || result.controlsVersion !== DESKTOP_VERSION + || result.controlsPatch?.version !== DESKTOP_VERSION + || result.controlsPatch?.bindingCount !== DESKTOP_BINDING_COUNT + || result.controlsPatch?.movement !== 'WASD' || !result.hasCanvas || !result.hasRuntime || !result.hasRuntimeInstance @@ -206,7 +220,20 @@ function createMainWindow() { } }); - void window.loadURL(GAME_URL); + // The packaged game is already fully local. Clear legacy Construct 2 offline + // workers/caches so an update can never shadow a newer embedded data.js. + void window.webContents.session.clearStorageData({ + storages: ['serviceworkers', 'cachestorage'], + }).then(() => window.loadURL(GAME_URL)).catch((error) => { + if (isSmokeTest) { + finishSmokeTest(error); + } else { + dialog.showErrorBox('MiniDayZ PC could not prepare local game files', error.stack || String(error)); + } + if (!window.isDestroyed()) { + window.close(); + } + }); return window; } diff --git a/package.json b/package.json index 850050a..01bbc8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minidayz-pc", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Reproducible Windows desktop port of MiniDayZ Plus 1.2.", "main": "desktop/main.js", @@ -9,6 +9,7 @@ "packageManager": "pnpm@11.7.0", "scripts": { "sync:game": "node scripts/sync-game-assets.mjs", + "patch:game": "node scripts/patch-game-assets.mjs", "start": "pnpm run sync:game && electron .", "start:windowed": "pnpm run sync:game && electron . --windowed", "smoke": "pnpm run sync:game && electron . --smoke-test", @@ -21,6 +22,8 @@ "productName": "MiniDayZ PC", "asar": true, "compression": "maximum", + "afterPack": "./scripts/after-pack.cjs", + "afterSign": "./scripts/after-pack.cjs", "npmRebuild": false, "directories": { "output": "release" diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs new file mode 100644 index 0000000..d3cda3f --- /dev/null +++ b/scripts/after-pack.cjs @@ -0,0 +1,38 @@ +'use strict'; + +const { access, rm } = require('node:fs/promises'); +const path = require('node:path'); + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function afterPack(context) { + if (context.electronPlatformName !== 'win32') { + return; + } + + const productExecutable = path.join( + context.appOutDir, + `${context.packager.appInfo.productFilename}.exe`, + ); + const baseExecutable = path.join(context.appOutDir, 'electron.exe'); + + if ( + productExecutable.toLowerCase() !== baseExecutable.toLowerCase() + && await exists(productExecutable) + && await exists(baseExecutable) + ) { + await rm(baseExecutable); + console.log('Removed redundant electron.exe from the Windows package.'); + } +} + +module.exports = afterPack; +module.exports.afterPack = afterPack; +module.exports.default = afterPack; diff --git a/scripts/desktop-control-bindings.mjs b/scripts/desktop-control-bindings.mjs new file mode 100644 index 0000000..d5cc49f --- /dev/null +++ b/scripts/desktop-control-bindings.mjs @@ -0,0 +1,164 @@ +export const DESKTOP_CONTROLS_VERSION = '0.2.0'; +export const DESKTOP_EVENT_GROUP = 'Desktop_controls'; +// 2_000_000_000 + major * 1_000_000 + minor * 1_000 + patch. +export const DESKTOP_OFFLINE_CACHE_VERSION = 2_000_002_000; + +export const RUNTIME_IDS = Object.freeze({ + functionPlugin: 189, + keyboardPlugin: 180, + keyDownCondition: 305, + keyPressedCondition: 453, + touchPlugin: 495, +}); + +// These source events belong to the checksum-pinned MiniDayZ Plus snapshot. +// Cloning them keeps every original gameplay guard and action intact while +// replacing only the touch condition with the matching keyboard condition. +export const DESKTOP_BINDINGS = Object.freeze([ + { + id: 'inventory', + label: 'Inventory', + key: 'Tab', + keyCode: 9, + mode: 'pressed', + sourceEventSid: 4706090438729328, + touchConditionId: 273, + touchObjectId: 497, + }, + { + id: 'interact', + label: 'Interact / pick up', + key: 'E', + keyCode: 69, + mode: 'pressed', + sourceEventSid: 8880091309135125, + touchConditionId: 273, + touchObjectId: 512, + }, + { + id: 'vehicle-exit', + label: 'Leave vehicle', + key: 'E', + keyCode: 69, + mode: 'pressed', + sourceEventSid: 893643204604230, + touchConditionId: 273, + touchObjectId: 807, + }, + { + id: 'reload', + label: 'Reload', + key: 'R', + keyCode: 82, + mode: 'pressed', + sourceEventSid: 8302300112826202, + touchConditionId: 227, + touchObjectId: 522, + }, + { + id: 'weapon-cycle', + label: 'Cycle weapon', + key: 'Q', + keyCode: 81, + mode: 'pressed', + sourceEventSid: 6889675909831562, + touchConditionId: 273, + touchObjectId: 509, + }, + { + id: 'primary-press', + label: 'Primary attack', + key: 'Space', + keyCode: 32, + mode: 'pressed', + sourceEventSid: 7708888474205845, + touchConditionId: 273, + touchObjectId: 505, + }, + { + id: 'primary-hold', + label: 'Automatic fire / held attack', + key: 'Space', + keyCode: 32, + mode: 'down', + sourceEventSid: 1671415525337555, + touchConditionId: 274, + touchObjectId: 505, + }, + { + id: 'vehicle-fire', + label: 'Fire from vehicle', + key: 'Space', + keyCode: 32, + mode: 'down', + sourceEventSid: 932569909445593, + touchConditionId: 274, + touchObjectId: 922, + }, + { + id: 'alternate-attack', + label: 'Alternate attack', + key: 'F', + keyCode: 70, + mode: 'pressed', + sourceEventSid: 372174106651111, + touchConditionId: 227, + touchObjectId: 945, + }, + { + id: 'aim', + label: 'Toggle aim', + key: 'X', + keyCode: 88, + mode: 'pressed', + sourceEventSid: 509486699693314, + touchConditionId: 227, + touchObjectId: 798, + }, + { + id: 'pause', + label: 'Pause / options', + key: 'Escape', + keyCode: 27, + mode: 'pressed', + sourceEventSid: 1982214218846053, + touchConditionId: 227, + touchObjectId: 288, + }, + { + id: 'perks', + label: 'Perks and status', + key: 'P', + keyCode: 80, + mode: 'pressed', + sourceEventSid: 6088908557716181, + touchConditionId: 227, + touchObjectId: 602, + }, + { + id: 'flare', + label: 'Use equipped flare', + key: 'G', + keyCode: 71, + mode: 'pressed', + sourceEventSid: 5900097328538972, + touchConditionId: 227, + touchObjectId: 642, + }, + { + id: 'talk', + label: 'Talk', + key: 'T', + keyCode: 84, + mode: 'pressed', + sourceEventSid: 622335141267763, + touchConditionId: 227, + touchObjectId: 736, + }, +]); + +export const QUICK_WEAPON_BINDINGS = Object.freeze([ + { id: 'melee', label: 'Select melee weapon', key: '1', keyCode: 49, functionName: 'Switch_to_melee' }, + { id: 'firearm', label: 'Select primary firearm', key: '2', keyCode: 50, functionName: 'Switch_to_firearm' }, + { id: 'pistol', label: 'Select pistol', key: '3', keyCode: 51, functionName: 'Switch_to_pistol' }, +]); diff --git a/scripts/patch-game-assets.mjs b/scripts/patch-game-assets.mjs new file mode 100644 index 0000000..1b61e55 --- /dev/null +++ b/scripts/patch-game-assets.mjs @@ -0,0 +1,363 @@ +import { copyFile, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + DESKTOP_BINDINGS, + DESKTOP_CONTROLS_VERSION, + DESKTOP_EVENT_GROUP, + DESKTOP_OFFLINE_CACHE_VERSION, + QUICK_WEAPON_BINDINGS, + RUNTIME_IDS, +} from './desktop-control-bindings.mjs'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const defaultGameRoot = path.join(projectRoot, 'docs'); +const controlsSource = path.join(projectRoot, 'desktop', 'game-controls.js'); +const INDEX_PATCH_START = ''; +const INDEX_PATCH_END = ''; +const SID_FLOOR = 10_000_000_000_000; + +function clone(value) { + return structuredClone(value); +} + +function visitArrays(value, callback) { + if (!Array.isArray(value)) { + return; + } + + callback(value); + for (const entry of value) { + visitArrays(entry, callback); + } +} + +function findEventBySid(value, sid) { + let found = null; + visitArrays(value, (entry) => { + if (!found && entry[0] === 0 && entry.length === 8 && entry[4] === sid) { + found = entry; + } + }); + return found; +} + +function referencesObject(condition, objectId) { + let found = false; + visitArrays(condition, (entry) => { + if (entry.length === 2 && entry[0] === 4 && entry[1] === objectId) { + found = true; + } + }); + return found; +} + +export function createSidAllocator(project) { + const used = new Set(); + visitArrays(project, (entry) => { + for (const value of entry) { + if (typeof value === 'number' && Number.isInteger(value) && value >= SID_FLOOR) { + used.add(value); + } + } + }); + + let candidate = 98_000_000_000_000; + return () => { + while (used.has(candidate)) { + candidate += 1; + } + const sid = candidate; + used.add(sid); + candidate += 1; + return sid; + }; +} + +function remapLargeIds(value, allocateSid, replacements = new Map()) { + if (!Array.isArray(value)) { + return; + } + + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (typeof entry === 'number' && Number.isInteger(entry) && entry >= SID_FLOOR) { + if (!replacements.has(entry)) { + replacements.set(entry, allocateSid()); + } + value[index] = replacements.get(entry); + } else { + remapLargeIds(entry, allocateSid, replacements); + } + } +} + +function keyboardCondition(keyCode, mode, allocateSid) { + const isPressed = mode === 'pressed'; + return [ + RUNTIME_IDS.keyboardPlugin, + isPressed ? RUNTIME_IDS.keyPressedCondition : RUNTIME_IDS.keyDownCondition, + null, + isPressed ? 1 : 0, + false, + false, + false, + allocateSid(), + false, + [[9, keyCode]], + ]; +} + +export function cloneTouchEventForKeyboard(sourceEvent, binding, allocateSid) { + const event = clone(sourceEvent); + remapLargeIds(event, allocateSid); + + const conditionIndex = event[5].findIndex((condition) => ( + condition?.[0] === RUNTIME_IDS.touchPlugin + && condition?.[1] === binding.touchConditionId + && referencesObject(condition, binding.touchObjectId) + )); + + if (conditionIndex === -1) { + throw new Error(`Could not find the touch condition for desktop binding ${binding.id}.`); + } + + event[5][conditionIndex] = keyboardCondition(binding.keyCode, binding.mode, allocateSid); + return event; +} + +function createFunctionBindingEvent(binding, allocateSid) { + return [ + 0, + null, + false, + null, + allocateSid(), + [keyboardCondition(binding.keyCode, 'pressed', allocateSid)], + [[ + RUNTIME_IDS.functionPlugin, + 69, + null, + allocateSid(), + false, + [[1, [2, binding.functionName]], [13]], + ]], + [], + ]; +} + +function groupActionName(entry) { + if (entry?.[0] !== -1 || entry?.[1] !== 74) { + return null; + } + return entry?.[5]?.[0]?.[1]?.[1] ?? null; +} + +function enforceDesktopMovement(project) { + const stats = { + controlDefaults: 0, + controlWrites: 0, + groupDefaults: 0, + groupWrites: 0, + storageWrites: 0, + }; + + visitArrays(project, (entry) => { + if (entry[0] === 1 && entry[1] === 'GUI_control_type') { + entry[3] = 2; + stats.controlDefaults += 1; + } + + if ( + entry[0] === 0 + && Array.isArray(entry[1]) + && ['Movement_wasd', 'Movement_stick', 'Movement_tap'].includes(entry[1][1]) + ) { + entry[1][0] = entry[1][1] === 'Movement_wasd'; + stats.groupDefaults += 1; + } + + const groupName = groupActionName(entry); + if (groupName === 'Movement_wasd') { + entry[5][1] = [3, 1]; + stats.groupWrites += 1; + } else if (groupName === 'Movement_stick' || groupName === 'Movement_tap') { + entry[5][1] = [3, 0]; + stats.groupWrites += 1; + } + + if ( + entry[0] === -1 + && entry[1] === 41 + && entry?.[5]?.[0]?.[0] === 11 + && entry?.[5]?.[0]?.[1] === 'GUI_control_type' + ) { + entry[5][1] = [7, [0, 2]]; + stats.controlWrites += 1; + } + + if ( + entry[0] === 422 + && entry[1] === 146 + && entry?.[5]?.[0]?.[1]?.[1] === 'CONTROLS' + ) { + entry[5][1] = [7, [2, 'WASD']]; + stats.storageWrites += 1; + } + }); + + if ( + stats.controlDefaults < 1 + || stats.controlWrites < 1 + || stats.groupDefaults < 3 + || stats.groupWrites < 3 + || stats.storageWrites < 1 + ) { + throw new Error(`The pinned game control structure changed unexpectedly: ${JSON.stringify(stats)}.`); + } + + return stats; +} + +export function applyDesktopControlsToProject(project) { + const eventSheets = project?.[6]; + const gameEventSheet = eventSheets?.find((sheet) => sheet?.[0] === 'Game_events'); + if (!gameEventSheet || !Array.isArray(gameEventSheet[1])) { + throw new Error('Could not find the MiniDayZ Game_events sheet.'); + } + + gameEventSheet[1] = gameEventSheet[1].filter((entry) => !( + entry?.[0] === 0 + && Array.isArray(entry[1]) + && entry[1][1] === DESKTOP_EVENT_GROUP + )); + + const movementStats = enforceDesktopMovement(project); + const allocateSid = createSidAllocator(project); + const bindingEvents = DESKTOP_BINDINGS.map((binding) => { + const sourceEvent = findEventBySid(gameEventSheet, binding.sourceEventSid); + if (!sourceEvent) { + throw new Error(`Could not find source event ${binding.sourceEventSid} for ${binding.id}.`); + } + return cloneTouchEventForKeyboard(sourceEvent, binding, allocateSid); + }); + + bindingEvents.push(...QUICK_WEAPON_BINDINGS.map((binding) => ( + createFunctionBindingEvent(binding, allocateSid) + ))); + + const groupSid = allocateSid(); + gameEventSheet[1].push([ + 0, + [true, DESKTOP_EVENT_GROUP], + false, + null, + groupSid, + [[ + -1, + 38, + null, + 0, + false, + false, + false, + groupSid, + false, + [[1, [2, DESKTOP_EVENT_GROUP]]], + ]], + [], + bindingEvents, + ]); + + return { + bindingCount: bindingEvents.length, + movement: movementStats, + }; +} + +function patchIndexHtml(indexHtml) { + const patchPattern = new RegExp( + `\\r?\\n\\r?\\n[\\t ]*${INDEX_PATCH_START}[\\s\\S]*?${INDEX_PATCH_END}`, + 'g', + ); + const cleanHtml = indexHtml.replace(patchPattern, ''); + const runtimeScript = ''; + if (!cleanHtml.includes(runtimeScript)) { + throw new Error('Could not locate c2runtime.js in the pinned game index.'); + } + + const patchBlock = [ + INDEX_PATCH_START, + '\t', + `\t${INDEX_PATCH_END}`, + ].join('\n'); + return cleanHtml.replace(runtimeScript, `${runtimeScript}\n\n\t${patchBlock}`); +} + +async function patchDataFile(gameRoot) { + const dataPath = path.join(gameRoot, 'data.js'); + const temporaryPath = `${dataPath}.desktop-patch`; + const source = await readFile(dataPath, 'utf8'); + const hasBom = source.charCodeAt(0) === 0xFEFF; + const data = JSON.parse(hasBom ? source.slice(1) : source); + const stats = applyDesktopControlsToProject(data.project); + const output = `${hasBom ? '\uFEFF' : ''}${JSON.stringify(data)}`; + + await rm(temporaryPath, { force: true }); + await writeFile(temporaryPath, output, 'utf8'); + await rename(temporaryPath, dataPath); + return stats; +} + +async function patchOfflineManifest(gameRoot) { + const offlinePath = path.join(gameRoot, 'offline.js'); + const source = await readFile(offlinePath, 'utf8'); + const hasBom = source.charCodeAt(0) === 0xFEFF; + const manifest = JSON.parse(hasBom ? source.slice(1) : source); + const desktopFiles = ['desktop-controls.js', '.minidayz-desktop-patch.json']; + + manifest.version = DESKTOP_OFFLINE_CACHE_VERSION; + manifest.fileList = [ + ...manifest.fileList.filter((entry) => !desktopFiles.includes(entry)), + ...desktopFiles, + ]; + await writeFile( + offlinePath, + `${hasBom ? '\uFEFF' : ''}${JSON.stringify(manifest, null, '\t')}\n`, + 'utf8', + ); +} + +export async function applyDesktopGamePatches(gameRoot = defaultGameRoot) { + const stats = await patchDataFile(gameRoot); + const indexPath = path.join(gameRoot, 'index.html'); + const indexHtml = await readFile(indexPath, 'utf8'); + await writeFile(indexPath, patchIndexHtml(indexHtml), 'utf8'); + await copyFile(controlsSource, path.join(gameRoot, 'desktop-controls.js')); + + const metadata = { + version: DESKTOP_CONTROLS_VERSION, + eventGroup: DESKTOP_EVENT_GROUP, + bindingCount: stats.bindingCount, + bindings: [ + ...DESKTOP_BINDINGS.map(({ id, key, mode }) => ({ id, key, mode })), + ...QUICK_WEAPON_BINDINGS.map(({ id, key }) => ({ id, key, mode: 'pressed' })), + ], + movement: 'WASD', + offlineCacheVersion: DESKTOP_OFFLINE_CACHE_VERSION, + }; + await writeFile( + path.join(gameRoot, '.minidayz-desktop-patch.json'), + `${JSON.stringify(metadata, null, 2)}\n`, + 'utf8', + ); + await patchOfflineManifest(gameRoot); + + return metadata; +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ''; +if (import.meta.url === invokedPath) { + const metadata = await applyDesktopGamePatches(); + console.log(`Applied MiniDayZ PC controls ${metadata.version} (${metadata.bindingCount} bindings).`); +} diff --git a/scripts/sync-game-assets.mjs b/scripts/sync-game-assets.mjs index aa91ed1..b3041a8 100644 --- a/scripts/sync-game-assets.mjs +++ b/scripts/sync-game-assets.mjs @@ -17,6 +17,7 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import extract from 'extract-zip'; +import { applyDesktopGamePatches } from './patch-game-assets.mjs'; const UPSTREAM_REPOSITORY = 'NextDev65/MiniDayZ'; const UPSTREAM_COMMIT = '40ac9cf58af806e2d7c1c0638f6c3214042239b7'; @@ -115,6 +116,8 @@ async function downloadArchive() { async function syncGame() { if (!force && await hasCompleteGame(gameDirectory)) { console.log('MiniDayZ Plus game assets are already present.'); + const patch = await applyDesktopGamePatches(gameDirectory); + console.log(`Applied MiniDayZ PC controls ${patch.version} (${patch.bindingCount} bindings).`); return; } @@ -160,6 +163,8 @@ async function syncGame() { await readFile(path.join(gameDirectory, '.minidayz-source.json'), 'utf8'), ); console.log(`Synced MiniDayZ Plus from ${sourceRecord.repository}@${sourceRecord.commit.slice(0, 8)}.`); + const patch = await applyDesktopGamePatches(gameDirectory); + console.log(`Applied MiniDayZ PC controls ${patch.version} (${patch.bindingCount} bindings).`); } await syncGame(); diff --git a/scripts/verify-project.mjs b/scripts/verify-project.mjs index 51d0421..61c60c7 100644 --- a/scripts/verify-project.mjs +++ b/scripts/verify-project.mjs @@ -2,6 +2,14 @@ import { spawnSync } from 'node:child_process'; import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + DESKTOP_BINDINGS, + DESKTOP_CONTROLS_VERSION, + DESKTOP_EVENT_GROUP, + DESKTOP_OFFLINE_CACHE_VERSION, + QUICK_WEAPON_BINDINGS, + RUNTIME_IDS, +} from './desktop-control-bindings.mjs'; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const gameRoot = path.join(projectRoot, 'docs'); @@ -29,7 +37,30 @@ async function countFiles(directory) { return total; } -for (const sourceFile of ['desktop/main.js', 'desktop/game-protocol.js']) { +function visitArrays(value, callback) { + if (!Array.isArray(value)) { + return; + } + callback(value); + for (const entry of value) { + visitArrays(entry, callback); + } +} + +function countOccurrences(source, fragment) { + return source.split(fragment).length - 1; +} + +const syntaxCheckedFiles = [ + 'desktop/main.js', + 'desktop/game-controls.js', + 'desktop/game-protocol.js', + 'scripts/desktop-control-bindings.mjs', + 'scripts/after-pack.cjs', + 'scripts/patch-game-assets.mjs', + 'scripts/sync-game-assets.mjs', +]; +for (const sourceFile of syntaxCheckedFiles) { await verifyFile(sourceFile, 100); const syntaxCheck = spawnSync(process.execPath, ['--check', path.join(projectRoot, sourceFile)], { encoding: 'utf8', @@ -43,6 +74,8 @@ await verifyFile('docs/index.html', 1_000); await verifyFile('docs/c2runtime.js', 100_000); await verifyFile('docs/data.js', 5_000_000); await verifyFile('docs/icon-256.png', 1_000); +await verifyFile('docs/desktop-controls.js', 1_000); +await verifyFile('docs/.minidayz-desktop-patch.json', 100); const packageJson = JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8')); if (!/^\d+\.\d+\.\d+$/.test(packageJson.version)) { @@ -51,6 +84,15 @@ if (!/^\d+\.\d+\.\d+$/.test(packageJson.version)) { if (packageJson.main !== 'desktop/main.js') { fail('package.json must use desktop/main.js as its Electron entry point.'); } +if (packageJson.version !== DESKTOP_CONTROLS_VERSION) { + fail(`Package ${packageJson.version} and control patch ${DESKTOP_CONTROLS_VERSION} versions differ.`); +} +if ( + packageJson.build?.afterPack !== './scripts/after-pack.cjs' + || packageJson.build?.afterSign !== './scripts/after-pack.cjs' +) { + fail('Electron builds must run the Windows package cleanup hook.'); +} const packagedFiles = new Set(packageJson.build?.files ?? []); for (const requiredPattern of ['desktop/**/*', 'docs/**/*', 'package.json']) { @@ -63,10 +105,101 @@ const indexHtml = await readFile(path.join(gameRoot, 'index.html'), 'utf8'); if (!indexHtml.includes('id="c2canvas"') || !indexHtml.includes('c2runtime.js')) { fail('docs/index.html is not a recognizable Construct 2 MiniDayZ export.'); } +if ( + countOccurrences(indexHtml, '') !== 1 + || countOccurrences(indexHtml, '') !== 1 +) { + fail('docs/index.html must load exactly one copy of the MiniDayZ PC controls bridge.'); +} + +const controlSource = await readFile(path.join(projectRoot, 'desktop', 'game-controls.js'), 'utf8'); +const embeddedControlSource = await readFile(path.join(gameRoot, 'desktop-controls.js'), 'utf8'); +if (embeddedControlSource !== controlSource) { + fail('The embedded desktop controls do not match desktop/game-controls.js.'); +} + +const patchMetadata = JSON.parse( + await readFile(path.join(gameRoot, '.minidayz-desktop-patch.json'), 'utf8'), +); +const expectedBindings = [...DESKTOP_BINDINGS, ...QUICK_WEAPON_BINDINGS]; +if ( + patchMetadata.version !== DESKTOP_CONTROLS_VERSION + || patchMetadata.eventGroup !== DESKTOP_EVENT_GROUP + || patchMetadata.bindingCount !== expectedBindings.length + || patchMetadata.movement !== 'WASD' + || patchMetadata.offlineCacheVersion !== DESKTOP_OFFLINE_CACHE_VERSION +) { + fail(`Desktop patch metadata is inconsistent: ${JSON.stringify(patchMetadata)}.`); +} + +const offlineSource = await readFile(path.join(gameRoot, 'offline.js'), 'utf8'); +const offlineManifest = JSON.parse( + offlineSource.charCodeAt(0) === 0xFEFF ? offlineSource.slice(1) : offlineSource, +); +if ( + offlineManifest.version !== DESKTOP_OFFLINE_CACHE_VERSION + || !offlineManifest.fileList.includes('desktop-controls.js') + || !offlineManifest.fileList.includes('.minidayz-desktop-patch.json') +) { + fail('The Construct 2 offline manifest does not include the current desktop patch.'); +} + +const dataSource = await readFile(path.join(gameRoot, 'data.js'), 'utf8'); +const gameData = JSON.parse(dataSource.charCodeAt(0) === 0xFEFF ? dataSource.slice(1) : dataSource); +const gameEventSheet = gameData.project?.[6]?.find((sheet) => sheet?.[0] === 'Game_events'); +const desktopGroups = gameEventSheet?.[1]?.filter((entry) => ( + entry?.[0] === 0 && entry?.[1]?.[1] === DESKTOP_EVENT_GROUP +)) ?? []; +if (desktopGroups.length !== 1 || desktopGroups[0][1][0] !== true) { + fail(`Expected exactly one active ${DESKTOP_EVENT_GROUP} event group.`); +} + +const desktopEvents = desktopGroups[0][7]; +if (desktopEvents.length !== expectedBindings.length) { + fail(`Expected ${expectedBindings.length} desktop events, found ${desktopEvents.length}.`); +} + +const actualBindingSignatures = desktopEvents.map((event) => { + const condition = event?.[5]?.find((entry) => ( + entry?.[0] === RUNTIME_IDS.keyboardPlugin + && [RUNTIME_IDS.keyDownCondition, RUNTIME_IDS.keyPressedCondition].includes(entry?.[1]) + )); + if (!condition) { + fail('A desktop event does not have a direct keyboard condition.'); + } + const mode = condition[1] === RUNTIME_IDS.keyPressedCondition ? 'pressed' : 'down'; + return `${condition?.[9]?.[0]?.[1]}:${mode}`; +}).sort(); +const expectedBindingSignatures = expectedBindings.map((binding) => ( + `${binding.keyCode}:${binding.mode ?? 'pressed'}` +)).sort(); +if (JSON.stringify(actualBindingSignatures) !== JSON.stringify(expectedBindingSignatures)) { + fail(`Injected keyboard bindings differ from the manifest: ${actualBindingSignatures.join(', ')}.`); +} + +const movementGroups = new Map(); +visitArrays(gameData.project, (entry) => { + if ( + entry?.[0] === 0 + && Array.isArray(entry?.[1]) + && ['Movement_wasd', 'Movement_stick', 'Movement_tap'].includes(entry[1][1]) + ) { + movementGroups.set(entry[1][1], entry[1][0]); + } +}); +if ( + movementGroups.get('Movement_wasd') !== true + || movementGroups.get('Movement_stick') !== false + || movementGroups.get('Movement_tap') !== false +) { + fail(`Desktop movement groups are not locked to WASD: ${JSON.stringify(Object.fromEntries(movementGroups))}.`); +} const gameFileCount = await countFiles(gameRoot); if (gameFileCount < 1_800) { fail(`Game asset set is incomplete: found only ${gameFileCount} files.`); } -console.log(`Verified MiniDayZ PC v${packageJson.version}: ${gameFileCount} game files are ready.`); +console.log( + `Verified MiniDayZ PC v${packageJson.version}: ${expectedBindings.length} desktop bindings and ${gameFileCount} game files are ready.`, +); diff --git a/test/after-pack.test.js b/test/after-pack.test.js new file mode 100644 index 0000000..70b2d07 --- /dev/null +++ b/test/after-pack.test.js @@ -0,0 +1,41 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { access, mkdtemp, rm, writeFile } = require('node:fs/promises'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const afterPack = require('../scripts/after-pack.cjs'); + +async function exists(filePath) { + return access(filePath).then(() => true, () => false); +} + +test('removes only the redundant Electron binary from Windows packages', async (context) => { + const appOutDir = await mkdtemp(path.join(tmpdir(), 'minidayz-after-pack-')); + context.after(() => rm(appOutDir, { recursive: true, force: true })); + const productExecutable = path.join(appOutDir, 'MiniDayZ PC.exe'); + const baseExecutable = path.join(appOutDir, 'electron.exe'); + await writeFile(productExecutable, 'product'); + await writeFile(baseExecutable, 'base'); + + await afterPack({ + appOutDir, + electronPlatformName: 'win32', + packager: { appInfo: { productFilename: 'MiniDayZ PC' } }, + }); + + assert.equal(await exists(productExecutable), true); + assert.equal(await exists(baseExecutable), false); +}); + +test('does not alter non-Windows packages', async (context) => { + const appOutDir = await mkdtemp(path.join(tmpdir(), 'minidayz-after-pack-')); + context.after(() => rm(appOutDir, { recursive: true, force: true })); + const baseExecutable = path.join(appOutDir, 'electron.exe'); + await writeFile(baseExecutable, 'base'); + + await afterPack({ appOutDir, electronPlatformName: 'linux' }); + + assert.equal(await exists(baseExecutable), true); +}); diff --git a/test/game-controls-patch.test.js b/test/game-controls-patch.test.js new file mode 100644 index 0000000..b35288c --- /dev/null +++ b/test/game-controls-patch.test.js @@ -0,0 +1,58 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +test('clones a guarded touch action as a keyboard action without mutating its source', async () => { + const { cloneTouchEventForKeyboard } = await import('../scripts/patch-game-assets.mjs'); + const source = [ + 0, + null, + false, + null, + 47_060_904_387_293, + [[495, 273, null, 1, false, false, false, 47_060_904_387_294, false, [[4, 497]]]], + [[-1, 41, null, 47_060_904_387_295, false, [[11, 'Inventory_open'], [7, [0, 1]]]]], + [], + ]; + const sourceSnapshot = structuredClone(source); + let nextSid = 98_000_000_001_000; + const cloned = cloneTouchEventForKeyboard(source, { + id: 'inventory', + keyCode: 9, + mode: 'pressed', + touchConditionId: 273, + touchObjectId: 497, + }, () => nextSid++); + + assert.deepEqual(source, sourceSnapshot); + assert.notEqual(cloned[4], source[4]); + assert.deepEqual(cloned[5][0].slice(0, 4), [180, 453, null, 1]); + assert.deepEqual(cloned[5][0][9], [[9, 9]]); + assert.notEqual(cloned[6][0][3], source[6][0][3]); +}); + +test('uses the continuous keyboard condition for held actions', async () => { + const { cloneTouchEventForKeyboard } = await import('../scripts/patch-game-assets.mjs'); + const source = [ + 0, + null, + false, + null, + 47_060_904_387_296, + [[495, 274, null, 1, false, false, false, 47_060_904_387_297, false, [[4, 505]]]], + [], + [], + ]; + let nextSid = 98_000_000_002_000; + const cloned = cloneTouchEventForKeyboard(source, { + id: 'primary-hold', + keyCode: 32, + mode: 'down', + touchConditionId: 274, + touchObjectId: 505, + }, () => nextSid++); + + assert.deepEqual(cloned[5][0].slice(0, 4), [180, 305, null, 0]); + assert.deepEqual(cloned[5][0][9], [[9, 32]]); +}); diff --git a/test/game-controls.test.js b/test/game-controls.test.js new file mode 100644 index 0000000..364d93b --- /dev/null +++ b/test/game-controls.test.js @@ -0,0 +1,71 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const controls = require('../desktop/game-controls'); + +class FakeKeyboardEvent extends Event { + constructor(type, options = {}) { + super(type, options); + this.code = options.code ?? ''; + this.key = options.key ?? ''; + this.repeat = options.repeat ?? false; + } +} + +function createFakeWindow() { + const document = new EventTarget(); + document.closest = (selector) => ( + selector === '#c2canvas, #c2canvasdiv' ? document : null + ); + return { document, KeyboardEvent: FakeKeyboardEvent }; +} + +test('publishes the complete v0.2.0 desktop-control manifest', () => { + assert.equal(controls.VERSION, '0.2.0'); + assert.equal(controls.PATCH_BINDING_COUNT, 17); + assert.equal(controls.ARROW_ALIASES.ArrowUp.keyCode, 87); + assert.equal(controls.MOUSE_ALIASES[2].keyCode, 88); + assert.ok(controls.HELP_ROWS.some(([key]) => key === 'Tab')); +}); + +test('turns arrow input into Construct-compatible WASD keyboard events', () => { + const windowObject = createFakeWindow(); + controls.install(windowObject); + const received = []; + windowObject.document.addEventListener('keydown', (event) => { + received.push({ key: event.key, which: event.which }); + }); + + const arrowEvent = new FakeKeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'ArrowUp', + }); + windowObject.document.dispatchEvent(arrowEvent); + + assert.equal(arrowEvent.defaultPrevented, true); + assert.deepEqual(received, [ + { key: 'w', which: 87 }, + { key: 'ArrowUp', which: undefined }, + ]); +}); + +test('maps right-click to the aim key without replacing left-click', () => { + const windowObject = createFakeWindow(); + controls.install(windowObject); + const received = []; + windowObject.document.addEventListener('keydown', (event) => received.push(event.which)); + + const rightClick = new Event('mousedown', { bubbles: true, cancelable: true }); + Object.defineProperty(rightClick, 'button', { value: 2 }); + windowObject.document.dispatchEvent(rightClick); + + const leftClick = new Event('mousedown', { bubbles: true, cancelable: true }); + Object.defineProperty(leftClick, 'button', { value: 0 }); + windowObject.document.dispatchEvent(leftClick); + + assert.equal(rightClick.defaultPrevented, true); + assert.equal(leftClick.defaultPrevented, false); + assert.deepEqual(received, [88]); +});