@combos-fun/engine 0.0.24 → 0.0.27

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.
package/agent-skill.md CHANGED
@@ -17,11 +17,11 @@ Read this every time before working on a Combos Fun project. The entry skill loa
17
17
 
18
18
  ### Values
19
19
 
20
- `Game`, `Scene`, `GameObject`, `Component`, `System`, `Transform`, `resource`, `resourceLoader`, `decorators`, `IDEProp`, `componentObserver`, `LOAD_EVENT`, `RESOURCE_TYPE`, `OBSERVER_TYPE`, `LOAD_SCENE_MODE`, `RESOURCE_TYPE_STRATEGY`, `version`, `COMBOS_GAME_PLUGIN_INIT_SUCCESS`, `postParentPluginInitSuccess`.
20
+ `Game`, `Scene`, `GameObject`, `Component`, `System`, `Transform`, `resource`, `resourceLoader`, `decorators`, `IDEProp`, `componentObserver`, `LOAD_EVENT`, `RESOURCE_TYPE`, `OBSERVER_TYPE`, `LOAD_SCENE_MODE`, `RESOURCE_TYPE_STRATEGY`, `version`, `COMBOS_GAME_PLUGIN_INIT_SUCCESS`, `COMBOS_GAME_READY`, `COMBOS_GAME_SET_PLAYING`, `COMBOS_GAME_STATE_CHANGED`, `postParentPluginInitSuccess`, `postParentGameReady`, `postParentGameState`, `parseSetPlayingMessage`, `DEFAULT_ALLOWED_MESSAGE_HOST_SUFFIXES`, `isAllowedMessageOrigin`, `mergeAllowedMessageOrigins`.
21
21
 
22
22
  ### Types
23
23
 
24
- `GameParams`, `PluginStruct`, `TransformParams`, `ComponentChanged`, `UpdateParams`, `ComponentParams`, `ObserverInfo`, `PureObserverInfo`, `ResourceBase`, `SystemConstructor`, `CombosGamePluginInitSuccessMessage`.
24
+ `GameParams`, `PluginStruct`, `TransformParams`, `ComponentChanged`, `UpdateParams`, `ComponentParams`, `ObserverInfo`, `PureObserverInfo`, `ResourceBase`, `SystemConstructor`, `CombosGamePluginInitSuccessMessage`, `CombosGameReadyMessage`, `CombosGameStateChangedMessage`, `CombosGameSetPlayingMessage`.
25
25
 
26
26
  ### `GameParams`
27
27
 
@@ -33,7 +33,8 @@ Read this every time before working on a Combos Fun project. The entry skill loa
33
33
  | `autoStart` | `boolean` | `true` | |
34
34
  | `needScene` | `boolean` | `true` | Auto-creates `Scene('scene')` |
35
35
  | `onSystemsBootstrapComplete` | `(game, error?) => void` | — | After all systems init |
36
- | `pluginInitNotifyTargetOrigin` | `string` | `'*'` | postMessage target |
36
+ | `pluginInitNotifyTargetOrigin` | `string` | `'*'` | Outbound postMessage target (init / ready / state) |
37
+ | `allowedMessageOrigins` | `string[]` | defaults | Inbound origins allowed to send `set-playing` (merged with defaults; `['*']` = any) |
37
38
 
38
39
 
39
40
  When the game runs inside an iframe (`window.parent !== window`), each `Game.addSystem` call posts to the parent after that system's `init` completes:
@@ -50,10 +51,31 @@ When the game runs inside an iframe (`window.parent !== window`), each `Game.add
50
51
 
51
52
  Official `@combos-fun/plugin-*` packages get `packageName` / `packageVersion` automatically via `scripts/build-package.mjs` (no hand-written static fields). Host pages can gate tooling on specific systems or versions using this payload. Types: `CombosGamePluginInitSuccessMessage`, helper `postParentPluginInitSuccess` in `bootstrapMessages.ts`.
52
53
 
54
+ ### Host lifecycle protocol (preload → hold → play)
55
+
56
+ Core `Game` speaks a `postMessage` protocol with the embedding page so a host APP can preload, hold, then start the game on user intent. All outbound messages go to `pluginInitNotifyTargetOrigin`; the inbound command is origin-checked against `allowedMessageOrigins`.
57
+
58
+ **Outbound (iframe → parent):**
59
+
60
+ | Type | When | Payload |
61
+ | ---- | ---- | ------- |
62
+ | `combos-game:ready` | Bootstrap finished (systems `init`/`awake`, optional scene load & start). Sent once even with `autoStart:false`. | `{ engineVersion, error? }` |
63
+ | `combos-game:state-changed` | After `start` / `pause` / `resume` | `{ playing, started }` |
64
+
65
+ **Inbound (parent → iframe):**
66
+
67
+ | Type | Effect | Payload |
68
+ | ---- | ------ | ------- |
69
+ | `combos-game:set-playing` | `true` → cold `start()` on first play, else `resume()`; `false` → `pause()` | `{ playing: boolean }` |
70
+
71
+ Recommended flow: create the game with `autoStart:false`, wait for `combos-game:ready`, keep the game held at frame 0 (host shows a cover/loading overlay), then post `{ type: 'combos-game:set-playing', playing: true }` when the user taps play. `game.setPlaying(playing)` is the programmatic equivalent. Helpers: `postParentGameReady`, `postParentGameState`, `parseSetPlayingMessage`.
72
+
73
+ > Note: play/pause is owned by the engine core here, **not** by `plugin-development-tool` (which now only handles pick mode + mute).
74
+
53
75
 
54
76
  ### `Game` methods
55
77
 
56
- `addSystem(system)`, `removeSystem(system | class | string)` (calls `system.destroy()` internally), `getSystem(class | string)`, `loadScene({ scene, mode?, params? })` (`LOAD_SCENE_MODE.SINGLE | MULTI_CANVAS`), `start()`, `pause()`, `resume()`, `destroy()`.
78
+ `addSystem(system)`, `removeSystem(system | class | string)` (calls `system.destroy()` internally), `getSystem(class | string)`, `loadScene({ scene, mode?, params? })` (`LOAD_SCENE_MODE.SINGLE | MULTI_CANVAS`), `start()`, `pause()`, `resume()`, `setPlaying(playing)` (host bridge: cold-start-or-resume / pause), `destroy()`.
57
79
 
58
80
  ### `resource` singleton
59
81
 
@@ -964,7 +964,7 @@ class Scene extends GameObject {
964
964
  }
965
965
 
966
966
  /** Generated at build from package.json */
967
- const version = "0.0.23";
967
+ const version = "0.0.27";
968
968
 
969
969
  /**
970
970
  * Sent to `window.parent` after each `System.init` completes during `Game.addSystem`
@@ -972,13 +972,36 @@ const version = "0.0.23";
972
972
  */
973
973
  const COMBOS_GAME_PLUGIN_INIT_SUCCESS = "combos-game:plugin-init-success";
974
974
  /**
975
- * Notifies the embedding page that a system (plugin) finished async/sync `init`.
975
+ * iframe parent: game bootstrap finished (all systems `init`/`awake`, optional
976
+ * scene load and `start`). Sent once, even when `autoStart` is `false`, so the host
977
+ * knows assets + scene graph are ready and it may pause / hold before play.
976
978
  */
977
- function postParentPluginInitSuccess(input, targetOrigin = "*") {
979
+ const COMBOS_GAME_READY = "combos-game:ready";
980
+ /**
981
+ * parent → iframe: start / pause the game loop. Payload: `{ playing: boolean }`.
982
+ * `true` cold-starts (`Game.start`) on first play, otherwise resumes; `false` pauses.
983
+ */
984
+ const COMBOS_GAME_SET_PLAYING = "combos-game:set-playing";
985
+ /**
986
+ * iframe → parent: current run state after a `start` / `pause` / `resume`.
987
+ */
988
+ const COMBOS_GAME_STATE_CHANGED = "combos-game:state-changed";
989
+ function postToParent(payload, targetOrigin) {
978
990
  if (typeof window === "undefined")
979
991
  return;
980
992
  if (!window.parent || window.parent === window)
981
993
  return;
994
+ try {
995
+ window.parent.postMessage(payload, targetOrigin);
996
+ }
997
+ catch {
998
+ /* ignore cross-origin or detached frame */
999
+ }
1000
+ }
1001
+ /**
1002
+ * Notifies the embedding page that a system (plugin) finished async/sync `init`.
1003
+ */
1004
+ function postParentPluginInitSuccess(input, targetOrigin = "*") {
982
1005
  const payload = {
983
1006
  type: COMBOS_GAME_PLUGIN_INIT_SUCCESS,
984
1007
  systemName: input.systemName,
@@ -986,12 +1009,109 @@ function postParentPluginInitSuccess(input, targetOrigin = "*") {
986
1009
  ...(input.packageName ? { packageName: input.packageName } : {}),
987
1010
  ...(input.packageVersion ? { packageVersion: input.packageVersion } : {}),
988
1011
  };
1012
+ postToParent(payload, targetOrigin);
1013
+ }
1014
+ /** Notifies the embedding page that game bootstrap finished. */
1015
+ function postParentGameReady(error, targetOrigin = "*") {
1016
+ const payload = {
1017
+ type: COMBOS_GAME_READY,
1018
+ engineVersion: version,
1019
+ ...(error === undefined
1020
+ ? {}
1021
+ : { error: error instanceof Error ? error.message : String(error) }),
1022
+ };
1023
+ postToParent(payload, targetOrigin);
1024
+ }
1025
+ /** Notifies the embedding page of the current play/pause state. */
1026
+ function postParentGameState(state, targetOrigin = "*") {
1027
+ const payload = {
1028
+ type: COMBOS_GAME_STATE_CHANGED,
1029
+ playing: state.playing,
1030
+ started: state.started,
1031
+ };
1032
+ postToParent(payload, targetOrigin);
1033
+ }
1034
+ /**
1035
+ * Reads a `combos-game:set-playing` command from an inbound `postMessage` payload.
1036
+ * Returns the requested `playing` value, or `null` when the message is unrelated / malformed.
1037
+ */
1038
+ function parseSetPlayingMessage(data) {
1039
+ if (!data || typeof data !== "object")
1040
+ return null;
1041
+ const d = data;
1042
+ if (d.type !== COMBOS_GAME_SET_PLAYING)
1043
+ return null;
1044
+ if (typeof d.playing !== "boolean")
1045
+ return null;
1046
+ return d.playing;
1047
+ }
1048
+
1049
+ /** Default inbound postMessage host suffixes (host + all subdomains). */
1050
+ const DEFAULT_ALLOWED_MESSAGE_HOST_SUFFIXES = [
1051
+ "knoffice.tech",
1052
+ "converge.ai",
1053
+ ];
1054
+ /**
1055
+ * Returns whether `origin` is allowed by `allowed`.
1056
+ *
1057
+ * Each entry in `allowed` is either:
1058
+ * - a **host suffix** (e.g. `knoffice.tech`) — matches that host and `*.knoffice.tech`;
1059
+ * - a **full origin** (e.g. `https://creator.knoffice.tech`) — exact match;
1060
+ * - `'*'` — accept any origin (escape hatch).
1061
+ */
1062
+ function isAllowedMessageOrigin(origin, allowed) {
1063
+ if (allowed.includes("*")) {
1064
+ return true;
1065
+ }
1066
+ if (allowed.length === 0) {
1067
+ return false;
1068
+ }
1069
+ let parsed;
989
1070
  try {
990
- window.parent.postMessage(payload, targetOrigin);
1071
+ parsed = new URL(origin);
991
1072
  }
992
1073
  catch {
993
- /* ignore cross-origin or detached frame */
1074
+ return false;
1075
+ }
1076
+ const originLower = origin.toLowerCase();
1077
+ const hostLower = parsed.hostname.toLowerCase();
1078
+ for (const entry of allowed) {
1079
+ const trimmed = entry.trim();
1080
+ if (!trimmed) {
1081
+ continue;
1082
+ }
1083
+ if (trimmed.includes("://")) {
1084
+ if (originLower === trimmed.toLowerCase()) {
1085
+ return true;
1086
+ }
1087
+ continue;
1088
+ }
1089
+ const suffix = trimmed.toLowerCase().replace(/^\./, "");
1090
+ if (hostLower === suffix || hostLower.endsWith(`.${suffix}`)) {
1091
+ return true;
1092
+ }
1093
+ }
1094
+ return false;
1095
+ }
1096
+ /** Defaults plus any extra entries (deduped). `['*']` alone accepts any origin. */
1097
+ function mergeAllowedMessageOrigins(extra) {
1098
+ if (!extra?.length) {
1099
+ return [...DEFAULT_ALLOWED_MESSAGE_HOST_SUFFIXES];
1100
+ }
1101
+ if (extra.includes("*")) {
1102
+ return ["*"];
1103
+ }
1104
+ const seen = new Set();
1105
+ const result = [];
1106
+ for (const entry of [...DEFAULT_ALLOWED_MESSAGE_HOST_SUFFIXES, ...extra]) {
1107
+ const trimmed = entry.trim();
1108
+ if (!trimmed || seen.has(trimmed)) {
1109
+ continue;
1110
+ }
1111
+ seen.add(trimmed);
1112
+ result.push(trimmed);
994
1113
  }
1114
+ return result;
995
1115
  }
996
1116
 
997
1117
  function systemClassName(ctor) {
@@ -1097,7 +1217,7 @@ const gameObjectPause = (gameObjects) => {
1097
1217
  }
1098
1218
  };
1099
1219
  class Game extends EventEmitter__default.default {
1100
- constructor({ systems, frameRate = 60, autoStart = true, needScene = true, onSystemsBootstrapComplete, pluginInitNotifyTargetOrigin = "*", } = {}) {
1220
+ constructor({ systems, frameRate = 60, autoStart = true, needScene = true, onSystemsBootstrapComplete, pluginInitNotifyTargetOrigin = "*", allowedMessageOrigins, } = {}) {
1101
1221
  super();
1102
1222
  /**
1103
1223
  * State of game
@@ -1109,20 +1229,61 @@ class Game extends EventEmitter__default.default {
1109
1229
  /** Systems alled to this game */
1110
1230
  this.systems = [];
1111
1231
  /**
1112
- * Passed to `postMessage` when reporting `COMBOS_GAME_PLUGIN_INIT_SUCCESS` to `window.parent`.
1232
+ * Passed to `postMessage` when reporting lifecycle events to `window.parent`
1233
+ * (`plugin-init-success`, `ready`, `state-changed`).
1113
1234
  */
1114
1235
  this.pluginInitNotifyTargetOrigin = "*";
1236
+ /** Inbound `postMessage` origins allowed to send host commands. */
1237
+ this.allowedMessageOrigins = mergeAllowedMessageOrigins();
1238
+ /** Bound inbound `message` handler; retained so it can be removed on `destroy`. */
1239
+ this.onHostMessage = (e) => {
1240
+ if (!isAllowedMessageOrigin(e.origin, this.allowedMessageOrigins))
1241
+ return;
1242
+ const playing = parseSetPlayingMessage(e.data);
1243
+ if (playing === null)
1244
+ return;
1245
+ this.setPlaying(playing);
1246
+ };
1115
1247
  this.pluginInitNotifyTargetOrigin = pluginInitNotifyTargetOrigin;
1248
+ this.allowedMessageOrigins = mergeAllowedMessageOrigins(allowedMessageOrigins);
1116
1249
  this.ticker = new Ticker({ autoStart: false, frameRate });
1117
1250
  this.initTicker();
1251
+ if (typeof window !== "undefined") {
1252
+ window.addEventListener("message", this.onHostMessage);
1253
+ }
1118
1254
  if (systems && systems.length) {
1119
1255
  const systemsToAdd = [...systems];
1120
1256
  void this.bootstrapSystemsAndMaybeStart(systemsToAdd, needScene, autoStart, onSystemsBootstrapComplete);
1121
1257
  }
1122
1258
  else {
1123
1259
  onSystemsBootstrapComplete?.(this);
1260
+ this.notifyReady();
1124
1261
  }
1125
1262
  }
1263
+ /**
1264
+ * Host command bridge: `true` cold-starts on first play (`start`), otherwise
1265
+ * `resume`s; `false` pauses. Mirrors what a parent frame drives via
1266
+ * `postMessage({ type: 'combos-game:set-playing', playing })`.
1267
+ */
1268
+ setPlaying(playing) {
1269
+ if (playing) {
1270
+ if (this.started) {
1271
+ this.resume();
1272
+ }
1273
+ else {
1274
+ this.start();
1275
+ }
1276
+ }
1277
+ else {
1278
+ this.pause();
1279
+ }
1280
+ }
1281
+ notifyReady(error) {
1282
+ postParentGameReady(error, this.pluginInitNotifyTargetOrigin);
1283
+ }
1284
+ notifyStateChanged() {
1285
+ postParentGameState({ playing: this.playing, started: this.started }, this.pluginInitNotifyTargetOrigin);
1286
+ }
1126
1287
  /** When `systems` is passed in the constructor, run async `init` for each before `loadScene` / `start`. */
1127
1288
  async bootstrapSystemsAndMaybeStart(systemsToAdd, needScene, autoStart, onComplete) {
1128
1289
  let error;
@@ -1143,6 +1304,7 @@ class Game extends EventEmitter__default.default {
1143
1304
  }
1144
1305
  finally {
1145
1306
  onComplete?.(this, error);
1307
+ this.notifyReady(error);
1146
1308
  }
1147
1309
  }
1148
1310
  /**
@@ -1245,6 +1407,7 @@ class Game extends EventEmitter__default.default {
1245
1407
  this.playing = false;
1246
1408
  this.ticker.pause();
1247
1409
  this.triggerPause();
1410
+ this.notifyStateChanged();
1248
1411
  }
1249
1412
  /** Start game */
1250
1413
  start() {
@@ -1253,6 +1416,7 @@ class Game extends EventEmitter__default.default {
1253
1416
  this.playing = true;
1254
1417
  this.started = true;
1255
1418
  this.ticker.start();
1419
+ this.notifyStateChanged();
1256
1420
  }
1257
1421
  /** Resume game */
1258
1422
  resume() {
@@ -1261,6 +1425,7 @@ class Game extends EventEmitter__default.default {
1261
1425
  this.playing = true;
1262
1426
  this.ticker.start();
1263
1427
  this.triggerResume();
1428
+ this.notifyStateChanged();
1264
1429
  }
1265
1430
  /**
1266
1431
  * add main render method to ticker
@@ -1327,6 +1492,9 @@ class Game extends EventEmitter__default.default {
1327
1492
  }
1328
1493
  /** Destroy game instance */
1329
1494
  destroy() {
1495
+ if (typeof window !== "undefined") {
1496
+ window.removeEventListener("message", this.onHostMessage);
1497
+ }
1330
1498
  this.removeAllListeners();
1331
1499
  this.pause();
1332
1500
  this.scene.destroy();
@@ -1728,7 +1896,11 @@ const decorators = {
1728
1896
  console.log(`@combos-fun/engine version: ${version}`);
1729
1897
 
1730
1898
  exports.COMBOS_GAME_PLUGIN_INIT_SUCCESS = COMBOS_GAME_PLUGIN_INIT_SUCCESS;
1899
+ exports.COMBOS_GAME_READY = COMBOS_GAME_READY;
1900
+ exports.COMBOS_GAME_SET_PLAYING = COMBOS_GAME_SET_PLAYING;
1901
+ exports.COMBOS_GAME_STATE_CHANGED = COMBOS_GAME_STATE_CHANGED;
1731
1902
  exports.Component = Component;
1903
+ exports.DEFAULT_ALLOWED_MESSAGE_HOST_SUFFIXES = DEFAULT_ALLOWED_MESSAGE_HOST_SUFFIXES;
1732
1904
  exports.Game = Game;
1733
1905
  exports.GameObject = GameObject;
1734
1906
  exports.IDEProp = IDEProp;
@@ -1738,6 +1910,11 @@ exports.System = System;
1738
1910
  exports.Transform = Transform;
1739
1911
  exports.componentObserver = componentObserver;
1740
1912
  exports.decorators = decorators;
1913
+ exports.isAllowedMessageOrigin = isAllowedMessageOrigin;
1914
+ exports.mergeAllowedMessageOrigins = mergeAllowedMessageOrigins;
1915
+ exports.parseSetPlayingMessage = parseSetPlayingMessage;
1916
+ exports.postParentGameReady = postParentGameReady;
1917
+ exports.postParentGameState = postParentGameState;
1741
1918
  exports.postParentPluginInitSuccess = postParentPluginInitSuccess;
1742
1919
  exports.resource = resource;
1743
1920
  exports.resourceLoader = resourceLoader;