diff --git a/src/layers/build.ts b/src/layers/build.ts new file mode 100644 index 00000000..e70801f2 --- /dev/null +++ b/src/layers/build.ts @@ -0,0 +1,10 @@ + +// Autogenerated +// ------------- +// gulpfile.ts/wasm.ts --> generateBuildInfo + +export const Build = { + short: "0.73.9", + version: "0.73.9 (8dd6a9be3fd1f818af5862d3c3c93443)", + buildSeed: 1673509944011, +}; diff --git a/src/layers/controls/button.ts b/src/layers/controls/button.ts new file mode 100644 index 00000000..3a4baa25 --- /dev/null +++ b/src/layers/controls/button.ts @@ -0,0 +1,225 @@ +import { CommandInterface } from "emulators"; +import { Layers } from "../dom/layers"; +import { namedKeyCodes, KBD_NONE } from "../dom/keys"; +import { pointer } from "../dom/pointer"; +import { LayoutPosition } from "./layout"; + +export type ActionType = "click" | "hold"; +// hold - means track press/release events separately + +export type Binding = number; + +export interface Button { + action: ActionType, + mapTo: Binding, + symbol?: string, + position: LayoutPosition, +} + +const keyCodeToName = initKeyCodeToName(); + +function initKeyCodeToName() { + const keyCodeToName: {[keyCode: number]: string} = {}; + for (const next of Object.keys(namedKeyCodes)) { + keyCodeToName[namedKeyCodes[next]] = next.substr(4, 2); + } + return keyCodeToName; +} + +export interface ButtonHandler { + onDown?: () => void; + onUp?: () => void; + onClick?: () => void; +} + +export interface HTMLJsDosButtonElement extends HTMLDivElement { + widthPx: number; + heightPx: number; +} + +export function createButton(symbol: string, + handler: ButtonHandler, + size: number): HTMLJsDosButtonElement { + const innerSize = Math.round(size * 0.6); + const innerTextSize = Math.round(size * 0.5); + const borderWidth = Math.max(1, Math.round(size / 20)); + const backgroundImage = symbolToUrl[symbol.toLowerCase()]; + const text = backgroundImage === undefined ? symbol : ""; + const button = createDiv("emulator-button-touch-zone") as HTMLJsDosButtonElement; + const innerButton = createDiv("emulator-button"); + const innerText = createDiv("emulator-button-text", + backgroundImage === undefined ? ((text === undefined || text.length === 0) ? + "□" : text.substr(0, 1).toUpperCase()) : ""); + + if (backgroundImage !== undefined) { + innerButton.style.backgroundImage = "url(\"" + backgroundImage + "\")"; + } + innerButton.style.width = innerSize + "px"; + innerButton.style.height = innerSize + "px"; + innerText.style.fontSize = innerTextSize + "px"; + + button.widthPx = size - borderWidth * 2; + button.heightPx = size - borderWidth * 2; + button.style.width = button.widthPx + "px"; + button.style.height = button.heightPx + "px"; + button.style.borderWidth = borderWidth + "px"; + button.appendChild(innerButton); + button.appendChild(innerText); + + const onStart = (e: Event) => { + if (handler.onDown !== undefined) { + handler.onDown(); + } + if (handler.onClick !== undefined) { + handler.onClick(); + } + e.stopPropagation(); + e.preventDefault(); + }; + const onEnd = (e: Event) => { + if (handler.onUp !== undefined) { + handler.onUp(); + } + e.stopPropagation(); + e.preventDefault(); + }; + const onPrevent = (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + }; + const options = { + capture: true, + }; + for (const next of pointer.starters) { + button.addEventListener(next, onStart, options); + } + for (const next of pointer.enders) { + button.addEventListener(next, onEnd, options); + } + for (const next of pointer.changers) { + button.addEventListener(next, onPrevent, options); + } + for (const next of pointer.leavers) { + button.addEventListener(next, onPrevent, options); + } + for (const next of pointer.prevents) { + button.addEventListener(next, onPrevent, options); + } + return button; +} + +export function deprecatedButton(layers: Layers, + ci: CommandInterface, + buttons: Button[], + size: number) { + const ident = Math.round(size / 4); + const toRemove: HTMLElement[] = []; + + for (const next of buttons) { + if (next.mapTo === KBD_NONE) { + continue; + } + + const symbol = (next.symbol || mapToSymbol(next.mapTo)).toUpperCase(); + const handler = deprecatedCreateHandler(next, layers); + const button = createButton(symbol, handler, size); + + button.style.position = "absolute"; + const cssStyle = (next as any).style; + if (cssStyle) { + for (const prop of Object.keys(cssStyle)) { + (button.style as any)[prop] = (cssStyle as any)[prop]; + } + } + + if (next.position !== undefined) { + const left = next.position.left; + const top = next.position.top; + const bottom = next.position.bottom; + const right = next.position.right; + + if (left !== undefined) { + button.style.left = (ident * left + size * (left - 1)) + "px"; + } + + if (right !== undefined) { + button.style.right = (ident * right + size * (right - 1)) + "px"; + } + + if (top !== undefined) { + button.style.top = (ident * top + size * (top - 1)) + "px"; + } + + if (bottom !== undefined) { + button.style.bottom = (ident * bottom + size * (bottom - 1)) + "px"; + } + } + layers.mouseOverlay.appendChild(button); + toRemove.push(button); + } + + return () => { + for (const next of toRemove) { + if (next.parentElement === layers.mouseOverlay) { + layers.mouseOverlay.removeChild(next); + } + } + }; +} + +function createDiv(className: string, innerHtml?: string) { + const el = document.createElement("div"); + el.className = className; + if (innerHtml !== undefined) { + el.innerHTML = innerHtml; + } + return el; +} + +function mapToSymbol(mapTo: Binding): string { + if (typeof mapTo === "number") { + return keyCodeToName[mapTo]; + } + + return mapTo; +} + +function deprecatedCreateHandler(button: Button, + layers: Layers): ButtonHandler { + return button.action === "click" ? + { onClick: () => layers.fireKeyPress(button.mapTo) } : + { + onDown: () => layers.fireKeyDown(button.mapTo), + onUp: () => layers.fireKeyUp(button.mapTo), + }; +} + +/* eslint-disable max-len */ +const down = "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Layer_1' xmlns:sketch='http://www.bohemiancoding.com/sketch/ns' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' fill='%23FFF' enable-background='new 0 0 20 20' xml:space='preserve'%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cg id='Page-1' sketch:type='MSPage'%3E%3Cg id='Artboard-1' transform='translate(-3.000000, -1.000000)' sketch:type='MSArtboardGroup'%3E%3Cpath id='Shape' sketch:type='MSShapeGroup' d='M19,12c-0.3,0-0.5,0.1-0.7,0.3L14,16.6V3c0-0.5-0.4-1-1-1s-1,0.5-1,1v13.6 l-4.3-4.3C7.5,12.1,7.3,12,7,12c-0.5,0-1,0.4-1,1c0,0.3,0.1,0.5,0.3,0.7l6,6c0.2,0.2,0.4,0.3,0.7,0.3s0.5-0.1,0.7-0.3l6-6 c0.2-0.2,0.3-0.4,0.3-0.7C20,12.4,19.5,12,19,12L19,12z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"; +const left = "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' enable-background='new 0 0 20 20' fill='%23FFF' xml:space='preserve'%3E%3Cg id='left_arrow_1_'%3E%3Cg%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M18,9H4.41l4.29-4.29C8.89,4.53,9,4.28,9,4c0-0.55-0.45-1-1-1 C7.72,3,7.47,3.11,7.29,3.29l-6,6C1.11,9.47,1,9.72,1,10c0,0.28,0.11,0.53,0.29,0.71l6,6C7.47,16.89,7.72,17,8,17 c0.55,0,1-0.45,1-1c0-0.28-0.11-0.53-0.29-0.71L4.41,11H18c0.55,0,1-0.45,1-1C19,9.45,18.55,9,18,9z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"; +const right = "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' fill='%23fff' viewBox='0 0 20 20' enable-background='new 0 0 20 20' xml:space='preserve'%3E%3Cg id='right_arrow_1_'%3E%3Cg%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M18.71,9.29l-6-6C12.53,3.11,12.28,3,12,3c-0.55,0-1,0.45-1,1 c0,0.28,0.11,0.53,0.29,0.71L15.59,9H2c-0.55,0-1,0.45-1,1c0,0.55,0.45,1,1,1h13.59l-4.29,4.29C11.11,15.47,11,15.72,11,16 c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29l6-6C18.89,10.53,19,10.28,19,10C19,9.72,18.89,9.47,18.71,9.29z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"; +const enter = "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' fill='%23fff' viewBox='0 0 20 20' enable-background='new 0 0 20 20' xml:space='preserve'%3E%3Cg id='key_enter_1_'%3E%3Cg%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M18,2c-0.55,0-1,0.45-1,1v5c0,2.21-1.79,4-4,4H4.41l2.29-2.29 C6.89,9.53,7,9.28,7,9c0-0.55-0.45-1-1-1C5.72,8,5.47,8.11,5.29,8.29l-4,4C1.11,12.47,1,12.72,1,13c0,0.28,0.11,0.53,0.29,0.71 l4,4C5.47,17.89,5.72,18,6,18c0.55,0,1-0.45,1-1c0-0.28-0.11-0.53-0.29-0.71L4.41,14H13c3.31,0,6-2.69,6-6V3C19,2.45,18.55,2,18,2 z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"; +const symbolToUrl: {[symbol: string]: string} = { + fullscreen: "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 16 16' enable-background='new 0 0 16 16' xml:space='preserve'%3E%3Cg id='maximize_1_' fill='%23FFFFFF'%3E%3Cg%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M5.99,8.99c-0.28,0-0.53,0.11-0.71,0.29l-3.29,3.29v-1.59c0-0.55-0.45-1-1-1 s-1,0.45-1,1v4c0,0.55,0.45,1,1,1h4c0.55,0,1-0.45,1-1s-0.45-1-1-1H3.41L6.7,10.7c0.18-0.18,0.29-0.43,0.29-0.71 C6.99,9.44,6.54,8.99,5.99,8.99z M14.99-0.01h-4c-0.55,0-1,0.45-1,1s0.45,1,1,1h1.59L9.28,5.29C9.1,5.47,8.99,5.72,8.99,5.99 c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29l3.29-3.29v1.59c0,0.55,0.45,1,1,1s1-0.45,1-1v-4C15.99,0.44,15.54-0.01,14.99-0.01 z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E", + save: "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 16 16' enable-background='new 0 0 16 16' fill='%23FFFFFF' xml:space='preserve'%3E%3Cg id='floppy_disk'%3E%3Cg%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15.71,2.29l-2-2C13.53,0.11,13.28,0,13,0h-1v6H4V0H1C0.45,0,0,0.45,0,1v14 c0,0.55,0.45,1,1,1h14c0.55,0,1-0.45,1-1V3C16,2.72,15.89,2.47,15.71,2.29z M14,15H2V9c0-0.55,0.45-1,1-1h10c0.55,0,1,0.45,1,1V15 z M11,1H9v4h2V1z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E%0A", + options: "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' enable-background='new 0 0 20 20' fill='%23FFF' xml:space='preserve'%3E%3Cg id='cog_2_'%3E%3Cg%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M19,8h-2.31c-0.14-0.46-0.33-0.89-0.56-1.3l1.7-1.7c0.39-0.39,0.39-1.02,0-1.41 l-1.41-1.41c-0.39-0.39-1.02-0.39-1.41,0l-1.7,1.7c-0.41-0.22-0.84-0.41-1.3-0.55V1c0-0.55-0.45-1-1-1H9C8.45,0,8,0.45,8,1v2.33 C7.52,3.47,7.06,3.67,6.63,3.91L5,2.28c-0.37-0.37-0.98-0.37-1.36,0L2.28,3.64C1.91,4.02,1.91,4.63,2.28,5l1.62,1.62 C3.66,7.06,3.46,7.51,3.31,8H1C0.45,8,0,8.45,0,9v2c0,0.55,0.45,1,1,1h2.31c0.14,0.46,0.33,0.89,0.56,1.3L2.17,15 c-0.39,0.39-0.39,1.02,0,1.41l1.41,1.41c0.39,0.39,1.02,0.39,1.41,0l1.7-1.7c0.41,0.22,0.84,0.41,1.3,0.55V19c0,0.55,0.45,1,1,1h2 c0.55,0,1-0.45,1-1v-2.33c0.48-0.14,0.94-0.35,1.37-0.59L15,17.72c0.37,0.37,0.98,0.37,1.36,0l1.36-1.36 c0.37-0.37,0.37-0.98,0-1.36l-1.62-1.62c0.24-0.43,0.45-0.89,0.6-1.38H19c0.55,0,1-0.45,1-1V9C20,8.45,19.55,8,19,8z M10,14 c-2.21,0-4-1.79-4-4c0-2.21,1.79-4,4-4s4,1.79,4,4C14,12.21,12.21,14,10,14z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E", + keyboard: "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 16 16' enable-background='new 0 0 16 16' xml:space='preserve'%3E%3Cg id='manually_entered_data_2_'%3E%3Cg%3E%3Cpath fill='%23FFFFFF' fill-rule='evenodd' clip-rule='evenodd' d='M1,8h3.76l2-2H1C0.45,6,0,6.45,0,7C0,7.55,0.45,8,1,8z M15.49,3.99 C15.8,3.67,16,3.23,16,2.75C16,1.78,15.22,1,14.25,1c-0.48,0-0.92,0.2-1.24,0.51l-1.44,1.44l2.47,2.47L15.49,3.99z M1,4h7.76l2-2 H1C0.45,2,0,2.45,0,3C0,3.55,0.45,4,1,4z M1,10c-0.55,0-1,0.45-1,1c0,0.48,0.35,0.86,0.8,0.96L2.76,10H1z M10.95,3.57l-6.69,6.69 l2.47,2.47l6.69-6.69L10.95,3.57z M15.2,6.04L13.24,8H15c0.55,0,1-0.45,1-1C16,6.52,15.65,6.14,15.2,6.04z M2,15l3.86-1.39 l-2.46-2.44L2,15z M15,10h-3.76l-2,2H15c0.55,0,1-0.45,1-1C16,10.45,15.55,10,15,10z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E", + up: "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Layer_1' xmlns:sketch='http://www.bohemiancoding.com/sketch/ns' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' fill='%23FFF' enable-background='new 0 0 20 20' xml:space='preserve'%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cg id='Page-1' sketch:type='MSPage'%3E%3Cg id='Artboard-1' transform='translate(-3.000000, -1.000000)' sketch:type='MSArtboardGroup'%3E%3Cpath id='Shape' sketch:type='MSShapeGroup' d='M19.7,8.3l-6-6C13.5,2.1,13.3,2,13,2s-0.5,0.1-0.7,0.3l-6,6C6.1,8.5,6,8.7,6,9 c0,0.6,0.5,1,1,1c0.3,0,0.5-0.1,0.7-0.3L12,5.4V19c0,0.5,0.4,1,1,1s1-0.5,1-1V5.4l4.3,4.3C18.5,9.9,18.7,10,19,10c0.5,0,1-0.4,1-1 C20,8.7,19.9,8.5,19.7,8.3L19.7,8.3z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E", + down, + do: down, + dw: down, + dwn: down, + left, + le: left, + lft: left, + right, + ri: right, + rght: right, + rgh: right, + enter, + en: enter, + enr: enter, + ent: enter, + entr: enter, +}; + diff --git a/src/layers/controls/grid.ts b/src/layers/controls/grid.ts new file mode 100644 index 00000000..39e4586d --- /dev/null +++ b/src/layers/controls/grid.ts @@ -0,0 +1,138 @@ +export type GridType = "square" | "honeycomb"; + +export interface Cell { + centerX: number; + centerY: number; +} + +export interface GridConfiguration { + gridType: GridType, + cells: Cell[][]; + columnWidth: number; + rowHeight: number; + columnsPadding: number; + rowsPadding: number; + width: number; + height: number; +} + +export interface Grid { + getConfiguration(width: number, height: number, scale?: number): GridConfiguration; +} + +export function getGrid(gridType: GridType) { + switch (gridType) { + case "square": return getSquareGrid(); + case "honeycomb": return getHoneyCombGrid(); + } + + throw new Error("Unknown grid type " + gridType); +} + +function getSquareGrid(): Grid { + class SquareGrid implements Grid { + aspect = 200 / 320; + + getConfiguration(width: number, height: number, scale = 1): GridConfiguration { + const cols = this.getCols(); + const rows = this.getRows(); + const middleCol = Math.floor(cols / 2); + const middleRow = Math.floor(rows / 2); + const columnsPadding = width * 5 / 100 / 2; + const rowsPadding = columnsPadding; + const columnWidth = (width - columnsPadding * 2) / cols * scale; + const rowHeight = (height - rowsPadding * 2) / rows * scale; + const size = Math.min(columnWidth, rowHeight); + const cells: Cell[][] = []; + for (let row = 0; row < rows; ++row) { + const cellRow: Cell[] = []; + for (let col = 0; col < cols; ++col) { + cellRow.push({ + centerX: col < middleCol ? + columnsPadding + size * (col + 1 / 2) : + width - columnsPadding - size * ((cols - col - 1) + 1 / 2), + centerY: row < middleRow ? + rowsPadding + size * (row + 1 / 2) : + height - rowsPadding - size * ((rows - row - 1) + 1 / 2), + }); + } + cells.push(cellRow); + } + return { + gridType: "square", + cells, + columnWidth: size, + rowHeight: size, + columnsPadding, + rowsPadding, + width, + height, + }; + } + + private getCols() { + return 10; + } + + private getRows() { + return Math.floor(this.getCols() * this.aspect) + 1; + } + } + + return new SquareGrid(); +} + +function getHoneyCombGrid(): Grid { + class SquareGrid implements Grid { + aspect = 200 / 320; + + getConfiguration(width: number, height: number, scale = 1): GridConfiguration { + const cols = this.getCols(); + const rows = this.getRows(); + const middleCol = Math.floor(cols / 2); + const middleRow = Math.floor(rows / 2); + const columnsPadding = width * 5 / 100 / 2; + const rowsPadding = columnsPadding; + const columnWidth = (width - columnsPadding * 2) / cols * scale; + const rowHeight = (height - rowsPadding * 2) / rows * scale; + const size = Math.min(columnWidth, rowHeight); + const cells: Cell[][] = []; + for (let row = 0; row < rows; ++row) { + const cellRow: Cell[] = []; + const cellCols = row % 2 == 0 ? cols : cols - 1; + const padding = row % 2 == 0 ? 0 : size / 2; + for (let col = 0; col < cellCols; ++col) { + cellRow.push({ + centerX: col < middleCol ? + padding + columnsPadding + size * (col + 1 / 2): + padding + width - columnsPadding - size * ((cols - col - 1) + 1/2), + centerY: row < middleRow ? + rowsPadding + size * (row + 1 / 2) : + height - rowsPadding - size * ((rows - row - 1) + 1 / 2), + }); + } + cells.push(cellRow); + } + return { + gridType: "honeycomb", + cells, + columnWidth: size, + rowHeight: size, + columnsPadding, + rowsPadding, + width, + height, + }; + } + + getCols() { + return 10; + } + + getRows() { + return Math.floor(this.getCols() * this.aspect) + 1; + } + } + + return new SquareGrid(); +} diff --git a/src/layers/controls/keyboard.ts b/src/layers/controls/keyboard.ts new file mode 100644 index 00000000..748c96a3 --- /dev/null +++ b/src/layers/controls/keyboard.ts @@ -0,0 +1,41 @@ +import { CommandInterface } from "emulators"; +import { Layers } from "../dom/layers"; + +export type Mapper = {[keyCode: number]: number}; + +export function keyboard(layers: Layers, + ci: CommandInterface, + mapperOpt?: Mapper) { + const mapper = mapperOpt || {}; + function map(keyCode: number) { + if (mapper[keyCode] !== undefined) { + return mapper[keyCode]; + } + + return keyCode; + } + + layers.setOnKeyDown((keyCode: number) => { + ci.sendKeyEvent(map(keyCode), true); + }); + layers.setOnKeyUp((keyCode: number) => { + ci.sendKeyEvent(map(keyCode), false); + }); + layers.setOnKeyPress((keyCode: number) => { + ci.simulateKeyPress(map(keyCode)); + }); + layers.setOnKeysPress((keyCodes: number[]) => { + ci.simulateKeyPress(...keyCodes); + }); + + return () => { + // eslint-disable-next-line + layers.setOnKeyDown((keyCode: number) => { /**/ }); + // eslint-disable-next-line + layers.setOnKeyUp((keyCode: number) => { /**/ }); + // eslint-disable-next-line + layers.setOnKeyPress((keyCode: number) => { /**/ }); + // eslint-disable-next-line + layers.setOnKeysPress((keyCodes: number[]) => { /**/ }); + }; +} diff --git a/src/layers/controls/layers-config.ts b/src/layers/controls/layers-config.ts new file mode 100644 index 00000000..9e2c8505 --- /dev/null +++ b/src/layers/controls/layers-config.ts @@ -0,0 +1,106 @@ +import { Button } from "./button"; +import { EventMapping } from "./nipple"; +import { Mapper } from "./keyboard"; + +import { GridType } from "./grid"; + +export type LayerControlType = + "Options" | "Key" | "Keyboard" | + "Switch" | "ScreenMove" | + "PointerButton" | "NippleActivator"; + +export interface LayerPosition { + column: number; + row: number; +} + +export interface LayerControl extends LayerPosition { + type: LayerControlType, + symbol: string; +} + +export interface LayerKeyControl extends LayerControl { + mapTo: number[]; +} + +export interface LayerSwitchControl extends LayerControl { + layerName: string, +} + +export interface LayerScreenMoveControl extends LayerControl { + direction: "up" | "down" | "left" | "right" | + "up-left" | "up-right" | "down-left" | "down-right"; +} + +export interface LayerPointerButtonControl extends LayerControl { + button: 0 | 1; + click: boolean; +} + +// eslint-disable-next-line +export interface LayerNippleActivatorControl extends LayerControl { +} + +// eslint-disable-next-line +export interface LayerPointerResetControl extends LayerControl { +} + +// eslint-disable-next-line +export interface LayerPointerToggleControl extends LayerControl { +} + +export interface LayerPointerMoveControl extends LayerControl { + x: number; + y: number; +} + + +export interface LayerConfig { + grid: GridType, + title: string, + controls: LayerControl[], +} + +export interface LayersConfig { + version: number, + layers: LayerConfig[], +} + + +export interface LegacyLayerConfig { + name: string, + buttons: Button[], + gestures: EventMapping[], + mapper: Mapper, +} + +export type LegacyLayersConfig = {[index: string]: LegacyLayerConfig}; + +export function extractLayersConfig(config: any): LayersConfig | LegacyLayersConfig | null { + if (config.layersConfig !== undefined) { + if (config.layersConfig.version === 1) { + migrateV1ToV2(config.layersConfig); + } + + return config.layersConfig; + } + + if (config.layers !== undefined) { + return config.layers; + } + + return null; +} + +function migrateV1ToV2(config: LayersConfig) { + for (const layer of config.layers) { + for (const control of layer.controls) { + if (control.type === "Key") { + const keyControl = control as LayerKeyControl; + if (typeof keyControl.mapTo === "number") { + keyControl.mapTo = [keyControl.mapTo]; + } + } + } + } +} diff --git a/src/layers/controls/layers-control.ts b/src/layers/controls/layers-control.ts new file mode 100644 index 00000000..86e06d28 --- /dev/null +++ b/src/layers/controls/layers-control.ts @@ -0,0 +1,723 @@ +import { Layers } from "../dom/layers"; +import { CommandInterface } from "emulators"; +import { + LayersConfig, LayerConfig, LayerKeyControl, + LayerControl, LayerSwitchControl, LayerScreenMoveControl, + LayerPointerButtonControl, LayerPointerMoveControl, LayerPointerResetControl, + LayerPointerToggleControl, LayerNippleActivatorControl, +} from "./layers-config"; +import { Cell, getGrid, GridConfiguration } from "./grid"; +import { createButton } from "./button"; +import { DosInstance } from "../js-dos"; +import { keyboard } from "./keyboard"; +import { mouse } from "./mouse/mouse-common"; +import { options } from "./options"; +import { pointer } from "../dom/pointer"; + +// eslint-disable-next-line +const nipplejs = require("nipplejs"); + +export function initLayersControl( + layers: Layers, + layersConfig: LayersConfig, + ci: CommandInterface, + dosInstance: DosInstance, + mirrored: boolean, + scale: number, + layerName?: string): () => void { + let selectedLayer = layersConfig.layers[0]; + if (layerName !== undefined) { + for (const next of layersConfig.layers) { + if (next.title === layerName) { + selectedLayer = next; + break; + } + } + } + return initLayerConfig(selectedLayer, layers, ci, dosInstance, mirrored, scale); +} + +interface CellWithMeta extends Cell { + hidden?: boolean, +} + +interface Sensor { + activate: () => void; + deactivate: () => void; +} + +interface MirroredInfo { + leftStart: number; + leftEnd: number; + rightStart: number; + rightEnd: number; +} + +class ControlSensors { + sensors: { [key: string]: Sensor } = {}; + + activate(row: number, column: number) { + const sensor = this.sensors[column + "_" + row]; + if (sensor !== undefined) { + sensor.activate(); + } + } + + deactivate(row: number, column: number) { + const sensor = this.sensors[column + "_" + row]; + if (sensor !== undefined) { + sensor.deactivate(); + } + } + + register(row: number, column: number, sensor: Sensor) { + this.sensors[column + "_" + row] = sensor; + } +} + +type ControlFactory = (control: any, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) => () => void; + +const factoryMapping: { [type: string]: ControlFactory } = { + Key: createKeyControl, + Options: createOptionsControl, + Keyboard: createKeyboardControl, + Switch: createSwitchControl, + ScreenMove: createScreenMoveControl, + PointerButton: createPointerButtonControl, + PointerMove: createPointerMoveControl, + PointerReset: createPointerResetControl, + PointerToggle: createPointerToggleControl, + NippleActivator: createNippleActivatorControl, +}; + +function initLayerConfig(layerConfig: LayerConfig, + layers: Layers, + ci: CommandInterface, + dosInstance: DosInstance, + mirrored: boolean, + scale: number): () => void { + const unbindKeyboard = keyboard(layers, ci); + const unbindMouse = mouse(dosInstance.autolock, dosInstance.sensitivity, layers, ci); + + const unbindControls: (() => void)[] = []; + function rebuild(width: number, height: number) { + for (const next of unbindControls) { + next(); + } + unbindControls.splice(0, unbindControls.length); + + const grid = getGrid(layerConfig.grid); + const gridConfig = grid.getConfiguration(width, height, scale); + const sensors = new ControlSensors(); + + for (const next of layerConfig.controls) { + const { row, column, type } = next; + if (type === "NippleActivator") { + setHiddenAround(gridConfig, row, column); + } + } + + let doOffsetColumnInRow = -1; + if (layers.options.optionControls?.length === 0) { + for (const next of layerConfig.controls) { + const { row, type } = next; + if (type === "Options") { + doOffsetColumnInRow = row; + break; + } + } + } + + const mirroredInfo: { [row: number]: MirroredInfo } = {}; + if (mirrored) { + for (const next of layerConfig.controls) { + const { row } = next; + let column = next.column; + const columnsCount = gridConfig.cells[row].length; + const middleColumn = columnsCount / 2; + + if (row === doOffsetColumnInRow && column >= middleColumn) { + column = Math.min(column + 1, columnsCount - 1); + } + + if (mirroredInfo[row] === undefined) { + mirroredInfo[row] = { + leftStart: middleColumn, + leftEnd: 0, + rightStart: columnsCount - 1, + rightEnd: middleColumn, + }; + } + + if (column < middleColumn) { + mirroredInfo[row].leftStart = Math.min(mirroredInfo[row].leftStart, column); + mirroredInfo[row].leftEnd = Math.max(mirroredInfo[row].leftEnd, column); + } else { + mirroredInfo[row].rightStart = Math.min(mirroredInfo[row].rightStart, column); + mirroredInfo[row].rightEnd = Math.max(mirroredInfo[row].rightEnd, column); + } + } + } + + for (const next of layerConfig.controls) { + const factory = factoryMapping[next.type]; + if (factory === undefined) { + console.error("Factory for control '" + next.type + "' is not defined"); + continue; + } + + const copy = { ...next }; + const columnsCount = gridConfig.cells[next.row].length; + const middleColumn = columnsCount / 2; + if (doOffsetColumnInRow === next.row && next.column >= middleColumn) { + copy.column = Math.min(copy.column + 1, columnsCount - 1); + } + + if (mirrored) { + const { leftStart, leftEnd, rightStart, rightEnd } = mirroredInfo[copy.row]; + const leftSide = copy.column < middleColumn; + if (leftSide) { + copy.column += middleColumn + (middleColumn - leftEnd) - leftStart - 1; + } else { + copy.column -= middleColumn + (rightStart - middleColumn) - (columnsCount - rightEnd) + 1; + } + + if (copy.column >= columnsCount) { + console.error("Column", copy.column, "is out of bound", + columnsCount, leftSide ? "[leftSide]" : "[rightSide]", mirroredInfo); + copy.column = columnsCount - 1; + } else if (copy.column < 0) { + console.error("Column", copy.column, "is out of bound", + 0, leftSide ? "[leftSide]" : "[rightSide]", mirroredInfo); + copy.column = 0; + } + } + + const unbind = factory(copy, layers, ci, gridConfig, sensors, dosInstance); + unbindControls.push(unbind); + } + } + + layers.addOnResize(rebuild); + rebuild(layers.width, layers.height); + + return () => { + layers.removeOnResize(rebuild); + unbindKeyboard(); + unbindMouse(); + for (const next of unbindControls) { + next(); + } + }; +} + +function createKeyControl(keyControl: LayerKeyControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + const { cells, columnWidth } = gridConfig; + const { row, column } = keyControl; + const { centerX, centerY } = cells[row][column]; + + const handler = { + onDown: () => { + for (const next of keyControl.mapTo) { + ci.sendKeyEvent(next, true); + } + }, + onUp: () => { + for (const next of keyControl.mapTo) { + ci.sendKeyEvent(next, false); + } + }, + }; + + sensors.register(row, column, { + activate: handler.onDown, + deactivate: handler.onUp, + }); + + if (isHidden(gridConfig, row, column)) { + return () => {}; + } + + const button = createButton(keyControl.symbol, handler, columnWidth); + + button.style.position = "absolute"; + button.style.left = (centerX - button.widthPx / 2) + "px"; + button.style.top = (centerY - button.heightPx / 2) + "px"; + + layers.mouseOverlay.appendChild(button); + return () => layers.mouseOverlay.removeChild(button); +} + +function createOptionsControl(optionControl: LayerControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + if (layers.options.optionControls?.length === 0) { + return () => {/**/}; + } + + if (layers.options.optionControls !== undefined && + layers.options.optionControls.length === 1 && + layers.options.optionControls[0] === "keyboard") { + return createKeyboardControl(optionControl, layers, ci, gridConfig, sensors, dosInstance); + } + + const { cells, columnWidth, rowHeight } = gridConfig; + const { row, column } = optionControl; + const { centerX, centerY } = cells[row][column]; + + const top = centerY - rowHeight / 2; + const left = centerX - columnWidth / 2; + const right = gridConfig.width - left - columnWidth; + + return options(layers, ["default"], () => {/**/}, + columnWidth, + top, + right); +} + +function createKeyboardControl(keyboardControl: LayerControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + const { cells, columnWidth } = gridConfig; + const { row, column } = keyboardControl; + const { centerX, centerY } = cells[row][column]; + + const button = createButton("keyboard", { + onUp: () => layers.toggleKeyboard(), + }, columnWidth); + + const onKeyboardVisibility = (visible: boolean) => { + if (visible) { + button.children[0].classList.add("emulator-control-close-icon"); + } else { + button.children[0].classList.remove("emulator-control-close-icon"); + } + }; + layers.setOnKeyboardVisibility(onKeyboardVisibility); + + button.style.position = "absolute"; + button.style.left = (centerX - button.widthPx / 2) + "px"; + button.style.top = (centerY - button.heightPx / 2) + "px"; + + layers.mouseOverlay.appendChild(button); + return () => { + layers.mouseOverlay.removeChild(button); + layers.removeOnKeyboardVisibility(onKeyboardVisibility); + }; +} + +function createSwitchControl(switchControl: LayerSwitchControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + const { cells, columnWidth } = gridConfig; + const { row, column } = switchControl; + const { centerX, centerY } = cells[row][column]; + + const button = createButton(switchControl.symbol, { + onUp: () => dosInstance.setLayersConfig(dosInstance.getLayersConfig(), switchControl.layerName), + }, columnWidth); + + button.style.position = "absolute"; + button.style.left = (centerX - button.widthPx / 2) + "px"; + button.style.top = (centerY - button.heightPx / 2) + "px"; + + layers.mouseOverlay.appendChild(button); + return () => { + layers.mouseOverlay.removeChild(button); + }; +} + +function createScreenMoveControl(screenMoveControl: LayerScreenMoveControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + const { cells, columnWidth } = gridConfig; + const { row, column } = screenMoveControl; + const { centerX, centerY } = cells[row][column]; + + let mX = 0.5; + let mY = 0.5; + + if (screenMoveControl.direction.indexOf("up") >= 0) { + mY = 0; + } + + if (screenMoveControl.direction.indexOf("down") >= 0) { + mY = 1; + } + + if (screenMoveControl.direction.indexOf("left") >= 0) { + mX = 0; + } + + if (screenMoveControl.direction.indexOf("right") >= 0) { + mX = 1; + } + + const handler = { + onDown: () => { + ci.sendMouseMotion(mX, mY); + }, + onUp: () => { + ci.sendMouseMotion(0.5, 0.5); + }, + }; + + sensors.register(row, column, { + activate: handler.onDown, + deactivate: handler.onUp, + }); + + if (isHidden(gridConfig, row, column)) { + return () => {}; + } + + const button = createButton(screenMoveControl.symbol, handler, columnWidth); + + button.style.position = "absolute"; + button.style.left = (centerX - button.widthPx / 2) + "px"; + button.style.top = (centerY - button.heightPx / 2) + "px"; + + layers.mouseOverlay.appendChild(button); + return () => layers.mouseOverlay.removeChild(button); +} + +function createPointerButtonControl(pointerButtonControl: LayerPointerButtonControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + const { cells, columnWidth } = gridConfig; + const { row, column, click } = pointerButtonControl; + const { centerX, centerY } = cells[row][column]; + + const handler = { + onDown: () => { + if (!click) { + layers.pointerButton = pointerButtonControl.button; + } else { + ci.sendMouseButton(pointerButtonControl.button, true); + } + }, + onUp: () => { + if (!click) { + layers.pointerButton = 0; + } else { + ci.sendMouseButton(pointerButtonControl.button, false); + } + }, + }; + + sensors.register(row, column, { + activate: handler.onDown, + deactivate: handler.onUp, + }); + + if (isHidden(gridConfig, row, column)) { + return () => {}; + } + + const button = createButton(pointerButtonControl.symbol, handler, columnWidth); + + button.style.position = "absolute"; + button.style.left = (centerX - button.widthPx / 2) + "px"; + button.style.top = (centerY - button.heightPx / 2) + "px"; + + layers.mouseOverlay.appendChild(button); + return () => layers.mouseOverlay.removeChild(button); +} + +function createPointerMoveControl(pointerMoveControl: LayerPointerMoveControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + const { cells, columnWidth } = gridConfig; + const { row, column, x, y } = pointerMoveControl; + const { centerX, centerY } = cells[row][column]; + + const handler = { + onDown: () => { + ci.sendMouseMotion(x, y); + }, + onUp: () => { + ci.sendMouseMotion(x, y); + }, + }; + + sensors.register(row, column, { + activate: handler.onDown, + deactivate: handler.onUp, + }); + + if (isHidden(gridConfig, row, column)) { + return () => {}; + } + + const button = createButton(pointerMoveControl.symbol, handler, columnWidth); + + button.style.position = "absolute"; + button.style.left = (centerX - button.widthPx / 2) + "px"; + button.style.top = (centerY - button.heightPx / 2) + "px"; + + layers.mouseOverlay.appendChild(button); + return () => layers.mouseOverlay.removeChild(button); +} + +function createPointerResetControl(pointerResetControl: LayerPointerResetControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + const { cells, columnWidth } = gridConfig; + const { row, column } = pointerResetControl; + const { centerX, centerY } = cells[row][column]; + + const handler = { + onDown: () => { + ci.sendMouseSync(); + }, + }; + + sensors.register(row, column, { + activate: handler.onDown, + deactivate: () => { }, + }); + + if (isHidden(gridConfig, row, column)) { + return () => {}; + } + + const button = createButton(pointerResetControl.symbol, handler, columnWidth); + + button.style.position = "absolute"; + button.style.left = (centerX - button.widthPx / 2) + "px"; + button.style.top = (centerY - button.heightPx / 2) + "px"; + + layers.mouseOverlay.appendChild(button); + return () => layers.mouseOverlay.removeChild(button); +} + +function createPointerToggleControl(pointerToggleControl: LayerPointerToggleControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + const { cells, columnWidth } = gridConfig; + const { row, column } = pointerToggleControl; + const { centerX, centerY } = cells[row][column]; + + const handler = { + onDown: () => { + layers.pointerDisabled = !layers.pointerDisabled; + if (layers.pointerDisabled) { + if (!button.classList.contains("emulator-button-highlight")) { + button.classList.add("emulator-button-highlight"); + } + } else { + button.classList.remove("emulator-button-highlight"); + } + }, + }; + + sensors.register(row, column, { + activate: handler.onDown, + deactivate: () => { }, + }); + + if (isHidden(gridConfig, row, column)) { + return () => {}; + } + + const button = createButton(pointerToggleControl.symbol, handler, columnWidth); + + button.style.position = "absolute"; + button.style.left = (centerX - button.widthPx / 2) + "px"; + button.style.top = (centerY - button.heightPx / 2) + "px"; + + layers.mouseOverlay.appendChild(button); + return () => layers.mouseOverlay.removeChild(button); +} + +function createNippleActivatorControl(nippleActivatorControl: LayerNippleActivatorControl, + layers: Layers, + ci: CommandInterface, + gridConfig: GridConfiguration, + sensors: ControlSensors, + dosInstance: DosInstance) { + const { cells, columnWidth, rowHeight, width, height } = gridConfig; + const { row, column } = nippleActivatorControl; + const { centerX, centerY } = cells[row][column]; + + const nippleContainer = document.createElement("div"); + const cellSize = 1.5; + const left = Math.max(0, centerX - columnWidth * cellSize); + const top = Math.max(0, centerY - rowHeight * cellSize); + const right = Math.max(0, width - centerX - columnWidth * cellSize); + const bottom = Math.max(0, height - centerY - rowHeight * cellSize); + + nippleContainer.style.position = "absolute"; + nippleContainer.style.zIndex = "999"; + nippleContainer.style.left = left + "px"; + nippleContainer.style.top = top + "px"; + nippleContainer.style.right = right + "px"; + nippleContainer.style.bottom = bottom + "px"; + + layers.mouseOverlay.appendChild(nippleContainer); + + const manager = nipplejs.create({ + zone: nippleContainer, + multitouch: false, + maxNumberOfNipples: 1, + mode: "static", + follow: false, + dynamicPage: true, + size: Math.max(columnWidth, rowHeight) * 1.5, + position: { + left: (width - right - left) / 2 + "px", + top: (height - bottom - top) / 2 + "px", + }, + }); + + let activeColumn = -1; + let activeRow = -1; + manager.on("move", (evt: any, data: any) => { + if (data.distance < 10) { + sensors.deactivate(activeRow, activeColumn); + activeColumn = -1; + activeRow = -1; + return; + } + let targetColumn = -1; + let targetRow = -1; + const step = 360 / 8; + const half = step / 2; + const degree = data.angle.degree; + if (degree > half && degree <= half + step) { + // console.log("up-right") + targetColumn = column + 1; + targetRow = row - 1; + } else if (degree > half + step && degree <= half + step * 2) { + // console.log("up"); + targetColumn = column; + targetRow = row - 1; + } else if (degree > half + step * 2 && degree <= half + step * 3) { + // console.log("up-left"); + targetColumn = column - 1; + targetRow = row - 1; + } else if (degree > half + step * 3 && degree <= half + step * 4) { + // console.log("left"); + targetColumn = column - 1; + targetRow = row; + } else if (degree > half + step * 4 && degree <= half + step * 5) { + // console.log("down-left"); + targetColumn = column - 1; + targetRow = row + 1; + } else if (degree > half + step * 5 && degree <= half + step * 6) { + // console.log("down") + targetColumn = column; + targetRow = row + 1; + } else if (degree > half + step * 6 && degree <= half + step * 7) { + // console.log("down-right"); + targetColumn = column + 1; + targetRow = row + 1; + } else { + // console.log("right"); + targetColumn = column + 1; + targetRow = row; + } + + if (activeColumn !== targetColumn || activeRow !== targetRow) { + sensors.deactivate(activeRow, activeColumn); + sensors.activate(targetRow, targetColumn); + activeColumn = targetColumn; + activeRow = targetRow; + } + }); + + let started = false; + manager.on("start", () => { + started = true; + }); + + manager.on("end", () => { + started = false; + sensors.deactivate(activeRow, activeColumn); + activeRow = -1; + activeColumn = -1; + }); + + const options = { + capture: true, + }; + + function onEnd(e: Event) { + if (started) { + manager.processOnEnd(e); + } + } + + for (const next of pointer.enders) { + layers.mouseOverlay.addEventListener(next, onEnd, options); + } + + return () => { + manager.destroy(); + layers.mouseOverlay.removeChild(nippleContainer); + for (const next of pointer.enders) { + layers.mouseOverlay.removeEventListener(next, onEnd, options); + } + }; +} + +function isHidden(config: GridConfiguration, row: number, column: number) { + return (config.cells[row][column] as CellWithMeta).hidden === true; +} + +function setHiddenAround(config: GridConfiguration, + centralRow: number, + centralColumn: number) { + function setHiddenIfValid(row: number, column: number) { + if (row === centralRow && column === centralColumn) { + return; + } + + if (row >= 0 && row < config.cells.length) { + const rowCells = config.cells[row]; + if (column >= 0 && column < rowCells.length) { + (rowCells[column] as CellWithMeta).hidden = true; + } + } + } + + for (let ri = centralRow - 1; ri <= centralRow + 1; ++ri) { + for (let ci = centralColumn - 1; ci <= centralColumn + 1; ++ci) { + setHiddenIfValid(ri, ci); + } + } +} diff --git a/src/layers/controls/layout.ts b/src/layers/controls/layout.ts new file mode 100644 index 00000000..f7ee82cd --- /dev/null +++ b/src/layers/controls/layout.ts @@ -0,0 +1,6 @@ +export interface LayoutPosition { + left?: 1 | 2, + top?: 1 | 2, + right?: 1 | 2, + bottom?: 1 | 2, +} diff --git a/src/layers/controls/legacy-layers-control.ts b/src/layers/controls/legacy-layers-control.ts new file mode 100644 index 00000000..3c96cff8 --- /dev/null +++ b/src/layers/controls/legacy-layers-control.ts @@ -0,0 +1,69 @@ +import { LegacyLayersConfig } from "./layers-config"; +import { Layers } from "../dom/layers"; +import { CommandInterface } from "emulators"; +import { deprecatedButton } from "./button"; +import { mouse } from "./mouse/mouse-common"; +import { nipple } from "./nipple"; +import { options } from "./options"; +import { keyboard } from "./keyboard"; +import { DosInstance } from "../js-dos"; + +export function initLegacyLayersControl( + dosInstance: DosInstance, + layers: Layers, + layersConfig: LegacyLayersConfig, + ci: CommandInterface) { + const layersNames = Object.keys(layersConfig); + + const unbind = { + keyboard: () => {/**/}, + mouse: () => {/**/}, + gestures: () => {/**/}, + buttons: () => {/**/}, + }; + + const changeControlLayer = (layerName: string) => { + unbind.keyboard(); + unbind.mouse(); + unbind.gestures(); + unbind.buttons(); + + unbind.keyboard = () => {/**/}; + unbind.mouse = () => {/**/}; + unbind.gestures = () => {/**/}; + unbind.buttons = () => {/**/}; + + const layer = layersConfig[layerName]; + if (layer === undefined) { + return; + } + + unbind.keyboard = keyboard(layers, ci, layer.mapper); + + if (layer.gestures !== undefined && layer.gestures.length > 0) { + unbind.gestures = nipple(layers, ci, layer.gestures); + } else { + unbind.mouse = mouse(dosInstance.autolock, dosInstance.sensitivity, layers, ci); + } + + if (layer.buttons !== undefined && layer.buttons.length) { + unbind.buttons = deprecatedButton(layers, ci, layer.buttons, 54); + } + }; + + + const unbindOptions = + (layers.options.optionControls?.length === 0) ? + () => {/**/} : + options(layers, layersNames, changeControlLayer, 54, 54 / 4, 0); + + changeControlLayer("default"); + + return () => { + unbind.gestures(); + unbind.buttons(); + unbind.mouse(); + unbind.keyboard(); + unbindOptions(); + }; +} diff --git a/src/layers/controls/mouse/mouse-common.ts b/src/layers/controls/mouse/mouse-common.ts new file mode 100644 index 00000000..10464b24 --- /dev/null +++ b/src/layers/controls/mouse/mouse-common.ts @@ -0,0 +1,186 @@ +import { CommandInterface } from "emulators"; +import { Layers } from "../../dom/layers"; +import { pointer, getPointerState } from "../../dom/pointer"; +import { mouseSwipe } from "./mouse-swipe"; +import { mouseNotLocked } from "./mouse-not-locked"; +import { mouseLocked } from "./mouse-locked"; + +const insensitivePadding = 1 / 100; + +export function mapXY(eX: number, eY: number, + ci: CommandInterface, layers: Layers) { + const frameWidth = ci.width(); + const frameHeight = ci.height(); + const containerWidth = layers.width; + const containerHeight = layers.height; + + const aspect = frameWidth / frameHeight; + + let width = containerWidth; + let height = containerWidth / aspect; + + if (height > containerHeight) { + height = containerHeight; + width = containerHeight * aspect; + } + + const top = (containerHeight - height) / 2; + const left = (containerWidth - width) / 2; + + let x = Math.max(0, Math.min(1, (eX - left) / width)); + let y = Math.max(0, Math.min(1, (eY - top) / height)); + + if (x <= insensitivePadding) { + x = 0; + } + + if (x >= (1 - insensitivePadding)) { + x = 1; + } + + if (y <= insensitivePadding) { + y = 0; + } + + if (y >= (1 - insensitivePadding)) { + y = 1; + } + + return { + x, + y, + }; +} + +export function mount(el: HTMLDivElement, layers: Layers, + onMouseDown: (x: number, y: number, button: number) => void, + onMouseMove: (x: number, y: number, mX: number, mY: number) => void, + onMouseUp: (x: number, y: number, button: number) => void, + onMouseLeave: (x: number, y: number) => void) { + // eslint-disable-next-line + function preventDefaultIfNeeded(e: Event) { + // not needed yet + } + + let pressedButton = 0; + const onStart = (e: Event) => { + if (e.target !== el) { + return; + } + + if (layers.pointerDisabled) { + e.stopPropagation(); + preventDefaultIfNeeded(e); + return; + } + + const state = getPointerState(e, el); + pressedButton = state.button || layers.pointerButton; + onMouseDown(state.x, state.y, pressedButton); + + e.stopPropagation(); + preventDefaultIfNeeded(e); + }; + + const onChange = (e: Event) => { + if (e.target !== el) { + return; + } + + if (layers.pointerDisabled) { + e.stopPropagation(); + preventDefaultIfNeeded(e); + return; + } + + const state = getPointerState(e, el); + onMouseMove(state.x, state.y, state.mX, state.mY); + e.stopPropagation(); + preventDefaultIfNeeded(e); + }; + + const onEnd = (e: Event) => { + if (layers.pointerDisabled) { + e.stopPropagation(); + preventDefaultIfNeeded(e); + return; + } + + const state = getPointerState(e, el); + onMouseUp(state.x, state.y, pressedButton); + e.stopPropagation(); + preventDefaultIfNeeded(e); + }; + + const onLeave = (e: Event) => { + if (e.target !== el) { + return; + } + + if (layers.pointerDisabled) { + e.stopPropagation(); + preventDefaultIfNeeded(e); + return; + } + + const state = getPointerState(e, el); + onMouseLeave(state.x, state.y); + e.stopPropagation(); + preventDefaultIfNeeded(e); + }; + + const onPrevent = (e: Event) => { + e.stopPropagation(); + preventDefaultIfNeeded(e); + }; + + const options = { + capture: false, + }; + + for (const next of pointer.starters) { + el.addEventListener(next, onStart, options); + } + for (const next of pointer.changers) { + el.addEventListener(next, onChange, options); + } + for (const next of pointer.enders) { + el.addEventListener(next, onEnd, options); + } + for (const next of pointer.prevents) { + el.addEventListener(next, onPrevent, options); + } + for (const next of pointer.leavers) { + el.addEventListener(next, onLeave, options); + } + + return () => { + for (const next of pointer.starters) { + el.removeEventListener(next, onStart, options); + } + for (const next of pointer.changers) { + el.removeEventListener(next, onChange, options); + } + for (const next of pointer.enders) { + el.removeEventListener(next, onEnd, options); + } + for (const next of pointer.prevents) { + el.removeEventListener(next, onPrevent, options); + } + for (const next of pointer.leavers) { + el.removeEventListener(next, onLeave, options); + } + }; +} + +export function mouse(autolock: boolean, sensitivity: number, layers: Layers, ci: CommandInterface) { + if (autolock && !pointer.canLock) { + return mouseSwipe(sensitivity, layers, ci); + } + + if (autolock) { + return mouseLocked(sensitivity, layers, ci); + } + + return mouseNotLocked(layers, ci); +} diff --git a/src/layers/controls/mouse/mouse-locked.ts b/src/layers/controls/mouse/mouse-locked.ts new file mode 100644 index 00000000..fb1d4d80 --- /dev/null +++ b/src/layers/controls/mouse/mouse-locked.ts @@ -0,0 +1,51 @@ +import { CommandInterface } from "emulators"; +import { Layers } from "../../dom/layers"; +import { mount } from "./mouse-common"; + +export function mouseLocked(sensitivity: number, layers: Layers, ci: CommandInterface) { + const el = layers.mouseOverlay; + + function isNotLocked() { + return document.pointerLockElement !== el; + } + + function onMouseDown(x: number, y: number, button: number) { + if (isNotLocked()) { + const requestPointerLock = el.requestPointerLock || + (el as any).mozRequestPointerLock || + (el as any).webkitRequestPointerLock; + + requestPointerLock.call(el); + + return; + } + + ci.sendMouseButton(button, true); + } + + function onMouseUp(x: number, y: number, button: number) { + if (isNotLocked()) { + return; + } + + ci.sendMouseButton(button, false); + } + + function onMouseMove(x: number, y: number, mX: number, mY: number) { + if (isNotLocked()) { + return; + } + + if (mX === 0 && mY === 0) { + return; + } + + (ci as any).sendMouseRelativeMotion(mX * sensitivity, mY * sensitivity); + } + + function onMouseLeave(x: number, y: number) { + // nothing to do + } + + return mount(el, layers, onMouseDown, onMouseMove, onMouseUp, onMouseLeave); +} diff --git a/src/layers/controls/mouse/mouse-nipple.ts b/src/layers/controls/mouse/mouse-nipple.ts new file mode 100644 index 00000000..188595fe --- /dev/null +++ b/src/layers/controls/mouse/mouse-nipple.ts @@ -0,0 +1,77 @@ +// eslint-disable-next-line +const nipplejs = require("nipplejs"); + +import { CommandInterface } from "emulators"; +import { Layers } from "../../dom/layers"; +import { pointer } from "../../dom/pointer"; + +export function mouseNipple(sensitivity: number, layers: Layers, ci: CommandInterface) { + const el = layers.mouseOverlay; + const options = { + capture: true, + }; + + let startedAt = -1; + const onStart = () => { + startedAt = Date.now(); + }; + + const onEnd = () => { + const delay = Date.now() - startedAt; + if (delay < 500) { + const button = layers.pointerButton || 0; + ci.sendMouseButton(button, true); + setTimeout(() => ci.sendMouseButton(button, false), 16); + } + }; + + for (const next of pointer.starters) { + el.addEventListener(next, onStart, options); + } + for (const next of pointer.enders) { + el.addEventListener(next, onEnd, options); + } + + const nipple = nipplejs.create({ + zone: el, + multitouch: false, + maxNumberOfNipples: 1, + mode: "dynamic", + }); + + let dx = 0; + let dy = 0; + + const intervalId = setInterval(() => { + (ci as any).sendMouseRelativeMotion(dx, dy); + }, 16); + + nipple.on("start", () => { + startedAt = Date.now(); + dx = 0; + dy = 0; + }); + + nipple.on("move", function(evt: any, data: any) { + const { x, y } = data.vector; + + dx = x * data.distance * sensitivity; + dy = -y * data.distance * sensitivity; + }); + + nipple.on("end", () => { + dx = 0; + dy = 0; + }); + + return () => { + for (const next of pointer.starters) { + el.removeEventListener(next, onStart, options); + } + for (const next of pointer.enders) { + el.removeEventListener(next, onEnd, options); + } + clearInterval(intervalId); + nipple.destroy(); + }; +} diff --git a/src/layers/controls/mouse/mouse-not-locked.ts b/src/layers/controls/mouse/mouse-not-locked.ts new file mode 100644 index 00000000..b3e3eb99 --- /dev/null +++ b/src/layers/controls/mouse/mouse-not-locked.ts @@ -0,0 +1,37 @@ +import { CommandInterface } from "emulators"; +import { Layers } from "../../dom/layers"; + +import { mapXY as doMapXY, mount } from "./mouse-common"; + +export function mouseNotLocked(layers: Layers, ci: CommandInterface) { + const el = layers.mouseOverlay; + const mapXY = (x: number, y: number) => doMapXY(x, y, ci, layers); + + if (document.pointerLockElement === el) { + document.exitPointerLock(); + } + + function onMouseDown(x: number, y: number, button: number) { + const xy = mapXY(x, y); + ci.sendMouseMotion(xy.x, xy.y); + ci.sendMouseButton(button, true); + } + + function onMouseUp(x: number, y: number, button: number) { + const xy = mapXY(x, y); + ci.sendMouseMotion(xy.x, xy.y); + ci.sendMouseButton(button, false); + } + + function onMouseMove(x: number, y: number, mX: number, mY: number) { + const xy = mapXY(x, y); + ci.sendMouseMotion(xy.x, xy.y); + } + + function onMouseLeave(x: number, y: number) { + const xy = mapXY(x, y); + ci.sendMouseMotion(xy.x, xy.y); + } + + return mount(el, layers, onMouseDown, onMouseMove, onMouseUp, onMouseLeave); +} diff --git a/src/layers/controls/mouse/mouse-swipe.ts b/src/layers/controls/mouse/mouse-swipe.ts new file mode 100644 index 00000000..ac44c6c7 --- /dev/null +++ b/src/layers/controls/mouse/mouse-swipe.ts @@ -0,0 +1,58 @@ +import { CommandInterface } from "emulators"; +import { Layers } from "../../dom/layers"; + +import { mount } from "./mouse-common"; + +const clickDelay = 500; +const clickThreshold = 50; + +export function mouseSwipe(sensitivity: number, layers: Layers, ci: CommandInterface) { + const el = layers.mouseOverlay; + + let startedAt = -1; + let acc = 0; + let prevX = 0; + let prevY = 0; + + const onMouseDown = (x: number, y: number) => { + startedAt = Date.now(); + acc = 0; + prevX = x; + prevY = y; + }; + + function onMouseMove(x: number, y: number, mX: number, mY: number) { + if (mX === undefined) { + mX = x - prevX; + } + + if (mY === undefined) { + mY = y - prevY; + } + + prevX = x; + prevY = y; + + if (mX === 0 && mY === 0) { + return; + } + + acc += Math.abs(mX) + Math.abs(mY); + + (ci as any).sendMouseRelativeMotion(mX * sensitivity * 2, mY * sensitivity * 2); + } + + const onMouseUp = (x: number, y: number) => { + const delay = Date.now() - startedAt; + + if (delay < clickDelay && acc < clickThreshold) { + const button = layers.pointerButton || 0; + ci.sendMouseButton(button, true); + setTimeout(() => ci.sendMouseButton(button, false), 60); + } + }; + + const noop = () => {}; + + return mount(el, layers, onMouseDown, onMouseMove, onMouseUp, noop); +} diff --git a/src/layers/controls/nipple.ts b/src/layers/controls/nipple.ts new file mode 100644 index 00000000..f14e3e8c --- /dev/null +++ b/src/layers/controls/nipple.ts @@ -0,0 +1,84 @@ +// eslint-disable-next-line +const nipplejs = require("nipplejs"); + +import { KBD_NONE } from "../dom/keys"; + +import { CommandInterface } from "emulators"; +import { Layers } from "../dom/layers"; + +export type Event = + "dir:up" | "dir:down" | "dir:left" | "dir:right" | + "plain:up" | "plain:down" | "plain:left" | "plain:right" | + "end:release" | "tap"; + +export interface EventMapping { + joystickId: 0 | 1, + event: Event, + mapTo: number, +} + +export function nipple(layers: Layers, + ci: CommandInterface, + mapping: EventMapping[]) { + const manager = nipplejs.create({ + zone: layers.mouseOverlay, + multitouch: true, + maxNumberOfNipples: 2, + }); + + let pressed = -1; + + const press = (keyCode: number) => { + layers.fireKeyDown(keyCode); + pressed = keyCode; + }; + + const release = () => { + if (pressed !== -1) { + layers.fireKeyUp(pressed); + pressed = -1; + } + }; + + const releaseOnEnd: {[index: number]: boolean} = {}; + const tapJoysticks: {[index: number]: number} = {}; + const usedTimes: {[index: number]: number} = { + }; + for (const next of mapping) { + if (next.event === "end:release") { + releaseOnEnd[next.joystickId] = true; + } else if (next.mapTo !== KBD_NONE) { + if (next.event === "tap") { + tapJoysticks[next.joystickId] = next.mapTo; + } else { + manager.on(next.event, () => { + usedTimes[next.joystickId] = Date.now(); + release(); + press(next.mapTo); + }); + } + } + } + + const startTimes: {[index: number]: number} = {}; + manager.on("start", () => { + const id = manager.ids.length - 1; + startTimes[id] = Date.now(); + }); + + manager.on("end", () => { + const id = manager.ids.length - 1; + const delay = Date.now() - startTimes[id]; + + if (releaseOnEnd[id] === true) { + release(); + } + + if (tapJoysticks[id] && delay < 500 && usedTimes[id] < startTimes[id]) { + layers.fireKeyPress(tapJoysticks[id]); + } + }); + + return () => manager.destroy(); +} + diff --git a/src/layers/controls/null-layers-control.ts b/src/layers/controls/null-layers-control.ts new file mode 100644 index 00000000..9796e45f --- /dev/null +++ b/src/layers/controls/null-layers-control.ts @@ -0,0 +1,24 @@ +import { Layers } from "../dom/layers"; +import { CommandInterface } from "emulators"; +import { keyboard } from "./keyboard"; +import { mouse } from "./mouse/mouse-common"; +import { options } from "./options"; +import { DosInstance } from "../js-dos"; + +export function initNullLayersControl( + dosInstance: DosInstance, + layers: Layers, + ci: CommandInterface) { + const unbindKeyboard = keyboard(layers, ci); + const unbindMouse = mouse(dosInstance.autolock, dosInstance.sensitivity, layers, ci); + const unbindOptions = + (layers.options.optionControls?.length === 0) ? + () => {/**/} : + options(layers, ["default"], () => {/**/}, 54, 54 / 4, 0); + + return () => { + unbindKeyboard(); + unbindMouse(); + unbindOptions(); + }; +} diff --git a/src/layers/controls/options.ts b/src/layers/controls/options.ts new file mode 100644 index 00000000..49fc0ace --- /dev/null +++ b/src/layers/controls/options.ts @@ -0,0 +1,153 @@ +import { Layers } from "../dom/layers"; +import { createButton } from "./button"; +import { createDiv, stopPropagation } from "../dom/helpers"; + +export function options(layers: Layers, + layersNames: string[], + onLayerChange: (layer: string) => void, + size: number, + top: number, + right: number) { + const ident = Math.round(size / 4); + + let controlsVisbile = false; + let keyboardVisible = false; + + const updateVisibility = () => { + const display = controlsVisbile ? "flex" : "none"; + for (const next of children) { + if (next == options) { + continue; + } + + next.style.display = display; + } + }; + + const toggleOptions = () => { + controlsVisbile = !controlsVisbile; + + if (!controlsVisbile && keyboardVisible) { + layers.toggleKeyboard(); + } + + updateVisibility(); + }; + + const children: HTMLElement[] = [ + createSelectForLayers(layersNames, onLayerChange), + createButton("keyboard", { + onClick: () => { + layers.toggleKeyboard(); + + if (controlsVisbile && !keyboardVisible) { + controlsVisbile = false; + updateVisibility(); + } + }, + }, size), + createButton("save", { + onClick: () => { + layers.save(); + + if (controlsVisbile) { + toggleOptions(); + } + }, + }, size), + createButton("fullscreen", { + onClick: () => { + layers.toggleFullscreen(); + + if (controlsVisbile) { + toggleOptions(); + } + }, + }, size), + createButton("options", { + onClick: toggleOptions, + }, size), + ]; + const options = children[children.length - 1]; + const fullscreen = children[children.length - 2].children[0]; + const keyboard = children[children.length - 4].children[0]; + + const onKeyboardVisibility = (visible: boolean) => { + keyboardVisible = visible; + + if (visible) { + keyboard.classList.add("emulator-control-close-icon"); + } else { + keyboard.classList.remove("emulator-control-close-icon"); + } + }; + layers.setOnKeyboardVisibility(onKeyboardVisibility); + onKeyboardVisibility(layers.keyboardVisible); + + layers.setOnFullscreen((fullscreenEnabled) => { + if (fullscreenEnabled) { + if (!fullscreen.classList.contains("emulator-control-exit-fullscreen-icon")) { + fullscreen.classList.add("emulator-control-exit-fullscreen-icon"); + } + } else { + fullscreen.classList.remove("emulator-control-exit-fullscreen-icon"); + } + }); + + if (layers.fullscreen) { + fullscreen.classList.add("emulator-control-exit-fullscreen-icon"); + } + + const container = createDiv("emulator-options"); + const intialDisplay = keyboardVisible ? "flex" : "none"; + for (const next of children) { + if (next !== options) { + next.classList.add("emulator-button-control"); + } + next.style.marginRight = ident + "px"; + next.style.marginBottom = ident + "px"; + if (next !== options) { + next.style.display = intialDisplay; + } + container.appendChild(next); + } + + container.style.position = "absolute"; + container.style.right = right + "px"; + container.style.top = top + "px"; + + layers.mouseOverlay.appendChild(container); + + return () => { + layers.mouseOverlay.removeChild(container); + layers.setOnFullscreen(() => {/**/}); + layers.removeOnKeyboardVisibility(onKeyboardVisibility); + }; +} + +function createSelectForLayers(layers: string[], onChange: (layer: string) => void) { + if (layers.length <= 1) { + return document.createElement("div"); + } + + const select = document.createElement("select"); + select.classList.add("emulator-control-select"); + + + for (const next of layers) { + const option = document.createElement("option"); + option.value = next; + option.innerHTML = next; + select.appendChild(option); + } + + select.onchange = (e: any) => { + const layer = e.target.value; + onChange(layer); + }; + + stopPropagation(select, false); + + return select; +} + diff --git a/src/layers/dom/helpers.ts b/src/layers/dom/helpers.ts new file mode 100644 index 00000000..54b008bd --- /dev/null +++ b/src/layers/dom/helpers.ts @@ -0,0 +1,34 @@ +import { pointer } from "./pointer"; + +export function createDiv(className: string, innerHtml?: string) { + const el = document.createElement("div"); + el.className = className; + if (innerHtml !== undefined) { + el.innerHTML = innerHtml; + } + return el; +} + +export function stopPropagation(el: HTMLElement, preventDefault = true) { + const onStop = (e: Event) => { + e.stopPropagation(); + }; + const onPrevent = (e: Event) => { + e.stopPropagation(); + if (preventDefault) { + e.preventDefault(); + } + }; + const options = { + capture: false, + }; + for (const next of pointer.starters) { + el.addEventListener(next, onStop, options); + } + for (const next of pointer.enders) { + el.addEventListener(next, onStop, options); + } + for (const next of pointer.prevents) { + el.addEventListener(next, onPrevent, options); + } +} diff --git a/src/layers/dom/keys.ts b/src/layers/dom/keys.ts new file mode 100644 index 00000000..7edfbefc --- /dev/null +++ b/src/layers/dom/keys.ts @@ -0,0 +1,331 @@ +/* eslint-disable camelcase */ + +export const KBD_NONE = 0; +export const KBD_0 = 48; +export const KBD_1 = 49; +export const KBD_2 = 50; +export const KBD_3 = 51; +export const KBD_4 = 52; +export const KBD_5 = 53; +export const KBD_6 = 54; +export const KBD_7 = 55; +export const KBD_8 = 56; +export const KBD_9 = 57; +export const KBD_a = 65; +export const KBD_b = 66; +export const KBD_c = 67; +export const KBD_d = 68; +export const KBD_e = 69; +export const KBD_f = 70; +export const KBD_g = 71; +export const KBD_h = 72; +export const KBD_i = 73; +export const KBD_j = 74; +export const KBD_k = 75; +export const KBD_l = 76; +export const KBD_m = 77; +export const KBD_n = 78; +export const KBD_o = 79; +export const KBD_p = 80; +export const KBD_q = 81; +export const KBD_r = 82; +export const KBD_s = 83; +export const KBD_t = 84; +export const KBD_u = 85; +export const KBD_v = 86; +export const KBD_w = 87; +export const KBD_x = 88; +export const KBD_y = 89; +export const KBD_z = 90; +export const KBD_f1 = 290; +export const KBD_f2 = 291; +export const KBD_f3 = 292; +export const KBD_f4 = 293; +export const KBD_f5 = 294; +export const KBD_f6 = 295; +export const KBD_f7 = 296; +export const KBD_f8 = 297; +export const KBD_f9 = 298; +export const KBD_f10 = 299; +export const KBD_f11 = 300; +export const KBD_f12 = 301; + +export const KBD_kp0 = 320; +export const KBD_kp1 = 321; +export const KBD_kp2 = 322; +export const KBD_kp3 = 323; +export const KBD_kp4 = 324; +export const KBD_kp5 = 325; +export const KBD_kp6 = 326; +export const KBD_kp7 = 327; +export const KBD_kp8 = 328; +export const KBD_kp9 = 329; + +export const KBD_kpperiod = 330; +export const KBD_kpdivide = 331; +export const KBD_kpmultiply = 332; +export const KBD_kpminus = 333; +export const KBD_kpplus = 334; +export const KBD_kpenter = 335; + +export const KBD_esc = 256; +export const KBD_tab = 258; +export const KBD_backspace = 259; +export const KBD_enter = 257; +export const KBD_space = 32; +export const KBD_leftalt = 342; +export const KBD_rightalt = 346; +export const KBD_leftctrl = 341; +export const KBD_rightctrl = 345; +export const KBD_leftshift = 340; +export const KBD_rightshift = 344; +export const KBD_capslock = 280; +export const KBD_scrolllock = 281; +export const KBD_numlock = 282; +export const KBD_grave = 96; +export const KBD_minus = 45; +export const KBD_equals = 61; +export const KBD_backslash = 92; +export const KBD_leftbracket = 91; +export const KBD_rightbracket = 93; +export const KBD_semicolon = 59; +export const KBD_quote = 39; +export const KBD_period = 46; +export const KBD_comma = 44; +export const KBD_slash = 47; +export const KBD_printscreen = 283; +export const KBD_pause = 284; +export const KBD_insert = 260; +export const KBD_home = 268; +export const KBD_pageup = 266; +export const KBD_delete = 261; +export const KBD_end = 269; +export const KBD_pagedown = 267; +export const KBD_left = 263; +export const KBD_up = 265; +export const KBD_down = 264; +export const KBD_right = 262; +export const KBD_extra_lt_gt = 348; // ??? + +export const domToKeyCodes: {[index: number]: number} = { + 8: KBD_backspace, + 9: KBD_tab, + 13: KBD_enter, + 16: KBD_leftshift, + 17: KBD_leftctrl, + 18: KBD_leftalt, + 19: KBD_pause, + 27: KBD_esc, + 32: KBD_space, + 33: KBD_pageup, + 34: KBD_pagedown, + 35: KBD_end, + 36: KBD_home, + 37: KBD_left, + 38: KBD_up, + 39: KBD_right, + 40: KBD_down, + 45: KBD_insert, + 46: KBD_delete, + 48: KBD_0, + 49: KBD_1, + 50: KBD_2, + 51: KBD_3, + 52: KBD_4, + 53: KBD_5, + 54: KBD_6, + 55: KBD_7, + 56: KBD_8, + 57: KBD_9, + 59: KBD_semicolon, + 64: KBD_equals, + 65: KBD_a, + 66: KBD_b, + 67: KBD_c, + 68: KBD_d, + 69: KBD_e, + 70: KBD_f, + 71: KBD_g, + 72: KBD_h, + 73: KBD_i, + 74: KBD_j, + 75: KBD_k, + 76: KBD_l, + 77: KBD_m, + 78: KBD_n, + 79: KBD_o, + 80: KBD_p, + 81: KBD_q, + 82: KBD_r, + 83: KBD_s, + 84: KBD_t, + 85: KBD_u, + 86: KBD_v, + 87: KBD_w, + 88: KBD_x, + 89: KBD_y, + 90: KBD_z, + 91: KBD_leftbracket, + 93: KBD_rightbracket, + 96: KBD_kp0, + 97: KBD_kp1, + 98: KBD_kp2, + 99: KBD_kp3, + 100: KBD_kp4, + 101: KBD_kp5, + 102: KBD_kp6, + 103: KBD_kp7, + 104: KBD_kp8, + 105: KBD_kp9, + // 106: KBD_kpmultiply, + // 107: KBD_kpadd, + // 109: KBD_kpsubtract, + // 110: KBD_kpdecimal, + 111: KBD_kpdivide, + 112: KBD_f1, + 113: KBD_f2, + 114: KBD_f3, + 115: KBD_f4, + 116: KBD_f5, + 117: KBD_f6, + 118: KBD_f7, + 119: KBD_f8, + 120: KBD_f9, + 121: KBD_f10, + 122: KBD_f11, + 123: KBD_f12, + 144: KBD_numlock, + 145: KBD_scrolllock, + 173: KBD_minus, + 186: KBD_semicolon, + 187: KBD_equals, + 188: KBD_comma, + 189: KBD_minus, + 190: KBD_period, + 191: KBD_slash, + // 192: KBD_graveaccent, + 219: KBD_leftbracket, + 220: KBD_backslash, + 221: KBD_rightbracket, + // 222: KBD_apostrophe, + // 224: KBD_left_super, +}; + +export const namedKeyCodes: {[name: string]: number} = { + "KBD_NONE": KBD_NONE, + "KBD_0": KBD_0, + "KBD_1": KBD_1, + "KBD_2": KBD_2, + "KBD_3": KBD_3, + "KBD_4": KBD_4, + "KBD_5": KBD_5, + "KBD_6": KBD_6, + "KBD_7": KBD_7, + "KBD_8": KBD_8, + "KBD_9": KBD_9, + "KBD_a": KBD_a, + "KBD_b": KBD_b, + "KBD_c": KBD_c, + "KBD_d": KBD_d, + "KBD_e": KBD_e, + "KBD_f": KBD_f, + "KBD_g": KBD_g, + "KBD_h": KBD_h, + "KBD_i": KBD_i, + "KBD_j": KBD_j, + "KBD_k": KBD_k, + "KBD_l": KBD_l, + "KBD_m": KBD_m, + "KBD_n": KBD_n, + "KBD_o": KBD_o, + "KBD_p": KBD_p, + "KBD_q": KBD_q, + "KBD_r": KBD_r, + "KBD_s": KBD_s, + "KBD_t": KBD_t, + "KBD_u": KBD_u, + "KBD_v": KBD_v, + "KBD_w": KBD_w, + "KBD_x": KBD_x, + "KBD_y": KBD_y, + "KBD_z": KBD_z, + "KBD_f1": KBD_f1, + "KBD_f2": KBD_f2, + "KBD_f3": KBD_f3, + "KBD_f4": KBD_f4, + "KBD_f5": KBD_f5, + "KBD_f6": KBD_f6, + "KBD_f7": KBD_f7, + "KBD_f8": KBD_f8, + "KBD_f9": KBD_f9, + "KBD_f10": KBD_f10, + "KBD_f11": KBD_f11, + "KBD_f12": KBD_f12, + + "KBD_kp0": KBD_kp0, + "KBD_kp1": KBD_kp1, + "KBD_kp2": KBD_kp2, + "KBD_kp3": KBD_kp3, + "KBD_kp4": KBD_kp4, + "KBD_kp5": KBD_kp5, + "KBD_kp6": KBD_kp6, + "KBD_kp7": KBD_kp7, + "KBD_kp8": KBD_kp8, + "KBD_kp9": KBD_kp9, + + "KBD_kpperiod": KBD_kpperiod, + "KBD_kpdivide": KBD_kpdivide, + "KBD_kpmultiply": KBD_kpmultiply, + "KBD_kpminus": KBD_kpminus, + "KBD_kpplus": KBD_kpplus, + "KBD_kpenter": KBD_kpenter, + + "KBD_esc": KBD_esc, + "KBD_tab": KBD_tab, + "KBD_backspace": KBD_backspace, + "KBD_enter": KBD_enter, + "KBD_space": KBD_space, + "KBD_leftalt": KBD_leftalt, + "KBD_rightalt": KBD_rightalt, + "KBD_leftctrl": KBD_leftctrl, + "KBD_rightctrl": KBD_rightctrl, + "KBD_leftshift": KBD_leftshift, + "KBD_rightshift": KBD_rightshift, + "KBD_capslock": KBD_capslock, + "KBD_scrolllock": KBD_scrolllock, + "KBD_numlock": KBD_numlock, + "KBD_grave": KBD_grave, + "KBD_minus": KBD_minus, + "KBD_equals": KBD_equals, + "KBD_backslash": KBD_backslash, + "KBD_leftbracket": KBD_leftbracket, + "KBD_rightbracket": KBD_rightbracket, + "KBD_semicolon": KBD_semicolon, + "KBD_quote": KBD_quote, + "KBD_period": KBD_period, + "KBD_comma": KBD_comma, + "KBD_slash": KBD_slash, + "KBD_printscreen": KBD_printscreen, + "KBD_pause": KBD_pause, + "KBD_insert": KBD_insert, + "KBD_home": KBD_home, + "KBD_pageup": KBD_pageup, + "KBD_delete": KBD_delete, + "KBD_end": KBD_end, + "KBD_pagedown": KBD_pagedown, + "KBD_left": KBD_left, + "KBD_up": KBD_up, + "KBD_down": KBD_down, + "KBD_right": KBD_right, + "KBD_extra_lt_gt": KBD_extra_lt_gt, +}; + +export const keyCodesToDom: {[index: number]: number} = {}; +for (const next of Object.keys(domToKeyCodes)) { + const key = Number.parseInt(next, 10); + keyCodesToDom[domToKeyCodes[key]] = key; +} + +export function domToKeyCode(domCode: number) { + return domToKeyCodes[domCode] || 0; +} diff --git a/src/layers/dom/layers.ts b/src/layers/dom/layers.ts new file mode 100644 index 00000000..7c7dfeb3 --- /dev/null +++ b/src/layers/dom/layers.ts @@ -0,0 +1,507 @@ +import { Notyf } from "notyf"; +import Keyboard from "simple-keyboard"; +import { createDiv, stopPropagation } from "./helpers"; + +/* eslint-disable camelcase */ +import { domToKeyCode, KBD_enter, KBD_leftshift, + KBD_backspace, KBD_capslock, KBD_tab, KBD_space, KBD_esc, + KBD_leftctrl, KBD_leftalt, KBD_comma, KBD_period, KBD_quote, + KBD_semicolon, KBD_leftbracket, KBD_rightbracket, KBD_up, KBD_down, KBD_left, KBD_right, +} from "./keys"; +/* eslint-enable camelcase */ + +// eslint-disable-next-line +const elementResizeDetector = require("element-resize-detector"); +const resizeDetector = elementResizeDetector({ +}); + +// eslint-disable-next-line +export interface LayersOptions { + optionControls?: string[]; + keyboardDiv?: HTMLDivElement; + keyboardInputDiv?: HTMLDivElement; + fullscreenElement?: HTMLElement; +} + +export function layers(root: HTMLDivElement, options?: LayersOptions) { + return new Layers(root, options || {}); +} + +export class Layers { + options: LayersOptions; + root: HTMLDivElement; + loading: HTMLDivElement; + canvas: HTMLCanvasElement; + video: HTMLVideoElement; + mouseOverlay: HTMLDivElement; + width: number; + height: number; + fullscreen = false; + keyboardVisible = false; + pointerLock = false; + pointerDisabled = false; + pointerButton: 0 | 1 = 0; + + notyf = new Notyf(); + toggleKeyboard: () => boolean = () => false; + + private fullscreenElement: HTMLElement; + private clickToStart: HTMLDivElement; + private loaderText: HTMLPreElement; + private onResize: ((width: number, height: number) => void)[]; + + private onKeyDown: (keyCode: number) => void; + private onKeyUp: (keyCode: number) => void; + private onKeyPress: (keyCode: number) => void; + private onKeysPress: (keyCodes: number[]) => void; + + private onSave: () => Promise; + private onSaveStarted: () => void; + private onSaveEnded: () => void; + + private onFullscreenChanged: ((fullscreen: boolean) => void)[] = []; + private onKeyboardChanged: ((visible: boolean) => void)[] = []; + + // eslint-disable-next-line + constructor(root: HTMLDivElement, options: LayersOptions) { + this.options = options; + this.root = root; + this.root.classList.add("emulator-root"); + this.fullscreenElement = options.fullscreenElement || this.root; + + this.canvas = document.createElement("canvas"); + this.canvas.className = "emulator-canvas"; + + this.video = document.createElement("video"); + this.video.setAttribute("autoplay", ""); + this.video.setAttribute("playsinline", ""); + this.video.className = "emulator-video"; + + this.loading = createLoadingLayer(); + this.loaderText = this.loading.querySelector(".emulator-loading-pre-2") as HTMLPreElement; + this.mouseOverlay = createMouseOverlayLayer(); + + this.clickToStart = createClickToStartLayer(); + this.clickToStart.onclick = () => { + this.clickToStart.style.display = "none"; + this.video.play(); + }; + + this.root.appendChild(this.canvas); + this.root.appendChild(this.video); + this.root.appendChild(this.mouseOverlay); + this.root.appendChild(this.clickToStart); + this.root.appendChild(this.loading); + + this.width = root.offsetWidth; + this.height = root.offsetHeight; + + this.onResize = []; + this.onKeyDown = () => {/**/}; + this.onKeyUp = () => {/**/}; + this.onKeyPress = () => {/**/}; + this.onKeysPress = () => {/**/}; + this.onSave = () => { + return Promise.reject(new Error("Not implemented")); + }; + this.onSaveStarted = () => {/**/}; + this.onSaveEnded = () => {/**/}; + + resizeDetector.listenTo(this.root, (el: HTMLElement) => { + if (el !== root) { + return; + } + + this.width = el.offsetWidth; + this.height = el.offsetHeight; + for (const next of this.onResize) { + next(this.width, this.height); + } + }); + + this.initKeyEvents(); + this.initKeyboard(); + this.preventContextMenu(); + + + this.fullscreenElement.onfullscreenchange = () => { + if (document.fullscreenElement !== this.fullscreenElement) { + this.fullscreen = false; + for (const next of this.onFullscreenChanged) { + next(this.fullscreen); + } + } + }; + } + + private initKeyEvents() { + const keyboardInput = this.options.keyboardInputDiv ?? this.root; + keyboardInput.style.outline = "none"; + if (!keyboardInput.tabIndex || keyboardInput.tabIndex === -1) { + keyboardInput.tabIndex = 0; + } + keyboardInput.addEventListener("keydown", (e) => { + const keyCode = domToKeyCode(e.keyCode); + this.onKeyDown(keyCode); + e.stopPropagation(); + e.preventDefault(); + }); + keyboardInput.addEventListener("keyup", (e) => { + const keyCode = domToKeyCode(e.keyCode); + this.onKeyUp(keyCode); + e.stopPropagation(); + e.preventDefault(); + }); + } + + preventContextMenu() { + this.root.addEventListener("contextmenu", (e) => { + e.stopPropagation(); + e.preventDefault(); + return false; + }); + } + + addOnResize(handler: (width: number, height: number) => void) { + this.onResize.push(handler); + } + + removeOnResize(handler: (width: number, height: number) => void) { + this.onResize = this.onResize.filter((n) => n !== handler); + } + + setOnKeyDown(handler: (keyCode: number) => void) { + this.onKeyDown = handler; + } + + fireKeyDown(keyCode: number) { + this.onKeyDown(keyCode); + } + + setOnKeyUp(handler: (keyCode: number) => void) { + this.onKeyUp = handler; + } + + fireKeyUp(keyCode: number) { + this.onKeyUp(keyCode); + } + + setOnKeyPress(handler: (keyCode: number) => void) { + this.onKeyPress = handler; + } + + fireKeyPress(keyCode: number) { + this.onKeyPress(keyCode); + } + + setOnKeysPress(handler: (keyCodes: number[]) => void) { + this.onKeysPress = handler; + } + + + fireKeysPress(keyCodes: number[]) { + this.onKeysPress(keyCodes); + } + + toggleFullscreen() { + if (this.fullscreen) { + this.fullscreen = false; + if (this.fullscreenElement.classList.contains("emulator-fullscreen-workaround")) { + this.fullscreenElement.classList.remove("emulator-fullscreen-workaround"); + } else if (document.exitFullscreen) { + document.exitFullscreen(); + } else if ((document as any).webkitExitFullscreen) { + (document as any).webkitExitFullscreen(); + } else if ((document as any).mozCancelFullScreen) { + (document as any).mozCancelFullScreen(); + } else if ((document as any).msExitFullscreen) { + (document as any).msExitFullscreen(); + } + for (const next of this.onFullscreenChanged) { + next(false); + } + } else { + this.fullscreen = true; + const element = this.fullscreenElement as any; + if (element.requestFullscreen) { + element.requestFullscreen(); + } else if (element.webkitRequestFullscreen) { + element.webkitRequestFullscreen(); + } else if (element.mozRequestFullScreen) { + element.mozRequestFullScreen(); + } else if (element.msRequestFullscreen) { + element.msRequestFullscreen(); + } else if (element.webkitEnterFullscreen) { + element.webkitEnterFullscreen(); + } else { + this.fullscreenElement.classList.add("emulator-fullscreen-workaround"); + } + for (const next of this.onFullscreenChanged) { + next(true); + } + } + } + + setOnFullscreen(onFullscreenChanged: (fullscreen: boolean) => void) { + this.onFullscreenChanged.push(onFullscreenChanged); + } + + removeOnFullscreen(onFullscreenChanged: (visible: boolean) => void) { + this.onFullscreenChanged = this.onFullscreenChanged.filter((n) => n !== onFullscreenChanged); + } + + setOnKeyboardVisibility(onKeyboardChanged: (visible: boolean) => void) { + this.onKeyboardChanged.push(onKeyboardChanged); + } + + removeOnKeyboardVisibility(onKeyboardChanged: (visible: boolean) => void) { + this.onKeyboardChanged = this.onKeyboardChanged.filter((n) => n !== onKeyboardChanged); + } + + save(): Promise { + this.onSaveStarted(); + return this.onSave() + .then(() => { + this.notyf.success("Saved"); + this.onSaveEnded(); + }) + .catch((error) => { + this.notyf.error(error.message); + this.onSaveEnded(); + }); + } + + setOnSave(handler: () => Promise) { + this.onSave = handler; + } + + getOnSave() { + return this.onSave; + } + + setOnSaveStarted(callback: () => void) { + this.onSaveStarted = callback; + } + + setOnSaveEnded(callback: () => void) { + this.onSaveEnded = callback; + } + + hideLoadingLayer() { + this.loading.style.visibility = "hidden"; + } + + showLoadingLayer() { + this.loading.style.visibility = "visible"; + } + + setLoadingMessage(message: string) { + this.loaderText.innerHTML = message; + } + + switchToVideo() { + this.video.style.display = "block"; + this.canvas.style.display = "none"; + } + + showClickToStart() { + this.clickToStart.style.display = "flex"; + } + + private initKeyboard() { + let keyboardVisible = false; + + const layout = { + en: [ + "{esc} ` 1 2 3 4 5 6 7 8 9 0 () - = {bksp} {enter}", + "{tab} q w e r t y u i o p { } \\ {up}", + "{shift} {left} {right} a s d f g h j k l ; ' [ {down}", + "⎘ {alt} {ctrl} z x c v b n m , . / ] {space}", + ], + }; + const enLayoutDisplay = { + "{esc}": "␛", + "{bksp}": "⌫", + "{enter}": "↵", + "{space}": "Space", + "{up}": "↑", + "{down}": "↓", + "{left}": "←", + "{right}": "→", + "{shift}": "⇑", + "{ctrl}": "Ctrl", + "{alt}": "Alt", + "{tab}": "Tab", + }; + const ruLayoutDisplay = { + "{esc}": "␛", + "{bksp}": "⌫", + "{enter}": "↵", + "{space}": "Space", + "{up}": "↑", + "{down}": "↓", + "{left}": "←", + "{right}": "→", + "{shift}": "⇑", + "{alt}": "Alt", + "{ctrl}": "Ctrl", + "{tab}": "Tab", + "q": "й", "w": "ц", "e": "у", "r": "к", "t": "е", + "y": "н", "u": "г", "i": "ш", "o": "щ", "p": "з", + "{": "х", "}": "ъ", "a": "ф", "s": "ы", "d": "в", + "f": "а", "g": "п", "h": "р", "j": "о", "k": "л", + "l": "д", ";": "ж", "'": "э", "z": "я", "x": "ч", + "c": "с", "v": "м", "b": "и", "n": "т", "m": "ь", + ",": "б", ".": "ю", + }; + const displayOrder = [enLayoutDisplay, ruLayoutDisplay]; + let displayIndex = 0; + + const keyboardDiv = this.options.keyboardDiv || createDiv(""); + keyboardDiv.classList.add("emulator-keyboard"); + keyboardDiv.style.display = "none"; + stopPropagation(keyboardDiv); + + const keyboard = new Keyboard(keyboardDiv, { + layout, + layoutName: "en", + display: displayOrder[displayIndex], + onKeyPress: (button: string) => { + if (button === "⎘") { + return; + } + + const keyCodes = buttonToCode(button); + for (const keyCode of keyCodes) { + this.fireKeyDown(keyCode); + } + }, + onKeyReleased: (button: string) => { + if (button === "⎘") { + displayIndex = (displayIndex + 1) % displayOrder.length; + keyboard.setOptions({ + display: displayOrder[displayIndex], + }); + return; + } + + const keyCodes = buttonToCode(button); + for (const keyCode of keyCodes) { + this.fireKeyUp(keyCode); + } + }, + preventMouseDownDefault: true, + preventMouseUpDefault: true, + stopMouseDownPropagation: true, + stopMouseUpPropagation: true, + physicalKeyboardHighlight: false, + physicalKeyboardHighlightPress: false, + physicalKeyboardHighlightPressUseClick: false, + physicalKeyboardHighlightPressUsePointerEvents: false, + }); + + this.toggleKeyboard = () => { + keyboardVisible = !keyboardVisible; + const display = keyboardVisible ? "block" : "none"; + keyboardDiv.style.display = display; + + for (const next of this.onKeyboardChanged) { + next(keyboardVisible); + } + + this.keyboardVisible = keyboardVisible; + return keyboardVisible; + }; + + if (!this.options.keyboardDiv) { + this.mouseOverlay.appendChild(keyboardDiv); + } + } +} + +function createLoadingLayer() { + return createDiv("emulator-loading", ` +
+
+        _                __
+       (_)____      ____/ /___  _____ _________  ____ ___
+      / / ___/_____/ __  / __ \\/ ___// ___/ __ \\/ __ \`__ \\
+     / (__  )_____/ /_/ / /_/ (__  )/ /__/ /_/ / / / / / /
+  __/ /____/      \\__,_/\\____/____(_)___/\\____/_/ /_/ /_/
+ /___/
+
+
+
+
+
+
+`); +} + +function createMouseOverlayLayer() { + return createDiv("emulator-mouse-overlay", ""); +} + +function createClickToStartLayer() { + return createDiv("emulator-click-to-start-overlay", ` +
Press to start
+
+`); +} + +/* eslint-disable camelcase */ +function buttonToCode(button: string): number[] { + if (button.length > 1) { + if (button === "{enter}") { + return [KBD_enter]; + } else if (button === "{shift}") { + return [KBD_leftshift]; + } else if (button === "{bksp}") { + return [KBD_backspace]; + } else if (button === "{lock}") { + return [KBD_capslock]; + } else if (button === "{tab}") { + return [KBD_tab]; + } else if (button === "{space}") { + return [KBD_space]; + } else if (button === "{esc}") { + return [KBD_esc]; + } else if (button === "{ctrl}") { + return [KBD_leftctrl]; + } else if (button === "{alt}") { + return [KBD_leftalt]; + } else if (button === "{up}") { + return [KBD_up]; + } else if (button === "{down}") { + return [KBD_down]; + } else if (button === "{left}") { + return [KBD_left]; + } else if (button === "{right}") { + return [KBD_right]; + } else { + console.warn("Unknown button", button); + return []; + } + } else if (button === ",") { + return [KBD_comma]; + } else if (button === ".") { + return [KBD_period]; + } else if (button === "'") { + return [KBD_quote]; + } else if (button === ":") { + return [KBD_semicolon]; + } else if (button === "{") { + return [KBD_leftshift, KBD_leftbracket]; + } else if (button === "}") { + return [KBD_leftshift, KBD_rightbracket]; + } + + const keyCode = domToKeyCode(button.toUpperCase().charCodeAt(0)); + if (keyCode === 0) { + return []; + } + + return [keyCode]; +} + +/* eslint-enable camelcase */ diff --git a/src/layers/dom/lifecycle.ts b/src/layers/dom/lifecycle.ts new file mode 100644 index 00000000..fd076801 --- /dev/null +++ b/src/layers/dom/lifecycle.ts @@ -0,0 +1,29 @@ +import { CommandInterface } from "emulators"; + +export function lifecycle(ci: CommandInterface) { + let hidden = ""; + let visibilityChange = ""; + + if (typeof document.hidden !== "undefined") { + hidden = "hidden"; + visibilityChange = "visibilitychange"; + } else if (typeof (document as any).mozHidden !== "undefined") { + hidden = "mozHidden"; + visibilityChange = "mozvisibilitychange"; + } else if (typeof (document as any).msHidden !== "undefined") { + hidden = "msHidden"; + visibilityChange = "msvisibilitychange"; + } else if (typeof (document as any).webkitHidden !== "undefined") { + hidden = "webkitHidden"; + visibilityChange = "webkitvisibilitychange"; + } + + function visibilitHandler() { + (document as any)[hidden] ? ci.pause() : ci.resume(); + } + + document.addEventListener(visibilityChange as any, visibilitHandler); + ci.events().onExit(() => { + document.removeEventListener(visibilityChange as any, visibilitHandler); + }); +} diff --git a/src/layers/dom/mem-storage.ts b/src/layers/dom/mem-storage.ts new file mode 100644 index 00000000..1f56e976 --- /dev/null +++ b/src/layers/dom/mem-storage.ts @@ -0,0 +1,30 @@ +export class MemStorage implements Storage { + length = 0; + + private storage: {[key: string]: string} = {}; + + setItem(key: string, value: string): void { + this.storage[key] = value; + this.length = Object.keys(this.storage).length; + } + + getItem(key: string): string | null { + const value = this.storage[key]; + return value === undefined ? null : value; + } + + removeItem(key: string): void { + delete this.storage[key]; + this.length = Object.keys(this.storage).length; + } + + key(index: number): string | null { + const keys = Object.keys(this.storage); + return keys[index] === undefined ? null : keys[index]; + } + + clear() { + this.length = 0; + this.storage = {}; + } +} diff --git a/src/layers/dom/pointer.ts b/src/layers/dom/pointer.ts new file mode 100644 index 00000000..37586fbd --- /dev/null +++ b/src/layers/dom/pointer.ts @@ -0,0 +1,89 @@ +export const pointer = initBind(); + +function initBind() { + const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); + const isTouch = isMobile && !!("ontouchstart" in window); + const isPointer = isMobile && (window.PointerEvent ? true : false); + const isMSPointer = isMobile && ((window as any).MSPointerEvent ? true : false); + let canLock = !isMobile; + + const starters: string[] = []; + const changers: string[] = []; + const enders: string[] = []; + const leavers: string[] = []; + const prevents: string[] = []; + + if (isPointer) { + starters.push("pointerdown"); + enders.push("pointerup", "pointercancel"); + changers.push("pointermove"); + prevents.push("touchstart", "touchmove", "touchend"); + } else if (isMSPointer) { + starters.push("MSPointerDown"); + changers.push("MSPointerMove"); + enders.push("MSPointerUp"); + } else if (isTouch) { + canLock = false; + starters.push("touchstart", "mousedown"); + changers.push("touchmove"); + enders.push("touchend", "touchcancel", "mouseup"); + } else { + starters.push("mousedown"); + changers.push("mousemove"); + enders.push("mouseup"); + leavers.push("mouseleave"); + } + + return { + mobile: isMobile, + canLock, + starters, + changers, + enders, + prevents, + leavers, + }; +} + +export interface PointerState { + x: number, + y: number, + mX: number, + mY: number, + button?: number, +} + +export function getPointerState(e: Event, el: HTMLElement): PointerState { + if (e.type.match(/^touch/)) { + const evt = e as TouchEvent; + const rect = el.getBoundingClientRect(); + return { + x: evt.targetTouches[0].clientX - rect.x, + y: evt.targetTouches[0].clientY - rect.y, + mX: 0, + mY: 0, + }; + } else if (e.type.match(/^pointer/)) { + const evt = e as PointerEvent; + return { + x: evt.offsetX, + y: evt.offsetY, + mX: evt.movementX, + mY: evt.movementY, + }; + } else { + const evt = e as MouseEvent; + return { + x: evt.offsetX, + y: evt.offsetY, + mX: evt.movementX, + mY: evt.movementY, + button: evt.button === 0 ? 0 : 1, + }; + } +} + +export const pointers = { + bind: pointer, + getPointerState, +}; diff --git a/src/layers/dom/storage.ts b/src/layers/dom/storage.ts new file mode 100644 index 00000000..1ed96777 --- /dev/null +++ b/src/layers/dom/storage.ts @@ -0,0 +1,124 @@ +import { MemStorage } from "./mem-storage"; + +const MAX_VALUE_SIZE = 1024; +const NEXT_PART_SYMBOL = "@"; +const NEXT_PART_SYFFIX = "."; + +export class LStorage implements Storage { + private backend: Storage; + length: number; + prefix: string; + + constructor(backend: Storage | undefined, prefix: string) { + this.prefix = prefix; + + try { + this.backend = backend || localStorage; + this.testBackend(); + } catch (e) { + this.backend = new MemStorage(); + } + + this.length = this.backend.length; + + if (typeof this.backend.sync === "function") { + (this as any).sync = (callback: any) => { + this.backend.sync(callback); + }; + } + } + + testBackend() { + const testKey = this.prefix + ".test.record"; + const testValue = "123"; + this.backend.setItem(testKey, testValue); + const readedValue = this.backend.getItem(testKey); + this.backend.removeItem(testKey); + const valid = readedValue === testValue && this.backend.getItem(testKey) === null; + + if (!valid) { + throw new Error("Storage backend is not working properly"); + } + } + + setLocalStoragePrefix(prefix: string) { + this.prefix = prefix; + } + + clear(): void { + if (!this.backend.length) { + return; + } + + const toRemove: string[] = []; + for (let i = 0; i < this.backend.length; ++i) { + const next = this.backend.key(i); + if (next && next.startsWith(this.prefix)) { + toRemove.push(next); + } + } + + for (const next of toRemove) { + this.backend.removeItem(next); + } + this.length = this.backend.length; + } + + key(index: number): string | null { + return this.backend.key(index); + } + + setItem(key: string, value: string): void { + if (!value || value.length === undefined || value.length === 0) { + this.writeStringToKey(key, ""); + return; + } + + let offset = 0; + while (offset < value.length) { + let substr = value.substr(offset, MAX_VALUE_SIZE); + offset += substr.length; + + if (offset < value.length) { + substr += NEXT_PART_SYMBOL; + } + + this.writeStringToKey(key, substr); + key += NEXT_PART_SYFFIX; + } + } + + getItem(key: string): string | null { + let value = this.readStringFromKey(key); + if (value === null) { + return null; + } + + if (value.length === 0) { + return value; + } + + while (value[value.length - 1] === NEXT_PART_SYMBOL) { + value = value.substr(0, value.length - 1); + key += NEXT_PART_SYFFIX; + const next = this.readStringFromKey(key); + value += next === null ? "" : next; + } + + return value; + } + + removeItem(key: string): void { + this.backend.removeItem(this.prefix + key); + this.length = this.backend.length; + } + + private writeStringToKey(key: string, value: string) { + this.backend.setItem(this.prefix + key, value); + this.length = this.backend.length; + } + + private readStringFromKey(key: string) { + return this.backend.getItem(this.prefix + key); + } +} diff --git a/src/layers/emulators-ui-loader.png b/src/layers/emulators-ui-loader.png new file mode 100644 index 00000000..01cdcd34 Binary files /dev/null and b/src/layers/emulators-ui-loader.png differ diff --git a/src/layers/emulators-ui.css b/src/layers/emulators-ui.css new file mode 100644 index 00000000..316db47a --- /dev/null +++ b/src/layers/emulators-ui.css @@ -0,0 +1,245 @@ +.emulator-root { + background: black; + overflow: hidden; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + touch-action: none; +} + +.emulator-canvas, .emulator-video { + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + image-rendering: pixelated; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + touch-action: none; +} + +.emulator-video { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: none; +} + +.emulator-loading { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + background: black; + color: white; + font-weight: bold; +} + +.emulator-loading-inner { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + height: 100%; + width: 100%; + margin: 0; + padding: 0; +} + +.emulator-loading-pre-1 { + font-size: 2vw; + margin: 0 0 1em 0; +} + +.emulator-loading-pre-2 { + margin: 1em 0; + font-size: 2vw; + font-family: monospace; + font-weight: 100; + text-transform: uppercase; +} + +.emulator-loader { + background: url(emulators-ui-loader.png) no-repeat; + background-size: cover; + width: 50px; + height: 50px; +} + +.emulator-control-exit-fullscreen-icon { + background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 16 16' enable-background='new 0 0 16 16' xml:space='preserve'%3E%3Cg id='minimize_1_' fill='%23FFFFFF'%3E%3Cg%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15.99,0.99c0-0.55-0.45-1-1-1c-0.28,0-0.53,0.11-0.71,0.29l-3.29,3.29V1.99 c0-0.55-0.45-1-1-1s-1,0.45-1,1v4c0,0.55,0.45,1,1,1h4c0.55,0,1-0.45,1-1s-0.45-1-1-1h-1.59L15.7,1.7 C15.88,1.52,15.99,1.27,15.99,0.99z M5.99,8.99h-4c-0.55,0-1,0.45-1,1s0.45,1,1,1h1.59l-3.29,3.29c-0.18,0.18-0.29,0.43-0.29,0.71 c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29l3.29-3.29v1.59c0,0.55,0.45,1,1,1s1-0.45,1-1v-4C6.99,9.44,6.54,8.99,5.99,8.99z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E") !important; +} + +.emulator-control-close-icon { + background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' enable-background='new 0 0 20 20' fill='%23FFF' xml:space='preserve'%3E%3Cg id='cross_mark_6_'%3E%3Cg%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11.41,10l4.29-4.29C15.89,5.53,16,5.28,16,5c0-0.55-0.45-1-1-1 c-0.28,0-0.53,0.11-0.71,0.29L10,8.59L5.71,4.29C5.53,4.11,5.28,4,5,4C4.45,4,4,4.45,4,5c0,0.28,0.11,0.53,0.29,0.71L8.59,10 l-4.29,4.29C4.11,14.47,4,14.72,4,15c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29L10,11.41l4.29,4.29 C14.47,15.89,14.72,16,15,16c0.55,0,1-0.45,1-1c0-0.28-0.11-0.53-0.29-0.71L11.41,10z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E") !important; +} + +.emulator-mouse-overlay { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; +} + +.emulator-click-to-start-overlay { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + display: none; +} + +.emulator-click-to-start-text { + color: white; + font-size: 2em; + margin: 0 0 1em 0; + font-family: monospace; + text-transform: uppercase; + font-weight: normal; +} + +.emulator-click-to-start-icon { + background-image: url("data:image/svg+xml,%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' enable-background='new 0 0 20 20' xml:space='preserve'%3E%3Cg id='play_1_'%3E%3Cg%3E%3Cpath fill='%23FFF' fill-rule='evenodd' clip-rule='evenodd' d='M16,10c0-0.36-0.2-0.67-0.49-0.84l0.01-0.01l-10-6L5.51,3.16 C5.36,3.07,5.19,3,5,3C4.45,3,4,3.45,4,4v12c0,0.55,0.45,1,1,1c0.19,0,0.36-0.07,0.51-0.16l0.01,0.01l10-6l-0.01-0.01 C15.8,10.67,16,10.36,16,10z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); + width: 64px; + height: 64px; +} + +.emulator-fullscreen-workaround { + position: fixed !important; + left: 0; + top: 0; + bottom: 0; + right: 0; + background: black; + z-index: 999; +} + +.emulator-button-touch-zone, +.emulator-button { + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + touch-action: none; + + overflow: hidden; + background: none; + color: rgb(255, 255, 255); + + border-radius: 50%; + filter: opacity(0.8); +} + +.emulator-button-touch-zone { + display: flex; + align-items: center; + justify-content: center; + + border-color: rgba(255, 255, 255, 0.5); + + border-style: solid; + box-shadow: 0 0 2px 2px rgba(255, 255, 255, 0.5), inset 0 0 2px 2px rgba(255, 255, 255, 0.5); +} + +.emulator-button { + position: absolute; + background-color: rgba(128, 128, 128, 0.5); + background-size: 50%; + background-repeat: no-repeat; + background-position: center; + + border: 1px solid rgb(255, 255, 255); + text-shadow: -1px -1px 0 rgb(0, 0, 0), 1px -1px 0 rgb(0, 0, 0), -1px 1px 0 rgb(0, 0, 0), 1px 1px 0 rgb(0, 0, 0); + text-align: center; + box-shadow: 0px 0px 0px 1px rgb(0, 0, 0); +} + +.emulator-button-text { +} + +.emulator-control-select:hover, +.emulator-button-touch-zone:hover { + filter: opacity(1.0) hue-rotate(-70deg) saturate(5) sepia(1); +} + +.emulator-button-touch-zone.emulator-button-control { + filter: opacity(1.0) !important; + background-color: rgb(128, 128, 128) !important; + z-index: 999; +} + +.emulator-button-highlight, +.emulator-button-control:hover { + filter: opacity(1.0) hue-rotate(-70deg) saturate(5) sepia(1) !important; +} + +.emulator-control-select { + overflow: hidden; + background: none; + color: white; + border-radius: 10%; + border: 1px solid rgb(255, 255, 255); + box-shadow: 0px 0px 0px 1px rgb(0, 0, 0); + + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + touch-action: none; + filter: opacity(0.7); +} + +.emulator-options { + justify-content: flex-end; + flex-wrap: wrap-reverse; + display: flex; + flex-direction: row; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + touch-action: none; +} + +.emulator-keyboard { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 999; + color: black; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + touch-action: none; +} + +/* notyf */ +@-webkit-keyframes notyf-fadeinup{0%{opacity:0;transform:translateY(25%)}to{opacity:1;transform:translateY(0)}}@keyframes notyf-fadeinup{0%{opacity:0;transform:translateY(25%)}to{opacity:1;transform:translateY(0)}}@-webkit-keyframes notyf-fadeinleft{0%{opacity:0;transform:translateX(25%)}to{opacity:1;transform:translateX(0)}}@keyframes notyf-fadeinleft{0%{opacity:0;transform:translateX(25%)}to{opacity:1;transform:translateX(0)}}@-webkit-keyframes notyf-fadeoutright{0%{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(25%)}}@keyframes notyf-fadeoutright{0%{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(25%)}}@-webkit-keyframes notyf-fadeoutdown{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(25%)}}@keyframes notyf-fadeoutdown{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(25%)}}@-webkit-keyframes ripple{0%{transform:scale(0) translateY(-45%) translateX(13%)}to{transform:scale(1) translateY(-45%) translateX(13%)}}@keyframes ripple{0%{transform:scale(0) translateY(-45%) translateX(13%)}to{transform:scale(1) translateY(-45%) translateX(13%)}}.notyf{position:fixed;top:0;left:0;height:100%;width:100%;color:#fff;z-index:9999;display:flex;flex-direction:column;align-items:flex-end;justify-content:flex-end;pointer-events:none;box-sizing:border-box;padding:20px}.notyf__icon--error,.notyf__icon--success{height:21px;width:21px;background:#fff;border-radius:50%;display:block;margin:0 auto;position:relative}.notyf__icon--error:after,.notyf__icon--error:before{content:"";background:currentColor;display:block;position:absolute;width:3px;border-radius:3px;left:9px;height:12px;top:5px}.notyf__icon--error:after{transform:rotate(-45deg)}.notyf__icon--error:before{transform:rotate(45deg)}.notyf__icon--success:after,.notyf__icon--success:before{content:"";background:currentColor;display:block;position:absolute;width:3px;border-radius:3px}.notyf__icon--success:after{height:6px;transform:rotate(-45deg);top:9px;left:6px}.notyf__icon--success:before{height:11px;transform:rotate(45deg);top:5px;left:10px}.notyf__toast{display:block;overflow:hidden;pointer-events:auto;-webkit-animation:notyf-fadeinup .3s ease-in forwards;animation:notyf-fadeinup .3s ease-in forwards;box-shadow:0 3px 7px 0 rgba(0,0,0,.25);position:relative;padding:0 15px;border-radius:2px;max-width:300px;transform:translateY(25%);box-sizing:border-box;flex-shrink:0}.notyf__toast--disappear{transform:translateY(0);-webkit-animation:notyf-fadeoutdown .3s forwards;animation:notyf-fadeoutdown .3s forwards;-webkit-animation-delay:.25s;animation-delay:.25s}.notyf__toast--disappear .notyf__icon,.notyf__toast--disappear .notyf__message{-webkit-animation:notyf-fadeoutdown .3s forwards;animation:notyf-fadeoutdown .3s forwards;opacity:1;transform:translateY(0)}.notyf__toast--disappear .notyf__dismiss{-webkit-animation:notyf-fadeoutright .3s forwards;animation:notyf-fadeoutright .3s forwards;opacity:1;transform:translateX(0)}.notyf__toast--disappear .notyf__message{-webkit-animation-delay:.05s;animation-delay:.05s}.notyf__toast--upper{margin-bottom:20px}.notyf__toast--lower{margin-top:20px}.notyf__toast--dismissible .notyf__wrapper{padding-right:30px}.notyf__ripple{height:400px;width:400px;position:absolute;transform-origin:bottom right;right:0;top:0;border-radius:50%;transform:scale(0) translateY(-51%) translateX(13%);z-index:5;-webkit-animation:ripple .4s ease-out forwards;animation:ripple .4s ease-out forwards}.notyf__wrapper{display:flex;align-items:center;padding-top:17px;padding-bottom:17px;padding-right:15px;border-radius:3px;position:relative;z-index:10}.notyf__icon{width:22px;text-align:center;font-size:1.3em;opacity:0;-webkit-animation:notyf-fadeinup .3s forwards;animation:notyf-fadeinup .3s forwards;-webkit-animation-delay:.3s;animation-delay:.3s;margin-right:13px}.notyf__dismiss{position:absolute;top:0;right:0;height:100%;width:26px;margin-right:-15px;-webkit-animation:notyf-fadeinleft .3s forwards;animation:notyf-fadeinleft .3s forwards;-webkit-animation-delay:.35s;animation-delay:.35s;opacity:0}.notyf__dismiss-btn{background-color:rgba(0,0,0,.25);border:none;cursor:pointer;transition:opacity .2s ease,background-color .2s ease;outline:none;opacity:.35;height:100%;width:100%}.notyf__dismiss-btn:after,.notyf__dismiss-btn:before{content:"";background:#fff;height:12px;width:2px;border-radius:3px;position:absolute;left:calc(50% - 1px);top:calc(50% - 5px)}.notyf__dismiss-btn:after{transform:rotate(-45deg)}.notyf__dismiss-btn:before{transform:rotate(45deg)}.notyf__dismiss-btn:hover{opacity:.7;background-color:rgba(0,0,0,.15)}.notyf__dismiss-btn:active{opacity:.8}.notyf__message{vertical-align:middle;position:relative;opacity:0;-webkit-animation:notyf-fadeinup .3s forwards;animation:notyf-fadeinup .3s forwards;-webkit-animation-delay:.25s;animation-delay:.25s;line-height:1.5em}@media only screen and (max-width:480px){.notyf{padding:0}.notyf__ripple{height:600px;width:600px;-webkit-animation-duration:.5s;animation-duration:.5s}.notyf__toast{max-width:none;border-radius:0;box-shadow:0 -2px 7px 0 rgba(0,0,0,.13);width:100%}.notyf__dismiss{width:56px}} + +/* simple-keyboard */ +.hg-theme-default{width:100%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;box-sizing:border-box;overflow:hidden;touch-action:manipulation}.hg-theme-default .hg-button span{pointer-events:none}.hg-theme-default button.hg-button{border-width:0;outline:0;font-size:inherit}.hg-theme-default{font-family:"HelveticaNeue-Light","Helvetica Neue Light","Helvetica Neue",Helvetica,Arial,"Lucida Grande",sans-serif;background-color:#ececec;padding:5px;border-radius:5px}.hg-theme-default .hg-button{display:inline-block;flex-grow:1}.hg-theme-default .hg-row{display:flex}.hg-theme-default .hg-row:not(:last-child){margin-bottom:5px}.hg-theme-default .hg-row .hg-button-container,.hg-theme-default .hg-row .hg-button:not(:last-child){margin-right:5px}.hg-theme-default .hg-row>div:last-child{margin-right:0}.hg-theme-default .hg-row .hg-button-container{display:flex}.hg-theme-default .hg-button{box-shadow:0 0 3px -1px rgba(0,0,0,.3);height:40px;border-radius:5px;box-sizing:border-box;padding:5px;background:#fff;border-bottom:1px solid #b5b5b5;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:rgba(0,0,0,0)}.hg-theme-default .hg-button.hg-activeButton{background:#efefef}.hg-theme-default.hg-layout-numeric .hg-button{width:33.3%;height:60px;align-items:center;display:flex;justify-content:center}.hg-theme-default .hg-button.hg-button-numpadadd,.hg-theme-default .hg-button.hg-button-numpadenter{height:85px}.hg-theme-default .hg-button.hg-button-numpad0{width:105px}.hg-theme-default .hg-button.hg-button-com{max-width:85px}.hg-theme-default .hg-button.hg-standardBtn.hg-button-at{max-width:45px}.hg-theme-default .hg-button.hg-selectedButton{background:rgba(5,25,70,.53);color:#fff}.hg-theme-default .hg-button.hg-standardBtn[data-skbtn=".com"]{max-width:82px}.hg-theme-default .hg-button.hg-standardBtn[data-skbtn="@"]{max-width:60px} diff --git a/src/layers/emulators-ui.ts b/src/layers/emulators-ui.ts new file mode 100644 index 00000000..781e5b07 --- /dev/null +++ b/src/layers/emulators-ui.ts @@ -0,0 +1,76 @@ + +import { Build } from "./build"; +import { layers } from "./dom/layers"; +import { lifecycle } from "./dom/lifecycle"; +import { resolveBundle } from "./network/xhr"; +import { _2d } from "./graphics/_2d"; +import { webGl } from "./graphics/webgl"; +import { video } from "./graphics/video"; +import { keyboard } from "./controls/keyboard"; +import { mouse } from "./controls/mouse/mouse-common"; +import { nipple } from "./controls/nipple"; +import { options } from "./controls/options"; +import { domToKeyCode, domToKeyCodes, keyCodesToDom, namedKeyCodes } from "./dom/keys"; +import { audioNode } from "./sound/audio-node"; +import { notyf } from "./notification/notyf"; +import { save, load } from "./persist/save-load"; +import { getGrid } from "./controls/grid"; +import { pointers } from "./dom/pointer"; +import { LStorage } from "./dom/storage"; + +import { DosInstance, DosFactoryType, DosOptions } from "./js-dos"; + +export class EmulatorsUi { + build = Build; + + dom = { + layers, // DOM components that used by js-dos player + lifecycle, // compnent that manges liefcycle events + pointers, // abstraction over mouse, touch, pointer API + storage: new LStorage(undefined, "emulators.ui."), // localStorage abstaction + }; + + network = { + resolveBundle, // GET request to download bundles + }; + + graphics = { + webGl, // default webgl renderer + _2d, // fallback renderer + video, // default janus renderer + }; + + sound = { + audioNode, // default auidio processor + }; + + persist = { + save, // store updated bundle into 'persist.db' + load, // get updated bundle form `persist.db` + }; + + controls = { + getGrid, // returns grid processor by grid type + namedKeyCodes, // mapping from key name to it's key code + domToKeyCodes, // mapping from DOM key codes to js-dos key codes + domToKeyCode, // function that converts DOM key code to js-dos key code + keyCodesToDom, // mapping from js-dos key codes to DOM key codes + keyboard, // default keyboard processor + mouse, // default mouse processor + nipple, // multitouch control for emulating keyboard on mobiles + options, // default options control (fullscreen, save, etc.) + }; + + notifications = { + notyf, // default notifications system + }; + + // default player + dos: DosFactoryType = (root: HTMLDivElement, options?: DosOptions) => { + return new DosInstance(root, impl, options || {}); + }; +} + +const impl = new EmulatorsUi(); +(window as any).emulatorsUi = impl; +(window as any).Dos = impl.dos; diff --git a/src/layers/graphics/_2d.ts b/src/layers/graphics/_2d.ts new file mode 100644 index 00000000..15089420 --- /dev/null +++ b/src/layers/graphics/_2d.ts @@ -0,0 +1,80 @@ +import { Layers } from "../dom/layers"; +import { CommandInterface } from "emulators"; + +export function _2d(layers: Layers, ci: CommandInterface, forceAspect?: number) { + const canvas = layers.canvas; + const context = canvas.getContext("2d"); + if (context === null) { + throw new Error("Unable to create 2d context on given canvas"); + } + + let containerWidth = layers.width; + let containerHeight = layers.height; + let frameWidth = 0; + let frameHeight = 0; + + const onResize = () => { + const aspect = forceAspect ?? frameWidth / frameHeight; + + let width = containerWidth; + let height = containerWidth / aspect; + + if (height > containerHeight) { + height = containerHeight; + width = containerHeight * aspect; + } + + canvas.style.position = "relative"; + canvas.style.top = (containerHeight - height) / 2 + "px"; + canvas.style.left = (containerWidth - width) / 2 + "px"; + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; + }; + + const onResizeLayer = (w: number, h: number) => { + containerWidth = w; + containerHeight = h; + onResize(); + }; + layers.addOnResize(onResizeLayer); + + let rgba = new Uint8ClampedArray(0); + const onResizeFrame = (w: number, h: number) => { + frameWidth = w; + frameHeight = h; + canvas.width = frameWidth; + canvas.height = frameHeight; + rgba = new Uint8ClampedArray(w * h * 4); + onResize(); + }; + ci.events().onFrameSize(onResizeFrame); + ci.events().onFrame((frameRgb, frameRgba) => { + if (frameRgb === null && frameRgba === null) { + return; + } + + const frame = (frameRgb !== null ? frameRgb : frameRgba) as Uint8Array; + + let frameOffset = 0; + let rgbaOffset = 0; + + while (rgbaOffset < rgba.length) { + rgba[rgbaOffset++] = frame[frameOffset++]; + rgba[rgbaOffset++] = frame[frameOffset++]; + rgba[rgbaOffset++] = frame[frameOffset++]; + rgba[rgbaOffset++] = 255; + + if (frame.length === rgba.length) { + frameOffset++; + } + } + + context.putImageData(new ImageData(rgba, frameWidth, frameHeight), 0, 0); + }); + + onResizeFrame(ci.width(), ci.height()); + + ci.events().onExit(() => { + layers.removeOnResize(onResizeLayer); + }); +} diff --git a/src/layers/graphics/video.ts b/src/layers/graphics/video.ts new file mode 100644 index 00000000..b70cfb27 --- /dev/null +++ b/src/layers/graphics/video.ts @@ -0,0 +1,13 @@ +import { Layers } from "../dom/layers"; +import { CommandInterface } from "emulators"; +import { JanusMessageType } from "emulators/dist/types/janus/janus-impl"; + +export function video(layers: Layers, ci: CommandInterface) { + layers.switchToVideo(); + + ci.events().onMessage((msgType: JanusMessageType | string, stream: MediaStream) => { + if (msgType === "onremotestream") { + (window as any).Janus.attachMediaStream(layers.video, stream); + } + }); +} diff --git a/src/layers/graphics/webgl.ts b/src/layers/graphics/webgl.ts new file mode 100644 index 00000000..df304c76 --- /dev/null +++ b/src/layers/graphics/webgl.ts @@ -0,0 +1,184 @@ +import { Layers } from "../dom/layers"; +import { CommandInterface } from "emulators"; + +const vsSource = ` +attribute vec4 aVertexPosition; +attribute vec2 aTextureCoord; + +varying highp vec2 vTextureCoord; + +void main(void) { + gl_Position = aVertexPosition; + vTextureCoord = aTextureCoord; +} +`; + +const fsSource = ` +varying highp vec2 vTextureCoord; +uniform sampler2D uSampler; + + +void main(void) { + highp vec4 color = texture2D(uSampler, vTextureCoord); + gl_FragColor = vec4(color.r, color.g, color.b, 1.0); +} +`; + +export function webGl(layers: Layers, ci: CommandInterface, forceAspect?: number) { + const canvas = layers.canvas; + const gl = canvas.getContext("webgl"); + if (gl === null) { + throw new Error("Unable to create webgl context on given canvas"); + } + + const shaderProgram = initShaderProgram(gl, vsSource, fsSource); + const vertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition"); + const textureCoord = gl.getAttribLocation(shaderProgram, "aTextureCoord"); + const uSampler = gl.getUniformLocation(shaderProgram, "uSampler"); + + initBuffers(gl, vertexPosition, textureCoord); + + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + + const pixel = new Uint8Array([0, 0, 0]); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, + 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, + pixel); + + gl.useProgram(shaderProgram); + gl.activeTexture(gl.TEXTURE0); + gl.uniform1i(uSampler, 0); + + let containerWidth = layers.width; + let containerHeight = layers.height; + let frameWidth = 0; + let frameHeight = 0; + + const onResize = () => { + const aspect = forceAspect ?? (frameWidth / frameHeight); + + let width = containerWidth; + let height = containerWidth / aspect; + + if (height > containerHeight) { + height = containerHeight; + width = containerHeight * aspect; + } + + canvas.style.position = "relative"; + canvas.style.top = (containerHeight - height) / 2 + "px"; + canvas.style.left = (containerWidth - width) / 2 + "px"; + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; + }; + + const onResizeLayer = (w: number, h: number) => { + containerWidth = w; + containerHeight = h; + onResize(); + }; + layers.addOnResize(onResizeLayer); + + const onResizeFrame = (w: number, h: number) => { + frameWidth = w; + frameHeight = h; + canvas.width = frameWidth; + canvas.height = frameHeight; + gl.viewport(0, 0, frameWidth, frameHeight); + onResize(); + }; + ci.events().onFrameSize(onResizeFrame); + onResizeFrame(ci.width(), ci.height()); + + let requestAnimationFrameId: number | null = null; + let frame: Uint8Array | null = null; + let frameFormat: number = 0; + + ci.events().onFrame((rgb, rgba) => { + frame = rgb != null ? rgb : rgba; + frameFormat = rgb != null ? gl.RGB : gl.RGBA; + if (requestAnimationFrameId === null) { + requestAnimationFrameId = requestAnimationFrame(updateTexture); + } + }); + + const updateTexture = () => { + gl.texImage2D(gl.TEXTURE_2D, 0, frameFormat, + frameWidth, frameHeight, 0, frameFormat, gl.UNSIGNED_BYTE, + frame); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + requestAnimationFrameId = null; + frame = null; + }; + + ci.events().onExit(() => { + layers.removeOnResize(onResizeLayer); + }); +} + +function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string) { + const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); + const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); + + const shaderProgram = gl.createProgram() as WebGLShader; + gl.attachShader(shaderProgram, vertexShader); + gl.attachShader(shaderProgram, fragmentShader); + gl.linkProgram(shaderProgram); + + if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { + throw new Error("Unable to initialize the shader program: " + gl.getProgramInfoLog(shaderProgram)); + } + + return shaderProgram; +} + +function loadShader(gl: WebGLRenderingContext, shaderType: GLenum, source: string) { + const shader = gl.createShader(shaderType) as WebGLShader; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error("An error occurred compiling the shaders: " + info); + } + + return shader; +} + +function initBuffers(gl: WebGLRenderingContext, vertexPosition: number, textureCoord: number) { + const positionBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + const positions = [ + -1.0, -1.0, 0.0, + 1.0, -1.0, 0.0, + 1.0, 1.0, 0.0, + -1.0, -1.0, 0.0, + 1.0, 1.0, 0.0, + -1.0, 1.0, 0.0, + ]; + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW); + gl.vertexAttribPointer(vertexPosition, 3, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(vertexPosition); + + const textureCoordBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer); + const textureCoordinates = [ + 0.0, 1.0, + 1.0, 1.0, + 1.0, 0.0, + 0.0, 1.0, + 1.0, 0.0, + 0.0, 0.0, + ]; + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoordinates), + gl.STATIC_DRAW); + + gl.vertexAttribPointer(textureCoord, 2, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(textureCoord); +} diff --git a/src/layers/js-dos.ts b/src/layers/js-dos.ts new file mode 100644 index 00000000..f954b435 --- /dev/null +++ b/src/layers/js-dos.ts @@ -0,0 +1,359 @@ +import { Emulators, CommandInterface, BackendOptions } from "emulators"; +import { TransportLayer } from "emulators/dist/types/protocol/protocol"; +import { EmulatorsUi } from "./emulators-ui"; +import { Layers, LayersOptions } from "./dom/layers"; + +import { extractLayersConfig, LegacyLayersConfig, LayersConfig } from "./controls/layers-config"; + +import { initLegacyLayersControl } from "./controls/legacy-layers-control"; +import { initNullLayersControl } from "./controls/null-layers-control"; +import { initLayersControl } from "./controls/layers-control"; + +import { pointers } from "./dom/pointer"; + +declare const emulators: Emulators; + +export type EmulatorFunction = "dosboxWorker" | "dosboxDirect" | "dosboxNode" | "janus" | "backend"; + +export interface DosOptions { + sensitivityValue?: number; + mirroredControls?: boolean; + scaleControls?: number; + aspect?: number; + noWebGL?: boolean; + emulatorFunction?: EmulatorFunction; + clickToStart?: boolean; + layersOptions?: LayersOptions; + createTransportLayer?: () => TransportLayer; +} + +export class DosInstance { + emulatorsUi: EmulatorsUi; + emulatorFunction: EmulatorFunction; + createTransportLayer?: () => TransportLayer; + layers: Layers; + layersConfig: LayersConfig | LegacyLayersConfig | null = null; + ciPromise?: Promise; + + options: DosOptions; + mobileControls: boolean; + mirroredControls: boolean; + scaleControls: number; + + autolock: boolean; + sensitivity: number; + + storage: Storage; + + volume: number; + + private clickToStart: boolean; + private unbindControls: () => void = () => {/**/}; + private storedLayersConfig: LayersConfig | LegacyLayersConfig | null = null; + private onMobileControlsChanged: (visible: boolean) => void; + private onSensitivityChanged: ((sensitivity: number) => void)[] = []; + private onScaleChanged: ((scale: number) => void)[] = []; + private onVolumeChanged: ((scale: number) => void)[] = []; + + setVolumeImplFn: (volume: number) => void = () => {}; + + constructor(root: HTMLDivElement, emulatorsUi: EmulatorsUi, options: DosOptions) { + this.options = options; + this.emulatorsUi = emulatorsUi; + this.storage = emulatorsUi.dom.storage; + this.emulatorFunction = options.emulatorFunction || "dosboxWorker"; + this.clickToStart = options.clickToStart || false; + this.layers = this.emulatorsUi.dom.layers(root, options.layersOptions); + this.layers.showLoadingLayer(); + this.createTransportLayer = options.createTransportLayer; + this.mobileControls = pointers.bind.mobile; + this.autolock = false; + + this.mirroredControls = + this.options.mirroredControls === true || + this.storage.getItem("mirroredControls") === "true"; + + const scaleControlsValue = + this.options.scaleControls ?? + Number.parseFloat(this.storage.getItem("scaleControls") ?? "1.0"); + this.scaleControls = Number.isNaN(scaleControlsValue) ? 1.0 : scaleControlsValue; + + const sensitivityValue = + this.options.sensitivityValue ?? + Number.parseFloat(this.storage.getItem("sensitivity") ?? "1.0"); + this.sensitivity = Number.isNaN(sensitivityValue) ? 1.0 : sensitivityValue; + + const volumeValue = Number.parseFloat(this.storage.getItem("volume") ?? "1.0"); + this.volume = Number.isNaN(volumeValue) ? 1.0 : volumeValue; + + this.onMobileControlsChanged = () => {/**/}; + + if (this.emulatorFunction === "backend" && this.createTransportLayer === undefined) { + throw new Error("Emulator function set to 'backend' but 'createTransportLayer' is not a function"); + } + } + + async run(bundleUrl: string, + optionalChangesUrl?: string, + optionalPersistKey?: string): Promise { + await this.stop(); + this.layers.setLoadingMessage("Starting..."); + + const persistKey = (optionalPersistKey !== undefined && + optionalPersistKey !== null && optionalPersistKey.length > 0) ? + optionalPersistKey : bundleUrl + ".changes"; + + let ci: CommandInterface; + try { + ci = await this.runBundle(bundleUrl, optionalChangesUrl, persistKey); + } catch (e) { + this.layers.setLoadingMessage("Unexpected error occured..."); + this.layers.notyf.error({ message: "Can't start emulator look browser logs for more info" }); + // eslint-disable-next-line + console.error(e); + throw e; + } + + const emulatorsUi = this.emulatorsUi; + if (this.emulatorFunction === "janus") { + emulatorsUi.graphics.video(this.layers, ci); + } else { + emulatorsUi.persist.save(persistKey, this.layers, ci, emulators); + try { + if (this.options.noWebGL === true) { + throw new Error("WebGL is disabled by options"); + } + emulatorsUi.graphics.webGl(this.layers, ci, this.options.aspect); + } catch (e) { + // eslint-disable-next-line + console.error("Unable to create webgl canvas, fallback to 2d rendering"); + emulatorsUi.graphics._2d(this.layers, ci, this.options.aspect); + } + this.setVolumeImplFn = emulatorsUi.sound.audioNode(ci); + this.setVolumeImplFn(this.volume); + } + + emulatorsUi.dom.lifecycle(ci); + + const config = await ci.config(); + this.autolock = config.output?.options?.autolock?.value === true; + await this.setLayersConfig(extractLayersConfig(config)); + + if (!this.mobileControls) { + this.mobileControls = true; // force disabling + this.disableMobileControls(); + } + + this.layers.setLoadingMessage("Ready"); + this.layers.hideLoadingLayer(); + + if (this.clickToStart) { + this.layers.showClickToStart(); + } + + return ci; + } + + async stop(): Promise { + this.layers.showLoadingLayer(); + + if (this.ciPromise === undefined) { + return; + } + + const ci = await this.ciPromise; + delete this.ciPromise; + await ci.exit(); + + return; + } + + public async setLayersConfig(config: LayersConfig | LegacyLayersConfig | null, layerName?: string) { + if (this.ciPromise === undefined) { + return; + } + + const ci = await this.ciPromise; + + this.layersConfig = config; + this.unbindControls(); + + if (config === null) { + this.unbindControls = initNullLayersControl(this, this.layers, ci); + } else if (config.version === undefined) { + this.unbindControls = initLegacyLayersControl(this, this.layers, config as LegacyLayersConfig, ci); + } else { + this.unbindControls = initLayersControl(this.layers, config as LayersConfig, + ci, this, this.mirroredControls, this.scaleControls, layerName); + } + } + + public getLayersConfig(): LayersConfig | LegacyLayersConfig | null { + return this.layersConfig; + } + + public async enableMobileControls() { + if (this.mobileControls) { + return; + } + this.mobileControls = true; + await this.setLayersConfig(this.storedLayersConfig); + this.storedLayersConfig = null; + this.onMobileControlsChanged(true); + } + + public async disableMobileControls() { + if (!this.mobileControls) { + return; + } + this.mobileControls = false; + this.storedLayersConfig = this.layersConfig; + await this.setLayersConfig(null); + this.onMobileControlsChanged(false); + } + + public async setMirroredControls(mirrored: boolean) { + if (this.mirroredControls === mirrored) { + return; + } + this.mirroredControls = mirrored; + this.storage.setItem("mirroredControls", mirrored + ""); + if (mirrored) { + if (this.mobileControls) { + await this.setLayersConfig(this.layersConfig); + } else { + await this.enableMobileControls(); + } + } else { + if (this.mobileControls) { + await this.setLayersConfig(this.layersConfig); + } else { + // do nothing + } + } + } + + public async setScaleControls(scale: number) { + if (scale === this.scaleControls) { + return; + } + this.scaleControls = scale; + this.storage.setItem("scaleControls", scale + ""); + if (this.mobileControls) { + await this.setLayersConfig(this.layersConfig); + } + for (const next of this.onScaleChanged) { + next(this.scaleControls); + } + } + + public async setSensitivity(sensitivity: number) { + if (sensitivity === this.sensitivity) { + return; + } + this.sensitivity = sensitivity; + this.storage.setItem("sensitivity", sensitivity + ""); + await this.setLayersConfig(this.layersConfig); + for (const next of this.onSensitivityChanged) { + next(this.sensitivity); + } + } + + public async setVolume(volume: number) { + this.volume = volume; + this.storage.setItem("volume", volume + ""); + this.setVolumeImplFn(volume); + for (const next of this.onVolumeChanged) { + next(this.volume); + } + } + + public async setAutolock(autolock: boolean) { + if (autolock === this.autolock) { + return; + } + this.autolock = autolock; + await this.setLayersConfig(this.layersConfig); + } + + public setOnMobileControlsChanged(handler: (visible: boolean) => void) { + this.onMobileControlsChanged = handler; + } + + public registerOnSensitivityChanged = (handler: (sensitivity: number) => void) => { + this.onSensitivityChanged.push(handler); + }; + + public removeOnSensitivityChanged = (handler: (sensitivity: number) => void) => { + this.onSensitivityChanged = this.onSensitivityChanged.filter((n) => n !== handler); + }; + + public registerOnScaleChanged = (handler: (scale: number) => void) => { + this.onScaleChanged.push(handler); + }; + + public removeOnScaleChanged = (handler: (scale: number) => void) => { + this.onScaleChanged = this.onScaleChanged.filter((n) => n !== handler); + }; + + public registerOnVolumeChanged = (handler: (volume: number) => void) => { + this.onVolumeChanged.push(handler); + }; + + public removeOnVolumeChanged = (handler: (volume: number) => void) => { + this.onVolumeChanged = this.onVolumeChanged.filter((n) => n !== handler); + }; + + private async runBundle(bundleUrl: string, optionalChangesUrl: string | undefined, persistKey: string) { + const emulatorsUi = this.emulatorsUi; + if (this.emulatorFunction === "janus") { + this.layers.setLoadingMessage("Connecting..."); + this.ciPromise = emulators.janus(bundleUrl); + } else { + this.layers.setLoadingMessage("Downloading bundle ..."); + const bundlePromise = emulatorsUi.network.resolveBundle(bundleUrl, { + onprogress: (percent) => this.layers.setLoadingMessage("Downloading bundle " + percent + "%"), + }); + const options: BackendOptions = { + onExtractProgress: (index, file, extracted, total) => { + if (index !== 0) { + return; + } + + const percent = Math.round(extracted / total * 100); + const lastIndex = file.lastIndexOf("/"); + + const name = file.substring(lastIndex + 1); + this.layers.setLoadingMessage("Extracting " + percent + "% (" + name + ")"); + }, + }; + try { + let changesBundle: Uint8Array | undefined; + if (optionalChangesUrl !== undefined && optionalChangesUrl !== null && optionalChangesUrl.length > 0) { + changesBundle = await emulatorsUi.network.resolveBundle(optionalChangesUrl, { httpCache: false }); + } else { + changesBundle = await emulatorsUi.persist.load(persistKey, emulators); + } + const bundle = await bundlePromise; + if (this.emulatorFunction === "backend") { + this.ciPromise = emulators.backend([bundle, changesBundle], + (this as any).createTransportLayer() as TransportLayer, options); + } else { + this.ciPromise = emulators[this.emulatorFunction]([bundle, changesBundle], options); + } + } catch { + const bundle = await bundlePromise; + if (this.emulatorFunction === "backend") { + this.ciPromise = emulators.backend([bundle], + (this as any).createTransportLayer() as TransportLayer, options); + } else { + this.ciPromise = emulators[this.emulatorFunction]([bundle], options); + } + } + } + + return this.ciPromise; + } +} + +export type DosFactoryType = (root: HTMLDivElement, options?: DosOptions) => DosInstance; diff --git a/src/layers/network/xhr.ts b/src/layers/network/xhr.ts new file mode 100644 index 00000000..1bcb880f --- /dev/null +++ b/src/layers/network/xhr.ts @@ -0,0 +1,47 @@ +export async function resolveBundle(url: string, + options?: { + httpCache?: boolean, + onprogress?: (progress: number) => void + }): Promise { + const onprogress = options?.onprogress; + const httpCache = !(options?.httpCache === false); + + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open("GET", url, true); + request.overrideMimeType("text/plain; charset=x-user-defined"); + request.addEventListener("error", () => { + reject(new Error("Network error, can't download " + url)); + }); + request.addEventListener("abort", () => { + reject(new Error("Request canceled for url " + url)); + }, false); + request.responseType = "arraybuffer"; + request.onreadystatechange = () => { + if (request.readyState === 4) { + if (request.status === 200) { + if (onprogress !== undefined) { + onprogress(100); + } + resolve(new Uint8Array(request.response)); + } else { + reject(new Error("Network error, can't download " + url)); + } + } + }; + if (onprogress !== undefined) { + request.onprogress = (event) => { + if (event.total && event.total > 0) { + const porgress = Math.round(event.loaded * 10000 / event.total) / 100; + onprogress(porgress); + } + }; + } + if (httpCache === false) { + request.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0"); + request.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT"); + request.setRequestHeader("Pragma", "no-cache"); + } + request.send(); + }); +} diff --git a/src/layers/notification/notyf.ts b/src/layers/notification/notyf.ts new file mode 100644 index 00000000..ebdeb15e --- /dev/null +++ b/src/layers/notification/notyf.ts @@ -0,0 +1,13 @@ +import { CommandInterface, MessageType } from "emulators"; +import { Layers } from "../dom/layers"; + +export function notyf(layers: Layers, ci: CommandInterface) { + const notyf = layers.notyf; + ci.events().onMessage((msgType: MessageType, ...args) => { + if (msgType === "error") { + notyf.error({ + message: JSON.stringify(args), + }); + } + }); +} diff --git a/src/layers/persist/cache.ts b/src/layers/persist/cache.ts new file mode 100644 index 00000000..5ccc733e --- /dev/null +++ b/src/layers/persist/cache.ts @@ -0,0 +1,151 @@ +/* eslint @typescript-eslint/no-unused-vars: 0 */ + +export interface Cache { + put: (key: string, data: string | ArrayBuffer) => Promise; + get: (key: string, defaultValue?: string | ArrayBuffer) => Promise; + forEach: (each: (key: string, value: any) => void, onend: () => void) => void; + close: () => void; +} + +class CacheNoop implements Cache { + // eslint-disable-next-line + public close() { + } + + public put(key: string, data: string | ArrayBuffer): Promise { + return Promise.resolve(); + } + + public get(key: string, defaultValue?: string | ArrayBuffer): Promise { + if (defaultValue !== undefined) { + return Promise.resolve(defaultValue); + } + return Promise.reject(new Error("Cache is not supported on this host")); + } + + public forEach(each: (key: string, value: any) => void, onend: () => void) { + onend(); + } +} + +export function makeCache(version: string, logger: { onErr(...args: any[]): any }): Promise { + return new Promise((resolve) => { + new CacheDbImpl(version, resolve, (msg: string) => { + logger.onErr(msg); + resolve(new CacheNoop()); + }); + }); +} + +class CacheDbImpl implements Cache { + public version: string; + private storeName = "files"; + private indexedDB: IDBFactory; + private db: IDBDatabase | null = null; + + constructor(version: string, onready: (cache: Cache) => void, onerror: (msg: string) => void) { + this.version = version; + this.indexedDB = (typeof window === "undefined" ? undefined : window.indexedDB || + (window as any).mozIndexedDB || + (window as any).webkitIndexedDB || (window as any).msIndexedDB) as any; + + if (!this.indexedDB) { + onerror("Indexed db is not supported on this host"); + return; + } + + try { + const openRequest = this.indexedDB.open("js-dos-cache (" + version + ")", 1); + openRequest.onerror = (event) => { + onerror("Can't open cache database: " + openRequest.error?.message); + }; + openRequest.onsuccess = (event) => { + this.db = openRequest.result; + onready(this); + }; + openRequest.onupgradeneeded = (event) => { + try { + this.db = openRequest.result; + this.db.onerror = (event) => { + onerror("Can't upgrade cache database"); + }; + + this.db.createObjectStore(this.storeName); + } catch (e) { + onerror("Can't upgrade cache database"); + } + }; + } catch (e: any) { + onerror("Can't open cache database: " + e.message); + } + } + + public close() { + if (this.db !== null) { + this.db.close(); + this.db = null; + } + } + + public put(key: string, data: string | ArrayBuffer): Promise { + return new Promise((resolve) => { + if (this.db === null) { + resolve(); + return; + } + + const transaction = this.db.transaction(this.storeName, "readwrite"); + transaction.oncomplete = () => resolve(); + transaction.objectStore(this.storeName).put(data, key); + }); + } + + public get(key: string, defaultValue?: string | ArrayBuffer): Promise { + return new Promise((resolve, reject) => { + function rejectOrResolve(message: string) { + if (defaultValue === undefined) { + reject(new Error(message)); + } else { + resolve(defaultValue); + } + } + + + if (this.db === null) { + rejectOrResolve("db is not initalized"); + return; + } + + const transaction = this.db.transaction(this.storeName, "readonly"); + const request = transaction.objectStore(this.storeName).get(key); + request.onerror = () => reject(new Error("Can't read value for key '" + key + "'")); + request.onsuccess = () => { + if (request.result) { + resolve(request.result); + } else { + rejectOrResolve("Result is empty for key '" + key + "', result: " + request.result); + } + }; + }); + } + + public forEach(each: (key: string, value: any) => void, onend: () => void) { + if (this.db === null) { + onend(); + return; + } + + const transaction = this.db.transaction(this.storeName, "readonly"); + const request = transaction.objectStore(this.storeName).openCursor(); + request.onerror = () => onend(); + request.onsuccess = (event) => { + const cursor = (event.target as any).result as IDBCursorWithValue; + if (cursor) { + each(cursor.key.toString(), cursor.value); + cursor.continue(); + } else { + onend(); + } + }; + } +} diff --git a/src/layers/persist/save-load.ts b/src/layers/persist/save-load.ts new file mode 100644 index 00000000..92ce3694 --- /dev/null +++ b/src/layers/persist/save-load.ts @@ -0,0 +1,26 @@ +import { CommandInterface, Emulators } from "emulators"; +import { Layers } from "../dom/layers"; +import { makeCache } from "./cache"; + +const cacheName = "emulators-ui-saves"; +const cachePromise = makeCache(cacheName, { + onErr: console.error, +}); + + +export function save(key: string, + layers: Layers, + ci: CommandInterface, + emulators: Emulators) { + layers.setOnSave(async () => { + const cache = await cachePromise; + const updated = await ci.persist(); + return cache.put(key, updated.buffer); + }); +} + +export async function load(key: string, + emulators: Emulators) { + const cache = await cachePromise; + return cache.get(key).then((buffer) => new Uint8Array(buffer as ArrayBuffer)); +} diff --git a/src/layers/sound/audio-node.ts b/src/layers/sound/audio-node.ts new file mode 100644 index 00000000..d2d045ab --- /dev/null +++ b/src/layers/sound/audio-node.ts @@ -0,0 +1,183 @@ +import { CommandInterface, DirectSound } from "emulators"; + +class SamplesQueue { + private samplesQueue: Float32Array[] = []; + + push(samples: Float32Array) { + this.samplesQueue.push(samples); + } + + length() { + let total = 0; + for (const next of this.samplesQueue) { + total += next.length; + } + return total; + } + + writeTo(dst: Float32Array, bufferSize: number) { + let writeIt = 0; + while (this.samplesQueue.length > 0) { + const src = this.samplesQueue[0]; + const toRead = Math.min(bufferSize - writeIt, src.length); + if (toRead === src.length) { + dst.set(src, writeIt); + this.samplesQueue.shift(); + } else { + dst.set(src.slice(0, toRead), writeIt); + this.samplesQueue[0] = src.slice(toRead); + } + + writeIt += toRead; + + if (writeIt === bufferSize) { + break; + } + } + + if (writeIt < bufferSize) { + dst.fill(0, writeIt); + } + } +} + +export function audioNode(ci: CommandInterface): (volume: number) => void { + const sampleRate = ci.soundFrequency(); + const channels = 1; + + if (sampleRate === 0) { + console.warn("Can't create audio node with sampleRate === 0, ingnoring"); + return () => {}; + } + + let audioContext: AudioContext | null = null; + + if (typeof AudioContext !== "undefined") { + audioContext = new AudioContext({ + sampleRate, + latencyHint: "interactive", + }); + } else if (typeof (window as any).webkitAudioContext !== "undefined") { + // eslint-disable-next-line new-cap + audioContext = new (window as any).webkitAudioContext({ + sampleRate, + latencyHint: "interactive", + }); + } + + if (audioContext == null) { + return () => {}; + } + + const samplesQueue = new SamplesQueue(); + const bufferSize = 2048; + const preBufferSize = 2048; + + ci.events().onSoundPush((samples) => { + if (samplesQueue.length() < bufferSize * 2 + preBufferSize) { + samplesQueue.push(samples); + } + }); + + const audioNode = audioContext.createScriptProcessor(bufferSize, 0, channels); + let started = false; + + let active = 0; + const directSound = ci.directSound as DirectSound; + const onDirectProcess = (event: AudioProcessingEvent) => { + if (!started) { + const buffer = directSound.buffer[0]; + started = Math.ceil(buffer[buffer.length - 1]) > 0; + } + + if (!started) { + return; + } + + let offset = 0; + let numFrames = event.outputBuffer.length; + const numChannels = event.outputBuffer.numberOfChannels; + + let numSamples; + let buffer = directSound.buffer[active]; + while (numFrames > 0 && (numSamples = Math.ceil(buffer[buffer.length - 1])) > 0) { + if (numFrames >= numSamples) { + const source = buffer.subarray(0, numSamples); + for (let channel = 0; channel < numChannels; ++channel) { + const channelData = event.outputBuffer.getChannelData(channel); + channelData.set(source, offset); + } + + offset += numSamples; + numFrames -= numSamples; + + buffer[buffer.length - 1] = 0; + active = (active + 1) % directSound.ringSize; + buffer = directSound.buffer[active]; + } else { + const source = buffer.subarray(0, numFrames); + for (let channel = 0; channel < numChannels; ++channel) { + const channelData = event.outputBuffer.getChannelData(channel); + channelData.set(source, offset); + } + + buffer[buffer.length - 1] = numSamples - numFrames; + buffer.set(buffer.subarray(numFrames, numFrames + buffer[buffer.length - 1])); + numFrames = 0; + } + } + }; + + const onQueueProcess = (event: AudioProcessingEvent) => { + const numFrames = event.outputBuffer.length; + const numChannels = event.outputBuffer.numberOfChannels; + const samplesCount = samplesQueue.length(); + + if (!started) { + started = samplesCount >= preBufferSize; + } + + if (!started) { + return; + } + + for (let channel = 0; channel < numChannels; channel++) { + const channelData = event.outputBuffer.getChannelData(channel); + samplesQueue.writeTo(channelData, numFrames); + } + }; + + audioNode.onaudioprocess = ci.directSound !== undefined ? onDirectProcess : onQueueProcess; + + const gainNode = audioContext.createGain(); + gainNode.connect(audioContext.destination); + audioNode.connect(gainNode); + + gainNode.gain.value = 1.0; + + const resumeWebAudio = () => { + if (audioContext !== null && audioContext.state === "suspended") { + audioContext.resume(); + } + }; + + document.addEventListener("click", resumeWebAudio, { once: true }); + document.addEventListener("touchstart", resumeWebAudio, { once: true }); + document.addEventListener("keydown", resumeWebAudio, { once: true }); + + ci.events().onExit(() => { + if (audioContext !== null) { + audioNode.disconnect(); + gainNode.disconnect(); + audioContext.close(); + } + + document.removeEventListener("click", resumeWebAudio); + document.removeEventListener("touchstart", resumeWebAudio); + document.removeEventListener("keydown", resumeWebAudio); + }); + + return (volume: number) => { + gainNode.gain.value = volume; + }; +}