From 191774f90a7ec771f012758003b81697fac905ca Mon Sep 17 00:00:00 2001 From: Nomen <111544765+helloyork@users.noreply.github.com> Date: Fri, 29 Nov 2024 19:24:40 -0800 Subject: [PATCH 1/2] Revert "[prod]narraleaf-react-0.2.0" --- CHANGELOG.md | 35 +- eslint.config.mjs | 1 - package.json | 2 +- src/game/nlcore/action/action.ts | 13 +- src/game/nlcore/action/actionTypes.ts | 56 +-- src/game/nlcore/action/actionable.ts | 9 +- src/game/nlcore/action/actions.ts | 6 +- .../nlcore/action/actions/characterAction.ts | 18 +- .../nlcore/action/actions/conditionAction.ts | 5 +- .../nlcore/action/actions/controlAction.ts | 5 +- .../action/actions/displayableAction.ts | 41 --- src/game/nlcore/action/actions/imageAction.ts | 53 +-- src/game/nlcore/action/actions/menuAction.ts | 5 +- .../nlcore/action/actions/persistentAction.ts | 23 -- src/game/nlcore/action/actions/sceneAction.ts | 111 ++---- src/game/nlcore/action/actions/soundAction.ts | 2 +- src/game/nlcore/action/actions/textAction.ts | 4 +- src/game/nlcore/action/constructable.ts | 22 +- src/game/nlcore/action/logicAction.ts | 60 +--- src/game/nlcore/action/srcManager.ts | 143 +------- src/game/nlcore/action/tree/actionTree.ts | 4 - src/game/nlcore/common/Utils.ts | 62 +--- src/game/nlcore/common/elements.ts | 54 +-- src/game/nlcore/common/game.ts | 2 +- src/game/nlcore/common/types.ts | 1 - .../nlcore/elements/character/sentence.ts | 2 - src/game/nlcore/elements/condition.ts | 110 +++--- .../elements/displayable/displayable.ts | 81 ----- .../elements/{displayable => }/image.ts | 340 +++--------------- src/game/nlcore/elements/menu.ts | 3 +- src/game/nlcore/elements/persistent.ts | 145 -------- src/game/nlcore/elements/scene.ts | 275 +++++--------- src/game/nlcore/elements/script.ts | 2 +- src/game/nlcore/elements/sound.ts | 2 - src/game/nlcore/elements/story.ts | 118 +----- .../nlcore/elements/{displayable => }/text.ts | 12 +- .../nlcore/elements/transform/transform.ts | 2 +- .../elements/transition/baseTransitions.ts | 3 - .../transition/imageTransitions/dissolve.ts | 4 +- .../transition/imageTransitions/fade.ts | 4 +- .../transition/imageTransitions/fadeIn.ts | 4 +- src/game/nlcore/elements/type.ts | 8 - src/game/nlcore/game.ts | 6 - src/game/nlcore/gameTypes.ts | 44 +-- src/game/nlcore/liveGame.ts | 85 +---- .../persistent => store}/storable.ts | 25 +- .../{elements/persistent => store}/type.ts | 0 src/game/player/elements/Player.tsx | 7 +- .../elements/displayable/Displayable.tsx | 4 +- .../elements/displayable/Displayables.tsx | 6 +- src/game/player/elements/displayable/Text.tsx | 2 +- .../elements/image/AspectScaleImage.tsx | 24 +- src/game/player/elements/image/Image.tsx | 25 +- src/game/player/elements/preload/Preload.tsx | 254 +++++-------- .../elements/scene/BackgroundTransition.tsx | 54 +-- src/game/player/elements/type.ts | 2 +- src/game/player/gameState.ts | 112 ++---- src/game/player/lib/ImageCacheManager.ts | 114 ------ src/game/player/lib/Preloaded.ts | 18 +- src/game/player/provider/preloaded.tsx | 5 +- src/util/data.ts | 225 +++--------- src/util/logger.ts | 110 ------ 62 files changed, 646 insertions(+), 2328 deletions(-) delete mode 100644 src/game/nlcore/action/actions/displayableAction.ts delete mode 100644 src/game/nlcore/action/actions/persistentAction.ts delete mode 100644 src/game/nlcore/elements/displayable/displayable.ts rename src/game/nlcore/elements/{displayable => }/image.ts (54%) delete mode 100644 src/game/nlcore/elements/persistent.ts rename src/game/nlcore/elements/{displayable => }/text.ts (95%) delete mode 100644 src/game/nlcore/elements/type.ts rename src/game/nlcore/{elements/persistent => store}/storable.ts (87%) rename src/game/nlcore/{elements/persistent => store}/type.ts (100%) delete mode 100644 src/game/player/lib/ImageCacheManager.ts delete mode 100644 src/util/logger.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 693eb41..370eda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,39 +1,6 @@ # Changelog -## [0.2.0] - 2024/11/29 - -### _Feature_ - -- Assign voice using generator or voice map -- Use `image.tag` to manage image src -- Use displayable actions to reorder layers -- Better image preloading -- Scene config inheritance -- Use the scene name to jump between two cross-referenced scenes -- Use `Persistent` to manage persistent data - -### _Incompatible Changes_ - -- Image constructor signature has changed. Now the first argument must be a config object. - -### Added - -- Voice map generator -- Image tag src management -- Displayable actions -- Layer actions -- Disable image auto initialize using image.config -- Quick image preloading only preloads images when needed -- Use `scene.inherit` to inherit scene config -- Use the scene name to jump between two cross-referenced scenes -- `Persistent` data management (storable actions wrapper) - -### Updated - -- Image preloader now stores images in stack, so the lib can easily control the process of preloading/unloading images -- Better signatures for `Condition` - -## [0.1.7] - 2024/11/16 +## [0.1.7] ### _Feature_ diff --git a/eslint.config.mjs b/eslint.config.mjs index 51040ad..8fd18ea 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,6 +48,5 @@ export default [...compat.extends( "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "@typescript-eslint/no-namespace": "off", - "@typescript-eslint/no-this-alias": "off", }, }]; \ No newline at end of file diff --git a/package.json b/package.json index 447aaec..7a330c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "narraleaf-react", - "version": "0.2.0", + "version": "0.1.6", "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 e490f0d..7c8bd06 100644 --- a/src/game/nlcore/action/action.ts +++ b/src/game/nlcore/action/action.ts @@ -3,7 +3,6 @@ import {ContentNode} from "@core/action/tree/actionTree"; import type {CalledActionResult} from "@core/gameTypes"; import {Awaitable, getCallStack} from "@lib/util/data"; import {GameState} from "@player/gameState"; -import {Story} from "@core/elements/story"; export class Action { static ActionTypes = { @@ -39,17 +38,7 @@ export class Action) { - this.contentNode = contentNode; - return this; - } - - getFutureActions(_story: Story): LogicAction.Actions[] { + getFutureActions(): LogicAction.Actions[] { const action = this.contentNode.getChild(); return (action && action.action) ? [action.action] : []; } diff --git a/src/game/nlcore/action/actionTypes.ts b/src/game/nlcore/action/actionTypes.ts index 39eec3c..2b5e31c 100644 --- a/src/game/nlcore/action/actionTypes.ts +++ b/src/game/nlcore/action/actionTypes.ts @@ -5,13 +5,13 @@ import {CommonDisplayable, ImageColor, ImageSrc} from "@core/types"; import {Transform} from "@core/elements/transform/transform"; import type {Scene} from "@core/elements/scene"; import type {MenuData} from "@core/elements/menu"; -import {Awaitable, FlexibleTuple, SelectElementFromEach} from "@lib/util/data"; -import {IImageTransition, ITransition} from "@core/elements/transition/type"; +import {Awaitable} from "@lib/util/data"; +import {ITransition} from "@core/elements/transition/type"; import type {Sound} from "@core/elements/sound"; import type {Script} from "@core/elements/script"; import {Sentence} from "@core/elements/character/sentence"; import type {TransformDefinitions} from "@core/elements/transform/type"; -import {Image, TagGroupDefinition} from "@core/elements/displayable/image"; +import {Image} from "@core/elements/image"; /* Character */ export const CharacterActionTypes = { @@ -31,6 +31,7 @@ export const SceneActionTypes = { action: "scene:action", setBackground: "scene:setBackground", sleep: "scene:sleep", + setTransition: "scene:setTransition", applyTransition: "scene:applyTransition", init: "scene:init", exit: "scene:exit", @@ -38,21 +39,20 @@ export const SceneActionTypes = { setBackgroundMusic: "scene:setBackgroundMusic", preUnmount: "scene:preUnmount", applyTransform: "scene:applyTransform", - transitionToScene: "scene:transitionToScene", } as const; export type SceneActionContentType = { [K in typeof SceneActionTypes[keyof typeof SceneActionTypes]]: K extends typeof SceneActionTypes["action"] ? Scene : K extends typeof SceneActionTypes["sleep"] ? number | Promise | Awaitable : K extends typeof SceneActionTypes["setBackground"] ? [ImageSrc | ImageColor] : - K extends typeof SceneActionTypes["applyTransition"] ? [ITransition] : - K extends typeof SceneActionTypes["init"] ? [Scene | string] : - K extends typeof SceneActionTypes["exit"] ? [] : - K extends typeof SceneActionTypes["jumpTo"] ? [Scene | string] : - K extends typeof SceneActionTypes["setBackgroundMusic"] ? [Sound | null, number?] : - K extends typeof SceneActionTypes["preUnmount"] ? [] : - K extends typeof SceneActionTypes["applyTransform"] ? [Transform] : - K extends typeof SceneActionTypes["transitionToScene"] ? [IImageTransition, Scene | string | undefined, ImageSrc | ImageColor | undefined] : + K extends typeof SceneActionTypes["setTransition"] ? [ITransition | null] : + K extends typeof SceneActionTypes["applyTransition"] ? [ITransition] : + K extends typeof SceneActionTypes["init"] ? [] : + K extends typeof SceneActionTypes["exit"] ? [] : + K extends typeof SceneActionTypes["jumpTo"] ? [Scene] : + K extends typeof SceneActionTypes["setBackgroundMusic"] ? [Sound | null, number?] : + K extends typeof SceneActionTypes["preUnmount"] ? [] : + K extends typeof SceneActionTypes["applyTransform"] ? [Transform] : any; } /* Story */ @@ -81,7 +81,6 @@ export const ImageActionTypes = { applyTransition: "image:applyTransition", flush: "image:flush", initWearable: "image:initWearable", - setAppearance: "image:setAppearance", } as const; export type ImageActionContentType = { [K in typeof ImageActionTypes[keyof typeof ImageActionTypes]]: @@ -96,8 +95,7 @@ export type ImageActionContentType = { K extends "image:applyTransition" ? [ITransition] : K extends "image:flush" ? [] : K extends "image:initWearable" ? [Image] : - K extends "image:setAppearance" ? [FlexibleTuple> | string[], IImageTransition | undefined] : - any; + any; } /* Condition */ export const ConditionActionTypes = { @@ -193,30 +191,4 @@ export type TextActionContentType = { K extends "text:applyTransition" ? [ITransition] : K extends "text:setFontSize" ? [number] : any; -} -export const DisplayableActionTypes = { - action: "displayable:action", - layerMoveUp: "displayable:layerMoveUp", - layerMoveDown: "displayable:layerMoveDown", - layerMoveTop: "displayable:layerMoveTop", - layerMoveBottom: "displayable:layerMoveBottom", -} as const; -export type DisplayableActionContentType = { - [K in typeof DisplayableActionTypes[keyof typeof DisplayableActionTypes]]: - K extends "displayable:layerMoveUp" ? [void] : - K extends "displayable:layerMoveDown" ? [void] : - K extends "displayable:layerMoveTop" ? [void] : - K extends "displayable:layerMoveBottom" ? [void] : - any; -} -/* Persistent */ -export const PersistentActionTypes = { - action: "persistent:action", - set: "persistent:set", -} as const; -export type PersistentActionContentType = { - [K in typeof PersistentActionTypes[keyof typeof PersistentActionTypes]]: - K extends "persistent:action" ? any : - K extends "persistent:set" ? [string, any] : - any; -} +} \ No newline at end of file diff --git a/src/game/nlcore/action/actionable.ts b/src/game/nlcore/action/actionable.ts index f4bce14..ed92b5d 100644 --- a/src/game/nlcore/action/actionable.ts +++ b/src/game/nlcore/action/actionable.ts @@ -3,7 +3,7 @@ import {Chainable, Chained, Proxied} from "@core/action/chain"; import GameElement = LogicAction.GameElement; export class Actionable< - StateData extends Record | null = Record, + StateData extends Record = Record, Self extends Actionable = any > extends Chainable { constructor() { @@ -15,12 +15,7 @@ export class Actionable< return null; } - /** - * @internal - * override this method can override the default behavior of chaining - * - * When converting a chain to actions, this method is called to convert the chain to actions - */ + /**@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 7da30cd..9a078c8 100644 --- a/src/game/nlcore/action/actions.ts +++ b/src/game/nlcore/action/actions.ts @@ -18,7 +18,7 @@ export class TypedAction< this.contentNode.action = this; } - unknownTypeError() { + unknownType() { throw new Error("Unknown action type: " + this.type); } @@ -32,8 +32,4 @@ export class TypedAction< })(); return a; } - - is(parent: new (...args: any[]) => T, type: string): this is T { - return this instanceof parent && this.type === type; - } } diff --git a/src/game/nlcore/action/actions/characterAction.ts b/src/game/nlcore/action/actions/characterAction.ts index 6c342ae..179d187 100644 --- a/src/game/nlcore/action/actions/characterAction.ts +++ b/src/game/nlcore/action/actions/characterAction.ts @@ -7,25 +7,11 @@ import {ContentNode} from "@core/action/tree/actionTree"; import {Sentence} from "@core/elements/character/sentence"; import {TypedAction} from "@core/action/actions"; import {SoundAction} from "@core/action/actions/soundAction"; -import {Sound} from "@core/elements/sound"; export class CharacterAction extends TypedAction { static ActionTypes = CharacterActionTypes; - static getVoice(state: GameState, sentence: Sentence): Sound | null { - const scene = state.getLastScene(); - if (!scene) { - throw new Error("No scene found when trying to play voice"); - } - - const {voiceId, voice} = sentence.config; - if (!voiceId && !voice) { - return null; - } - return Sound.toSound(scene.getVoice(voiceId) || voice); - } - public executeAction(state: GameState): CalledActionResult | Awaitable { if (this.type === CharacterActionTypes.say) { const awaitable = @@ -36,7 +22,7 @@ export class CharacterAction).getContent(); - const voice = CharacterAction.getVoice(state, sentence); + const voice = sentence.config.voice; if (voice) { SoundAction.initSound(state, voice); @@ -62,6 +48,6 @@ export class CharacterAction extends TypedAction { @@ -21,7 +20,7 @@ export class ConditionAction extends TypedAction { @@ -156,9 +155,9 @@ export class ControlAction = Values, - Self extends Displayable = Displayable -> - extends TypedAction { - static ActionTypes = DisplayableActionTypes; - - public executeAction(gameState: GameState) { - const scene = gameState.getLastSceneIfNot(); - if (this.type === DisplayableActionTypes.layerMoveUp) { - gameState.moveUpElement(scene, this.callee); - gameState.stage.update(); - - return super.executeAction(gameState); - } else if (this.type === DisplayableActionTypes.layerMoveDown) { - gameState.moveDownElement(scene, this.callee); - gameState.stage.update(); - - return super.executeAction(gameState); - } else if (this.type === DisplayableActionTypes.layerMoveTop) { - gameState.moveTopElement(scene, this.callee); - gameState.stage.update(); - - return super.executeAction(gameState); - } else if (this.type === DisplayableActionTypes.layerMoveBottom) { - gameState.moveBottomElement(scene, this.callee); - gameState.stage.update(); - - return super.executeAction(gameState); - } - - throw this.unknownTypeError(); - } -} \ No newline at end of file diff --git a/src/game/nlcore/action/actions/imageAction.ts b/src/game/nlcore/action/actions/imageAction.ts index 76aed49..6168ed8 100644 --- a/src/game/nlcore/action/actions/imageAction.ts +++ b/src/game/nlcore/action/actions/imageAction.ts @@ -1,5 +1,5 @@ import {ImageActionContentType, ImageActionTypes} from "@core/action/actionTypes"; -import {Image} from "@core/elements/displayable/image"; +import {Image} from "@core/elements/image"; import {GameState} from "@player/gameState"; import type {CalledActionResult} from "@core/gameTypes"; import {Awaitable, SkipController} from "@lib/util/data"; @@ -44,10 +44,6 @@ export class ImageAction).getContent()[0]; state.logger.debug("Image - Set Src", this.callee.state.src); @@ -93,6 +89,9 @@ export class ImageAction(v => v) .registerSkipController(new SkipController(() => { + if (this.type === ImageActionTypes.hide) { + this.callee.state.display = false; + } return { type: this.type, node: this.contentNode.getChild() @@ -109,41 +108,19 @@ export class ImageAction).getContent(); - if (!this.callee.state.tag || !this.callee.state.currentTags) { - throw this.callee._srcNotSpecifiedError(); - } - - const newTags = this.callee.resolveTags(this.callee.state.currentTags, tags); - const newSrc = Image.getSrcFromTags(newTags, this.callee.state.src); - - state.logger.debug("Image - Set Appearance", newTags, newSrc); - - if (transition) { - const awaitable = new Awaitable(v => v) - .registerSkipController(new SkipController(() => { - return { - type: this.type, - node: this.contentNode.getChild() - }; - })); - transition.setSrc(newSrc); - this.callee.events.any("event:displayable.applyTransition", transition).then(() => { - this.callee.state.currentTags = newTags; - awaitable.resolve({ - type: this.type, - node: this.contentNode.getChild() - }); - state.stage.next(); - }); - } - this.callee.state.currentTags = newTags; + // const awaitable = new Awaitable(v => v); + // this.callee.events.any("event:image.flushComponent") + // .then(() => { + // awaitable.resolve({ + // type: this.type, + // node: this.contentNode.getChild() + // }); + // state.stage.next(); + // }); + // return awaitable; return super.executeAction(state); } - throw super.unknownTypeError(); + throw super.unknownType(); } } \ No newline at end of file diff --git a/src/game/nlcore/action/actions/menuAction.ts b/src/game/nlcore/action/actions/menuAction.ts index 9cc7d67..aa70c6c 100644 --- a/src/game/nlcore/action/actions/menuAction.ts +++ b/src/game/nlcore/action/actions/menuAction.ts @@ -5,7 +5,6 @@ import {Awaitable} from "@lib/util/data"; import type {CalledActionResult} from "@core/gameTypes"; import {ContentNode} from "@core/action/tree/actionTree"; import {TypedAction} from "@core/action/actions"; -import {Story} from "@core/elements/story"; export class MenuAction extends TypedAction { @@ -28,8 +27,8 @@ export class MenuAction).getContent(); - return [...this.callee._getFutureActions(menu.choices), ...super.getFutureActions(story)]; + return [...this.callee._getFutureActions(menu.choices), ...super.getFutureActions()]; } } \ No newline at end of file diff --git a/src/game/nlcore/action/actions/persistentAction.ts b/src/game/nlcore/action/actions/persistentAction.ts deleted file mode 100644 index f8e2876..0000000 --- a/src/game/nlcore/action/actions/persistentAction.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {PersistentActionContentType, PersistentActionTypes} from "@core/action/actionTypes"; -import {GameState} from "@player/gameState"; -import {TypedAction} from "@core/action/actions"; -import {Values} from "@lib/util/data"; -import {Persistent} from "@core/elements/persistent"; - -export class PersistentAction = Values> - extends TypedAction> { - static ActionTypes = PersistentActionTypes; - - executeAction(gameState: GameState) { - const action: PersistentAction = this; - if (action.is>(PersistentAction, "persistent:set")) { - const [key, value] = action.contentNode.getContent(); - gameState.getStorable().getNamespace( - action.callee.getNamespaceName() - ).set(key, value); - return super.executeAction(gameState); - } - - throw this.unknownTypeError(); - } -} \ No newline at end of file diff --git a/src/game/nlcore/action/actions/sceneAction.ts b/src/game/nlcore/action/actions/sceneAction.ts index b98f8b1..5466ff7 100644 --- a/src/game/nlcore/action/actions/sceneAction.ts +++ b/src/game/nlcore/action/actions/sceneAction.ts @@ -7,21 +7,13 @@ import {ContentNode} from "@core/action/tree/actionTree"; import {LogicAction} from "@core/action/logicAction"; import {TypedAction} from "@core/action/actions"; import {SoundAction} from "@core/action/actions/soundAction"; -import {ITransition} from "@core/elements/transition/type"; -import {Story} from "@core/elements/story"; -import {RuntimeScriptError} from "@core/common/Utils"; export class SceneAction extends TypedAction { static ActionTypes = SceneActionTypes; - static handleSceneInit(sceneAction: SceneAction, state: GameState, awaitable: Awaitable) { - const [targetScene] = sceneAction.contentNode.getContent(); - const scene = typeof targetScene === "string" ? state.getSceneByName(targetScene) : targetScene; - if (!scene) { - throw sceneAction._sceneNotFoundError(sceneAction.getSceneName(targetScene)); - } - if (state.isSceneActive(scene)) { + static handleSceneInit(sceneAction: SceneAction, state: GameState, awaitable: Awaitable) { + if (state.isSceneActive(sceneAction.callee)) { return { type: sceneAction.type, node: sceneAction.contentNode.getChild() @@ -29,10 +21,10 @@ export class SceneAction { + SceneAction.registerEventListeners(sceneAction.callee, state, () => { awaitable.resolve({ type: sceneAction.type, node: sceneAction.contentNode.getChild() @@ -65,25 +57,6 @@ export class SceneAction() - .registerSkipController(new SkipController(() => { - state.logger.info("Background Transition", "Skipped"); - return { - type: this.type, - node: this.contentNode.getChild() - }; - })); - this.callee.events.any("event:displayable.applyTransition", transition).then(() => { - awaitable.resolve({ - type: this.type, - node: this.contentNode.getChild() - }); - state.stage.next(); - }); - return awaitable; - } - public executeAction(state: GameState): CalledActionResult | Awaitable { if (this.type === SceneActionTypes.action) { return super.executeAction(state); @@ -113,9 +86,24 @@ export class SceneAction).getContent(); - return this.applyTransition(state, transition); - } else if (this.is>(SceneAction, "scene:init")) { + const awaitable = new Awaitable() + .registerSkipController(new SkipController(() => { + state.logger.info("NarraLeaf-React: Background Transition", "Skipped"); + return { + type: this.type, + node: this.contentNode.getChild() + }; + })); + const transition = (this.contentNode as ContentNode).getContent()[0]; + this.callee.events.any("event:displayable.applyTransition", transition).then(() => { + awaitable.resolve({ + type: this.type, + node: this.contentNode.getChild() + }); + state.stage.next(); + }); + return awaitable; + } else if (this.type === SceneActionTypes.init) { const awaitable = new Awaitable(v => v); return SceneAction.handleSceneInit(this, state, awaitable); } else if (this.type === SceneActionTypes.exit) { @@ -133,20 +121,13 @@ export class SceneAction).getContent()[0]; + const scene = (this.contentNode as ContentNode).getContent()[0]; const current = this.contentNode; - const scene = state.getStory().getScene(targetScene); - if (!scene) { - throw this._sceneNotFoundError(this.getSceneName(targetScene)); - } - const future = scene.getSceneRoot().contentNode; + const future = scene.sceneRoot?.contentNode || null; if (future) current.addChild(future); - return { - type: this.type, - node: future - }; + return super.executeAction(state); } else if (this.type === SceneActionTypes.setBackgroundMusic) { const [sound, fade] = (this.contentNode as ContentNode).getContent(); @@ -174,53 +155,19 @@ export class SceneAction).getContent(); - if (targetScene) { - const scene = state.getStory().getScene(targetScene); - if (!scene) { - throw this._sceneNotFoundError(this.getSceneName(targetScene)); - } - if (!scene.config.background) { - return super.executeAction(state); - } - transition.setSrc(scene.config.background); - } else if (src) { - transition.setSrc(src); - } - - return this.applyTransition(state, transition); } throw new Error("Unknown scene action type: " + this.type); } - getFutureActions(story: Story): LogicAction.Actions[] { + getFutureActions(): LogicAction.Actions[] { if (this.type === SceneActionTypes.jumpTo) { - // It doesn't care about the actions after jumpTo + // We don't care about the actions after jumpTo // because they won't be executed - const targetScene = (this.contentNode as ContentNode).getContent()[0]; - const scene = story.getScene(targetScene, true); - - if (!scene.isSceneRootConstructed()) { - scene.constructSceneRoot(story); - } - - const sceneRootNode = story.getScene(targetScene, true).getSceneRoot()?.contentNode; + const sceneRootNode = (this.contentNode as ContentNode).getContent()[0]?.sceneRoot?.contentNode; return sceneRootNode?.action ? [sceneRootNode.action] : []; } const action = this.contentNode.getChild()?.action; return action ? [action] : []; } - - _sceneNotFoundError(sceneId: string): Error { - return new RuntimeScriptError(`Scene with name ${sceneId} not found` - + "\nMake sure you have registered the scene using story.register" - + `\nAction: (id: ${this.getId()}) ${this.type}` - + `\nAt: ${this.__stack}`); - } - - getSceneName(scene: Scene | string): string { - return typeof scene === "string" ? scene : scene.name; - } } \ No newline at end of file diff --git a/src/game/nlcore/action/actions/soundAction.ts b/src/game/nlcore/action/actions/soundAction.ts index dcdd083..376db81 100644 --- a/src/game/nlcore/action/actions/soundAction.ts +++ b/src/game/nlcore/action/actions/soundAction.ts @@ -78,6 +78,6 @@ export class SoundAction void)): void { + forEachChild(actionOrActions: LogicAction.Actions | LogicAction.Actions[], cb: ((action: TypedAction) => void)): void { const seen = new Set(); const queue: LogicAction.Actions[] = []; @@ -38,36 +38,36 @@ export class Constructable< cb(action); - const children = action.getFutureActions(story) + const children = action.getFutureActions() .filter(action => !seen.has(action)); queue.push(...children); } } /**@internal */ - getAllChildren(story: Story, action: LogicAction.Actions | LogicAction.Actions[]): LogicAction.Actions[] { + getAllChildren(action: LogicAction.Actions | LogicAction.Actions[]): LogicAction.Actions[] { const children: LogicAction.Actions[] = []; - this.forEachChild(story, action, action => children.push(action)); + this.forEachChild(action, action => children.push(action)); return children; } /**@internal */ - getAllChildrenMap(story: Story, action: LogicAction.Actions | LogicAction.Actions[]): Map { + getAllChildrenMap(action: LogicAction.Actions | LogicAction.Actions[]): Map { const map = new Map(); - this.forEachChild(story, action, action => map.set(action.getId(), action)); + this.forEachChild(action, action => map.set(action.getId(), action)); return map; } /**@internal */ - getAllElementMap(story: Story, action: LogicAction.Actions | LogicAction.Actions[]): Map { + getAllElementMap(action: LogicAction.Actions | LogicAction.Actions[]): Map { const map = new Map(); - this.forEachChild(story, action, action => map.set(action.callee.getId(), action.callee)); + this.forEachChild(action, action => map.set(action.callee.getId(), action.callee)); return map; } /**@internal */ - getAllChildrenElements(story: Story, action: LogicAction.Actions | LogicAction.Actions[]): LogicAction.GameElement[] { - return Array.from(new Set(this.getAllChildren(story, action).map(action => action.callee))); + getAllChildrenElements(action: LogicAction.Actions | LogicAction.Actions[]): LogicAction.GameElement[] { + return Array.from(new Set(this.getAllChildren(action).map(action => action.callee))); } /**@internal */ diff --git a/src/game/nlcore/action/logicAction.ts b/src/game/nlcore/action/logicAction.ts index 37144e8..680eec2 100644 --- a/src/game/nlcore/action/logicAction.ts +++ b/src/game/nlcore/action/logicAction.ts @@ -1,7 +1,7 @@ import type {Character} from "@core/elements/character"; import type {Scene} from "@core/elements/scene"; import type {Story} from "@core/elements/story"; -import type {Image} from "@core/elements/displayable/image"; +import type {Image} from "@core/elements/image"; import type {Condition} from "@core/elements/condition"; import type {Script} from "@core/elements/script"; import type {Menu} from "@core/elements/menu"; @@ -15,12 +15,10 @@ import { ConditionActionContentType, ConditionActionTypes, ControlActionContentType, - DisplayableActionContentType, - DisplayableActionTypes, ImageActionContentType, ImageActionTypes, MenuActionContentType, - MenuActionTypes, PersistentActionContentType, PersistentActionTypes, + MenuActionTypes, SceneActionContentType, SceneActionTypes, ScriptActionContentType, @@ -39,42 +37,24 @@ import {ScriptAction} from "@core/action/actions/scriptAction"; import {MenuAction} from "@core/action/actions/menuAction"; import {SoundAction} from "@core/action/actions/soundAction"; import {ControlAction} from "@core/action/actions/controlAction"; -import {Text} from "@core/elements/displayable/text"; +import {Text} from "@core/elements/text"; import {TextAction} from "@core/action/actions/textAction"; -import {Displayable as AbstractDisplayable} from "@core/elements/displayable/displayable"; -import {DisplayableAction} from "@core/action/actions/displayableAction"; -import {Persistent} from "@core/elements/persistent"; -import {PersistentAction} from "@core/action/actions/persistentAction"; export namespace LogicAction { - export type DisplayableElements = Text | Image | AbstractDisplayable; - export type GameElement = - Character - | Scene - | Story - | Image - | Condition - | Script - | Menu - | Sound - | Control - | Text - | AbstractDisplayable - | Persistent; + export type Displayable = Text | Image; + export type GameElement = Character | Scene | Story | Image | Condition | Script | Menu | Sound | Control | Text; export type Actions = - TypedAction - | CharacterAction - | ConditionAction - | ImageAction - | SceneAction - | ScriptAction - | StoryAction - | MenuAction - | SoundAction - | ControlAction - | TextAction - | DisplayableAction - | PersistentAction; + (TypedAction + | CharacterAction + | ConditionAction + | ImageAction + | SceneAction + | ScriptAction + | StoryAction + | MenuAction + | SoundAction + | ControlAction + | TextAction); export type ActionTypes = Values | Values @@ -85,9 +65,7 @@ export namespace LogicAction { | Values | Values | Values - | Values - | Values - | Values; + | Values; export type ActionContents = CharacterActionContentType & ConditionActionContentType @@ -98,7 +76,5 @@ export namespace LogicAction { & MenuActionContentType & SoundActionContentType & ControlActionContentType - & TextActionContentType - & DisplayableActionContentType - & PersistentActionContentType; + & TextActionContentType; } \ No newline at end of file diff --git a/src/game/nlcore/action/srcManager.ts b/src/game/nlcore/action/srcManager.ts index afc4799..1db4182 100644 --- a/src/game/nlcore/action/srcManager.ts +++ b/src/game/nlcore/action/srcManager.ts @@ -1,12 +1,7 @@ import {Sound} from "@core/elements/sound"; -import {Image as GameImage, Image} from "@core/elements/displayable/image"; -import {Story, Utils} from "@core/common/core"; +import {Image} from "@core/elements/image"; +import {Utils} from "@core/common/core"; import {StaticImageData} from "@core/types"; -import {LogicAction} from "@core/action/logicAction"; -import {ImageAction} from "@core/action/actions/imageAction"; -import {ImageActionContentType, ImageActionTypes, SceneActionTypes} from "@core/action/actionTypes"; -import {ContentNode} from "@core/action/tree/actionTree"; -import {SceneAction} from "@core/action/actions/sceneAction"; export type SrcType = "image" | "video" | "audio"; export type Src = { @@ -19,9 +14,6 @@ export type Src = { type: "audio"; src: Sound; }; -export type ActiveSrc = Src & { - activeType: T; -}; export class SrcManager { static SrcTypes: { @@ -31,125 +23,6 @@ export class SrcManager { video: "video", audio: "audio", } as const; - - static catSrc(src: Src[]): { - image: Image[]; - video: string[]; - audio: Sound[]; - } { - const images: Set = new Set(); - const videos: Set = new Set(); - const audios: Set = new Set(); - - src.forEach(({type, src}) => { - if (type === SrcManager.SrcTypes.image) { - images.add(src); - } else if (type === SrcManager.SrcTypes.video) { - videos.add(src); - } else { - audios.add(src); - } - }); - - return { - image: Array.from(images), - video: Array.from(videos), - audio: Array.from(audios), - }; - } - - static getSrc(src: Src | string | Image): string { - if (typeof src === "string") { - return src; - } - if (src instanceof Image) { - return GameImage.getSrc(src.state); - } - if (src.type === "image") { - return GameImage.getSrc(src.src.state); - } else if (src.type === "video") { - return src.src; - } else if (src.type === "audio") { - return src.src.getSrc(); - } - return ""; - } - - static getPreloadableSrc(story: Story, action: LogicAction.Actions): (Src & { - activeType: "scene" | "once" - }) | null { - if (action.is>(SceneAction, SceneActionTypes.setBackground)) { - const content = action.contentNode.getContent()[0]; - const src = Utils.backgroundToSrc(content); - if (src) { - return { - type: "image", - src: new Image({src}), - activeType: "scene" - }; - } - } else if (action.is>(SceneAction, SceneActionTypes.jumpTo)) { - const targetScene = action.contentNode.getContent()[0]; - const scene = story.getScene(targetScene, true); - const sceneBackground = scene.config.background; - if (Utils.isStaticImageData(sceneBackground) || typeof sceneBackground === "string") { - return { - type: "image", - src: new Image({src: sceneBackground}), - activeType: "once" - }; - } - } else if (action instanceof ImageAction) { - const imageAction = action as ImageAction; - if (imageAction.callee.config.tag) { - return { - type: "image", - src: new Image({ - src: Image.getSrcFromTags(imageAction.callee.config.tag.defaults, imageAction.callee.config.src) - }), - activeType: "scene" - }; - } - if (action.is>(ImageAction, ImageActionTypes.setSrc)) { - const content = action.contentNode.getContent()[0]; - return { - type: "image", - src: new Image({src: content}), - activeType: "scene" - }; - } else if (action.type === ImageActionTypes.initWearable) { - const image = (action.contentNode as ContentNode).getContent()[0]; - return { - type: "image", - src: image, - activeType: "scene" - }; - } else if (action.type === ImageActionTypes.setAppearance) { - const tags = (action.contentNode as ContentNode).getContent()[0]; - if (typeof imageAction.callee.config.src !== "function") { - throw imageAction.callee._invalidSrcHandlerError(); - } - if (tags.length === imageAction.callee.state.tag?.groups.length) { - return { - type: "image", - src: Image.fromSrc(Image.getSrcFromTags(tags, imageAction.callee.config.src)), - activeType: "scene" - }; - } - } else if (action.type === ImageActionTypes.init) { - const src = action.callee.config.src; - if (typeof src === "string" || Utils.isStaticImageData(src)) { - return { - type: "image", - src: new Image({src}), - activeType: "scene" - }; - } - } - } - return null; - } - src: Src[] = []; future: SrcManager[] = []; @@ -166,15 +39,13 @@ export class SrcManager { this.src.push({type: "audio", src: arg0}); } else if (arg0 instanceof Image || Utils.isStaticImageData(arg0)) { if (arg0 instanceof Image) { - if (this.isSrcRegistered(GameImage.getSrc(arg0.state))) return this; + if (this.isSrcRegistered(Utils.srcToString(arg0.state.src))) return this; } else { if (this.isSrcRegistered(Utils.srcToString(arg0["src"]))) return this; } this.src.push({ type: "image", src: - arg0 instanceof Image ? new Image({ - src: Image.getSrc(arg0.state), - }) : new Image({ + arg0 instanceof Image ? arg0 : new Image("", { src: Utils.staticImageDataToSrc(arg0), }) }); @@ -203,7 +74,7 @@ export class SrcManager { if (s.type === SrcManager.SrcTypes.audio) { return target === s.src.getSrc(); } else if (s.type === SrcManager.SrcTypes.image) { - return target === GameImage.getSrc(s.src.state); + return target === Utils.srcToString(s.src.state.src); } else { return target === s.src; } @@ -227,9 +98,5 @@ export class SrcManager { hasFuture(s: SrcManager): boolean { return this.future.includes(s); } - - getFutureSrc(): Src[] { - return this.future.map(s => s.getSrc()).flat(2); - } } diff --git a/src/game/nlcore/action/tree/actionTree.ts b/src/game/nlcore/action/tree/actionTree.ts index 5589434..7edf476 100644 --- a/src/game/nlcore/action/tree/actionTree.ts +++ b/src/game/nlcore/action/tree/actionTree.ts @@ -39,10 +39,6 @@ export type ContentNodeData = { } export class ContentNode extends Node { - static create(content: T): ContentNode { - return new ContentNode().setContent(content); - } - static forEachParent(node: RenderableNode, callback: (node: RenderableNode) => void) { const seen: Set = new Set(); let current: RenderableNode | null = node; diff --git a/src/game/nlcore/common/Utils.ts b/src/game/nlcore/common/Utils.ts index bb69e42..38a6c7e 100644 --- a/src/game/nlcore/common/Utils.ts +++ b/src/game/nlcore/common/Utils.ts @@ -1,6 +1,6 @@ import type {Background, color, HexColor, ImageColor, ImageSrc, NextJSStaticImageData} from "@core/types"; import type {Scene} from "@core/elements/scene"; -import type {Image} from "@core/elements/displayable/image"; +import type {Image} from "@core/elements/image"; import type {LogicAction} from "@core/action/logicAction"; import { ImageActionContentType, @@ -12,8 +12,6 @@ import {ContentNode} from "@core/action/tree/actionTree"; import {SceneAction} from "@core/action/actions/sceneAction"; import {ImageAction} from "@core/action/actions/imageAction"; import {toHex, Values} from "@lib/util/data"; -import {Action} from "@core/action/action"; -import {Story} from "@core/elements/story"; export class RGBColor { static isHexString(color: any): color is HexColor { @@ -74,7 +72,7 @@ export class Utils { } public static isStaticImageData(src: any): src is NextJSStaticImageData { - return src?.src !== undefined && typeof src.src === "string"; + return src?.src !== undefined; } public static backgroundToSrc(background: Background["background"]): string | null { @@ -141,7 +139,7 @@ export class StaticScriptWarning extends UseError<{ } constructor(message: string, info?: any) { - super(message, {info}, "StaticScriptWarning"); + super(message, {info}, "NarraLeafReact-StaticScriptWarning"); } } @@ -157,14 +155,18 @@ export class StaticChecker { this.scene = target; } - public run(story: Story) { + public run() { + if (!this.scene.sceneRoot) { + return null; + } + const imageStates = new Map(); const scenes = new Map(); const queue: LogicAction.Actions[] = []; const seen: Set = new Set(); - const sceneActions = this.scene.getAllChildren(story, this.scene.getSceneRoot()); + const sceneActions = this.scene.getAllChildren(this.scene.sceneRoot); if (!sceneActions.length) { return null; @@ -175,7 +177,6 @@ export class StaticChecker { const action = queue.shift()!; this.checkAction( - story, action, {imageStates, scenes}, seen @@ -190,11 +191,9 @@ export class StaticChecker { return imageStates; } - private checkAction( - story: Story, - action: LogicAction.Actions, - {imageStates, scenes}: { imageStates: Map, scenes: Map }, - seen: Set + private checkAction(action: LogicAction.Actions, + {imageStates, scenes}: { imageStates: Map, scenes: Map }, + seen: Set ) { if (action instanceof ImageAction) { if (!imageStates.has(action.callee)) { @@ -219,11 +218,10 @@ export class StaticChecker { if (action.type === SceneActionTypes.jumpTo) { const targetScene = (action.contentNode as ContentNode).getContent()[0]; - const scene = story.getScene(targetScene, true); - if (seen.has(scene)) { + if (seen.has(targetScene)) { return; } else { - seen.add(scene); + seen.add(targetScene); } } } @@ -257,35 +255,3 @@ export class StaticChecker { } } -export class RuntimeScriptError extends Error { - static toMessage(msg: string | string[], trace?: Action | Action[]) { - const messages: string[] = []; - messages.push(...(Array.isArray(msg) ? msg : [msg])); - if (trace) { - messages.push(...( - Array.isArray(trace) - ? trace.map(RuntimeScriptError.getActionTrace) - : [RuntimeScriptError.getActionTrace(trace)] - )); - } - return messages.join(""); - } - - static getActionTrace(action: Action): string { - return `\nUsing action (id: ${action.getId()})` + - `\n at: ${action.__stack}`; - } - - constructor(message: string | string[], trace?: Action | Action[]) { - super(RuntimeScriptError.toMessage(message, trace)); - this.name = "RuntimeScriptError"; - } -} - -export class RuntimeGameError extends Error { - constructor(message: string) { - super(message); - this.name = "RuntimeGameError"; - } -} - diff --git a/src/game/nlcore/common/elements.ts b/src/game/nlcore/common/elements.ts index 11eb310..5a2456a 100644 --- a/src/game/nlcore/common/elements.ts +++ b/src/game/nlcore/common/elements.ts @@ -1,13 +1,7 @@ import {Character} from "../elements/character"; import {Condition, Lambda} from "../elements/condition"; import {Control} from "@core/elements/control"; -import { - Image as ImageClass, - RichImageUserConfig, - TagDefinitions, - TagGroupDefinition, - TagSrcResolver -} from "../elements/displayable/image"; +import {Image} from "../elements/image"; import {Menu} from "../elements/menu"; import {Scene} from "../elements/scene"; import {Script} from "../elements/script"; @@ -16,49 +10,8 @@ import {Story} from "../elements/story"; import {Transform} from "@core/elements/transform/transform"; import {Sentence} from "@core/elements/character/sentence"; import {Word} from "@core/elements/character/word"; -import {Text} from "@core/elements/displayable/text"; +import {Text} from "@core/elements/text"; import {Pause} from "@core/elements/character/pause"; -import {StaticImageData} from "@core/types"; -import {Persistent} from "@core/elements/persistent"; - -interface ImageConstructor { - new( - config: Omit>, "src"> & - (T extends null ? - { - src: string | StaticImageData; - tag?: never; - } : T extends TagGroupDefinition ? - { - src: TagSrcResolver; - tag: TagDefinitions; - } - : never), - ): ImageClass; -} - -const Image: ImageConstructor = function ( - this: ImageClass, - config: Omit>, "src"> & - (T extends null ? - { - src: string | StaticImageData; - tag?: never; - } : T extends TagGroupDefinition ? - { - src: TagSrcResolver; - tag: TagDefinitions; - } - : never), -): ImageClass { - if (!new.target) { - throw new Error("Image is a constructor and should be called with new keyword"); - } - return new ImageClass( - config as Partial>, - config.tag as TagDefinitions | undefined - ); -} as unknown as ImageConstructor; export { Character, @@ -76,6 +29,5 @@ export { Transform, Word, Text, - Pause, - Persistent, + Pause }; \ No newline at end of file diff --git a/src/game/nlcore/common/game.ts b/src/game/nlcore/common/game.ts index 9d6dceb..da03fb6 100644 --- a/src/game/nlcore/common/game.ts +++ b/src/game/nlcore/common/game.ts @@ -1,6 +1,6 @@ import {Game} from "@core/game"; import {GameState} from "@player/gameState"; -import {Storable, Namespace} from "../elements/persistent/storable"; +import {Storable, Namespace} from "../store/storable"; import {LiveGame} from "@core/liveGame"; export { diff --git a/src/game/nlcore/common/types.ts b/src/game/nlcore/common/types.ts index ccae994..d820df1 100644 --- a/src/game/nlcore/common/types.ts +++ b/src/game/nlcore/common/types.ts @@ -1,6 +1,5 @@ import {TransformDefinitions} from "@core/elements/transform/type"; -export * from "@core/elements/type"; export type { TransformDefinitions, }; diff --git a/src/game/nlcore/elements/character/sentence.ts b/src/game/nlcore/elements/character/sentence.ts index ae8035e..32c55c7 100644 --- a/src/game/nlcore/elements/character/sentence.ts +++ b/src/game/nlcore/elements/character/sentence.ts @@ -10,7 +10,6 @@ export type SentenceConfig = { pause?: boolean | number; voice: Sound | null; character: Character | null; - voiceId: string | number | null; } & Color & Font; export type SentenceDataRaw = { @@ -38,7 +37,6 @@ export class Sentence { pause: true, voice: null, character: null, - voiceId: null, }; /**@internal */ static defaultState: SentenceState = { diff --git a/src/game/nlcore/elements/condition.ts b/src/game/nlcore/elements/condition.ts index a0d416a..f3b5032 100644 --- a/src/game/nlcore/elements/condition.ts +++ b/src/game/nlcore/elements/condition.ts @@ -1,35 +1,31 @@ +import {deepMerge} from "@lib/util/data"; import {ContentNode, RenderableNode} from "@core/action/tree/actionTree"; import {LogicAction} from "@core/action/logicAction"; import {Actionable} from "@core/action/actionable"; import {GameState} from "@player/gameState"; import {Chained, ChainedActions, Proxied} from "@core/action/chain"; +import {ScriptCtx} from "@core/elements/script"; import {StaticScriptWarning} from "@core/common/Utils"; -import {ConditionAction} from "@core/action/actions/conditionAction"; -import {LambdaCtx, LambdaHandler} from "@core/elements/type"; import Actions = LogicAction.Actions; +import {ConditionAction} from "@core/action/actions/conditionAction"; -export class Lambda { - /**@internal */ - public static isLambda(value: any): value is Lambda { - return value instanceof Lambda && "handler" in value; - } +/* eslint-disable @typescript-eslint/no-empty-object-type */ +export type ConditionConfig = {}; - /**@internal */ - public static from(obj: Lambda | LambdaHandler): Lambda { - return Lambda.isLambda(obj) ? obj : new Lambda(obj); - } +interface LambdaCtx extends ScriptCtx { +} - /**@internal */ - handler: LambdaHandler; +type LambdaHandler = (ctx: LambdaCtx) => T; + +export class Lambda { + handler: LambdaHandler; - /**@internal */ constructor(handler: LambdaHandler) { this.handler = handler; } - /**@internal */ evaluate({gameState}: { gameState: GameState }): { - value: T; + value: any; } { const value = this.handler(this.getCtx({gameState})); return { @@ -37,7 +33,6 @@ export class Lambda { }; } - /**@internal */ getCtx({gameState}: { gameState: GameState }): LambdaCtx { return { gameState, @@ -62,7 +57,10 @@ export type ConditionData = { } }; -export class Condition extends Actionable { +export class Condition extends Actionable { + /**@internal */ + static defaultConfig: ConditionConfig = {}; + /**@internal */ static getInitialState(): ConditionData { return { @@ -77,15 +75,8 @@ export class Condition extends Actionable { }; } - /** - * @chainable - */ - public static If( - condition: Lambda | LambdaHandler, action: ChainedActions - ): Proxied> { - return new Condition().createIfCondition(condition, action); - } - + /**@internal */ + readonly config: ConditionConfig; /**@internal */ conditions: ConditionData = { If: { @@ -98,25 +89,54 @@ export class Condition extends Actionable { } }; - /**@internal */ - private constructor() { + constructor(config: ConditionConfig = {}) { super(); + this.config = deepMerge(Condition.defaultConfig, config); + } + + /** + * @chainable + */ + public If( + condition: Lambda | LambdaHandler, action: ChainedActions + ): Proxied> { + // when IF condition already set + if (this.conditions.If.condition) { + throw new StaticScriptWarning("IF condition already set\nYou are trying to set multiple IF conditions for the same condition"); + } + + // when ELSE-IF condition already set + if (this.conditions.ElseIf.length) { + throw new StaticScriptWarning("ELSE-IF condition already set\nYou are trying to set an IF condition after an ELSE-IF condition"); + } + + // when ELSE condition already set + if (this.conditions.Else.action) { + throw new StaticScriptWarning("ELSE condition already set\nYou are trying to set an IF condition after an ELSE condition"); + } + this.conditions.If.condition = condition instanceof Lambda ? condition : new Lambda(condition); + this.conditions.If.action = this.construct(Array.isArray(action) ? action : [action]); + return this.chain(); } /** * @chainable */ public ElseIf( - condition: Closed extends false ? (Lambda | LambdaHandler) : never, - action: Closed extends false ? ChainedActions : never - ): Closed extends false ? Proxied> : never { + condition: Lambda | LambdaHandler, action: ChainedActions + ): Proxied> { + // when there is no IF condition + if (!this.conditions.If.condition) { + throw new StaticScriptWarning("IF condition not set\nYou are trying to set an ELSE-IF condition without an IF condition"); + } + // when ELSE condition already set if (this.conditions.Else.action) { throw new StaticScriptWarning("ELSE condition already set\nYou are trying to set an ELSE-IF condition after an ELSE condition"); } this.conditions.ElseIf.push({ - condition: Lambda.isLambda(condition) ? condition : new Lambda(condition), + condition: condition instanceof Lambda ? condition : new Lambda(condition), action: this.construct(Array.isArray(action) ? action : [action]) }); return this.chain(); @@ -126,8 +146,13 @@ export class Condition extends Actionable { * @chainable */ public Else( - action: Closed extends false ? ChainedActions : never - ): Closed extends false ? Proxied, Chained> : never { + action: ChainedActions + ): Proxied> { + // when there is no IF condition + if (!this.conditions.If.condition) { + throw new StaticScriptWarning("IF condition not set\nYou are trying to set an ELSE condition without an IF condition"); + } + // when ELSE condition already set if (this.conditions.Else.action) { throw new StaticScriptWarning("ELSE condition already set\nYou are trying to set multiple ELSE conditions for the same condition"); @@ -189,18 +214,9 @@ export class Condition extends Actionable { /**@internal */ _getFutureActions(): LogicAction.Actions[] { return Chained.toActions([ - (this.conditions.If.action?.[0] || []), - ...this.conditions.ElseIf.flatMap(e => e.action?.[0] || []), - (this.conditions.Else.action?.[0] || []) + ...(this.conditions.If.action || []), + ...this.conditions.ElseIf.flatMap(e => e.action || []), + ...(this.conditions.Else.action || []) ]); } - - /**@internal */ - private createIfCondition( - condition: Lambda | LambdaHandler, action: ChainedActions - ): Proxied> { - this.conditions.If.condition = condition instanceof Lambda ? condition : new Lambda(condition); - this.conditions.If.action = this.construct(Array.isArray(action) ? action : [action]); - return this.chain(); - } } diff --git a/src/game/nlcore/elements/displayable/displayable.ts b/src/game/nlcore/elements/displayable/displayable.ts deleted file mode 100644 index f2101a7..0000000 --- a/src/game/nlcore/elements/displayable/displayable.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {Actionable} from "@core/action/actionable"; -import {EventfulDisplayable} from "@core/types"; -import {Transform} from "@core/elements/transform/transform"; -import {EventDispatcher, Values} from "@lib/util/data"; -import {ITransition} from "@core/elements/transition/type"; -import {DisplayableAction} from "@core/action/actions/displayableAction"; -import {DisplayableActionContentType, DisplayableActionTypes} from "@core/action/actionTypes"; -import {Chained, Proxied} from "@core/action/chain"; -import {LogicAction} from "@core/action/logicAction"; -import {ContentNode} from "@core/action/tree/actionTree"; - -export type DisplayableEventTypes = { - "event:displayable.applyTransition": [ITransition]; - "event:displayable.applyTransform": [Transform]; - "event:displayable.init": []; -}; - -export abstract class Displayable< - StateData extends Record, - Self extends Actionable -> - extends Actionable - implements EventfulDisplayable { - /**@internal */ - static EventTypes: { [K in keyof DisplayableEventTypes]: K } = { - "event:displayable.applyTransition": "event:displayable.applyTransition", - "event:displayable.applyTransform": "event:displayable.applyTransform", - "event:displayable.init": "event:displayable.init", - }; - /**@internal */ - readonly abstract events: EventDispatcher; - - abstract toDisplayableTransform(): Transform; - - /** - * Move the layer up - * @chainable - */ - public layerMoveUp(): Proxied> { - const chain = this.chain(); - return this.chain(this.constructLayerAction(chain, DisplayableActionTypes.layerMoveUp)); - } - - /** - * Move the layer down - * @chainable - */ - public layerMoveDown(): Proxied> { - const chain = this.chain(); - return this.chain(this.constructLayerAction(chain, DisplayableActionTypes.layerMoveDown)); - } - - /** - * Move the layer to the top - * @chainable - */ - public layerMoveTop(): Proxied> { - const chain = this.chain(); - return this.chain(this.constructLayerAction(chain, DisplayableActionTypes.layerMoveTop)); - } - - /** - * Move the layer to the bottom - * @chainable - */ - public layerMoveBottom(): Proxied> { - const chain = this.chain(); - return this.chain(this.constructLayerAction(chain, DisplayableActionTypes.layerMoveBottom)); - } - - protected constructLayerAction>( - chain: Proxied>, - type: T, - ): DisplayableAction { - return new DisplayableAction( - chain, - type, - new ContentNode(), - ); - } -} diff --git a/src/game/nlcore/elements/displayable/image.ts b/src/game/nlcore/elements/image.ts similarity index 54% rename from src/game/nlcore/elements/displayable/image.ts rename to src/game/nlcore/elements/image.ts index ad21ee0..a1c3685 100644 --- a/src/game/nlcore/elements/displayable/image.ts +++ b/src/game/nlcore/elements/image.ts @@ -1,9 +1,10 @@ import React from "react"; import type {TransformDefinitions} from "@core/elements/transform/type"; import {ContentNode} from "@core/action/tree/actionTree"; +import {Actionable} from "@core/action/actionable"; import {Utils} from "@core/common/Utils"; import {Scene} from "@core/elements/scene"; -import {Transform} from "../transform/transform"; +import {Transform} from "./transform/transform"; import {CommonDisplayable, EventfulDisplayable, StaticImageData} from "@core/types"; import {ImageActionContentType} from "@core/action/actionTypes"; import {LogicAction} from "@core/game"; @@ -15,31 +16,17 @@ import { IPosition, PositionUtils } from "@core/elements/transform/position"; -import { - deepEqual, - deepMerge, - EventDispatcher, - FlexibleTuple, - getCallStack, - SelectElementFromEach, - TypeOf -} from "@lib/util/data"; +import {deepEqual, deepMerge, DeepPartial, EventDispatcher, getCallStack} from "@lib/util/data"; import {Chained, Proxied} from "@core/action/chain"; import {Control} from "@core/elements/control"; import {ImageAction} from "@core/action/actions/imageAction"; -import {Displayable, DisplayableEventTypes} from "@core/elements/displayable/displayable"; export type ImageConfig = { + src: string | StaticImageData; display: boolean; - /**@internal */ disposed?: boolean; wearables: Image[]; isWearable?: boolean; - name?: string; - /** - * If set to false, the image won't be initialized unless you call `init` method - */ - autoInit: boolean; } & CommonDisplayable; export type ImageDataRaw = { @@ -47,48 +34,25 @@ export type ImageDataRaw = { }; export type ImageEventTypes = { + "event:displayable.applyTransition": [ITransition]; + "event:displayable.applyTransform": [Transform]; + "event:displayable.init": []; "event:wearable.create": [Image]; -} & DisplayableEventTypes; -export type TagDefinitions = - T extends TagGroupDefinition ? { - groups: T; - defaults: SelectElementFromEach; - } : never; -export type TagGroupDefinition = string[][]; -export type TagSrcResolver = (...tags: SelectElementFromEach) => string; -export type RichImageUserConfig = ImageConfig & { - /**@internal */ - currentTags?: SelectElementFromEach | null; -} & - (T extends null ? - { - src: string | StaticImageData; - tag?: never; - } : T extends TagGroupDefinition ? - { - src: TagSrcResolver; - tag: TagDefinitions; - } - : never); -export type RichImageConfig = RichImageUserConfig & {}; -export type StaticRichConfig = RichImageUserConfig; - +}; -export class Image< - Tags extends TagGroupDefinition | null = TagGroupDefinition | null -> - extends Displayable +export class Image + extends Actionable implements EventfulDisplayable { - /**@internal */ static EventTypes: { [K in keyof ImageEventTypes]: K } = { - ...Displayable.EventTypes, + "event:displayable.applyTransition": "event:displayable.applyTransition", + "event:displayable.applyTransform": "event:displayable.applyTransform", + "event:displayable.init": "event:displayable.init", "event:wearable.create": "event:wearable.create", }; /**@internal */ - public static DefaultImagePlaceholder = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; - /**@internal */ - static defaultConfig: RichImageUserConfig = { + static defaultConfig: ImageConfig = { + src: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", display: false, position: new CommonPosition(CommonPositionType.Center), scale: 1, @@ -96,9 +60,6 @@ export class Image< opacity: 0, isWearable: false, wearables: [], - src: Image.DefaultImagePlaceholder, - currentTags: null, - autoInit: true, }; /**@internal */ @@ -118,7 +79,7 @@ export class Image< }; /**@internal */ - public static deserializeImageState(state: Record): StaticRichConfig { + public static deserializeImageState(state: Record): ImageConfig { const handlers: Record any)> = { position: (value: D2Position) => { return PositionUtils.toCoord2D(value); @@ -130,67 +91,44 @@ export class Image< result[key] = handlers[key] ? handlers[key](state[key]) : state[key]; } } - return result as StaticRichConfig; + return result as ImageConfig; } /**@internal */ - public static getSrc(state: StaticRichConfig): string { - if (typeof state.src === "string" || Utils.isStaticImageData(state.src)) { - const {src} = state as RichImageConfig; - return Utils.isStaticImageData(src) ? Utils.staticImageDataToSrc(src) : src; - } - const {src, currentTags} = state as RichImageConfig; - if (!currentTags) { - throw new Error("Tags not resolved\nTags must be resolved before getting the src"); - } - return Image.getSrcFromTags(currentTags, src); - } - + readonly name: string; /**@internal */ - public static getSrcFromTags( - tags: SelectElementFromEach | string[], - tagResolver: (...tags: SelectElementFromEach | string[]) => string - ): string { - return tagResolver(...tags); - } - - /**@internal */ - public static fromSrc(src: string): Image { - return new Image({ - src: src, - }); - } - - /**@internal */ - name: string; - /**@internal */ - readonly config: RichImageUserConfig; + readonly config: ImageConfig; /**@internal */ readonly events: EventDispatcher = new EventDispatcher(); /**@internal */ ref: React.RefObject | undefined = undefined; /**@internal */ - state: RichImageConfig; + state: ImageConfig; + + constructor(name: string, config: Partial); - constructor(config: Partial> = {}, tagDefinition?: TagDefinitions) { + constructor(config?: DeepPartial); + + constructor(arg0: string | DeepPartial = {}, config?: Partial) { super(); - this.name = config.name || "(anonymous)"; - this.config = deepMerge>(Image.defaultConfig, config, { - tag: tagDefinition || config.tag, - position: config.position ? PositionUtils.tryParsePosition(config.position) : new CommonPosition(CommonPositionType.Center), - currentTags: config.tag?.defaults - ? [...config.tag.defaults] as SelectElementFromEach - : null - }); + if (typeof arg0 === "string") { + this.name = arg0; + this.config = deepMerge(Image.defaultConfig, config || {}); + if (this.config.position) this.config.position = PositionUtils.tryParsePosition(this.config.position); + } else { + this.name = ""; + this.config = deepMerge(Image.defaultConfig, arg0); + if (this.config.position) this.config.position = PositionUtils.tryParsePosition(this.config.position); + } + this.state = deepMerge({}, this.config); - this.state = deepMerge>({}, this.config); this.checkConfig(this.config); } /** - * Dispose of the image + * Dispose the image * - * Normally, you don't need to dispose of the image manually + * Normally, you don't need to dispose the image manually * @chainable */ public dispose() { @@ -203,52 +141,17 @@ export class Image< } /**@internal */ - checkConfig(config: RichImageUserConfig) { - // invalid-position error + checkConfig(config: ImageConfig) { if (!Transform.isPosition(config.position)) { throw new Error("Invalid position\nPosition must be one of CommonImagePosition, Align, Coord2D"); } - // mixed-src error - if (TypeOf(config.src) === TypeOf.DataTypes.string && config.tag) { - throw this._mixedSrcError(); - } - // src-not-specified error - if (!config.src && !config.tag) { - throw this._srcNotSpecifiedError(); - } - // invalid-wearable error for (const wearable of config.wearables) { if (!wearable.config.isWearable) { - throw this._invalidWearableError(JSON.stringify(wearable.config)); - } - } - // invalid-tag-group-definition error - if (config.tag) { - const seen: Set = new Set(); - for (const tags of config.tag.groups) { - for (const tag of tags) { - if (seen.has(tag)) { - throw this._invalidTagGroupDefinitionError(); - } - seen.add(tag); - } - } - } - // conflict-tag error - if (config.tag) { - const tagMap: Map = this.constructTagMap(config.tag.groups); - const usedTags = new Set(); - for (const tag of config.tag.defaults) { - if (usedTags.has(tag)) { - throw new Error(`Tag conflict\nTag "${tag}" is conflicting with another tag\nError found in config.tag.defaults`); - } - if (!tagMap.has(tag)) { - throw new Error(`Tag not found\nTag "${tag}" is not defined in tagDefinitions\nError found in config.tag.defaults`); - } - tagMap.get(tag)?.forEach(t => usedTags.add(t)); + throw new Error("Invalid wearable\nWearable must be an Image with isWearable set to true" + + "\nIt seems like you are trying to add a non-wearable image to wearables" + + "\nImage below violates the rule:\n" + JSON.stringify(wearable.config)); } } - return this; } @@ -269,27 +172,16 @@ export class Image< */ public setSrc(src: string | StaticImageData, transition?: IImageTransition): Proxied> { return this.combineActions(new Control(), chain => { - return this._setSrc(chain, src, transition); - }); - } - - /** - * Set the appearance of the image - * - * Note: using a full set of tags will help the library preload the images. - * @chainable - */ - public setAppearance( - tags: Tags extends TagGroupDefinition ? FlexibleTuple> : string[], - transition?: IImageTransition - ): Proxied> { - return this.combineActions(new Control(), chain => { - const action = new ImageAction( + if (transition) { + const copy = transition.copy(); + copy.setSrc(Utils.srcToString(src)); + chain._transitionSrc(copy); + } + const action = new ImageAction( chain, - ImageAction.ActionTypes.setAppearance, - new ContentNode().setContent([ - tags, - transition?.copy(), + ImageAction.ActionTypes.setSrc, + new ContentNode<[string]>().setContent([ + typeof src === "string" ? src : Utils.staticImageDataToSrc(src) ]) ); return chain @@ -351,7 +243,7 @@ export class Image< /** * Show the image * - * if options are provided, the image will show with the provided transform options + * if options is provided, the image will show with the provided transform options * @example * ```ts * image.show({ @@ -443,7 +335,7 @@ export class Image< * Bind this image to a parent image as a wearable */ public bindWearable(parent: Image): this { - parent.addWearable([this as Image]); + parent.addWearable(this); return this; } @@ -468,8 +360,8 @@ export class Image< return this.ref; } - public copy(): Image { - return new Image(this.config); + public copy(): Image { + return new Image(this.name, this.config); } /**@internal */ @@ -485,7 +377,7 @@ export class Image< /**@internal */ fromData(data: ImageDataRaw): this { - this.state = deepMerge>(this.state, Image.deserializeImageState(data.state)); + this.state = deepMerge(this.state, Image.deserializeImageState(data.state)); return this; } @@ -539,7 +431,7 @@ export class Image< /**@internal */ override reset() { - this.state = deepMerge>({}, this.config); + this.state = deepMerge({}, this.config); } /**@internal */ @@ -560,105 +452,6 @@ export class Image< }; } - /** - * @internal - * resolve tags, return the tags that aren't conflicting - */ - resolveTags( - oldTags: SelectElementFromEach | string[], - newTags: SelectElementFromEach | string[] - ): SelectElementFromEach { - if (!this.state.tag) { - throw new Error("Tag not defined\nTag must be defined in the image config"); - } - const tagMap: Map = this.constructTagMap(this.state.tag.groups); - const resultTags: Set = new Set(); - - const resolve = (tags: SelectElementFromEach | string[]) => { - for (const tag of tags) { - const conflictGroup = tagMap.get(tag); - if (!conflictGroup) continue; - - for (const conflictTag of conflictGroup) { - resultTags.delete(conflictTag); - } - resultTags.add(tag); - } - }; - - resolve(oldTags); - resolve(newTags); - - return Array.from(resultTags) as SelectElementFromEach; - } - - /**@internal */ - _mixedSrcError(): TypeError { - throw new TypeError("To better understand the behavior of the image, " + - "you cannot mix src and tags in the same image. " + - "If you are using tags, remove the src from the image config and do not use setSrc method. " + - "If you are using src, remove the tags from the image config and do not use setAppearance method."); - } - - /**@internal */ - _invalidSrcHandlerError(): Error { - throw new Error("Invalid src handler, " + - "If you are using tags, config.src must be a function that resolves the src from the tags. " + - "If you are using src, config.src must be a string or StaticImageData"); - } - - /**@internal */ - _srcNotSpecifiedError(): TypeError { - throw new TypeError("Src not specified\nPlease provide a src or tags in the image config"); - } - - /**@internal */ - _invalidWearableError(trace: string): Error { - throw new Error("Invalid wearable\nWearable must be an Image with isWearable set to true" + - "\nIt seems like you are trying to add a non-wearable image to wearables" + - "\nImage below violates the rule:\n" + trace); - } - - /**@internal */ - _invalidTagGroupDefinitionError(): Error { - throw new Error("Invalid tag group definition. " + - "Tags in groups must be unique and not conflicting with each other."); - } - - /**@internal */ - private constructTagMap(definitions: TagGroupDefinition): Map { - const tagMap: Map = new Map(); - for (const tags of definitions) { - for (const tag of tags) { - tagMap.set(tag, tags); - } - } - return tagMap; - } - - /**@internal */ - private _setSrc( - chain: Proxied>, - src: string | StaticImageData, - transition?: IImageTransition - ): Proxied> { - 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()); - } - /**@internal */ private _transitionSrc(transition: ITransition): this { const t = transition.copy(); @@ -674,23 +467,4 @@ export class Image< new ContentNode() )); } -} - -/** - * @class - * @internal - * This class is only for internal use, - * DO NOT USE THIS CLASS DIRECTLY - */ -export class VirtualImageProxy extends Image { - constructor(config: Partial> = {}) { - super(); - this.name = config.name || "(anonymous [virtual image proxy])"; - this.config.opacity = 1; - this.state.opacity = 1; - } - - override checkConfig(_: RichImageUserConfig): this { - return this; - } -} +} \ No newline at end of file diff --git a/src/game/nlcore/elements/menu.ts b/src/game/nlcore/elements/menu.ts index 1727283..8c0e05a 100644 --- a/src/game/nlcore/elements/menu.ts +++ b/src/game/nlcore/elements/menu.ts @@ -93,8 +93,7 @@ export class Menu extends Actionable { /**@internal */ _getFutureActions(choices: Choice[]): LogicAction.Actions[] { - return choices.map(choice => choice.action[0] || null) - .filter(action => action !== null); + return choices.map(choice => choice.action).flat(2); } /**@internal */ diff --git a/src/game/nlcore/elements/persistent.ts b/src/game/nlcore/elements/persistent.ts deleted file mode 100644 index e0ba452..0000000 --- a/src/game/nlcore/elements/persistent.ts +++ /dev/null @@ -1,145 +0,0 @@ -import {Actionable} from "@core/action/actionable"; -import {StorableType} from "@core/elements/persistent/type"; -import {Chained, Proxied} from "@core/action/chain"; -import {LogicAction} from "@core/game"; -import {PersistentActionContentType, PersistentActionTypes} from "@core/action/actionTypes"; -import {PersistentAction} from "@core/action/actions/persistentAction"; -import {BooleanKeys, StringKeyOf, Values} from "@lib/util/data"; -import {ContentNode} from "@core/action/tree/actionTree"; -import {Lambda} from "@core/elements/condition"; -import {Word} from "@core/elements/character/word"; -import {DynamicWord, DynamicWordResult} from "@core/elements/character/sentence"; -import {LambdaHandler} from "@core/elements/type"; -import {Namespace, Storable} from "@core/elements/persistent/storable"; - -type PersistentContent = { - [key: string]: StorableType; -}; -type ChainedPersistent = Proxied, Chained>; - -export class Persistent - extends Actionable { - - constructor(private namespace: string, private defaultContent: T) { - super(); - } - - /**@internal */ - init(storable: Storable) { - if (!storable.hasNamespace(this.namespace)) { - storable.addNamespace(new Namespace(this.namespace, this.defaultContent)); - } - } - - /** - * @chainable - */ - public set>(key: K, value: T[K]): ChainedPersistent { - return this.chain(this.createAction( - PersistentActionTypes.set, - [key, value] - )); - } - - /** - * Determine whether the values are equal, can be used in {@link Condition} - */ - public equals>(key: K, value: T[K]): Lambda { - return new Lambda(({storable}) => { - return storable.getNamespace(this.namespace).equals(key, value); - }); - } - - /** - * Determine whether the values aren't equal, can be used in {@link Condition} - */ - public notEquals>(key: K, value: T[K]): Lambda { - return new Lambda(({storable}) => { - return !storable.getNamespace(this.namespace).equals(key, value); - }); - } - - /** - * Determine whether the value is true, can be used in {@link Condition} - */ - public isTrue>>(key: K): Lambda { - return new Lambda(({storable}) => { - return storable.getNamespace(this.namespace).equals(key, true); - }); - } - - /** - * Determine whether the value is false, can be used in {@link Condition} - */ - public isFalse>>(key: K): Lambda { - return new Lambda(({storable}) => { - return storable.getNamespace(this.namespace).equals(key, false); - }); - } - - /** - * Determine whether the value isn't null or undefined, can be used in {@link Condition} - */ - public isNotNull>(key: K): Lambda { - return new Lambda(({storable}) => { - const value = storable.getNamespace(this.namespace).get(key); - return value !== null && value !== undefined; - }); - } - - /** - * Convert to a dynamic word - * @example - * ```typescript - * character.say(["You have ", persis.toWord("gold"), " gold"]); - * ``` - */ - public toWord>(key: K): Word { - return new Word(({storable}) => { - return [String(storable.getNamespace(this.namespace).get(key))]; - }); - } - - /** - * Create a conditional word - * - * @example - * ```typescript - * character.say([ - * "Your flag is ", - * persis.conditional( - * persis.isTrue("flag"), - * "on", - * "off" - * ) - * ]); - * ``` - */ - public conditional( - condition: Lambda | LambdaHandler, - ifTrue: DynamicWordResult, - ifFalse: DynamicWordResult - ): Word { - return new Word((ctx) => { - const isTrue = Lambda.from(condition).evaluate(ctx).value; - return isTrue ? ifTrue : ifFalse; - }); - } - - /**@internal */ - getNamespaceName(): string { - return this.namespace; - } - - /**@internal */ - private createAction>( - type: U, - content: PersistentActionContentType[U] - ): PersistentAction { - return new PersistentAction( - this.chain(), - type, - ContentNode.create(content) - ); - } -} diff --git a/src/game/nlcore/elements/scene.ts b/src/game/nlcore/elements/scene.ts index 602ce5d..78767d2 100644 --- a/src/game/nlcore/elements/scene.ts +++ b/src/game/nlcore/elements/scene.ts @@ -6,17 +6,22 @@ import {LogicAction} from "@core/action/logicAction"; import {Transform} from "@core/elements/transform/transform"; import {IImageTransition, ITransition} from "@core/elements/transition/type"; import {SrcManager} from "@core/action/srcManager"; -import {Sound, SoundDataRaw, VoiceIdMap, VoiceSrcGenerator} from "@core/elements/sound"; +import {Sound, SoundDataRaw} from "@core/elements/sound"; import {TransformDefinitions} from "@core/elements/transform/type"; -import {SceneActionContentType, SceneActionTypes} from "@core/action/actionTypes"; -import {Image, ImageDataRaw, VirtualImageProxy} from "@core/elements/displayable/image"; -import {Control, Story, Utils} from "@core/common/core"; +import { + ImageActionContentType, + ImageActionTypes, + SceneActionContentType, + SceneActionTypes +} from "@core/action/actionTypes"; +import {Image, ImageDataRaw} from "@core/elements/image"; +import {Control, Utils} from "@core/common/core"; import {Chained, Proxied} from "@core/action/chain"; import {SceneAction} from "@core/action/actions/sceneAction"; import {ImageAction} from "@core/action/actions/imageAction"; import {SoundAction} from "@core/action/actions/soundAction"; import {ControlAction} from "@core/action/actions/controlAction"; -import {Text} from "@core/elements/displayable/text"; +import {Text} from "@core/elements/text"; import {RGBColor} from "@core/common/Utils"; import Actions = LogicAction.Actions; import ImageTransformProps = TransformDefinitions.ImageTransformProps; @@ -28,7 +33,7 @@ export type SceneConfig = { invertX: boolean; backgroundMusic: Sound | null; backgroundMusicFade: number; - voices: VoiceIdMap | VoiceSrcGenerator | null; + backgroundImage: Image; } & { background: ImageSrc | ImageColor | null; }; @@ -38,16 +43,13 @@ export interface ISceneConfig { invertX: boolean; backgroundMusic: Sound | null; backgroundMusicFade: number; - voices?: VoiceIdMap | VoiceSrcGenerator; background?: ImageSrc | ImageColor; } -export type SceneState = { - backgroundImageProxy: VirtualImageProxy; -}; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type SceneState = {}; export type JumpConfig = { transition: IImageTransition; - unloadScene: boolean; } type ChainableAction = Proxied> | Actions; @@ -94,34 +96,19 @@ export class Scene extends Constructable< "event:displayable.init": "event:displayable.init", }; /**@internal */ - static defaultConfig: ISceneConfig = { + static defaultConfig: Omit = { invertY: false, invertX: false, backgroundMusic: null, backgroundMusicFade: 0, }; /**@internal */ - static defaultState: SceneState = { - backgroundImageProxy: new VirtualImageProxy(), - }; - - /**@internal */ - static isScene(object: any): object is Scene { - return object instanceof Scene; - } - - /**@internal */ - static getScene(story: Story, targetScene: Scene | string): Scene | null { - if (typeof targetScene === "string") { - return story.getScene(targetScene); - } - return targetScene; - } + static defaultState: SceneState = {}; /**@internal */ readonly name: string; /**@internal */ - config: SceneConfig; + readonly config: SceneConfig; /**@internal */ readonly srcManager: SrcManager = new SrcManager(); /**@internal */ @@ -129,26 +116,21 @@ export class Scene extends Constructable< /**@internal */ state: SceneConfig & SceneState; /**@internal */ - actions: (ChainableAction | ChainableAction[])[] | ((scene: Scene) => ChainableAction[]) = []; - /**@internal */ - private sceneRoot?: SceneAction<"scene:action">; - /**@internal */ - private _userConfig: Partial = {}; + sceneRoot?: SceneAction<"scene:action">; - constructor(name: string, config?: Partial) { + constructor(name: string, config: Partial = Scene.defaultConfig) { super(); this.name = name; - this._userConfig = config || {}; - const {background, voices, ...rest} = deepMerge(Scene.defaultConfig, config || {}); + const {background, ...rest} = deepMerge(Scene.defaultConfig, config); this.config = { ...rest, - voices: voices || null, + backgroundImage: new Image({ + opacity: 1, + }), background: background || null, }; - this.state = deepMerge(this.config, { - backgroundImageProxy: this._createImageProxy(), - }); + this.state = deepMerge(this.config, {}); } /**@internal */ @@ -165,7 +147,7 @@ export class Scene extends Constructable< /** * Activate the scene * - * This is only used when auto activation isn't working + * This is only used when auto activation is not working * @chainable */ public activate(): ChainedScene { @@ -175,7 +157,7 @@ export class Scene extends Constructable< /** * Deactivate the scene * - * This is only used when auto deactivation isn't working + * This is only used when auto deactivation is not working * @chainable */ public deactivate(): ChainedScene { @@ -183,7 +165,7 @@ export class Scene extends Constructable< } /** - * Set background, if {@link transition} is provided, it'll be applied + * Set background, if {@link transition} is provided, it will be applied * @chainable */ public setBackground(background: UserImageInput, transition?: IImageTransition): ChainedScene { @@ -191,7 +173,7 @@ export class Scene extends Constructable< if (transition) { const copy = transition.copy(); copy.setSrc(this.toBackground(background)); - chain._transitionToScene(copy, undefined, this.toBackground(background)); + chain._transitionToScene(undefined, copy, this.toBackground(background)); } return chain.chain(new SceneAction<"scene:setBackground">( chain, @@ -220,29 +202,25 @@ export class Scene extends Constructable< /** * Jump to the specified scene * - * After calling the method, you **won't be able to return to the context of the scene** that called the jump, - * so the scene will be unloaded + * After calling the method, you **will not be able to return to the context of the scene** that called the jump, so the scene will be unloaded * - * Any operations after the jump operation won't be executed + * Any operations after the jump operation will not be executed * @chainable */ - public jumpTo(scene: Scene | string, config: Partial = {}): ChainedScene { + public jumpTo(arg0: Scene, config?: Partial): ChainedScene { return this.combineActions(new Control(), chain => { - const defaultJumpConfig: Partial = {unloadScene: true}; - const jumpConfig = deepMerge(defaultJumpConfig, config); - chain + const jumpConfig: Partial = config || {}; + return chain .chain(new SceneAction( chain, "scene:preUnmount", new ContentNode().setContent([]) )) - ._transitionToScene(jumpConfig.transition, scene) - .chain(this._init(scene)); - if (jumpConfig.unloadScene) { - chain.chain(this._exit()); - } - return chain; - })._jumpTo(scene); + ._transitionToScene(arg0, jumpConfig.transition) + .chain(arg0._init()) + .chain(this._exit()) + ._jumpTo(arg0); + }); } /** @@ -285,7 +263,7 @@ export class Scene extends Constructable< backgroundMusic: this.state.backgroundMusic?.toData(), background: this.state.background, }, - backgroundImageState: this.state.backgroundImageProxy.toData(), + backgroundImageState: this.state.backgroundImage.toData(), } satisfies SceneDataRaw; } @@ -308,7 +286,7 @@ export class Scene extends Constructable< }, backgroundImageState: (backgroundImageState) => { if (backgroundImageState) { - this.state.backgroundImageProxy = new Image().fromData(backgroundImageState); + this.state.backgroundImage = new Image().fromData(backgroundImageState); } }, }); @@ -320,7 +298,7 @@ export class Scene extends Constructable< return new Transform([ { props: { - ...this.state.backgroundImageProxy.state, + ...this.state.backgroundImage.state, opacity: 1, }, options: { @@ -338,19 +316,6 @@ export class Scene extends Constructable< public action(actions: ((scene: Scene) => ChainableAction[])): this; public action(actions: (ChainableAction | ChainableAction[])[] | ((scene: Scene) => ChainableAction[])): this { - this.actions = actions; - return this; - } - - /**@internal */ - constructSceneRoot(story: Story): this { - this.sceneRoot = new SceneAction<"scene:action">( - this.chain(), - "scene:action", - new ContentNode(), - ); - - const actions = this.actions; const userChainedActions: ChainableAction[] = Array.isArray(actions) ? actions.flat(2) : actions(this).flat(2); const userActions = userChainedActions.map(v => { if (Chained.isChained(v)) { @@ -360,7 +325,7 @@ export class Scene extends Constructable< }).flat(2); const images: Image[] = [], texts: Text[] = []; - this.getAllChildrenElements(story, userActions).forEach(element => { + this.getAllChildrenElements(userActions).forEach(element => { if (Chained.isChained(element)) { return; } @@ -372,7 +337,7 @@ export class Scene extends Constructable< }); // disable auto initialization for wearables, - // the scene can't initialize wearables, + // wearables cannot be initialized by the scene, // they must be initialized by the image const @@ -401,9 +366,7 @@ export class Scene extends Constructable< const futureActions = [ this._init(this), - ...nonWearableImages - .filter(image => image.config.autoInit) - .map(image => image._init()), + ...nonWearableImages.map(image => image._init()), ...usedWearableImages.map(image => { if (!wearableImagesMap.has(image)) { throw new Error("Wearable image must have a parent image"); @@ -415,44 +378,27 @@ export class Scene extends Constructable< ]; const constructed = super.constructNodes(futureActions); - const sceneRoot = new ContentNode(this.sceneRoot, undefined, constructed || void 0).setContent(this); + const sceneRoot = new ContentNode(undefined, undefined, constructed || void 0).setContent(this); constructed?.setParent(sceneRoot); - this.sceneRoot?.setContentNode(sceneRoot); - - return this; - } - - /**@internal */ - isSceneRootConstructed(): boolean { - return !!this.sceneRoot; - } - - /** - * Inherit configuration from another scene - */ - public inherit(scene: Scene): this { - const {background, ...rest} = deepMerge(Scene.defaultConfig, scene.config, this._userConfig); + this.sceneRoot = new SceneAction( + this.chain(), + "scene:action", + sceneRoot + ); - this.config = { - ...rest, - background: background || null, - }; - this.state = deepMerge(this.config, { - backgroundImageProxy: this._createImageProxy(), - }); return this; } /**@internal */ - registerSrc(story: Story, seen: Set = new Set()) { + registerSrc(seen: Set = new Set()) { if (!this.sceneRoot) { return; } // [0.0.5] - 2024/10/04 - // Without this check, this method will enter the cycle and cost a lot of time, - // For example, Control will add some actions to the scene, this check won't stop correctly + // Without this check, this method will enter cycle and cost a lot of time + // For example, Control will add some actions to the scene, ths check will not stop correctly const seenActions = new Set(); const seenJump = new Set>(); @@ -473,15 +419,7 @@ export class Scene extends Constructable< if (action instanceof SceneAction) { if (action.type === SceneActionTypes.jumpTo) { const jumpTo = action as SceneAction; - const scene = Scene.getScene(story, jumpTo.contentNode.getContent()[0]); - if (!scene) { - throw action._sceneNotFoundError(action.getSceneName(jumpTo.contentNode.getContent()[0])); - } - - const background = SrcManager.getPreloadableSrc(story, action); - if (background) { - this.srcManager.register(background); - } + const scene = jumpTo.contentNode.getContent()[0]; if (seenJump.has(jumpTo) || seen.has(scene)) { continue; @@ -491,29 +429,35 @@ export class Scene extends Constructable< futureScene.add(scene); seen.add(scene); } else if (action.type === SceneActionTypes.setBackground) { - const src = SrcManager.getPreloadableSrc(story, action); + const content = (action.contentNode as ContentNode).getContent()[0]; + const src = Utils.backgroundToSrc(content); if (src) { - this.srcManager.register(src); + this.srcManager.register(new Image({src})); } } } else if (action instanceof ImageAction) { - const src = SrcManager.getPreloadableSrc(story, action); - if (src) { - this.srcManager.register(src); + const imageAction = action as ImageAction; + this.srcManager.register(imageAction.callee); + if (action.type === ImageActionTypes.setSrc) { + const content = (action.contentNode as ContentNode).getContent()[0]; + this.srcManager.register(new Image({src: content})); + } else if (action.type === ImageActionTypes.initWearable) { + const image = (action.contentNode as ContentNode).getContent()[0]; + this.srcManager.register(image); } } else if (action instanceof SoundAction) { this.srcManager.register(action.callee); } else if (action instanceof ControlAction) { const controlAction = action as ControlAction; - const actions = controlAction.getFutureActions(story); + const actions = controlAction.getFutureActions(); queue.push(...actions); } - queue.push(...action.getFutureActions(story)); + queue.push(...action.getFutureActions()); } futureScene.forEach(scene => { - scene.registerSrc(story, seen); + scene.registerSrc(seen); this.srcManager.registerFuture(scene.srcManager); }); } @@ -521,8 +465,8 @@ export class Scene extends Constructable< /** * @internal STILL IN DEVELOPMENT */ - assignActionId(story: Story) { - const actions = this.getAllChildren(story, this.sceneRoot || []); + assignActionId() { + const actions = this.getAllChildren(this.sceneRoot || []); actions.forEach((action, i) => { action.setId(`action-${i}`); @@ -532,73 +476,40 @@ export class Scene extends Constructable< /** * @internal STILL IN DEVELOPMENT */ - assignElementId(story: Story) { - const elements = this.getAllChildrenElements(story, this.sceneRoot || []); + assignElementId() { + const elements = this.getAllChildrenElements(this.sceneRoot || []); elements.forEach((element, i) => { element.setId(`element-${i}`); }); } - /**@internal */ - getVoice(id: string | number | null): string | Sound | null { - if (!id) { - return null; - } - - const voices = this.config.voices; - if (voices) { - if (typeof voices === "function") { - return voices(id); - } - return voices[id] || null; - } - return null; - } - - /**@internal */ - getSceneRoot(): SceneAction<"scene:action"> { - if (!this.sceneRoot) { - throw new Error("Scene root is not constructed"); - } - return this.sceneRoot; - } - /**@internal */ override reset() { this.state = deepMerge(Scene.defaultState, this.config); - this.state.backgroundImageProxy.reset(); + this.state.backgroundImage.reset(); } /**@internal */ toDisplayableTransform(): Transform { - return this.state.backgroundImageProxy.toDisplayableTransform(); - } - - /** - * Manually register an image to preload - */ - public requestImagePreload(src: ImageSrc) { - this.srcManager.register({ - type: "image", - src: new Image({src}), - }); + return this.state.backgroundImage.toDisplayableTransform(); } /**@internal */ - private _createImageProxy(): VirtualImageProxy { - return new VirtualImageProxy({ - opacity: 1, - src: Image.DefaultImagePlaceholder, - }); + private _applyTransition(transition: ITransition): ChainedScene { + return this.chain(new SceneAction<"scene:applyTransition">( + this.chain(), + "scene:applyTransition", + new ContentNode().setContent([transition]) + )); } /**@internal */ - private _jumpTo(scene: Scene | string): ChainedScene { - return this.chain(new SceneAction<"scene:jumpTo">( + private _jumpTo(scene: Scene): ChainedScene { + return this.chain(new SceneAction( this.chain(), "scene:jumpTo", - new ContentNode().setContent([ + new ContentNode<[Scene]>().setContent([ scene ]) )); @@ -614,26 +525,26 @@ export class Scene extends Constructable< } /**@internal */ - private _transitionToScene(transition?: IImageTransition, scene?: Scene | string, src?: ImageSrc | ImageColor): ChainedScene { + private _transitionToScene(scene?: Scene, transition?: IImageTransition, src?: ImageSrc | ImageColor): ChainedScene { const chain = this.chain(); if (transition) { const copy = transition.copy(); - const action = new SceneAction( - chain, - SceneActionTypes["transitionToScene"], - new ContentNode().setContent([copy, scene, src]) - ); - chain.chain(action); + + if (scene && scene.config.background) { + copy.setSrc(scene.config.background); + } + if (src) copy.setSrc(src); + chain._applyTransition(copy); } return chain; } /**@internal */ - private _init(target: Scene | string): SceneAction<"scene:init"> { - return new SceneAction<"scene:init">( - this.chain(), + private _init(target = this): SceneAction<"scene:init"> { + return new SceneAction( + target.chain(), "scene:init", - new ContentNode().setContent([target]) + new ContentNode().setContent([]) ); } } diff --git a/src/game/nlcore/elements/script.ts b/src/game/nlcore/elements/script.ts index eeb32e7..d870aa6 100644 --- a/src/game/nlcore/elements/script.ts +++ b/src/game/nlcore/elements/script.ts @@ -4,7 +4,7 @@ import {LogicAction} from "@core/action/logicAction"; import {Actionable} from "@core/action/actionable"; import {GameState} from "@player/gameState"; import {Chained, Proxied} from "@core/action/chain"; -import type {Storable} from "@core/elements/persistent/storable"; +import type {Storable} from "@core/store/storable"; import {ScriptAction} from "@core/action/actions/scriptAction"; import {LiveGame} from "@core/liveGame"; diff --git a/src/game/nlcore/elements/sound.ts b/src/game/nlcore/elements/sound.ts index a6a5c96..54286e7 100644 --- a/src/game/nlcore/elements/sound.ts +++ b/src/game/nlcore/elements/sound.ts @@ -20,8 +20,6 @@ export enum SoundType { export type SoundDataRaw = { config: SoundConfig; }; -export type VoiceIdMap = Record; -export type VoiceSrcGenerator = (id: string | number) => string | Sound; export type SoundConfig = { /** diff --git a/src/game/nlcore/elements/story.ts b/src/game/nlcore/elements/story.ts index 8c162c0..cc97115 100644 --- a/src/game/nlcore/elements/story.ts +++ b/src/game/nlcore/elements/story.ts @@ -1,12 +1,9 @@ import {Constructable} from "../action/constructable"; import {deepMerge} from "@lib/util/data"; import {Scene} from "@core/elements/scene"; -import {RuntimeScriptError, StaticChecker} from "@core/common/Utils"; +import {StaticChecker} from "@core/common/Utils"; import {RawData} from "@core/action/tree/actionTree"; import {SceneAction} from "@core/action/actions/sceneAction"; -import {LogicAction} from "@core/action/logicAction"; -import {Persistent} from "@core/elements/persistent"; -import {Storable} from "@core/elements/persistent/storable"; /* eslint-disable @typescript-eslint/no-empty-object-type */ export type StoryConfig = {}; @@ -17,21 +14,13 @@ export class Story extends Constructable< SceneAction<"scene:action">, Story > { - /**@internal */ static defaultConfig: StoryConfig = {}; - /**@internal */ - static MAX_DEPTH = 10000; - /**@internal */ readonly name: string; /**@internal */ readonly config: StoryConfig; /**@internal */ entryScene: Scene | null = null; - /**@internal */ - scenes: Map = new Map(); - /**@internal */ - persistent: Persistent[] = []; constructor(name: string, config: StoryConfig = {}) { super(); @@ -53,60 +42,6 @@ export class Story extends Constructable< return this; } - /** - * Register a scene to the story - * @example - * ```typescript - * // register a scene - * const story = new Story("story"); - * const scene1 = new Scene("scene1"); - * const scene2 = new Scene("scene2"); - * - * story.register(scene1); // Register scene1 - * - * scene2.action([ - * scene2.jump("scene1") // Jump to scene1 - * ]); - * ``` - */ - public registerScene(name: string, scene: Scene): this; - public registerScene(scene: Scene): this; - public registerScene(arg0: string | Scene, arg1?: Scene): this { - const name = typeof arg0 === "string" ? arg0 : arg0.name; - const scene = typeof arg0 === "string" ? arg1! : arg0; - - if (this.scenes.has(name) && this.scenes.get(name) !== scene) { - throw new Error(`Scene with name ${name} already exists when registering scene`); - } - this.scenes.set(name, scene); - return this; - } - - /** - * Register a Persistent to the story - * - * You can't use a Persistent that isn't registered to the story - */ - public registerPersistent(persistent: Persistent): this { - this.persistent.push(persistent); - return this; - } - - /** - * @internal - */ - getScene(name: string | Scene, assert: true, error?: (message: string) => Error): Scene; - getScene(name: string | Scene, assert?: false): Scene | null; - getScene(name: string | Scene, assert = false, error?: (message: string) => Error): Scene | null { - if (Scene.isScene(name)) return name; - const scene = this.scenes.get(name) || null; - if (!scene && assert) { - const constructor = error || RuntimeScriptError; - throw Reflect.construct(constructor, [`Scene with name ${name} not found`]); - } - return scene; - } - /**@internal */ constructStory(): this { const scene = this.entryScene; @@ -115,10 +50,9 @@ export class Story extends Constructable< throw new Error("Story must have an entry scene"); } - this.constructSceneRoots(scene); - scene.registerSrc(this); - scene.assignActionId(this); - scene.assignElementId(this); + scene.registerSrc(); + scene.assignActionId(); + scene.assignElementId(); this.runStaticCheck(scene); return this; @@ -126,7 +60,7 @@ export class Story extends Constructable< /**@internal */ getAllElementStates(): RawData[] { - const elements = this.getAllChildrenElements(this, this.entryScene?.getSceneRoot() || []); + const elements = this.getAllChildrenElements(this.entryScene?.sceneRoot || []); return elements .map(e => { return { @@ -137,49 +71,9 @@ export class Story extends Constructable< .filter(e => !!e.data); } - /**@internal */ - constructSceneRoots(entryScene: Scene): this { - const seen = new Set(); - const queue: LogicAction.Actions[] = []; - let depth = 0; - - entryScene.constructSceneRoot(this); - queue.push(entryScene.getSceneRoot()); - - while (queue.length) { - depth++; - if (depth > Story.MAX_DEPTH) { - throw new Error(`Max depth reached while constructing scene roots (max depth: ${Story.MAX_DEPTH})`); - } - - const action = queue.shift()!; - if (Scene.isScene(action.callee)) { - if (seen.has(action.callee)) { - continue; - } - if (!action.callee.isSceneRootConstructed()) { - action.callee.constructSceneRoot(this); - } - seen.add(action.callee); - } - - const children = action.getFutureActions(this); - queue.push(...children); - } - return this; - } - - /**@internal */ - initPersistent(storable: Storable): this { - this.persistent.forEach(persistent => { - persistent.init(storable); - }); - return this; - } - /**@internal */ private runStaticCheck(scene: Scene) { - return new StaticChecker(scene).run(this); + return new StaticChecker(scene).run(); } } diff --git a/src/game/nlcore/elements/displayable/text.ts b/src/game/nlcore/elements/text.ts similarity index 95% rename from src/game/nlcore/elements/displayable/text.ts rename to src/game/nlcore/elements/text.ts index cc20910..552385d 100644 --- a/src/game/nlcore/elements/displayable/text.ts +++ b/src/game/nlcore/elements/text.ts @@ -11,10 +11,9 @@ import {ContentNode} from "@core/action/tree/actionTree"; import {TextActionContentType} from "@core/action/actionTypes"; import {TextAction} from "@core/action/actions/textAction"; import {Scene} from "@core/elements/scene"; -import {ITextTransition} from "@core/elements/transition/type"; +import {ITextTransition, ITransition} from "@core/elements/transition/type"; import {Control} from "@core/elements/control"; import {FontSizeTransition} from "@core/elements/transition/textTransitions/fontSizeTransition"; -import {Displayable, DisplayableEventTypes} from "@core/elements/displayable/displayable"; export type TextConfig = { alignX: "left" | "center" | "right"; @@ -33,16 +32,21 @@ export type TextDataRaw = { export type TextEventTypes = { "event:text.show": [Transform]; "event:text.hide": [Transform]; -} & DisplayableEventTypes; + "event:displayable.applyTransition": [ITransition]; + "event:displayable.applyTransform": [Transform]; + "event:displayable.init": []; +}; export class Text extends Actionable implements EventfulDisplayable { /**@internal */ static EventTypes: { [K in keyof TextEventTypes]: K } = { - ...Displayable.EventTypes, "event:text.show": "event:text.show", "event:text.hide": "event:text.hide", + "event:displayable.applyTransition": "event:displayable.applyTransition", + "event:displayable.applyTransform": "event:displayable.applyTransform", + "event:displayable.init": "event:displayable.init", }; /**@internal */ static defaultConfig: TextConfig = { diff --git a/src/game/nlcore/elements/transform/transform.ts b/src/game/nlcore/elements/transform/transform.ts index 003a2a9..33e18f0 100644 --- a/src/game/nlcore/elements/transform/transform.ts +++ b/src/game/nlcore/elements/transform/transform.ts @@ -9,7 +9,7 @@ import {CSSProps} from "@core/elements/transition/type"; import {Utils} from "@core/common/Utils"; import {animate} from "framer-motion/dom"; import React from "react"; -import {ImageConfig} from "@core/elements/displayable/image"; +import {ImageConfig} from "@core/elements/image"; import Sequence = TransformDefinitions.Sequence; import SequenceProps = TransformDefinitions.SequenceProps; diff --git a/src/game/nlcore/elements/transition/baseTransitions.ts b/src/game/nlcore/elements/transition/baseTransitions.ts index 1a07ee6..6d44056 100644 --- a/src/game/nlcore/elements/transition/baseTransitions.ts +++ b/src/game/nlcore/elements/transition/baseTransitions.ts @@ -3,7 +3,6 @@ import {ElementProp, EventTypes, IImageTransition, ITransition, TransitionEventT import {animate} from "framer-motion/dom"; import type {AnimationPlaybackControls, ValueAnimationTransition} from "framer-motion"; import {ImageColor, ImageSrc} from "@core/types"; -import {TransformDefinitions} from "@core/common/types"; export class BaseTransition implements ITransition { @@ -65,8 +64,6 @@ export class BaseTransition implements ITransition { export class BaseImageTransition extends BaseTransition implements IImageTransition { - static DefaultEasing: TransformDefinitions.EasingDefinition = "linear"; - public setSrc(_src?: ImageSrc | ImageColor): this { throw new Error("Method not implemented."); } diff --git a/src/game/nlcore/elements/transition/imageTransitions/dissolve.ts b/src/game/nlcore/elements/transition/imageTransitions/dissolve.ts index 2ab5a85..f3fb7aa 100644 --- a/src/game/nlcore/elements/transition/imageTransitions/dissolve.ts +++ b/src/game/nlcore/elements/transition/imageTransitions/dissolve.ts @@ -18,7 +18,7 @@ export class Dissolve extends BaseImageTransition implements IIm opacity: 0, }; private src?: ImageSrc | ImageColor; - private readonly easing: TransformDefinitions.EasingDefinition; + private readonly easing: TransformDefinitions.EasingDefinition | undefined; /** * Image will dissolve from one image to another @@ -26,7 +26,7 @@ export class Dissolve extends BaseImageTransition implements IIm constructor(duration: number = 1000, easing?: TransformDefinitions.EasingDefinition) { super(); this.duration = duration; - this.easing = easing || BaseImageTransition.DefaultEasing; + this.easing = easing; } setSrc(src: ImageSrc | ImageColor | undefined): this { diff --git a/src/game/nlcore/elements/transition/imageTransitions/fade.ts b/src/game/nlcore/elements/transition/imageTransitions/fade.ts index 7b7ee1b..e5bcb9b 100644 --- a/src/game/nlcore/elements/transition/imageTransitions/fade.ts +++ b/src/game/nlcore/elements/transition/imageTransitions/fade.ts @@ -12,7 +12,7 @@ export class Fade extends BaseImageTransition implements IImageT opacity: 1, }; private src?: ImageSrc | ImageColor; - private readonly easing: TransformDefinitions.EasingDefinition; + private readonly easing: TransformDefinitions.EasingDefinition | undefined; /** * The current image will fade out, and the next image will fade in @@ -20,7 +20,7 @@ export class Fade extends BaseImageTransition implements IImageT constructor(duration: number = 1000, ease?: TransformDefinitions.EasingDefinition) { super(); this.duration = duration; - this.easing = ease || BaseImageTransition.DefaultEasing; + this.easing = ease; } setSrc(src: ImageSrc | ImageColor | undefined): this { diff --git a/src/game/nlcore/elements/transition/imageTransitions/fadeIn.ts b/src/game/nlcore/elements/transition/imageTransitions/fadeIn.ts index af985d7..e32bbd2 100644 --- a/src/game/nlcore/elements/transition/imageTransitions/fadeIn.ts +++ b/src/game/nlcore/elements/transition/imageTransitions/fadeIn.ts @@ -15,7 +15,7 @@ export class FadeIn extends BaseImageTransition implements IImag transform: "" }; private src?: ImageSrc | ImageColor; - private readonly easing: TransformDefinitions.EasingDefinition; + private readonly easing: TransformDefinitions.EasingDefinition | undefined; /** * The next image will fade-in in a direction @@ -29,7 +29,7 @@ export class FadeIn extends BaseImageTransition implements IImag this.duration = duration; this.direction = direction; this.offset = offset; - this.easing = easing || BaseImageTransition.DefaultEasing; + this.easing = easing; this.__stack = getCallStack(); } diff --git a/src/game/nlcore/elements/type.ts b/src/game/nlcore/elements/type.ts deleted file mode 100644 index 0d6d949..0000000 --- a/src/game/nlcore/elements/type.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {TagGroupDefinition} from "@core/elements/displayable/image"; -import {ScriptCtx} from "@core/elements/script"; - -export type { - TagGroupDefinition, -}; -export type LambdaCtx = ScriptCtx; -export type LambdaHandler = (ctx: LambdaCtx) => T; \ No newline at end of file diff --git a/src/game/nlcore/game.ts b/src/game/nlcore/game.ts index 7491b46..29ea8ff 100644 --- a/src/game/nlcore/game.ts +++ b/src/game/nlcore/game.ts @@ -50,12 +50,6 @@ export class Game { skipKey: ["Control"], skipInterval: 100, ratioUpdateInterval: 50, - preloadDelay: 100, - preloadConcurrency: 5, - waitForPreload: false, - preloadAllImages: true, - forceClearCache: false, - maxPreloadActions: 10, }, elements: { say: { diff --git a/src/game/nlcore/gameTypes.ts b/src/game/nlcore/gameTypes.ts index 1d07d77..d22a4bc 100644 --- a/src/game/nlcore/gameTypes.ts +++ b/src/game/nlcore/gameTypes.ts @@ -2,7 +2,7 @@ import {ContentNode, RawData} from "@core/action/tree/actionTree"; import {LogicAction} from "@core/action/logicAction"; import {ElementStateRaw} from "@core/elements/story"; import {PlayerStateData} from "@player/gameState"; -import {StorableData} from "@core/elements/persistent/type"; +import {StorableData} from "@core/store/type"; import {MenuComponent, SayComponent} from "@player/elements/type"; import React from "react"; @@ -43,24 +43,24 @@ export type GameConfig = { /** * Base width of the player in pixels, Image scale will be calculated based on this value * - * For 16/9, the recommended value is 1920 + * For 16/9, recommended value is 1920 */ width: number; /** * Base height of the player in pixels, Image scale will be calculated based on this value * - * For 16/9, the recommended value is 1080 + * For 16/9, recommended value is 1080 */ height: number; /** - * When the player presses one of these keys, the game will skip the current action + * When player presses one of these keys, the game will skip the current action * * See [Key_Values](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) */ skipKey: React.KeyboardEvent["key"][]; /** * The interval in milliseconds between each skip action. - * ex: 100 ms means the player can skip 10 actions per second. + * ex: 100ms means the player can skip 10 actions per second. * higher value means faster skipping. */ skipInterval: number; @@ -68,32 +68,6 @@ export type GameConfig = { * The interval in milliseconds between each ratio update. */ ratioUpdateInterval: number; - /** - * The game will preload the image with this delay in milliseconds - */ - preloadDelay: number; - /** - * Maximum number of images to preload at the same time - */ - preloadConcurrency: number; - /** - * Wait for the images to load before showing the game - */ - waitForPreload: boolean; - /** - * Preload all possible images in the scene - * - * Enabling this may have a performance impact but is better for the user experience - */ - preloadAllImages: boolean; - /** - * Force the game to clear the cache when the scene changes - */ - forceClearCache: boolean; - /** - * The number of actions will be predicted and preloaded - */ - maxPreloadActions: number; }; elements: { say: { @@ -104,7 +78,7 @@ export type GameConfig = { */ nextKey: React.KeyboardEvent["key"][]; /** - * The speed of the text effects in milliseconds. + * The speed of the text effect in milliseconds. * higher value means slower text effect. * default: 50 */ @@ -124,10 +98,8 @@ export type GameConfig = { img: { /** * If true, the game will show a warning when loading takes longer than `elements.img.slowLoadThreshold` - * @deprecated */ slowLoadWarning: boolean; - /**@deprecated */ slowLoadThreshold: number; /** * If true, when you press [GameConfig.player.skipKey], the game will skip the image transform @@ -163,13 +135,13 @@ export type GameConfig = { /** * Base width of the dialog in pixels * - * For 16/9, the recommended value is 1920 + * For 16/9, recommended value is 1920 */ width: number; /** * Base height of the dialog in pixels * - * For 16/9, the recommended value is 1080 * 0.2 + * For 16/9, recommended value is 1080 * 0.2 */ height: number; } diff --git a/src/game/nlcore/liveGame.ts b/src/game/nlcore/liveGame.ts index 52c6266..32fb24a 100644 --- a/src/game/nlcore/liveGame.ts +++ b/src/game/nlcore/liveGame.ts @@ -2,16 +2,10 @@ import {Awaitable, Lock, MultiLock} from "@lib/util/data"; import type {CalledActionResult, SavedGame} from "@core/gameTypes"; import {Story} from "@core/elements/story"; import {GameState} from "@player/gameState"; -import {Namespace, Storable} from "@core/elements/persistent/storable"; +import {Namespace, Storable} from "@core/store/storable"; import {LogicAction} from "@core/action/logicAction"; -import {StorableType} from "@core/elements/persistent/type"; +import {StorableType} from "@core/store/type"; import {Game} from "@core/game"; -import {ContentNode} from "@core/action/tree/actionTree"; -import {ConditionAction} from "@core/action/actions/conditionAction"; -import {SceneAction} from "@core/action/actions/sceneAction"; -import {ControlActionTypes, SceneActionTypes} from "@core/action/actionTypes"; -import {Scene} from "@core/elements/scene"; -import {ControlAction} from "@core/action/actions/controlAction"; export class LiveGame { static DefaultNamespaces = { @@ -54,9 +48,6 @@ export class LiveGame { this.storable.clear().addNamespace(new Namespace>(LiveGame.GameSpacesKey.game, LiveGame.DefaultNamespaces.game)); - if (this.story) { - this.story.initPersistent(this.storable); - } return this; } @@ -67,8 +58,7 @@ export class LiveGame { /* Game */ /**@internal */ loadStory(story: Story) { - this.story = story - .constructStory(); + this.story = story.constructStory(); return this; } @@ -77,7 +67,7 @@ export class LiveGame { * * 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 + * Note: Even if you change just a single line of script, the saved game might not be compatible with the new version */ public serialize(): SavedGame { if (!this.gameState) { @@ -114,7 +104,7 @@ export class LiveGame { /** * Load a saved game * - * Note: even if you change just a single line of script, 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 */ @@ -144,7 +134,7 @@ export class LiveGame { } = savedGame; // construct maps - story.forEachChild(story, story.entryScene?.getSceneRoot() || [], action => { + story.forEachChild(story.entryScene?.sceneRoot || [], action => { actionMaps.set(action.getId(), action); elementMaps.set(action.callee.getId(), action.callee); }); @@ -187,7 +177,6 @@ export class LiveGame { throw new Error("No game state"); } const gameState = this.gameState; - const logGroup = gameState.logger.group("LiveGame"); this.reset({gameState}); this.initNamespaces(); @@ -197,7 +186,7 @@ export class LiveGame { this.currentSavedGame = newGame; const elements: Map | undefined = - this.story?.getAllElementMap(this.story, this.story?.entryScene?.getSceneRoot() || []); + this.story?.getAllElementMap(this.story?.entryScene?.sceneRoot || []); if (elements) { elements.forEach((element) => { gameState.logger.debug("reset element", element); @@ -209,7 +198,6 @@ export class LiveGame { gameState.stage.forceUpdate(); gameState.stage.next(); - logGroup.end(); return this; } @@ -220,7 +208,7 @@ export class LiveGame { this.lockedAwaiting.abort(); } - this.currentAction = this.story?.entryScene?.getSceneRoot() || null; + this.currentAction = this.story?.entryScene?.sceneRoot || null; this.lockedAwaiting = null; this.currentSavedGame = null; @@ -259,7 +247,7 @@ export class LiveGame { if (this._lockedCount > 1000) { // sometimes react will make it stuck and enter a dead cycle - // that's not cool, so it need to throw an error to break it + // that's not cool, so we need to throw an error to break it throw new Error("LiveGame locked: dead cycle detected\nPlease refresh the page"); } @@ -269,7 +257,7 @@ export class LiveGame { const next = this.lockedAwaiting.result; this.currentAction = next?.node?.action || null; - state.logger.debug("next action (lockedAwaiting)", next); + state.logger.debug("next action", next); this.lockedAwaiting = null; @@ -279,7 +267,7 @@ export class LiveGame { if (!this.currentAction) { state.events.emit(GameState.EventTypes["event:state.end"]); - state.logger.weakWarn("LiveGame", "No current action"); // Congrats, you've reached the end of the story + state.logger.warn("LiveGame", "No current action"); // Congrats, you've reached the end of the story this._nextLock.unlock(); return null; @@ -296,6 +284,7 @@ export class LiveGame { state.logger.debug("next action", nextAction); this._lockedCount = 0; + this.currentAction = nextAction.node?.action || null; this._nextLock.unlock(); @@ -330,54 +319,6 @@ export class LiveGame { return this.gameState; } - /**@internal */ - getAllPredictableActions(story: Story, action?: LogicAction.Actions | null, limit?: number): LogicAction.Actions[] { - let current: ContentNode | null = action?.contentNode || null; - const actions: LogicAction.Actions[] = []; - const queue: LogicAction.Actions[] = []; - const seenScene = new Set(); - - while (current || queue.length) { - if (limit && actions.length >= limit) { - break; - } - if (!current) { - current = queue.pop()!.contentNode; - } - - if ([ConditionAction].some(a => current?.action && current.action instanceof a)) { - current = null; - continue; - } - if (current.action && current.action.is>(SceneAction, SceneActionTypes.jumpTo)) { - const [targetScene] = current.action.contentNode.getContent(); - const scene = story.getScene(targetScene); - if (!scene) { - throw current.action._sceneNotFoundError(current.action.getSceneName(targetScene)); - } - - if (seenScene.has(scene)) { - current = null; - continue; - } - seenScene.add(scene); - - current = scene.getSceneRoot()?.contentNode || null; - continue; - } else if (current.action && - current.action.is>(ControlAction as any, ControlActionTypes.do) - ) { - const [content] = current.action.contentNode.getContent(); - if (current.getChild()?.action) queue.push(current.getChild()!.action!); - current = content[0]?.contentNode || null; - } - if (current.action) actions.push(current.action); - current = current.getChild(); - } - - return actions; - } - /**@internal */ private getNewSavedGame(): SavedGame { return { @@ -392,7 +333,7 @@ export class LiveGame { scenes: [], }, elementStates: [], - currentAction: this.story?.entryScene?.getSceneRoot().getId() || null, + currentAction: this.story?.entryScene?.sceneRoot?.getId() || null, } }; } diff --git a/src/game/nlcore/elements/persistent/storable.ts b/src/game/nlcore/store/storable.ts similarity index 87% rename from src/game/nlcore/elements/persistent/storable.ts rename to src/game/nlcore/store/storable.ts index e892739..c580c55 100644 --- a/src/game/nlcore/elements/persistent/storable.ts +++ b/src/game/nlcore/store/storable.ts @@ -6,9 +6,8 @@ import { StorableData, StorableType, WrappedStorableData -} from "@core/elements/persistent/type"; +} from "@core/store/type"; import {deepMerge} from "@lib/util/data"; -import {RuntimeGameError} from "@core/common/Utils"; export class Namespace> { static isSerializable(value: any): boolean { @@ -44,7 +43,7 @@ export class Namespace> { public set(key: Key, value: T[Key]): this { if (!Namespace.isSerializable(value)) { - console.warn(`Value "${value}" in key "${String(key)}" is not serializable, and will not be set\n at namespace "${this.name}"`); + console.warn(`Value "${value}" in key "${String(key)}" is not serializable, and will not be set\nat namespace "${this.name}"`); this.content[key] = value; return this; } @@ -56,17 +55,6 @@ export class Namespace> { return this.content[key] as T[Key]; } - public equals(key: Key, value: T[Key]): boolean { - return this.content[key] === value; - } - - public assign(values: Partial): this { - Object.entries(values).forEach(([key, value]) => { - this.set(key as keyof T, value as any); - }); - return this; - } - /**@internal */ toData(): { [key: string]: WrappedStorableData } { return this.serialize(); @@ -143,16 +131,13 @@ export class Storable { public addNamespace>(namespace: Namespace) { if (this.namespaces[namespace.key]) { - return; + console.warn(`Namespace ${namespace.key} already exists`); } this.namespaces[namespace.key] = namespace; return this; } public getNamespace = any>(key: string): Namespace { - if (!this.namespaces[key]) { - throw new RuntimeGameError(`Namespace ${key} is not initialized`); - } return this.namespaces[key]; } @@ -161,10 +146,6 @@ export class Storable { return this; } - public hasNamespace(key: string) { - return !!this.namespaces[key]; - } - public getNamespaces() { return this.namespaces; } diff --git a/src/game/nlcore/elements/persistent/type.ts b/src/game/nlcore/store/type.ts similarity index 100% rename from src/game/nlcore/elements/persistent/type.ts rename to src/game/nlcore/store/type.ts diff --git a/src/game/player/elements/Player.tsx b/src/game/player/elements/Player.tsx index cc5bd12..a3258dd 100644 --- a/src/game/player/elements/Player.tsx +++ b/src/game/player/elements/Player.tsx @@ -42,7 +42,7 @@ export default function Player( const [state, dispatch] = useReducer(handleAction, new GameState(game, { update, forceUpdate: () => { - (state as GameState).logger.weakWarn("Player", "force update"); + (state as GameState).logger.warn("Player", "force update"); flushSync(() => { update(); }); @@ -79,10 +79,7 @@ export default function Player( } useEffect(() => { - game.getLiveGame().setGameState(state); - if (story) { - game.getLiveGame().loadStory(story); - } + game.getLiveGame().setGameState(state).loadStory(story); return () => { game.getLiveGame().setGameState(undefined); diff --git a/src/game/player/elements/displayable/Displayable.tsx b/src/game/player/elements/displayable/Displayable.tsx index a19be67..f397282 100644 --- a/src/game/player/elements/displayable/Displayable.tsx +++ b/src/game/player/elements/displayable/Displayable.tsx @@ -150,13 +150,13 @@ export default function Displayable( listener: transition.events.on(TransitionEventTypes.end, () => { setTransition(null); - gameState.logger.debug("transition end", transition); + gameState.logger.debug("scene background transition end", transition); }) }, { type: TransitionEventTypes.start, listener: transition.events.on(TransitionEventTypes.start, () => { - gameState.logger.debug("transition start", transition); + gameState.logger.debug("scene background transition start", transition); }) } ]); diff --git a/src/game/player/elements/displayable/Displayables.tsx b/src/game/player/elements/displayable/Displayables.tsx index 6d931ed..56ec4a4 100644 --- a/src/game/player/elements/displayable/Displayables.tsx +++ b/src/game/player/elements/displayable/Displayables.tsx @@ -1,16 +1,16 @@ import React from "react"; import {GameState} from "@player/gameState"; import {LogicAction} from "@core/action/logicAction"; -import {Text as GameText} from "@core/elements/displayable/text"; +import {Text as GameText} from "@core/elements/text"; import {default as StageText} from "@player/elements/displayable/Text"; -import {Image as GameImage} from "@core/elements/displayable/image"; +import {Image as GameImage} from "@core/elements/image"; import {default as StageImage} from "@player/elements/image/Image"; export default function Displayables( {state, displayable}: Readonly<{ state: GameState; - displayable: LogicAction.DisplayableElements[]; + displayable: LogicAction.Displayable[]; }>) { return (<> {displayable.map((displayable) => { diff --git a/src/game/player/elements/displayable/Text.tsx b/src/game/player/elements/displayable/Text.tsx index 27e390d..e1a3ebf 100644 --- a/src/game/player/elements/displayable/Text.tsx +++ b/src/game/player/elements/displayable/Text.tsx @@ -1,5 +1,5 @@ import {GameState} from "@player/gameState"; -import {Text as GameText} from "@core/elements/displayable/text"; +import {Text as GameText} from "@core/elements/text"; import React from "react"; import {Transform, TransformersMap, TransformHandler} from "@core/elements/transform/transform"; import {SpanElementProp} from "@core/elements/transition/type"; diff --git a/src/game/player/elements/image/AspectScaleImage.tsx b/src/game/player/elements/image/AspectScaleImage.tsx index 224f8c2..3e9ab19 100644 --- a/src/game/player/elements/image/AspectScaleImage.tsx +++ b/src/game/player/elements/image/AspectScaleImage.tsx @@ -1,9 +1,6 @@ import React, {useEffect, useRef} from "react"; import {ImgElementProp} from "@core/elements/transition/type"; import {useRatio} from "@player/provider/ratio"; -import {usePreloaded} from "@player/provider/preloaded"; -import {Image} from "@core/elements/displayable/image"; -import {useGame} from "@core/common/player"; export default function AspectScaleImage( { @@ -21,10 +18,6 @@ export default function AspectScaleImage( const imgRef = useRef(null); const {ratio} = useRatio(); const [width, setWidth] = React.useState(0); - const {cacheManager} = usePreloaded(); - const {game} = useGame(); - - const LogTag = "AspectScaleImage"; function updateWidth() { const ref = Ref || imgRef; @@ -33,23 +26,15 @@ export default function AspectScaleImage( } } - useEffect(() => { - if (props.src && (!cacheManager.has(props.src) || cacheManager.isPreloading(props.src))) { - game.getLiveGame().getGameState()?.logger.warn(LogTag, - `Image not preloaded: "${props.src}". ` - + "\nThis may be caused by complicated image action behavior that cannot be predicted. " - + "\nTo fix this issue, you can manually register the image using scene.requestImagePreload(YourImageSrc). " - ); - } - }, [props, props.src, id]); - useEffect(() => { updateWidth(); return ratio.onUpdate(updateWidth); - }, [props, id]); + }, [props.src]); - const src: string = props.src ? (cacheManager.get(props.src) || props.src) : Image.DefaultImagePlaceholder; + useEffect(() => { + updateWidth(); + }, [props, id]); return ( {props.alt} ); } diff --git a/src/game/player/elements/image/Image.tsx b/src/game/player/elements/image/Image.tsx index 72fb11f..4631510 100644 --- a/src/game/player/elements/image/Image.tsx +++ b/src/game/player/elements/image/Image.tsx @@ -1,8 +1,10 @@ -import {Image as GameImage} from "@core/elements/displayable/image"; +import {Image as GameImage} from "@core/elements/image"; import React, {useEffect, useRef, useState} from "react"; import {GameState} from "@player/gameState"; import {deepMerge} from "@lib/util/data"; +import {Utils} from "@core/common/core"; import {ImgElementProp} from "@core/elements/transition/type"; +import {useGame} from "@player/provider/game-state"; import {DisplayableChildProps} from "@player/elements/displayable/type"; import Displayable from "@player/elements/displayable/Displayable"; import Inspect from "@player/lib/Inspect"; @@ -17,10 +19,29 @@ export default function Image({ image: GameImage; state: GameState; }>) { + const [startTime, setStartTime] = useState(0); + const {game} = useGame(); + + useEffect(() => { + setStartTime(performance.now()); + }, []); + /** * Slow load warning */ const handleLoad = () => { + const endTime = performance.now(); + const loadTime = endTime - startTime; + const threshold = game.config.elements.img.slowLoadThreshold; + + if (loadTime > threshold && game.config.elements.img.slowLoadWarning) { + state.logger.warn( + "NarraLeaf-React", + `Image took ${loadTime}ms to load, which exceeds the threshold of ${threshold}ms. ` + + "Consider enable cache for the image, so Preloader can preload it before it's used. " + + "To disable this warning, set `elements.img.slowLoadWarning` to false in the game config." + ); + } }; return ( @@ -59,7 +80,7 @@ function DisplayableImage( const [wearables, setWearables] = useState([]); const defaultProps: ImgElementProp = { - src: GameImage.getSrc(image.state), + src: Utils.staticImageDataToSrc(image.state.src), style: { ...(state.game.config.app.debug ? { outline: "1px solid red", diff --git a/src/game/player/elements/preload/Preload.tsx b/src/game/player/elements/preload/Preload.tsx index c0bbcc0..c2a1fb5 100644 --- a/src/game/player/elements/preload/Preload.tsx +++ b/src/game/player/elements/preload/Preload.tsx @@ -1,186 +1,122 @@ import {useEffect, useRef} from "react"; import {GameState} from "@player/gameState"; -import {ActiveSrc, SrcManager} from "@core/action/srcManager"; +import {Sound} from "@core/elements/sound"; +import {SrcManager} from "@core/action/srcManager"; import {usePreloaded} from "@player/provider/preloaded"; -import {Preloaded} from "@player/lib/Preloaded"; -import {TaskPool} from "@lib/util/data"; -import {useGame} from "@player/provider/game-state"; - -export function Preload( - { - state, - }: Readonly<{ - state: GameState; - }>) { - const {preloaded, cacheManager} = usePreloaded(); - const {game} = useGame(); - const cachedSrc = useRef>(new Set()); - - const LogTag = "Preload"; +import {Preloaded, PreloadedSrc} from "@player/lib/Preloaded"; +import {Image as GameImage} from "@core/elements/image"; +import {Utils} from "@core/common/Utils"; + +export function Preload({ + state, + }: Readonly<{ + state: GameState; +}>) { + const {preloaded} = usePreloaded(); const lastScene = state.getLastScene(); - const currentAction = game.getLiveGame().getCurrentAction(); - const story = game.getLiveGame().story; - - /** - * preload logic 2.0 - * - * Fetch the images and store them as base64 in the stack - */ + const time = useRef(0); + useEffect(() => { - if (typeof fetch === "undefined") { - preloaded.events.emit(Preloaded.EventTypes["event:preloaded.ready"]); - state.logger.warn(LogTag, "Fetch is not supported in this environment, skipping preload"); - return; - } - if (!game.config.player.preloadAllImages) { - preloaded.events.emit(Preloaded.EventTypes["event:preloaded.ready"]); - state.logger.debug(LogTag, "Preload all images is disabled, skipping preload"); - return; - } - if (game.config.player.forceClearCache) { - cacheManager.clear(); - state.logger.weakWarn(LogTag, "Cache cleared"); - } - if (!story) { - state.logger.weakWarn(LogTag, "Story not found, skipping preload"); + if (typeof window === "undefined") { + console.warn("Window is not supported in this environment"); return; } - const timeStart = performance.now(); - const sceneSrc = SrcManager.catSrc([ - ...(lastScene?.srcManager?.src || []), - ...(lastScene?.srcManager?.getFutureSrc() || []), - ]); - const taskPool = new TaskPool( - game.config.player.preloadConcurrency, - game.config.player.preloadDelay, - ); - const loadedSrc: string[] = []; - const logGroup = state.logger.group(LogTag, true); - - state.logger.debug(LogTag, "preloading:", sceneSrc); - - for (const image of sceneSrc.image) { - const src = SrcManager.getSrc(image); - loadedSrc.push(src); - - if (cacheManager.has(src) || cacheManager.isPreloading(src)) { - state.logger.debug(LogTag, `Image already loaded (${sceneSrc.image.indexOf(image) + 1}/${sceneSrc.image.length})`, src); - continue; - } - taskPool.addTask(() => new Promise(resolve => { - cacheManager.preload(src) - .onFinished(() => { - state.logger.debug(LogTag, `Image loaded (${sceneSrc.image.indexOf(image) + 1}/${sceneSrc.image.length})`, src); - resolve(); - }); - })); + if (window.performance) { + time.current = performance.now(); } - logGroup.end(); - - taskPool.start().then(() => { - state.logger.info(LogTag, "Image preload", `loaded ${cacheManager.size()} images in ${performance.now() - timeStart}ms`); + const currentSceneSrc = state.getLastScene()?.srcManager; + const futureSceneSrc = state.getLastScene()?.srcManager.future || []; + const combinedSrc = [ + ...(currentSceneSrc ? currentSceneSrc.src : []), + ...(futureSceneSrc.map(v => v.src)).flat(2), + ]; + + const src = { + image: new Set(), + audio: new Set(), + video: new Set() + }; - if (game.config.player.waitForPreload) { - preloaded.events.emit(Preloaded.EventTypes["event:preloaded.ready"]); + combinedSrc.forEach(srcItem => { + if (srcItem.type === SrcManager.SrcTypes.image) { + src.image.add(srcItem.src); + } else if (srcItem.type === SrcManager.SrcTypes.audio) { + src.audio.add(srcItem.src); + } else if (srcItem.type === SrcManager.SrcTypes.video) { + src.video.add(srcItem.src); } - state.events.emit(GameState.EventTypes["event:state.preload.loaded"]); - cacheManager.filter(loadedSrc); }); - if (!game.config.player.waitForPreload) { - preloaded.events.emit(Preloaded.EventTypes["event:preloaded.ready"]); - } - preloaded.events.emit(Preloaded.EventTypes["event:preloaded.mount"]); - - return () => { - state.events.emit(GameState.EventTypes["event:state.preload.unmount"]); - state.logger.debug(LogTag, "Preload unmounted"); - }; - }, [lastScene, story]); + state.logger.debug("Preloading", src, futureSceneSrc); - /** - * Remove cached src when scenes changed - */ - useEffect(() => { - cachedSrc.current.clear(); - }, [lastScene]); - - /** - * predict preload logic - * - * Get future src and preload them - */ - useEffect(() => { - if (typeof fetch === "undefined") { - return; - } - if (game.config.player.preloadAllImages) { - return; - } - if (!story) { - state.logger.weakWarn(LogTag, "Story not found, skipping preload"); - return; - } - - const timeStart = performance.now(); - const allSrc: ActiveSrc[] = game - .getLiveGame() - .getAllPredictableActions(story, currentAction, game.config.player.maxPreloadActions) - .map(s => SrcManager.getPreloadableSrc(story, s)) - .filter(function (src): src is ActiveSrc { - return src !== null; - }); - const sceneBasedSrc = - allSrc.filter(function (src): src is ActiveSrc<"scene"> { - return src?.activeType === "scene"; - }); - sceneBasedSrc.forEach(src => { - if (cachedSrc.current.has(src)) { - return; + preloaded.preloaded = preloaded.preloaded.filter(p => { + if (p.type === SrcManager.SrcTypes.audio) { + let has = src[p.type].has((p as PreloadedSrc<"audio">).src); + if (!has) { + // downgraded check + has = Array.from(src[p.type]).some(s => { + return preloaded.getSrc(p) === preloaded.getSrc(s.config.src); + }); + } + return has; + } else if (p.type === SrcManager.SrcTypes.image) { + return src[p.type].has((p as PreloadedSrc<"image">).src); } - cachedSrc.current.add(src); + const preloadedSrcP = preloaded.getSrc(p); + return src[p.type].has(preloadedSrcP); }); - const actionSrc = SrcManager.catSrc([ - ...cachedSrc.current, - ...allSrc, - ]); - - const taskPool = new TaskPool( - game.config.player.preloadConcurrency, - game.config.player.preloadDelay, - ); - const preloadSrc: string[] = []; - const logGroup = state.logger.group(LogTag); + const newImages: HTMLImageElement[] = []; + const promises: Promise[] = []; + src.image.forEach((src: GameImage) => { + const htmlImg = new Image(); + htmlImg.src = Utils.srcToString(src.state.src); + htmlImg.onload = () => { + state.logger.debug("Image loaded", src.state.src); + }; + newImages.push(htmlImg); + + preloaded.add({type: "image", src}); + }); - state.logger.debug(LogTag, "preloading:", actionSrc); + 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"]); - for (const image of actionSrc.image) { - const src = SrcManager.getSrc(image); - preloadSrc.push(src); + if (window.performance) { + const endTime = performance.now(); + const loadTime = endTime - time.current; + state.logger.info("Preload", `Preloaded ${src.image.size} images in ${loadTime}ms`); + } + }); - if (cacheManager.has(src) || cacheManager.isPreloading(src)) { - state.logger.debug(LogTag, `Image already loaded (${actionSrc.image.indexOf(image) + 1}/${actionSrc.image.length})`, src); - continue; + src.audio.forEach((src: Sound) => { + if (!src.getPlaying()) { + src.setPlaying(new (state.getHowl())({ + src: src.config.src, + loop: src.config.loop, + volume: src.config.volume, + autoplay: false, + preload: true, + })); } - taskPool.addTask(() => new Promise(resolve => { - cacheManager.preload(src) - .onFinished(() => { - state.logger.debug(LogTag, `Image loaded (${actionSrc.image.indexOf(image) + 1}/${actionSrc.image.length})`, src); - resolve(); - }); - })); - } + }); - logGroup.end(); + preloaded.events.emit(Preloaded.EventTypes["event:preloaded.mount"]); - taskPool.start().then(() => { - state.logger.info(LogTag, "Image preload (quick reload)", `loaded ${cacheManager.size()} images in ${performance.now() - timeStart}ms`); - cacheManager.filter(preloadSrc); - }); - }, [currentAction, story]); + // maybe video preload here + + return () => { + newImages.forEach(img => { + img.onload = null; + }); + state.events.emit(GameState.EventTypes["event:state.preload.unmount"]); + state.logger.debug("Preload unmounted"); + }; + }, [lastScene]); return null; } diff --git a/src/game/player/elements/scene/BackgroundTransition.tsx b/src/game/player/elements/scene/BackgroundTransition.tsx index a24f3e0..5051b10 100644 --- a/src/game/player/elements/scene/BackgroundTransition.tsx +++ b/src/game/player/elements/scene/BackgroundTransition.tsx @@ -9,7 +9,6 @@ import {DisplayableChildProps} from "@player/elements/displayable/type"; import {m} from "framer-motion"; import Displayable from "@player/elements/displayable/Displayable"; import {useRatio} from "@player/provider/ratio"; -import {usePreloaded} from "@player/provider/preloaded"; export default function BackgroundTransition({scene, props, state}: { scene: GameScene, @@ -48,7 +47,6 @@ function DisplayableBackground( }> ) { const {ratio} = useRatio(); - const {cacheManager} = usePreloaded(); const [imageLoaded, setImageLoaded] = React.useState(false); function handleImageOnload() { @@ -71,13 +69,6 @@ function DisplayableBackground( } }; - function tryGetCache(src: string | undefined): string { - if (src) { - return cacheManager.has(src) ? cacheManager.get(src)! : src; - } - return emptyImage; - } - return (
- {(transition ? transition.toElementProps() : [{}]).map((elementProps, index) => { - const mergedProps = - deepMerge(defaultProps, props, elementProps); - return ( - {mergedProps.alt} - ); - })} + { + transition ? (() => { + return transition.toElementProps().map((elementProps, index) => { + const mergedProps = + deepMerge(defaultProps, props, elementProps); + return ( + {mergedProps.alt} + ); + }); + })() : (() => { + const mergedProps = + deepMerge(defaultProps, props); + return ( + {mergedProps.alt} + ); + })() + }
); diff --git a/src/game/player/elements/type.ts b/src/game/player/elements/type.ts index 0313b28..6f17d92 100644 --- a/src/game/player/elements/type.ts +++ b/src/game/player/elements/type.ts @@ -5,7 +5,7 @@ import {Story} from "@core/elements/story"; import clsx from "clsx"; import {Game} from "@core/game"; import {GameState} from "@player/gameState"; -import {Storable} from "@core/elements/persistent/storable"; +import {Storable} from "@core/store/storable"; import {LiveGame} from "@core/liveGame"; export type Components> = (props: Readonly) => React.JSX.Element; diff --git a/src/game/player/gameState.ts b/src/game/player/gameState.ts index 50f8e89..e03ff9b 100644 --- a/src/game/player/gameState.ts +++ b/src/game/player/gameState.ts @@ -1,27 +1,24 @@ import {CalledActionResult} from "@core/gameTypes"; -import {EventDispatcher, moveElementInArray, sleep} from "@lib/util/data"; +import {EventDispatcher, Logger, sleep} from "@lib/util/data"; import {Choice, MenuData} from "@core/elements/menu"; -import {Image, ImageEventTypes} from "@core/elements/displayable/image"; +import {Image, ImageEventTypes} from "@core/elements/image"; import {Scene} from "@core/elements/scene"; import {Sound} from "@core/elements/sound"; import * as Howler from "howler"; import {HowlOptions} from "howler"; import {SrcManager} from "@core/action/srcManager"; import {LogicAction} from "@core/action/logicAction"; -import {Storable} from "@core/elements/persistent/storable"; +import {Storable} from "@core/store/storable"; import {Game} from "@core/game"; import {Clickable, MenuElement, TextElement} from "@player/gameState.type"; import {Sentence} from "@core/elements/character/sentence"; import {SceneAction} from "@core/action/actions/sceneAction"; -import {Text, TextEventTypes} from "@core/elements/displayable/text"; -import {Logger} from "@lib/util/logger"; -import {RuntimeGameError} from "@core/common/Utils"; -import {Story} from "@core/elements/story"; +import {Text, TextEventTypes} from "@core/elements/text"; type PlayerStateElement = { texts: Clickable[]; menus: Clickable[]; - displayable: LogicAction.DisplayableElements[]; + displayable: LogicAction.Displayable[]; }; export type PlayerState = { sounds: Sound[]; @@ -86,7 +83,7 @@ export class GameState { return this.state.elements.find(e => e.scene === scene) || null; } - public findElementByDisplayable(displayable: LogicAction.DisplayableElements): { + public findElementByDisplayable(displayable: LogicAction.Displayable): { scene: Scene, ele: PlayerStateElement } | null { @@ -134,54 +131,6 @@ export class GameState { return false; } - public moveUpElement(scene: Scene, element: LogicAction.DisplayableElements): this { - const targetElement = this.findElementByScene(scene); - if (!targetElement) return this; - - targetElement.ele.displayable = moveElementInArray( - targetElement.ele.displayable, - element, - Math.min(targetElement.ele.displayable.indexOf(element) + 1, targetElement.ele.displayable.length - 1) - ); - return this; - } - - public moveDownElement(scene: Scene, element: LogicAction.DisplayableElements): this { - const targetElement = this.findElementByScene(scene); - if (!targetElement) return this; - - targetElement.ele.displayable = moveElementInArray( - targetElement.ele.displayable, - element, - Math.max(targetElement.ele.displayable.indexOf(element) - 1, 0) - ); - return this; - } - - public moveTopElement(scene: Scene, element: LogicAction.DisplayableElements): this { - const targetElement = this.findElementByScene(scene); - if (!targetElement) return this; - - targetElement.ele.displayable = moveElementInArray( - targetElement.ele.displayable, - element, - targetElement.ele.displayable.length - 1 - ); - return this; - } - - public moveBottomElement(scene: Scene, element: LogicAction.DisplayableElements): this { - const targetElement = this.findElementByScene(scene); - if (!targetElement) return this; - - targetElement.ele.displayable = moveElementInArray( - targetElement.ele.displayable, - element, - 0 - ); - return this; - } - handle(action: PlayerAction): this { if (this.currentHandling === action) return this; this.currentHandling = action; @@ -194,7 +143,7 @@ export class GameState { } public createText(id: string, sentence: Sentence, afterClick?: () => void, scene?: Scene) { - const texts = this.findElementByScene(this.getLastSceneIfNot(scene))?.ele.texts; + const texts = this.findElementByScene(this._getLastSceneIfNot(scene))?.ele.texts; if (!texts) { throw new Error("Scene not found"); } @@ -214,7 +163,7 @@ export class GameState { if (!menu.choices.length) { throw new Error("Menu must have at least one choice"); } - const menus = this.findElementByScene(this.getLastSceneIfNot(scene))?.ele.menus; + const menus = this.findElementByScene(this._getLastSceneIfNot(scene))?.ele.menus; if (!menus) { throw new Error("Scene not found"); } @@ -227,7 +176,7 @@ export class GameState { } public createImage(image: Image, scene?: Scene) { - const targetScene = this.getLastSceneIfNot(scene); + const targetScene = this._getLastSceneIfNot(scene); const targetElement = this.findElementByScene(targetScene); if (!targetElement) return this; targetElement.ele.displayable.push(image); @@ -239,7 +188,7 @@ export class GameState { } public disposeImage(image: Image, scene?: Scene) { - const targetScene = this.getLastSceneIfNot(scene); + const targetScene = this._getLastSceneIfNot(scene); const images = this.findElementByScene(targetScene)?.ele.displayable; if (!images) { throw new Error("Scene not found"); @@ -253,16 +202,16 @@ export class GameState { return this; } - public createDisplayable(displayable: LogicAction.DisplayableElements, scene?: Scene) { - const targetScene = this.getLastSceneIfNot(scene); + public createDisplayable(displayable: LogicAction.Displayable, scene?: Scene) { + const targetScene = this._getLastSceneIfNot(scene); const targetElement = this.findElementByScene(targetScene); if (!targetElement) return this; targetElement.ele.displayable.push(displayable); return this; } - public disposeDisplayable(displayable: LogicAction.DisplayableElements, scene?: Scene) { - const targetScene = this.getLastSceneIfNot(scene); + public disposeDisplayable(displayable: LogicAction.Displayable, scene?: Scene) { + const targetScene = this._getLastSceneIfNot(scene); const displayables = this.findElementByScene(targetScene)?.ele.displayable; if (!displayables) { throw new Error("Scene not found"); @@ -380,17 +329,6 @@ export class GameState { return this.game.getLiveGame().getStorable(); } - public getSceneByName(name: string): Scene | null { - return this.game.getLiveGame().story?.getScene(name) || null; - } - - public getStory(): Story { - if (!this.game.getLiveGame().story) { - throw new RuntimeGameError("Story not loaded"); - } - return this.game.getLiveGame().story!; - } - /** * Dispose the game state * @@ -429,15 +367,15 @@ export class GameState { const scene = elementMap.get(sceneId) as Scene; if (!scene) { - throw new RuntimeGameError("Scene not found, id: " + sceneId + "\nNarraLeaf cannot find the element with the id from the saved game"); + throw new Error("Scene not found, id: " + sceneId + "\nNarraLeaf cannot find the element with the id from the saved game"); } const displayable = elements.displayable.map(d => { if (!elementMap.has(d)) { - throw new RuntimeGameError("Displayable not found, id: " + d + "\nNarraLeaf cannot find the element with the id from the saved game" + + throw new Error("Displayable not found, id: " + d + "\nNarraLeaf cannot find the element with the id from the saved game" + "\nThis may be caused by the damage of the saved game file or the change of the story file"); } - return elementMap.get(d) as LogicAction.DisplayableElements; + return elementMap.get(d) as LogicAction.Displayable; }); const element: { scene: Scene; ele: PlayerStateElement; } = { scene, @@ -460,14 +398,6 @@ export class GameState { }); } - public getLastSceneIfNot(scene: Scene | null | void) { - const targetScene = scene || this.getLastScene(); - if (!targetScene || !this.sceneExists(targetScene)) { - throw new RuntimeGameError("Scene not found, please call \"scene.activate()\" first."); - } - return targetScene; - } - private getElementMap(): PlayerStateElement { return { texts: [], @@ -483,6 +413,14 @@ export class GameState { return this; } + private _getLastSceneIfNot(scene: Scene | null | void) { + const targetScene = scene || this.getLastScene(); + if (!targetScene || !this.sceneExists(targetScene)) { + throw new Error("Scene not found, please call \"scene.activate()\" first."); + } + return targetScene; + } + private anyEvent(type: any, target: any, onEnd: () => void, ...args: any[]) { (target.events as EventDispatcher).any( type, diff --git a/src/game/player/lib/ImageCacheManager.ts b/src/game/player/lib/ImageCacheManager.ts deleted file mode 100644 index 67db649..0000000 --- a/src/game/player/lib/ImageCacheManager.ts +++ /dev/null @@ -1,114 +0,0 @@ -import {getImageDataUrl} from "@lib/util/data"; - -type ImageCacheTask = { - promise: Promise; - controller: AbortController; -}; -export type PreloadedToken = { - abort: () => void; - onFinished: (callback: () => void) => void; -}; - -export class ImageCacheManager { - public static getImage(src: string, abortSignal?: AbortSignal): Promise { - return getImageDataUrl(src, { - signal: abortSignal, - }); - } - - private src: Map = new Map(); - private preloadTasks: Map = new Map(); - - public has(name: string): boolean { - return this.src.has(name); - } - - public add(name: string, src: string): this { - this.src.set(name, src); - return this; - } - - public remove(name: string): this { - this.src.delete(name); - return this; - } - - public get(name: string): string | undefined { - return this.src.get(name); - } - - public clear(): this { - this.src.clear(); - return this; - } - - public size(): number { - return this.src.size; - } - - public isPreloading(src: string): boolean { - return this.preloadTasks.has(src); - } - - public preload(url: string): PreloadedToken { - if (this.src.has(url) || this.preloadTasks.has(url)) return { - abort: () => { - }, - onFinished: () => { - } - }; - - const controller = new AbortController(); - const signal = controller.signal; - - const task: ImageCacheTask = { - promise: ImageCacheManager.getImage(url, signal), - controller, - }; - this.preloadTasks.set(url, task); - task.promise.then((dataUrl) => { - this.preloadTasks.delete(url); - if (dataUrl) { - this.add(url, dataUrl); - } - }); - - return { - abort: () => { - controller.abort(); - this.preloadTasks.delete(url); - }, - onFinished: (callback: () => void) => { - task.promise.then(callback); - } - }; - } - - public abortAll(): void { - this.preloadTasks.forEach(task => { - task.controller.abort(); - }); - this.preloadTasks.clear(); - } - - public abort(src: string): void { - const task = this.preloadTasks.get(src); - if (task) { - task.controller.abort(); - this.preloadTasks.delete(src); - } - } - - public preloadedSrc(): string[] { - return Array.from(this.src.values()); - } - - public filter(names: string[]): this { - for (const name of this.src.keys()) { - if (!names.includes(name)) { - this.src.delete(name); - } - } - return this; - } -} \ No newline at end of file diff --git a/src/game/player/lib/Preloaded.ts b/src/game/player/lib/Preloaded.ts index 875df5e..8a80770 100644 --- a/src/game/player/lib/Preloaded.ts +++ b/src/game/player/lib/Preloaded.ts @@ -1,7 +1,8 @@ import {Sound} from "@core/elements/sound"; -import {Src, SrcManager} from "@core/action/srcManager"; +import {Src} from "@core/action/srcManager"; import {EventDispatcher} from "@lib/util/data"; -import {Image} from "@core/elements/displayable/image"; +import {Image} from "@core/elements/image"; +import {Utils} from "@core/common/Utils"; export type PreloadedSrcTypes = "image" | "audio" | "video"; export type PreloadedSrc = ({ @@ -82,7 +83,16 @@ export class Preloaded { } getSrc(src: Src | string): string { - return SrcManager.getSrc(src); + if (typeof src === "string") { + return src; + } + if (src.type === "image") { + return Utils.srcToString(src.src.state.src); + } else if (src.type === "video") { + return src.src; + } else if (src.type === "audio") { + return src.src.getSrc(); + } + return ""; } } - diff --git a/src/game/player/provider/preloaded.tsx b/src/game/player/provider/preloaded.tsx index f26bbaa..56b9760 100644 --- a/src/game/player/provider/preloaded.tsx +++ b/src/game/player/provider/preloaded.tsx @@ -2,11 +2,9 @@ import React, {createContext, useContext, useState} from "react"; import {Preloaded} from "@player/lib/Preloaded"; -import {ImageCacheManager} from "@player/lib/ImageCacheManager"; type PreloadedContextType = { preloaded: Preloaded; - cacheManager: ImageCacheManager; }; const Context = createContext(null); @@ -15,11 +13,10 @@ export function PreloadedProvider({children}: { children: React.ReactNode }) { const [preloaded] = useState(new Preloaded()); - const [cacheManager] = useState(new ImageCacheManager()); return ( <> - + {children} diff --git a/src/util/data.ts b/src/util/data.ts index afa2af6..53383ae 100644 --- a/src/util/data.ts +++ b/src/util/data.ts @@ -1,66 +1,6 @@ +import type {Game} from "@core/game"; import {HexColor} from "@core/types"; -interface ITypeOf { - DataTypes: typeof DataTypes; - call: typeof TypeOf; - - (value: any): DataTypes; -} - -export enum DataTypes { - "string", - "number", - "boolean", - "object", - "array", - "function", - "symbol", - "undefined", - "null", - "date", - "regexp", - "other", -} - -export const TypeOf = (function (value: any): DataTypes { - if (typeof value === "string") { - return DataTypes.string; - } - if (typeof value === "number") { - return DataTypes.number; - } - if (typeof value === "boolean") { - return DataTypes.boolean; - } - if (typeof value === "object") { - if (Array.isArray(value)) { - return DataTypes.array; - } - if (value === null) { - return DataTypes.null; - } - if (value instanceof Date) { - return DataTypes.date; - } - if (value instanceof RegExp) { - return DataTypes.regexp; - } - return DataTypes.object; - } - if (typeof value === "function") { - return DataTypes.function; - } - if (typeof value === "symbol") { - return DataTypes.symbol; - } - if (typeof value === "undefined") { - return DataTypes.undefined; - } - return DataTypes.other; -}) as unknown as ITypeOf; - -TypeOf.DataTypes = DataTypes; - /** * @param obj1 source object * @param obj2 this object will overwrite the source object @@ -73,7 +13,8 @@ export function deepMerge>(obj1: Record, ob const result: Record = {}; const mergeValue = (_: string, value1: any, value2: any) => { - if (TypeOf(value1) === DataTypes.object && TypeOf(value2) === DataTypes.object) { + if (typeof value1 === "object" && value1 !== null && !Array.isArray(value1) && + typeof value2 === "object" && value2 !== null && !Array.isArray(value2)) { if (value1.constructor !== Object || value2.constructor !== Object) { return value2 || value1; } @@ -106,8 +47,6 @@ export function deepMerge>(obj1: Record, ob if (typeof obj2[key] === "object" && obj2[key] !== null) { if (obj2[key].constructor === Object) { result[key] = deepMerge({}, obj2[key]); - } else if (Array.isArray(obj2[key])) { - result[key] = [...obj2[key]]; } else { result[key] = obj2[key]; } @@ -380,6 +319,60 @@ export function deepEqual(obj1: any, obj2: any): boolean { return true; } +export class Logger { + private game: Game; + private readonly prefix: string | undefined; + + constructor(game: Game, prefix?: string) { + this.game = game; + this.prefix = prefix; + } + + log(tag: string, ...args: any[]) { + if (this.game.config.app.logger.log) { + console.log(...this._log(tag, ...args)); + } + } + + info(tag: string, ...args: any[]) { + if (this.game.config.app.logger.info) { + console.info(...this._log(tag, ...args)); + } + } + + warn(tag: string, ...args: any[]) { + if (this.game.config.app.logger.warn) { + console.warn(...this._log(tag, ...args)); + } + } + + error(tag: string, ...args: any[]) { + if (this.game.config.app.logger.error) { + console.error(...this._log(tag, ...args)); + } + } + + debug(tag: string, ...args: any[]) { + if (this.game.config.app.logger.debug) { + console.debug(...this._log(tag, ...args)); + } + } + + trace(tag: string, ...args: any[]) { + if (this.game.config.app.logger.trace) { + console.trace(this._log(tag, ...args)); + } + } + + private _log(tag: string, ...args: any[]) { + if (args.length === 0) { + return [this.prefix || "", tag]; + } else { + return [`${this.prefix || ""} [${tag}]`, ...args]; + } + } +} + type SkipControllerEvents = { "event:skipController.abort": []; } @@ -593,107 +586,3 @@ export function crossCombine(a: T[], b: U[]): (T | U)[] { } return result; } - -export type SelectElementFromEach = - T extends [infer First, ...infer Rest] - ? First extends string[] - ? Rest extends string[][] - ? { - [K in First[number]]: [K, ...SelectElementFromEach>]; - }[First[number]] - : [] - : [] - : []; -export type ExcludeEach = - T extends [infer First, ...infer Rest] - ? First extends string[] - ? Rest extends string[][] - ? [[Exclude], ...ExcludeEach] - : [] - : [] - : []; -export type FlexibleTuple = - T extends [infer First, ...infer Rest] - ? Rest extends any[] - ? [First, ...FlexibleTuple] | FlexibleTuple - : [First] - : []; - -export function moveElement(arr: T[], element: T, direction: "up" | "down" | "top" | "bottom"): T[] { - const index = arr.indexOf(element); - if (index === -1) return arr; - - const result = [...arr]; - result.splice(index, 1); - - switch (direction) { - case "up": - result.splice(Math.max(index - 1, 0), 0, element); - break; - case "down": - result.splice(Math.min(index + 1, arr.length), 0, element); - break; - case "top": - result.unshift(element); - break; - case "bottom": - result.push(element); - break; - } - - return result; -} - -export function moveElementInArray(arr: T[], element: T, newIndex: number): T[] { - const index = arr.indexOf(element); - if (index === -1) return arr; - - const result = [...arr]; - result.splice(index, 1); - result.splice(newIndex, 0, element); - - return result; -} - -export async function getImageDataUrl(src: string, options?: RequestInit): Promise { - const response = await fetch(src, options); - const blob = await response.blob(); - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => { - resolve(reader.result as string); - }; - reader.readAsDataURL(blob); - }); -} - -export class TaskPool { - private tasks: (() => Promise)[] = []; - - constructor(private readonly concurrency: number, private readonly delay: number) {} - - addTask(task: () => Promise) { - this.tasks.push(task); - } - - async start(): Promise { - const run = async () => { - if (this.tasks.length === 0) { - return; - } - const tasks = this.tasks.splice(0, this.concurrency); - await Promise.all(tasks.map(task => task())); - await sleep(this.delay); - await run(); - }; - await run(); - } -} - -export type StringKeyOf = Extract; -export type ValuesWithin = { - [K in keyof T]: T[K] extends U ? K : never; -}[keyof T]; -export type BooleanKeys = { - [K in keyof T]: T[K] extends boolean ? K : never; -}[keyof T]; diff --git a/src/util/logger.ts b/src/util/logger.ts deleted file mode 100644 index c904d82..0000000 --- a/src/util/logger.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type {Game} from "@core/game"; -import React from "react"; - -export class Logger { - private game: Game; - private readonly prefix: string | undefined; - - constructor(game: Game, prefix?: string) { - this.game = game; - this.prefix = prefix; - } - - log(tag: string, ...args: any[]) { - if (this.game.config.app.logger.log) { - console.log(...this.colorLog("gray", tag, ...args)); - } - } - - info(tag: string, ...args: any[]) { - if (this.game.config.app.logger.info) { - console.info(...this._log(tag, ...args)); - } - } - - warn(tag: string, ...args: any[]) { - if (this.game.config.app.logger.warn) { - console.warn(...this._log(tag, ...args)); - } - } - - error(tag: string, ...args: any[]) { - if (this.game.config.app.logger.error) { - console.error(...this._log(tag, ...args)); - } - } - - debug(tag: string, ...args: any[]) { - if (this.game.config.app.logger.debug) { - console.debug(...this.colorLog("gray", tag, ...args)); - } - } - - trace(tag: string, ...args: any[]) { - if (this.game.config.app.logger.trace) { - console.trace(this._log(tag, ...args)); - } - } - - weakWarn(tag: string, ...args: any[]) { - if (this.game.config.app.logger.warn) { - console.log(...this.colorLog("yellow", tag, ...args)); - } - } - - group(tag: string, collapsed = false) { - const groupTag = this._log(tag).join(" "); - if (this.game.config.app.logger.info) { - if (collapsed) { - console.groupCollapsed(groupTag); - } else { - console.group(groupTag); - } - } - return { - end: () => { - if (this.game.config.app.logger.info) { - console.groupEnd(); - } - } - }; - } - - private _log(tag: string, ...args: any[]) { - if (args.length === 0) { - return [this.prefix || "", tag]; - } else { - return [`${this.prefix || ""} [${tag}]`, ...args]; - } - } - - private colorLog(color: React.CSSProperties["color"], tag: string, ...args: any[]) { - if (args.length === 0) { - return [`%c${this.prefix || ""} ${tag}`, `color: ${color}`]; - } - const messages: string[] = []; - const styles: string[] = []; - const logArgs: any[] = []; - - if (this.prefix) { - messages.push(`%c${this.prefix} [${tag}]`); - styles.push(`color: ${color}`); - } else { - messages.push(`%c[${tag}]`); - styles.push(`color: ${color}`); - } - - args.forEach(arg => { - if (typeof arg === "string") { - messages.push(`%c${arg}`); - styles.push(`color: ${color}`); - } else { - messages.push("%O"); - logArgs.push(arg); - styles.push(""); - } - }); - - return [messages.join(" ")].concat(styles, logArgs); - } -} \ No newline at end of file From a2d466817449a5a52b1a580f469f05a174acbd92 Mon Sep 17 00:00:00 2001 From: Nomen <111544765+helloyork@users.noreply.github.com> Date: Fri, 29 Nov 2024 19:36:27 -0800 Subject: [PATCH 2/2] Revert "Revert "[prod]narraleaf-react-0.2.0"" --- CHANGELOG.md | 35 +- eslint.config.mjs | 1 + package.json | 2 +- src/game/nlcore/action/action.ts | 13 +- src/game/nlcore/action/actionTypes.ts | 56 ++- src/game/nlcore/action/actionable.ts | 9 +- src/game/nlcore/action/actions.ts | 6 +- .../nlcore/action/actions/characterAction.ts | 18 +- .../nlcore/action/actions/conditionAction.ts | 5 +- .../nlcore/action/actions/controlAction.ts | 5 +- .../action/actions/displayableAction.ts | 41 +++ src/game/nlcore/action/actions/imageAction.ts | 53 ++- src/game/nlcore/action/actions/menuAction.ts | 5 +- .../nlcore/action/actions/persistentAction.ts | 23 ++ src/game/nlcore/action/actions/sceneAction.ts | 111 ++++-- src/game/nlcore/action/actions/soundAction.ts | 2 +- src/game/nlcore/action/actions/textAction.ts | 4 +- src/game/nlcore/action/constructable.ts | 22 +- src/game/nlcore/action/logicAction.ts | 60 +++- src/game/nlcore/action/srcManager.ts | 143 +++++++- src/game/nlcore/action/tree/actionTree.ts | 4 + src/game/nlcore/common/Utils.ts | 62 +++- src/game/nlcore/common/elements.ts | 54 ++- src/game/nlcore/common/game.ts | 2 +- src/game/nlcore/common/types.ts | 1 + .../nlcore/elements/character/sentence.ts | 2 + src/game/nlcore/elements/condition.ts | 110 +++--- .../elements/displayable/displayable.ts | 81 +++++ .../elements/{ => displayable}/image.ts | 340 +++++++++++++++--- .../nlcore/elements/{ => displayable}/text.ts | 12 +- src/game/nlcore/elements/menu.ts | 3 +- src/game/nlcore/elements/persistent.ts | 145 ++++++++ .../persistent}/storable.ts | 25 +- .../{store => elements/persistent}/type.ts | 0 src/game/nlcore/elements/scene.ts | 275 +++++++++----- src/game/nlcore/elements/script.ts | 2 +- src/game/nlcore/elements/sound.ts | 2 + src/game/nlcore/elements/story.ts | 118 +++++- .../nlcore/elements/transform/transform.ts | 2 +- .../elements/transition/baseTransitions.ts | 3 + .../transition/imageTransitions/dissolve.ts | 4 +- .../transition/imageTransitions/fade.ts | 4 +- .../transition/imageTransitions/fadeIn.ts | 4 +- src/game/nlcore/elements/type.ts | 8 + src/game/nlcore/game.ts | 6 + src/game/nlcore/gameTypes.ts | 44 ++- src/game/nlcore/liveGame.ts | 85 ++++- src/game/player/elements/Player.tsx | 7 +- .../elements/displayable/Displayable.tsx | 4 +- .../elements/displayable/Displayables.tsx | 6 +- src/game/player/elements/displayable/Text.tsx | 2 +- .../elements/image/AspectScaleImage.tsx | 24 +- src/game/player/elements/image/Image.tsx | 25 +- src/game/player/elements/preload/Preload.tsx | 254 ++++++++----- .../elements/scene/BackgroundTransition.tsx | 54 ++- src/game/player/elements/type.ts | 2 +- src/game/player/gameState.ts | 112 ++++-- src/game/player/lib/ImageCacheManager.ts | 114 ++++++ src/game/player/lib/Preloaded.ts | 18 +- src/game/player/provider/preloaded.tsx | 5 +- src/util/data.ts | 225 +++++++++--- src/util/logger.ts | 110 ++++++ 62 files changed, 2328 insertions(+), 646 deletions(-) create mode 100644 src/game/nlcore/action/actions/displayableAction.ts create mode 100644 src/game/nlcore/action/actions/persistentAction.ts create mode 100644 src/game/nlcore/elements/displayable/displayable.ts rename src/game/nlcore/elements/{ => displayable}/image.ts (54%) rename src/game/nlcore/elements/{ => displayable}/text.ts (95%) create mode 100644 src/game/nlcore/elements/persistent.ts rename src/game/nlcore/{store => elements/persistent}/storable.ts (87%) rename src/game/nlcore/{store => elements/persistent}/type.ts (100%) create mode 100644 src/game/nlcore/elements/type.ts create mode 100644 src/game/player/lib/ImageCacheManager.ts create mode 100644 src/util/logger.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 370eda1..693eb41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,39 @@ # Changelog -## [0.1.7] +## [0.2.0] - 2024/11/29 + +### _Feature_ + +- Assign voice using generator or voice map +- Use `image.tag` to manage image src +- Use displayable actions to reorder layers +- Better image preloading +- Scene config inheritance +- Use the scene name to jump between two cross-referenced scenes +- Use `Persistent` to manage persistent data + +### _Incompatible Changes_ + +- Image constructor signature has changed. Now the first argument must be a config object. + +### Added + +- Voice map generator +- Image tag src management +- Displayable actions +- Layer actions +- Disable image auto initialize using image.config +- Quick image preloading only preloads images when needed +- Use `scene.inherit` to inherit scene config +- Use the scene name to jump between two cross-referenced scenes +- `Persistent` data management (storable actions wrapper) + +### Updated + +- Image preloader now stores images in stack, so the lib can easily control the process of preloading/unloading images +- Better signatures for `Condition` + +## [0.1.7] - 2024/11/16 ### _Feature_ diff --git a/eslint.config.mjs b/eslint.config.mjs index 8fd18ea..51040ad 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,5 +48,6 @@ export default [...compat.extends( "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-this-alias": "off", }, }]; \ No newline at end of file diff --git a/package.json b/package.json index 7a330c0..447aaec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "narraleaf-react", - "version": "0.1.6", + "version": "0.2.0", "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 7c8bd06..e490f0d 100644 --- a/src/game/nlcore/action/action.ts +++ b/src/game/nlcore/action/action.ts @@ -3,6 +3,7 @@ import {ContentNode} from "@core/action/tree/actionTree"; import type {CalledActionResult} from "@core/gameTypes"; import {Awaitable, getCallStack} from "@lib/util/data"; import {GameState} from "@player/gameState"; +import {Story} from "@core/elements/story"; export class Action { static ActionTypes = { @@ -38,7 +39,17 @@ export class Action) { + this.contentNode = contentNode; + return this; + } + + getFutureActions(_story: Story): LogicAction.Actions[] { const action = this.contentNode.getChild(); return (action && action.action) ? [action.action] : []; } diff --git a/src/game/nlcore/action/actionTypes.ts b/src/game/nlcore/action/actionTypes.ts index 2b5e31c..39eec3c 100644 --- a/src/game/nlcore/action/actionTypes.ts +++ b/src/game/nlcore/action/actionTypes.ts @@ -5,13 +5,13 @@ import {CommonDisplayable, ImageColor, ImageSrc} from "@core/types"; import {Transform} from "@core/elements/transform/transform"; import type {Scene} from "@core/elements/scene"; import type {MenuData} from "@core/elements/menu"; -import {Awaitable} from "@lib/util/data"; -import {ITransition} from "@core/elements/transition/type"; +import {Awaitable, FlexibleTuple, SelectElementFromEach} from "@lib/util/data"; +import {IImageTransition, ITransition} from "@core/elements/transition/type"; import type {Sound} from "@core/elements/sound"; import type {Script} from "@core/elements/script"; import {Sentence} from "@core/elements/character/sentence"; import type {TransformDefinitions} from "@core/elements/transform/type"; -import {Image} from "@core/elements/image"; +import {Image, TagGroupDefinition} from "@core/elements/displayable/image"; /* Character */ export const CharacterActionTypes = { @@ -31,7 +31,6 @@ export const SceneActionTypes = { action: "scene:action", setBackground: "scene:setBackground", sleep: "scene:sleep", - setTransition: "scene:setTransition", applyTransition: "scene:applyTransition", init: "scene:init", exit: "scene:exit", @@ -39,20 +38,21 @@ export const SceneActionTypes = { setBackgroundMusic: "scene:setBackgroundMusic", preUnmount: "scene:preUnmount", applyTransform: "scene:applyTransform", + transitionToScene: "scene:transitionToScene", } as const; export type SceneActionContentType = { [K in typeof SceneActionTypes[keyof typeof SceneActionTypes]]: K extends typeof SceneActionTypes["action"] ? Scene : K extends typeof SceneActionTypes["sleep"] ? number | Promise | Awaitable : K extends typeof SceneActionTypes["setBackground"] ? [ImageSrc | ImageColor] : - K extends typeof SceneActionTypes["setTransition"] ? [ITransition | null] : - K extends typeof SceneActionTypes["applyTransition"] ? [ITransition] : - K extends typeof SceneActionTypes["init"] ? [] : - K extends typeof SceneActionTypes["exit"] ? [] : - K extends typeof SceneActionTypes["jumpTo"] ? [Scene] : - K extends typeof SceneActionTypes["setBackgroundMusic"] ? [Sound | null, number?] : - K extends typeof SceneActionTypes["preUnmount"] ? [] : - K extends typeof SceneActionTypes["applyTransform"] ? [Transform] : + K extends typeof SceneActionTypes["applyTransition"] ? [ITransition] : + K extends typeof SceneActionTypes["init"] ? [Scene | string] : + K extends typeof SceneActionTypes["exit"] ? [] : + K extends typeof SceneActionTypes["jumpTo"] ? [Scene | string] : + K extends typeof SceneActionTypes["setBackgroundMusic"] ? [Sound | null, number?] : + K extends typeof SceneActionTypes["preUnmount"] ? [] : + K extends typeof SceneActionTypes["applyTransform"] ? [Transform] : + K extends typeof SceneActionTypes["transitionToScene"] ? [IImageTransition, Scene | string | undefined, ImageSrc | ImageColor | undefined] : any; } /* Story */ @@ -81,6 +81,7 @@ export const ImageActionTypes = { applyTransition: "image:applyTransition", flush: "image:flush", initWearable: "image:initWearable", + setAppearance: "image:setAppearance", } as const; export type ImageActionContentType = { [K in typeof ImageActionTypes[keyof typeof ImageActionTypes]]: @@ -95,7 +96,8 @@ export type ImageActionContentType = { K extends "image:applyTransition" ? [ITransition] : K extends "image:flush" ? [] : K extends "image:initWearable" ? [Image] : - any; + K extends "image:setAppearance" ? [FlexibleTuple> | string[], IImageTransition | undefined] : + any; } /* Condition */ export const ConditionActionTypes = { @@ -191,4 +193,30 @@ export type TextActionContentType = { K extends "text:applyTransition" ? [ITransition] : K extends "text:setFontSize" ? [number] : any; -} \ No newline at end of file +} +export const DisplayableActionTypes = { + action: "displayable:action", + layerMoveUp: "displayable:layerMoveUp", + layerMoveDown: "displayable:layerMoveDown", + layerMoveTop: "displayable:layerMoveTop", + layerMoveBottom: "displayable:layerMoveBottom", +} as const; +export type DisplayableActionContentType = { + [K in typeof DisplayableActionTypes[keyof typeof DisplayableActionTypes]]: + K extends "displayable:layerMoveUp" ? [void] : + K extends "displayable:layerMoveDown" ? [void] : + K extends "displayable:layerMoveTop" ? [void] : + K extends "displayable:layerMoveBottom" ? [void] : + any; +} +/* Persistent */ +export const PersistentActionTypes = { + action: "persistent:action", + set: "persistent:set", +} as const; +export type PersistentActionContentType = { + [K in typeof PersistentActionTypes[keyof typeof PersistentActionTypes]]: + K extends "persistent:action" ? any : + K extends "persistent:set" ? [string, any] : + any; +} diff --git a/src/game/nlcore/action/actionable.ts b/src/game/nlcore/action/actionable.ts index ed92b5d..f4bce14 100644 --- a/src/game/nlcore/action/actionable.ts +++ b/src/game/nlcore/action/actionable.ts @@ -3,7 +3,7 @@ import {Chainable, Chained, Proxied} from "@core/action/chain"; import GameElement = LogicAction.GameElement; export class Actionable< - StateData extends Record = Record, + StateData extends Record | null = Record, Self extends Actionable = any > extends Chainable { constructor() { @@ -15,7 +15,12 @@ export class Actionable< return null; } - /**@internal */ + /** + * @internal + * override this method can override the default behavior of chaining + * + * When converting a chain to actions, this method is called to convert the chain to actions + */ 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 9a078c8..7da30cd 100644 --- a/src/game/nlcore/action/actions.ts +++ b/src/game/nlcore/action/actions.ts @@ -18,7 +18,7 @@ export class TypedAction< this.contentNode.action = this; } - unknownType() { + unknownTypeError() { throw new Error("Unknown action type: " + this.type); } @@ -32,4 +32,8 @@ export class TypedAction< })(); return a; } + + is(parent: new (...args: any[]) => T, type: string): this is T { + return this instanceof parent && this.type === type; + } } diff --git a/src/game/nlcore/action/actions/characterAction.ts b/src/game/nlcore/action/actions/characterAction.ts index 179d187..6c342ae 100644 --- a/src/game/nlcore/action/actions/characterAction.ts +++ b/src/game/nlcore/action/actions/characterAction.ts @@ -7,11 +7,25 @@ import {ContentNode} from "@core/action/tree/actionTree"; import {Sentence} from "@core/elements/character/sentence"; import {TypedAction} from "@core/action/actions"; import {SoundAction} from "@core/action/actions/soundAction"; +import {Sound} from "@core/elements/sound"; export class CharacterAction extends TypedAction { static ActionTypes = CharacterActionTypes; + static getVoice(state: GameState, sentence: Sentence): Sound | null { + const scene = state.getLastScene(); + if (!scene) { + throw new Error("No scene found when trying to play voice"); + } + + const {voiceId, voice} = sentence.config; + if (!voiceId && !voice) { + return null; + } + return Sound.toSound(scene.getVoice(voiceId) || voice); + } + public executeAction(state: GameState): CalledActionResult | Awaitable { if (this.type === CharacterActionTypes.say) { const awaitable = @@ -22,7 +36,7 @@ export class CharacterAction).getContent(); - const voice = sentence.config.voice; + const voice = CharacterAction.getVoice(state, sentence); if (voice) { SoundAction.initSound(state, voice); @@ -48,6 +62,6 @@ export class CharacterAction extends TypedAction { @@ -20,7 +21,7 @@ export class ConditionAction extends TypedAction { @@ -155,9 +156,9 @@ export class ControlAction = Values, + Self extends Displayable = Displayable +> + extends TypedAction { + static ActionTypes = DisplayableActionTypes; + + public executeAction(gameState: GameState) { + const scene = gameState.getLastSceneIfNot(); + if (this.type === DisplayableActionTypes.layerMoveUp) { + gameState.moveUpElement(scene, this.callee); + gameState.stage.update(); + + return super.executeAction(gameState); + } else if (this.type === DisplayableActionTypes.layerMoveDown) { + gameState.moveDownElement(scene, this.callee); + gameState.stage.update(); + + return super.executeAction(gameState); + } else if (this.type === DisplayableActionTypes.layerMoveTop) { + gameState.moveTopElement(scene, this.callee); + gameState.stage.update(); + + return super.executeAction(gameState); + } else if (this.type === DisplayableActionTypes.layerMoveBottom) { + gameState.moveBottomElement(scene, this.callee); + gameState.stage.update(); + + return super.executeAction(gameState); + } + + throw this.unknownTypeError(); + } +} \ No newline at end of file diff --git a/src/game/nlcore/action/actions/imageAction.ts b/src/game/nlcore/action/actions/imageAction.ts index 6168ed8..76aed49 100644 --- a/src/game/nlcore/action/actions/imageAction.ts +++ b/src/game/nlcore/action/actions/imageAction.ts @@ -1,5 +1,5 @@ import {ImageActionContentType, ImageActionTypes} from "@core/action/actionTypes"; -import {Image} from "@core/elements/image"; +import {Image} from "@core/elements/displayable/image"; import {GameState} from "@player/gameState"; import type {CalledActionResult} from "@core/gameTypes"; import {Awaitable, SkipController} from "@lib/util/data"; @@ -44,6 +44,10 @@ export class ImageAction).getContent()[0]; state.logger.debug("Image - Set Src", this.callee.state.src); @@ -89,9 +93,6 @@ export class ImageAction(v => v) .registerSkipController(new SkipController(() => { - if (this.type === ImageActionTypes.hide) { - this.callee.state.display = false; - } return { type: this.type, node: this.contentNode.getChild() @@ -108,19 +109,41 @@ 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; + return super.executeAction(state); + } else if (this.type === ImageActionTypes.setAppearance) { + const [tags, transition] = + (this.contentNode as ContentNode).getContent(); + if (!this.callee.state.tag || !this.callee.state.currentTags) { + throw this.callee._srcNotSpecifiedError(); + } + + const newTags = this.callee.resolveTags(this.callee.state.currentTags, tags); + const newSrc = Image.getSrcFromTags(newTags, this.callee.state.src); + + state.logger.debug("Image - Set Appearance", newTags, newSrc); + + if (transition) { + const awaitable = new Awaitable(v => v) + .registerSkipController(new SkipController(() => { + return { + type: this.type, + node: this.contentNode.getChild() + }; + })); + transition.setSrc(newSrc); + this.callee.events.any("event:displayable.applyTransition", transition).then(() => { + this.callee.state.currentTags = newTags; + awaitable.resolve({ + type: this.type, + node: this.contentNode.getChild() + }); + state.stage.next(); + }); + } + this.callee.state.currentTags = newTags; return super.executeAction(state); } - throw super.unknownType(); + throw super.unknownTypeError(); } } \ No newline at end of file diff --git a/src/game/nlcore/action/actions/menuAction.ts b/src/game/nlcore/action/actions/menuAction.ts index aa70c6c..9cc7d67 100644 --- a/src/game/nlcore/action/actions/menuAction.ts +++ b/src/game/nlcore/action/actions/menuAction.ts @@ -5,6 +5,7 @@ import {Awaitable} from "@lib/util/data"; import type {CalledActionResult} from "@core/gameTypes"; import {ContentNode} from "@core/action/tree/actionTree"; import {TypedAction} from "@core/action/actions"; +import {Story} from "@core/elements/story"; export class MenuAction extends TypedAction { @@ -27,8 +28,8 @@ export class MenuAction).getContent(); - return [...this.callee._getFutureActions(menu.choices), ...super.getFutureActions()]; + return [...this.callee._getFutureActions(menu.choices), ...super.getFutureActions(story)]; } } \ No newline at end of file diff --git a/src/game/nlcore/action/actions/persistentAction.ts b/src/game/nlcore/action/actions/persistentAction.ts new file mode 100644 index 0000000..f8e2876 --- /dev/null +++ b/src/game/nlcore/action/actions/persistentAction.ts @@ -0,0 +1,23 @@ +import {PersistentActionContentType, PersistentActionTypes} from "@core/action/actionTypes"; +import {GameState} from "@player/gameState"; +import {TypedAction} from "@core/action/actions"; +import {Values} from "@lib/util/data"; +import {Persistent} from "@core/elements/persistent"; + +export class PersistentAction = Values> + extends TypedAction> { + static ActionTypes = PersistentActionTypes; + + executeAction(gameState: GameState) { + const action: PersistentAction = this; + if (action.is>(PersistentAction, "persistent:set")) { + const [key, value] = action.contentNode.getContent(); + gameState.getStorable().getNamespace( + action.callee.getNamespaceName() + ).set(key, value); + return super.executeAction(gameState); + } + + throw this.unknownTypeError(); + } +} \ No newline at end of file diff --git a/src/game/nlcore/action/actions/sceneAction.ts b/src/game/nlcore/action/actions/sceneAction.ts index 5466ff7..b98f8b1 100644 --- a/src/game/nlcore/action/actions/sceneAction.ts +++ b/src/game/nlcore/action/actions/sceneAction.ts @@ -7,13 +7,21 @@ import {ContentNode} from "@core/action/tree/actionTree"; import {LogicAction} from "@core/action/logicAction"; import {TypedAction} from "@core/action/actions"; import {SoundAction} from "@core/action/actions/soundAction"; +import {ITransition} from "@core/elements/transition/type"; +import {Story} from "@core/elements/story"; +import {RuntimeScriptError} from "@core/common/Utils"; export class SceneAction extends TypedAction { static ActionTypes = SceneActionTypes; - static handleSceneInit(sceneAction: SceneAction, state: GameState, awaitable: Awaitable) { - if (state.isSceneActive(sceneAction.callee)) { + static handleSceneInit(sceneAction: SceneAction, state: GameState, awaitable: Awaitable) { + const [targetScene] = sceneAction.contentNode.getContent(); + const scene = typeof targetScene === "string" ? state.getSceneByName(targetScene) : targetScene; + if (!scene) { + throw sceneAction._sceneNotFoundError(sceneAction.getSceneName(targetScene)); + } + if (state.isSceneActive(scene)) { return { type: sceneAction.type, node: sceneAction.contentNode.getChild() @@ -21,10 +29,10 @@ export class SceneAction { + SceneAction.registerEventListeners(scene, state, () => { awaitable.resolve({ type: sceneAction.type, node: sceneAction.contentNode.getChild() @@ -57,6 +65,25 @@ export class SceneAction() + .registerSkipController(new SkipController(() => { + state.logger.info("Background Transition", "Skipped"); + return { + type: this.type, + node: this.contentNode.getChild() + }; + })); + this.callee.events.any("event:displayable.applyTransition", transition).then(() => { + awaitable.resolve({ + type: this.type, + node: this.contentNode.getChild() + }); + state.stage.next(); + }); + return awaitable; + } + public executeAction(state: GameState): CalledActionResult | Awaitable { if (this.type === SceneActionTypes.action) { return super.executeAction(state); @@ -86,24 +113,9 @@ export class SceneAction() - .registerSkipController(new SkipController(() => { - state.logger.info("NarraLeaf-React: Background Transition", "Skipped"); - return { - type: this.type, - node: this.contentNode.getChild() - }; - })); - const transition = (this.contentNode as ContentNode).getContent()[0]; - this.callee.events.any("event:displayable.applyTransition", transition).then(() => { - awaitable.resolve({ - type: this.type, - node: this.contentNode.getChild() - }); - state.stage.next(); - }); - return awaitable; - } else if (this.type === SceneActionTypes.init) { + const [transition] = (this.contentNode as ContentNode).getContent(); + return this.applyTransition(state, transition); + } else if (this.is>(SceneAction, "scene:init")) { const awaitable = new Awaitable(v => v); return SceneAction.handleSceneInit(this, state, awaitable); } else if (this.type === SceneActionTypes.exit) { @@ -121,13 +133,20 @@ export class SceneAction).getContent()[0]; + const targetScene = (this.contentNode as ContentNode).getContent()[0]; const current = this.contentNode; + const scene = state.getStory().getScene(targetScene); + if (!scene) { + throw this._sceneNotFoundError(this.getSceneName(targetScene)); + } - const future = scene.sceneRoot?.contentNode || null; + const future = scene.getSceneRoot().contentNode; if (future) current.addChild(future); - return super.executeAction(state); + return { + type: this.type, + node: future + }; } else if (this.type === SceneActionTypes.setBackgroundMusic) { const [sound, fade] = (this.contentNode as ContentNode).getContent(); @@ -155,19 +174,53 @@ export class SceneAction).getContent(); + if (targetScene) { + const scene = state.getStory().getScene(targetScene); + if (!scene) { + throw this._sceneNotFoundError(this.getSceneName(targetScene)); + } + if (!scene.config.background) { + return super.executeAction(state); + } + transition.setSrc(scene.config.background); + } else if (src) { + transition.setSrc(src); + } + + return this.applyTransition(state, transition); } throw new Error("Unknown scene action type: " + this.type); } - getFutureActions(): LogicAction.Actions[] { + getFutureActions(story: Story): LogicAction.Actions[] { if (this.type === SceneActionTypes.jumpTo) { - // We don't care about the actions after jumpTo + // It doesn't care about the actions after jumpTo // because they won't be executed - const sceneRootNode = (this.contentNode as ContentNode).getContent()[0]?.sceneRoot?.contentNode; + const targetScene = (this.contentNode as ContentNode).getContent()[0]; + const scene = story.getScene(targetScene, true); + + if (!scene.isSceneRootConstructed()) { + scene.constructSceneRoot(story); + } + + const sceneRootNode = story.getScene(targetScene, true).getSceneRoot()?.contentNode; return sceneRootNode?.action ? [sceneRootNode.action] : []; } const action = this.contentNode.getChild()?.action; return action ? [action] : []; } + + _sceneNotFoundError(sceneId: string): Error { + return new RuntimeScriptError(`Scene with name ${sceneId} not found` + + "\nMake sure you have registered the scene using story.register" + + `\nAction: (id: ${this.getId()}) ${this.type}` + + `\nAt: ${this.__stack}`); + } + + getSceneName(scene: Scene | string): string { + return typeof scene === "string" ? scene : scene.name; + } } \ No newline at end of file diff --git a/src/game/nlcore/action/actions/soundAction.ts b/src/game/nlcore/action/actions/soundAction.ts index 376db81..dcdd083 100644 --- a/src/game/nlcore/action/actions/soundAction.ts +++ b/src/game/nlcore/action/actions/soundAction.ts @@ -78,6 +78,6 @@ export class SoundAction void)): void { + forEachChild(story: Story, actionOrActions: LogicAction.Actions | LogicAction.Actions[], cb: ((action: LogicAction.Actions) => void)): void { const seen = new Set(); const queue: LogicAction.Actions[] = []; @@ -38,36 +38,36 @@ export class Constructable< cb(action); - const children = action.getFutureActions() + const children = action.getFutureActions(story) .filter(action => !seen.has(action)); queue.push(...children); } } /**@internal */ - getAllChildren(action: LogicAction.Actions | LogicAction.Actions[]): LogicAction.Actions[] { + getAllChildren(story: Story, action: LogicAction.Actions | LogicAction.Actions[]): LogicAction.Actions[] { const children: LogicAction.Actions[] = []; - this.forEachChild(action, action => children.push(action)); + this.forEachChild(story, action, action => children.push(action)); return children; } /**@internal */ - getAllChildrenMap(action: LogicAction.Actions | LogicAction.Actions[]): Map { + getAllChildrenMap(story: Story, action: LogicAction.Actions | LogicAction.Actions[]): Map { const map = new Map(); - this.forEachChild(action, action => map.set(action.getId(), action)); + this.forEachChild(story, action, action => map.set(action.getId(), action)); return map; } /**@internal */ - getAllElementMap(action: LogicAction.Actions | LogicAction.Actions[]): Map { + getAllElementMap(story: Story, action: LogicAction.Actions | LogicAction.Actions[]): Map { const map = new Map(); - this.forEachChild(action, action => map.set(action.callee.getId(), action.callee)); + this.forEachChild(story, action, action => map.set(action.callee.getId(), action.callee)); return map; } /**@internal */ - getAllChildrenElements(action: LogicAction.Actions | LogicAction.Actions[]): LogicAction.GameElement[] { - return Array.from(new Set(this.getAllChildren(action).map(action => action.callee))); + getAllChildrenElements(story: Story, action: LogicAction.Actions | LogicAction.Actions[]): LogicAction.GameElement[] { + return Array.from(new Set(this.getAllChildren(story, action).map(action => action.callee))); } /**@internal */ diff --git a/src/game/nlcore/action/logicAction.ts b/src/game/nlcore/action/logicAction.ts index 680eec2..37144e8 100644 --- a/src/game/nlcore/action/logicAction.ts +++ b/src/game/nlcore/action/logicAction.ts @@ -1,7 +1,7 @@ import type {Character} from "@core/elements/character"; import type {Scene} from "@core/elements/scene"; import type {Story} from "@core/elements/story"; -import type {Image} from "@core/elements/image"; +import type {Image} from "@core/elements/displayable/image"; import type {Condition} from "@core/elements/condition"; import type {Script} from "@core/elements/script"; import type {Menu} from "@core/elements/menu"; @@ -15,10 +15,12 @@ import { ConditionActionContentType, ConditionActionTypes, ControlActionContentType, + DisplayableActionContentType, + DisplayableActionTypes, ImageActionContentType, ImageActionTypes, MenuActionContentType, - MenuActionTypes, + MenuActionTypes, PersistentActionContentType, PersistentActionTypes, SceneActionContentType, SceneActionTypes, ScriptActionContentType, @@ -37,24 +39,42 @@ import {ScriptAction} from "@core/action/actions/scriptAction"; import {MenuAction} from "@core/action/actions/menuAction"; import {SoundAction} from "@core/action/actions/soundAction"; import {ControlAction} from "@core/action/actions/controlAction"; -import {Text} from "@core/elements/text"; +import {Text} from "@core/elements/displayable/text"; import {TextAction} from "@core/action/actions/textAction"; +import {Displayable as AbstractDisplayable} from "@core/elements/displayable/displayable"; +import {DisplayableAction} from "@core/action/actions/displayableAction"; +import {Persistent} from "@core/elements/persistent"; +import {PersistentAction} from "@core/action/actions/persistentAction"; export namespace LogicAction { - export type Displayable = Text | Image; - export type GameElement = Character | Scene | Story | Image | Condition | Script | Menu | Sound | Control | Text; + export type DisplayableElements = Text | Image | AbstractDisplayable; + export type GameElement = + Character + | Scene + | Story + | Image + | Condition + | Script + | Menu + | Sound + | Control + | Text + | AbstractDisplayable + | Persistent; export type Actions = - (TypedAction - | CharacterAction - | ConditionAction - | ImageAction - | SceneAction - | ScriptAction - | StoryAction - | MenuAction - | SoundAction - | ControlAction - | TextAction); + TypedAction + | CharacterAction + | ConditionAction + | ImageAction + | SceneAction + | ScriptAction + | StoryAction + | MenuAction + | SoundAction + | ControlAction + | TextAction + | DisplayableAction + | PersistentAction; export type ActionTypes = Values | Values @@ -65,7 +85,9 @@ export namespace LogicAction { | Values | Values | Values - | Values; + | Values + | Values + | Values; export type ActionContents = CharacterActionContentType & ConditionActionContentType @@ -76,5 +98,7 @@ export namespace LogicAction { & MenuActionContentType & SoundActionContentType & ControlActionContentType - & TextActionContentType; + & TextActionContentType + & DisplayableActionContentType + & PersistentActionContentType; } \ No newline at end of file diff --git a/src/game/nlcore/action/srcManager.ts b/src/game/nlcore/action/srcManager.ts index 1db4182..afc4799 100644 --- a/src/game/nlcore/action/srcManager.ts +++ b/src/game/nlcore/action/srcManager.ts @@ -1,7 +1,12 @@ import {Sound} from "@core/elements/sound"; -import {Image} from "@core/elements/image"; -import {Utils} from "@core/common/core"; +import {Image as GameImage, Image} from "@core/elements/displayable/image"; +import {Story, Utils} from "@core/common/core"; import {StaticImageData} from "@core/types"; +import {LogicAction} from "@core/action/logicAction"; +import {ImageAction} from "@core/action/actions/imageAction"; +import {ImageActionContentType, ImageActionTypes, SceneActionTypes} from "@core/action/actionTypes"; +import {ContentNode} from "@core/action/tree/actionTree"; +import {SceneAction} from "@core/action/actions/sceneAction"; export type SrcType = "image" | "video" | "audio"; export type Src = { @@ -14,6 +19,9 @@ export type Src = { type: "audio"; src: Sound; }; +export type ActiveSrc = Src & { + activeType: T; +}; export class SrcManager { static SrcTypes: { @@ -23,6 +31,125 @@ export class SrcManager { video: "video", audio: "audio", } as const; + + static catSrc(src: Src[]): { + image: Image[]; + video: string[]; + audio: Sound[]; + } { + const images: Set = new Set(); + const videos: Set = new Set(); + const audios: Set = new Set(); + + src.forEach(({type, src}) => { + if (type === SrcManager.SrcTypes.image) { + images.add(src); + } else if (type === SrcManager.SrcTypes.video) { + videos.add(src); + } else { + audios.add(src); + } + }); + + return { + image: Array.from(images), + video: Array.from(videos), + audio: Array.from(audios), + }; + } + + static getSrc(src: Src | string | Image): string { + if (typeof src === "string") { + return src; + } + if (src instanceof Image) { + return GameImage.getSrc(src.state); + } + if (src.type === "image") { + return GameImage.getSrc(src.src.state); + } else if (src.type === "video") { + return src.src; + } else if (src.type === "audio") { + return src.src.getSrc(); + } + return ""; + } + + static getPreloadableSrc(story: Story, action: LogicAction.Actions): (Src & { + activeType: "scene" | "once" + }) | null { + if (action.is>(SceneAction, SceneActionTypes.setBackground)) { + const content = action.contentNode.getContent()[0]; + const src = Utils.backgroundToSrc(content); + if (src) { + return { + type: "image", + src: new Image({src}), + activeType: "scene" + }; + } + } else if (action.is>(SceneAction, SceneActionTypes.jumpTo)) { + const targetScene = action.contentNode.getContent()[0]; + const scene = story.getScene(targetScene, true); + const sceneBackground = scene.config.background; + if (Utils.isStaticImageData(sceneBackground) || typeof sceneBackground === "string") { + return { + type: "image", + src: new Image({src: sceneBackground}), + activeType: "once" + }; + } + } else if (action instanceof ImageAction) { + const imageAction = action as ImageAction; + if (imageAction.callee.config.tag) { + return { + type: "image", + src: new Image({ + src: Image.getSrcFromTags(imageAction.callee.config.tag.defaults, imageAction.callee.config.src) + }), + activeType: "scene" + }; + } + if (action.is>(ImageAction, ImageActionTypes.setSrc)) { + const content = action.contentNode.getContent()[0]; + return { + type: "image", + src: new Image({src: content}), + activeType: "scene" + }; + } else if (action.type === ImageActionTypes.initWearable) { + const image = (action.contentNode as ContentNode).getContent()[0]; + return { + type: "image", + src: image, + activeType: "scene" + }; + } else if (action.type === ImageActionTypes.setAppearance) { + const tags = (action.contentNode as ContentNode).getContent()[0]; + if (typeof imageAction.callee.config.src !== "function") { + throw imageAction.callee._invalidSrcHandlerError(); + } + if (tags.length === imageAction.callee.state.tag?.groups.length) { + return { + type: "image", + src: Image.fromSrc(Image.getSrcFromTags(tags, imageAction.callee.config.src)), + activeType: "scene" + }; + } + } else if (action.type === ImageActionTypes.init) { + const src = action.callee.config.src; + if (typeof src === "string" || Utils.isStaticImageData(src)) { + return { + type: "image", + src: new Image({src}), + activeType: "scene" + }; + } + } + } + return null; + } + src: Src[] = []; future: SrcManager[] = []; @@ -39,13 +166,15 @@ export class SrcManager { this.src.push({type: "audio", src: arg0}); } else if (arg0 instanceof Image || Utils.isStaticImageData(arg0)) { if (arg0 instanceof Image) { - if (this.isSrcRegistered(Utils.srcToString(arg0.state.src))) return this; + if (this.isSrcRegistered(GameImage.getSrc(arg0.state))) return this; } else { if (this.isSrcRegistered(Utils.srcToString(arg0["src"]))) return this; } this.src.push({ type: "image", src: - arg0 instanceof Image ? arg0 : new Image("", { + arg0 instanceof Image ? new Image({ + src: Image.getSrc(arg0.state), + }) : new Image({ src: Utils.staticImageDataToSrc(arg0), }) }); @@ -74,7 +203,7 @@ export class SrcManager { if (s.type === SrcManager.SrcTypes.audio) { return target === s.src.getSrc(); } else if (s.type === SrcManager.SrcTypes.image) { - return target === Utils.srcToString(s.src.state.src); + return target === GameImage.getSrc(s.src.state); } else { return target === s.src; } @@ -98,5 +227,9 @@ export class SrcManager { hasFuture(s: SrcManager): boolean { return this.future.includes(s); } + + getFutureSrc(): Src[] { + return this.future.map(s => s.getSrc()).flat(2); + } } diff --git a/src/game/nlcore/action/tree/actionTree.ts b/src/game/nlcore/action/tree/actionTree.ts index 7edf476..5589434 100644 --- a/src/game/nlcore/action/tree/actionTree.ts +++ b/src/game/nlcore/action/tree/actionTree.ts @@ -39,6 +39,10 @@ export type ContentNodeData = { } export class ContentNode extends Node { + static create(content: T): ContentNode { + return new ContentNode().setContent(content); + } + static forEachParent(node: RenderableNode, callback: (node: RenderableNode) => void) { const seen: Set = new Set(); let current: RenderableNode | null = node; diff --git a/src/game/nlcore/common/Utils.ts b/src/game/nlcore/common/Utils.ts index 38a6c7e..bb69e42 100644 --- a/src/game/nlcore/common/Utils.ts +++ b/src/game/nlcore/common/Utils.ts @@ -1,6 +1,6 @@ import type {Background, color, HexColor, ImageColor, ImageSrc, NextJSStaticImageData} from "@core/types"; import type {Scene} from "@core/elements/scene"; -import type {Image} from "@core/elements/image"; +import type {Image} from "@core/elements/displayable/image"; import type {LogicAction} from "@core/action/logicAction"; import { ImageActionContentType, @@ -12,6 +12,8 @@ import {ContentNode} from "@core/action/tree/actionTree"; import {SceneAction} from "@core/action/actions/sceneAction"; import {ImageAction} from "@core/action/actions/imageAction"; import {toHex, Values} from "@lib/util/data"; +import {Action} from "@core/action/action"; +import {Story} from "@core/elements/story"; export class RGBColor { static isHexString(color: any): color is HexColor { @@ -72,7 +74,7 @@ export class Utils { } public static isStaticImageData(src: any): src is NextJSStaticImageData { - return src?.src !== undefined; + return src?.src !== undefined && typeof src.src === "string"; } public static backgroundToSrc(background: Background["background"]): string | null { @@ -139,7 +141,7 @@ export class StaticScriptWarning extends UseError<{ } constructor(message: string, info?: any) { - super(message, {info}, "NarraLeafReact-StaticScriptWarning"); + super(message, {info}, "StaticScriptWarning"); } } @@ -155,18 +157,14 @@ export class StaticChecker { this.scene = target; } - public run() { - if (!this.scene.sceneRoot) { - return null; - } - + public run(story: Story) { const imageStates = new Map(); const scenes = new Map(); const queue: LogicAction.Actions[] = []; const seen: Set = new Set(); - const sceneActions = this.scene.getAllChildren(this.scene.sceneRoot); + const sceneActions = this.scene.getAllChildren(story, this.scene.getSceneRoot()); if (!sceneActions.length) { return null; @@ -177,6 +175,7 @@ export class StaticChecker { const action = queue.shift()!; this.checkAction( + story, action, {imageStates, scenes}, seen @@ -191,9 +190,11 @@ export class StaticChecker { return imageStates; } - private checkAction(action: LogicAction.Actions, - {imageStates, scenes}: { imageStates: Map, scenes: Map }, - seen: Set + private checkAction( + story: Story, + action: LogicAction.Actions, + {imageStates, scenes}: { imageStates: Map, scenes: Map }, + seen: Set ) { if (action instanceof ImageAction) { if (!imageStates.has(action.callee)) { @@ -218,10 +219,11 @@ export class StaticChecker { if (action.type === SceneActionTypes.jumpTo) { const targetScene = (action.contentNode as ContentNode).getContent()[0]; - if (seen.has(targetScene)) { + const scene = story.getScene(targetScene, true); + if (seen.has(scene)) { return; } else { - seen.add(targetScene); + seen.add(scene); } } } @@ -255,3 +257,35 @@ export class StaticChecker { } } +export class RuntimeScriptError extends Error { + static toMessage(msg: string | string[], trace?: Action | Action[]) { + const messages: string[] = []; + messages.push(...(Array.isArray(msg) ? msg : [msg])); + if (trace) { + messages.push(...( + Array.isArray(trace) + ? trace.map(RuntimeScriptError.getActionTrace) + : [RuntimeScriptError.getActionTrace(trace)] + )); + } + return messages.join(""); + } + + static getActionTrace(action: Action): string { + return `\nUsing action (id: ${action.getId()})` + + `\n at: ${action.__stack}`; + } + + constructor(message: string | string[], trace?: Action | Action[]) { + super(RuntimeScriptError.toMessage(message, trace)); + this.name = "RuntimeScriptError"; + } +} + +export class RuntimeGameError extends Error { + constructor(message: string) { + super(message); + this.name = "RuntimeGameError"; + } +} + diff --git a/src/game/nlcore/common/elements.ts b/src/game/nlcore/common/elements.ts index 5a2456a..11eb310 100644 --- a/src/game/nlcore/common/elements.ts +++ b/src/game/nlcore/common/elements.ts @@ -1,7 +1,13 @@ import {Character} from "../elements/character"; import {Condition, Lambda} from "../elements/condition"; import {Control} from "@core/elements/control"; -import {Image} from "../elements/image"; +import { + Image as ImageClass, + RichImageUserConfig, + TagDefinitions, + TagGroupDefinition, + TagSrcResolver +} from "../elements/displayable/image"; import {Menu} from "../elements/menu"; import {Scene} from "../elements/scene"; import {Script} from "../elements/script"; @@ -10,8 +16,49 @@ import {Story} from "../elements/story"; import {Transform} from "@core/elements/transform/transform"; import {Sentence} from "@core/elements/character/sentence"; import {Word} from "@core/elements/character/word"; -import {Text} from "@core/elements/text"; +import {Text} from "@core/elements/displayable/text"; import {Pause} from "@core/elements/character/pause"; +import {StaticImageData} from "@core/types"; +import {Persistent} from "@core/elements/persistent"; + +interface ImageConstructor { + new( + config: Omit>, "src"> & + (T extends null ? + { + src: string | StaticImageData; + tag?: never; + } : T extends TagGroupDefinition ? + { + src: TagSrcResolver; + tag: TagDefinitions; + } + : never), + ): ImageClass; +} + +const Image: ImageConstructor = function ( + this: ImageClass, + config: Omit>, "src"> & + (T extends null ? + { + src: string | StaticImageData; + tag?: never; + } : T extends TagGroupDefinition ? + { + src: TagSrcResolver; + tag: TagDefinitions; + } + : never), +): ImageClass { + if (!new.target) { + throw new Error("Image is a constructor and should be called with new keyword"); + } + return new ImageClass( + config as Partial>, + config.tag as TagDefinitions | undefined + ); +} as unknown as ImageConstructor; export { Character, @@ -29,5 +76,6 @@ export { Transform, Word, Text, - Pause + Pause, + Persistent, }; \ No newline at end of file diff --git a/src/game/nlcore/common/game.ts b/src/game/nlcore/common/game.ts index da03fb6..9d6dceb 100644 --- a/src/game/nlcore/common/game.ts +++ b/src/game/nlcore/common/game.ts @@ -1,6 +1,6 @@ import {Game} from "@core/game"; import {GameState} from "@player/gameState"; -import {Storable, Namespace} from "../store/storable"; +import {Storable, Namespace} from "../elements/persistent/storable"; import {LiveGame} from "@core/liveGame"; export { diff --git a/src/game/nlcore/common/types.ts b/src/game/nlcore/common/types.ts index d820df1..ccae994 100644 --- a/src/game/nlcore/common/types.ts +++ b/src/game/nlcore/common/types.ts @@ -1,5 +1,6 @@ import {TransformDefinitions} from "@core/elements/transform/type"; +export * from "@core/elements/type"; export type { TransformDefinitions, }; diff --git a/src/game/nlcore/elements/character/sentence.ts b/src/game/nlcore/elements/character/sentence.ts index 32c55c7..ae8035e 100644 --- a/src/game/nlcore/elements/character/sentence.ts +++ b/src/game/nlcore/elements/character/sentence.ts @@ -10,6 +10,7 @@ export type SentenceConfig = { pause?: boolean | number; voice: Sound | null; character: Character | null; + voiceId: string | number | null; } & Color & Font; export type SentenceDataRaw = { @@ -37,6 +38,7 @@ export class Sentence { pause: true, voice: null, character: null, + voiceId: null, }; /**@internal */ static defaultState: SentenceState = { diff --git a/src/game/nlcore/elements/condition.ts b/src/game/nlcore/elements/condition.ts index f3b5032..a0d416a 100644 --- a/src/game/nlcore/elements/condition.ts +++ b/src/game/nlcore/elements/condition.ts @@ -1,31 +1,35 @@ -import {deepMerge} from "@lib/util/data"; import {ContentNode, RenderableNode} from "@core/action/tree/actionTree"; import {LogicAction} from "@core/action/logicAction"; import {Actionable} from "@core/action/actionable"; import {GameState} from "@player/gameState"; import {Chained, ChainedActions, Proxied} from "@core/action/chain"; -import {ScriptCtx} from "@core/elements/script"; import {StaticScriptWarning} from "@core/common/Utils"; -import Actions = LogicAction.Actions; import {ConditionAction} from "@core/action/actions/conditionAction"; +import {LambdaCtx, LambdaHandler} from "@core/elements/type"; +import Actions = LogicAction.Actions; -/* eslint-disable @typescript-eslint/no-empty-object-type */ -export type ConditionConfig = {}; - -interface LambdaCtx extends ScriptCtx { -} +export class Lambda { + /**@internal */ + public static isLambda(value: any): value is Lambda { + return value instanceof Lambda && "handler" in value; + } -type LambdaHandler = (ctx: LambdaCtx) => T; + /**@internal */ + public static from(obj: Lambda | LambdaHandler): Lambda { + return Lambda.isLambda(obj) ? obj : new Lambda(obj); + } -export class Lambda { - handler: LambdaHandler; + /**@internal */ + handler: LambdaHandler; + /**@internal */ constructor(handler: LambdaHandler) { this.handler = handler; } + /**@internal */ evaluate({gameState}: { gameState: GameState }): { - value: any; + value: T; } { const value = this.handler(this.getCtx({gameState})); return { @@ -33,6 +37,7 @@ export class Lambda { }; } + /**@internal */ getCtx({gameState}: { gameState: GameState }): LambdaCtx { return { gameState, @@ -57,10 +62,7 @@ export type ConditionData = { } }; -export class Condition extends Actionable { - /**@internal */ - static defaultConfig: ConditionConfig = {}; - +export class Condition extends Actionable { /**@internal */ static getInitialState(): ConditionData { return { @@ -75,8 +77,15 @@ export class Condition extends Actionable { }; } - /**@internal */ - readonly config: ConditionConfig; + /** + * @chainable + */ + public static If( + condition: Lambda | LambdaHandler, action: ChainedActions + ): Proxied> { + return new Condition().createIfCondition(condition, action); + } + /**@internal */ conditions: ConditionData = { If: { @@ -89,54 +98,25 @@ export class Condition extends Actionable { } }; - constructor(config: ConditionConfig = {}) { + /**@internal */ + private constructor() { super(); - this.config = deepMerge(Condition.defaultConfig, config); - } - - /** - * @chainable - */ - public If( - condition: Lambda | LambdaHandler, action: ChainedActions - ): Proxied> { - // when IF condition already set - if (this.conditions.If.condition) { - throw new StaticScriptWarning("IF condition already set\nYou are trying to set multiple IF conditions for the same condition"); - } - - // when ELSE-IF condition already set - if (this.conditions.ElseIf.length) { - throw new StaticScriptWarning("ELSE-IF condition already set\nYou are trying to set an IF condition after an ELSE-IF condition"); - } - - // when ELSE condition already set - if (this.conditions.Else.action) { - throw new StaticScriptWarning("ELSE condition already set\nYou are trying to set an IF condition after an ELSE condition"); - } - this.conditions.If.condition = condition instanceof Lambda ? condition : new Lambda(condition); - this.conditions.If.action = this.construct(Array.isArray(action) ? action : [action]); - return this.chain(); } /** * @chainable */ public ElseIf( - condition: Lambda | LambdaHandler, action: ChainedActions - ): Proxied> { - // when there is no IF condition - if (!this.conditions.If.condition) { - throw new StaticScriptWarning("IF condition not set\nYou are trying to set an ELSE-IF condition without an IF condition"); - } - + condition: Closed extends false ? (Lambda | LambdaHandler) : never, + action: Closed extends false ? ChainedActions : never + ): Closed extends false ? Proxied> : never { // when ELSE condition already set if (this.conditions.Else.action) { throw new StaticScriptWarning("ELSE condition already set\nYou are trying to set an ELSE-IF condition after an ELSE condition"); } this.conditions.ElseIf.push({ - condition: condition instanceof Lambda ? condition : new Lambda(condition), + condition: Lambda.isLambda(condition) ? condition : new Lambda(condition), action: this.construct(Array.isArray(action) ? action : [action]) }); return this.chain(); @@ -146,13 +126,8 @@ export class Condition extends Actionable { * @chainable */ public Else( - action: ChainedActions - ): Proxied> { - // when there is no IF condition - if (!this.conditions.If.condition) { - throw new StaticScriptWarning("IF condition not set\nYou are trying to set an ELSE condition without an IF condition"); - } - + action: Closed extends false ? ChainedActions : never + ): Closed extends false ? Proxied, Chained> : never { // when ELSE condition already set if (this.conditions.Else.action) { throw new StaticScriptWarning("ELSE condition already set\nYou are trying to set multiple ELSE conditions for the same condition"); @@ -214,9 +189,18 @@ export class Condition extends Actionable { /**@internal */ _getFutureActions(): LogicAction.Actions[] { return Chained.toActions([ - ...(this.conditions.If.action || []), - ...this.conditions.ElseIf.flatMap(e => e.action || []), - ...(this.conditions.Else.action || []) + (this.conditions.If.action?.[0] || []), + ...this.conditions.ElseIf.flatMap(e => e.action?.[0] || []), + (this.conditions.Else.action?.[0] || []) ]); } + + /**@internal */ + private createIfCondition( + condition: Lambda | LambdaHandler, action: ChainedActions + ): Proxied> { + this.conditions.If.condition = condition instanceof Lambda ? condition : new Lambda(condition); + this.conditions.If.action = this.construct(Array.isArray(action) ? action : [action]); + return this.chain(); + } } diff --git a/src/game/nlcore/elements/displayable/displayable.ts b/src/game/nlcore/elements/displayable/displayable.ts new file mode 100644 index 0000000..f2101a7 --- /dev/null +++ b/src/game/nlcore/elements/displayable/displayable.ts @@ -0,0 +1,81 @@ +import {Actionable} from "@core/action/actionable"; +import {EventfulDisplayable} from "@core/types"; +import {Transform} from "@core/elements/transform/transform"; +import {EventDispatcher, Values} from "@lib/util/data"; +import {ITransition} from "@core/elements/transition/type"; +import {DisplayableAction} from "@core/action/actions/displayableAction"; +import {DisplayableActionContentType, DisplayableActionTypes} from "@core/action/actionTypes"; +import {Chained, Proxied} from "@core/action/chain"; +import {LogicAction} from "@core/action/logicAction"; +import {ContentNode} from "@core/action/tree/actionTree"; + +export type DisplayableEventTypes = { + "event:displayable.applyTransition": [ITransition]; + "event:displayable.applyTransform": [Transform]; + "event:displayable.init": []; +}; + +export abstract class Displayable< + StateData extends Record, + Self extends Actionable +> + extends Actionable + implements EventfulDisplayable { + /**@internal */ + static EventTypes: { [K in keyof DisplayableEventTypes]: K } = { + "event:displayable.applyTransition": "event:displayable.applyTransition", + "event:displayable.applyTransform": "event:displayable.applyTransform", + "event:displayable.init": "event:displayable.init", + }; + /**@internal */ + readonly abstract events: EventDispatcher; + + abstract toDisplayableTransform(): Transform; + + /** + * Move the layer up + * @chainable + */ + public layerMoveUp(): Proxied> { + const chain = this.chain(); + return this.chain(this.constructLayerAction(chain, DisplayableActionTypes.layerMoveUp)); + } + + /** + * Move the layer down + * @chainable + */ + public layerMoveDown(): Proxied> { + const chain = this.chain(); + return this.chain(this.constructLayerAction(chain, DisplayableActionTypes.layerMoveDown)); + } + + /** + * Move the layer to the top + * @chainable + */ + public layerMoveTop(): Proxied> { + const chain = this.chain(); + return this.chain(this.constructLayerAction(chain, DisplayableActionTypes.layerMoveTop)); + } + + /** + * Move the layer to the bottom + * @chainable + */ + public layerMoveBottom(): Proxied> { + const chain = this.chain(); + return this.chain(this.constructLayerAction(chain, DisplayableActionTypes.layerMoveBottom)); + } + + protected constructLayerAction>( + chain: Proxied>, + type: T, + ): DisplayableAction { + return new DisplayableAction( + chain, + type, + new ContentNode(), + ); + } +} diff --git a/src/game/nlcore/elements/image.ts b/src/game/nlcore/elements/displayable/image.ts similarity index 54% rename from src/game/nlcore/elements/image.ts rename to src/game/nlcore/elements/displayable/image.ts index a1c3685..ad21ee0 100644 --- a/src/game/nlcore/elements/image.ts +++ b/src/game/nlcore/elements/displayable/image.ts @@ -1,10 +1,9 @@ import React from "react"; import type {TransformDefinitions} from "@core/elements/transform/type"; import {ContentNode} from "@core/action/tree/actionTree"; -import {Actionable} from "@core/action/actionable"; import {Utils} from "@core/common/Utils"; import {Scene} from "@core/elements/scene"; -import {Transform} from "./transform/transform"; +import {Transform} from "../transform/transform"; import {CommonDisplayable, EventfulDisplayable, StaticImageData} from "@core/types"; import {ImageActionContentType} from "@core/action/actionTypes"; import {LogicAction} from "@core/game"; @@ -16,17 +15,31 @@ import { IPosition, PositionUtils } from "@core/elements/transform/position"; -import {deepEqual, deepMerge, DeepPartial, EventDispatcher, getCallStack} from "@lib/util/data"; +import { + deepEqual, + deepMerge, + EventDispatcher, + FlexibleTuple, + getCallStack, + SelectElementFromEach, + TypeOf +} from "@lib/util/data"; import {Chained, Proxied} from "@core/action/chain"; import {Control} from "@core/elements/control"; import {ImageAction} from "@core/action/actions/imageAction"; +import {Displayable, DisplayableEventTypes} from "@core/elements/displayable/displayable"; export type ImageConfig = { - src: string | StaticImageData; display: boolean; + /**@internal */ disposed?: boolean; wearables: Image[]; isWearable?: boolean; + name?: string; + /** + * If set to false, the image won't be initialized unless you call `init` method + */ + autoInit: boolean; } & CommonDisplayable; export type ImageDataRaw = { @@ -34,25 +47,48 @@ export type ImageDataRaw = { }; export type ImageEventTypes = { - "event:displayable.applyTransition": [ITransition]; - "event:displayable.applyTransform": [Transform]; - "event:displayable.init": []; "event:wearable.create": [Image]; -}; +} & DisplayableEventTypes; +export type TagDefinitions = + T extends TagGroupDefinition ? { + groups: T; + defaults: SelectElementFromEach; + } : never; +export type TagGroupDefinition = string[][]; +export type TagSrcResolver = (...tags: SelectElementFromEach) => string; +export type RichImageUserConfig = ImageConfig & { + /**@internal */ + currentTags?: SelectElementFromEach | null; +} & + (T extends null ? + { + src: string | StaticImageData; + tag?: never; + } : T extends TagGroupDefinition ? + { + src: TagSrcResolver; + tag: TagDefinitions; + } + : never); +export type RichImageConfig = RichImageUserConfig & {}; +export type StaticRichConfig = RichImageUserConfig; + -export class Image - extends Actionable +export class Image< + Tags extends TagGroupDefinition | null = TagGroupDefinition | null +> + extends Displayable implements EventfulDisplayable { + /**@internal */ static EventTypes: { [K in keyof ImageEventTypes]: K } = { - "event:displayable.applyTransition": "event:displayable.applyTransition", - "event:displayable.applyTransform": "event:displayable.applyTransform", - "event:displayable.init": "event:displayable.init", + ...Displayable.EventTypes, "event:wearable.create": "event:wearable.create", }; /**@internal */ - static defaultConfig: ImageConfig = { - src: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", + public static DefaultImagePlaceholder = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; + /**@internal */ + static defaultConfig: RichImageUserConfig = { display: false, position: new CommonPosition(CommonPositionType.Center), scale: 1, @@ -60,6 +96,9 @@ export class Image opacity: 0, isWearable: false, wearables: [], + src: Image.DefaultImagePlaceholder, + currentTags: null, + autoInit: true, }; /**@internal */ @@ -79,7 +118,7 @@ export class Image }; /**@internal */ - public static deserializeImageState(state: Record): ImageConfig { + public static deserializeImageState(state: Record): StaticRichConfig { const handlers: Record any)> = { position: (value: D2Position) => { return PositionUtils.toCoord2D(value); @@ -91,44 +130,67 @@ export class Image result[key] = handlers[key] ? handlers[key](state[key]) : state[key]; } } - return result as ImageConfig; + return result as StaticRichConfig; } /**@internal */ - readonly name: string; + public static getSrc(state: StaticRichConfig): string { + if (typeof state.src === "string" || Utils.isStaticImageData(state.src)) { + const {src} = state as RichImageConfig; + return Utils.isStaticImageData(src) ? Utils.staticImageDataToSrc(src) : src; + } + const {src, currentTags} = state as RichImageConfig; + if (!currentTags) { + throw new Error("Tags not resolved\nTags must be resolved before getting the src"); + } + return Image.getSrcFromTags(currentTags, src); + } + /**@internal */ - readonly config: ImageConfig; + public static getSrcFromTags( + tags: SelectElementFromEach | string[], + tagResolver: (...tags: SelectElementFromEach | string[]) => string + ): string { + return tagResolver(...tags); + } + + /**@internal */ + public static fromSrc(src: string): Image { + return new Image({ + src: src, + }); + } + + /**@internal */ + name: string; + /**@internal */ + readonly config: RichImageUserConfig; /**@internal */ readonly events: EventDispatcher = new EventDispatcher(); /**@internal */ ref: React.RefObject | undefined = undefined; /**@internal */ - state: ImageConfig; - - constructor(name: string, config: Partial); + state: RichImageConfig; - constructor(config?: DeepPartial); - - constructor(arg0: string | DeepPartial = {}, config?: Partial) { + constructor(config: Partial> = {}, tagDefinition?: TagDefinitions) { super(); - if (typeof arg0 === "string") { - this.name = arg0; - this.config = deepMerge(Image.defaultConfig, config || {}); - if (this.config.position) this.config.position = PositionUtils.tryParsePosition(this.config.position); - } else { - this.name = ""; - this.config = deepMerge(Image.defaultConfig, arg0); - if (this.config.position) this.config.position = PositionUtils.tryParsePosition(this.config.position); - } - this.state = deepMerge({}, this.config); + this.name = config.name || "(anonymous)"; + this.config = deepMerge>(Image.defaultConfig, config, { + tag: tagDefinition || config.tag, + position: config.position ? PositionUtils.tryParsePosition(config.position) : new CommonPosition(CommonPositionType.Center), + currentTags: config.tag?.defaults + ? [...config.tag.defaults] as SelectElementFromEach + : null + }); + this.state = deepMerge>({}, this.config); this.checkConfig(this.config); } /** - * Dispose the image + * Dispose of the image * - * Normally, you don't need to dispose the image manually + * Normally, you don't need to dispose of the image manually * @chainable */ public dispose() { @@ -141,17 +203,52 @@ export class Image } /**@internal */ - checkConfig(config: ImageConfig) { + checkConfig(config: RichImageUserConfig) { + // invalid-position error if (!Transform.isPosition(config.position)) { throw new Error("Invalid position\nPosition must be one of CommonImagePosition, Align, Coord2D"); } + // mixed-src error + if (TypeOf(config.src) === TypeOf.DataTypes.string && config.tag) { + throw this._mixedSrcError(); + } + // src-not-specified error + if (!config.src && !config.tag) { + throw this._srcNotSpecifiedError(); + } + // invalid-wearable error for (const wearable of config.wearables) { if (!wearable.config.isWearable) { - throw new Error("Invalid wearable\nWearable must be an Image with isWearable set to true" + - "\nIt seems like you are trying to add a non-wearable image to wearables" + - "\nImage below violates the rule:\n" + JSON.stringify(wearable.config)); + throw this._invalidWearableError(JSON.stringify(wearable.config)); + } + } + // invalid-tag-group-definition error + if (config.tag) { + const seen: Set = new Set(); + for (const tags of config.tag.groups) { + for (const tag of tags) { + if (seen.has(tag)) { + throw this._invalidTagGroupDefinitionError(); + } + seen.add(tag); + } + } + } + // conflict-tag error + if (config.tag) { + const tagMap: Map = this.constructTagMap(config.tag.groups); + const usedTags = new Set(); + for (const tag of config.tag.defaults) { + if (usedTags.has(tag)) { + throw new Error(`Tag conflict\nTag "${tag}" is conflicting with another tag\nError found in config.tag.defaults`); + } + if (!tagMap.has(tag)) { + throw new Error(`Tag not found\nTag "${tag}" is not defined in tagDefinitions\nError found in config.tag.defaults`); + } + tagMap.get(tag)?.forEach(t => usedTags.add(t)); } } + return this; } @@ -172,16 +269,27 @@ export class Image */ public setSrc(src: string | StaticImageData, transition?: IImageTransition): Proxied> { return this.combineActions(new Control(), chain => { - if (transition) { - const copy = transition.copy(); - copy.setSrc(Utils.srcToString(src)); - chain._transitionSrc(copy); - } - const action = new ImageAction( + return this._setSrc(chain, src, transition); + }); + } + + /** + * Set the appearance of the image + * + * Note: using a full set of tags will help the library preload the images. + * @chainable + */ + public setAppearance( + tags: Tags extends TagGroupDefinition ? FlexibleTuple> : string[], + transition?: IImageTransition + ): Proxied> { + return this.combineActions(new Control(), chain => { + const action = new ImageAction( chain, - ImageAction.ActionTypes.setSrc, - new ContentNode<[string]>().setContent([ - typeof src === "string" ? src : Utils.staticImageDataToSrc(src) + ImageAction.ActionTypes.setAppearance, + new ContentNode().setContent([ + tags, + transition?.copy(), ]) ); return chain @@ -243,7 +351,7 @@ export class Image /** * Show the image * - * if options is provided, the image will show with the provided transform options + * if options are provided, the image will show with the provided transform options * @example * ```ts * image.show({ @@ -335,7 +443,7 @@ export class Image * Bind this image to a parent image as a wearable */ public bindWearable(parent: Image): this { - parent.addWearable(this); + parent.addWearable([this as Image]); return this; } @@ -360,8 +468,8 @@ export class Image return this.ref; } - public copy(): Image { - return new Image(this.name, this.config); + public copy(): Image { + return new Image(this.config); } /**@internal */ @@ -377,7 +485,7 @@ export class Image /**@internal */ fromData(data: ImageDataRaw): this { - this.state = deepMerge(this.state, Image.deserializeImageState(data.state)); + this.state = deepMerge>(this.state, Image.deserializeImageState(data.state)); return this; } @@ -431,7 +539,7 @@ export class Image /**@internal */ override reset() { - this.state = deepMerge({}, this.config); + this.state = deepMerge>({}, this.config); } /**@internal */ @@ -452,6 +560,105 @@ export class Image }; } + /** + * @internal + * resolve tags, return the tags that aren't conflicting + */ + resolveTags( + oldTags: SelectElementFromEach | string[], + newTags: SelectElementFromEach | string[] + ): SelectElementFromEach { + if (!this.state.tag) { + throw new Error("Tag not defined\nTag must be defined in the image config"); + } + const tagMap: Map = this.constructTagMap(this.state.tag.groups); + const resultTags: Set = new Set(); + + const resolve = (tags: SelectElementFromEach | string[]) => { + for (const tag of tags) { + const conflictGroup = tagMap.get(tag); + if (!conflictGroup) continue; + + for (const conflictTag of conflictGroup) { + resultTags.delete(conflictTag); + } + resultTags.add(tag); + } + }; + + resolve(oldTags); + resolve(newTags); + + return Array.from(resultTags) as SelectElementFromEach; + } + + /**@internal */ + _mixedSrcError(): TypeError { + throw new TypeError("To better understand the behavior of the image, " + + "you cannot mix src and tags in the same image. " + + "If you are using tags, remove the src from the image config and do not use setSrc method. " + + "If you are using src, remove the tags from the image config and do not use setAppearance method."); + } + + /**@internal */ + _invalidSrcHandlerError(): Error { + throw new Error("Invalid src handler, " + + "If you are using tags, config.src must be a function that resolves the src from the tags. " + + "If you are using src, config.src must be a string or StaticImageData"); + } + + /**@internal */ + _srcNotSpecifiedError(): TypeError { + throw new TypeError("Src not specified\nPlease provide a src or tags in the image config"); + } + + /**@internal */ + _invalidWearableError(trace: string): Error { + throw new Error("Invalid wearable\nWearable must be an Image with isWearable set to true" + + "\nIt seems like you are trying to add a non-wearable image to wearables" + + "\nImage below violates the rule:\n" + trace); + } + + /**@internal */ + _invalidTagGroupDefinitionError(): Error { + throw new Error("Invalid tag group definition. " + + "Tags in groups must be unique and not conflicting with each other."); + } + + /**@internal */ + private constructTagMap(definitions: TagGroupDefinition): Map { + const tagMap: Map = new Map(); + for (const tags of definitions) { + for (const tag of tags) { + tagMap.set(tag, tags); + } + } + return tagMap; + } + + /**@internal */ + private _setSrc( + chain: Proxied>, + src: string | StaticImageData, + transition?: IImageTransition + ): Proxied> { + 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()); + } + /**@internal */ private _transitionSrc(transition: ITransition): this { const t = transition.copy(); @@ -467,4 +674,23 @@ export class Image new ContentNode() )); } -} \ No newline at end of file +} + +/** + * @class + * @internal + * This class is only for internal use, + * DO NOT USE THIS CLASS DIRECTLY + */ +export class VirtualImageProxy extends Image { + constructor(config: Partial> = {}) { + super(); + this.name = config.name || "(anonymous [virtual image proxy])"; + this.config.opacity = 1; + this.state.opacity = 1; + } + + override checkConfig(_: RichImageUserConfig): this { + return this; + } +} diff --git a/src/game/nlcore/elements/text.ts b/src/game/nlcore/elements/displayable/text.ts similarity index 95% rename from src/game/nlcore/elements/text.ts rename to src/game/nlcore/elements/displayable/text.ts index 552385d..cc20910 100644 --- a/src/game/nlcore/elements/text.ts +++ b/src/game/nlcore/elements/displayable/text.ts @@ -11,9 +11,10 @@ import {ContentNode} from "@core/action/tree/actionTree"; import {TextActionContentType} from "@core/action/actionTypes"; import {TextAction} from "@core/action/actions/textAction"; import {Scene} from "@core/elements/scene"; -import {ITextTransition, ITransition} from "@core/elements/transition/type"; +import {ITextTransition} from "@core/elements/transition/type"; import {Control} from "@core/elements/control"; import {FontSizeTransition} from "@core/elements/transition/textTransitions/fontSizeTransition"; +import {Displayable, DisplayableEventTypes} from "@core/elements/displayable/displayable"; export type TextConfig = { alignX: "left" | "center" | "right"; @@ -32,21 +33,16 @@ export type TextDataRaw = { export type TextEventTypes = { "event:text.show": [Transform]; "event:text.hide": [Transform]; - "event:displayable.applyTransition": [ITransition]; - "event:displayable.applyTransform": [Transform]; - "event:displayable.init": []; -}; +} & DisplayableEventTypes; export class Text extends Actionable implements EventfulDisplayable { /**@internal */ static EventTypes: { [K in keyof TextEventTypes]: K } = { + ...Displayable.EventTypes, "event:text.show": "event:text.show", "event:text.hide": "event:text.hide", - "event:displayable.applyTransition": "event:displayable.applyTransition", - "event:displayable.applyTransform": "event:displayable.applyTransform", - "event:displayable.init": "event:displayable.init", }; /**@internal */ static defaultConfig: TextConfig = { diff --git a/src/game/nlcore/elements/menu.ts b/src/game/nlcore/elements/menu.ts index 8c0e05a..1727283 100644 --- a/src/game/nlcore/elements/menu.ts +++ b/src/game/nlcore/elements/menu.ts @@ -93,7 +93,8 @@ export class Menu extends Actionable { /**@internal */ _getFutureActions(choices: Choice[]): LogicAction.Actions[] { - return choices.map(choice => choice.action).flat(2); + return choices.map(choice => choice.action[0] || null) + .filter(action => action !== null); } /**@internal */ diff --git a/src/game/nlcore/elements/persistent.ts b/src/game/nlcore/elements/persistent.ts new file mode 100644 index 0000000..e0ba452 --- /dev/null +++ b/src/game/nlcore/elements/persistent.ts @@ -0,0 +1,145 @@ +import {Actionable} from "@core/action/actionable"; +import {StorableType} from "@core/elements/persistent/type"; +import {Chained, Proxied} from "@core/action/chain"; +import {LogicAction} from "@core/game"; +import {PersistentActionContentType, PersistentActionTypes} from "@core/action/actionTypes"; +import {PersistentAction} from "@core/action/actions/persistentAction"; +import {BooleanKeys, StringKeyOf, Values} from "@lib/util/data"; +import {ContentNode} from "@core/action/tree/actionTree"; +import {Lambda} from "@core/elements/condition"; +import {Word} from "@core/elements/character/word"; +import {DynamicWord, DynamicWordResult} from "@core/elements/character/sentence"; +import {LambdaHandler} from "@core/elements/type"; +import {Namespace, Storable} from "@core/elements/persistent/storable"; + +type PersistentContent = { + [key: string]: StorableType; +}; +type ChainedPersistent = Proxied, Chained>; + +export class Persistent + extends Actionable { + + constructor(private namespace: string, private defaultContent: T) { + super(); + } + + /**@internal */ + init(storable: Storable) { + if (!storable.hasNamespace(this.namespace)) { + storable.addNamespace(new Namespace(this.namespace, this.defaultContent)); + } + } + + /** + * @chainable + */ + public set>(key: K, value: T[K]): ChainedPersistent { + return this.chain(this.createAction( + PersistentActionTypes.set, + [key, value] + )); + } + + /** + * Determine whether the values are equal, can be used in {@link Condition} + */ + public equals>(key: K, value: T[K]): Lambda { + return new Lambda(({storable}) => { + return storable.getNamespace(this.namespace).equals(key, value); + }); + } + + /** + * Determine whether the values aren't equal, can be used in {@link Condition} + */ + public notEquals>(key: K, value: T[K]): Lambda { + return new Lambda(({storable}) => { + return !storable.getNamespace(this.namespace).equals(key, value); + }); + } + + /** + * Determine whether the value is true, can be used in {@link Condition} + */ + public isTrue>>(key: K): Lambda { + return new Lambda(({storable}) => { + return storable.getNamespace(this.namespace).equals(key, true); + }); + } + + /** + * Determine whether the value is false, can be used in {@link Condition} + */ + public isFalse>>(key: K): Lambda { + return new Lambda(({storable}) => { + return storable.getNamespace(this.namespace).equals(key, false); + }); + } + + /** + * Determine whether the value isn't null or undefined, can be used in {@link Condition} + */ + public isNotNull>(key: K): Lambda { + return new Lambda(({storable}) => { + const value = storable.getNamespace(this.namespace).get(key); + return value !== null && value !== undefined; + }); + } + + /** + * Convert to a dynamic word + * @example + * ```typescript + * character.say(["You have ", persis.toWord("gold"), " gold"]); + * ``` + */ + public toWord>(key: K): Word { + return new Word(({storable}) => { + return [String(storable.getNamespace(this.namespace).get(key))]; + }); + } + + /** + * Create a conditional word + * + * @example + * ```typescript + * character.say([ + * "Your flag is ", + * persis.conditional( + * persis.isTrue("flag"), + * "on", + * "off" + * ) + * ]); + * ``` + */ + public conditional( + condition: Lambda | LambdaHandler, + ifTrue: DynamicWordResult, + ifFalse: DynamicWordResult + ): Word { + return new Word((ctx) => { + const isTrue = Lambda.from(condition).evaluate(ctx).value; + return isTrue ? ifTrue : ifFalse; + }); + } + + /**@internal */ + getNamespaceName(): string { + return this.namespace; + } + + /**@internal */ + private createAction>( + type: U, + content: PersistentActionContentType[U] + ): PersistentAction { + return new PersistentAction( + this.chain(), + type, + ContentNode.create(content) + ); + } +} diff --git a/src/game/nlcore/store/storable.ts b/src/game/nlcore/elements/persistent/storable.ts similarity index 87% rename from src/game/nlcore/store/storable.ts rename to src/game/nlcore/elements/persistent/storable.ts index c580c55..e892739 100644 --- a/src/game/nlcore/store/storable.ts +++ b/src/game/nlcore/elements/persistent/storable.ts @@ -6,8 +6,9 @@ import { StorableData, StorableType, WrappedStorableData -} from "@core/store/type"; +} from "@core/elements/persistent/type"; import {deepMerge} from "@lib/util/data"; +import {RuntimeGameError} from "@core/common/Utils"; export class Namespace> { static isSerializable(value: any): boolean { @@ -43,7 +44,7 @@ export class Namespace> { public set(key: Key, value: T[Key]): this { if (!Namespace.isSerializable(value)) { - console.warn(`Value "${value}" in key "${String(key)}" is not serializable, and will not be set\nat namespace "${this.name}"`); + console.warn(`Value "${value}" in key "${String(key)}" is not serializable, and will not be set\n at namespace "${this.name}"`); this.content[key] = value; return this; } @@ -55,6 +56,17 @@ export class Namespace> { return this.content[key] as T[Key]; } + public equals(key: Key, value: T[Key]): boolean { + return this.content[key] === value; + } + + public assign(values: Partial): this { + Object.entries(values).forEach(([key, value]) => { + this.set(key as keyof T, value as any); + }); + return this; + } + /**@internal */ toData(): { [key: string]: WrappedStorableData } { return this.serialize(); @@ -131,13 +143,16 @@ export class Storable { public addNamespace>(namespace: Namespace) { if (this.namespaces[namespace.key]) { - console.warn(`Namespace ${namespace.key} already exists`); + return; } this.namespaces[namespace.key] = namespace; return this; } public getNamespace = any>(key: string): Namespace { + if (!this.namespaces[key]) { + throw new RuntimeGameError(`Namespace ${key} is not initialized`); + } return this.namespaces[key]; } @@ -146,6 +161,10 @@ export class Storable { return this; } + public hasNamespace(key: string) { + return !!this.namespaces[key]; + } + public getNamespaces() { return this.namespaces; } diff --git a/src/game/nlcore/store/type.ts b/src/game/nlcore/elements/persistent/type.ts similarity index 100% rename from src/game/nlcore/store/type.ts rename to src/game/nlcore/elements/persistent/type.ts diff --git a/src/game/nlcore/elements/scene.ts b/src/game/nlcore/elements/scene.ts index 78767d2..602ce5d 100644 --- a/src/game/nlcore/elements/scene.ts +++ b/src/game/nlcore/elements/scene.ts @@ -6,22 +6,17 @@ import {LogicAction} from "@core/action/logicAction"; import {Transform} from "@core/elements/transform/transform"; import {IImageTransition, ITransition} from "@core/elements/transition/type"; import {SrcManager} from "@core/action/srcManager"; -import {Sound, SoundDataRaw} from "@core/elements/sound"; +import {Sound, SoundDataRaw, VoiceIdMap, VoiceSrcGenerator} from "@core/elements/sound"; import {TransformDefinitions} from "@core/elements/transform/type"; -import { - ImageActionContentType, - ImageActionTypes, - SceneActionContentType, - SceneActionTypes -} from "@core/action/actionTypes"; -import {Image, ImageDataRaw} from "@core/elements/image"; -import {Control, Utils} from "@core/common/core"; +import {SceneActionContentType, SceneActionTypes} from "@core/action/actionTypes"; +import {Image, ImageDataRaw, VirtualImageProxy} from "@core/elements/displayable/image"; +import {Control, Story, Utils} from "@core/common/core"; import {Chained, Proxied} from "@core/action/chain"; import {SceneAction} from "@core/action/actions/sceneAction"; import {ImageAction} from "@core/action/actions/imageAction"; import {SoundAction} from "@core/action/actions/soundAction"; import {ControlAction} from "@core/action/actions/controlAction"; -import {Text} from "@core/elements/text"; +import {Text} from "@core/elements/displayable/text"; import {RGBColor} from "@core/common/Utils"; import Actions = LogicAction.Actions; import ImageTransformProps = TransformDefinitions.ImageTransformProps; @@ -33,7 +28,7 @@ export type SceneConfig = { invertX: boolean; backgroundMusic: Sound | null; backgroundMusicFade: number; - backgroundImage: Image; + voices: VoiceIdMap | VoiceSrcGenerator | null; } & { background: ImageSrc | ImageColor | null; }; @@ -43,13 +38,16 @@ export interface ISceneConfig { invertX: boolean; backgroundMusic: Sound | null; backgroundMusicFade: number; + voices?: VoiceIdMap | VoiceSrcGenerator; background?: ImageSrc | ImageColor; } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export type SceneState = {}; +export type SceneState = { + backgroundImageProxy: VirtualImageProxy; +}; export type JumpConfig = { transition: IImageTransition; + unloadScene: boolean; } type ChainableAction = Proxied> | Actions; @@ -96,19 +94,34 @@ export class Scene extends Constructable< "event:displayable.init": "event:displayable.init", }; /**@internal */ - static defaultConfig: Omit = { + static defaultConfig: ISceneConfig = { invertY: false, invertX: false, backgroundMusic: null, backgroundMusicFade: 0, }; /**@internal */ - static defaultState: SceneState = {}; + static defaultState: SceneState = { + backgroundImageProxy: new VirtualImageProxy(), + }; + + /**@internal */ + static isScene(object: any): object is Scene { + return object instanceof Scene; + } + + /**@internal */ + static getScene(story: Story, targetScene: Scene | string): Scene | null { + if (typeof targetScene === "string") { + return story.getScene(targetScene); + } + return targetScene; + } /**@internal */ readonly name: string; /**@internal */ - readonly config: SceneConfig; + config: SceneConfig; /**@internal */ readonly srcManager: SrcManager = new SrcManager(); /**@internal */ @@ -116,21 +129,26 @@ export class Scene extends Constructable< /**@internal */ state: SceneConfig & SceneState; /**@internal */ - sceneRoot?: SceneAction<"scene:action">; + actions: (ChainableAction | ChainableAction[])[] | ((scene: Scene) => ChainableAction[]) = []; + /**@internal */ + private sceneRoot?: SceneAction<"scene:action">; + /**@internal */ + private _userConfig: Partial = {}; - constructor(name: string, config: Partial = Scene.defaultConfig) { + constructor(name: string, config?: Partial) { super(); this.name = name; - const {background, ...rest} = deepMerge(Scene.defaultConfig, config); + this._userConfig = config || {}; + const {background, voices, ...rest} = deepMerge(Scene.defaultConfig, config || {}); this.config = { ...rest, - backgroundImage: new Image({ - opacity: 1, - }), + voices: voices || null, background: background || null, }; - this.state = deepMerge(this.config, {}); + this.state = deepMerge(this.config, { + backgroundImageProxy: this._createImageProxy(), + }); } /**@internal */ @@ -147,7 +165,7 @@ export class Scene extends Constructable< /** * Activate the scene * - * This is only used when auto activation is not working + * This is only used when auto activation isn't working * @chainable */ public activate(): ChainedScene { @@ -157,7 +175,7 @@ export class Scene extends Constructable< /** * Deactivate the scene * - * This is only used when auto deactivation is not working + * This is only used when auto deactivation isn't working * @chainable */ public deactivate(): ChainedScene { @@ -165,7 +183,7 @@ export class Scene extends Constructable< } /** - * Set background, if {@link transition} is provided, it will be applied + * Set background, if {@link transition} is provided, it'll be applied * @chainable */ public setBackground(background: UserImageInput, transition?: IImageTransition): ChainedScene { @@ -173,7 +191,7 @@ export class Scene extends Constructable< if (transition) { const copy = transition.copy(); copy.setSrc(this.toBackground(background)); - chain._transitionToScene(undefined, copy, this.toBackground(background)); + chain._transitionToScene(copy, undefined, this.toBackground(background)); } return chain.chain(new SceneAction<"scene:setBackground">( chain, @@ -202,25 +220,29 @@ export class Scene extends Constructable< /** * Jump to the specified scene * - * After calling the method, you **will not be able to return to the context of the scene** that called the jump, so the scene will be unloaded + * After calling the method, you **won't be able to return to the context of the scene** that called the jump, + * so the scene will be unloaded * - * Any operations after the jump operation will not be executed + * Any operations after the jump operation won't be executed * @chainable */ - public jumpTo(arg0: Scene, config?: Partial): ChainedScene { + public jumpTo(scene: Scene | string, config: Partial = {}): ChainedScene { return this.combineActions(new Control(), chain => { - const jumpConfig: Partial = config || {}; - return chain + const defaultJumpConfig: Partial = {unloadScene: true}; + const jumpConfig = deepMerge(defaultJumpConfig, config); + chain .chain(new SceneAction( chain, "scene:preUnmount", new ContentNode().setContent([]) )) - ._transitionToScene(arg0, jumpConfig.transition) - .chain(arg0._init()) - .chain(this._exit()) - ._jumpTo(arg0); - }); + ._transitionToScene(jumpConfig.transition, scene) + .chain(this._init(scene)); + if (jumpConfig.unloadScene) { + chain.chain(this._exit()); + } + return chain; + })._jumpTo(scene); } /** @@ -263,7 +285,7 @@ export class Scene extends Constructable< backgroundMusic: this.state.backgroundMusic?.toData(), background: this.state.background, }, - backgroundImageState: this.state.backgroundImage.toData(), + backgroundImageState: this.state.backgroundImageProxy.toData(), } satisfies SceneDataRaw; } @@ -286,7 +308,7 @@ export class Scene extends Constructable< }, backgroundImageState: (backgroundImageState) => { if (backgroundImageState) { - this.state.backgroundImage = new Image().fromData(backgroundImageState); + this.state.backgroundImageProxy = new Image().fromData(backgroundImageState); } }, }); @@ -298,7 +320,7 @@ export class Scene extends Constructable< return new Transform([ { props: { - ...this.state.backgroundImage.state, + ...this.state.backgroundImageProxy.state, opacity: 1, }, options: { @@ -316,6 +338,19 @@ export class Scene extends Constructable< public action(actions: ((scene: Scene) => ChainableAction[])): this; public action(actions: (ChainableAction | ChainableAction[])[] | ((scene: Scene) => ChainableAction[])): this { + this.actions = actions; + return this; + } + + /**@internal */ + constructSceneRoot(story: Story): this { + this.sceneRoot = new SceneAction<"scene:action">( + this.chain(), + "scene:action", + new ContentNode(), + ); + + const actions = this.actions; const userChainedActions: ChainableAction[] = Array.isArray(actions) ? actions.flat(2) : actions(this).flat(2); const userActions = userChainedActions.map(v => { if (Chained.isChained(v)) { @@ -325,7 +360,7 @@ export class Scene extends Constructable< }).flat(2); const images: Image[] = [], texts: Text[] = []; - this.getAllChildrenElements(userActions).forEach(element => { + this.getAllChildrenElements(story, userActions).forEach(element => { if (Chained.isChained(element)) { return; } @@ -337,7 +372,7 @@ export class Scene extends Constructable< }); // disable auto initialization for wearables, - // wearables cannot be initialized by the scene, + // the scene can't initialize wearables, // they must be initialized by the image const @@ -366,7 +401,9 @@ export class Scene extends Constructable< const futureActions = [ this._init(this), - ...nonWearableImages.map(image => image._init()), + ...nonWearableImages + .filter(image => image.config.autoInit) + .map(image => image._init()), ...usedWearableImages.map(image => { if (!wearableImagesMap.has(image)) { throw new Error("Wearable image must have a parent image"); @@ -378,27 +415,44 @@ export class Scene extends Constructable< ]; const constructed = super.constructNodes(futureActions); - const sceneRoot = new ContentNode(undefined, undefined, constructed || void 0).setContent(this); + const sceneRoot = new ContentNode(this.sceneRoot, undefined, constructed || void 0).setContent(this); constructed?.setParent(sceneRoot); - this.sceneRoot = new SceneAction( - this.chain(), - "scene:action", - sceneRoot - ); + this.sceneRoot?.setContentNode(sceneRoot); + + return this; + } + + /**@internal */ + isSceneRootConstructed(): boolean { + return !!this.sceneRoot; + } + + /** + * Inherit configuration from another scene + */ + public inherit(scene: Scene): this { + const {background, ...rest} = deepMerge(Scene.defaultConfig, scene.config, this._userConfig); + this.config = { + ...rest, + background: background || null, + }; + this.state = deepMerge(this.config, { + backgroundImageProxy: this._createImageProxy(), + }); return this; } /**@internal */ - registerSrc(seen: Set = new Set()) { + registerSrc(story: Story, seen: Set = new Set()) { if (!this.sceneRoot) { return; } // [0.0.5] - 2024/10/04 - // Without this check, this method will enter cycle and cost a lot of time - // For example, Control will add some actions to the scene, ths check will not stop correctly + // Without this check, this method will enter the cycle and cost a lot of time, + // For example, Control will add some actions to the scene, this check won't stop correctly const seenActions = new Set(); const seenJump = new Set>(); @@ -419,7 +473,15 @@ export class Scene extends Constructable< if (action instanceof SceneAction) { if (action.type === SceneActionTypes.jumpTo) { const jumpTo = action as SceneAction; - const scene = jumpTo.contentNode.getContent()[0]; + const scene = Scene.getScene(story, jumpTo.contentNode.getContent()[0]); + if (!scene) { + throw action._sceneNotFoundError(action.getSceneName(jumpTo.contentNode.getContent()[0])); + } + + const background = SrcManager.getPreloadableSrc(story, action); + if (background) { + this.srcManager.register(background); + } if (seenJump.has(jumpTo) || seen.has(scene)) { continue; @@ -429,35 +491,29 @@ export class Scene extends Constructable< futureScene.add(scene); seen.add(scene); } else if (action.type === SceneActionTypes.setBackground) { - const content = (action.contentNode as ContentNode).getContent()[0]; - const src = Utils.backgroundToSrc(content); + const src = SrcManager.getPreloadableSrc(story, action); if (src) { - this.srcManager.register(new Image({src})); + this.srcManager.register(src); } } } else if (action instanceof ImageAction) { - const imageAction = action as ImageAction; - this.srcManager.register(imageAction.callee); - if (action.type === ImageActionTypes.setSrc) { - const content = (action.contentNode as ContentNode).getContent()[0]; - this.srcManager.register(new Image({src: content})); - } else if (action.type === ImageActionTypes.initWearable) { - const image = (action.contentNode as ContentNode).getContent()[0]; - this.srcManager.register(image); + const src = SrcManager.getPreloadableSrc(story, action); + if (src) { + this.srcManager.register(src); } } else if (action instanceof SoundAction) { this.srcManager.register(action.callee); } else if (action instanceof ControlAction) { const controlAction = action as ControlAction; - const actions = controlAction.getFutureActions(); + const actions = controlAction.getFutureActions(story); queue.push(...actions); } - queue.push(...action.getFutureActions()); + queue.push(...action.getFutureActions(story)); } futureScene.forEach(scene => { - scene.registerSrc(seen); + scene.registerSrc(story, seen); this.srcManager.registerFuture(scene.srcManager); }); } @@ -465,8 +521,8 @@ export class Scene extends Constructable< /** * @internal STILL IN DEVELOPMENT */ - assignActionId() { - const actions = this.getAllChildren(this.sceneRoot || []); + assignActionId(story: Story) { + const actions = this.getAllChildren(story, this.sceneRoot || []); actions.forEach((action, i) => { action.setId(`action-${i}`); @@ -476,40 +532,73 @@ export class Scene extends Constructable< /** * @internal STILL IN DEVELOPMENT */ - assignElementId() { - const elements = this.getAllChildrenElements(this.sceneRoot || []); + assignElementId(story: Story) { + const elements = this.getAllChildrenElements(story, this.sceneRoot || []); elements.forEach((element, i) => { element.setId(`element-${i}`); }); } + /**@internal */ + getVoice(id: string | number | null): string | Sound | null { + if (!id) { + return null; + } + + const voices = this.config.voices; + if (voices) { + if (typeof voices === "function") { + return voices(id); + } + return voices[id] || null; + } + return null; + } + + /**@internal */ + getSceneRoot(): SceneAction<"scene:action"> { + if (!this.sceneRoot) { + throw new Error("Scene root is not constructed"); + } + return this.sceneRoot; + } + /**@internal */ override reset() { this.state = deepMerge(Scene.defaultState, this.config); - this.state.backgroundImage.reset(); + this.state.backgroundImageProxy.reset(); } /**@internal */ toDisplayableTransform(): Transform { - return this.state.backgroundImage.toDisplayableTransform(); + return this.state.backgroundImageProxy.toDisplayableTransform(); + } + + /** + * Manually register an image to preload + */ + public requestImagePreload(src: ImageSrc) { + this.srcManager.register({ + type: "image", + src: new Image({src}), + }); } /**@internal */ - private _applyTransition(transition: ITransition): ChainedScene { - return this.chain(new SceneAction<"scene:applyTransition">( - this.chain(), - "scene:applyTransition", - new ContentNode().setContent([transition]) - )); + private _createImageProxy(): VirtualImageProxy { + return new VirtualImageProxy({ + opacity: 1, + src: Image.DefaultImagePlaceholder, + }); } /**@internal */ - private _jumpTo(scene: Scene): ChainedScene { - return this.chain(new SceneAction( + private _jumpTo(scene: Scene | string): ChainedScene { + return this.chain(new SceneAction<"scene:jumpTo">( this.chain(), "scene:jumpTo", - new ContentNode<[Scene]>().setContent([ + new ContentNode().setContent([ scene ]) )); @@ -525,26 +614,26 @@ export class Scene extends Constructable< } /**@internal */ - private _transitionToScene(scene?: Scene, transition?: IImageTransition, src?: ImageSrc | ImageColor): ChainedScene { + private _transitionToScene(transition?: IImageTransition, scene?: Scene | string, src?: ImageSrc | ImageColor): ChainedScene { const chain = this.chain(); if (transition) { const copy = transition.copy(); - - if (scene && scene.config.background) { - copy.setSrc(scene.config.background); - } - if (src) copy.setSrc(src); - chain._applyTransition(copy); + const action = new SceneAction( + chain, + SceneActionTypes["transitionToScene"], + new ContentNode().setContent([copy, scene, src]) + ); + chain.chain(action); } return chain; } /**@internal */ - private _init(target = this): SceneAction<"scene:init"> { - return new SceneAction( - target.chain(), + private _init(target: Scene | string): SceneAction<"scene:init"> { + return new SceneAction<"scene:init">( + this.chain(), "scene:init", - new ContentNode().setContent([]) + new ContentNode().setContent([target]) ); } } diff --git a/src/game/nlcore/elements/script.ts b/src/game/nlcore/elements/script.ts index d870aa6..eeb32e7 100644 --- a/src/game/nlcore/elements/script.ts +++ b/src/game/nlcore/elements/script.ts @@ -4,7 +4,7 @@ import {LogicAction} from "@core/action/logicAction"; import {Actionable} from "@core/action/actionable"; import {GameState} from "@player/gameState"; import {Chained, Proxied} from "@core/action/chain"; -import type {Storable} from "@core/store/storable"; +import type {Storable} from "@core/elements/persistent/storable"; import {ScriptAction} from "@core/action/actions/scriptAction"; import {LiveGame} from "@core/liveGame"; diff --git a/src/game/nlcore/elements/sound.ts b/src/game/nlcore/elements/sound.ts index 54286e7..a6a5c96 100644 --- a/src/game/nlcore/elements/sound.ts +++ b/src/game/nlcore/elements/sound.ts @@ -20,6 +20,8 @@ export enum SoundType { export type SoundDataRaw = { config: SoundConfig; }; +export type VoiceIdMap = Record; +export type VoiceSrcGenerator = (id: string | number) => string | Sound; export type SoundConfig = { /** diff --git a/src/game/nlcore/elements/story.ts b/src/game/nlcore/elements/story.ts index cc97115..8c162c0 100644 --- a/src/game/nlcore/elements/story.ts +++ b/src/game/nlcore/elements/story.ts @@ -1,9 +1,12 @@ import {Constructable} from "../action/constructable"; import {deepMerge} from "@lib/util/data"; import {Scene} from "@core/elements/scene"; -import {StaticChecker} from "@core/common/Utils"; +import {RuntimeScriptError, StaticChecker} from "@core/common/Utils"; import {RawData} from "@core/action/tree/actionTree"; import {SceneAction} from "@core/action/actions/sceneAction"; +import {LogicAction} from "@core/action/logicAction"; +import {Persistent} from "@core/elements/persistent"; +import {Storable} from "@core/elements/persistent/storable"; /* eslint-disable @typescript-eslint/no-empty-object-type */ export type StoryConfig = {}; @@ -14,13 +17,21 @@ export class Story extends Constructable< SceneAction<"scene:action">, Story > { + /**@internal */ static defaultConfig: StoryConfig = {}; + /**@internal */ + static MAX_DEPTH = 10000; + /**@internal */ readonly name: string; /**@internal */ readonly config: StoryConfig; /**@internal */ entryScene: Scene | null = null; + /**@internal */ + scenes: Map = new Map(); + /**@internal */ + persistent: Persistent[] = []; constructor(name: string, config: StoryConfig = {}) { super(); @@ -42,6 +53,60 @@ export class Story extends Constructable< return this; } + /** + * Register a scene to the story + * @example + * ```typescript + * // register a scene + * const story = new Story("story"); + * const scene1 = new Scene("scene1"); + * const scene2 = new Scene("scene2"); + * + * story.register(scene1); // Register scene1 + * + * scene2.action([ + * scene2.jump("scene1") // Jump to scene1 + * ]); + * ``` + */ + public registerScene(name: string, scene: Scene): this; + public registerScene(scene: Scene): this; + public registerScene(arg0: string | Scene, arg1?: Scene): this { + const name = typeof arg0 === "string" ? arg0 : arg0.name; + const scene = typeof arg0 === "string" ? arg1! : arg0; + + if (this.scenes.has(name) && this.scenes.get(name) !== scene) { + throw new Error(`Scene with name ${name} already exists when registering scene`); + } + this.scenes.set(name, scene); + return this; + } + + /** + * Register a Persistent to the story + * + * You can't use a Persistent that isn't registered to the story + */ + public registerPersistent(persistent: Persistent): this { + this.persistent.push(persistent); + return this; + } + + /** + * @internal + */ + getScene(name: string | Scene, assert: true, error?: (message: string) => Error): Scene; + getScene(name: string | Scene, assert?: false): Scene | null; + getScene(name: string | Scene, assert = false, error?: (message: string) => Error): Scene | null { + if (Scene.isScene(name)) return name; + const scene = this.scenes.get(name) || null; + if (!scene && assert) { + const constructor = error || RuntimeScriptError; + throw Reflect.construct(constructor, [`Scene with name ${name} not found`]); + } + return scene; + } + /**@internal */ constructStory(): this { const scene = this.entryScene; @@ -50,9 +115,10 @@ export class Story extends Constructable< throw new Error("Story must have an entry scene"); } - scene.registerSrc(); - scene.assignActionId(); - scene.assignElementId(); + this.constructSceneRoots(scene); + scene.registerSrc(this); + scene.assignActionId(this); + scene.assignElementId(this); this.runStaticCheck(scene); return this; @@ -60,7 +126,7 @@ export class Story extends Constructable< /**@internal */ getAllElementStates(): RawData[] { - const elements = this.getAllChildrenElements(this.entryScene?.sceneRoot || []); + const elements = this.getAllChildrenElements(this, this.entryScene?.getSceneRoot() || []); return elements .map(e => { return { @@ -71,9 +137,49 @@ export class Story extends Constructable< .filter(e => !!e.data); } + /**@internal */ + constructSceneRoots(entryScene: Scene): this { + const seen = new Set(); + const queue: LogicAction.Actions[] = []; + let depth = 0; + + entryScene.constructSceneRoot(this); + queue.push(entryScene.getSceneRoot()); + + while (queue.length) { + depth++; + if (depth > Story.MAX_DEPTH) { + throw new Error(`Max depth reached while constructing scene roots (max depth: ${Story.MAX_DEPTH})`); + } + + const action = queue.shift()!; + if (Scene.isScene(action.callee)) { + if (seen.has(action.callee)) { + continue; + } + if (!action.callee.isSceneRootConstructed()) { + action.callee.constructSceneRoot(this); + } + seen.add(action.callee); + } + + const children = action.getFutureActions(this); + queue.push(...children); + } + return this; + } + + /**@internal */ + initPersistent(storable: Storable): this { + this.persistent.forEach(persistent => { + persistent.init(storable); + }); + return this; + } + /**@internal */ private runStaticCheck(scene: Scene) { - return new StaticChecker(scene).run(); + return new StaticChecker(scene).run(this); } } diff --git a/src/game/nlcore/elements/transform/transform.ts b/src/game/nlcore/elements/transform/transform.ts index 33e18f0..003a2a9 100644 --- a/src/game/nlcore/elements/transform/transform.ts +++ b/src/game/nlcore/elements/transform/transform.ts @@ -9,7 +9,7 @@ import {CSSProps} from "@core/elements/transition/type"; import {Utils} from "@core/common/Utils"; import {animate} from "framer-motion/dom"; import React from "react"; -import {ImageConfig} from "@core/elements/image"; +import {ImageConfig} from "@core/elements/displayable/image"; import Sequence = TransformDefinitions.Sequence; import SequenceProps = TransformDefinitions.SequenceProps; diff --git a/src/game/nlcore/elements/transition/baseTransitions.ts b/src/game/nlcore/elements/transition/baseTransitions.ts index 6d44056..1a07ee6 100644 --- a/src/game/nlcore/elements/transition/baseTransitions.ts +++ b/src/game/nlcore/elements/transition/baseTransitions.ts @@ -3,6 +3,7 @@ import {ElementProp, EventTypes, IImageTransition, ITransition, TransitionEventT import {animate} from "framer-motion/dom"; import type {AnimationPlaybackControls, ValueAnimationTransition} from "framer-motion"; import {ImageColor, ImageSrc} from "@core/types"; +import {TransformDefinitions} from "@core/common/types"; export class BaseTransition implements ITransition { @@ -64,6 +65,8 @@ export class BaseTransition implements ITransition { export class BaseImageTransition extends BaseTransition implements IImageTransition { + static DefaultEasing: TransformDefinitions.EasingDefinition = "linear"; + public setSrc(_src?: ImageSrc | ImageColor): this { throw new Error("Method not implemented."); } diff --git a/src/game/nlcore/elements/transition/imageTransitions/dissolve.ts b/src/game/nlcore/elements/transition/imageTransitions/dissolve.ts index f3fb7aa..2ab5a85 100644 --- a/src/game/nlcore/elements/transition/imageTransitions/dissolve.ts +++ b/src/game/nlcore/elements/transition/imageTransitions/dissolve.ts @@ -18,7 +18,7 @@ export class Dissolve extends BaseImageTransition implements IIm opacity: 0, }; private src?: ImageSrc | ImageColor; - private readonly easing: TransformDefinitions.EasingDefinition | undefined; + private readonly easing: TransformDefinitions.EasingDefinition; /** * Image will dissolve from one image to another @@ -26,7 +26,7 @@ export class Dissolve extends BaseImageTransition implements IIm constructor(duration: number = 1000, easing?: TransformDefinitions.EasingDefinition) { super(); this.duration = duration; - this.easing = easing; + this.easing = easing || BaseImageTransition.DefaultEasing; } setSrc(src: ImageSrc | ImageColor | undefined): this { diff --git a/src/game/nlcore/elements/transition/imageTransitions/fade.ts b/src/game/nlcore/elements/transition/imageTransitions/fade.ts index e5bcb9b..7b7ee1b 100644 --- a/src/game/nlcore/elements/transition/imageTransitions/fade.ts +++ b/src/game/nlcore/elements/transition/imageTransitions/fade.ts @@ -12,7 +12,7 @@ export class Fade extends BaseImageTransition implements IImageT opacity: 1, }; private src?: ImageSrc | ImageColor; - private readonly easing: TransformDefinitions.EasingDefinition | undefined; + private readonly easing: TransformDefinitions.EasingDefinition; /** * The current image will fade out, and the next image will fade in @@ -20,7 +20,7 @@ export class Fade extends BaseImageTransition implements IImageT constructor(duration: number = 1000, ease?: TransformDefinitions.EasingDefinition) { super(); this.duration = duration; - this.easing = ease; + this.easing = ease || BaseImageTransition.DefaultEasing; } setSrc(src: ImageSrc | ImageColor | undefined): this { diff --git a/src/game/nlcore/elements/transition/imageTransitions/fadeIn.ts b/src/game/nlcore/elements/transition/imageTransitions/fadeIn.ts index e32bbd2..af985d7 100644 --- a/src/game/nlcore/elements/transition/imageTransitions/fadeIn.ts +++ b/src/game/nlcore/elements/transition/imageTransitions/fadeIn.ts @@ -15,7 +15,7 @@ export class FadeIn extends BaseImageTransition implements IImag transform: "" }; private src?: ImageSrc | ImageColor; - private readonly easing: TransformDefinitions.EasingDefinition | undefined; + private readonly easing: TransformDefinitions.EasingDefinition; /** * The next image will fade-in in a direction @@ -29,7 +29,7 @@ export class FadeIn extends BaseImageTransition implements IImag this.duration = duration; this.direction = direction; this.offset = offset; - this.easing = easing; + this.easing = easing || BaseImageTransition.DefaultEasing; this.__stack = getCallStack(); } diff --git a/src/game/nlcore/elements/type.ts b/src/game/nlcore/elements/type.ts new file mode 100644 index 0000000..0d6d949 --- /dev/null +++ b/src/game/nlcore/elements/type.ts @@ -0,0 +1,8 @@ +import {TagGroupDefinition} from "@core/elements/displayable/image"; +import {ScriptCtx} from "@core/elements/script"; + +export type { + TagGroupDefinition, +}; +export type LambdaCtx = ScriptCtx; +export type LambdaHandler = (ctx: LambdaCtx) => T; \ No newline at end of file diff --git a/src/game/nlcore/game.ts b/src/game/nlcore/game.ts index 29ea8ff..7491b46 100644 --- a/src/game/nlcore/game.ts +++ b/src/game/nlcore/game.ts @@ -50,6 +50,12 @@ export class Game { skipKey: ["Control"], skipInterval: 100, ratioUpdateInterval: 50, + preloadDelay: 100, + preloadConcurrency: 5, + waitForPreload: false, + preloadAllImages: true, + forceClearCache: false, + maxPreloadActions: 10, }, elements: { say: { diff --git a/src/game/nlcore/gameTypes.ts b/src/game/nlcore/gameTypes.ts index d22a4bc..1d07d77 100644 --- a/src/game/nlcore/gameTypes.ts +++ b/src/game/nlcore/gameTypes.ts @@ -2,7 +2,7 @@ import {ContentNode, RawData} from "@core/action/tree/actionTree"; import {LogicAction} from "@core/action/logicAction"; import {ElementStateRaw} from "@core/elements/story"; import {PlayerStateData} from "@player/gameState"; -import {StorableData} from "@core/store/type"; +import {StorableData} from "@core/elements/persistent/type"; import {MenuComponent, SayComponent} from "@player/elements/type"; import React from "react"; @@ -43,24 +43,24 @@ export type GameConfig = { /** * Base width of the player in pixels, Image scale will be calculated based on this value * - * For 16/9, recommended value is 1920 + * For 16/9, the recommended value is 1920 */ width: number; /** * Base height of the player in pixels, Image scale will be calculated based on this value * - * For 16/9, recommended value is 1080 + * For 16/9, the recommended value is 1080 */ height: number; /** - * When player presses one of these keys, the game will skip the current action + * When the player presses one of these keys, the game will skip the current action * * See [Key_Values](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) */ skipKey: React.KeyboardEvent["key"][]; /** * The interval in milliseconds between each skip action. - * ex: 100ms means the player can skip 10 actions per second. + * ex: 100 ms means the player can skip 10 actions per second. * higher value means faster skipping. */ skipInterval: number; @@ -68,6 +68,32 @@ export type GameConfig = { * The interval in milliseconds between each ratio update. */ ratioUpdateInterval: number; + /** + * The game will preload the image with this delay in milliseconds + */ + preloadDelay: number; + /** + * Maximum number of images to preload at the same time + */ + preloadConcurrency: number; + /** + * Wait for the images to load before showing the game + */ + waitForPreload: boolean; + /** + * Preload all possible images in the scene + * + * Enabling this may have a performance impact but is better for the user experience + */ + preloadAllImages: boolean; + /** + * Force the game to clear the cache when the scene changes + */ + forceClearCache: boolean; + /** + * The number of actions will be predicted and preloaded + */ + maxPreloadActions: number; }; elements: { say: { @@ -78,7 +104,7 @@ export type GameConfig = { */ nextKey: React.KeyboardEvent["key"][]; /** - * The speed of the text effect in milliseconds. + * The speed of the text effects in milliseconds. * higher value means slower text effect. * default: 50 */ @@ -98,8 +124,10 @@ export type GameConfig = { img: { /** * If true, the game will show a warning when loading takes longer than `elements.img.slowLoadThreshold` + * @deprecated */ slowLoadWarning: boolean; + /**@deprecated */ slowLoadThreshold: number; /** * If true, when you press [GameConfig.player.skipKey], the game will skip the image transform @@ -135,13 +163,13 @@ export type GameConfig = { /** * Base width of the dialog in pixels * - * For 16/9, recommended value is 1920 + * For 16/9, the recommended value is 1920 */ width: number; /** * Base height of the dialog in pixels * - * For 16/9, recommended value is 1080 * 0.2 + * For 16/9, the recommended value is 1080 * 0.2 */ height: number; } diff --git a/src/game/nlcore/liveGame.ts b/src/game/nlcore/liveGame.ts index 32fb24a..52c6266 100644 --- a/src/game/nlcore/liveGame.ts +++ b/src/game/nlcore/liveGame.ts @@ -2,10 +2,16 @@ import {Awaitable, Lock, MultiLock} from "@lib/util/data"; import type {CalledActionResult, SavedGame} from "@core/gameTypes"; import {Story} from "@core/elements/story"; import {GameState} from "@player/gameState"; -import {Namespace, Storable} from "@core/store/storable"; +import {Namespace, Storable} from "@core/elements/persistent/storable"; import {LogicAction} from "@core/action/logicAction"; -import {StorableType} from "@core/store/type"; +import {StorableType} from "@core/elements/persistent/type"; import {Game} from "@core/game"; +import {ContentNode} from "@core/action/tree/actionTree"; +import {ConditionAction} from "@core/action/actions/conditionAction"; +import {SceneAction} from "@core/action/actions/sceneAction"; +import {ControlActionTypes, SceneActionTypes} from "@core/action/actionTypes"; +import {Scene} from "@core/elements/scene"; +import {ControlAction} from "@core/action/actions/controlAction"; export class LiveGame { static DefaultNamespaces = { @@ -48,6 +54,9 @@ export class LiveGame { this.storable.clear().addNamespace(new Namespace>(LiveGame.GameSpacesKey.game, LiveGame.DefaultNamespaces.game)); + if (this.story) { + this.story.initPersistent(this.storable); + } return this; } @@ -58,7 +67,8 @@ export class LiveGame { /* Game */ /**@internal */ loadStory(story: Story) { - this.story = story.constructStory(); + this.story = story + .constructStory(); return this; } @@ -67,7 +77,7 @@ export class LiveGame { * * 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 + * Note: even if you change just a single line of script, the saved game might not be compatible with the new version */ public serialize(): SavedGame { if (!this.gameState) { @@ -104,7 +114,7 @@ export class LiveGame { /** * Load a saved game * - * Note: Even if you change just a single line of script, 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 */ @@ -134,7 +144,7 @@ export class LiveGame { } = savedGame; // construct maps - story.forEachChild(story.entryScene?.sceneRoot || [], action => { + story.forEachChild(story, story.entryScene?.getSceneRoot() || [], action => { actionMaps.set(action.getId(), action); elementMaps.set(action.callee.getId(), action.callee); }); @@ -177,6 +187,7 @@ export class LiveGame { throw new Error("No game state"); } const gameState = this.gameState; + const logGroup = gameState.logger.group("LiveGame"); this.reset({gameState}); this.initNamespaces(); @@ -186,7 +197,7 @@ export class LiveGame { this.currentSavedGame = newGame; const elements: Map | undefined = - this.story?.getAllElementMap(this.story?.entryScene?.sceneRoot || []); + this.story?.getAllElementMap(this.story, this.story?.entryScene?.getSceneRoot() || []); if (elements) { elements.forEach((element) => { gameState.logger.debug("reset element", element); @@ -198,6 +209,7 @@ export class LiveGame { gameState.stage.forceUpdate(); gameState.stage.next(); + logGroup.end(); return this; } @@ -208,7 +220,7 @@ export class LiveGame { this.lockedAwaiting.abort(); } - this.currentAction = this.story?.entryScene?.sceneRoot || null; + this.currentAction = this.story?.entryScene?.getSceneRoot() || null; this.lockedAwaiting = null; this.currentSavedGame = null; @@ -247,7 +259,7 @@ export class LiveGame { if (this._lockedCount > 1000) { // sometimes react will make it stuck and enter a dead cycle - // that's not cool, so we need to throw an error to break it + // that's not cool, so it need to throw an error to break it throw new Error("LiveGame locked: dead cycle detected\nPlease refresh the page"); } @@ -257,7 +269,7 @@ export class LiveGame { const next = this.lockedAwaiting.result; this.currentAction = next?.node?.action || null; - state.logger.debug("next action", next); + state.logger.debug("next action (lockedAwaiting)", next); this.lockedAwaiting = null; @@ -267,7 +279,7 @@ export class LiveGame { if (!this.currentAction) { state.events.emit(GameState.EventTypes["event:state.end"]); - state.logger.warn("LiveGame", "No current action"); // Congrats, you've reached the end of the story + state.logger.weakWarn("LiveGame", "No current action"); // Congrats, you've reached the end of the story this._nextLock.unlock(); return null; @@ -284,7 +296,6 @@ export class LiveGame { state.logger.debug("next action", nextAction); this._lockedCount = 0; - this.currentAction = nextAction.node?.action || null; this._nextLock.unlock(); @@ -319,6 +330,54 @@ export class LiveGame { return this.gameState; } + /**@internal */ + getAllPredictableActions(story: Story, action?: LogicAction.Actions | null, limit?: number): LogicAction.Actions[] { + let current: ContentNode | null = action?.contentNode || null; + const actions: LogicAction.Actions[] = []; + const queue: LogicAction.Actions[] = []; + const seenScene = new Set(); + + while (current || queue.length) { + if (limit && actions.length >= limit) { + break; + } + if (!current) { + current = queue.pop()!.contentNode; + } + + if ([ConditionAction].some(a => current?.action && current.action instanceof a)) { + current = null; + continue; + } + if (current.action && current.action.is>(SceneAction, SceneActionTypes.jumpTo)) { + const [targetScene] = current.action.contentNode.getContent(); + const scene = story.getScene(targetScene); + if (!scene) { + throw current.action._sceneNotFoundError(current.action.getSceneName(targetScene)); + } + + if (seenScene.has(scene)) { + current = null; + continue; + } + seenScene.add(scene); + + current = scene.getSceneRoot()?.contentNode || null; + continue; + } else if (current.action && + current.action.is>(ControlAction as any, ControlActionTypes.do) + ) { + const [content] = current.action.contentNode.getContent(); + if (current.getChild()?.action) queue.push(current.getChild()!.action!); + current = content[0]?.contentNode || null; + } + if (current.action) actions.push(current.action); + current = current.getChild(); + } + + return actions; + } + /**@internal */ private getNewSavedGame(): SavedGame { return { @@ -333,7 +392,7 @@ export class LiveGame { scenes: [], }, elementStates: [], - currentAction: this.story?.entryScene?.sceneRoot?.getId() || null, + currentAction: this.story?.entryScene?.getSceneRoot().getId() || null, } }; } diff --git a/src/game/player/elements/Player.tsx b/src/game/player/elements/Player.tsx index a3258dd..cc5bd12 100644 --- a/src/game/player/elements/Player.tsx +++ b/src/game/player/elements/Player.tsx @@ -42,7 +42,7 @@ export default function Player( const [state, dispatch] = useReducer(handleAction, new GameState(game, { update, forceUpdate: () => { - (state as GameState).logger.warn("Player", "force update"); + (state as GameState).logger.weakWarn("Player", "force update"); flushSync(() => { update(); }); @@ -79,7 +79,10 @@ export default function Player( } useEffect(() => { - game.getLiveGame().setGameState(state).loadStory(story); + game.getLiveGame().setGameState(state); + if (story) { + game.getLiveGame().loadStory(story); + } return () => { game.getLiveGame().setGameState(undefined); diff --git a/src/game/player/elements/displayable/Displayable.tsx b/src/game/player/elements/displayable/Displayable.tsx index f397282..a19be67 100644 --- a/src/game/player/elements/displayable/Displayable.tsx +++ b/src/game/player/elements/displayable/Displayable.tsx @@ -150,13 +150,13 @@ export default function Displayable( listener: transition.events.on(TransitionEventTypes.end, () => { setTransition(null); - gameState.logger.debug("scene background transition end", transition); + gameState.logger.debug("transition end", transition); }) }, { type: TransitionEventTypes.start, listener: transition.events.on(TransitionEventTypes.start, () => { - gameState.logger.debug("scene background transition start", transition); + gameState.logger.debug("transition start", transition); }) } ]); diff --git a/src/game/player/elements/displayable/Displayables.tsx b/src/game/player/elements/displayable/Displayables.tsx index 56ec4a4..6d931ed 100644 --- a/src/game/player/elements/displayable/Displayables.tsx +++ b/src/game/player/elements/displayable/Displayables.tsx @@ -1,16 +1,16 @@ import React from "react"; import {GameState} from "@player/gameState"; import {LogicAction} from "@core/action/logicAction"; -import {Text as GameText} from "@core/elements/text"; +import {Text as GameText} from "@core/elements/displayable/text"; import {default as StageText} from "@player/elements/displayable/Text"; -import {Image as GameImage} from "@core/elements/image"; +import {Image as GameImage} from "@core/elements/displayable/image"; import {default as StageImage} from "@player/elements/image/Image"; export default function Displayables( {state, displayable}: Readonly<{ state: GameState; - displayable: LogicAction.Displayable[]; + displayable: LogicAction.DisplayableElements[]; }>) { return (<> {displayable.map((displayable) => { diff --git a/src/game/player/elements/displayable/Text.tsx b/src/game/player/elements/displayable/Text.tsx index e1a3ebf..27e390d 100644 --- a/src/game/player/elements/displayable/Text.tsx +++ b/src/game/player/elements/displayable/Text.tsx @@ -1,5 +1,5 @@ import {GameState} from "@player/gameState"; -import {Text as GameText} from "@core/elements/text"; +import {Text as GameText} from "@core/elements/displayable/text"; import React from "react"; import {Transform, TransformersMap, TransformHandler} from "@core/elements/transform/transform"; import {SpanElementProp} from "@core/elements/transition/type"; diff --git a/src/game/player/elements/image/AspectScaleImage.tsx b/src/game/player/elements/image/AspectScaleImage.tsx index 3e9ab19..224f8c2 100644 --- a/src/game/player/elements/image/AspectScaleImage.tsx +++ b/src/game/player/elements/image/AspectScaleImage.tsx @@ -1,6 +1,9 @@ import React, {useEffect, useRef} from "react"; import {ImgElementProp} from "@core/elements/transition/type"; import {useRatio} from "@player/provider/ratio"; +import {usePreloaded} from "@player/provider/preloaded"; +import {Image} from "@core/elements/displayable/image"; +import {useGame} from "@core/common/player"; export default function AspectScaleImage( { @@ -18,6 +21,10 @@ export default function AspectScaleImage( const imgRef = useRef(null); const {ratio} = useRatio(); const [width, setWidth] = React.useState(0); + const {cacheManager} = usePreloaded(); + const {game} = useGame(); + + const LogTag = "AspectScaleImage"; function updateWidth() { const ref = Ref || imgRef; @@ -27,15 +34,23 @@ export default function AspectScaleImage( } useEffect(() => { - updateWidth(); - - return ratio.onUpdate(updateWidth); - }, [props.src]); + if (props.src && (!cacheManager.has(props.src) || cacheManager.isPreloading(props.src))) { + game.getLiveGame().getGameState()?.logger.warn(LogTag, + `Image not preloaded: "${props.src}". ` + + "\nThis may be caused by complicated image action behavior that cannot be predicted. " + + "\nTo fix this issue, you can manually register the image using scene.requestImagePreload(YourImageSrc). " + ); + } + }, [props, props.src, id]); useEffect(() => { updateWidth(); + + return ratio.onUpdate(updateWidth); }, [props, id]); + const src: string = props.src ? (cacheManager.get(props.src) || props.src) : Image.DefaultImagePlaceholder; + return ( {props.alt} ); } diff --git a/src/game/player/elements/image/Image.tsx b/src/game/player/elements/image/Image.tsx index 4631510..72fb11f 100644 --- a/src/game/player/elements/image/Image.tsx +++ b/src/game/player/elements/image/Image.tsx @@ -1,10 +1,8 @@ -import {Image as GameImage} from "@core/elements/image"; +import {Image as GameImage} from "@core/elements/displayable/image"; import React, {useEffect, useRef, useState} from "react"; import {GameState} from "@player/gameState"; import {deepMerge} from "@lib/util/data"; -import {Utils} from "@core/common/core"; import {ImgElementProp} from "@core/elements/transition/type"; -import {useGame} from "@player/provider/game-state"; import {DisplayableChildProps} from "@player/elements/displayable/type"; import Displayable from "@player/elements/displayable/Displayable"; import Inspect from "@player/lib/Inspect"; @@ -19,29 +17,10 @@ export default function Image({ image: GameImage; state: GameState; }>) { - const [startTime, setStartTime] = useState(0); - const {game} = useGame(); - - useEffect(() => { - setStartTime(performance.now()); - }, []); - /** * Slow load warning */ const handleLoad = () => { - const endTime = performance.now(); - const loadTime = endTime - startTime; - const threshold = game.config.elements.img.slowLoadThreshold; - - if (loadTime > threshold && game.config.elements.img.slowLoadWarning) { - state.logger.warn( - "NarraLeaf-React", - `Image took ${loadTime}ms to load, which exceeds the threshold of ${threshold}ms. ` + - "Consider enable cache for the image, so Preloader can preload it before it's used. " + - "To disable this warning, set `elements.img.slowLoadWarning` to false in the game config." - ); - } }; return ( @@ -80,7 +59,7 @@ function DisplayableImage( const [wearables, setWearables] = useState([]); const defaultProps: ImgElementProp = { - src: Utils.staticImageDataToSrc(image.state.src), + src: GameImage.getSrc(image.state), style: { ...(state.game.config.app.debug ? { outline: "1px solid red", diff --git a/src/game/player/elements/preload/Preload.tsx b/src/game/player/elements/preload/Preload.tsx index c2a1fb5..c0bbcc0 100644 --- a/src/game/player/elements/preload/Preload.tsx +++ b/src/game/player/elements/preload/Preload.tsx @@ -1,122 +1,186 @@ import {useEffect, useRef} from "react"; import {GameState} from "@player/gameState"; -import {Sound} from "@core/elements/sound"; -import {SrcManager} from "@core/action/srcManager"; +import {ActiveSrc, SrcManager} from "@core/action/srcManager"; import {usePreloaded} from "@player/provider/preloaded"; -import {Preloaded, PreloadedSrc} from "@player/lib/Preloaded"; -import {Image as GameImage} from "@core/elements/image"; -import {Utils} from "@core/common/Utils"; - -export function Preload({ - state, - }: Readonly<{ - state: GameState; -}>) { - const {preloaded} = usePreloaded(); +import {Preloaded} from "@player/lib/Preloaded"; +import {TaskPool} from "@lib/util/data"; +import {useGame} from "@player/provider/game-state"; + +export function Preload( + { + state, + }: Readonly<{ + state: GameState; + }>) { + const {preloaded, cacheManager} = usePreloaded(); + const {game} = useGame(); + const cachedSrc = useRef>(new Set()); + + const LogTag = "Preload"; const lastScene = state.getLastScene(); - const time = useRef(0); - + const currentAction = game.getLiveGame().getCurrentAction(); + const story = game.getLiveGame().story; + + /** + * preload logic 2.0 + * + * Fetch the images and store them as base64 in the stack + */ useEffect(() => { - if (typeof window === "undefined") { - console.warn("Window is not supported in this environment"); + if (typeof fetch === "undefined") { + preloaded.events.emit(Preloaded.EventTypes["event:preloaded.ready"]); + state.logger.warn(LogTag, "Fetch is not supported in this environment, skipping preload"); + return; + } + if (!game.config.player.preloadAllImages) { + preloaded.events.emit(Preloaded.EventTypes["event:preloaded.ready"]); + state.logger.debug(LogTag, "Preload all images is disabled, skipping preload"); + return; + } + if (game.config.player.forceClearCache) { + cacheManager.clear(); + state.logger.weakWarn(LogTag, "Cache cleared"); + } + if (!story) { + state.logger.weakWarn(LogTag, "Story not found, skipping preload"); return; } - if (window.performance) { - time.current = performance.now(); + const timeStart = performance.now(); + const sceneSrc = SrcManager.catSrc([ + ...(lastScene?.srcManager?.src || []), + ...(lastScene?.srcManager?.getFutureSrc() || []), + ]); + const taskPool = new TaskPool( + game.config.player.preloadConcurrency, + game.config.player.preloadDelay, + ); + const loadedSrc: string[] = []; + const logGroup = state.logger.group(LogTag, true); + + state.logger.debug(LogTag, "preloading:", sceneSrc); + + for (const image of sceneSrc.image) { + const src = SrcManager.getSrc(image); + loadedSrc.push(src); + + if (cacheManager.has(src) || cacheManager.isPreloading(src)) { + state.logger.debug(LogTag, `Image already loaded (${sceneSrc.image.indexOf(image) + 1}/${sceneSrc.image.length})`, src); + continue; + } + taskPool.addTask(() => new Promise(resolve => { + cacheManager.preload(src) + .onFinished(() => { + state.logger.debug(LogTag, `Image loaded (${sceneSrc.image.indexOf(image) + 1}/${sceneSrc.image.length})`, src); + resolve(); + }); + })); } - const currentSceneSrc = state.getLastScene()?.srcManager; - const futureSceneSrc = state.getLastScene()?.srcManager.future || []; - const combinedSrc = [ - ...(currentSceneSrc ? currentSceneSrc.src : []), - ...(futureSceneSrc.map(v => v.src)).flat(2), - ]; - - const src = { - image: new Set(), - audio: new Set(), - video: new Set() - }; + logGroup.end(); + + taskPool.start().then(() => { + state.logger.info(LogTag, "Image preload", `loaded ${cacheManager.size()} images in ${performance.now() - timeStart}ms`); - combinedSrc.forEach(srcItem => { - if (srcItem.type === SrcManager.SrcTypes.image) { - src.image.add(srcItem.src); - } else if (srcItem.type === SrcManager.SrcTypes.audio) { - src.audio.add(srcItem.src); - } else if (srcItem.type === SrcManager.SrcTypes.video) { - src.video.add(srcItem.src); + if (game.config.player.waitForPreload) { + preloaded.events.emit(Preloaded.EventTypes["event:preloaded.ready"]); } + state.events.emit(GameState.EventTypes["event:state.preload.loaded"]); + cacheManager.filter(loadedSrc); }); - state.logger.debug("Preloading", src, futureSceneSrc); + if (!game.config.player.waitForPreload) { + preloaded.events.emit(Preloaded.EventTypes["event:preloaded.ready"]); + } + preloaded.events.emit(Preloaded.EventTypes["event:preloaded.mount"]); - preloaded.preloaded = preloaded.preloaded.filter(p => { - if (p.type === SrcManager.SrcTypes.audio) { - let has = src[p.type].has((p as PreloadedSrc<"audio">).src); - if (!has) { - // downgraded check - has = Array.from(src[p.type]).some(s => { - return preloaded.getSrc(p) === preloaded.getSrc(s.config.src); - }); - } - return has; - } else if (p.type === SrcManager.SrcTypes.image) { - return src[p.type].has((p as PreloadedSrc<"image">).src); - } - const preloadedSrcP = preloaded.getSrc(p); - return src[p.type].has(preloadedSrcP); - }); + return () => { + state.events.emit(GameState.EventTypes["event:state.preload.unmount"]); + state.logger.debug(LogTag, "Preload unmounted"); + }; + }, [lastScene, story]); - const newImages: HTMLImageElement[] = []; - const promises: Promise[] = []; - src.image.forEach((src: GameImage) => { - const htmlImg = new Image(); - htmlImg.src = Utils.srcToString(src.state.src); - htmlImg.onload = () => { - state.logger.debug("Image loaded", src.state.src); - }; - newImages.push(htmlImg); - - preloaded.add({type: "image", src}); - }); + /** + * Remove cached src when scenes changed + */ + useEffect(() => { + cachedSrc.current.clear(); + }, [lastScene]); - 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"]); + /** + * predict preload logic + * + * Get future src and preload them + */ + useEffect(() => { + if (typeof fetch === "undefined") { + return; + } + if (game.config.player.preloadAllImages) { + return; + } + if (!story) { + state.logger.weakWarn(LogTag, "Story not found, skipping preload"); + return; + } - if (window.performance) { - const endTime = performance.now(); - const loadTime = endTime - time.current; - state.logger.info("Preload", `Preloaded ${src.image.size} images in ${loadTime}ms`); + const timeStart = performance.now(); + const allSrc: ActiveSrc[] = game + .getLiveGame() + .getAllPredictableActions(story, currentAction, game.config.player.maxPreloadActions) + .map(s => SrcManager.getPreloadableSrc(story, s)) + .filter(function (src): src is ActiveSrc { + return src !== null; + }); + const sceneBasedSrc = + allSrc.filter(function (src): src is ActiveSrc<"scene"> { + return src?.activeType === "scene"; + }); + sceneBasedSrc.forEach(src => { + if (cachedSrc.current.has(src)) { + return; } + cachedSrc.current.add(src); }); - src.audio.forEach((src: Sound) => { - if (!src.getPlaying()) { - src.setPlaying(new (state.getHowl())({ - src: src.config.src, - loop: src.config.loop, - volume: src.config.volume, - autoplay: false, - preload: true, - })); - } - }); + const actionSrc = SrcManager.catSrc([ + ...cachedSrc.current, + ...allSrc, + ]); - preloaded.events.emit(Preloaded.EventTypes["event:preloaded.mount"]); + const taskPool = new TaskPool( + game.config.player.preloadConcurrency, + game.config.player.preloadDelay, + ); + const preloadSrc: string[] = []; + const logGroup = state.logger.group(LogTag); - // maybe video preload here + state.logger.debug(LogTag, "preloading:", actionSrc); - return () => { - newImages.forEach(img => { - img.onload = null; - }); - state.events.emit(GameState.EventTypes["event:state.preload.unmount"]); - state.logger.debug("Preload unmounted"); - }; - }, [lastScene]); + for (const image of actionSrc.image) { + const src = SrcManager.getSrc(image); + preloadSrc.push(src); + + if (cacheManager.has(src) || cacheManager.isPreloading(src)) { + state.logger.debug(LogTag, `Image already loaded (${actionSrc.image.indexOf(image) + 1}/${actionSrc.image.length})`, src); + continue; + } + taskPool.addTask(() => new Promise(resolve => { + cacheManager.preload(src) + .onFinished(() => { + state.logger.debug(LogTag, `Image loaded (${actionSrc.image.indexOf(image) + 1}/${actionSrc.image.length})`, src); + resolve(); + }); + })); + } + + logGroup.end(); + + taskPool.start().then(() => { + state.logger.info(LogTag, "Image preload (quick reload)", `loaded ${cacheManager.size()} images in ${performance.now() - timeStart}ms`); + cacheManager.filter(preloadSrc); + }); + }, [currentAction, story]); return null; } diff --git a/src/game/player/elements/scene/BackgroundTransition.tsx b/src/game/player/elements/scene/BackgroundTransition.tsx index 5051b10..a24f3e0 100644 --- a/src/game/player/elements/scene/BackgroundTransition.tsx +++ b/src/game/player/elements/scene/BackgroundTransition.tsx @@ -9,6 +9,7 @@ import {DisplayableChildProps} from "@player/elements/displayable/type"; import {m} from "framer-motion"; import Displayable from "@player/elements/displayable/Displayable"; import {useRatio} from "@player/provider/ratio"; +import {usePreloaded} from "@player/provider/preloaded"; export default function BackgroundTransition({scene, props, state}: { scene: GameScene, @@ -47,6 +48,7 @@ function DisplayableBackground( }> ) { const {ratio} = useRatio(); + const {cacheManager} = usePreloaded(); const [imageLoaded, setImageLoaded] = React.useState(false); function handleImageOnload() { @@ -69,6 +71,13 @@ function DisplayableBackground( } }; + function tryGetCache(src: string | undefined): string { + if (src) { + return cacheManager.has(src) ? cacheManager.get(src)! : src; + } + return emptyImage; + } + return (
- { - transition ? (() => { - return transition.toElementProps().map((elementProps, index) => { - const mergedProps = - deepMerge(defaultProps, props, elementProps); - return ( - {mergedProps.alt} - ); - }); - })() : (() => { - const mergedProps = - deepMerge(defaultProps, props); - return ( - {mergedProps.alt} - ); - })() - } + {(transition ? transition.toElementProps() : [{}]).map((elementProps, index) => { + const mergedProps = + deepMerge(defaultProps, props, elementProps); + return ( + {mergedProps.alt} + ); + })}
); diff --git a/src/game/player/elements/type.ts b/src/game/player/elements/type.ts index 6f17d92..0313b28 100644 --- a/src/game/player/elements/type.ts +++ b/src/game/player/elements/type.ts @@ -5,7 +5,7 @@ import {Story} from "@core/elements/story"; import clsx from "clsx"; import {Game} from "@core/game"; import {GameState} from "@player/gameState"; -import {Storable} from "@core/store/storable"; +import {Storable} from "@core/elements/persistent/storable"; import {LiveGame} from "@core/liveGame"; export type Components> = (props: Readonly) => React.JSX.Element; diff --git a/src/game/player/gameState.ts b/src/game/player/gameState.ts index e03ff9b..50f8e89 100644 --- a/src/game/player/gameState.ts +++ b/src/game/player/gameState.ts @@ -1,24 +1,27 @@ import {CalledActionResult} from "@core/gameTypes"; -import {EventDispatcher, Logger, sleep} from "@lib/util/data"; +import {EventDispatcher, moveElementInArray, sleep} from "@lib/util/data"; import {Choice, MenuData} from "@core/elements/menu"; -import {Image, ImageEventTypes} from "@core/elements/image"; +import {Image, ImageEventTypes} from "@core/elements/displayable/image"; import {Scene} from "@core/elements/scene"; import {Sound} from "@core/elements/sound"; import * as Howler from "howler"; import {HowlOptions} from "howler"; import {SrcManager} from "@core/action/srcManager"; import {LogicAction} from "@core/action/logicAction"; -import {Storable} from "@core/store/storable"; +import {Storable} from "@core/elements/persistent/storable"; import {Game} from "@core/game"; import {Clickable, MenuElement, TextElement} from "@player/gameState.type"; import {Sentence} from "@core/elements/character/sentence"; import {SceneAction} from "@core/action/actions/sceneAction"; -import {Text, TextEventTypes} from "@core/elements/text"; +import {Text, TextEventTypes} from "@core/elements/displayable/text"; +import {Logger} from "@lib/util/logger"; +import {RuntimeGameError} from "@core/common/Utils"; +import {Story} from "@core/elements/story"; type PlayerStateElement = { texts: Clickable[]; menus: Clickable[]; - displayable: LogicAction.Displayable[]; + displayable: LogicAction.DisplayableElements[]; }; export type PlayerState = { sounds: Sound[]; @@ -83,7 +86,7 @@ export class GameState { return this.state.elements.find(e => e.scene === scene) || null; } - public findElementByDisplayable(displayable: LogicAction.Displayable): { + public findElementByDisplayable(displayable: LogicAction.DisplayableElements): { scene: Scene, ele: PlayerStateElement } | null { @@ -131,6 +134,54 @@ export class GameState { return false; } + public moveUpElement(scene: Scene, element: LogicAction.DisplayableElements): this { + const targetElement = this.findElementByScene(scene); + if (!targetElement) return this; + + targetElement.ele.displayable = moveElementInArray( + targetElement.ele.displayable, + element, + Math.min(targetElement.ele.displayable.indexOf(element) + 1, targetElement.ele.displayable.length - 1) + ); + return this; + } + + public moveDownElement(scene: Scene, element: LogicAction.DisplayableElements): this { + const targetElement = this.findElementByScene(scene); + if (!targetElement) return this; + + targetElement.ele.displayable = moveElementInArray( + targetElement.ele.displayable, + element, + Math.max(targetElement.ele.displayable.indexOf(element) - 1, 0) + ); + return this; + } + + public moveTopElement(scene: Scene, element: LogicAction.DisplayableElements): this { + const targetElement = this.findElementByScene(scene); + if (!targetElement) return this; + + targetElement.ele.displayable = moveElementInArray( + targetElement.ele.displayable, + element, + targetElement.ele.displayable.length - 1 + ); + return this; + } + + public moveBottomElement(scene: Scene, element: LogicAction.DisplayableElements): this { + const targetElement = this.findElementByScene(scene); + if (!targetElement) return this; + + targetElement.ele.displayable = moveElementInArray( + targetElement.ele.displayable, + element, + 0 + ); + return this; + } + handle(action: PlayerAction): this { if (this.currentHandling === action) return this; this.currentHandling = action; @@ -143,7 +194,7 @@ export class GameState { } public createText(id: string, sentence: Sentence, afterClick?: () => void, scene?: Scene) { - const texts = this.findElementByScene(this._getLastSceneIfNot(scene))?.ele.texts; + const texts = this.findElementByScene(this.getLastSceneIfNot(scene))?.ele.texts; if (!texts) { throw new Error("Scene not found"); } @@ -163,7 +214,7 @@ export class GameState { if (!menu.choices.length) { throw new Error("Menu must have at least one choice"); } - const menus = this.findElementByScene(this._getLastSceneIfNot(scene))?.ele.menus; + const menus = this.findElementByScene(this.getLastSceneIfNot(scene))?.ele.menus; if (!menus) { throw new Error("Scene not found"); } @@ -176,7 +227,7 @@ export class GameState { } public createImage(image: Image, scene?: Scene) { - const targetScene = this._getLastSceneIfNot(scene); + const targetScene = this.getLastSceneIfNot(scene); const targetElement = this.findElementByScene(targetScene); if (!targetElement) return this; targetElement.ele.displayable.push(image); @@ -188,7 +239,7 @@ export class GameState { } public disposeImage(image: Image, scene?: Scene) { - const targetScene = this._getLastSceneIfNot(scene); + const targetScene = this.getLastSceneIfNot(scene); const images = this.findElementByScene(targetScene)?.ele.displayable; if (!images) { throw new Error("Scene not found"); @@ -202,16 +253,16 @@ export class GameState { return this; } - public createDisplayable(displayable: LogicAction.Displayable, scene?: Scene) { - const targetScene = this._getLastSceneIfNot(scene); + public createDisplayable(displayable: LogicAction.DisplayableElements, scene?: Scene) { + const targetScene = this.getLastSceneIfNot(scene); const targetElement = this.findElementByScene(targetScene); if (!targetElement) return this; targetElement.ele.displayable.push(displayable); return this; } - public disposeDisplayable(displayable: LogicAction.Displayable, scene?: Scene) { - const targetScene = this._getLastSceneIfNot(scene); + public disposeDisplayable(displayable: LogicAction.DisplayableElements, scene?: Scene) { + const targetScene = this.getLastSceneIfNot(scene); const displayables = this.findElementByScene(targetScene)?.ele.displayable; if (!displayables) { throw new Error("Scene not found"); @@ -329,6 +380,17 @@ export class GameState { return this.game.getLiveGame().getStorable(); } + public getSceneByName(name: string): Scene | null { + return this.game.getLiveGame().story?.getScene(name) || null; + } + + public getStory(): Story { + if (!this.game.getLiveGame().story) { + throw new RuntimeGameError("Story not loaded"); + } + return this.game.getLiveGame().story!; + } + /** * Dispose the game state * @@ -367,15 +429,15 @@ export class GameState { 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"); + throw new RuntimeGameError("Scene not found, id: " + sceneId + "\nNarraLeaf cannot find the element with the id from the saved game"); } const displayable = elements.displayable.map(d => { if (!elementMap.has(d)) { - throw new Error("Displayable not found, id: " + d + "\nNarraLeaf cannot find the element with the id from the saved game" + + throw new RuntimeGameError("Displayable not found, id: " + d + "\nNarraLeaf cannot find the element with the id from the saved game" + "\nThis may be caused by the damage of the saved game file or the change of the story file"); } - return elementMap.get(d) as LogicAction.Displayable; + return elementMap.get(d) as LogicAction.DisplayableElements; }); const element: { scene: Scene; ele: PlayerStateElement; } = { scene, @@ -398,6 +460,14 @@ export class GameState { }); } + public getLastSceneIfNot(scene: Scene | null | void) { + const targetScene = scene || this.getLastScene(); + if (!targetScene || !this.sceneExists(targetScene)) { + throw new RuntimeGameError("Scene not found, please call \"scene.activate()\" first."); + } + return targetScene; + } + private getElementMap(): PlayerStateElement { return { texts: [], @@ -413,14 +483,6 @@ export class GameState { return this; } - private _getLastSceneIfNot(scene: Scene | null | void) { - const targetScene = scene || this.getLastScene(); - if (!targetScene || !this.sceneExists(targetScene)) { - throw new Error("Scene not found, please call \"scene.activate()\" first."); - } - return targetScene; - } - private anyEvent(type: any, target: any, onEnd: () => void, ...args: any[]) { (target.events as EventDispatcher).any( type, diff --git a/src/game/player/lib/ImageCacheManager.ts b/src/game/player/lib/ImageCacheManager.ts new file mode 100644 index 0000000..67db649 --- /dev/null +++ b/src/game/player/lib/ImageCacheManager.ts @@ -0,0 +1,114 @@ +import {getImageDataUrl} from "@lib/util/data"; + +type ImageCacheTask = { + promise: Promise; + controller: AbortController; +}; +export type PreloadedToken = { + abort: () => void; + onFinished: (callback: () => void) => void; +}; + +export class ImageCacheManager { + public static getImage(src: string, abortSignal?: AbortSignal): Promise { + return getImageDataUrl(src, { + signal: abortSignal, + }); + } + + private src: Map = new Map(); + private preloadTasks: Map = new Map(); + + public has(name: string): boolean { + return this.src.has(name); + } + + public add(name: string, src: string): this { + this.src.set(name, src); + return this; + } + + public remove(name: string): this { + this.src.delete(name); + return this; + } + + public get(name: string): string | undefined { + return this.src.get(name); + } + + public clear(): this { + this.src.clear(); + return this; + } + + public size(): number { + return this.src.size; + } + + public isPreloading(src: string): boolean { + return this.preloadTasks.has(src); + } + + public preload(url: string): PreloadedToken { + if (this.src.has(url) || this.preloadTasks.has(url)) return { + abort: () => { + }, + onFinished: () => { + } + }; + + const controller = new AbortController(); + const signal = controller.signal; + + const task: ImageCacheTask = { + promise: ImageCacheManager.getImage(url, signal), + controller, + }; + this.preloadTasks.set(url, task); + task.promise.then((dataUrl) => { + this.preloadTasks.delete(url); + if (dataUrl) { + this.add(url, dataUrl); + } + }); + + return { + abort: () => { + controller.abort(); + this.preloadTasks.delete(url); + }, + onFinished: (callback: () => void) => { + task.promise.then(callback); + } + }; + } + + public abortAll(): void { + this.preloadTasks.forEach(task => { + task.controller.abort(); + }); + this.preloadTasks.clear(); + } + + public abort(src: string): void { + const task = this.preloadTasks.get(src); + if (task) { + task.controller.abort(); + this.preloadTasks.delete(src); + } + } + + public preloadedSrc(): string[] { + return Array.from(this.src.values()); + } + + public filter(names: string[]): this { + for (const name of this.src.keys()) { + if (!names.includes(name)) { + this.src.delete(name); + } + } + return this; + } +} \ No newline at end of file diff --git a/src/game/player/lib/Preloaded.ts b/src/game/player/lib/Preloaded.ts index 8a80770..875df5e 100644 --- a/src/game/player/lib/Preloaded.ts +++ b/src/game/player/lib/Preloaded.ts @@ -1,8 +1,7 @@ import {Sound} from "@core/elements/sound"; -import {Src} from "@core/action/srcManager"; +import {Src, SrcManager} from "@core/action/srcManager"; import {EventDispatcher} from "@lib/util/data"; -import {Image} from "@core/elements/image"; -import {Utils} from "@core/common/Utils"; +import {Image} from "@core/elements/displayable/image"; export type PreloadedSrcTypes = "image" | "audio" | "video"; export type PreloadedSrc = ({ @@ -83,16 +82,7 @@ export class Preloaded { } getSrc(src: Src | string): string { - if (typeof src === "string") { - return src; - } - if (src.type === "image") { - return Utils.srcToString(src.src.state.src); - } else if (src.type === "video") { - return src.src; - } else if (src.type === "audio") { - return src.src.getSrc(); - } - return ""; + return SrcManager.getSrc(src); } } + diff --git a/src/game/player/provider/preloaded.tsx b/src/game/player/provider/preloaded.tsx index 56b9760..f26bbaa 100644 --- a/src/game/player/provider/preloaded.tsx +++ b/src/game/player/provider/preloaded.tsx @@ -2,9 +2,11 @@ import React, {createContext, useContext, useState} from "react"; import {Preloaded} from "@player/lib/Preloaded"; +import {ImageCacheManager} from "@player/lib/ImageCacheManager"; type PreloadedContextType = { preloaded: Preloaded; + cacheManager: ImageCacheManager; }; const Context = createContext(null); @@ -13,10 +15,11 @@ export function PreloadedProvider({children}: { children: React.ReactNode }) { const [preloaded] = useState(new Preloaded()); + const [cacheManager] = useState(new ImageCacheManager()); return ( <> - + {children} diff --git a/src/util/data.ts b/src/util/data.ts index 53383ae..afa2af6 100644 --- a/src/util/data.ts +++ b/src/util/data.ts @@ -1,6 +1,66 @@ -import type {Game} from "@core/game"; import {HexColor} from "@core/types"; +interface ITypeOf { + DataTypes: typeof DataTypes; + call: typeof TypeOf; + + (value: any): DataTypes; +} + +export enum DataTypes { + "string", + "number", + "boolean", + "object", + "array", + "function", + "symbol", + "undefined", + "null", + "date", + "regexp", + "other", +} + +export const TypeOf = (function (value: any): DataTypes { + if (typeof value === "string") { + return DataTypes.string; + } + if (typeof value === "number") { + return DataTypes.number; + } + if (typeof value === "boolean") { + return DataTypes.boolean; + } + if (typeof value === "object") { + if (Array.isArray(value)) { + return DataTypes.array; + } + if (value === null) { + return DataTypes.null; + } + if (value instanceof Date) { + return DataTypes.date; + } + if (value instanceof RegExp) { + return DataTypes.regexp; + } + return DataTypes.object; + } + if (typeof value === "function") { + return DataTypes.function; + } + if (typeof value === "symbol") { + return DataTypes.symbol; + } + if (typeof value === "undefined") { + return DataTypes.undefined; + } + return DataTypes.other; +}) as unknown as ITypeOf; + +TypeOf.DataTypes = DataTypes; + /** * @param obj1 source object * @param obj2 this object will overwrite the source object @@ -13,8 +73,7 @@ export function deepMerge>(obj1: Record, ob const result: Record = {}; const mergeValue = (_: string, value1: any, value2: any) => { - if (typeof value1 === "object" && value1 !== null && !Array.isArray(value1) && - typeof value2 === "object" && value2 !== null && !Array.isArray(value2)) { + if (TypeOf(value1) === DataTypes.object && TypeOf(value2) === DataTypes.object) { if (value1.constructor !== Object || value2.constructor !== Object) { return value2 || value1; } @@ -47,6 +106,8 @@ export function deepMerge>(obj1: Record, ob if (typeof obj2[key] === "object" && obj2[key] !== null) { if (obj2[key].constructor === Object) { result[key] = deepMerge({}, obj2[key]); + } else if (Array.isArray(obj2[key])) { + result[key] = [...obj2[key]]; } else { result[key] = obj2[key]; } @@ -319,60 +380,6 @@ export function deepEqual(obj1: any, obj2: any): boolean { return true; } -export class Logger { - private game: Game; - private readonly prefix: string | undefined; - - constructor(game: Game, prefix?: string) { - this.game = game; - this.prefix = prefix; - } - - log(tag: string, ...args: any[]) { - if (this.game.config.app.logger.log) { - console.log(...this._log(tag, ...args)); - } - } - - info(tag: string, ...args: any[]) { - if (this.game.config.app.logger.info) { - console.info(...this._log(tag, ...args)); - } - } - - warn(tag: string, ...args: any[]) { - if (this.game.config.app.logger.warn) { - console.warn(...this._log(tag, ...args)); - } - } - - error(tag: string, ...args: any[]) { - if (this.game.config.app.logger.error) { - console.error(...this._log(tag, ...args)); - } - } - - debug(tag: string, ...args: any[]) { - if (this.game.config.app.logger.debug) { - console.debug(...this._log(tag, ...args)); - } - } - - trace(tag: string, ...args: any[]) { - if (this.game.config.app.logger.trace) { - console.trace(this._log(tag, ...args)); - } - } - - private _log(tag: string, ...args: any[]) { - if (args.length === 0) { - return [this.prefix || "", tag]; - } else { - return [`${this.prefix || ""} [${tag}]`, ...args]; - } - } -} - type SkipControllerEvents = { "event:skipController.abort": []; } @@ -586,3 +593,107 @@ export function crossCombine(a: T[], b: U[]): (T | U)[] { } return result; } + +export type SelectElementFromEach = + T extends [infer First, ...infer Rest] + ? First extends string[] + ? Rest extends string[][] + ? { + [K in First[number]]: [K, ...SelectElementFromEach>]; + }[First[number]] + : [] + : [] + : []; +export type ExcludeEach = + T extends [infer First, ...infer Rest] + ? First extends string[] + ? Rest extends string[][] + ? [[Exclude], ...ExcludeEach] + : [] + : [] + : []; +export type FlexibleTuple = + T extends [infer First, ...infer Rest] + ? Rest extends any[] + ? [First, ...FlexibleTuple] | FlexibleTuple + : [First] + : []; + +export function moveElement(arr: T[], element: T, direction: "up" | "down" | "top" | "bottom"): T[] { + const index = arr.indexOf(element); + if (index === -1) return arr; + + const result = [...arr]; + result.splice(index, 1); + + switch (direction) { + case "up": + result.splice(Math.max(index - 1, 0), 0, element); + break; + case "down": + result.splice(Math.min(index + 1, arr.length), 0, element); + break; + case "top": + result.unshift(element); + break; + case "bottom": + result.push(element); + break; + } + + return result; +} + +export function moveElementInArray(arr: T[], element: T, newIndex: number): T[] { + const index = arr.indexOf(element); + if (index === -1) return arr; + + const result = [...arr]; + result.splice(index, 1); + result.splice(newIndex, 0, element); + + return result; +} + +export async function getImageDataUrl(src: string, options?: RequestInit): Promise { + const response = await fetch(src, options); + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.readAsDataURL(blob); + }); +} + +export class TaskPool { + private tasks: (() => Promise)[] = []; + + constructor(private readonly concurrency: number, private readonly delay: number) {} + + addTask(task: () => Promise) { + this.tasks.push(task); + } + + async start(): Promise { + const run = async () => { + if (this.tasks.length === 0) { + return; + } + const tasks = this.tasks.splice(0, this.concurrency); + await Promise.all(tasks.map(task => task())); + await sleep(this.delay); + await run(); + }; + await run(); + } +} + +export type StringKeyOf = Extract; +export type ValuesWithin = { + [K in keyof T]: T[K] extends U ? K : never; +}[keyof T]; +export type BooleanKeys = { + [K in keyof T]: T[K] extends boolean ? K : never; +}[keyof T]; diff --git a/src/util/logger.ts b/src/util/logger.ts new file mode 100644 index 0000000..c904d82 --- /dev/null +++ b/src/util/logger.ts @@ -0,0 +1,110 @@ +import type {Game} from "@core/game"; +import React from "react"; + +export class Logger { + private game: Game; + private readonly prefix: string | undefined; + + constructor(game: Game, prefix?: string) { + this.game = game; + this.prefix = prefix; + } + + log(tag: string, ...args: any[]) { + if (this.game.config.app.logger.log) { + console.log(...this.colorLog("gray", tag, ...args)); + } + } + + info(tag: string, ...args: any[]) { + if (this.game.config.app.logger.info) { + console.info(...this._log(tag, ...args)); + } + } + + warn(tag: string, ...args: any[]) { + if (this.game.config.app.logger.warn) { + console.warn(...this._log(tag, ...args)); + } + } + + error(tag: string, ...args: any[]) { + if (this.game.config.app.logger.error) { + console.error(...this._log(tag, ...args)); + } + } + + debug(tag: string, ...args: any[]) { + if (this.game.config.app.logger.debug) { + console.debug(...this.colorLog("gray", tag, ...args)); + } + } + + trace(tag: string, ...args: any[]) { + if (this.game.config.app.logger.trace) { + console.trace(this._log(tag, ...args)); + } + } + + weakWarn(tag: string, ...args: any[]) { + if (this.game.config.app.logger.warn) { + console.log(...this.colorLog("yellow", tag, ...args)); + } + } + + group(tag: string, collapsed = false) { + const groupTag = this._log(tag).join(" "); + if (this.game.config.app.logger.info) { + if (collapsed) { + console.groupCollapsed(groupTag); + } else { + console.group(groupTag); + } + } + return { + end: () => { + if (this.game.config.app.logger.info) { + console.groupEnd(); + } + } + }; + } + + private _log(tag: string, ...args: any[]) { + if (args.length === 0) { + return [this.prefix || "", tag]; + } else { + return [`${this.prefix || ""} [${tag}]`, ...args]; + } + } + + private colorLog(color: React.CSSProperties["color"], tag: string, ...args: any[]) { + if (args.length === 0) { + return [`%c${this.prefix || ""} ${tag}`, `color: ${color}`]; + } + const messages: string[] = []; + const styles: string[] = []; + const logArgs: any[] = []; + + if (this.prefix) { + messages.push(`%c${this.prefix} [${tag}]`); + styles.push(`color: ${color}`); + } else { + messages.push(`%c[${tag}]`); + styles.push(`color: ${color}`); + } + + args.forEach(arg => { + if (typeof arg === "string") { + messages.push(`%c${arg}`); + styles.push(`color: ${color}`); + } else { + messages.push("%O"); + logArgs.push(arg); + styles.push(""); + } + }); + + return [messages.join(" ")].concat(styles, logArgs); + } +} \ No newline at end of file