diff --git a/.vscode/settings.json b/.vscode/settings.json index 985ff4196..1c231ae35 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,8 @@ "lerp", "minver", "mult", + "ohhi", + "unuse", "verts" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 38435dc73..f737bf38e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog + + All notable changes to this project will be documented in this file. The format is (mostly) based on @@ -40,6 +42,13 @@ So your change should look like: ### Added +- The `onHide` and `onShow` global handlers (which have been deprecated for a + while) are now actually removed, you must use `onTabHide` and `onTabShow`. + `onHide` and `onShow` still exist but now handle reacting to changes in the + `.hidden` property of game objects (#1041) - @dragoncoder047 +- Added `onPause()`, `onUnpause()`, `onHide()`, and `onShow()` events for + listening to the state of the `paused` and `hidden` properties on game objects + (#1041) - @dragoncoder047 - Made random generator algorithm configurable using `setRNG()` (#1057) - @mflerackers - Added xorshift32 as random generator (#1057) - @mflerackers diff --git a/src/api/eventHandlers.ts b/src/api/eventHandlers.ts index 461aec14f..19ebc6f90 100644 --- a/src/api/eventHandlers.ts +++ b/src/api/eventHandlers.ts @@ -211,6 +211,30 @@ export function onHoverUpdate(tag: Tag, action: (obj: GameObj) => void) { return on("hoverUpdate", tag, action); } +export const onHide = overload2((cb: (x: GameObj) => void) => { + return _k.game.events.on("hide", cb); +}, (tag: Tag, cb: (x: GameObj) => void) => { + return on("hide", tag, cb); +}); + +export const onShow = overload2((cb: (x: GameObj) => void) => { + return _k.game.events.on("show", cb); +}, (tag: Tag, cb: (x: GameObj) => void) => { + return on("show", tag, cb); +}); + +export const onPause = overload2((cb: (obj: GameObj) => void) => { + return _k.game.events.on("pause", cb); +}, (tag: Tag, cb: (obj: GameObj) => void) => { + return on("pause", tag, cb); +}); + +export const onUnpause = overload2((cb: (obj: GameObj) => void) => { + return _k.game.events.on("unpause", cb); +}, (tag: Tag, cb: (obj: GameObj) => void) => { + return on("unpause", tag, cb); +}); + // #region just ev handlers export function onError(action: (err: Error) => void) { return _k.game.events.on("error", action); diff --git a/src/app/app.ts b/src/app/app.ts index 2862bf51b..74b5c134b 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -672,11 +672,11 @@ export const initApp = ( } function onTabHide(action: () => void): KEventController { - return state.events.on("show", action); + return state.events.on("tabHide", action); } function onTabShow(action: () => void): KEventController { - return state.events.on("show", action); + return state.events.on("tabShow", action); } const onGamepadButtonPress = overload2( @@ -1245,11 +1245,11 @@ export const initApp = ( // prevent a surge of dt when switch back after the tab being hidden for a while state.skipTime = true; state.isHidden = false; - state.events.trigger("show"); + state.events.trigger("tabShow"); } else { state.isHidden = true; - state.events.trigger("hide"); + state.events.trigger("tabHide"); } }; diff --git a/src/core/context.ts b/src/core/context.ts index cd3b997e4..dbd50fba5 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -460,8 +460,6 @@ export const createContext = ( onTouchMove: defaultScope.onTouchMove, onTouchEnd: defaultScope.onTouchEnd, onScroll: defaultScope.onScroll, - onHide: defaultScope.onHide, - onShow: defaultScope.onShow, onTabShow: defaultScope.onTabShow, onTabHide: defaultScope.onTabHide, onGamepadButtonDown: defaultScope.onGamepadButtonDown, @@ -496,6 +494,10 @@ export const createContext = ( releaseButton: app.releaseButton, getLastInputDeviceType: app.getLastInputDeviceType, charInputted: app.charInputted, + onHide: defaultScope.onHide, + onShow: defaultScope.onShow, + onPause: defaultScope.onPause, + onUnpause: defaultScope.onUnpause, // timer loop, wait, diff --git a/src/core/contextType.ts b/src/core/contextType.ts index 2cb73735c..a8e6468aa 100644 --- a/src/core/contextType.ts +++ b/src/core/contextType.ts @@ -2801,6 +2801,58 @@ export interface KAPLAYCtx { * @group Events */ onTabShow(action: () => void): KEventController; + /** + * Register an event that runs when an object with the provided tag becomes visible, either directly or when a parent is un-hidden. + * + * (Note that "visible" just means "the draw handlers will run", it doesn't tell you anything about whether pixels will be drawn visibly on screen.) + * + * @param tag - The tag to listen for. + * @param action - The function that runs when an object is un-hidden. + * + * @example + * ```js + * onShow("player", () => { + * debug.log("hi player"); + * }); + * + * const player = add([ + * pos(), + * "player" + * ]); + * player.hidden = false; + * player.hidden = true; // Logs "hi player" + * ``` + * + * @returns The event controller. + * @since v2000.0 + * @group Events + */ + onShow(tag: Tag, action: (obj: GameObj) => void): KEventController; + /** + * Register an event that runs when an object becomes visible, either directly or when a parent is un-hidden. + * + * (Note that "visible" just means "the draw handlers will run", it doesn't tell you anything about whether pixels will be drawn visibly on screen.) + * + * @param action - The function that runs when an object is un-hidden. + * + * @example + * ```js + * onShow(() => { + * debug.log("ohhi"); + * }); + * + * const obj = add([ + * pos(), + * ]); + * obj.hidden = false; + * obj.hidden = true; // Logs "ohhi" + * ``` + * + * @returns The event controller. + * @since v2000.0 + * @group Events + */ + onShow(action: (obj: GameObj) => void): KEventController; /** * Register an event that runs when tab is hidden. * @@ -2832,57 +2884,159 @@ export interface KAPLAYCtx { */ onTabHide(action: () => void): KEventController; /** - * @deprecated use `onTabHide` instead + * Register an event that runs when an object with the provided tag becomes invisible, either directly or when a parent is hidden. * - * Register an event that runs when tab is hidden. + * (Note that "invisible" just means "the draw handlers will not run", it doesn't tell you anything about whether pixels were being drawn visibly on screen.) * - * @param action - The function that is run what the tab is hidden. + * @param tag - The tag to listen for. + * @param action - The function that runs when an object is hidden. * * @example * ```js - * // spooky ghost - * let ghosty = add([ - * pos(center()), - * sprite("ghosty"), - * anchor("center"), + * onHide("player", () => { + * debug.log("bye player"); + * }); + * + * const player = add([ + * pos(), + * "player" * ]); + * player.hidden = true; // Logs "bye player" + * ``` * - * // when switching tabs, this runs + * @returns The event controller. + * @since v2000.0 + * @group Events + */ + onHide(tag: Tag, action: (obj: GameObj) => void): KEventController; + /** + * Register an event that runs when an object becomes invisible, either directly or when a parent is hidden. + * + * (Note that "invisible" just means "the draw handlers will not run", it doesn't tell you anything about whether pixels were being drawn visibly on screen.) + * + * @param action - The function that runs when an object is hidden. + * + * @example + * ```js * onHide(() => { - * destroy(ghosty); - * add([ - * text("There was never aa ghosttttt"), - * pos(center()), - * anchor("center"), - * ]); + * debug.log("bye"); + * }); + * + * const obj = add([ + * pos(), + * ]); + * obj.hidden = true; // Logs "bye" + * ``` + * + * @returns The event controller. + * @since v2000.0 + * @group Events + */ + onHide(action: (obj: GameObj) => void): KEventController; + /** + * Register an event that runs when an object with the provided tag becomes paused, either directly or when a parent is paused. + * + * ("Paused" simply means "the update and fixedUpdate handlers will not run.") + * + * @param tag - The tag to listen for. + * @param action - The function that runs when an object is paused. + * + * @example + * ```js + * onPause("player", () => { + * debug.log("player is frozen"); * }); + * + * const player = add([ + * pos(), + * "player" + * ]); + * player.paused = true; // Logs "player is frozen" * ``` * * @returns The event controller. - * @since v3001.0 + * @since v2000.0 * @group Events */ - onHide(action: () => void): KEventController; + onPause(tag: Tag, action: (obj: GameObj) => void): KEventController; /** - * @deprecated use `onTabShow` instead + * Register an event that runs when an object becomes paused, either directly or when a parent is paused. * - * Register an event that runs when tab is shown. + * ("Paused" simply means "the update and fixedUpdate handlers will not run.") * - * @param action - The function that is run when the tab is shown. + * @param action - The function that runs when an object is paused. * * @example * ```js - * // user has returned to this tab - * onShow(() => { - * burp(); + * onPause(() => { + * debug.log("frozen"); * }); + * + * const obj = add([ + * pos(), + * ]); + * obj.paused = true; // Logs "frozen" * ``` * * @returns The event controller. - * @since v3001.0 + * @since v2000.0 + * @group Events + */ + onPause(action: (obj: GameObj) => void): KEventController; + /** + * Register an event that runs when an object with the provided tag becomes unpaused, either directly or when a parent is unpaused. + * + * ("Unpaused" simply means "the update and fixedUpdate handlers will run.") + * + * @param tag - The tag to listen for. + * @param action - The function that runs when an object is unpaused. + * + * @example + * ```js + * onUnpause("player", () => { + * debug.log("player is melted"); + * }); + * + * const player = add([ + * pos(), + * "player" + * ]); + * player.paused = false; // Logs nothing, player is already unpaused + * player.paused = true; + * player.paused = false; // Logs "player is melted" + * ``` + * + * @returns The event controller. + * @since v2000.0 + * @group Events + */ + onUnpause(tag: Tag, action: (obj: GameObj) => void): KEventController; + /** + * Register an event that runs when an object becomes unpaused, either directly or when a parent is unpaused. + * + * ("Unpaused" simply means "the update and fixedUpdate handlers will run.") + * + * @param action - The function that runs when an object is unpaused. + * + * @example + * ```js + * onUnpause(() => { + * debug.log("unfrozen"); + * }); + * + * const obj = add([ + * pos(), + * ]); + * obj.paused = false; // Logs nothing, obj is already unpaused + * obj.paused = true; + * obj.paused = false; // Logs "frozen" + * ``` + * + * @returns The event controller. + * @since v2000.0 * @group Events */ - onShow(action: () => void): KEventController; + onUnpause(action: (obj: GameObj) => void): KEventController; /** * Register an event that runs at a fixed framerate. * diff --git a/src/ecs/entity/GameObjRaw.ts b/src/ecs/entity/GameObjRaw.ts index d131ca17a..bb5ce3e48 100644 --- a/src/ecs/entity/GameObjRaw.ts +++ b/src/ecs/entity/GameObjRaw.ts @@ -49,7 +49,7 @@ import type { SkewComp } from "../components/transform/skew"; import type { ZComp } from "../components/transform/z"; import { internalIsMaking, make } from "./make"; import { deserializePrefabAsset, type SerializedGameObj } from "./prefab"; -import { isFixed } from "./utils"; +import { isFixed, isHidden, isPaused } from "./utils"; export enum KeepFlags { Pos = 1, @@ -482,6 +482,38 @@ export interface GameObjRaw { * @since v4000.0 */ onUntag(action: (tag: string) => void): KEventController; + /** + * Register an event that runs when the object becomes paused (either directly + * or by a parent being set to paused). + * + * @returns The event controller. + * @since v4000.0 + */ + onPause(action: () => void): KEventController; + /** + * Register an event that runs when the object becomes unpaused (either directly + * or by a parent being set to unpaused). + * + * @returns The event controller. + * @since v4000.0 + */ + onUnpause(action: () => void): KEventController; + /** + * Register an event that runs when the object becomes hidden (either directly + * or by a parent being set to hidden). + * + * @returns The event controller. + * @since v4000.0 + */ + onHide(action: () => void): KEventController; + /** + * Register an event that runs when the object becomes visible (either directly + * or by a parent being set to visible). + * + * @returns The event controller. + * @since v4000.0 + */ + onShow(action: () => void): KEventController; onKeyDown: KAPLAYCtx["onKeyDown"]; onKeyPress: KAPLAYCtx["onKeyPress"]; onKeyPressRepeat: KAPLAYCtx["onKeyPressRepeat"]; @@ -504,8 +536,6 @@ export interface GameObjRaw { onButtonRelease: KAPLAYCtx["onButtonRelease"]; onTabShow: KAPLAYCtx["onTabShow"]; onTabHide: KAPLAYCtx["onTabHide"]; - onShow: KAPLAYCtx["onShow"]; - onHide: KAPLAYCtx["onHide"]; } export type InternalGameObjRaw = GameObjRaw & { @@ -539,6 +569,10 @@ export type InternalGameObjRaw = GameObjRaw & { _treeIndex: number; /** @readonly */ _transformVersion: number; + /** @readonly */ + _paused: boolean; + /** @readonly */ + _hidden: boolean; /** * Adds a component or anonymous component. @@ -562,6 +596,22 @@ export type InternalGameObjRaw = GameObjRaw & { * @param compId - Component ID for searching. */ _checkDependents(compId: string): void; + /** + * Check if the game obj changed the effective paused state + * and trigger the onPause / onUnpause events recursively if needed + * + * @param oldPaused - previous effective paused state + * @param newPaused - current effective paused state + */ + _updatePausedState(oldPaused: boolean, newPaused: boolean): void; + /** + * Check if the game obj changed the effective hidden state + * and trigger the onHide / onShow events recursively if needed + * + * @param oldHidden - previous effective hidden state + * @param newHidden - current effective hidden state + */ + _updateHiddenState(oldHidden: boolean, newHidden: boolean): void; }; type GameObjTransform = @@ -611,8 +661,9 @@ export const GameObjRawPrototype: Omit< _drawEvents: null as any, _drawLayerIndex: null as any, _treeIndex: null as any, + _hidden: null as any, + _paused: null as any, children: null as any, - hidden: null as any, id: null as any, transform: null as any, _transformVersion: null as any, @@ -628,6 +679,11 @@ export const GameObjRawPrototype: Omit< } if (this._parent === p) return; + + // save the old paused and hidden states + const oldPaused = isPaused(this as any); + const oldHidden = isHidden(this as any); + const index = this._parent ? this._parent.children.indexOf(this as unknown as GameObj) : -1; @@ -639,9 +695,64 @@ export const GameObjRawPrototype: Omit< p.children.push(this as unknown as GameObj); this._transformVersion = nextTransformVersion(); } + + const newPaused = isPaused(this as any); + const newHidden = isHidden(this as any); + + this._updatePausedState(oldPaused, newPaused); + this._updateHiddenState(oldHidden, newHidden); + }, + + set paused(paused: boolean) { + if (this._paused === paused) return; + const oldPaused = isPaused(this as any); + this._paused = paused; + const newPaused = isPaused(this as any); + this._updatePausedState(oldPaused, newPaused); }, - paused: false, + get paused() { + return this._paused; + }, + + _updatePausedState(oldPaused, newPaused) { + if (oldPaused === newPaused) return; + const eventName = newPaused ? "pause" : "unpause"; + const recurse = (obj: GameObj, first: boolean) => { + obj.trigger(eventName); + _k.game.events.trigger(eventName, obj); + for (const e of obj._inputEvents) { + e.paused = newPaused; + } + if (obj.paused && !first) return; + obj.children.forEach(c => recurse(c, false)); + }; + recurse(this as any, true); + }, + + set hidden(hidden: boolean) { + if (this._hidden === hidden) return; + const oldHidden = isHidden(this as any); + this._hidden = hidden; + const newHidden = isHidden(this as any); + this._updateHiddenState(oldHidden, newHidden); + }, + + get hidden() { + return this._hidden; + }, + + _updateHiddenState(oldHidden, newHidden) { + if (oldHidden === newHidden) return; + const eventName = newHidden ? "hide" : "show"; + const recurse = (obj: GameObj, first: boolean) => { + obj.trigger(eventName); + _k.game.events.trigger(eventName, obj); + if (obj.hidden && !first) return; + obj.children.forEach(c => recurse(c, false)); + }; + recurse(this as any, true); + }, get parent() { return this._parent; @@ -1621,5 +1732,21 @@ export const GameObjRawPrototype: Omit< onUnuse(action: (id: string) => void): KEventController { return this.on("unuse", action); }, + + onPause(action: () => void): KEventController { + return this.on("pause", action); + }, + + onUnpause(action: () => void): KEventController { + return this.on("unpause", action); + }, + + onShow(action: () => void): KEventController { + return this.on("show", action); + }, + + onHide(action: () => void): KEventController { + return this.on("hide", action); + }, // #endregion }; diff --git a/src/ecs/entity/make.ts b/src/ecs/entity/make.ts index cd2dba690..e85e6f1f9 100644 --- a/src/ecs/entity/make.ts +++ b/src/ecs/entity/make.ts @@ -44,8 +44,8 @@ export function makeInternal>( obj._fixedUpdateEvents = new KEvent<[]>(); obj._drawEvents = new KEvent<[]>(); obj._inputEvents = []; - obj.paused = false; - obj.hidden = false; + obj._paused = false; + obj._hidden = false; obj.id = id; obj.transform = new Mat23(); diff --git a/src/ecs/entity/utils.ts b/src/ecs/entity/utils.ts index 64f78da08..2961570c8 100644 --- a/src/ecs/entity/utils.ts +++ b/src/ecs/entity/utils.ts @@ -13,12 +13,11 @@ export function getTreeRoot(): GameObj { return _k.game.root; } -export function isFixed(obj: GameObj): boolean { - if (obj.fixed) return true; - return obj.parent ? isFixed(obj.parent) : false; -} +const is = ( + obj: GameObj | null, + field: "paused" | "fixed" | "hidden", +): boolean => obj ? (obj[field] ? true : is(obj.parent, field)) : false; -export function isPaused(obj: GameObj): boolean { - if (obj.paused) return true; - return obj.parent ? isPaused(obj.parent) : false; -} +export const isFixed = (obj: GameObj): boolean => is(obj, "fixed"); +export const isPaused = (obj: GameObj): boolean => is(obj, "paused"); +export const isHidden = (obj: GameObj): boolean => is(obj, "hidden"); diff --git a/src/ecs/systems/createCollisionSystem.ts b/src/ecs/systems/createCollisionSystem.ts index acecef1c5..90ec79547 100644 --- a/src/ecs/systems/createCollisionSystem.ts +++ b/src/ecs/systems/createCollisionSystem.ts @@ -14,6 +14,7 @@ import { import { _k } from "../../shared"; import type { GameObj } from "../../types"; import { type AreaComp, usesArea } from "../components/physics/area"; +import { isPaused } from "../entity/utils"; import { Collision } from "./Collision"; export type BroadPhaseType = "sap" | "sapv" | "quadtree" | "grid"; export type NarrowPhaseType = "gjk" | "sat" | "box"; @@ -103,9 +104,19 @@ export const createCollisionSystem = ( broadPhaseIntersection.remove(obj as GameObj); } }); + _k.appScope.onPause(obj => { + if (obj.has("area")) { + broadPhaseIntersection.remove(obj as GameObj); + } + }); + _k.appScope.onUnpause(obj => { + if (obj.has("area")) { + broadPhaseIntersection.add(obj as GameObj); + } + }); for (const obj of _k.game.root.get("*", { recursive: true })) { - if (obj.has("area")) { + if (obj.has("area") && !isPaused(obj)) { broadPhaseIntersection.add(obj as GameObj); } } diff --git a/src/events/eventMap.ts b/src/events/eventMap.ts index f6a382da1..815cc053a 100644 --- a/src/events/eventMap.ts +++ b/src/events/eventMap.ts @@ -273,8 +273,8 @@ export type AppEventMap = { buttonPress: [string]; buttonRelease: [string]; scroll: [Vec2]; - hide: []; - show: []; + tabHide: []; + tabShow: []; resize: []; input: []; update: []; @@ -295,4 +295,8 @@ export type GameEventMap = { error: [Error]; sceneLeave: [string]; sceneEnter: [string]; + pause: [GameObj]; + unpause: [GameObj]; + hide: [GameObj]; + show: [GameObj]; }; diff --git a/src/events/scopeHandlers.ts b/src/events/scopeHandlers.ts index 9aa8d3e05..fb1b5229b 100644 --- a/src/events/scopeHandlers.ts +++ b/src/events/scopeHandlers.ts @@ -9,14 +9,18 @@ import { onDraw, onError, onFixedUpdate, + onHide, onHover, onHoverEnd, onHoverUpdate, onLoad, onLoadError, onLoading, + onPause, onSceneLeave, + onShow, onTag, + onUnpause, onUntag, onUnuse, onUpdate, @@ -77,9 +81,10 @@ export const createScopeHandlers = (app: App) => { onError: onError, onLoadError: onLoadError, onCleanup: onCleanup, - // deprecated - onShow: app.onShow, - onHide: app.onHide, + onShow: onShow, + onHide: onHide, + onPause: onPause, + onUnpause: onUnpause, }; return handlers; diff --git a/src/events/scopes.ts b/src/events/scopes.ts index 641ab87f4..6a3790259 100644 --- a/src/events/scopes.ts +++ b/src/events/scopes.ts @@ -63,6 +63,10 @@ const ingnoredHandlersForObject = [ "onHover", "onHoverEnd", "onHoverUpdate", + "onPause", + "onUnpause", + "onHide", + "onShow", "on", ] as const; diff --git a/tests/playtests/recursivepause.js b/tests/playtests/recursivepause.js new file mode 100644 index 000000000..979b29099 --- /dev/null +++ b/tests/playtests/recursivepause.js @@ -0,0 +1,28 @@ +/** + * @file Recursive Paused + * @description The pause and unpause events are recursive + * @difficulty 3 + * @tags debug + * @minver 4000.0 + */ + +kaplay({ background: "black" }); + +const t = add([text("testing..."), pos(100, 100)]); + +const a = add([]); +const b = a.add([]); +const c = b.add(["final"]); +onPause("final", () => { + t.text += "\nPASSED!"; +}); +c.onUnpause(() => { + t.text += "\nFAILED!"; +}); + +// This causes a, b, c to fire onPause +a.paused = true; +// the tree is already paused, so this won't change anything +b.paused = true; +// this will unpause only the parent a because b is already paused +a.paused = false;