@next2d/builder 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,15 @@ export interface IElectronOverlayConfig {
5
5
  anchor: "bottom";
6
6
  clickThrough: boolean;
7
7
  }
8
+ export type ElectronMiniAnchor = "bottom-right" | "bottom-left" | "top-right" | "top-left";
9
+ /** A small always-on-top window the game can switch to and back from at run time (idle/companion modes). */
10
+ export interface IElectronMiniConfig {
11
+ width: number;
12
+ height: number;
13
+ anchor: ElectronMiniAnchor;
14
+ margin: number;
15
+ alwaysOnTop: boolean;
16
+ }
8
17
  export interface IElectronConfig {
9
18
  nativeBridge?: INativeBridgeConfig;
10
19
  appId: string;
@@ -19,6 +28,7 @@ export interface IElectronConfig {
19
28
  height: number;
20
29
  fullscreen: boolean;
21
30
  overlay?: IElectronOverlayConfig;
31
+ mini?: IElectronMiniConfig;
22
32
  };
23
33
  steam?: {
24
34
  appId: string | null;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { getTemplateDir } from "./utils.js";
4
4
  import { resolveDescription } from "./description.js";
5
5
  import { validateNativeBridge } from "./electron-native.js";
6
+ const MINI_ANCHORS = ["bottom-right", "bottom-left", "top-right", "top-left"];
6
7
  export const validateSteamId = (value) => {
7
8
  const id = String(value);
8
9
  if (!/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id)) || Number(id) > 4294967295) {
@@ -74,6 +75,32 @@ export const readElectronConfig = (root) => {
74
75
  config.window.fullscreen = false;
75
76
  }
76
77
  }
78
+ const mini = config.window.mini;
79
+ if (mini !== undefined) {
80
+ if (!mini || typeof mini !== "object" || Array.isArray(mini)) {
81
+ throw new Error("electron.config.json window.mini must be an object.");
82
+ }
83
+ config.window.mini = { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true, ...mini };
84
+ const { width, height, anchor, margin, alwaysOnTop } = config.window.mini;
85
+ for (const dimension of [width, height]) {
86
+ if (!Number.isInteger(dimension) || dimension < 1 || dimension > 16384) {
87
+ throw new Error("electron.config.json window.mini.width/height must be integers between 1 and 16384.");
88
+ }
89
+ }
90
+ if (!MINI_ANCHORS.includes(anchor)) {
91
+ throw new Error(`electron.config.json window.mini.anchor must be one of ${MINI_ANCHORS.map((a) => `"${a}"`).join(", ")}.`);
92
+ }
93
+ if (!Number.isInteger(margin) || margin < 0 || margin > 1000) {
94
+ throw new Error("electron.config.json window.mini.margin must be an integer between 0 and 1000.");
95
+ }
96
+ if (typeof alwaysOnTop !== "boolean") {
97
+ throw new Error("electron.config.json window.mini.alwaysOnTop must be a boolean.");
98
+ }
99
+ // The overlay already is the small always-on-top window, and it never takes keyboard focus.
100
+ if (config.window.overlay?.enabled) {
101
+ throw new Error("electron.config.json window.mini cannot be combined with window.overlay.enabled.");
102
+ }
103
+ }
77
104
  if (config.macos.notarize && !config.macos.sign) {
78
105
  throw new Error("macos.notarize requires macos.sign.");
79
106
  }
package/docs/steam.md CHANGED
@@ -111,6 +111,38 @@ Windowsでスリープ(ノートPCのふたを閉じる等)から復帰す
111
111
 
112
112
  Linuxの透明表示はコンポジタが必要です。
113
113
 
114
+ #### ミニウィンドウ(通常の窓 ⇄ 小窓)
115
+
116
+ ゲーム中に、通常の窓(全画面を含む)と、デスクトップの隅に置く最前面の小窓を切り替えられます。放置ゲームの「作業しながら遊ぶ」表示などに使います。`window.mini` 未指定なら従来どおりです。重ね表示(`window.overlay.enabled: true`)とは同時に使えません。
117
+
118
+ ```json
119
+ "window": {
120
+ "width": 1920, "height": 1080, "fullscreen": true,
121
+ "mini": { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true }
122
+ }
123
+ ```
124
+
125
+ | 項目 | 内容 |
126
+ |---|---|
127
+ | `width` / `height` | 小窓の中身(タイトルバーを除く)の大きさ。既定 720×180 |
128
+ | `anchor` | 初めて小窓にしたときの位置。`"bottom-right"`(既定)/ `"bottom-left"` / `"top-right"` / `"top-left"`。窓のある画面の作業領域(タスクバー・Dockを除く)の隅 |
129
+ | `margin` | 隅からの間隔(px)。既定 `16` |
130
+ | `alwaysOnTop` | `true`(既定)で小窓を最前面に置く(macOSは全ワークスペース・全画面アプリ上にも表示) |
131
+
132
+ 小窓は通常の窓のまま(タイトルバーあり。タイトルバーで動かせる)で、キー入力も受け取ります。リサイズ・最大化・全画面はできません。動かした位置は、アプリを終了するまで次の小窓で使います。通常の窓に戻すと、小窓にする前の位置・大きさ・最大化・全画面に戻ります。小窓の間は F11/Alt+Enter/Esc で全画面を切り替えません(Esc はゲームに届きます)。
133
+
134
+ rendererへ `window.next2dWindow` を公開します(アプリのメインフレームのみ)。
135
+
136
+ ```ts
137
+ const { mode, width, height } = await window.next2dWindow.getMode(); // mode は "full" か "mini"
138
+ await window.next2dWindow.setMode("mini"); // 切り替えが終わると { mode, width, height } で解決
139
+ const off = window.next2dWindow.onModeChange(({ mode, width, height }) => { /* 表示を合わせ直す */ });
140
+ const { x, y, width: w, height: h, scaleFactor } = await window.next2dWindow.getBounds();
141
+ await window.next2dWindow.quit();
142
+ ```
143
+
144
+ `width` / `height` は窓の中身の大きさ(CSS ピクセル)です。Next2D のステージは縦横比を保ったまま窓に合わせて縮むため、1920×1080 のステージを 720×180 の小窓で表示すると、ステージの範囲は高さ 180・幅 320 になります(`options.fullScreen: true` なら、左右のステージの外も描かれます)。小窓用の表示は、この大きさに合わせてゲーム側で配置してください。
145
+
114
146
  ### STEP4:認証を用意する
115
147
 
116
148
  #### macOSの署名・公証
@@ -333,6 +365,38 @@ On Windows, after sleep (e.g. closing a laptop lid) the overlay window can stop
333
365
 
334
366
  Transparency on Linux requires a compositor.
335
367
 
368
+ #### Mini window (normal window ⇄ small window)
369
+
370
+ 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`).
371
+
372
+ ```json
373
+ "window": {
374
+ "width": 1920, "height": 1080, "fullscreen": true,
375
+ "mini": { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true }
376
+ }
377
+ ```
378
+
379
+ | Field | Behaviour |
380
+ |---|---|
381
+ | `width` / `height` | Content size of the small window (title bar excluded). Default 720×180. |
382
+ | `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. |
383
+ | `margin` | Distance from that corner in px. Default `16`. |
384
+ | `alwaysOnTop` | `true` (default) keeps the small window on top (on macOS also on every workspace and above fullscreen apps). |
385
+
386
+ 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).
387
+
388
+ The renderer gets `window.next2dWindow` (app main frame only):
389
+
390
+ ```ts
391
+ const { mode, width, height } = await window.next2dWindow.getMode(); // mode is "full" or "mini"
392
+ await window.next2dWindow.setMode("mini"); // resolves with { mode, width, height } once applied
393
+ const off = window.next2dWindow.onModeChange(({ mode, width, height }) => { /* lay the page out again */ });
394
+ const { x, y, width: w, height: h, scaleFactor } = await window.next2dWindow.getBounds();
395
+ await window.next2dWindow.quit();
396
+ ```
397
+
398
+ `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.
399
+
336
400
  ### STEP4: Prepare credentials
337
401
 
338
402
  #### macOS signing and notarization
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@next2d/builder",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Multi-platform builder for Next2d Framework, supporting export to various platforms such as macOS, Windows, iOS, Android and Web(HTML)",
5
5
  "author": "Toshiyuki Ienaga <ienaga@next2d.app>",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  "@typescript-eslint/parser": "^8.72.1",
48
48
  "eslint": "^10.14.0",
49
49
  "eslint-plugin-unused-imports": "^4.4.1",
50
- "globals": "^17.13.0",
50
+ "globals": "^17.14.0",
51
51
  "typescript": "^6.0.3",
52
52
  "vite": "^8.3.5"
53
53
  }
@@ -34,7 +34,92 @@ protocol.registerSchemesAsPrivileged([{
34
34
 
35
35
  const startURL = "next2d://game/index.html";
36
36
  const overlay = config.window.overlay?.enabled ? config.window.overlay : null;
37
+ // The game can switch between its normal window ("full") and a small always-on-top window ("mini") at run time.
38
+ const mini = !overlay && config.window.mini ? config.window.mini : null;
37
39
  let mainWindow;
40
+ let windowMode = "full";
41
+ // How the window looked before it became small, to put it back exactly (position, size, maximized, fullscreen).
42
+ let fullState = null;
43
+ // Where the player last left the small window (it has a title bar and can be moved).
44
+ let miniPosition = null;
45
+ // Mode changes are applied one after another (leaving fullscreen is animated on macOS).
46
+ let modeQueue = Promise.resolve();
47
+ const alive = () => Boolean(mainWindow && !mainWindow.isDestroyed());
48
+ const modeInfo = () => {
49
+ const [width, height] = mainWindow.getContentSize();
50
+ return { mode: windowMode, width, height };
51
+ };
52
+ // Entering and leaving fullscreen is animated on macOS: wait for it, so the size the page is told is the real one.
53
+ const setFullScreenAndWait = (win, value) => new Promise((resolve) => {
54
+ if (win.isFullScreen() === value) {
55
+ resolve();
56
+ return;
57
+ }
58
+ let timer;
59
+ const finish = () => {
60
+ clearTimeout(timer);
61
+ resolve();
62
+ };
63
+ // Some window managers never report it; do not wait forever.
64
+ timer = setTimeout(finish, 1500);
65
+ win.once(value ? "enter-full-screen" : "leave-full-screen", finish);
66
+ win.setFullScreen(value);
67
+ });
68
+ const cornerOf = (win) => {
69
+ const { width, height } = win.getBounds();
70
+ const { workArea } = screen.getDisplayMatching(win.getBounds());
71
+ const right = mini.anchor.endsWith("right");
72
+ const bottom = mini.anchor.startsWith("bottom");
73
+ return {
74
+ x: right ? workArea.x + workArea.width - width - mini.margin : workArea.x + mini.margin,
75
+ y: bottom ? workArea.y + workArea.height - height - mini.margin : workArea.y + mini.margin
76
+ };
77
+ };
78
+ const toMini = async () => {
79
+ const win = mainWindow;
80
+ fullState = { bounds: win.getNormalBounds(), maximized: win.isMaximized(), fullScreen: win.isFullScreen() };
81
+ await setFullScreenAndWait(win, false);
82
+ if (win.isMaximized()) win.unmaximize();
83
+ // Resize first: some platforms ignore size changes of a window that is no longer resizable.
84
+ win.setContentSize(mini.width, mini.height);
85
+ win.setFullScreenable(false);
86
+ win.setMaximizable(false);
87
+ win.setResizable(false);
88
+ const { x, y } = miniPosition ?? cornerOf(win);
89
+ win.setPosition(x, y);
90
+ if (mini.alwaysOnTop) {
91
+ win.setAlwaysOnTop(true, "floating");
92
+ if (process.platform === "darwin") win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
93
+ }
94
+ windowMode = "mini";
95
+ };
96
+ const toFull = async () => {
97
+ const win = mainWindow;
98
+ const [x, y] = win.getPosition();
99
+ miniPosition = { x, y };
100
+ if (mini.alwaysOnTop) {
101
+ win.setAlwaysOnTop(false);
102
+ if (process.platform === "darwin") win.setVisibleOnAllWorkspaces(false);
103
+ }
104
+ win.setResizable(true);
105
+ win.setMaximizable(true);
106
+ win.setFullScreenable(true);
107
+ if (fullState) {
108
+ win.setBounds(fullState.bounds);
109
+ if (fullState.maximized) win.maximize();
110
+ if (fullState.fullScreen) await setFullScreenAndWait(win, true);
111
+ }
112
+ win.focus();
113
+ windowMode = "full";
114
+ };
115
+ const setWindowMode = (mode) => {
116
+ modeQueue = modeQueue.then(async () => {
117
+ if (!alive() || mode === windowMode) return;
118
+ await (mode === "mini" ? toMini() : toFull());
119
+ mainWindow.webContents.send("next2d:window:mode", modeInfo());
120
+ }).catch((error) => console.error("Window mode change failed:", error));
121
+ return modeQueue.then(() => (alive() ? modeInfo() : { mode: windowMode, width: 0, height: 0 }));
122
+ };
38
123
  // The last click-through state asked for, so it can be applied again after sleep/lock.
39
124
  let overlayIgnore = Boolean(config.window.overlay?.enabled && config.window.overlay.clickThrough);
40
125
  // Bottom anchor: flush with the primary display's work area (above the taskbar/Dock), centred.
@@ -129,7 +214,8 @@ const createWindow = async () => {
129
214
  // The preload reads these flags; each API is exposed only when its feature is configured.
130
215
  const preloadFlags = [
131
216
  ...(config.nativeBridge ? ["--next2d-native-bridge"] : []),
132
- ...(overlay ? ["--next2d-overlay"] : [])
217
+ ...(overlay ? ["--next2d-overlay"] : []),
218
+ ...(mini ? ["--next2d-window-mode"] : [])
133
219
  ];
134
220
  mainWindow = new BrowserWindow({
135
221
  width: Math.min(config.window.width, width),
@@ -197,7 +283,8 @@ const createWindow = async () => {
197
283
  }
198
284
  });
199
285
  mainWindow.webContents.on("before-input-event", (event, input) => {
200
- if (input.type !== "keyDown" || overlay) {
286
+ // The small window never goes fullscreen; Escape belongs to the game there.
287
+ if (input.type !== "keyDown" || overlay || windowMode === "mini") {
201
288
  return;
202
289
  }
203
290
  if (input.key === "F11" || (input.alt && input.key === "Enter")) {
@@ -259,24 +346,42 @@ app.whenReady().then(async () => {
259
346
  return nativeBridge.request(method, params);
260
347
  });
261
348
  }
349
+ const trusted = (event) => {
350
+ if (!isTrustedSender(event, mainWindow)) {
351
+ diag(`window API access denied (frame url: ${event?.senderFrame?.url})`);
352
+ throw new Error("Window API access denied");
353
+ }
354
+ };
355
+ if (overlay || mini) {
356
+ ipcMain.handle("next2d:window:get-bounds", (event) => {
357
+ trusted(event);
358
+ const bounds = mainWindow.getBounds();
359
+ return { ...bounds, scaleFactor: screen.getDisplayMatching(bounds).scaleFactor };
360
+ });
361
+ ipcMain.handle("next2d:window:quit", (event) => {
362
+ trusted(event);
363
+ // Reply before the window is torn down.
364
+ setImmediate(() => app.quit());
365
+ });
366
+ }
367
+ if (mini) {
368
+ ipcMain.handle("next2d:window:get-mode", (event) => {
369
+ trusted(event);
370
+ return modeInfo();
371
+ });
372
+ ipcMain.handle("next2d:window:set-mode", (event, mode) => {
373
+ trusted(event);
374
+ if (mode !== "full" && mode !== "mini") throw new TypeError("mode must be \"full\" or \"mini\"");
375
+ return setWindowMode(mode);
376
+ });
377
+ }
262
378
  if (overlay) {
263
- const trusted = (event) => {
264
- if (!isTrustedSender(event, mainWindow)) {
265
- diag(`window API access denied (frame url: ${event?.senderFrame?.url})`);
266
- throw new Error("Window API access denied");
267
- }
268
- };
269
379
  ipcMain.handle("next2d:window:set-ignore-mouse-events", (event, ignore) => {
270
380
  trusted(event);
271
381
  if (typeof ignore !== "boolean") throw new TypeError("ignore must be a boolean");
272
382
  overlayIgnore = ignore;
273
383
  mainWindow.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined);
274
384
  });
275
- ipcMain.handle("next2d:window:get-bounds", (event) => {
276
- trusted(event);
277
- const bounds = mainWindow.getBounds();
278
- return { ...bounds, scaleFactor: screen.getDisplayMatching(bounds).scaleFactor };
279
- });
280
385
  // After sleep or screen lock the OS can drop the mouse-forwarding hook and reset the window's
281
386
  // z-order/bounds. Apply the overlay state again and tell the page so it can recover too.
282
387
  const recoverOverlay = () => {
@@ -300,11 +405,6 @@ app.whenReady().then(async () => {
300
405
  });
301
406
  }
302
407
  app.on("child-process-gone", (_event, details) => diag(`child-process-gone ${details.type} ${details.reason}`));
303
- ipcMain.handle("next2d:window:quit", (event) => {
304
- trusted(event);
305
- // Reply before the window is torn down.
306
- setImmediate(() => app.quit());
307
- });
308
408
  }
309
409
  await createWindow();
310
410
  }).catch((error) => {
@@ -31,3 +31,24 @@ if (flags.has("--next2d-overlay")) {
31
31
  }
32
32
  });
33
33
  }
34
+
35
+ // window.mini: switch between the normal window ("full") and the small always-on-top window ("mini").
36
+ // Never set together with the overlay (the config is rejected), so the two never expose next2dWindow twice.
37
+ if (flags.has("--next2d-window-mode")) {
38
+ contextBridge.exposeInMainWorld("next2dWindow", {
39
+ getMode: () => ipcRenderer.invoke("next2d:window:get-mode"),
40
+ setMode: async (mode) => {
41
+ if (mode !== "full" && mode !== "mini") throw new TypeError("Expected \"full\" or \"mini\"");
42
+ return ipcRenderer.invoke("next2d:window:set-mode", mode);
43
+ },
44
+ getBounds: () => ipcRenderer.invoke("next2d:window:get-bounds"),
45
+ quit: async () => { await ipcRenderer.invoke("next2d:window:quit"); },
46
+ // Fires after every change with { mode, width, height } (content size in CSS pixels).
47
+ onModeChange: (listener) => {
48
+ if (typeof listener !== "function") throw new TypeError("Expected mode listener");
49
+ const handler = (_event, info) => listener(info);
50
+ ipcRenderer.on("next2d:window:mode", handler);
51
+ return () => ipcRenderer.removeListener("next2d:window:mode", handler);
52
+ }
53
+ });
54
+ }