diff --git a/changelog.md b/changelog.md index 7a73054..7a2ef7a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,21 @@ # Changelog +## [0.0.4] - 2024/10/01 + +### Fixed + +- `liveGame.newGame` does not reset the game state +- deserializing does not trigger repainting +- Some methods in `Control` are working incorrectly +- Some image components cannot update correctly + +### Changed + +- `scene.backgroundImageState` is deprecated, use `scene.backgroundImage` instead +- Now applying of transformations and transitions are separated, you can now apply both at the same time +- Deprecated `contentNode.initChild` +- `liveGame.newGame`, `liveGame.deserialize` and `liveGame.serialize` now does not require a gameState instance + ## [0.0.3] - 2024/10/01 ### Added @@ -16,6 +32,8 @@ ### Fixed - New game does not reset the game state +- Positions cannot handle number 0 +- Components does not flush after applying transformations ## [0.0.3-beta.1] - 2024-09-30 diff --git a/package.json b/package.json index 7622412..297a343 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "narraleaf-react", - "version": "0.0.3", + "version": "0.0.4", "description": "A React visual novel player framework", "main": "./dist/main.js", "types": "./dist/index.d.ts", diff --git a/src/game/nlcore/action/action.ts b/src/game/nlcore/action/action.ts index 47d7681..8107213 100644 --- a/src/game/nlcore/action/action.ts +++ b/src/game/nlcore/action/action.ts @@ -26,7 +26,7 @@ export class Action { public executeAction(_state: GameState): CalledActionResult | Awaitable { return { type: this.type as any, - node: this.contentNode, + node: this.contentNode.getChild(), }; } diff --git a/src/game/nlcore/action/actionTypes.ts b/src/game/nlcore/action/actionTypes.ts index ef07d97..2c4e429 100644 --- a/src/game/nlcore/action/actionTypes.ts +++ b/src/game/nlcore/action/actionTypes.ts @@ -70,8 +70,12 @@ export const ImageActionTypes = { applyTransform: "image:applyTransform", init: "image:init", dispose: "image:dispose", + /** + * @deprecated + */ setTransition: "image:setTransition", applyTransition: "image:applyTransition", + flush: "image:flush", } as const; export type ImageActionContentType = { [K in typeof ImageActionTypes[keyof typeof ImageActionTypes]]: @@ -84,7 +88,8 @@ export type ImageActionContentType = { K extends "image:dispose" ? [] : K extends "image:setTransition" ? [ITransition | null] : K extends "image:applyTransition" ? [ITransition] : - any; + K extends "image:flush" ? [] : + any; } /* Condition */ export const ConditionActionTypes = { diff --git a/src/game/nlcore/action/actionable.ts b/src/game/nlcore/action/actionable.ts index bef390d..ed92b5d 100644 --- a/src/game/nlcore/action/actionable.ts +++ b/src/game/nlcore/action/actionable.ts @@ -15,11 +15,6 @@ export class Actionable< return null; } - /**@internal */ - public fromData(_: StateData): this { - return this; - } - /**@internal */ public fromChained(chained: Proxied>): LogicAction.Actions[] { return chained.getActions(); diff --git a/src/game/nlcore/action/actions.ts b/src/game/nlcore/action/actions.ts index 80ea84c..66b1c17 100644 --- a/src/game/nlcore/action/actions.ts +++ b/src/game/nlcore/action/actions.ts @@ -74,7 +74,8 @@ export class CharacterAction { static ActionTypes = SceneActionTypes; + static handleSceneInit(sceneAction: SceneAction, state: GameState, awaitable: Awaitable) { + if (sceneAction.callee._liveState.active) { + return { + type: sceneAction.type, + node: sceneAction.contentNode.getChild() + }; + } + sceneAction.callee._liveState.active = true; + + state + .registerSrcManager(sceneAction.callee.srcManager) + .addScene(sceneAction.callee); + + SceneAction.registerEventListeners(sceneAction.callee, state, () => { + awaitable.resolve({ + type: sceneAction.type, + node: sceneAction.contentNode.getChild() + }); + state.stage.next(); + }); + + return awaitable; + } + + static registerEventListeners(scene: Scene, state: GameState, onInit?: () => void) { + scene.events.once("event:scene.unmount", () => { + state.offSrcManager(scene.srcManager); + }); + + scene.events.once("event:scene.mount", () => { + if (scene.state.backgroundMusic) { + SoundAction.initSound(state, scene.state.backgroundMusic); + scene.events.emit("event:scene.setBackgroundMusic", + scene.state.backgroundMusic, + scene.config.backgroundMusicFade + ); + } + }); + + scene.events.once("event:scene.imageLoaded", () => { + const initTransform = scene.getInitTransform(); + scene.events.any("event:scene.initTransform", initTransform).then(() => { + if (onInit) { + onInit(); + } + }); + }); + } + public executeAction(state: GameState): CalledActionResult | Awaitable { if (this.type === SceneActionTypes.action) { return super.executeAction(state); @@ -129,41 +179,8 @@ export class SceneAction(v => v); - state - .registerSrcManager(this.callee.srcManager) - .addScene(this.callee); - - this.callee.events.once("event:scene.unmount", () => { - state.offSrcManager(this.callee.srcManager); - }); - - this.callee.events.once("event:scene.mount", () => { - if (this.callee.state.backgroundMusic) { - SoundAction.initSound(state, this.callee.state.backgroundMusic); - this.callee.events.emit("event:scene.setBackgroundMusic", - this.callee.state.backgroundMusic, - this.callee.config.backgroundMusicFade - ); - } - }); - - this.callee.events.once("event:scene.imageLoaded", () => { - const initTransform = this.callee.getInitTransform(); - this.callee.events.any("event:scene.initTransform", initTransform).then(() => { - awaitable.resolve({ - type: this.type, - node: this.contentNode.getChild() - }); - state.stage.next(); - }); - }); - return awaitable; + return SceneAction.handleSceneInit(this, state, awaitable); } else if (this.type === SceneActionTypes.exit) { this.callee._liveState.active = false; @@ -266,10 +283,10 @@ export class ImageAction).getContent()[0]; + state.logger.debug("Image - Set Src", this.callee.state.src); + state.stage.update(); return super.executeAction(state); } else if ([ @@ -283,10 +300,7 @@ export class ImageAction).getContent()[1]; @@ -311,12 +325,6 @@ export class ImageAction).getContent()[0] - ); - return super.executeAction(state); } else if (this.type === ImageActionTypes.applyTransition) { const awaitable = new Awaitable(v => v) .registerSkipController(new SkipController(() => { @@ -329,7 +337,7 @@ export class ImageAction).getContent()[0]; - transition.start(() => { + this.callee.events.any("event:image.applyTransition", transition).then(() => { awaitable.resolve({ type: this.type, node: this.contentNode.getChild() @@ -337,6 +345,17 @@ export class ImageAction(v => v); + this.callee.events.any("event:image.flushComponent") + .then(() => { + awaitable.resolve({ + type: this.type, + node: this.contentNode.getChild() + }); + state.stage.next(); + }); + return awaitable; } throw super.unknownType(); @@ -355,7 +374,7 @@ export class ConditionAction; const [content] = contentNode.getContent() as [LogicAction.Actions[]]; if (this.type === ControlActionTypes.do) { - const firstNode = content[0]?.contentNode; - const lastNode = content[content.length - 1]?.contentNode; - const thisChild = this.contentNode.getChild(); - - lastNode?.addChild(thisChild); - this.contentNode.addChild(firstNode || null); - return super.executeAction(state); + const awaitable = new Awaitable(v => v); + return this.execute(state, awaitable, content); } else if (this.type === ControlActionTypes.doAsync) { (async () => { if (content.length > 0) { @@ -571,29 +586,44 @@ export class ControlAction(v => v); - return this.execute(state, awaitable, content); + (async () => { + await Promise.all(content.map(action => this.executeSingleAction(state, action))); + awaitable.resolve({ + type: this.type, + node: this.contentNode.getChild() + }); + state.stage.next(); + })(); + return awaitable; } else if (this.type === ControlActionTypes.allAsync) { (async () => { - if (content.length > 0) { - await this.executeAllActions(state, content[0]); + for (const action of content) { + this.executeSingleAction(state, action).then(_ => (void 0)); } })(); return super.executeAction(state); } else if (this.type === ControlActionTypes.repeat) { const [actions, times] = (this.contentNode as ContentNode).getContent(); + const awaitable = new Awaitable(v => v); (async () => { for (let i = 0; i < times; i++) { if (actions.length > 0) { await this.executeAllActions(state, actions[0]); } } + awaitable.resolve({ + type: this.type, + node: this.contentNode.getChild() + }); + state.stage.next(); })(); - return super.executeAction(state); + return awaitable; } throw new Error("Unknown control action type: " + this.type); diff --git a/src/game/nlcore/action/baseElement.ts b/src/game/nlcore/action/baseElement.ts index b1e40fe..681b394 100644 --- a/src/game/nlcore/action/baseElement.ts +++ b/src/game/nlcore/action/baseElement.ts @@ -1,3 +1,6 @@ +import {ElementStateRaw} from "@core/elements/story"; +import {LogicAction} from "@core/action/logicAction"; + export class BaseElement { /**@internal */ protected id: string = ""; @@ -15,5 +18,21 @@ export class BaseElement { /**@internal */ reset() { } + + /**@internal */ + fromData(_: ElementStateRaw) { + return this; + } + + /**@internal */ + protected construct(actions: LogicAction.Actions[]): LogicAction.Actions[] { + for (let i = 0; i < actions.length; i++) { + const action = actions[i]; + if (i !== 0) { + actions[i - 1]?.contentNode.setChild(action.contentNode); + } + } + return actions; + } } diff --git a/src/game/nlcore/action/chain.ts b/src/game/nlcore/action/chain.ts index 76d1ae3..5e16ba8 100644 --- a/src/game/nlcore/action/chain.ts +++ b/src/game/nlcore/action/chain.ts @@ -1,7 +1,10 @@ import {LogicAction} from "@core/action/logicAction"; +import {BaseElement} from "@core/action/baseElement"; +import {ControlAction} from "@core/action/actions"; +import {ContentNode} from "@core/action/tree/actionTree"; +import type {Control} from "@core/elements/control"; import Actions = LogicAction.Actions; import GameElement = LogicAction.GameElement; -import {BaseElement} from "@core/action/baseElement"; export type Proxied, U extends Record> = T & U; @@ -11,7 +14,7 @@ export type ChainedActions = (ChainedAction | ChainedAction[] | Actions | Action const ChainedFlag = Symbol("_Chained"); -export class Chained { +export class Chained = any> { static isChained(value: any): value is Chained { return value && value[ChainedFlag]; } @@ -54,19 +57,24 @@ export class Chained { public getSelf(): Self { return this.__self; } + + /**@internal */ + public newChain() { + return this.getSelf().chain(); + } } /** * - T - the action type * - U - self constructor */ -export class Chainable> extends BaseElement { +export class Chainable> extends BaseElement { /**@internal */ - public chain(arg0?: T[] | T): Proxied> { - const chained: Proxied> = + public chain(arg0?: T[] | T): Proxied> { + const chained: Proxied> = Chained.isChained(this) ? (this as unknown as Proxied>) : - this.proxy>(this as any, new Chained(this as any)); + this.proxy>(this as any, new Chained(this as any)); if (!arg0) { return chained; @@ -97,6 +105,23 @@ export class Chainable> extends BaseElement { }) as Proxied; return proxy; } + + /**@internal */ + protected combineActions( + control: Control, + getActions: ((chain: Proxied>) + => ChainedAction) + ): Proxied> { + const chain = getActions(this.chain().newChain()); + const action = new ControlAction( + control.chain(), + ControlAction.ActionTypes.do, + new ContentNode().setContent([ + this.construct(Chained.toActions([chain])) + ]) + ); + return this.chain(action as T); + } } diff --git a/src/game/nlcore/action/constructable.ts b/src/game/nlcore/action/constructable.ts index d458cde..b224a8c 100644 --- a/src/game/nlcore/action/constructable.ts +++ b/src/game/nlcore/action/constructable.ts @@ -61,7 +61,7 @@ export class Constructable< /**@internal */ getAllElementMap(action: LogicAction.Actions | LogicAction.Actions[]): Map { const map = new Map(); - this.forEachChild(action, action => map.set(action.getId(), action.callee)); + this.forEachChild(action, action => map.set(action.callee.getId(), action.callee)); return map; } @@ -75,22 +75,17 @@ export class Constructable< return null; } - /**@internal */ - fromData(_: Record) { - return this; - } - /** * Construct the actions into a tree * @internal */ - protected construct(actions: LogicAction.Actions[], parent?: RenderableNode): RenderableNode | null { + protected constructNodes(actions: LogicAction.Actions[], parent?: RenderableNode): RenderableNode | null { for (let i = 0; i < actions.length; i++) { const action = actions[i]; if (i === 0 && parent) { - parent.setInitChild(action.contentNode); + parent.setChild(action.contentNode); } else if (i > 0) { - (actions[i - 1].contentNode)?.setInitChild(action.contentNode); + (actions[i - 1].contentNode)?.setChild(action.contentNode); } } return (actions.length) ? actions[0].contentNode : null; diff --git a/src/game/nlcore/action/tree/actionTree.ts b/src/game/nlcore/action/tree/actionTree.ts index 44c1492..7edf476 100644 --- a/src/game/nlcore/action/tree/actionTree.ts +++ b/src/game/nlcore/action/tree/actionTree.ts @@ -65,7 +65,6 @@ export class ContentNode extends Node { } } - initChild?: RenderableNode | null; action: LogicAction.Actions | null; private child?: RenderableNode | null; private parent: RenderableNode | null; @@ -108,15 +107,6 @@ export class ContentNode extends Node { return this.parent || null; } - /** - * To track the changes of the child - * should only be called when constructing the tree - */ - setInitChild(child: RenderableNode) { - this.initChild = child; - return this.setChild(child); - } - /** * Public method for setting the content of the node * should only be called when changing the state in-game diff --git a/src/game/nlcore/elements/condition.ts b/src/game/nlcore/elements/condition.ts index ced2719..1bee9c6 100644 --- a/src/game/nlcore/elements/condition.ts +++ b/src/game/nlcore/elements/condition.ts @@ -197,13 +197,13 @@ export class Condition extends Actionable { const node = actions[i].contentNode; const child = actions[i + 1]?.contentNode; if (child) { - node.setInitChild(child); + node.setChild(child); } if (i === actions.length - 1 && lastChild) { - node.setInitChild(lastChild); + node.setChild(lastChild); } if (i === 0 && parentChild) { - parentChild.setInitChild(node); + parentChild.setChild(node); } } return actions; diff --git a/src/game/nlcore/elements/control.ts b/src/game/nlcore/elements/control.ts index d2f9b11..11ab817 100644 --- a/src/game/nlcore/elements/control.ts +++ b/src/game/nlcore/elements/control.ts @@ -4,7 +4,7 @@ import {ControlAction} from "@core/action/actions"; import {ContentNode} from "@core/action/tree/actionTree"; import {Values} from "@lib/util/data"; import {Chained, ChainedActions, Proxied} from "@core/action/chain"; -import Actions = LogicAction.Actions; + type ChainedControl = Proxied>; @@ -109,17 +109,6 @@ export class Control extends Actionable { return this.push(ControlAction.ActionTypes.repeat, actions, times); } - /**@internal */ - construct(actions: Actions[]): Actions[] { - for (let i = 0; i < actions.length; i++) { - const action = actions[i]; - if (i !== 0) { - actions[i - 1]?.contentNode.setInitChild(action.contentNode); - } - } - return actions; - } - /**@internal */ private push( type: Values, diff --git a/src/game/nlcore/elements/image.ts b/src/game/nlcore/elements/image.ts index 0ace40a..2b6f4d2 100644 --- a/src/game/nlcore/elements/image.ts +++ b/src/game/nlcore/elements/image.ts @@ -19,6 +19,7 @@ import { } from "@core/elements/transform/position"; import {deepEqual, deepMerge, DeepPartial, EventDispatcher, getCallStack} from "@lib/util/data"; import {Chained, Proxied} from "@core/action/chain"; +import {Control} from "@core/elements/control"; export type ImageConfig = { src: string | StaticImageData; @@ -37,9 +38,11 @@ export type ImageEventTypes = { "event:image.applyTransform": [Transform]; "event:image.mount": []; "event:image.unmount": []; - "event:image.ready": [React.MutableRefObject]; + "event:image.ready": [React.MutableRefObject]; "event:image.elementLoaded": []; - "event:image.setTransition": [ITransition | null]; + "event:image.applyTransition": [ITransition]; + "event:image.flush": []; + "event:image.flushComponent": []; }; export class Image extends Actionable { @@ -52,7 +55,9 @@ export class Image extends Actionable { "event:image.unmount": "event:image.unmount", "event:image.ready": "event:image.ready", "event:image.elementLoaded": "event:image.elementLoaded", - "event:image.setTransition": "event:image.setTransition", + "event:image.applyTransition": "event:image.applyTransition", + "event:image.flush": "event:image.flush", + "event:image.flushComponent": "event:image.flushComponent", }; static defaultConfig: ImageConfig = { src: "", @@ -101,7 +106,7 @@ export class Image extends Actionable { /**@internal */ readonly events: EventDispatcher = new EventDispatcher(); /**@internal */ - ref: React.RefObject | undefined = undefined; + ref: React.RefObject | undefined = undefined; /**@internal */ state: ImageConfig; @@ -167,20 +172,23 @@ export class Image extends Actionable { * @chainable */ public setSrc(src: string | StaticImageData, transition?: ITransition): Proxied> { - const chain = this.chain(); - if (transition) { - const copy = transition.copy(); - copy.setSrc(Utils.srcToString(src)); - chain._transitionSrc(copy); - } - const action = new ImageAction( - this.chain(), - ImageAction.ActionTypes.setSrc, - new ContentNode<[string]>().setContent([ - typeof src === "string" ? src : Utils.staticImageDataToSrc(src) - ]) - ); - return chain.chain(action); + return this.combineActions(new Control, chain => { + if (transition) { + const copy = transition.copy(); + copy.setSrc(Utils.srcToString(src)); + chain._transitionSrc(copy); + } + const action = new ImageAction( + chain, + ImageAction.ActionTypes.setSrc, + new ContentNode<[string]>().setContent([ + typeof src === "string" ? src : Utils.staticImageDataToSrc(src) + ]) + ); + return chain + .chain(action) + .chain(this._flush()); + }); } /** @@ -217,16 +225,20 @@ export class Image extends Actionable { * @chainable */ public applyTransform(transform: Transform): Proxied> { - const action = new ImageAction( - this.chain(), - ImageAction.ActionTypes.applyTransform, - new ContentNode().setContent([ - void 0, - transform, - getCallStack() - ]) - ); - return this.chain(action); + return this.combineActions(new Control(), chain => { + const action = new ImageAction( + chain, + ImageAction.ActionTypes.applyTransform, + new ContentNode().setContent([ + void 0, + transform, + getCallStack() + ]) + ); + return chain + .chain(action) + .chain(this._flush()); + }); } /** @@ -257,15 +269,19 @@ export class Image extends Actionable { options: options || {} } ]); - const action = new ImageAction( - this.chain(), - ImageAction.ActionTypes.show, - new ContentNode().setContent([ - void 0, - trans - ]) - ); - return this.chain(action); + return this.combineActions(new Control(), chain => { + const action = new ImageAction( + chain, + ImageAction.ActionTypes.show, + new ContentNode().setContent([ + void 0, + trans + ]) + ); + return chain + .chain(action) + .chain(this._flush()); + }); } /** @@ -279,22 +295,28 @@ export class Image extends Actionable { public hide(transform: Partial): Proxied>; public hide(arg0?: Transform | Partial): Proxied> { - const action = new ImageAction( - this.chain(), - ImageAction.ActionTypes.hide, - new ContentNode().setContent([ - void 0, - (arg0 instanceof Transform) ? arg0 : new Transform([ - { - props: { - opacity: 0, - }, - options: arg0 || {} - } - ]) - ]) - ); - return this.chain(action); + return this.combineActions( + new Control(), + chain => { + const action = new ImageAction( + chain, + ImageAction.ActionTypes.hide, + new ContentNode().setContent([ + void 0, + (arg0 instanceof Transform) ? arg0 : new Transform([ + { + props: { + opacity: 0, + }, + options: arg0 || {} + } + ]) + ]) + ); + return chain + .chain(action) + .chain(this._flush()); + }); } /**@internal */ @@ -305,13 +327,13 @@ export class Image extends Actionable { } /**@internal */ - setScope(scope: React.RefObject): this { + setScope(scope: React.RefObject): this { this.ref = scope; return this; } /**@internal */ - getScope(): React.RefObject | undefined { + getScope(): React.RefObject | undefined { return this.ref; } @@ -342,17 +364,6 @@ export class Image extends Actionable { return this; } - /**@internal */ - _setTransition(transition: ITransition | null): Proxied> { - return this.chain(new ImageAction( - this.chain(), - ImageAction.ActionTypes.setTransition, - new ContentNode<[ITransition | null]>().setContent([ - transition - ]) - )); - } - /**@internal */ _applyTransition(transition: ITransition): Proxied> { return this.chain(new ImageAction<"image:applyTransition">( @@ -375,6 +386,15 @@ export class Image extends Actionable { ); } + /**@internal */ + _flush(): ImageAction { + return new ImageAction( + this.chain(), + ImageAction.ActionTypes.flush, + new ContentNode() + ); + } + /**@internal */ override reset() { this.state = deepMerge({}, this.config); @@ -383,8 +403,7 @@ export class Image extends Actionable { /**@internal */ private _transitionSrc(transition: ITransition): this { const t = transition.copy(); - this._setTransition(t) - ._applyTransition(t); + this._applyTransition(t); return this; } diff --git a/src/game/nlcore/elements/menu.ts b/src/game/nlcore/elements/menu.ts index 9054d38..ebd88da 100644 --- a/src/game/nlcore/elements/menu.ts +++ b/src/game/nlcore/elements/menu.ts @@ -95,18 +95,18 @@ export class Menu extends Actionable { } /**@internal */ - private construct(actions: Actions[], lastChild?: RenderableNode, parentChild?: RenderableNode): Actions[] { + private constructNodes(actions: Actions[], lastChild?: RenderableNode, parentChild?: RenderableNode): Actions[] { for (let i = 0; i < actions.length; i++) { const node = actions[i].contentNode; const child = actions[i + 1]?.contentNode; if (child) { - node.setInitChild(child); + node.setChild(child); } if (i === this.choices.length - 1 && lastChild) { - node.setInitChild(lastChild); + node.setChild(lastChild); } if (i === 0 && parentChild) { - parentChild.setInitChild(node); + parentChild.setChild(node); } } return actions; @@ -116,7 +116,7 @@ export class Menu extends Actionable { private constructChoices(): Choice[] { return this.choices.map(choice => { return { - action: this.construct(choice.action), + action: this.constructNodes(choice.action), prompt: choice.prompt }; }); diff --git a/src/game/nlcore/elements/scene.ts b/src/game/nlcore/elements/scene.ts index b5624ce..e6767c0 100644 --- a/src/game/nlcore/elements/scene.ts +++ b/src/game/nlcore/elements/scene.ts @@ -1,6 +1,6 @@ import {Constructable} from "../action/constructable"; import {Awaitable, deepMerge, DeepPartial, EventDispatcher, safeClone} from "@lib/util/data"; -import {Background, CommonImage} from "@core/types"; +import {Background} from "@core/types"; import {ContentNode} from "@core/action/tree/actionTree"; import {LogicAction} from "@core/action/logicAction"; import {ControlAction, ImageAction, SceneAction, SoundAction} from "@core/action/actions"; @@ -16,8 +16,8 @@ import { SceneActionContentType, SceneActionTypes } from "@core/action/actionTypes"; -import {Image} from "@core/elements/image"; -import {Utils} from "@core/common/core"; +import {Image, ImageDataRaw} from "@core/elements/image"; +import {Control, Utils} from "@core/common/core"; import {Chained, Proxied} from "@core/action/chain"; import Actions = LogicAction.Actions; import ImageTransformProps = TransformDefinitions.ImageTransformProps; @@ -44,7 +44,7 @@ export type SceneDataRaw = { backgroundMusic?: SoundDataRaw | null; background?: Background["background"]; }; - backgroundImageState?: Partial; + backgroundImageState?: ImageDataRaw | null; } export type SceneEventTypes = { @@ -98,23 +98,24 @@ export class Scene extends Constructable< /**@internal */ state: SceneConfig & SceneState; /**@internal */ - backgroundImageState: Partial; - /**@internal */ _liveState = { active: false, }; /**@internal */ sceneRoot?: SceneAction<"scene:action">; + /**@internal */ + backgroundImage: Image; constructor(name: string, config: DeepPartial = Scene.defaultConfig) { super(); this.name = name; this.config = deepMerge(Scene.defaultConfig, config); this.state = deepMerge(Scene.defaultState, this.config); - this.backgroundImageState = { + this.backgroundImage = new Image({ position: new CommonPosition(CommonPositionType.Center), opacity: 1, - }; + src: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" + }); } /** @@ -142,19 +143,20 @@ export class Scene extends Constructable< * @chainable */ public setBackground(background: Background["background"], transition?: ITransition): ChainedScene { - const chain = this.chain(); - if (transition) { - const copy = transition.copy(); - copy.setSrc(Utils.backgroundToSrc(background)); - chain._transitionToScene(undefined, copy, background); - } - return chain.chain(new SceneAction<"scene:setBackground">( - chain, - "scene:setBackground", - new ContentNode().setContent([ - background, - ]) - )); + return this.combineActions(new Control(), chain => { + if (transition) { + const copy = transition.copy(); + copy.setSrc(Utils.backgroundToSrc(background)); + chain._transitionToScene(undefined, copy, background); + } + return chain.chain(new SceneAction<"scene:setBackground">( + chain, + "scene:setBackground", + new ContentNode().setContent([ + background, + ]) + )); + }); } /** @@ -180,18 +182,19 @@ export class Scene extends Constructable< * @chainable */ public jumpTo(arg0: Scene, config?: Partial): ChainedScene { - const chain = this.chain(new SceneAction( - this.chain(), - "scene:preUnmount", - new ContentNode().setContent([]) - )); - - const jumpConfig: Partial = config || {}; - return chain - ._transitionToScene(arg0, jumpConfig.transition) - .chain(arg0._init()) - .chain(this._exit()) - ._jumpTo(arg0); + return this.combineActions(new Control(), chain => { + const jumpConfig: Partial = config || {}; + return chain + .chain(new SceneAction( + chain, + "scene:preUnmount", + new ContentNode().setContent([]) + )) + ._transitionToScene(arg0, jumpConfig.transition) + .chain(arg0._init()) + .chain(this._exit()) + ._jumpTo(arg0); + }); } /** @@ -234,7 +237,7 @@ export class Scene extends Constructable< backgroundMusic: this.state.backgroundMusic?.toData(), background: this.state.background, }, - backgroundImageState: Image.serializeImageState(this.backgroundImageState), + backgroundImageState: this.backgroundImage.toData(), } satisfies SceneDataRaw; } @@ -246,7 +249,7 @@ export class Scene extends Constructable< this.state.background = data.state.background; } if (data.backgroundImageState) { - this.backgroundImageState = Image.deserializeImageState(data.backgroundImageState); + this.backgroundImage.fromData(data.backgroundImageState); } return this; } @@ -256,7 +259,7 @@ export class Scene extends Constructable< return new Transform([ { props: { - ...this.backgroundImageState, + ...this.backgroundImage.state, opacity: 1, }, options: { @@ -291,7 +294,7 @@ export class Scene extends Constructable< ...userActions, ]; - const constructed = super.construct(futureActions); + const constructed = super.constructNodes(futureActions); const sceneRoot = new ContentNode(undefined, undefined, constructed || void 0).setContent(this); constructed?.setParent(sceneRoot); @@ -385,10 +388,7 @@ export class Scene extends Constructable< /**@internal */ override reset() { this.state = deepMerge(Scene.defaultState, this.config); - this.backgroundImageState = { - position: new CommonPosition(CommonPositionType.Center), - opacity: 1, - }; + this.backgroundImage.reset(); } /**@internal */ diff --git a/src/game/nlcore/elements/transform/position.ts b/src/game/nlcore/elements/transform/position.ts index 3de5aca..0b71ab4 100644 --- a/src/game/nlcore/elements/transform/position.ts +++ b/src/game/nlcore/elements/transform/position.ts @@ -102,6 +102,10 @@ export class PositionUtils { } } + static orUnknown(arg: T | UnknownAble | undefined): T | Unknown { + return (PositionUtils.isUnknown(arg) || arg === undefined) ? PositionUtils.Unknown : arg; + } + static mergePosition(a: IPosition, b: IPosition): Coord2D { const aPos = this.toCoord2D(a); const bPos = this.toCoord2D(b); @@ -166,6 +170,11 @@ export class PositionUtils { export class CommonPosition implements IPosition { public static Positions = CommonPositionType; + + static isCommonPositionType(arg: any): arg is CommonPosition { + return arg instanceof CommonPosition; + } + readonly position: CommonPositionType; /** @@ -180,10 +189,6 @@ export class CommonPosition implements IPosition { this.position = position; } - static isCommonPositionType(arg: any): arg is CommonPosition { - return arg instanceof CommonPosition; - } - toCSS(): D2Position { return { x: CommonPositions[this.position], @@ -195,6 +200,35 @@ export class CommonPosition implements IPosition { } export class Coord2D implements IPosition { + static isCoord2DPosition(arg: any): arg is Coord2D { + return arg instanceof Coord2D; + } + + static fromCommonPosition(position: CommonPosition): Coord2D { + return new Coord2D({ + x: CommonPositions[position.position], + y: "50%", + }); + } + + static fromAlignPosition(position: AlignPosition): Coord2D { + return new Coord2D({ + x: (PositionUtils.isUnknown(position.xalign)) ? PositionUtils.Unknown : `${position.xalign * 100}%`, + y: (PositionUtils.isUnknown(position.yalign)) ? PositionUtils.Unknown : `${position.yalign * 100}%`, + xoffset: position.xoffset, + yoffset: position.yoffset + }); + } + + static merge(a: Coord2D, b: Coord2D): Coord2D { + return new Coord2D({ + x: ((PositionUtils.isUnknown(b.x)) ? a.x : b.x), + y: ((PositionUtils.isUnknown(b.y)) ? a.y : b.y), + xoffset: ((PositionUtils.isUnknown(b.xoffset)) ? a.xoffset : b.xoffset), + yoffset: ((PositionUtils.isUnknown(b.yoffset)) ? a.yoffset : b.yoffset), + }); + } + readonly x: UnknownAble; readonly y: UnknownAble; readonly xoffset: UnknownAble; @@ -225,47 +259,18 @@ export class Coord2D implements IPosition { yoffset?: UnknownAble; } | UnknownAble, y?: UnknownAble) { if (typeof arg0 === "object") { - this.x = arg0.x || PositionUtils.Unknown; - this.y = arg0.y || PositionUtils.Unknown; - this.xoffset = arg0.xoffset || PositionUtils.Unknown; - this.yoffset = arg0.yoffset || PositionUtils.Unknown; + this.x = PositionUtils.orUnknown(arg0.x); + this.y = PositionUtils.orUnknown(arg0.y); + this.xoffset = PositionUtils.orUnknown(arg0.xoffset); + this.yoffset = PositionUtils.orUnknown(arg0.yoffset); } else { - this.x = arg0 || PositionUtils.Unknown; - this.y = y || PositionUtils.Unknown; + this.x = PositionUtils.orUnknown(arg0); + this.y = PositionUtils.orUnknown(y); this.xoffset = PositionUtils.Unknown; this.yoffset = PositionUtils.Unknown; } } - static isCoord2DPosition(arg: any): arg is Coord2D { - return arg instanceof Coord2D; - } - - static fromCommonPosition(position: CommonPosition): Coord2D { - return new Coord2D({ - x: CommonPositions[position.position], - y: "50%", - }); - } - - static fromAlignPosition(position: AlignPosition): Coord2D { - return new Coord2D({ - x: (PositionUtils.isUnknown(position.xalign)) ? PositionUtils.Unknown : `${position.xalign * 100}%`, - y: (PositionUtils.isUnknown(position.yalign)) ? PositionUtils.Unknown : `${position.yalign * 100}%`, - xoffset: position.xoffset, - yoffset: position.yoffset - }); - } - - static merge(a: Coord2D, b: Coord2D): Coord2D { - return new Coord2D({ - x: ((PositionUtils.isUnknown(b.x)) ? a.x : b.x), - y: ((PositionUtils.isUnknown(b.y)) ? a.y : b.y), - xoffset: ((PositionUtils.isUnknown(b.xoffset)) ? a.xoffset : b.xoffset), - yoffset: ((PositionUtils.isUnknown(b.yoffset)) ? a.yoffset : b.yoffset), - }); - } - toCSS(): D2Position { return { x: this.x, @@ -277,6 +282,11 @@ export class Coord2D implements IPosition { } export class Align implements IPosition { + + static isAlignPosition(arg: any): arg is AlignPosition { + return arg instanceof Align; + } + readonly xalign: UnknownAble; readonly yalign: UnknownAble; readonly xoffset: UnknownAble; @@ -307,22 +317,18 @@ export class Align implements IPosition { yoffset?: UnknownAble; } | UnknownAble, yalign?: UnknownAble) { if (typeof arg0 === "object") { - this.xalign = arg0.xalign || PositionUtils.Unknown; - this.yalign = arg0.yalign || PositionUtils.Unknown; - this.xoffset = arg0.xoffset || PositionUtils.Unknown; - this.yoffset = arg0.yoffset || PositionUtils.Unknown; + this.xalign = PositionUtils.orUnknown(arg0.xalign); + this.yalign = PositionUtils.orUnknown(arg0.yalign); + this.xoffset = PositionUtils.orUnknown(arg0.xoffset); + this.yoffset = PositionUtils.orUnknown(arg0.yoffset); } else { - this.xalign = arg0 || PositionUtils.Unknown; - this.yalign = yalign || PositionUtils.Unknown; + this.xalign = PositionUtils.orUnknown(arg0); + this.yalign = PositionUtils.orUnknown(yalign); this.xoffset = PositionUtils.Unknown; this.yoffset = PositionUtils.Unknown; } } - static isAlignPosition(arg: any): arg is AlignPosition { - return arg instanceof Align; - } - toCSS(): D2Position { return { x: (PositionUtils.isUnknown(this.xalign)) ? this.xalign : `${this.xalign * 100}%`, diff --git a/src/game/nlcore/elements/transform/transform.ts b/src/game/nlcore/elements/transform/transform.ts index d438dae..ac6d04a 100644 --- a/src/game/nlcore/elements/transform/transform.ts +++ b/src/game/nlcore/elements/transform/transform.ts @@ -11,6 +11,7 @@ import {animate} from "framer-motion/dom"; import Sequence = TransformDefinitions.Sequence; import SequenceProps = TransformDefinitions.SequenceProps; import React from "react"; +import {Image, ImageConfig} from "@core/elements/image"; export type Transformers = "position" @@ -145,28 +146,28 @@ export class Transform, - overwrites?: Partial<{ [K in Transformers]?: TransformHandler }> + scope: React.MutableRefObject, + overwrites?: Partial<{ [K in Transformers]?: TransformHandler }>, + image: Image, }, gameState: GameState, - initState: SequenceProps, + initState?: SequenceProps, after?: (state: DeepPartial) => void ) { - let state: DeepPartial = deepMerge>(initState, {}); return new Promise((resolve) => { (async () => { if (!this.sequenceOptions.sync) { resolve(); if (after) { - after(state); + after(image.state as any); } } for (let i = 0; i < this.sequenceOptions.repeat; i++) { for (const {props, options} of this.sequences) { - const initState = deepMerge({}, this.propToCSS(gameState, state)); + const initState = deepMerge({}, this.propToCSS(gameState, image.state as any)); if (!scope.current) { throw new Error("No scope found when animating."); @@ -174,23 +175,23 @@ export class Transform { - Object.assign(current["style"], this.propToCSS(gameState, state, overwrites)); + Object.assign(current["style"], this.propToCSS(gameState, image.state as any, overwrites)); this.setControl(null); }); } else { await new Promise(r => animation.then(() => r())); - Object.assign(current["style"], this.propToCSS(gameState, state, overwrites)); + Object.assign(current["style"], this.propToCSS(gameState, image.state as any, overwrites)); this.setControl(null); } } @@ -202,7 +203,7 @@ export class Transform implements ITransition { this.events.emit(TransitionEventTypes.update, this.toElementProps()); }, onComplete: () => { + this.controller = undefined; this.events.emit(TransitionEventTypes.end, null); if (onComplete) { onComplete(); } - this.controller = undefined; }, ...options, }); diff --git a/src/game/nlcore/elements/transition/dissolve.ts b/src/game/nlcore/elements/transition/dissolve.ts index 18c90ad..f8e5260 100644 --- a/src/game/nlcore/elements/transition/dissolve.ts +++ b/src/game/nlcore/elements/transition/dissolve.ts @@ -55,7 +55,12 @@ export class Dissolve extends Base implements ITransition { + this.state.opacity = Dissolve.Frames[1]; + if (onComplete) { + onComplete(); + } + }, onUpdate: (value) => { this.state.opacity = value; } @@ -70,7 +75,7 @@ export class Dissolve extends Base implements ITransition implements ITransition { end: Fade.Frames[1], duration: this.duration }, { - onComplete, + onComplete: () => { + if (onComplete) { + onComplete(); + } + this.state.opacity = Fade.Frames[1]; + }, onUpdate: (value) => { this.state.opacity = value; } @@ -58,17 +63,13 @@ export class Fade extends Base implements ITransition { public toElementProps(): (FadeProps & ElementProp)[] { return [ - { - style: { - opacity: 1, - }, - }, + {}, { style: { opacity: this.state.opacity, }, src: this.src, - } + }, ]; } diff --git a/src/game/nlcore/game.ts b/src/game/nlcore/game.ts index f9cffea..0d3585c 100644 --- a/src/game/nlcore/game.ts +++ b/src/game/nlcore/game.ts @@ -136,6 +136,8 @@ export class LiveGame { story: Story | null = null; /**@internal */ lockedAwaiting: Awaitable | null = null; + /**@internal */ + gameState: GameState | undefined = undefined; /**@internal */ _lockedCount = 0; @@ -171,44 +173,64 @@ export class LiveGame { } /** - * Start a new game + * Serialize the current game state + * + * You can use this to save the game state to a file or a database + * + * Note: Even if you change just a single line of script, the saved game might not be compatible with the new version */ - public newGame({gameState}: { gameState: GameState }) { - this.reset({gameState}); - this.initNamespaces(); - - const newGame = this.getNewSavedGame(); - newGame.name = "NewGame-" + Date.now(); - this.currentSavedGame = newGame; + public serialize(): SavedGame { + if (!this.gameState) { + throw new Error("No game state"); + } + const gameState = this.gameState; - const elements: Map | undefined = - this.story?.getAllElementMap(this.story?.entryScene?.sceneRoot || []); - if (elements) { - (Object.values(elements) as LogicAction.GameElement[]).forEach(element => { - element.reset(); - }); + const story = this.story; + if (!story) { + throw new Error("No story loaded"); } - gameState.stage.forceUpdate(); - gameState.stage.next(); + // get all element state + const store = this.storable.toData(); + const stage = gameState.toData(); + const currentAction = this.getCurrentAction()?.getId() || null; + const elementStates = story.getAllElementStates(); - return this; + return { + name: this.currentSavedGame?.name || "", + meta: { + created: this.currentSavedGame?.meta.created || Date.now(), + updated: Date.now(), + }, + game: { + store, + stage, + currentAction, + elementStates, + } + }; } /** * Load a saved game * - * Note: Even if you change just a single line of code, the saved game might not be compatible with the new version + * Note: Even if you change just a single line of script, the saved game might not be compatible with the new version * * After calling this method, the current game state will be lost, and the stage will trigger force reset */ - public deserialize(savedGame: SavedGame, {gameState}: { gameState: GameState }) { + public deserialize(savedGame: SavedGame) { + if (!this.gameState) { + throw new Error("No game state"); + } + const gameState = this.gameState; + const story = this.story; if (!story) { throw new Error("No story loaded"); } this.reset({gameState}); + gameState.stage.forceUpdate(); const actionMaps = new Map(); const elementMaps = new Map(); @@ -232,11 +254,14 @@ export class LiveGame { // restore elements elementStates.forEach(({id, data}) => { + gameState.logger.debug("restore element", id); + const element = elementMaps.get(id); if (!element) { throw new Error("Element not found, id: " + id + "\nNarraLeaf cannot find the element with the id from the saved game"); } - element.fromData(data); + element.reset(); + element.fromData(data as any); }); // restore game state @@ -249,6 +274,42 @@ export class LiveGame { } this.currentAction = action; } + + gameState.stage.forceUpdate(); + gameState.stage.next(); + } + + /** + * Start a new game + */ + public newGame() { + if (!this.gameState) { + throw new Error("No game state"); + } + const gameState = this.gameState; + + this.reset({gameState}); + this.initNamespaces(); + + const newGame = this.getNewSavedGame(); + newGame.name = "NewGame-" + Date.now(); + this.currentSavedGame = newGame; + + const elements: Map | undefined = + this.story?.getAllElementMap(this.story?.entryScene?.sceneRoot || []); + if (elements) { + elements.forEach((element) => { + gameState.logger.debug("reset element", element); + element.reset(); + }); + } else { + gameState.logger.warn("No elements found"); + } + + gameState.stage.forceUpdate(); + gameState.stage.next(); + + return this; } /**@internal */ @@ -262,41 +323,6 @@ export class LiveGame { this.currentSavedGame = null; gameState.forceReset(); - gameState.stage.update(); - } - - /** - * Serialize the current game state - * - * You can use this to save the game state to a file or a database - * - * Note: Even if you change just a single line of code, the saved game might not be compatible with the new version - */ - public serialize({gameState}: { gameState: GameState }): SavedGame { - const story = this.story; - if (!story) { - throw new Error("No story loaded"); - } - - // get all element state - const store = this.storable.toData(); - const stage = gameState.toData(); - const currentAction = this.getCurrentAction()?.getId() || null; - const elementStates = story.getAllElementStates(); - - return { - name: this.currentSavedGame?.name || "", - meta: { - created: this.currentSavedGame?.meta.created || Date.now(), - updated: Date.now(), - }, - game: { - store, - stage, - currentAction, - elementStates, - } - }; } /**@internal */ @@ -354,7 +380,7 @@ export class LiveGame { this._lockedCount = 0; - this.currentAction = nextAction.node?.getChild()?.action || null; + this.currentAction = nextAction.node?.action || null; return nextAction; } @@ -373,7 +399,17 @@ export class LiveGame { if (Awaitable.isAwaitable(nextAction)) { return nextAction; } - return nextAction?.node?.getChild()?.action || null; + return nextAction?.node?.action || null; + } + + /**@internal */ + setGameState(state: GameState | undefined) { + this.gameState = state; + return this; + } + + getGameState() { + return this.gameState; } /**@internal */ diff --git a/src/game/player/elements/Player.tsx b/src/game/player/elements/Player.tsx index f4003a6..2e699c1 100644 --- a/src/game/player/elements/Player.tsx +++ b/src/game/player/elements/Player.tsx @@ -39,6 +39,7 @@ export default function Player( const [state, dispatch] = useReducer(handleAction, new GameState(game, { update, forceUpdate: () => { + (state as GameState).logger.warn("Player", "force update"); flushSync(() => { update(); }); @@ -67,7 +68,11 @@ export default function Player( } useEffect(() => { - game.getLiveGame().loadStory(story); + game.getLiveGame().setGameState(state).loadStory(story); + + return () => { + game.getLiveGame().setGameState(undefined); + }; }, [game]); useEffect(() => { @@ -148,6 +153,8 @@ export default function Player( }; }, []); + state.events.emit(GameState.EventTypes["event:state.player.flush"]); + function handlePreloadLoaded() { state.stage.update(); if (story) { @@ -166,14 +173,8 @@ export default function Player( }} className={clsx(className, "__narraleaf_content-player")}> - { - state.state.srcManagers.map((srcManager, i) => { - return ( - - ); - }) - } - + + { state.getSceneElements().map(({scene, ele}) => ( @@ -234,7 +235,11 @@ export default function Player( ); } -function OnlyPreloaded({children, onLoaded}: Readonly<{ children: React.ReactNode, onLoaded: () => void }>) { +function OnlyPreloaded({children, onLoaded, state}: Readonly<{ + children: React.ReactNode, + onLoaded: () => void, + state: GameState +}>) { const {preloaded} = usePreloaded(); const [preloadedReady, setPreloadedReady] = useState(false); useEffect(() => { @@ -242,8 +247,12 @@ function OnlyPreloaded({children, onLoaded}: Readonly<{ children: React.ReactNod setPreloadedReady(true); onLoaded(); }); + const unmountListener = state.events.on(GameState.EventTypes["event:state.preload.unmount"], () => { + setPreloadedReady(false); + }); return () => { preloaded.events.off(Preloaded.EventTypes["event:preloaded.ready"], listener); + state.events.off(GameState.EventTypes["event:state.preload.unmount"], unmountListener); }; }, [preloadedReady]); return ( diff --git a/src/game/player/elements/image/Image.tsx b/src/game/player/elements/image/Image.tsx index c08ad11..583f10e 100644 --- a/src/game/player/elements/image/Image.tsx +++ b/src/game/player/elements/image/Image.tsx @@ -19,7 +19,7 @@ export default function Image({ state: GameState; onAnimationEnd?: () => any; }>) { - const scope = useRef(null); + const scope = useRef(null); const [transform, setTransform] = useState | null>(null); const [transformProps, setTransformProps] = @@ -47,22 +47,27 @@ export default function Image({ return { type, listener: image.events.on(type, async (transform) => { + if (!scope.current) { + throw new Error("scope not ready"); + } + assignTo(transform.propToCSS(state, image.state)); setTransform(transform); state.logger.debug("transform", transform, transform.propToCSS(state, image.state)); - await transform.animate({scope}, state, image.state, (after) => { + await transform.animate({scope, image}, state, image.state, (after) => { image.state = deepMerge(image.state, after); setTransformProps({ style: transform.propToCSS(state, image.state) as any, }); + setTransform(null); + state.logger.debug("transform end", transform, transform.propToCSS(state, image.state), image.state); + if (onAnimationEnd) { onAnimationEnd(); } - - setTransform(null); }); return true; }), @@ -70,7 +75,7 @@ export default function Image({ }), { type: GameImage.EventTypes["event:image.init"], listener: image.events.on(GameImage.EventTypes["event:image.init"], async () => { - await image.toTransform().animate({scope}, state, image.state, (after) => { + await image.toTransform().animate({scope, image}, state, image.state, (after) => { image.state = deepMerge(image.state, after); setTransformProps({ style: image.toTransform().propToCSS(state, image.state) as any, @@ -79,54 +84,72 @@ export default function Image({ }) }]); - assignTo(image.toTransform().propToCSS(state, image.state)); - - image.events.emit(GameImage.EventTypes["event:image.ready"], scope); - - return () => { - imageEventToken.cancel(); - image.events.emit(GameImage.EventTypes["event:image.unmount"]); - }; - }, []); + const imageTransitionEventToken = image.events.onEvents([ + { + type: GameImage.EventTypes["event:image.applyTransition"], + listener: image.events.on(GameImage.EventTypes["event:image.applyTransition"], (t) => { + setTransition(t); + if (!t) { + state.logger.warn("transition not set"); + return Promise.resolve(); + } + return new Promise(resolve => { + const eventToken = t.events.onEvents([ + { + type: TransitionEventTypes.update, + listener: t.events.on(TransitionEventTypes.update, (progress) => { + setTransitionProps(progress); + }), + }, + { + type: TransitionEventTypes.end, + listener: t.events.on(TransitionEventTypes.end, () => { + setTransition(null); - /** - * Listen to image transition events - */ - useEffect(() => { - const imageEventToken = image.events.onEvents([ + state.logger.debug("image transition end", t); + }) + }, + { + type: TransitionEventTypes.start, + listener: t.events.on(TransitionEventTypes.start, () => { + state.logger.debug("image transition start", t); + }) + } + ]); + t.start(() => { + eventToken.cancel(); + resolve(); + }); + }); + }) + }, { - type: GameImage.EventTypes["event:image.setTransition"], - listener: image.events.on(GameImage.EventTypes["event:image.setTransition"], (transition) => { - setTransition(transition); + type: GameImage.EventTypes["event:image.flushComponent"], + listener: image.events.on(GameImage.EventTypes["event:image.flushComponent"], async () => { + state.stage.update(); + await new Promise(resolve => { + // It is hard to explain why this is needed, but it is needed + // react does not flush between some microtasks + // So we need to wait for the next microtask + setTimeout(() => { + resolve(); + }, 10); + }); + return true; }) } ]); - return () => { - imageEventToken.cancel(); - }; - }, [transition, image]); + assignTo(image.toTransform().propToCSS(state, image.state)); - useEffect(() => { - const transitionEventTokens = transition ? transition.events.onEvents([ - { - type: TransitionEventTypes.update, - listener: transition.events.on(TransitionEventTypes.update, (progress) => { - setTransitionProps(progress); - }) - }, - { - type: TransitionEventTypes.end, - listener: transition.events.on(TransitionEventTypes.end, () => { - setTransition(null); - }) - }, - ]) : null; + image.events.emit(GameImage.EventTypes["event:image.ready"], scope); return () => { - transitionEventTokens?.cancel?.(); + imageEventToken.cancel(); + imageTransitionEventToken.cancel(); + image.events.emit(GameImage.EventTypes["event:image.unmount"]); }; - }, [transition]); + }, []); useEffect(() => { setStartTime(performance.now()); @@ -194,39 +217,58 @@ export default function Image({ } const defaultProps: ImgElementProp = { - className: "absolute", src: Utils.staticImageDataToSrc(image.state.src), style: { - opacity: 0, ...(game.config.app.debug ? { border: "1px solid red", } : {}) }, }; + state.logger.debug("image props", defaultProps, image.state, image.state.src); return ( - {transition ? transition.toElementProps().map((elementProps, index, arr) => { - const mergedProps = - deepMerge(defaultProps, transformProps, elementProps) as any; - return ( +
(scope.current = ref)} + className={"absolute"} + {...(deepMerge({ + style: { + opacity: 0, + } + }, transformProps))} + > + {transition ? (<> + {transition.toElementProps().map((elementProps, index, arr) => { + const mergedProps = + deepMerge(defaultProps, elementProps, { + style: { + transform: "translate(0%, -50%)" + } + }) as any; + return ( + + ); + })} + ) : ( - ); - }) : ( - - )} + )} + {(() => { + image.events.emit(GameImage.EventTypes["event:image.flush"]); + return null; + })()} +
); }; \ No newline at end of file diff --git a/src/game/player/elements/preload/Preload.tsx b/src/game/player/elements/preload/Preload.tsx index aea04f5..83789df 100644 --- a/src/game/player/elements/preload/Preload.tsx +++ b/src/game/player/elements/preload/Preload.tsx @@ -9,12 +9,11 @@ import {Utils} from "@core/common/Utils"; export function Preload({ state, - srcManager }: Readonly<{ state: GameState; - srcManager: SrcManager; }>) { const {preloaded} = usePreloaded(); + const lastScene = state.getLastScene(); useEffect(() => { if (typeof window === "undefined") { @@ -25,7 +24,6 @@ export function Preload({ const currentSceneSrc = state.getLastScene()?.srcManager; const futureSceneSrc = state.getLastScene()?.srcManager.future || []; const combinedSrc = [ - ...srcManager.src, ...(currentSceneSrc ? currentSceneSrc.src : []), ...(futureSceneSrc.map(v => v.src)).flat(2), ]; @@ -81,6 +79,7 @@ export function Preload({ Promise.all(promises).then(() => { state.logger.log("Preloaded", `Preloaded ${src.image.size} images`); preloaded.events.emit(Preloaded.EventTypes["event:preloaded.ready"]); + state.events.emit(GameState.EventTypes["event:state.preload.loaded"]); }); src.audio.forEach((src: Sound) => { @@ -106,7 +105,7 @@ export function Preload({ state.events.emit(GameState.EventTypes["event:state.preload.unmount"]); state.logger.debug("Preload unmounted"); }; - }, [state]); + }, [lastScene]); return null; } diff --git a/src/game/player/elements/scene/BackgroundTransition.tsx b/src/game/player/elements/scene/BackgroundTransition.tsx index e83d5d0..e15fda0 100644 --- a/src/game/player/elements/scene/BackgroundTransition.tsx +++ b/src/game/player/elements/scene/BackgroundTransition.tsx @@ -47,15 +47,14 @@ export default function BackgroundTransition({scene, props, state}: { type: TransitionEventTypes.end, listener: t.events.on(TransitionEventTypes.end, () => { setTransition(null); - resolve(); - state.logger.debug("transition end", t); + state.logger.debug("scene background transition end", t); }) }, { type: TransitionEventTypes.start, listener: t.events.on(TransitionEventTypes.start, () => { - state.logger.debug("transition start", t); + state.logger.debug("scene background transition start", t); }) } ]); @@ -69,15 +68,18 @@ export default function BackgroundTransition({scene, props, state}: { { type: GameScene.EventTypes["event:scene.applyTransform"], listener: scene.events.on(GameScene.EventTypes["event:scene.applyTransform"], async (transform) => { - assignTo(transform.propToCSS(state, scene.backgroundImageState)); + assignTo(transform.propToCSS(state, scene.backgroundImage.state)); setTransform(transform); - await transform.animate({scope}, state, scene.backgroundImageState, (after) => { + await transform.animate({ + scope, + image: scene.backgroundImage + }, state, scene.backgroundImage.state, (after) => { - scene.backgroundImageState = deepMerge(scene.backgroundImageState, after); + scene.backgroundImage.state = deepMerge(scene.backgroundImage.state, after); setTransformProps({ - style: transform.propToCSS(state, scene.backgroundImageState) as any, + style: transform.propToCSS(state, scene.backgroundImage.state) as any, }); setTransform(null); @@ -88,8 +90,11 @@ export default function BackgroundTransition({scene, props, state}: { type: GameScene.EventTypes["event:scene.initTransform"], listener: scene.events.on(GameScene.EventTypes["event:scene.initTransform"], async (transform) => { state.logger.debug("init transform", transform); - await transform.animate({scope}, state, scene.backgroundImageState, (after) => { - scene.backgroundImageState = deepMerge(scene.backgroundImageState, after); + await transform.animate({ + scope, + image: scene.backgroundImage + }, state, scene.backgroundImage.state, (after) => { + scene.backgroundImage.state = deepMerge(scene.backgroundImage.state, after); }); }) } @@ -98,10 +103,10 @@ export default function BackgroundTransition({scene, props, state}: { return () => { sceneEventTokens.cancel(); }; - }, [scene]); + }, []); useEffect(() => { - assignTo(scene.backgroundImageState); + assignTo(scene.backgroundImage.state); }, []); useEffect(() => { @@ -136,7 +141,7 @@ export default function BackgroundTransition({scene, props, state}: { throw new Error("scope not ready"); } if (arg0 instanceof Transform) { - Object.assign(scope.current.style, arg0.propToCSS(state, scene.backgroundImageState)); + Object.assign(scope.current.style, arg0.propToCSS(state, scene.backgroundImage.state)); } else { Object.assign(scope.current.style, arg0); } @@ -171,7 +176,7 @@ export default function BackgroundTransition({scene, props, state}: { })() : (() => { const mergedProps = deepMerge(defaultProps, props, { - style: new Transform([]).propToCSS(state, scene.backgroundImageState) + style: new Transform([]).propToCSS(state, scene.backgroundImage.state) }, transformProps); return ( diff --git a/src/game/player/gameState.ts b/src/game/player/gameState.ts index 5526c6d..e0c2c6d 100644 --- a/src/game/player/gameState.ts +++ b/src/game/player/gameState.ts @@ -11,6 +11,7 @@ import {LogicAction} from "@core/action/logicAction"; import {Storable} from "@core/store/storable"; import {Game} from "@core/game"; import {Clickable, MenuElement, TextElement} from "@player/gameState.type"; +import {SceneAction} from "@core/action/actions"; type PlayerStateElement = { texts: Clickable[]; @@ -45,6 +46,8 @@ type GameStateEvents = { "event:state.end": []; "event:state.player.skip": []; "event:state.preload.unmount": []; + "event:state.preload.loaded": []; + "event:state.player.flush": []; }; export class GameState { @@ -53,6 +56,8 @@ export class GameState { "event:state.end": "event:state.end", "event:state.player.skip": "event:state.player.skip", "event:state.preload.unmount": "event:state.preload.unmount", + "event:state.preload.loaded": "event:state.preload.loaded", + "event:state.player.flush": "event:state.player.flush", }; state: PlayerState = { sounds: [], @@ -254,6 +259,8 @@ export class GameState { const {scenes} = data; scenes.forEach(({sceneId, elements}) => { + this.logger.debug("Loading scene: " + sceneId); + const scene = elementMap.get(sceneId) as Scene; if (!scene) { throw new Error("Scene not found, id: " + sceneId + "\nNarraLeaf cannot find the element with the id from the saved game"); @@ -275,7 +282,15 @@ export class GameState { }; this.state.elements.push(element); - this.state.srcManagers.push(element.scene.srcManager); + this.registerSrcManager(scene.srcManager); + scene._liveState.active = true; + SceneAction.registerEventListeners(scene, this); + }); + } + + initScenes() { + this.state.elements.forEach(({scene}) => { + SceneAction.registerEventListeners(scene, this); }); } diff --git a/src/game/player/lib/isolated.tsx b/src/game/player/lib/isolated.tsx index 423c05a..4decada 100644 --- a/src/game/player/lib/isolated.tsx +++ b/src/game/player/lib/isolated.tsx @@ -6,7 +6,13 @@ import clsx from "clsx"; import {useRatio} from "@player/provider/ratio"; export default function Isolated( - {children, className}: Readonly<{ children: ReactNode, className?: string }> + {children, className, props, ref}: + Readonly<{ + children: ReactNode, + className?: string, + props?: Record, + ref?: React.MutableRefObject + }> ) { const {ratio} = useRatio(); @@ -24,7 +30,7 @@ export default function Isolated(
+ }} {...(props || {})} ref={ref}> {children}
diff --git a/src/index.ts b/src/index.ts index c2dd43c..26d68ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,4 @@ export * from "@core/common/player"; // @todo: add voice to "say" element // @todo: add other modes to "say" element - +// @fixme: background transition not working after load a saved game