Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/steam.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,38 @@ Windowsでスリープ(ノートPCのふたを閉じる等)から復帰す

Linuxの透明表示はコンポジタが必要です。

#### ミニウィンドウ(通常の窓 ⇄ 小窓)

ゲーム中に、通常の窓(全画面を含む)と、デスクトップの隅に置く最前面の小窓を切り替えられます。放置ゲームの「作業しながら遊ぶ」表示などに使います。`window.mini` 未指定なら従来どおりです。重ね表示(`window.overlay.enabled: true`)とは同時に使えません。

```json
"window": {
"width": 1920, "height": 1080, "fullscreen": true,
"mini": { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true }
}
```

| 項目 | 内容 |
|---|---|
| `width` / `height` | 小窓の中身(タイトルバーを除く)の大きさ。既定 720×180 |
| `anchor` | 初めて小窓にしたときの位置。`"bottom-right"`(既定)/ `"bottom-left"` / `"top-right"` / `"top-left"`。窓のある画面の作業領域(タスクバー・Dockを除く)の隅 |
| `margin` | 隅からの間隔(px)。既定 `16` |
| `alwaysOnTop` | `true`(既定)で小窓を最前面に置く(macOSは全ワークスペース・全画面アプリ上にも表示) |

小窓は通常の窓のまま(タイトルバーあり。タイトルバーで動かせる)で、キー入力も受け取ります。リサイズ・最大化・全画面はできません。動かした位置は、アプリを終了するまで次の小窓で使います。通常の窓に戻すと、小窓にする前の位置・大きさ・最大化・全画面に戻ります。小窓の間は F11/Alt+Enter/Esc で全画面を切り替えません(Esc はゲームに届きます)。

rendererへ `window.next2dWindow` を公開します(アプリのメインフレームのみ)。

```ts
const { mode, width, height } = await window.next2dWindow.getMode(); // mode は "full" か "mini"
await window.next2dWindow.setMode("mini"); // 切り替えが終わると { mode, width, height } で解決
const off = window.next2dWindow.onModeChange(({ mode, width, height }) => { /* 表示を合わせ直す */ });
const { x, y, width: w, height: h, scaleFactor } = await window.next2dWindow.getBounds();
await window.next2dWindow.quit();
```

`width` / `height` は窓の中身の大きさ(CSS ピクセル)です。Next2D のステージは縦横比を保ったまま窓に合わせて縮むため、1920×1080 のステージを 720×180 の小窓で表示すると、ステージの範囲は高さ 180・幅 320 になります(`options.fullScreen: true` なら、左右のステージの外も描かれます)。小窓用の表示は、この大きさに合わせてゲーム側で配置してください。

### STEP4:認証を用意する

#### macOSの署名・公証
Expand Down Expand Up @@ -333,6 +365,38 @@ On Windows, after sleep (e.g. closing a laptop lid) the overlay window can stop

Transparency on Linux requires a compositor.

#### Mini window (normal window ⇄ small window)

At run time the game can switch between its normal window (fullscreen included) and a small always-on-top window in a corner of the desktop, for "play while you work" idle modes and the like. Without `window.mini` the window behaves as before. It cannot be combined with the overlay (`window.overlay.enabled: true`).

```json
"window": {
"width": 1920, "height": 1080, "fullscreen": true,
"mini": { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true }
}
```

| Field | Behaviour |
|---|---|
| `width` / `height` | Content size of the small window (title bar excluded). Default 720×180. |
| `anchor` | Where the small window first appears: `"bottom-right"` (default), `"bottom-left"`, `"top-right"` or `"top-left"` corner of the work area (taskbar/Dock excluded) of the display the window is on. |
| `margin` | Distance from that corner in px. Default `16`. |
| `alwaysOnTop` | `true` (default) keeps the small window on top (on macOS also on every workspace and above fullscreen apps). |

The small window stays a normal window: it has a title bar (drag it to move the window) and takes keyboard input. It cannot be resized, maximized or made fullscreen. Where the player moves it is used for the next small window until the app quits. Switching back restores the position, size, maximized and fullscreen state from before. In the small window F11 / Alt+Enter / Escape do not toggle fullscreen (Escape reaches the game).

The renderer gets `window.next2dWindow` (app main frame only):

```ts
const { mode, width, height } = await window.next2dWindow.getMode(); // mode is "full" or "mini"
await window.next2dWindow.setMode("mini"); // resolves with { mode, width, height } once applied
const off = window.next2dWindow.onModeChange(({ mode, width, height }) => { /* lay the page out again */ });
const { x, y, width: w, height: h, scaleFactor } = await window.next2dWindow.getBounds();
await window.next2dWindow.quit();
```

`width` / `height` are the content size in CSS pixels. The Next2D stage keeps its aspect ratio when it is fitted into the window, so a 1920×1080 stage shown in a 720×180 window covers 320×180 (with `options.fullScreen: true` the area left and right of the stage is drawn too). Lay out the small-window view for that size in the game.

### STEP4: Prepare credentials

#### macOS signing and notarization
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@next2d/builder",
"version": "0.4.7",
"version": "0.4.8",
"description": "Multi-platform builder for Next2d Framework, supporting export to various platforms such as macOS, Windows, iOS, Android and Web(HTML)",
"author": "Toshiyuki Ienaga <ienaga@next2d.app>",
"license": "MIT",
Expand Down Expand Up @@ -47,7 +47,7 @@
"@typescript-eslint/parser": "^8.72.1",
"eslint": "^10.14.0",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.13.0",
"globals": "^17.14.0",
"typescript": "^6.0.3",
"vite": "^8.3.5"
}
Expand Down
32 changes: 31 additions & 1 deletion src/electron-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import type { INativeBridgeConfig } from "./electron-native.js";

export type ElectronOS = "windows" | "macos" | "linux";
export interface IElectronOverlayConfig { enabled: boolean; anchor: "bottom"; clickThrough: boolean }
export type ElectronMiniAnchor = "bottom-right" | "bottom-left" | "top-right" | "top-left";
/** A small always-on-top window the game can switch to and back from at run time (idle/companion modes). */
export interface IElectronMiniConfig { width: number; height: number; anchor: ElectronMiniAnchor; margin: number; alwaysOnTop: boolean }
const MINI_ANCHORS: readonly ElectronMiniAnchor[] = ["bottom-right", "bottom-left", "top-right", "top-left"];
export interface IElectronConfig {
nativeBridge?: INativeBridgeConfig;
appId: string;
Expand All @@ -16,7 +20,7 @@ export interface IElectronConfig {
companyName: string;
icons: Partial<Record<ElectronOS, string>>;
architectures: Partial<Record<ElectronOS, string>>;
window: { width: number; height: number; fullscreen: boolean; overlay?: IElectronOverlayConfig };
window: { width: number; height: number; fullscreen: boolean; overlay?: IElectronOverlayConfig; mini?: IElectronMiniConfig };
steam?: { appId: string | null; depots: Partial<Record<ElectronOS, string | null>>; branch?: string };
macos: { sign: boolean; notarize: boolean; localNetworkUsageDescription?: string };
}
Expand Down Expand Up @@ -93,6 +97,32 @@ export const readElectronConfig = (root: string): IElectronConfig => {
config.window.fullscreen = false;
}
}
const mini: Partial<IElectronMiniConfig> | undefined = config.window.mini;
if (mini !== undefined) {
if (!mini || typeof mini !== "object" || Array.isArray(mini)) {
throw new Error("electron.config.json window.mini must be an object.");
}
config.window.mini = { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true, ...mini };
const { width, height, anchor, margin, alwaysOnTop } = config.window.mini;
for (const dimension of [width, height]) {
if (!Number.isInteger(dimension) || dimension < 1 || dimension > 16384) {
throw new Error("electron.config.json window.mini.width/height must be integers between 1 and 16384.");
}
}
if (!MINI_ANCHORS.includes(anchor)) {
throw new Error(`electron.config.json window.mini.anchor must be one of ${MINI_ANCHORS.map((a) => `"${a}"`).join(", ")}.`);
}
if (!Number.isInteger(margin) || margin < 0 || margin > 1000) {
throw new Error("electron.config.json window.mini.margin must be an integer between 0 and 1000.");
}
if (typeof alwaysOnTop !== "boolean") {
throw new Error("electron.config.json window.mini.alwaysOnTop must be a boolean.");
}
// The overlay already is the small always-on-top window, and it never takes keyboard focus.
if (config.window.overlay?.enabled) {
throw new Error("electron.config.json window.mini cannot be combined with window.overlay.enabled.");
}
}
if (config.macos.notarize && !config.macos.sign) {
throw new Error("macos.notarize requires macos.sign.");
}
Expand Down
136 changes: 118 additions & 18 deletions templates/electron/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,92 @@ protocol.registerSchemesAsPrivileged([{

const startURL = "next2d://game/index.html";
const overlay = config.window.overlay?.enabled ? config.window.overlay : null;
// The game can switch between its normal window ("full") and a small always-on-top window ("mini") at run time.
const mini = !overlay && config.window.mini ? config.window.mini : null;
let mainWindow;
let windowMode = "full";
// How the window looked before it became small, to put it back exactly (position, size, maximized, fullscreen).
let fullState = null;
// Where the player last left the small window (it has a title bar and can be moved).
let miniPosition = null;
// Mode changes are applied one after another (leaving fullscreen is animated on macOS).
let modeQueue = Promise.resolve();
const alive = () => Boolean(mainWindow && !mainWindow.isDestroyed());
const modeInfo = () => {
const [width, height] = mainWindow.getContentSize();
return { mode: windowMode, width, height };
};
// Entering and leaving fullscreen is animated on macOS: wait for it, so the size the page is told is the real one.
const setFullScreenAndWait = (win, value) => new Promise((resolve) => {
if (win.isFullScreen() === value) {
resolve();
return;
}
let timer;
const finish = () => {
clearTimeout(timer);
resolve();
};
// Some window managers never report it; do not wait forever.
timer = setTimeout(finish, 1500);
win.once(value ? "enter-full-screen" : "leave-full-screen", finish);
win.setFullScreen(value);
});
const cornerOf = (win) => {
const { width, height } = win.getBounds();
const { workArea } = screen.getDisplayMatching(win.getBounds());
const right = mini.anchor.endsWith("right");
const bottom = mini.anchor.startsWith("bottom");
return {
x: right ? workArea.x + workArea.width - width - mini.margin : workArea.x + mini.margin,
y: bottom ? workArea.y + workArea.height - height - mini.margin : workArea.y + mini.margin
};
};
const toMini = async () => {
const win = mainWindow;
fullState = { bounds: win.getNormalBounds(), maximized: win.isMaximized(), fullScreen: win.isFullScreen() };
await setFullScreenAndWait(win, false);
if (win.isMaximized()) win.unmaximize();
// Resize first: some platforms ignore size changes of a window that is no longer resizable.
win.setContentSize(mini.width, mini.height);
win.setFullScreenable(false);
win.setMaximizable(false);
win.setResizable(false);
const { x, y } = miniPosition ?? cornerOf(win);
win.setPosition(x, y);
if (mini.alwaysOnTop) {
win.setAlwaysOnTop(true, "floating");
if (process.platform === "darwin") win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
windowMode = "mini";
};
const toFull = async () => {
const win = mainWindow;
const [x, y] = win.getPosition();
miniPosition = { x, y };
if (mini.alwaysOnTop) {
win.setAlwaysOnTop(false);
if (process.platform === "darwin") win.setVisibleOnAllWorkspaces(false);
}
win.setResizable(true);
win.setMaximizable(true);
win.setFullScreenable(true);
if (fullState) {
win.setBounds(fullState.bounds);
if (fullState.maximized) win.maximize();
if (fullState.fullScreen) await setFullScreenAndWait(win, true);
}
win.focus();
windowMode = "full";
};
const setWindowMode = (mode) => {
modeQueue = modeQueue.then(async () => {
if (!alive() || mode === windowMode) return;
await (mode === "mini" ? toMini() : toFull());
mainWindow.webContents.send("next2d:window:mode", modeInfo());
}).catch((error) => console.error("Window mode change failed:", error));
return modeQueue.then(() => (alive() ? modeInfo() : { mode: windowMode, width: 0, height: 0 }));
};
// The last click-through state asked for, so it can be applied again after sleep/lock.
let overlayIgnore = Boolean(config.window.overlay?.enabled && config.window.overlay.clickThrough);
// Bottom anchor: flush with the primary display's work area (above the taskbar/Dock), centred.
Expand Down Expand Up @@ -129,7 +214,8 @@ const createWindow = async () => {
// The preload reads these flags; each API is exposed only when its feature is configured.
const preloadFlags = [
...(config.nativeBridge ? ["--next2d-native-bridge"] : []),
...(overlay ? ["--next2d-overlay"] : [])
...(overlay ? ["--next2d-overlay"] : []),
...(mini ? ["--next2d-window-mode"] : [])
];
mainWindow = new BrowserWindow({
width: Math.min(config.window.width, width),
Expand Down Expand Up @@ -197,7 +283,8 @@ const createWindow = async () => {
}
});
mainWindow.webContents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || overlay) {
// The small window never goes fullscreen; Escape belongs to the game there.
if (input.type !== "keyDown" || overlay || windowMode === "mini") {
return;
}
if (input.key === "F11" || (input.alt && input.key === "Enter")) {
Expand Down Expand Up @@ -259,24 +346,42 @@ app.whenReady().then(async () => {
return nativeBridge.request(method, params);
});
}
const trusted = (event) => {
if (!isTrustedSender(event, mainWindow)) {
diag(`window API access denied (frame url: ${event?.senderFrame?.url})`);
throw new Error("Window API access denied");
}
};
if (overlay || mini) {
ipcMain.handle("next2d:window:get-bounds", (event) => {
trusted(event);
const bounds = mainWindow.getBounds();
return { ...bounds, scaleFactor: screen.getDisplayMatching(bounds).scaleFactor };
});
ipcMain.handle("next2d:window:quit", (event) => {
trusted(event);
// Reply before the window is torn down.
setImmediate(() => app.quit());
});
}
if (mini) {
ipcMain.handle("next2d:window:get-mode", (event) => {
trusted(event);
return modeInfo();
});
ipcMain.handle("next2d:window:set-mode", (event, mode) => {
trusted(event);
if (mode !== "full" && mode !== "mini") throw new TypeError("mode must be \"full\" or \"mini\"");
return setWindowMode(mode);
});
}
if (overlay) {
const trusted = (event) => {
if (!isTrustedSender(event, mainWindow)) {
diag(`window API access denied (frame url: ${event?.senderFrame?.url})`);
throw new Error("Window API access denied");
}
};
ipcMain.handle("next2d:window:set-ignore-mouse-events", (event, ignore) => {
trusted(event);
if (typeof ignore !== "boolean") throw new TypeError("ignore must be a boolean");
overlayIgnore = ignore;
mainWindow.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined);
});
ipcMain.handle("next2d:window:get-bounds", (event) => {
trusted(event);
const bounds = mainWindow.getBounds();
return { ...bounds, scaleFactor: screen.getDisplayMatching(bounds).scaleFactor };
});
// After sleep or screen lock the OS can drop the mouse-forwarding hook and reset the window's
// z-order/bounds. Apply the overlay state again and tell the page so it can recover too.
const recoverOverlay = () => {
Expand All @@ -300,11 +405,6 @@ app.whenReady().then(async () => {
});
}
app.on("child-process-gone", (_event, details) => diag(`child-process-gone ${details.type} ${details.reason}`));
ipcMain.handle("next2d:window:quit", (event) => {
trusted(event);
// Reply before the window is torn down.
setImmediate(() => app.quit());
});
}
await createWindow();
}).catch((error) => {
Expand Down
Loading
Loading