From 680f90752654895d0d5e0f2eb3f54eb23f7796ac Mon Sep 17 00:00:00 2001 From: Damien Simonin Feugas Date: Mon, 25 Sep 2023 08:10:50 +0100 Subject: [PATCH] feat(web): handles 3d state while replaying (#167) - fix(server): no redirection to home/lobby closure when owner deletes the current game/lobby. - fix(web): peer do not apply host game state on game sync. - fix(web): when receiving game or stream update, aside jumps to the player tab. - fix(web): peers muted/stopped states are reset when reconnecting to game. - feat(web): joins a simulation engine to the real engine, to maintain game state while replaying. - feat(server, web): records hand actions in history. - refactor(web): binds manager singletons to the 3d engine. --- TODO.md | 5 +- apps/server/src/graphql/games-resolver.js | 24 +- apps/server/src/graphql/games.graphql | 6 +- apps/server/src/services/games.js | 1 + .../tests/graphql/games-resolver.test.js | 30 +- apps/web/src/3d/behaviors/anchorable.js | 48 +- apps/web/src/3d/behaviors/animatable.js | 1 - apps/web/src/3d/behaviors/detailable.js | 8 +- apps/web/src/3d/behaviors/drawable.js | 20 +- apps/web/src/3d/behaviors/flippable.js | 8 +- apps/web/src/3d/behaviors/lockable.js | 13 +- apps/web/src/3d/behaviors/movable.js | 10 +- apps/web/src/3d/behaviors/quantifiable.js | 66 +- apps/web/src/3d/behaviors/randomizable.js | 12 +- apps/web/src/3d/behaviors/rotable.js | 10 +- apps/web/src/3d/behaviors/stackable.js | 73 +- apps/web/src/3d/behaviors/targetable.js | 18 +- apps/web/src/3d/engine.js | 307 ++++--- apps/web/src/3d/managers/camera.js | 88 +- apps/web/src/3d/managers/control.js | 65 +- apps/web/src/3d/managers/custom-shape.js | 39 +- apps/web/src/3d/managers/hand.js | 237 +++--- apps/web/src/3d/managers/index.js | 14 + apps/web/src/3d/managers/indicator.js | 43 +- apps/web/src/3d/managers/input.js | 144 ++-- apps/web/src/3d/managers/material.js | 80 +- apps/web/src/3d/managers/move.js | 88 +- apps/web/src/3d/managers/replay.js | 237 +++--- apps/web/src/3d/managers/selection.js | 90 +- apps/web/src/3d/managers/target.js | 51 +- apps/web/src/3d/meshes/box.js | 22 +- apps/web/src/3d/meshes/card.js | 20 +- apps/web/src/3d/meshes/custom.js | 29 +- apps/web/src/3d/meshes/die.js | 20 +- apps/web/src/3d/meshes/index.js | 1 + apps/web/src/3d/meshes/prism.js | 22 +- apps/web/src/3d/meshes/round-token.js | 22 +- apps/web/src/3d/meshes/rounded-tile.js | 26 +- apps/web/src/3d/utils/actions.js | 2 +- apps/web/src/3d/utils/behaviors.js | 71 +- apps/web/src/3d/utils/gravity.js | 20 +- apps/web/src/3d/utils/lights.js | 5 +- apps/web/src/3d/utils/mesh.js | 4 +- apps/web/src/3d/utils/scene-loader.js | 23 +- apps/web/src/3d/utils/scene.js | 11 +- apps/web/src/3d/utils/table.js | 27 +- apps/web/src/3d/utils/vector.js | 12 +- .../web/src/components/Aside/Container.svelte | 35 +- .../src/components/Aside/VideoCommands.svelte | 6 - .../components/Discussion/Container.svelte | 18 +- .../components/FriendList/Container.svelte | 1 + apps/web/src/graphql/games.graphql | 1 + apps/web/src/locales/en.yaml | 3 +- apps/web/src/locales/fr.yaml | 1 + .../routes/[[lang=lang]]/home/+page.svelte | 3 + apps/web/src/stores/game-engine.js | 79 +- apps/web/src/stores/game-manager.js | 145 ++-- apps/web/src/stores/indicators.js | 19 +- apps/web/src/stores/peer-channels.js | 18 +- apps/web/src/stores/stream.js | 29 +- apps/web/src/types/@babylonjs.d.ts | 21 + apps/web/src/types/vitest.d.ts | 7 +- apps/web/src/utils/game-interaction.js | 621 ++++++++------ .../web/tests/3d/behaviors/anchorable.test.js | 185 ++-- .../web/tests/3d/behaviors/animatable.test.js | 2 +- .../web/tests/3d/behaviors/detailable.test.js | 43 +- apps/web/tests/3d/behaviors/drawable.test.js | 57 +- apps/web/tests/3d/behaviors/flippable.test.js | 39 +- apps/web/tests/3d/behaviors/lockable.test.js | 42 +- apps/web/tests/3d/behaviors/movable.test.js | 26 +- .../tests/3d/behaviors/quantifiable.test.js | 66 +- .../tests/3d/behaviors/randomizable.test.js | 101 ++- apps/web/tests/3d/behaviors/rotable.test.js | 64 +- apps/web/tests/3d/behaviors/stackable.test.js | 298 ++++--- .../web/tests/3d/behaviors/targetable.test.js | 34 +- apps/web/tests/3d/engine.test.js | 553 +++++++++--- apps/web/tests/3d/managers/camera.test.js | 84 +- apps/web/tests/3d/managers/control.test.js | 68 +- .../tests/3d/managers/custom-shape.test.js | 51 +- apps/web/tests/3d/managers/hand.test.js | 515 +++++------ apps/web/tests/3d/managers/indicator.test.js | 587 +++++++------ apps/web/tests/3d/managers/input.test.js | 194 ++--- apps/web/tests/3d/managers/material.test.js | 371 ++++---- apps/web/tests/3d/managers/move.test.js | 610 +++++++------ apps/web/tests/3d/managers/replay.test.js | 309 ++++--- apps/web/tests/3d/managers/selection.test.js | 346 ++++---- apps/web/tests/3d/managers/target.test.js | 802 +++++++++--------- apps/web/tests/3d/meshes/box.test.js | 21 +- apps/web/tests/3d/meshes/card.test.js | 21 +- apps/web/tests/3d/meshes/custom.test.js | 80 +- apps/web/tests/3d/meshes/die.test.js | 62 +- apps/web/tests/3d/meshes/prism.test.js | 21 +- apps/web/tests/3d/meshes/round-token.test.js | 29 +- .../web/tests/3d/meshes/rounded-tiles.test.js | 29 +- apps/web/tests/3d/utils/behaviors.test.js | 208 +++-- apps/web/tests/3d/utils/scene-loader.test.js | 138 +-- apps/web/tests/3d/utils/table.test.js | 21 +- apps/web/tests/components/Aside.test.js | 88 +- .../tests/components/Discussion.tools.svelte | 3 +- apps/web/tests/components/Dropdown.test.js | 2 +- apps/web/tests/components/FriendList.test.js | 2 +- apps/web/tests/components/Menu.test.js | 2 +- .../components/MinimizableSection.svelte | 7 +- .../components/MinimizableSection.test.js | 2 +- .../tests/components/QuantityButton.test.js | 2 +- apps/web/tests/components/RuleViewer.test.js | 2 +- apps/web/tests/components/Typeahead.test.js | 2 +- .../__snapshots__/Discussion.tools.shot | 13 + .../MinimizableSection.tools.shot | 78 ++ .../web/tests/fixtures/Discussion.testdata.js | 29 +- apps/web/tests/matchers.js | 10 +- .../(auth)/account/+page.test.js | 2 +- .../(auth)/game/[gameid]/FPSViewer.test.js | 2 +- .../(auth)/game/[gameid]/GameMenu.test.js | 4 +- .../(auth)/game/[gameid]/MeshDetails.test.js | 2 +- .../[gameid]/Parameters/Container.test.js | 2 +- .../game/[gameid]/Parameters/utils.test.js | 2 +- .../(auth)/game/[gameid]/RadialMenu.test.js | 2 +- .../accept-terms/+page.server.test.js | 2 +- .../routes/[[lang=lang]]/home/+page.test.js | 22 + .../routes/[[lang=lang]]/login/Form.test.js | 2 +- apps/web/tests/setup.js | 2 + apps/web/tests/stores/game-engine.test.js | 249 +++--- apps/web/tests/stores/game-manager.test.js | 42 +- apps/web/tests/stores/indicators.test.js | 137 +-- apps/web/tests/stores/notifications.test.js | 2 +- apps/web/tests/stores/peer-channels.test.js | 29 + apps/web/tests/stores/players.test.js | 2 +- apps/web/tests/stores/stream.test.js | 110 ++- apps/web/tests/stores/toaster.test.js | 2 +- apps/web/tests/test-utils.js | 162 +++- apps/web/tests/utils/game-interaction.test.js | 626 ++++++++------ apps/web/tests/utils/logger.test.js | 15 +- 133 files changed, 6049 insertions(+), 4870 deletions(-) diff --git a/TODO.md b/TODO.md index fc82b8dd..47543678 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,5 @@ # TODO -during replay: host save - ## Refactor - hand: reuse playMeshes() and pickMesh() in handDrag() @@ -16,9 +14,10 @@ during replay: host save ## UI +- bug: die do not display the same face in multiplayer (states are correct) - when hovering target, highlight should have the dragged mesh's shape, not the target shape (what about parts?) - hand count on peer pointers/player tab? -- score card (Mah-jong, Belote) +- score (Mah-jong, Belote) - command to reset some mesh state and restart a game (Mah-jong, Belote) - "box" space for unusued/undesired meshes - hide/distinguish non-connected participants? diff --git a/apps/server/src/graphql/games-resolver.js b/apps/server/src/graphql/games-resolver.js index b06f599b..985103c1 100644 --- a/apps/server/src/graphql/games-resolver.js +++ b/apps/server/src/graphql/games-resolver.js @@ -239,18 +239,24 @@ export default { const subscription = services.gameListsUpdate .pipe(filter(({ playerId }) => playerId === player.id)) .subscribe(({ games }) => { - const game = games.find(({ id }) => id === gameId) - if (game) { - pubsub.publish({ topic, payload: { receiveGameUpdates: game } }) - logger.debug( - { res: { topic, game: { id: game.id, kind: game.kind } } }, - 'sent single game update' - ) - } + const game = games.find(({ id }) => id === gameId) ?? null + pubsub.publish({ topic, payload: { receiveGameUpdates: game } }) + logger.debug( + { + res: { + topic, + game: { id: gameId, kind: game?.kind, removed: !game } + } + }, + 'sent single game update' + ) }) const queue = await pubsub.subscribe(topic) queue.once('close', () => subscription.unsubscribe()) - logger.debug({ ctx: { topic } }, 'subscribed to single game updates') + logger.debug( + { ctx: { topic, playerId: player.id } }, + 'subscribed to single game updates' + ) return queue } ) diff --git a/apps/server/src/graphql/games.graphql b/apps/server/src/graphql/games.graphql index eeb0342b..3116fa53 100644 --- a/apps/server/src/graphql/games.graphql +++ b/apps/server/src/graphql/games.graphql @@ -266,6 +266,7 @@ interface HistoryRecord { meshId: ID! playerId: ID! duration: Int + fromHand: Boolean } type PlayerAction implements HistoryRecord { @@ -276,6 +277,7 @@ type PlayerAction implements HistoryRecord { argsStr: String revertStr: String duration: Int + fromHand: Boolean } type PlayerMove implements HistoryRecord { @@ -285,6 +287,7 @@ type PlayerMove implements HistoryRecord { pos: [Float]! prev: [Float]! duration: Int + fromHand: Boolean } input GameInput { @@ -475,6 +478,7 @@ input HistoryRecordInput { pos: [Float] prev: [Float] duration: Int + fromHand: Boolean } type GameParameters { @@ -508,5 +512,5 @@ extend type Mutation { extend type Subscription { receiveGameListUpdates: [Game!]! - receiveGameUpdates(gameId: ID!): Game! + receiveGameUpdates(gameId: ID!): Game } diff --git a/apps/server/src/services/games.js b/apps/server/src/services/games.js index cb45a82e..4efb5b15 100644 --- a/apps/server/src/services/games.js +++ b/apps/server/src/services/games.js @@ -225,6 +225,7 @@ import { canAccess } from './catalog.js' * @property {number} time - when this record happened (timestamp). * @property {string} playerId - who created this record. * @property {string} meshId - modified mesh id. + * @property {boolean} fromHand - whether this operation happened in this player's hand. * @property {number} [duration] - optional animation duration, in milliseconds. */ diff --git a/apps/server/tests/graphql/games-resolver.test.js b/apps/server/tests/graphql/games-resolver.test.js index 2aa84b59..0a50fc6f 100644 --- a/apps/server/tests/graphql/games-resolver.test.js +++ b/apps/server/tests/graphql/games-resolver.test.js @@ -544,14 +544,16 @@ describe('given a started server', () => { playerId: player.id, meshId: 'box1', fn: /** @type {ActionName} */ ('flip'), - argsStr: '[]' + argsStr: '[]', + fromHand: true }, { time: Date.now() - 3000, playerId: player.id, meshId: 'box1', pos: [0, 0, 3], - prev: [0, 0, 0] + prev: [0, 0, 0], + fromHand: false } ], guestIds: [], @@ -585,6 +587,7 @@ describe('given a started server', () => { time, playerId, meshId, + fromHand, ... on PlayerAction { fn, argsStr @@ -1152,6 +1155,29 @@ describe('given a started server', () => { expect(services.getPlayerById).toHaveBeenCalledWith(playerId) expect(services.getPlayerById).toHaveBeenCalledOnce() }) + + it('send update on game deletion', async () => { + await startSubscription( + ws, + `subscription { + receiveGameUpdates(gameId: "${games[0].id}") { + id + created + players { id username } + } + }`, + signToken(playerId, configuration.auth.jwt.key) + ) + const data = waitOnMessage(ws, data => data.type === 'data') + services.gameListsUpdate.next({ playerId, games: [] }) + expect(await data).toEqual( + expect.objectContaining({ + payload: { data: { receiveGameUpdates: null } } + }) + ) + expect(services.getPlayerById).toHaveBeenCalledWith(playerId) + expect(services.getPlayerById).toHaveBeenCalledOnce() + }) }) }) }) diff --git a/apps/web/src/3d/behaviors/anchorable.js b/apps/web/src/3d/behaviors/anchorable.js index a185463d..a022e7b0 100644 --- a/apps/web/src/3d/behaviors/anchorable.js +++ b/apps/web/src/3d/behaviors/anchorable.js @@ -6,8 +6,6 @@ * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName * @typedef {import('@src/3d/behaviors/stackable').StackBehavior} StackBehavior * @typedef {import('@src/3d/behaviors/targetable').DropDetails} DropDetails - * @typedef {import('@src/3d/managers/control').Action} Action - * @typedef {import('@src/3d/managers/control').Move} Move * @typedef {import('@src/3d/managers/move').MoveDetails} MoveDetails * @typedef {import('@src/3d/managers/target').SingleDropZone} SingleDropZone */ @@ -19,10 +17,6 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js' import { makeLogger } from '../../utils/logger' -import { controlManager } from '../managers/control' -import { indicatorManager } from '../managers/indicator' -import { moveManager } from '../managers/move' -import { selectionManager } from '../managers/selection' import { actionNames } from '../utils/actions' import { animateMove, @@ -45,18 +39,19 @@ export class AnchorBehavior extends TargetBehavior { * Creates behavior to make a mesh anchorable: it has one or several anchors to snap other meshes. * Each anchor can take up to one mesh only. * @param {AnchorableState} state - behavior state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(state = {}) { - super() + constructor(state, managers) { + super({}, managers) /** @type {RequiredAnchorableState} state - the behavior's current state. */ this.state = /** @type {RequiredAnchorableState} */ (state) /** @protected @type {?Observer} */ this.dropObserver = null /** @protected @type {?Observer} */ this.moveObserver = null - /** @protected @type {?Observer}} */ + /** @protected @type {?Observer}} */ this.actionObserver = null - /** @internal @type {Map} */ + /** @internal @type {Map} */ this.zoneBySnappedId = new Map() } @@ -94,22 +89,22 @@ export class AnchorBehavior extends TargetBehavior { } ) - this.moveObserver = moveManager.onMoveObservable.add(({ mesh }) => { + this.moveObserver = this.managers.move.onMoveObservable.add(({ mesh }) => { // unsnap the moved mesh, unless: // 1. it is not snapped! // 2. it is moved together with the current mesh if ( this.zoneBySnappedId.has(mesh?.id) && !( - selectionManager.meshes.has(mesh) && - selectionManager.meshes.has(/** @type {Mesh} */ (this.mesh)) + this.managers.selection.meshes.has(mesh) && + this.managers.selection.meshes.has(/** @type {Mesh} */ (this.mesh)) ) ) { this.unsnap(mesh.id) } }) - this.actionObserver = controlManager.onActionObservable.add( + this.actionObserver = this.managers.control.onActionObservable.add( async actionOrMove => { // 1. unsnap all when drawing main mesh // 2. unsnap drawn snapped mesh @@ -149,8 +144,8 @@ export class AnchorBehavior extends TargetBehavior { * Detaches this behavior from its mesh. */ detach() { - controlManager.onActionObservable.remove(this.actionObserver) - moveManager.onMoveObservable.remove(this.moveObserver) + this.managers.control.onActionObservable.remove(this.actionObserver) + this.managers.move.onMoveObservable.remove(this.moveObserver) this.onDropObservable?.remove(this.dropObserver) super.detach() } @@ -176,7 +171,7 @@ export class AnchorBehavior extends TargetBehavior { } /** - * @returns {String[]} ids for the meshes snapped to this one + * @returns ids for the meshes snapped to this one */ getSnappedIds() { return [...this.zoneBySnappedId.keys()] @@ -230,7 +225,7 @@ export class AnchorBehavior extends TargetBehavior { /** * Returns the zone to which a given mesh is snapped * @param {string} meshId - id of the tested mesh - * @returns {?SingleDropZone} zone to which this mesh is snapped, if any + * @returns zone to which this mesh is snapped, if any */ snappedZone(meshId) { return this.zoneBySnappedId.get(meshId) ?? null @@ -331,14 +326,14 @@ async function internalSnap( } const position = snapped.position.asArray() const angle = snapped.metadata.angle - indicatorManager.registerFeedback({ + behavior.managers.indicator.registerFeedback({ action: actionNames.snap, position: zone.mesh.absolutePosition.asArray() }) - moveManager.notifyMove(snapped) + behavior.managers.move.notifyMove(snapped) await snapToAnchor(behavior, snappedId, zone, immediate) // record after so flippable could flip on demand, after the mesh was snapped. - controlManager.record({ + behavior.managers.control.record({ mesh: behavior.mesh, fn: actionNames.snap, args: [snappedId, anchorId, immediate], @@ -348,7 +343,7 @@ async function internalSnap( }) const isFlipped = behavior.getZoneFlip(anchorId) if (isFlipped != undefined && snapped.metadata.isFlipped !== isFlipped) { - await controlManager.invokeLocal(snapped, actionNames.flip) + await behavior.managers.control.invokeLocal(snapped, actionNames.flip) } } @@ -377,9 +372,9 @@ async function internalUnsnap( `release snapped ${snappedId} from ${behavior.mesh.id}, zone ${zone.mesh.id}` ) if (isFlipped != undefined && released.metadata.isFlipped !== isFlipped) { - await controlManager.invokeLocal(released, actionNames.flip) + await behavior.managers.control.invokeLocal(released, actionNames.flip) } - controlManager.record({ + behavior.managers.control.record({ mesh: behavior.mesh, fn: actionNames.unsnap, args: [releasedId], @@ -387,7 +382,7 @@ async function internalUnsnap( isLocal }) unsetAnchor(behavior, zone, released) - indicatorManager.registerFeedback({ + behavior.managers.indicator.registerFeedback({ action: actionNames.unsnap, position: zone.mesh.absolutePosition.asArray() }) @@ -401,7 +396,6 @@ async function internalUnsnap( * @param {string} snappedId - snapped mesh id. * @param {SingleDropZone} zone - drop zone. * @param {boolean} [loading=false] - whether the scene is loading. - * @returns {Promise} */ async function snapToAnchor(behavior, snappedId, zone, loading = false) { const { @@ -483,7 +477,7 @@ function unsetAnchor(behavior, zone, snapped) { /** * @param {Scene|undefined} scene - scene containing meshes. * @param {string} meshId - searched mesh id. - * @returns {?Mesh[]} list of stacked meshes, if any. + * @returns list of stacked meshes, if any. */ function getMeshList(scene, meshId) { let mesh = scene?.getMeshById(meshId) diff --git a/apps/web/src/3d/behaviors/animatable.js b/apps/web/src/3d/behaviors/animatable.js index dc6753fc..91455185 100644 --- a/apps/web/src/3d/behaviors/animatable.js +++ b/apps/web/src/3d/behaviors/animatable.js @@ -81,7 +81,6 @@ export class AnimateBehavior { * @param {?Vector3} rotation - its final rotation (set to null to leave unmodified). * @param {number} duration - move duration (in milliseconds). * @param {boolean} [gravity=true] - applies gravity at the end. - * @returns {Promise} */ async moveTo(to, rotation, duration, gravity = true) { const { mesh, moveAnimation, rotateAnimation } = this diff --git a/apps/web/src/3d/behaviors/detailable.js b/apps/web/src/3d/behaviors/detailable.js index 2393fc52..16e5bd39 100644 --- a/apps/web/src/3d/behaviors/detailable.js +++ b/apps/web/src/3d/behaviors/detailable.js @@ -5,7 +5,6 @@ * @typedef {import('../utils').ScreenPosition} ScreenPosition */ -import { controlManager } from '../managers/control' import { attachFunctions, attachProperty, @@ -18,8 +17,11 @@ export class DetailBehavior { /** * Creates behavior to get details of a mesh. * @param {DetailableState} state - behavior state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(state = { frontImage: '' }) { + constructor(state, managers) { + /** @internal */ + this.managers = managers /** @type {?Mesh} mesh - the related mesh. */ this.mesh = null /** @type {DetailableState} state - the behavior's current state. */ @@ -64,7 +66,7 @@ export class DetailBehavior { detail() { if (!this.mesh) return const stackable = this.mesh.getBehaviorByName(StackBehaviorName) - controlManager.onDetailedObservable.notifyObservers({ + this.managers.control.onDetailedObservable.notifyObservers({ position: /** @type {ScreenPosition} */ ( getMeshScreenPosition(this.mesh) ), diff --git a/apps/web/src/3d/behaviors/drawable.js b/apps/web/src/3d/behaviors/drawable.js index d95baf76..f195f578 100644 --- a/apps/web/src/3d/behaviors/drawable.js +++ b/apps/web/src/3d/behaviors/drawable.js @@ -10,7 +10,6 @@ import { Animation } from '@babylonjs/core/Animations/animation' -import { handManager } from '../managers/hand' import { actionNames } from '../utils/actions' import { attachFunctions, @@ -28,9 +27,12 @@ export class DrawBehavior extends AnimateBehavior { /** * Creates behavior to draw mesh from and to player's hand. * @param {DrawableState} state - behavior state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(state = {}) { + constructor(state, managers) { super() + /** @internal */ + this.managers = managers /** @type {RequiredDrawableState} state - the behavior's current state. */ this.state = /** @type {RequiredDrawableState} */ (state) /** @protected @type {Animation} */ @@ -85,9 +87,9 @@ export class DrawBehavior extends AnimateBehavior { async draw(state, playerId) { if (!this.mesh) return if (state && playerId) { - await handManager.applyDraw(state, playerId) + await this.managers.hand.applyDraw(state, playerId) } else { - await handManager.draw(this.mesh) + await this.managers.hand.draw(this.mesh) } } @@ -97,7 +99,7 @@ export class DrawBehavior extends AnimateBehavior { */ async play() { if (!this.mesh) return - await handManager.play(this.mesh) + await this.managers.hand.play(this.mesh) } /** @@ -109,14 +111,13 @@ export class DrawBehavior extends AnimateBehavior { if (this.mesh && args.length === 2) { const [state, playerId] = args if (action === actionNames.play) { - await handManager.applyDraw(state, playerId) + await this.managers.hand.applyDraw(state, playerId) } } } /** * Runs the animation to move mesh from main scene to hand. - * @returns {Promise} */ async animateToHand() { const { @@ -142,7 +143,6 @@ export class DrawBehavior extends AnimateBehavior { /** * Runs the animation to move mesh from hand to main scene - * @returns {Promise} */ async animateToMain() { const { @@ -183,12 +183,12 @@ export class DrawBehavior extends AnimateBehavior { attachProperty( this, 'drawable', - () => this.mesh && !handManager.isManaging(this.mesh) + () => this.mesh && !this.managers.hand.isManaging(this.mesh) ) attachProperty( this, 'playable', - () => this.mesh && handManager.isManaging(this.mesh) + () => this.mesh && this.managers.hand.isManaging(this.mesh) ) } } diff --git a/apps/web/src/3d/behaviors/flippable.js b/apps/web/src/3d/behaviors/flippable.js index 1aff8116..12f15ecf 100644 --- a/apps/web/src/3d/behaviors/flippable.js +++ b/apps/web/src/3d/behaviors/flippable.js @@ -6,7 +6,6 @@ */ import { makeLogger } from '../../utils/logger' -import { controlManager } from '../managers/control' import { actionNames } from '../utils/actions' import { attachFunctions, @@ -30,9 +29,12 @@ export class FlipBehavior extends AnimateBehavior { /** * Creates behavior to make a mesh flippable with animation. * @param {FlippableState} state - behavior state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(state = {}) { + constructor(state, managers) { super() + /** @internal */ + this.managers = managers /** @type {RequiredFlippableState} state - the behavior's current state. */ this.state = /** @type {RequiredFlippableState} */ (state) } @@ -112,7 +114,7 @@ async function internalFlip( return } logger.debug({ mesh }, `start flipping ${mesh.id}`) - controlManager.record({ + behavior.managers.control.record({ mesh, fn: actionNames.flip, duration, diff --git a/apps/web/src/3d/behaviors/lockable.js b/apps/web/src/3d/behaviors/lockable.js index 6b2dca37..1198a7df 100644 --- a/apps/web/src/3d/behaviors/lockable.js +++ b/apps/web/src/3d/behaviors/lockable.js @@ -6,8 +6,6 @@ */ import { makeLogger } from '../../utils/logger' -import { controlManager } from '../managers/control' -import { indicatorManager } from '../managers/indicator' import { actionNames } from '../utils/actions' import { attachFunctions, attachProperty } from '../utils/behaviors' import { LockBehaviorName, MoveBehaviorName } from './names' @@ -20,8 +18,11 @@ export class LockBehavior { /** * Creates behavior to lock some actions on a mesh, by acting on other behaviors. * @param {LockableState} state - behavior state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(state = {}) { + constructor(state, managers) { + /** @internal */ + this.managers = managers /** @type {?Mesh} mesh - the related mesh. */ this.mesh = null /** @type {RequiredLockableState} state - the behavior's current state. */ @@ -97,17 +98,17 @@ export class LockBehavior { } function internalToggle( - /** @type {LockBehavior} */ { state, mesh }, + /** @type {LockBehavior} */ { state, mesh, managers }, isLocal = false ) { if (mesh) { - controlManager.record({ + managers.control.record({ mesh, fn: actionNames.toggleLock, args: [], isLocal }) - indicatorManager.registerFeedback({ + managers.indicator.registerFeedback({ action: state.isLocked ? 'unlock' : 'lock', position: mesh.absolutePosition.asArray() }) diff --git a/apps/web/src/3d/behaviors/movable.js b/apps/web/src/3d/behaviors/movable.js index ba66eb9a..450b2284 100644 --- a/apps/web/src/3d/behaviors/movable.js +++ b/apps/web/src/3d/behaviors/movable.js @@ -4,7 +4,6 @@ * @typedef {import('@tabulous/server/src/graphql').MovableState} MovableState */ -import { moveManager } from '../managers/move' import { attachProperty } from '../utils/behaviors' import { AnimateBehavior } from './animatable' import { MoveBehaviorName } from './names' @@ -17,9 +16,12 @@ export class MoveBehavior extends AnimateBehavior { * When moving mesh, its final position will snap to a virtual grid. * A mesh can only be dropped onto zones with the same kind. * @param {MovableState} state - behavior state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(state = {}) { + constructor(state, managers) { super() + /** @internal */ + this.managers = managers /** @type {RequiredMovableState} state - the behavior's current state. */ this.state = /** @type {RequiredMovableState} */ (state) /** @type {boolean} enabled - activity status (true by default). */ @@ -42,7 +44,7 @@ export class MoveBehavior extends AnimateBehavior { super.attach(mesh) mesh.isPickable = true this.fromState(this.state) - moveManager.registerMovable(this) + this.managers.move.registerMovable(this) } /** @@ -51,7 +53,7 @@ export class MoveBehavior extends AnimateBehavior { detach() { if (this.mesh) { this.mesh.isPickable = false - moveManager.unregisterMovable(this) + this.managers.move.unregisterMovable(this) } super.detach() } diff --git a/apps/web/src/3d/behaviors/quantifiable.js b/apps/web/src/3d/behaviors/quantifiable.js index 09fb5c96..4bc2c30c 100644 --- a/apps/web/src/3d/behaviors/quantifiable.js +++ b/apps/web/src/3d/behaviors/quantifiable.js @@ -17,11 +17,6 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector' import { makeLogger } from '../../utils/logger' -import { controlManager } from '../managers/control' -import { indicatorManager } from '../managers/indicator' -import { moveManager } from '../managers/move' -import { selectionManager } from '../managers/selection' -import { targetManager } from '../managers/target' import { actionNames } from '../utils/actions' import { animateMove, @@ -44,9 +39,10 @@ export class QuantityBehavior extends TargetBehavior { * Dropped meshes are destroyed while quantity is incremented. * Poped meshes are created on the fly, except when the quantity is 1. * @param {QuantifiableState} state - behavior state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(state = {}) { - super() + constructor(state, managers) { + super({}, managers) /** @type {RequiredQuantifiableState} state - the behavior's current state. */ this.state = /** @type {RequiredQuantifiableState} */ (state) /** @protected @type {?Observer} */ @@ -90,24 +86,26 @@ export class QuantityBehavior extends TargetBehavior { } ) - this.preMoveObserver = moveManager.onPreMoveObservable.add(({ meshes }) => { - if ( - this.mesh && - !selectionManager.meshes.has(this.mesh) && - meshes.some(({ id }) => id === this.mesh?.id) && - this.state.quantity > 1 - ) { - moveManager.exclude(this.mesh) - this.decrement().then(mesh => moveManager.include(mesh)) + this.preMoveObserver = this.managers.move.onPreMoveObservable.add( + ({ meshes }) => { + if ( + this.mesh && + !this.managers.selection.meshes.has(this.mesh) && + meshes.some(({ id }) => id === this.mesh?.id) && + this.state.quantity > 1 + ) { + this.managers.move.exclude(this.mesh) + this.decrement().then(mesh => this.managers.move.include(mesh)) + } } - }) + ) } /** * Detaches this behavior from its mesh, unsubscribing observables */ detach() { - moveManager.onPreMoveObservable.remove(this.preMoveObserver) + this.managers.move.onPreMoveObservable.remove(this.preMoveObserver) this.onDropObservable?.remove(this.dropObserver) super.detach() } @@ -121,7 +119,7 @@ export class QuantityBehavior extends TargetBehavior { return ( Boolean(mesh) && Boolean(mesh.getBehaviorByName(QuantityBehaviorName)) && - targetManager.canAccept( + this.managers.target.canAccept( this.dropZone, mesh.getBehaviorByName(MoveBehaviorName)?.state.kind ) @@ -160,7 +158,7 @@ export class QuantityBehavior extends TargetBehavior { if (!mesh || state.quantity === 1) return created const createdId = makeId(mesh) const duration = withMove ? state.duration : undefined - controlManager.record({ + this.managers.control.record({ mesh, fn: actionNames.decrement, args: [count, withMove], @@ -180,7 +178,7 @@ export class QuantityBehavior extends TargetBehavior { serialized.quantifiable.quantity = quantity serialized.id = createdId created = /** @type {Mesh} */ ( - await createMeshFromState(serialized, mesh.getScene()) + await createMeshFromState(serialized, mesh.getScene(), this.managers) ) logger.info( @@ -201,7 +199,7 @@ export class QuantityBehavior extends TargetBehavior { true ) } - updateIndicator(mesh, state.quantity) + updateIndicator(this.managers, mesh, state.quantity) return move ? move.then(() => created) : created } @@ -224,7 +222,7 @@ export class QuantityBehavior extends TargetBehavior { await Promise.all( states.map(async (/** @type {SerializedMesh} */ state) => { const count = state.quantifiable?.quantity ?? 1 - controlManager.record({ + this.managers.control.record({ mesh, fn: actionNames.decrement, args: [count, withMove], @@ -233,7 +231,7 @@ export class QuantityBehavior extends TargetBehavior { isLocal: true }) this.state.quantity -= count - const created = await createMeshFromState(state, scene) + const created = await createMeshFromState(state, scene, this.managers) if (withMove) { created.setAbsolutePosition(mesh.absolutePosition) await animateMove( @@ -245,7 +243,7 @@ export class QuantityBehavior extends TargetBehavior { } }) ) - updateIndicator(mesh, this.state.quantity) + updateIndicator(this.managers, mesh, this.state.quantity) } else if (action === actionNames.decrement) { await internalIncrement(this, [args[0]], args[1], true) } @@ -266,7 +264,7 @@ export class QuantityBehavior extends TargetBehavior { throw new Error('Can not restore state without mesh') } this.state = { quantity, kinds, priority, extent, duration } - updateIndicator(this.mesh, quantity) + updateIndicator(this.managers, this.mesh, quantity) // dispose previous drop zone if (this.dropZone) { this.removeZone(/** @type {SingleDropZone} */ (this.dropZone)) @@ -282,7 +280,7 @@ export class QuantityBehavior extends TargetBehavior { } async function internalIncrement( - /** @type {QuantityBehavior} */ { mesh, state }, + /** @type {QuantityBehavior} */ { mesh, state, managers }, /** @type {string[]} */ meshIds, /** @type {boolean} */ immediate, isLocal = false @@ -294,7 +292,7 @@ async function internalIncrement( const duration = immediate ? 0 : state.duration - controlManager.record({ + managers.control.record({ mesh, fn: actionNames.increment, args: [meshIds, immediate], @@ -311,7 +309,7 @@ async function internalIncrement( `increment ${mesh.id} by ${increment} with ${other.id}` ) state.quantity += increment - updateIndicator(mesh, state.quantity) + updateIndicator(managers, mesh, state.quantity) if (duration) { await animateMove(other, mesh.absolutePosition, null, duration) } @@ -323,12 +321,16 @@ function getQuantity(/** @type {Mesh} */ mesh) { return mesh.metadata?.quantity ?? 1 } -function updateIndicator(/** @type {Mesh} */ mesh, /** @type {number} */ size) { +function updateIndicator( + /** @type {import('@src/3d/managers').Managers} */ { indicator }, + /** @type {Mesh} */ mesh, + /** @type {number} */ size +) { const id = `${mesh.id}.quantity` if (size > 1) { - indicatorManager.registerMeshIndicator({ id, mesh, size }) + indicator.registerMeshIndicator({ id, mesh, size }) } else { - indicatorManager.unregisterIndicator({ id }) + indicator.unregisterIndicator({ id }) } } diff --git a/apps/web/src/3d/behaviors/randomizable.js b/apps/web/src/3d/behaviors/randomizable.js index 944728e1..121ab5e3 100644 --- a/apps/web/src/3d/behaviors/randomizable.js +++ b/apps/web/src/3d/behaviors/randomizable.js @@ -14,7 +14,6 @@ import { VertexBuffer } from '@babylonjs/core/Buffers/buffer' import { Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector' import { makeLogger } from '../../utils/logger' -import { controlManager } from '../managers/control' import { actionNames } from '../utils/actions' import { attachFunctions, @@ -42,9 +41,12 @@ export class RandomBehavior extends AnimateBehavior { /** * Creates behavior to make a mesh randomizable: it has a face vaule and this face can be set, or randomly set. * @param {RandomizableState & Extras} stateWithExtra - behavior persistent state, with internal parameters provided by the mesh. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(stateWithExtra) { + constructor(stateWithExtra, managers) { super() + /** @internal */ + this.managers = managers /** @type {RequiredRandomizableState} state - the behavior's current state (+ extras) */ this.state = /** @type {RequiredRandomizableState} */ (stateWithExtra) if (!(stateWithExtra.quaternionPerFace instanceof Map)) { @@ -100,7 +102,6 @@ export class RandomBehavior extends AnimateBehavior { * - returns * Does nothing if the mesh is already being animated, or can not be set. * @param {number} face - desired face value. - * @returns {Promise} * @throws {Error} if desired face is not withing 1..max. */ async setFace(face) { @@ -129,7 +130,6 @@ export class RandomBehavior extends AnimateBehavior { * - returns * Does nothing if the mesh is already being animated. * @param {number} [face] - final face value, used when applying random operation from peers - * @returns {Promise} */ async random(face) { if (!face) { @@ -319,7 +319,7 @@ function applyRotation( mesh.updateVerticesData(VertexBuffer.PositionKind, [...save.positions]) mesh.updateVerticesData(VertexBuffer.NormalKind, [...save.normals]) mesh.rotationQuaternion = /** @type {Quaternion} */ ( - quaternionPerFace.get(face ?? 0) + quaternionPerFace.get(face ?? 1) ).clone() mesh.bakeCurrentTransformIntoVertices() mesh.refreshBoundingInfo() @@ -360,7 +360,7 @@ async function animate(behavior, isLocal, fn, face, duration, ...animations) { { mesh, face, oldFace }, `starts ${fn} on ${mesh.id} (${oldFace} > ${face})` ) - controlManager.record({ + behavior.managers.control.record({ mesh, fn, args: [face], diff --git a/apps/web/src/3d/behaviors/rotable.js b/apps/web/src/3d/behaviors/rotable.js index 3cb084f9..8f09a6f4 100644 --- a/apps/web/src/3d/behaviors/rotable.js +++ b/apps/web/src/3d/behaviors/rotable.js @@ -9,7 +9,6 @@ import { Vector3 } from '@babylonjs/core/Maths/math' import { makeLogger } from '../../utils/logger' -import { controlManager } from '../managers/control' import { actionNames } from '../utils/actions' import { attachFunctions, @@ -35,10 +34,13 @@ export class RotateBehavior extends AnimateBehavior { * It will add to this mesh's metadata: * - a `rotate()` function to rotate by 45°. * - a rotation `angle` (in radian). - * @param {RotableState} state - behavior state. + * @param {RotableState} state - rotable state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(state = {}) { + constructor(state, managers) { super() + /** @internal */ + this.managers = managers this._state = /** @type {RequiredRotableState} */ (state) } @@ -141,7 +143,7 @@ async function internalRotate( rotation *= -1 } - controlManager.record({ + behavior.managers.control.record({ mesh, fn: actionNames.rotate, args: [rotation], diff --git a/apps/web/src/3d/behaviors/stackable.js b/apps/web/src/3d/behaviors/stackable.js index 9f0bf6c7..a8e56f64 100644 --- a/apps/web/src/3d/behaviors/stackable.js +++ b/apps/web/src/3d/behaviors/stackable.js @@ -6,8 +6,6 @@ * @typedef {import('@tabulous/server/src/graphql').StackableState} StackableState * @typedef {import('@src/3d/behaviors/animatable').AnimateBehavior} AnimateBehavior * @typedef {import('@src/3d/behaviors/targetable').DropDetails} DropDetails - * @typedef {import('@src/3d/managers/control').Action} Action - * @typedef {import('@src/3d/managers/control').Move} Move * @typedef {import('@src/3d/managers/move').MoveDetails} MoveDetails * @typedef {import('@src/3d/managers/target').SingleDropZone} SingleDropZone * @typedef {import('@src/3d/utils').Vector3KeyFrame} Vector3KeyFrame @@ -21,11 +19,6 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector' import { makeLogger } from '../../utils/logger' import { sleep } from '../../utils/time' -import { controlManager } from '../managers/control' -import { indicatorManager } from '../managers/indicator' -import { moveManager } from '../managers/move' -import { selectionManager } from '../managers/selection' -import { targetManager } from '../managers/target' import { actionNames } from '../utils/actions' import { animateMove, @@ -64,13 +57,11 @@ export class StackBehavior extends TargetBehavior { * and targetable (it can receive other stackable meshs). * Once a mesh is stacked bellow others, it can not be moved independently, and its targets and anchors are disabled. * Only the highest mesh on stack can be moved (it is automatically poped out) and be targeted. - * - * @property {StackableState} state - the behavior's current state. - * * @param {StackableState} state - behavior state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor(state = {}) { - super() + constructor(state = {}, managers) { + super({}, managers) /** @type {RequiredStackableState} */ this._state = /** @type {RequiredStackableState} */ (state) /** @type {Mesh[]} array of meshes (initially contains this mesh). */ @@ -83,7 +74,7 @@ export class StackBehavior extends TargetBehavior { this.moveObserver = null /** @protected @type {?Observer} */ this.dropObserver = null - /** @protected @type {?Observer} */ + /** @protected @type {?Observer} */ this.actionObserver = null /** @internal @type {boolean} */ this.isReordering = false @@ -123,7 +114,7 @@ export class StackBehavior extends TargetBehavior { } ) - this.moveObserver = moveManager.onMoveObservable.add(({ mesh }) => { + this.moveObserver = this.managers.move.onMoveObservable.add(({ mesh }) => { // pop the last item if it's dragged, unless: // 1. there's only one item // 2. the first item is also dragged (we're dragging the whole stack) @@ -131,13 +122,13 @@ export class StackBehavior extends TargetBehavior { if ( stack.length > 1 && stack[stack.length - 1] === mesh && - !selectionManager.meshes.has(stack[0]) + !this.managers.selection.meshes.has(stack[0]) ) { this.pop() } }) - this.actionObserver = controlManager.onActionObservable.add( + this.actionObserver = this.managers.control.onActionObservable.add( async actionOrMove => { const { stack } = this if ( @@ -147,7 +138,7 @@ export class StackBehavior extends TargetBehavior { stack[stack.length - 1].id === actionOrMove.meshId ) { const poped = await internalPop(this, 1, false, true) - indicatorManager.registerFeedback({ + this.managers.indicator.registerFeedback({ action: actionNames.pop, position: poped[0].absolutePosition.asArray() }) @@ -160,8 +151,8 @@ export class StackBehavior extends TargetBehavior { * Detaches this behavior from its mesh, unsubscribing observables */ detach() { - controlManager.onActionObservable.remove(this.actionObserver) - moveManager.onMoveObservable.remove(this.moveObserver) + this.managers.control.onActionObservable.remove(this.actionObserver) + this.managers.move.onMoveObservable.remove(this.moveObserver) this.onDropObservable?.remove(this.dropObserver) super.detach() } @@ -175,7 +166,7 @@ export class StackBehavior extends TargetBehavior { const last = this.stack[this.stack.length - 1] return last === this.mesh ? Boolean(mesh) && - targetManager.canAccept( + this.managers.target.canAccept( this.dropZone, mesh.getBehaviorByName(MoveBehaviorName)?.state.kind ) @@ -226,7 +217,7 @@ export class StackBehavior extends TargetBehavior { ) ) } - indicatorManager.registerFeedback({ + this.managers.indicator.registerFeedback({ action: actionNames.pop, position: poped[0].absolutePosition.asArray() }) @@ -257,11 +248,11 @@ export class StackBehavior extends TargetBehavior { * When the base mesh is flipped, re-ordering happens first so the highest mesh doesn't change after flipping. */ async flipAll() { - await internalFlip(this) + await internalFlipAll(this) } /** - * Revert push, pop and reorder actions. Ignores other actions + * Revert push, pop, flipAll and reorder actions. Ignores other actions * @param {ActionName} action - reverted action. * @param {any[]} [args] - reverted arguments. */ @@ -296,7 +287,7 @@ export class StackBehavior extends TargetBehavior { this.state.duration, false ) - indicatorManager.registerFeedback({ + this.managers.indicator.registerFeedback({ action: actionNames.pop, position: last.absolutePosition.asArray() }) @@ -310,7 +301,7 @@ export class StackBehavior extends TargetBehavior { const [ids, animate] = args await internalReorder(this, ids, animate, true) } else if (action === actionNames.flipAll) { - await internalFlip(this, true) + await internalFlipAll(this, true) } } @@ -395,7 +386,7 @@ async function internalPush( const { angle } = behavior._state if (!behavior.inhibitControl) { - controlManager.record({ + behavior.managers.control.record({ mesh: stack[0], fn: actionNames.push, args: [meshId, immediate], @@ -404,7 +395,7 @@ async function internalPush( revert: [1, immediate, mesh.absolutePosition.asArray(), mesh.rotation.y], isLocal }) - moveManager.notifyMove(mesh) + behavior.managers.move.notifyMove(mesh) } const { x, z } = base.mesh.absolutePosition // when hydrating, we must break existing stacks, because they are meant to be broken by serialization @@ -442,7 +433,7 @@ async function internalPush( } attach() if (!behavior.inhibitControl) { - indicatorManager.registerFeedback({ + behavior.managers.indicator.registerFeedback({ action: actionNames.push, position: pushed.absolutePosition.asArray() }) @@ -468,7 +459,7 @@ function internalPop( const mesh = /** @type {Mesh} */ (stack.pop()) poped.push(mesh) setBase(mesh, null) - updateIndicator(mesh, 0) + updateIndicator(behavior.managers, mesh, 0) // note: no need to enable the poped mesh target: since it was last, it's always enabled setStatus(stack, stack.length - 1, true, behavior) logger.info( @@ -480,7 +471,7 @@ function internalPop( poped.push(stack[0]) } // note: all mesh in stack are uncontrollable, so we pass the poped mesh id - controlManager.record({ + behavior.managers.control.record({ mesh: stack[0], fn: actionNames.pop, args: [count, withMove], @@ -510,7 +501,7 @@ async function internalReorder( const stack = ids.map(id => old[posById.get(id) ?? -1]).filter(Boolean) const oldIds = old.map(({ id }) => id) - controlManager.record({ + behavior.managers.control.record({ mesh: old[0], fn: actionNames.reorder, args: [ids, animate], @@ -666,12 +657,12 @@ async function internalReorder( } } -async function internalFlip( +async function internalFlipAll( /** @type {StackBehavior} */ behavior, isLocal = false ) { const base = behavior.base ?? behavior - controlManager.record({ + behavior.managers.control.record({ mesh: base.stack[0], fn: actionNames.flipAll, args: [], @@ -682,7 +673,9 @@ async function internalFlip( invertStack(base) } await Promise.all( - base.stack.map(mesh => controlManager.invokeLocal(mesh, actionNames.flip)) + base.stack.map(mesh => + behavior.managers.control.invokeLocal(mesh, actionNames.flip) + ) ) if (!isFlipped) { invertStack(base) @@ -719,7 +712,7 @@ function setStatus(stack, rank, enabled, behavior) { movable.enabled = enabled logger.info({ mesh }, `${operation} moves for ${mesh.id}`) } - updateIndicator(mesh, enabled ? stack.length : 0) + updateIndicator(behavior.managers, mesh, enabled ? stack.length : 0) } function setBase( @@ -743,12 +736,16 @@ function setBase( return targetable } -function updateIndicator(/** @type {Mesh} */ mesh, /** @type {number} */ size) { +function updateIndicator( + /** @type {import('@src/3d/managers').Managers} */ { indicator }, + /** @type {Mesh} */ mesh, + /** @type {number} */ size +) { const id = `${mesh.id}.stack-size` if (size > 1) { - indicatorManager.registerMeshIndicator({ id, mesh, size }) + indicator.registerMeshIndicator({ id, mesh, size }) } else { - indicatorManager.unregisterIndicator({ id }) + indicator.unregisterIndicator({ id }) } } diff --git a/apps/web/src/3d/behaviors/targetable.js b/apps/web/src/3d/behaviors/targetable.js index a4063f77..cc31dfe7 100644 --- a/apps/web/src/3d/behaviors/targetable.js +++ b/apps/web/src/3d/behaviors/targetable.js @@ -9,8 +9,6 @@ import { Observable } from '@babylonjs/core/Misc/observable.js' -import { indicatorManager } from '../managers/indicator' -import { targetManager } from '../managers/target' import { TargetBehaviorName } from './names' /** @typedef {Targetable & Required> & Pick} ZoneProps properties of a drop zone */ @@ -29,16 +27,18 @@ export class TargetBehavior { * A targetable mesh can have multiple drop zones, materialized with 3D geometries and each allowing one or several kinds. * All zones can be enable and disabled at once. * An observable emits every time one of the zone receives a drop. - * @param {object} params - parameters, including: - * @param {number} [params.moveDuration=100] - duration (in milliseconds) of an individual mesh re-order animation. + * @param {object} state - unused state. + * @param {import('@src/3d/managers').Managers} managers - current managers. */ - constructor() { + constructor(state, managers) { /** @type {?Mesh} mesh - the related mesh. */ this.mesh = null /** @type {SingleDropZone[]} defined drop zones for this target. */ this.zones = [] /** @type {Observable} emits every time draggable meshes are dropped to one of the zones.*/ this.onDropObservable = new Observable() + /** @internal */ + this.managers = managers } /** @@ -60,7 +60,7 @@ export class TargetBehavior { */ attach(mesh) { this.mesh = mesh - targetManager.registerTargetable(this) + this.managers.target.registerTargetable(this) } /** @@ -68,7 +68,7 @@ export class TargetBehavior { * and unregistering it from the target manager. */ detach() { - targetManager.unregisterTargetable(this) + this.managers.target.unregisterTargetable(this) for (const { mesh } of this.zones) { mesh.dispose() } @@ -81,7 +81,7 @@ export class TargetBehavior { * By default, zone is enabled, accepts all kind, with a priority of 0, and no playerId. * @param {Mesh} mesh - invisible, unpickable mesh acting as drop zone. * @param {ZoneProps} properties - drop zone properties. - * @returns {SingleDropZone} the created zone. + * @returns the created zone. */ addZone(mesh, properties) { mesh.visibility = 0 @@ -100,7 +100,7 @@ export class TargetBehavior { } if (properties.playerId) { const id = `${properties.playerId}.drop-zone.${mesh.id}` - indicatorManager.registerMeshIndicator({ + this.managers.indicator.registerMeshIndicator({ id, mesh, playerId: properties.playerId diff --git a/apps/web/src/3d/engine.js b/apps/web/src/3d/engine.js index 0eb0b0a8..99e0044d 100644 --- a/apps/web/src/3d/engine.js +++ b/apps/web/src/3d/engine.js @@ -1,11 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Engine} Engine - * @typedef {import('@tabulous/server/src/graphql').PlayerPreference} PlayerPreferenceSerializedMesh - * @typedef {import('@src/common').Locale} Locale - * @typedef {import('@src/graphql').Game} Game - * @typedef {import('@src/types').Translate} Translate - */ // all BabylonJS imports must be from individual files to allow tree shaking. // more [here](https://doc.babylonjs.com/divingDeeper/developWithBjs/treeShaking) @@ -15,22 +8,23 @@ import '@babylonjs/core/Materials/Textures/Loaders/ktxTextureLoader' import '@babylonjs/core/Rendering/edgesRenderer' import '@babylonjs/core/Rendering/outlineRenderer' -import { Engine as RealEngine } from '@babylonjs/core/Engines/engine' +import { Engine } from '@babylonjs/core/Engines/engine' +import { NullEngine } from '@babylonjs/core/Engines/nullEngine' import { Observable } from '@babylonjs/core/Misc/observable' import { gameAssetsUrl, sleep } from '../utils' import { - cameraManager, - controlManager, - customShapeManager, - handManager, - indicatorManager, - inputManager, - materialManager, - moveManager, - replayManager, - selectionManager, - targetManager + CameraManager, + ControlManager, + CustomShapeManager, + HandManager, + IndicatorManager, + InputManager, + MaterialManager, + MoveManager, + ReplayManager, + SelectionManager, + TargetManager } from './managers' import { actionNames, @@ -50,52 +44,98 @@ import { * @property {string[]} selectedIds - list of selected mesh ids */ +/** + * @typedef {object} EngineArgs + * @property {HTMLElement} interaction - HTML element receiving user interaction (mouse events, taps). + * @property {HTMLElement} hand - HTML element holding hand. + * @property {number} longTapDelay - number of milliseconds to hold pointer down before it is considered as long. + * @property {import('@src/types').Translate} translate - function that translate a i18n key into a localized text. + * @property {HTMLCanvasElement} [canvas] - HTML canvas used to display the scene. Unset to create a simulation engine. + * @property {import('@src/common').Locale} [locale] - locale used to download the game textures. + * @property {(canvas: HTMLCanvasElement) => Engine} [makeEngine=RealEngine] - 3D engine factory. + */ + const { flip, random, rotate } = actionNames /** * Creates the Babylon's 3D engine, with its single scene, and its render loop. - * Handles pointer out event, to cancel multiple selection or drag'n drop operations. + * It creates a simulation engine that will not render anything, takes no input and has no lights nor material. + * This one is used to update the game state when applying actions. + * It can create an option "real" engine when given a canvas to render the scene on. * Note: must be called before any other 3D elements. - * @param {object} params - parameters, including: - * @param {new (canvas: HTMLCanvasElement, antialias: boolean) => Engine} [params.Engine=RealEngine] - Babylon's 3D Engine class us() => voiced. - * @param {HTMLCanvasElement} params.canvas - HTML canvas used to display the scene. - * @param {HTMLElement} params.interaction - HTML element receiving user interaction (mouse events, taps). - * @param {HTMLElement} params.hand - HTML element holding hand. - * @param {number} params.longTapDelay - number of milliseconds to hold pointer down before it is considered as long. - * @param {Translate} params.translate - function that translate a i18n key into a localized text. - * @param {Locale} params.locale - locale used to download the game textures. - * @returns {Engine} the created 3D engine. + * @param {EngineArgs} params - creation parameters. + * @returns the created 3D engine. */ export function createEngine({ - Engine = RealEngine, + makeEngine = canvas => new Engine(canvas, true), canvas, - interaction, - hand, - longTapDelay, - locale, - translate + ...args }) { - const engine = new Engine(canvas, true) //, { disableWebGL2Support: true }) // force WebGL1, useful for testing + const simulation = initEngineAnScenes(new NullEngine(), args) + if (canvas) { + const engine = initEngineAnScenes(makeEngine(canvas), args, simulation) + transferActionsAndSelections(engine, simulation) + return engine + } + return simulation +} + +function initEngineAnScenes( + /** @type {Engine} */ engine, + /** @type {EngineArgs} */ { + longTapDelay, + interaction, + hand, + locale, + translate + }, + /** @type {?Engine} */ + simulation = null +) { + const isSimulation = simulation === null engine.enableOfflineSupport = false engine.onLoadingObservable = new Observable() engine.onBeforeDisposeObservable = new Observable() // scene ordering is important: main scene must come last to allow ray picking scene.pickWithRay(new Ray(vertex, down)) const handScene = new ExtendedScene(engine) - const scene = new ExtendedScene(engine) handScene.autoClear = false + const scene = new ExtendedScene(engine) - cameraManager.init({ scene, handScene }) - inputManager.init({ - scene, - handScene, - longTapDelay, - interaction, - onCameraMove: cameraManager.onMoveObservable - }) - moveManager.init({ scene }) - controlManager.init({ scene, handScene }) - indicatorManager.init({ scene }) + /** @type {import('@src/3d/managers').Managers} */ + const managers = { + camera: new CameraManager({ scene, handScene }), + input: new InputManager({ + scene, + handScene, + longTapDelay, + interaction + }), + move: new MoveManager({ scene }), + control: new ControlManager({ scene, handScene }), + indicator: new IndicatorManager({ scene }), + selection: new SelectionManager({ scene, handScene }), + customShape: new CustomShapeManager({ gameAssetsUrl }), + target: new TargetManager({ scene }), + material: new MaterialManager({ + gameAssetsUrl, + locale, + scene, + handScene, + isWebGL1: engine.version === 1, + disabled: isSimulation + }), + hand: new HandManager({ + scene, + handScene, + overlay: hand, + duration: isSimulation ? 0 : 100 + }), + replay: new ReplayManager({ + engine, + moveDuration: isSimulation ? 0 : 200 + }) + } engine.start = () => engine.runRenderLoop(() => { @@ -103,20 +143,20 @@ export function createEngine({ handScene.render() }) - const isWebGL1 = engine.version === 1 - let isLoading = false const actionNamesByButton = new Map() let actionNamesByKey = new Map() Object.defineProperty(engine, 'isLoading', { get: () => isLoading }) + Object.defineProperty(engine, 'simulation', { get: () => simulation }) Object.defineProperty(engine, 'actionNamesByKey', { get: () => actionNamesByKey }) Object.defineProperty(engine, 'actionNamesByButton', { get: () => actionNamesByButton }) + Object.defineProperty(engine, 'managers', { get: () => managers }) engine.load = async ( gameData, @@ -124,8 +164,8 @@ export function createEngine({ initial ) => { const game = removeNulls(gameData) - cameraManager.adjustZoomLevels(game.zoomSpec) - const handsEnabled = hasHandsEnabled(game) + managers.camera.adjustZoomLevels(game.zoomSpec) + managers.hand.enabled = hasHandsEnabled(game) if (initial) { actionNamesByButton.clear() for (const [button, actions] of Object.entries( @@ -146,94 +186,94 @@ export function createEngine({ ], translate ) - - selectionManager.init({ scene, handScene }) - targetManager.init({ - scene, + managers.target.init({ + managers, playerId, color: colorByPlayerId.get(playerId) ?? 'red' }) - materialManager.init( - { - gameAssetsUrl, - locale, - scene, - handScene: handsEnabled ? handScene : undefined, - isWebGL1 - }, - game - ) - replayManager.init({ playerId, engine, history: game.history ?? [] }) + managers.material.init(game) + managers.replay.init({ managers, playerId, history: game.history }) + managers.control.init({ managers }) + managers.move.init({ managers }) + managers.hand.init({ + managers, + playerId, + angleOnPlay: preferences?.angle + }) + + if (!isSimulation) { + managers.input.init({ managers }) + createLights({ scene, handScene }) + } - createLights({ scene, handScene, isWebGL1 }) - createTable(game.tableSpec, scene) + createTable(game.tableSpec, managers, scene) scene.onDataLoadedObservable.addOnce(async () => { isLoading = false // slight delay to let the UI disappear await sleep(100) engine.onLoadingObservable.notifyObservers(isLoading) }) - if (handsEnabled) { - handManager.init({ - scene, - handScene, - playerId, - overlay: hand, - angleOnPlay: preferences?.angle - }) - } } - selectionManager.updateColors(playerId, colorByPlayerId) + managers.selection.init({ managers, playerId, colorByPlayerId }) + await managers.customShape.init(game) - await customShapeManager.init({ - gameAssetsUrl, - meshes: game.meshes ?? [], - hands: game.hands ?? [] - }) - await loadMeshes(scene, game.meshes ?? []) - if (handsEnabled) { + await loadMeshes(scene, game.meshes ?? [], managers) + if (managers.hand.enabled) { await loadMeshes( handScene, (game.hands ?? []).find(hand => playerId === hand.playerId)?.meshes ?? - [] + [], + managers ) } if (gameData.selections) { for (const { playerId: peerId, selectedIds } of gameData.selections) { if (peerId !== playerId) { - selectionManager.apply(selectedIds, peerId) + managers.selection.apply(selectedIds, peerId) } } } } engine.serialize = () => { - return ( - replayManager.save ?? { - meshes: serializeMeshes(scene), - handMeshes: serializeMeshes(handScene), - history: replayManager.history - } - ) + return { + meshes: serializeMeshes(scene), + handMeshes: serializeMeshes(handScene), + history: managers.replay.history + } } - scene.onDataLoadedObservable.addOnce(() => { - inputManager.enabled = true - }) + engine.applyRemoteSelection = ( + /** @type {string[]} */ selectedIds, + /** @type {string} */ playerId + ) => { + if (!managers.replay.isReplaying) { + managers.selection.apply(selectedIds, playerId) + } + } + + engine.applyRemoteAction = async ( + /** @type {import('@src/3d/managers').ActionOrMove} */ actionOrMove, + /** @type {string} */ playerId + ) => { + managers.replay.record(actionOrMove, playerId) + if (!managers.replay.isReplaying) { + await managers.control.apply(actionOrMove) + } + } - interaction.addEventListener('pointerleave', handleLeave) + if (!isSimulation) { + scene.onDataLoadedObservable.addOnce(() => { + managers.input.enabled = true + }) + } engine.onDisposeObservable.addOnce(() => { - interaction.removeEventListener('pointerleave', handleLeave) - customShapeManager.clear() + managers.customShape.clear() }) - function handleLeave(/** @type {Event} */ event) { - inputManager.stopAll(event) - } - /* c8 ignore start */ - if (typeof window !== 'undefined') { + if (typeof window !== 'undefined' && !isSimulation) { window.toggleDebugger = async (main = true, hand = false) => { await import('@babylonjs/core/Debug/debugLayer') await import('@babylonjs/inspector') @@ -260,12 +300,65 @@ export function createEngine({ engine.onBeforeDisposeObservable.notifyObservers() dispose.call(engine) } + return engine } -function hasHandsEnabled(/** @type {Game} */ { meshes, hands }) { +function hasHandsEnabled( + /** @type {import('@src/graphql').Game} */ { meshes, hands } +) { return ( (hands ?? []).some(({ meshes }) => meshes.length > 0) || (meshes ?? []).some(({ drawable }) => drawable) ) } + +function transferActionsAndSelections( + /** @type {Engine} */ engine, + /** @type {Engine} */ simulation +) { + const original = engine.load + engine.load = async (...args) => { + const promise = simulation.load(...args) + if (!engine.managers.replay.isReplaying) { + await original(...args) + } + await promise + } + rebindMethod(engine, simulation, 'start') + rebindMethod(engine, simulation, 'dispose') + rebindMethod(engine, simulation, 'applyRemoteSelection') + rebindMethod(engine, simulation, 'applyRemoteAction') + engine.serialize = simulation.serialize.bind(simulation) + simulation.getRenderWidth = engine.getRenderWidth.bind(engine) + simulation.getRenderHeight = engine.getRenderHeight.bind(engine) + engine.managers.control.onActionObservable.add(action => { + if (!engine.managers.replay.isReplaying) { + simulation.managers.control.apply(action) + simulation.managers.replay.record(action) + } + }) + engine.managers.selection.onSelectionObservable.add(meshes => { + simulation.managers.selection.apply( + [...meshes].map(({ id }) => id), + engine.managers.selection.playerId + ) + }) +} + +/** + * @template {{ [k in keyof Engine]: Engine[k] extends Function ? k : never }[keyof Engine]} M + * @template {M extends undefined ? never : M} MethodName + * @param {Engine} engine + * @param {Engine} simulation + * @param {MethodName} methodName + */ +function rebindMethod(engine, simulation, methodName) { + const original = engine[methodName] + engine[methodName] = (/** @type {any} */ ...args) => { + return Promise.all([ + original(...args), + simulation[methodName](...args) + ]).then(([result]) => result) + } +} diff --git a/apps/web/src/3d/managers/camera.js b/apps/web/src/3d/managers/camera.js index 889c3766..805b93d4 100644 --- a/apps/web/src/3d/managers/camera.js +++ b/apps/web/src/3d/managers/camera.js @@ -18,29 +18,13 @@ import { isPositionAboveTable, screenToGround } from '../utils/vector' /** @typedef {Omit} CameraPosition */ -class CameraManager { +export class CameraManager { /** * Creates a manager to control the camera: * - pan, zoom and rotate * - save and restore its position * An ArcRotateCamera with no controls nor behaviors is created when initializing the manager. * Clears all observers on scene disposal. - */ - constructor() { - /** @type {?ArcRotateCamera} managed camera.*/ - this.camera = null - /** @type {?TargetCamera} camera for the hand scene, fixed.*/ - this.type = null - /** @type {CameraPosition[]} list of camera state saves.*/ - this.saves = [] - /** @type {Observable} emits when saving new camera positions.*/ - this.onSaveObservable = new Observable() - /** @type {Observable} emits when moving current camera (target, angle or elevation).*/ - this.onMoveObservable = new Observable() - } - - /** - * Creates a camera in current scene, that supports animated zooming and panning. * It can not leave the table. * Altitude is always in 3D world coordinate, angle in radians, and position in screen coordinates * It maintains a list of saves, the first being its initial state. @@ -53,7 +37,7 @@ class CameraManager { * @param {Scene} [params.scene] - main scene. * @param {Scene} [params.handScene] - hand scene. */ - init({ + constructor({ y = 35, beta = Math.PI / 8, minY = 5, @@ -63,6 +47,7 @@ class CameraManager { handScene } = {}) { logger.info({ y, minY, maxY }, 'initialize camera manager') + /** managed camera.*/ this.camera = new ArcRotateCamera( 'camera', (3 * Math.PI) / 2, @@ -88,8 +73,7 @@ class CameraManager { this.onMoveObservable.clear() }) - this.saves = [serialize(this.camera)] - + /** camera for the hand scene, fixed.*/ this.handSceneCamera = new TargetCamera( 'camera', new Vector3(0, 20, 0), @@ -97,6 +81,13 @@ class CameraManager { ) // providing exactly PI/2 gives unpredictable result depending on the CPU architecture this.handSceneCamera.rotation.x = Math.PI / 2.00001 + + /** @type {Observable} emits when saving new camera positions.*/ + this.onSaveObservable = new Observable() + /** @type {Observable} emits when moving current camera (target, angle or elevation).*/ + this.onMoveObservable = new Observable() + /** list of camera state saves.*/ + this.saves = [serialize(this.camera)] } /** @@ -106,11 +97,6 @@ class CameraManager { */ adjustZoomLevels({ min, max, hand } = {}) { const { camera, handSceneCamera } = this - if (!camera) { - throw new Error( - `please init the camera manager prior to adjusting zoom levels` - ) - } if (min) { camera.lowerRadiusLimit = min } @@ -129,10 +115,8 @@ class CameraManager { * @param {ScreenPosition} movementStart - movement starting point, in screen coordinate. * @param {ScreenPosition} movementEnd -movement ending point, in screen coordinate. * @param {number} [duration=300] - animation duration, in ms. - * @returns {Promise} */ async pan(movementStart, movementEnd, duration = 300) { - if (!this.camera) return const scene = this.camera.getScene() const start = screenToGround(scene, movementStart) @@ -155,11 +139,8 @@ class CameraManager { * @param {number} [alpha=0] - longitudinal rotation (around the Z axis), in radian. * @param {number} [beta=0] - latitudinal rotation, in radian, between minAngle and PI/2. * @param {number} [duration=300] - animation duration, in ms. - * @returns {Promise} */ async rotate(alpha = 0, beta = 0, duration = 300) { - if (!this.camera) return - if ((alpha || beta) && !currentAnimation) { await animate( this, @@ -174,10 +155,8 @@ class CameraManager { * Ends with the animation. * @param {number} elevation - positive or negative elevation (in 3D world coordinates). * @param {number} [duration=300] - animation duration, in ms. - * @returns {Promise} */ async zoom(elevation, duration = 300) { - if (!this.camera) return await animate(this, { elevation: this.camera.radius + elevation }, duration) } @@ -188,7 +167,7 @@ class CameraManager { * @param {number} [index=0] - slot where the state is saved. */ save(index = 0) { - if (!this.camera || index < 0 || index > this.saves.length) return + if (index < 0 || index > this.saves.length) return this.saves[index] = serialize(this.camera) this.onSaveObservable.notifyObservers([...this.saves]) } @@ -200,7 +179,7 @@ class CameraManager { * @param {number} [duration=300] - animation duration, in milliseconds. */ async restore(index = 0, duration = 300) { - if (!this.camera || !this.saves[index]) return + if (!this.saves[index]) return await animate( this, { @@ -223,12 +202,6 @@ class CameraManager { } } -/** - * Camera manager singleton. - * @type {CameraManager} - */ -export const cameraManager = new CameraManager() - const logger = makeLogger('camera') const frameRate = 10 @@ -276,17 +249,15 @@ const pan = /** @type {Animation & {targetProperty: 'lockedTarget'}} */ ( /** @type {?Animatable} */ let currentAnimation = null -/** - * - * @param {CameraManager} manager - * @param {Partial> & { target?: Vector3 }} newPosition - * @param {number} duration - * @returns - */ async function animate( - { camera, onMoveObservable }, - { alpha, beta, elevation, target }, - duration + /** @type {CameraManager} */ { camera, onMoveObservable }, + /** @type {Partial> & { target?: Vector3 }} */ { + alpha, + beta, + elevation, + target + }, + /** @type {number} */ duration ) { if (!camera) return const lastFrame = Math.round(frameRate * (duration / 1000)) @@ -346,11 +317,7 @@ async function animate( }) } -/** - * @param {ArcRotateCamera} camera - * @returns {CameraPosition} - */ -function serialize(camera) { +function serialize(/** @type {ArcRotateCamera} */ camera) { return addHash({ hash: '', alpha: camera[rotateAlpha.targetProperty], @@ -360,19 +327,12 @@ function serialize(camera) { }) } -/** - * @param {CameraPosition} save - * @returns {CameraPosition} - */ -function addHash(save) { +function addHash(/** @type {CameraPosition} */ save) { save.hash = `${save.target[0]}-${save.target[1]}-${save.target[2]}-${save.alpha}-${save.beta}-${save.elevation}` return save } -/** - * @param {ArcRotateCamera} camera - */ -function fixAlpha(camera) { +function fixAlpha(/** @type {ArcRotateCamera} */ camera) { if (camera.alpha < Math.PI / 2) { camera.alpha += 2 * Math.PI } else if (camera.alpha > (5 * Math.PI) / 2) { diff --git a/apps/web/src/3d/managers/control.js b/apps/web/src/3d/managers/control.js index 1ff1d783..2e66ec56 100644 --- a/apps/web/src/3d/managers/control.js +++ b/apps/web/src/3d/managers/control.js @@ -13,7 +13,6 @@ import { Observable } from '@babylonjs/core/Misc/observable.js' import { actionNames } from '../utils/actions' import { animateMove } from '../utils/behaviors' -import { handManager } from './hand' /** * @typedef {object} _Action @@ -28,6 +27,8 @@ import { handManager } from './hand' * * @typedef {Omit & _Move} Move applied move to a given mesh. * + * @typedef {Action|Move} ActionOrMove + * * @typedef {object} RecordedAction applied action to a given mesh: * @property {Mesh} mesh - modified mesh. * @property {ActionName} fn - name of the applied action. @@ -47,41 +48,37 @@ import { handManager } from './hand' * @property {string[]} images - list of images for this mesh (could be multiple for stacked meshes). */ -class ControlManager { +export class ControlManager { /** * Creates a manager to remotely control a collection of meshes: * - applies actions received to specific meshes * - propagates applied actions to observers (with cycle breaker) * Clears all observers on scene disposal. + * Invokes init() before any other function. + * @param {object} params - parameters, including: + * @param {Scene} params.scene - main scene. + * @param {Scene} params.handScene - scene for meshes in hand. */ - constructor() { - /** @type {Observable} emits applied actions. */ + constructor({ scene, handScene }) { + /** @type {Observable} emits applied actions. */ this.onActionObservable = new Observable() /** @type {Observable} emits when displaying details of a given mesh. */ this.onDetailedObservable = new Observable() /** @type {Observable>} emits the list of controlled meshes. */ this.onControlledObservable = new Observable() - /** @private @type {?Scene} */ - this.scene = null - /** @private @type {?Scene} */ - this.handScene = null - /** @private @type {Map} */ + /** @internal */ + this.scene = scene + /** @internal */ + this.handScene = handScene + /** @internal @type {Map} */ this.controlables = new Map() // prevents loops when applying an received action - /** @private @type {Set} */ + /** @internal @type {Set} */ this.localKeys = new Set() - } + /** @internal @type {import('@src/3d/managers').Managers} */ + this.managers - /** - * Gives a scene to the manager. - * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene. - * @param {Scene} params.handScene - scene for meshes in hand. - */ - init({ scene, handScene }) { - this.scene = scene - this.handScene = handScene this.scene.onDisposeObservable.addOnce(() => { this.onActionObservable.clear() this.onDetailedObservable.clear() @@ -89,6 +86,16 @@ class ControlManager { }) } + /** + * Initializes with game data and managers + * + * @param {object} params - parameters, including: + * @param {import('@src/3d/managers').Managers} params.managers - current managers. + */ + init({ managers }) { + this.managers = managers + } + /** * Registers a new controllable mesh. * Does nothing if this mesh is already managed. @@ -117,7 +124,7 @@ class ControlManager { /** * @param {Mesh} mesh - tested mesh - * @returns {boolean} whether this mesh is controlled or not + * @returns whether this mesh is controlled or not */ isManaging(mesh) { return this.controlables.has(mesh?.id) @@ -188,7 +195,7 @@ class ControlManager { if ('fn' in actionOrMove) { const { fn, args } = actionOrMove if (fn === actionNames.draw) { - await handManager.applyPlay(args[0], args[1]) + await this.managers.hand.applyPlay(args[0], args[1]) } else { for (const behavior of mesh?.behaviors ?? []) { if ('revert' in behavior) { @@ -220,7 +227,7 @@ class ControlManager { if ('fn' in action) { const args = action.args || [] if (action.fn === actionNames.play) { - await handManager.applyPlay(args[0], args[1]) + await this.managers.hand.applyPlay(args[0], args[1]) } else { // @ts-expect-error -- args can not be narrowed await mesh?.metadata?.[action.fn]?.(...args) @@ -237,16 +244,6 @@ class ControlManager { } } -/** - * Control manager singleton. - * @type {ControlManager} - */ -export const controlManager = new ControlManager() - -/** - * @param {Partial>} action - keyed action - * @returns {string} key for this action - */ -function getKey(action) { +function getKey(/** @type {Partial>} */ action) { return `${action?.meshId}-${action.fn?.toString() || 'pos'}` } diff --git a/apps/web/src/3d/managers/custom-shape.js b/apps/web/src/3d/managers/custom-shape.js index 85baecfc..0a7d5064 100644 --- a/apps/web/src/3d/managers/custom-shape.js +++ b/apps/web/src/3d/managers/custom-shape.js @@ -8,31 +8,28 @@ import { getDieModelFile } from '../meshes' const logger = makeLogger('custom-shape') -class CustomShapeManager { +export class CustomShapeManager { /** * Creates a manager to download and cache custom mesh shapes. + * @param {object} params - parameters, including: + * @param {string} [params.gameAssetsUrl] - base url hosting the game shape files. */ - constructor() { - /** @type {string} base url hosting the game shape files. */ - this.gameAssetsUrl = '' + constructor({ gameAssetsUrl }) { + /** base url hosting the game shape files. */ + this.gameAssetsUrl = gameAssetsUrl ?? '' /** @internal @type {Map} */ this.dataByFile = new Map() } /** - * Initialize manager with scene and configuration values. - * @param {object} params - parameters, including: - * @param {string} [params.gameAssetsUrl] - base url hosting the game shape files. - * @param {Game['meshes']} params.meshes - list of meshes. - * @param {Game['hands']} params.hands - list of hand meshes - * @returns {Promise} + * Download modesl and cache their results. + * @param {Game} game - game data. */ - async init({ gameAssetsUrl, meshes, hands }) { + async init({ meshes, hands }) { logger.debug( { files: [...this.dataByFile.keys()] }, 'init custom shape manager' ) - this.gameAssetsUrl = gameAssetsUrl ?? '' const files = new Set([ ...extractFiles(meshes), ...(hands ?? []).flatMap(({ meshes }) => extractFiles(meshes)) @@ -51,12 +48,12 @@ class CustomShapeManager { /** * Returns data for a given dile * @param {string} file - desired custom shape file name. - * @returns {string} the corresponding data, as acceptable by Babylon's SceneLoader.ImportMesh(). + * @returns the corresponding data, as acceptable by Babylon's SceneLoader.ImportMesh(). * @throws {Error} if requested file was not loaded. */ get(file) { const data = this.dataByFile.get(file) - if (!data) { + if (data === undefined) { logger.error( { file }, `custom shape manager does not have data for ${file}` @@ -78,17 +75,7 @@ class CustomShapeManager { } } -/** - * Custom shape manager singleton. - * @type {CustomShapeManager} - */ -export const customShapeManager = new CustomShapeManager() - -/** - * @param {Game['meshes']} meshes - * @returns {string[]} - */ -function extractFiles(meshes) { +function extractFiles(/** @type {Game['meshes']} */ meshes) { /** @type {string[]} */ const files = [] for (const { id, shape, file, faces } of meshes ?? []) { @@ -107,7 +94,7 @@ function extractFiles(meshes) { /** * @param {CustomShapeManager} manager - manager instance. * @param {string} file - downloaded file. - * @returns {Promise} resolves when the file is downloaded. + * @returns resolves when the file is downloaded. */ async function downloadAndStore(manager, file) { logger.debug({ file }, `starts downloading ${file}`) diff --git a/apps/web/src/3d/managers/hand.js b/apps/web/src/3d/managers/hand.js index 20930600..a2629eaf 100644 --- a/apps/web/src/3d/managers/hand.js +++ b/apps/web/src/3d/managers/hand.js @@ -12,8 +12,6 @@ * @typedef {import('@src/3d/behaviors/quantifiable').QuantityBehavior} QuantityBehavior * @typedef {import('@src/3d/behaviors/rotable').RotateBehavior} RotateBehavior * @typedef {import('@src/3d/behaviors/stackable').StackBehavior} StackBehavior - * @typedef {import('@src/3d/managers/control').Action} Action - * @typedef {import('@src/3d/managers/control').Move} Move * @typedef {import('@src/3d/managers/input').DragData} DragData * @typedef {import('@src/3d/managers/target').DropZone} DropZone * @typedef {import('@src/3d/utils').ScreenPosition} ScreenPosition @@ -50,12 +48,6 @@ import { isAboveTable, screenToGround } from '../utils/vector' -import { controlManager } from './control' -import { indicatorManager } from './indicator' -import { inputManager } from './input' -import { moveManager } from './move' -import { selectionManager } from './selection' -import { targetManager } from './target' /** * @typedef {Required>} EngineDimension observed dimension of the rendering engine (pixels). @@ -75,38 +67,57 @@ import { targetManager } from './target' const logger = makeLogger('hand') -class HandManager { +export class HandManager { /** * Creates a manager for the player's hand meshes: * - display and organize them in their dedicated scene. * - handles actions from and to the main scene. - * Is only enabled after having been initialized. + * Is starts disabled and must be manually enabled. + * Invokes init() before any other function. + * @param {object} params - parameters, including: + * @param {Scene} params.scene - main scene. + * @param {Scene} params.handScene - scene for meshes in hand. + * @param {HTMLElement} params.overlay - HTML element defining hand's available height. + * @param {number} [params.gap=0.5] - gap between hand meshes, when render width allows it, in 3D coordinates. + * @param {number} [params.verticalPadding=1] - vertical padding between meshes and the viewport edges, in 3D coordinates. + * @param {number} [params.horizontalPadding=2] - horizontal padding between meshes and the viewport edges, in 3D coordinates. + * @param {number} [params.transitionMargin=20] - margin (in pixel) applied to the hand scene border. Meshes dragged within this margin will be drawn or played. + * @param {number} [params.duration=100] - duration (in milliseconds) when moving meshes. */ - constructor() { - /** @type {Scene} the main scene. */ - this.scene - /** @type {Scene} scene for meshes in hand. */ - this.handScene - /** @type {boolean} whether this manager is enabled. */ + constructor({ + scene, + handScene, + overlay, + gap = 0.5, + verticalPadding = 0.5, + horizontalPadding = 2, + transitionMargin = 20, + duration = 100 + }) { + /** the main scene. */ + this.scene = scene + /** scene for meshes in hand. */ + this.handScene = handScene + /** whether this manager is enabled. */ this.enabled = false - /** @type {number} gap between hand meshes, when render width allows it, in 3D coordinates. */ - this.gap = 0 - /** @type {number} vertical padding between meshes and the viewport edges, in 3D coordinates. */ - this.verticalPadding = 0 - /** @type {number} horizontal padding between meshes and the viewport edges, in 3D coordinates. */ - this.horizontalPadding = 0 - /** @type {number} duration (in milliseconds) when moving meshes. */ - this.duration = 100 - /** @type {number} margin (in pixel) applied to the hand scene border. Meshes dragged within this margin will be drawn or played. */ - this.transitionMargin = 0 + /** gap between hand meshes, when render width allows it, in 3D coordinates. */ + this.gap = gap + /** vertical padding between meshes and the viewport edges, in 3D coordinates. */ + this.verticalPadding = verticalPadding + /** horizontal padding between meshes and the viewport edges, in 3D coordinates. */ + this.horizontalPadding = horizontalPadding + /** duration (in milliseconds) when moving meshes. */ + this.duration = duration + /** margin (in pixel) applied to the hand scene border. Meshes dragged within this margin will be drawn or played. */ + this.transitionMargin = transitionMargin /** @type {number} angle applied when playing rotable meshes, due to the player position. */ - this.angleOnPlay = 0 + this.angleOnPlay /** @type {Observable} emits new state on hand changes. */ this.onHandChangeObservable = new Observable() /** @type {Observable} emits a boolean when dragged may (or not) be dragged to hand. */ this.onDraggableToHandObservable = new Observable() - /** @type {HTMLElement} HTML element defining hand's available height. */ - this.overlay + /** HTML element defining hand's available height. */ + this.overlay = overlay /** @internal @type {Extent} */ this.extent = { height: 0, @@ -134,45 +145,22 @@ class HandManager { } }) /** @internal @type {string} */ - this.playerId = '' + this.playerId + /** @internal @type {import('@src/3d/managers').Managers} */ + this.managers } /** - * Gives scenes to the manager. + * Initialize with game data * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene. - * @param {Scene} params.handScene - scene for meshes in hand. - * @param {HTMLElement} params.overlay - HTML element defining hand's available height. + * @param {import('@src/3d/managers').Managers} params.managers - current managers. * @param {string} params.playerId - id of the local player. - * @param {number} [params.gap=0.5] - gap between hand meshes, when render width allows it, in 3D coordinates. - * @param {number} [params.verticalPadding=1] - vertical padding between meshes and the viewport edges, in 3D coordinates. - * @param {number} [params.horizontalPadding=2] - horizontal padding between meshes and the viewport edges, in 3D coordinates. - * @param {number} [params.transitionMargin=20] - margin (in pixel) applied to the hand scene border. Meshes dragged within this margin will be drawn or played. - * @param {number} [params.duration=100] - duration (in milliseconds) when moving meshes. * @param {number} [params.angleOnPlay=0] - angle applied when playing rotable meshes, due to the player position. */ - init({ - scene, - handScene, - overlay, - playerId, - gap = 0.5, - verticalPadding = 0.5, - horizontalPadding = 2, - transitionMargin = 20, - duration = 100, - angleOnPlay = 0 - }) { - this.scene = scene - this.handScene = handScene - this.gap = gap - this.verticalPadding = verticalPadding - this.horizontalPadding = horizontalPadding - this.duration = duration - this.transitionMargin = transitionMargin - this.overlay = overlay - this.angleOnPlay = angleOnPlay + init({ managers, playerId, angleOnPlay = 0 }) { + this.managers = managers this.playerId = playerId + this.angleOnPlay = angleOnPlay const engine = this.handScene.getEngine() /** @type {(() => void)[]} */ @@ -190,16 +178,17 @@ class HandManager { } }, { - observable: controlManager.onActionObservable, - handle: (/** @type {Action|Move} */ action) => - handleAction(this, action) + observable: this.managers.control.onActionObservable, + handle: ( + /** @type {import('@src/3d/managers').ActionOrMove} */ action + ) => handleAction(this, action) }, { - observable: inputManager.onDragObservable, + observable: this.managers.input.onDragObservable, handle: (/** @type {DragData} */ action) => handDrag(this, action) }, { - observable: handScene.onNewMeshAddedObservable, + observable: this.handScene.onNewMeshAddedObservable, handle: (/** @type {Mesh} */ added) => { // delay because mesh names are set after being constructed setTimeout(() => { @@ -211,7 +200,7 @@ class HandManager { } }, { - observable: handScene.onMeshRemovedObservable, + observable: this.handScene.onMeshRemovedObservable, handle: (/** @type {Mesh} */ removed) => { if (isSerializable(removed)) { logger.info( @@ -226,6 +215,15 @@ class HandManager { this.changes$.next() } } + }, + { + observable: this.managers.replay.onReplayRankObservable, + handle: () => + // delay until replay move is over + setTimeout( + () => this.changes$.next(), + this.managers.replay.moveDuration + ) } ])) { const observer = observable.add(handle) @@ -248,7 +246,6 @@ class HandManager { computeExtent(this, engine) storeMeshDimensions(this) - this.enabled = true layoutMeshs(this) } @@ -293,8 +290,8 @@ class HandManager { ) { return } - await playMeshes(this, selectionManager.getSelection(playedMesh)) - selectionManager.clear() + await playMeshes(this, this.managers.selection.getSelection(playedMesh)) + this.managers.selection.clear() } /** @@ -317,11 +314,11 @@ class HandManager { await pickMesh(this, mesh, true) } else { logger.info({ mesh, playerId }, `another player pick ${mesh.id} in hand`) - record(mesh, actionNames.draw, playerId, true) + record(mesh, this.managers, actionNames.draw, playerId, true) await animateToHand(mesh) } if (!isSamePlayer) { - indicatorManager.registerFeedback({ + this.managers.indicator.registerFeedback({ action: actionNames.draw, playerId, position @@ -349,23 +346,23 @@ class HandManager { if (isSamePlayer) { this.handScene.getMeshById(state.id)?.dispose() } - const mesh = await createMeshFromState(state, this.scene) + const mesh = await createMeshFromState(state, this.scene, this.managers) logger.info( { mesh }, `${isSamePlayer ? '' : 'another player '}play ${mesh.id} from hand` ) // record should comes before dropping on a zone, but after creating main mesh - record(mesh, actionNames.play, playerId, true) - const dropZone = targetManager.findDropZone( + record(mesh, this.managers, actionNames.play, playerId, true) + const dropZone = this.managers.target.findDropZone( mesh, mesh.getBehaviorByName(MoveBehaviorName)?.state.kind ) if (dropZone) { - targetManager.dropOn(dropZone, { immediate: true, isLocal: true }) + this.managers.target.dropOn(dropZone, { immediate: true, isLocal: true }) } await getDrawable(mesh)?.animateToMain() if (!isSamePlayer) { - indicatorManager.registerFeedback({ + this.managers.indicator.registerFeedback({ action: actionNames.play, playerId, position: [state.x ?? 0, state.y ?? 0, state.z ?? 0] @@ -397,15 +394,9 @@ class HandManager { } } -/** - * Player's hand manager singleton. - * @type {HandManager} - */ -export const handManager = new HandManager() - /** * @param {HandManager} manager - manager instance. - * @param {Action|Move} action - applied action. + * @param {import('@src/3d/managers').ActionOrMove} action - applied action. */ function handleAction(manager, action) { if ( @@ -427,8 +418,8 @@ function handleAction(manager, action) { * @param {DragData} drag - drag details. */ async function handDrag(manager, { type, mesh, event }) { - const { handScene } = manager - if (!hasSelectedDrawableMeshes(mesh)) { + const { handScene, managers } = manager + if (!hasSelectedDrawableMeshes(mesh, managers)) { return } @@ -443,7 +434,7 @@ async function handDrag(manager, { type, mesh, event }) { if (mesh.getScene() === handScene) { let moved = manager.moved if (type === 'dragStart') { - moved = selectionManager.getSelection(mesh) + moved = manager.managers.selection.getSelection(mesh) } else if (type === 'dragStop') { moved = [] } @@ -461,16 +452,16 @@ async function handDrag(manager, { type, mesh, event }) { { mesh: movedMesh, x, z }, `play mesh ${movedMesh.id} from hand by dragging` ) - const wasSelected = selectionManager.meshes.has(movedMesh) + const wasSelected = managers.selection.meshes.has(movedMesh) const mesh = await createMainMesh(manager, movedMesh, { x, z }) /** @type {?DropZone} */ let dropZone if (droppedList.length) { // when first drawn mesh was dropped on player zone, tries to drop others on top of it. - dropZone = canDropAbove(droppedList[0], mesh) + dropZone = canDropAbove(managers, droppedList[0], mesh) } else { // can first mesh be dropped on player zone? - dropZone = targetManager.findPlayerZone(mesh) + dropZone = managers.target.findPlayerZone(mesh) } if (dropZone) { @@ -489,22 +480,26 @@ async function handDrag(manager, { type, mesh, event }) { } record( mesh, + managers, actionNames.play, manager.playerId, false, getPositionAboveZone(mesh, dropZone) ) - targetManager.dropOn(dropZone, { immediate: true, isLocal: true }) + managers.target.dropOn(dropZone, { + immediate: true, + isLocal: true + }) } else { if (wasSelected) { - selectionManager.select(mesh) + managers.selection.select(mesh) } - record(mesh, actionNames.play, manager.playerId) + record(mesh, managers, actionNames.play, manager.playerId) } } if (saved) { - moveManager.exclude(...droppedList) - selectionManager.clear() + managers.move.exclude(...droppedList) + managers.selection.clear() // play move animation for local player only const current = saved.mesh.absolutePosition.clone() saved.mesh.setAbsolutePosition(saved.position) @@ -514,18 +509,18 @@ async function handDrag(manager, { type, mesh, event }) { layoutMeshs(manager) } else if (isMainMeshNextToHand(manager, mesh)) { if (type !== 'dragStop') { - inputManager.stopDrag(event) + managers.input.stopDrag(event) } else { // for replay, is it important we apply actions to highest meshes first, // so they could be poped from their stack - const drawn = sortByElevation(selectionManager.getSelection(mesh), true) + const drawn = sortByElevation(managers.selection.getSelection(mesh), true) logger.debug({ drawn }, `dragged meshes into hand`) const { x: positionX } = screenToGround(manager.handScene, event) const z = -manager.contentDimensions.depth const origin = drawn[0].absolutePosition.x for (const mesh of drawn) { logger.info({ mesh }, `pick mesh ${mesh.id} in hand by dragging`) - record(mesh, actionNames.draw, manager.playerId) + record(mesh, managers, actionNames.draw, manager.playerId) mesh.isPhantom = true const x = positionX + mesh.absolutePosition.x - origin await createHandMesh(manager, mesh, { x, z }) @@ -562,7 +557,11 @@ async function createMainMesh(manager, handMesh, extraState = {}) { transformOnPlay(manager, handMesh) const state = handMesh.metadata.serialize() handMesh.dispose() - return createMeshFromState({ ...state, ...extraState }, manager.scene) + return createMeshFromState( + { ...state, ...extraState }, + manager.scene, + manager.managers + ) } /** @@ -574,11 +573,12 @@ async function createMainMesh(manager, handMesh, extraState = {}) { async function createHandMesh(manager, mainMesh, extraState = {}) { const state = { ...mainMesh.metadata.serialize(), ...extraState } transformOnPick(state) - return createMeshFromState(state, manager.handScene) + return createMeshFromState(state, manager.handScene, manager.managers) } function record( /** @type {Mesh} */ mesh, + /** @type {import('@src/3d/managers').Managers} */ managers, /** @type {actionNames['play'] | actionNames['draw']} */ fn, /** @type {string} */ playerId, /** @type {boolean} */ isLocal = false, @@ -590,7 +590,7 @@ function record( state.y = finalPosition.y state.z = finalPosition.z } - controlManager.record({ + managers.control.record({ mesh, fn, args: [state, playerId], @@ -749,10 +749,13 @@ function transformOnPlay( } /** @returns whether this selected mesh is drawable. */ -function hasSelectedDrawableMeshes(/** @type {?Mesh|undefined} */ mesh) { +function hasSelectedDrawableMeshes( + /** @type {?Mesh|undefined} */ mesh, + /** @type {import('@src/3d/managers').Managers} */ managers +) { return ( Boolean(mesh) && - selectionManager + managers.selection .getSelection(/** @type {Mesh} */ (mesh)) .some(mesh => mesh.getBehaviorByName(DrawBehaviorName)) ) @@ -762,6 +765,7 @@ async function playMeshes( /** @type {HandManager} */ manager, /** @type {Mesh[]} */ meshes ) { + const { extent, scene, managers } = manager /** @type {?Mesh} */ let dropped = null /** @type {Mesh[]} */ @@ -770,10 +774,10 @@ async function playMeshes( logger.info({ mesh: drawnMesh }, `play mesh ${drawnMesh.id} from hand`) const screenPosition = { x: /** @type {ScreenPosition} */ (getMeshScreenPosition(drawnMesh)).x, - y: manager.extent.size.height * 0.5 + y: extent.size.height * 0.5 } - const position = screenToGround(manager.scene, screenPosition) - if (!position || !isAboveTable(manager.scene, screenPosition)) { + const position = screenToGround(scene, screenPosition) + if (!position || !isAboveTable(scene, screenPosition)) { return [] } const mesh = await createMainMesh(manager, drawnMesh, { @@ -786,10 +790,10 @@ async function playMeshes( let dropZone = null if (dropped) { // when first drawn mesh was dropped on player zone, tries to drop others on top of it. - dropZone = canDropAbove(dropped, mesh) + dropZone = canDropAbove(managers, dropped, mesh) } else { // can first mesh be dropped on player zone? - dropZone = targetManager.findPlayerZone(mesh) + dropZone = managers.target.findPlayerZone(mesh) if (dropZone) { dropped = mesh } @@ -797,35 +801,40 @@ async function playMeshes( if (!dropZone) { // mesh can not be dropped on player zone nor first mesh, try to stack it. - dropZone = findStackZone(mesh) + dropZone = findStackZone(managers, mesh) } if (dropZone) { record( mesh, + managers, actionNames.play, manager.playerId, false, getPositionAboveZone(mesh, dropZone) ) - targetManager.dropOn(dropZone, { immediate: true, isLocal: true }) + managers.target.dropOn(dropZone, { immediate: true, isLocal: true }) } else { // no possible drop: let it lie on the table. applyGravity(mesh) - record(mesh, actionNames.play, manager.playerId) + record(mesh, managers, actionNames.play, manager.playerId) } } await Promise.all(created.map(mesh => getDrawable(mesh)?.animateToMain())) } -function findStackZone(/** @type {Mesh} */ mesh) { +function findStackZone( + /** @type {import('@src/3d/managers').Managers} */ managers, + /** @type {Mesh} */ mesh +) { mesh.computeWorldMatrix(true) - return targetManager.findDropZone( + return managers.target.findDropZone( mesh, mesh.getBehaviorByName(MoveBehaviorName)?.state.kind ) } function canDropAbove( + /** @type {import('@src/3d/managers').Managers} */ managers, /** @type {Mesh} */ baseMesh, /** @type {Mesh} */ dropped ) { @@ -833,7 +842,7 @@ function canDropAbove( dropped.setAbsolutePosition( baseMesh.absolutePosition.add(new Vector3(0, 100, 0)) ) - const dropZone = findStackZone(dropped) + const dropZone = findStackZone(managers, dropped) if (dropZone) { return dropZone } @@ -848,7 +857,7 @@ async function pickMesh( isLocal = false ) { logger.info({ mesh }, `pick mesh ${mesh.id} in hand`) - record(mesh, actionNames.draw, manager.playerId, isLocal) + record(mesh, manager.managers, actionNames.draw, manager.playerId, isLocal) const { minZ } = manager.extent const { width } = manager.contentDimensions const { depth } = getDimensions(mesh) diff --git a/apps/web/src/3d/managers/index.js b/apps/web/src/3d/managers/index.js index 643929ee..d37a9697 100644 --- a/apps/web/src/3d/managers/index.js +++ b/apps/web/src/3d/managers/index.js @@ -1,3 +1,17 @@ +/** + * @typedef {object} Managers + * @property {import('@src/3d/managers/camera').CameraManager} camera + * @property {import('@src/3d/managers/control').ControlManager} control + * @property {import('@src/3d/managers/custom-shape').CustomShapeManager} customShape + * @property {import('@src/3d/managers/hand').HandManager} hand + * @property {import('@src/3d/managers/indicator').IndicatorManager} indicator + * @property {import('@src/3d/managers/input').InputManager} input + * @property {import('@src/3d/managers/material').MaterialManager} material + * @property {import('@src/3d/managers/move').MoveManager} move + * @property {import('@src/3d/managers/replay').ReplayManager} replay + * @property {import('@src/3d/managers/selection').SelectionManager} selection + * @property {import('@src/3d/managers/target').TargetManager} target + */ export * from './camera' export * from './control' export * from './custom-shape' diff --git a/apps/web/src/3d/managers/indicator.js b/apps/web/src/3d/managers/indicator.js index df66a025..eecd93e8 100644 --- a/apps/web/src/3d/managers/indicator.js +++ b/apps/web/src/3d/managers/indicator.js @@ -59,13 +59,15 @@ import { getMeshScreenPosition, getScreenPosition } from '../utils/vector' const logger = makeLogger('indicator') -class IndicatorManager { +export class IndicatorManager { /** * Creates a manager for indications above meshes. + * @param {object} params - parameters, including: + * @param {Scene} params.scene - main scene */ - constructor() { + constructor({ scene }) { /** @type {Scene} the main scene. */ - this.scene + this.scene = scene /** @type {Observable} emits when the indicator list has changed. */ this.onChangeObservable = new Observable() /** @internal @type {Map} a map of displayed indicator by their id. */ @@ -74,17 +76,7 @@ class IndicatorManager { this.pointerByPlayerId = new Map() /** @internal @type {?() => void} */ this.unsubscribeOnRender = null - } - - /** - * Gives a scene to the manager. - * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene - */ - async init({ scene }) { logger.debug({}, 'init indicators manager') - this.unsubscribeOnRender?.() - this.scene = scene const engine = scene.getEngine() const onRenderObserver = engine.onEndFrameObservable.add(() => handleFrame(this) @@ -101,7 +93,7 @@ class IndicatorManager { * Registers an indicator for a given mesh. * Does nothing if this indicator is already managed. * @param {MeshPlayerIndicator|MeshSizeIndicator} indicator - new size or player indicator. - * @returns {ManagedIndicator} the registered indicator. + * @returns the registered indicator. */ registerMeshIndicator(indicator) { const existing = this.getById(indicator?.id) @@ -124,7 +116,7 @@ class IndicatorManager { * Registers an indicator for a given player. * @param {string} playerId - id of the corresponding player. * @param {number[]} position - Vector3 components describing the pointer position in 3D engine. - * @returns {ManagedPointer} the registered indicator. + * @returns the registered indicator. */ registerPointerIndicator(playerId, position) { let indicator = this.pointerByPlayerId.get(playerId) @@ -176,7 +168,7 @@ class IndicatorManager { /** * @param {Pick} indicator - tested indicator - * @returns {boolean} whether this indicator is controlled or not + * @returns whether this indicator is controlled or not */ isManaging(indicator) { return this.indicators.has(indicator?.id) @@ -184,7 +176,7 @@ class IndicatorManager { /** * @param {string} [id] - requested indicator id. - * @returns {ManagedIndicator|ManagedPointer|undefined} the indicator found, if any. + * @returns the indicator found, if any. */ getById(id) { return id ? this.indicators.get(id) : undefined @@ -204,16 +196,7 @@ class IndicatorManager { } } -/** - * Indicator manager singleton. - * @type {IndicatorManager} - */ -export const indicatorManager = new IndicatorManager() - -/** - * @param {IndicatorManager} manager - manager instance - */ -function handleFrame(manager) { +function handleFrame(/** @type {IndicatorManager} */ manager) { let hasChanged = false for (const [, indicator] of manager.indicators) { if ('mesh' in indicator) { @@ -229,7 +212,7 @@ function handleFrame(manager) { /** * @param {ManagedIndicator} indicator - updated indicator. - * @returns {boolean} if this indicator position has changed. + * @returns if this indicator position has changed. */ function setMeshPosition(indicator) { const { depth } = getDimensions(indicator.mesh) @@ -247,7 +230,7 @@ function setMeshPosition(indicator) { /** * @param {ManagedPointer|ManagedFeedback} indicator - updated indicator. * @param {IndicatorManager} manager - instance manager. - * @returns {boolean} if this indicator position has changed. + * @returns if this indicator position has changed. */ function setPointerPosition(indicator, { scene }) { const { x, y } = getScreenPosition( @@ -286,7 +269,7 @@ function notifyChange( /** * @param {IndicatorManager} manager - manager instance. * @param {Indicator} indicator - checked indicator. - * @returns {boolean} whether this indicator is in the current camera frustum. + * @returns whether this indicator is in the current camera frustum. */ function isInFrustum({ scene }, indicator) { let point = diff --git a/apps/web/src/3d/managers/input.js b/apps/web/src/3d/managers/input.js index 7afeae3e..cf206e4a 100644 --- a/apps/web/src/3d/managers/input.js +++ b/apps/web/src/3d/managers/input.js @@ -10,8 +10,6 @@ import { Scene } from '@babylonjs/core/scene.js' import { makeLogger } from '../../utils/logger' import { distance } from '../../utils/math' import { screenToGround } from '../utils/vector' -import { replayManager } from './replay' -import { selectionManager } from './selection' const logger = makeLogger('input') @@ -109,7 +107,7 @@ const DragMinimumDistance = 5 * @property {ReturnType} deferLong - timeout id for long press. */ -class InputManager { +export class InputManager { /** * Creates a manager to manages user inputs, and notify observers for: * - single and double taps with fingers, stylus or mouse, on table or on mesh @@ -117,9 +115,21 @@ class InputManager { * - mouse hovering a given mesh * - mouse wheel * Clears all observers on scene disposal. + * Invokes init() before any other function. + * @param {object} params - parameters, including: + * @param {Scene} params.scene - scene attached to. + * @param {Scene} params.handScene - hand scene overlay. + * @param {HTMLElement} params.interaction - the DOM element to attach event handlers to + * @param {number} [params.longTapDelay=500] - number of milliseconds to hold pointer down before it is considered as long. */ - constructor() { - /** @type {boolean} whether inputs are handled or ignored. */ + constructor({ scene, handScene, longTapDelay = 500, interaction }) { + /** main scene. */ + this.scene = scene + /** hand scene. */ + this.handScene = handScene + this.longTapDelay = longTapDelay + + /** whether inputs are handled or ignored. */ this.enabled = false /** @type {Observable} emits single and double tap events. */ this.onTapObservable = new Observable() @@ -137,28 +147,20 @@ class InputManager { this.onKeyObservable = new Observable() /** @type {Observable} emits Vector3 components describing the current pointer position in 3D engine. */ this.onPointerObservable = new Observable() - /** @protected @type {?() => void} */ - this.dispose = null + this.interaction = interaction + /** @internal */ + interaction.style.setProperty('--cursor', 'move') + /** @internal @type {import('@src/3d/managers').Managers} */ + this.managers + /** @internal */ } /** - * Gives a scene to the manager, so it can bind to underlying events. + * Initializes with other managers. * @param {object} params - parameters, including: - * @param {Scene} params.scene - scene attached to. - * @param {Scene} params.handScene - hand scene overlay. - * @param {HTMLElement} params.interaction - the DOM element to attach event handlers to - * @param {Observable} params.onCameraMove - observable triggered when the main camera is moving. - * @param {number} params.longTapDelay - number of milliseconds to hold pointer down before it is considered as long. - * @param {boolean} [params.enabled=false] - whether the input manager actively handles inputs or not. + * @param {import('@src/3d/managers').Managers} params.managers - current managers. */ - init({ - scene, - handScene, - enabled = false, - longTapDelay, - interaction, - onCameraMove - }) { + init({ managers }) { // same finger/stylus/mouse will have same pointerId for down, move(s) and up events // different fingers will have different ids /** @type {Map} */ @@ -180,10 +182,6 @@ class InputManager { let lastMoveEvent = /** @type {PointerEvent} */ ({}) /** @type {Set} */ let pressedPointerIds = new Set() - this.enabled = enabled - this.longTapDelay = longTapDelay - this.dispose?.() - interaction.style.setProperty('--cursor', 'move') const handleCameraMove = () => { const { mesh } = computeMetas(lastMoveEvent) @@ -206,7 +204,7 @@ class InputManager { logger.info(data, `start hovering ${mesh.id}`) hoveredByPointerId.set(event.pointerId, mesh) this.onHoverObservable.notifyObservers(data) - interaction.style.setProperty('--cursor', 'grab') + this.interaction.style.setProperty('--cursor', 'grab') } } @@ -220,7 +218,7 @@ class InputManager { logger.info(data, `stop hovering ${mesh.id}`) hoveredByPointerId.delete(event.pointerId) this.onHoverObservable.notifyObservers(data) - interaction.style.setProperty('--cursor', 'move') + this.interaction.style.setProperty('--cursor', 'move') } } else { for (const pointerId of hoveredByPointerId.keys()) { @@ -239,7 +237,8 @@ class InputManager { ...dragOrigin, // in case mesh was moved between scenes by dragging, return the new one mesh: meshId - ? scene.getMeshById(meshId) ?? handScene?.getMeshById(meshId) + ? this.scene.getMeshById(meshId) ?? + this.handScene?.getMeshById(meshId) : null, event, pointers: pointers.size, @@ -289,22 +288,17 @@ class InputManager { pressedPointerIds.clear() } - /** - * @param {PointerEvent|WheelEvent} event - * @returns {{ button?: PointerEvent['button'], mesh: ?Mesh}} - */ - function computeMetas(event) { - return { - button: - 'pointerType' in event && event.pointerType === 'mouse' - ? event.button - : undefined, - // takes mesh with highest elevation, and only when they are pickable and when not replaying - mesh: replayManager.isReplaying - ? null - : findPickedMesh(handScene, event) ?? findPickedMesh(scene, event) - } - } + const computeMetas = (/** @type {PointerEvent|WheelEvent} */ event) => ({ + button: + 'pointerType' in event && event.pointerType === 'mouse' + ? event.button + : undefined, + // takes mesh with highest elevation, and only when they are pickable and when not replaying + mesh: managers.replay.isReplaying + ? null + : findPickedMesh(this.handScene, managers, event) ?? + findPickedMesh(this.scene, managers, event) + }) const handlePointerDown = (/** @type {PointerEvent} */ event) => { if (!this.enabled) return @@ -350,7 +344,7 @@ class InputManager { const handlePointerMove = (/** @type {PointerEvent} */ event) => { if (!this.enabled || !hasMoved(pointers, event)) return lastMoveEvent = event - const pointer = screenToGround(scene, event).asArray() + const pointer = screenToGround(this.scene, event).asArray() if (pointer) { this.onPointerObservable.notifyObservers(pointer) } @@ -479,7 +473,7 @@ class InputManager { Date.now() - lastTap < Scene.DoubleClickDelay ? 'doubletap' : 'tap', pointers: pointers.size, ...storedPointer, - fromHand: storedPointer.mesh?.getScene() === handScene, + fromHand: storedPointer.mesh?.getScene() === this.handScene, event } // for multiple pointers, potentially use long, timestamp and mesh from others @@ -493,7 +487,7 @@ class InputManager { } if (mesh && !data.mesh) { data.mesh = mesh - data.fromHand = mesh.getScene() === handScene + data.fromHand = mesh.getScene() === this.handScene } } } @@ -550,30 +544,35 @@ class InputManager { this.stopAll(event) } - interaction.addEventListener('blur', handleBlur) - interaction.addEventListener('pointerdown', handlePointerDown) - interaction.addEventListener('pointermove', handlePointerMove) - interaction.addEventListener('pointerup', handlePointerUp) - interaction.addEventListener('wheel', handleWheel, { passive: true }) - interaction.addEventListener('keydown', handleKeyDown) - const cameraObserver = onCameraMove.add(handleCameraMove) - this.dispose = () => { - interaction.removeEventListener('blur', handleBlur) - interaction.removeEventListener('pointerdown', handlePointerDown) - interaction.removeEventListener('pointermove', handlePointerMove) - interaction.removeEventListener('pointerup', handlePointerUp) - interaction.removeEventListener('wheel', handleWheel) - interaction.removeEventListener('keydown', handleKeyDown) - onCameraMove.remove(cameraObserver) + this.interaction.addEventListener('blur', handleBlur) + this.interaction.addEventListener('pointerdown', handlePointerDown) + this.interaction.addEventListener('pointermove', handlePointerMove) + this.interaction.addEventListener('pointerup', handlePointerUp) + this.interaction.addEventListener('pointerleave', handleBlur) + this.interaction.addEventListener('wheel', handleWheel, { passive: true }) + this.interaction.addEventListener('keydown', handleKeyDown) + const cameraObserver = + managers.camera.onMoveObservable.add(handleCameraMove) + const dispose = () => { + this.interaction.removeEventListener('blur', handleBlur) + this.interaction.removeEventListener('pointerdown', handlePointerDown) + this.interaction.removeEventListener('pointermove', handlePointerMove) + this.interaction.removeEventListener('pointerup', handlePointerUp) + this.interaction.removeEventListener('pointerleave', handleBlur) + this.interaction.removeEventListener('wheel', handleWheel) + this.interaction.removeEventListener('keydown', handleKeyDown) + managers.camera.onMoveObservable.remove(cameraObserver) } - scene.onDisposeObservable.addOnce(() => { + this.scene.onDisposeObservable.addOnce(() => { this.onTapObservable.clear() this.onDragObservable.clear() this.onHoverObservable.clear() this.onWheelObservable.clear() this.onKeyObservable.clear() this.onPointerObservable.clear() - this.dispose?.() + this.onLongObservable.clear() + this.onPinchObservable.clear() + dispose?.() }) } @@ -609,25 +608,20 @@ class InputManager { } } -/** - * Input manager singleton. - * @type {InputManager} - */ -export const inputManager = new InputManager() - /** * @param {Scene} scene - scene in which mesh are picked. + * @param {import('@src/3d/managers').Managers} managers - other managers. * @param {MouseEvent} event - picking event. - * @returns {?Mesh} picked mesh, if any. + * @returns picked mesh, if any. */ -function findPickedMesh(scene, { x, y }) { +function findPickedMesh(scene, { selection }, { x, y }) { return /** @type {?Mesh} */ ( scene .multiPickWithRay( scene.createPickingRay(x, y, null, null), mesh => mesh.isPickable && - !selectionManager.isSelectedByPeer(/** @type {Mesh} */ (mesh)) + !selection.isSelectedByPeer(/** @type {Mesh} */ (mesh)) ) ?.sort((a, b) => a.distance - b.distance)[0]?.pickedMesh ?? null ) @@ -636,7 +630,7 @@ function findPickedMesh(scene, { x, y }) { /** * @param {Map} pointers - map of pointers. * @param {PointerEvent} event - tested event. - * @returns {boolean} whether this pointer has moved or not + * @returns whether this pointer has moved or not */ function hasMoved(pointers, event) { const { x, y } = (event && 'pointerId' in event diff --git a/apps/web/src/3d/managers/material.js b/apps/web/src/3d/managers/material.js index d4a302c0..db75136e 100644 --- a/apps/web/src/3d/managers/material.js +++ b/apps/web/src/3d/managers/material.js @@ -35,51 +35,49 @@ KhronosTextureContainer2.URLConfig.wasmMSCTranscoder = const logger = makeLogger('material') -class MaterialManager { +export class MaterialManager { /** * Creates a manager to manage and reuse materials * - builds textured or colored material for a given scene * - reuse built materials based on their texture file (or color) * - allows using the same texture in between hand and main scene * - automatically clears cache scene disposal. - */ - constructor() { - /** @type {Scene} main scene. */ - this.scene - /** @type {Scene|undefined} hand scene. */ - this.handScene - /** @type {string} base url hosting the game textures. */ - this.gameAssetsUrl = '' - /** @property {string} locale used to download the game textures. */ - this.locale = '' - /** @internal @type {Map} map of material for the main scene. */ - this.mainMaterialByUrl = new Map() - /** @internal @type {Map} map of material for the hand scene.*/ - this.handMaterialByUrl = new Map() - /** @internal @type {boolean} */ - this.isWebGL1 = false - } - - /** - * Initialize manager with scene and configuration values. * @param {object} params - parameters, including: * @param {Scene} params.scene - main scene. * @param {Scene} [params.handScene] - scene for meshes in hand. * @param {string} [params.gameAssetsUrl] - base url hosting the game textures. * @param {string} [params.locale] - locale used to download the game textures. * @param {boolean} [params.isWebGL1] - true if the rendering engine only supports WebGL1. - * @param {Game} [game] - loaded game data. + * @param {boolean} [params.disabled] - true to disable material support and always return scene's default material. */ - init({ scene, handScene, gameAssetsUrl, locale, isWebGL1 }, game) { + constructor({ scene, handScene, gameAssetsUrl, locale, isWebGL1, disabled }) { + /** main scene. */ this.scene = scene + /** optional hand scene. */ this.handScene = handScene + /** base url hosting the game textures. */ this.gameAssetsUrl = gameAssetsUrl ?? '' + /** used to download the game textures. */ this.locale = locale ?? 'fr' + /** @internal @type {Map} map of material for the main scene. */ + this.mainMaterialByUrl = new Map() + /** @internal @type {Map} map of material for the hand scene.*/ + this.handMaterialByUrl = new Map() + /** true to disable material support and always return scene's default material. */ + this.disabled = disabled ?? false + /** @internal */ this.isWebGL1 = isWebGL1 === true logger.debug('material manager initialized') this.clear() scene.onDisposeObservable.addOnce(() => this.clear()) - if (game) { + } + + /** + * Initializes with game data. + * @param {Game} game - loaded game data. + */ + init(game) { + if (!this.disabled) { preloadMaterials(this, game) } } @@ -92,8 +90,12 @@ class MaterialManager { */ configure(mesh, texture) { const scene = mesh.getScene() - mesh.material = buildMaterials(this, texture, scene) - mesh.receiveShadows = true + if (this.disabled) { + mesh.material = scene.defaultMaterial + } else { + mesh.material = buildMaterials(this, texture, scene) + mesh.receiveShadows = true + } } /** @@ -104,7 +106,9 @@ class MaterialManager { * @returns the build (or cached) material. */ buildOnDemand(texture, scene) { - return buildMaterials(this, texture, scene) + return this.disabled + ? scene.defaultMaterial + : buildMaterials(this, texture, scene) } /** @@ -126,18 +130,13 @@ class MaterialManager { * @returns whether this material is managed or not */ isManaging(texture) { - return ( - this.mainMaterialByUrl.has(texture) || this.handMaterialByUrl.has(texture) - ) + return this.disabled + ? true + : this.mainMaterialByUrl.has(texture) || + this.handMaterialByUrl.has(texture) } } -/** - * Material manager singleton. - * @type {MaterialManager} - */ -export const materialManager = new MaterialManager() - /** * @param {MaterialManager} manager - manager instance. * @param {Scene} scene - concerned scene. @@ -149,11 +148,10 @@ function getMaterialCache(manager, scene) { : manager.handMaterialByUrl } -/** - * @param {MaterialManager} manager - manager instance. - * @param {Game} game - parsed game data. - */ -function preloadMaterials(manager, game) { +function preloadMaterials( + /** @type {MaterialManager} */ manager, + /** @type {Game} */ game +) { for (const { texture } of [ ...(game.meshes ?? []), ...(game.hands ?? []).flatMap(({ meshes }) => meshes) diff --git a/apps/web/src/3d/managers/move.js b/apps/web/src/3d/managers/move.js index 3cc17a03..afca11a9 100644 --- a/apps/web/src/3d/managers/move.js +++ b/apps/web/src/3d/managers/move.js @@ -17,9 +17,6 @@ import { actionNames } from '../utils/actions' import { animateMove } from '../utils/behaviors' import { sortByElevation } from '../utils/gravity' import { isAboveTable, screenToGround } from '../utils/vector' -import { controlManager } from './control' -import { selectionManager } from './selection' -import { targetManager } from './target' const logger = makeLogger('move') @@ -33,7 +30,7 @@ const logger = makeLogger('move') * @property {Mesh[]} meshes - meshes that are about to be moved. */ -class MoveManager { +export class MoveManager { /** * Creates a manager to move meshes with MoveBehavior: * - can start, continue and stop moving managed mesh @@ -42,35 +39,39 @@ class MoveManager { * - release mesh on table, or on their relevant target * * Prior to move operation, the onPreMoveObservable allows to add or remove meshes to the list. + * Invokes init() before any other function. + * @param {object} params - parameters, including: + * @param {Scene} params.scene - scene attached to. + * @param {number} [params.elevation=0.5] - elevation applied to meshes while dragging them. */ - constructor() { - /** @type {number} elevation applied to meshes while dragging them. */ - this.elevation - /** @type {boolean} true while a move operation is in progress. */ + constructor({ scene, elevation = 0.5 }) { + /** elevation applied to meshes while dragging them. */ + this.elevation = elevation + /** true while a move operation is in progress. */ this.inProgress = false /** @type {Observable} emits when moving a given mesh. */ this.onMoveObservable = new Observable() /** @type {Observable} emits prior to starting the operation. */ this.onPreMoveObservable = new Observable() - /** @internal @type {Scene} main scene. */ - this.scene + /** @internal main scene. */ + this.scene = scene /** @internal @type {Set} managed mesh ids. */ this.meshIds = new Set() /** @internal @type {Map} managed behaviors by their mesh id. */ this.behaviorByMeshId = new Map() /** @internal @type {Set} set of meshes to re-select after moving them. */ this.autoSelect = new Set() + /** @internal @type {import('@src/3d/managers').Managers} */ + this.managers } /** - * Gives a scene to the manager, and binds on input manager observables. + * Initializes with other managers. * @param {object} params - parameters, including: - * @param {Scene} params.scene - scene attached to. - * @param {number} [params.elevation=0.5] - elevation applied to meshes while dragging them. + * @param {import('@src/3d/managers').Managers} params.managers - current managers. */ - init({ scene, elevation = 0.5 }) { - this.scene = scene - this.elevation = elevation + init({ managers }) { + this.managers = managers } /** @@ -87,8 +88,8 @@ class MoveManager { this.autoSelect.clear() let sceneUsed = mesh.getScene() - const meshes = selectionManager.meshes.has(mesh) - ? [...selectionManager.meshes].filter( + const meshes = this.managers.selection.meshes.has(mesh) + ? [...this.managers.selection.meshes].filter( mesh => this.isManaging(mesh) && mesh.getScene() === sceneUsed ) : [mesh] @@ -108,7 +109,7 @@ class MoveManager { /** @type {Set} */ let zones = new Set() this.inProgress = true - const actionObserver = controlManager.onActionObservable.add( + const actionObserver = this.managers.control.onActionObservable.add( actionOrMove => { if ('fn' in actionOrMove && actionOrMove.fn === actionNames.play) { const mesh = moved.find(({ id }) => id === actionOrMove.meshId) @@ -135,20 +136,20 @@ class MoveManager { const deselectAuto = (/** @type {(?Mesh)[]} */ meshes) => { for (const mesh of meshes) { if (mesh && this.autoSelect.has(mesh)) { - selectionManager.unselect(mesh) + this.managers.selection.unselect(mesh) this.autoSelect.delete(mesh) } } } const startMoving = (/** @type {Mesh} */ mesh) => { - if (!selectionManager.meshes.has(mesh)) { + if (!this.managers.selection.meshes.has(mesh)) { this.autoSelect.add(mesh) - selectionManager.select(mesh) + this.managers.selection.select(mesh) } const { x, y, z } = mesh.absolutePosition mesh.setAbsolutePosition(new Vector3(x, y + this.elevation, z)) - controlManager.record({ + this.managers.control.record({ mesh, pos: mesh.absolutePosition.asArray(), prev: [x, y, z] @@ -160,7 +161,7 @@ class MoveManager { this.continue = (/** @type {ScreenPosition} */ event) => { if (moved.length === 0) return for (const zone of zones) { - targetManager.clear(zone) + this.managers.target.clear(zone) } zones.clear() @@ -171,7 +172,12 @@ class MoveManager { lastPosition = currentPosition const { min, max } = computeMovedExtend(moved) - const boundingBoxes = findCollidingBoundingBoxes(sceneUsed, moved, min) + const boundingBoxes = findCollidingBoundingBoxes( + sceneUsed, + this.managers, + moved, + min + ) const newY = elevateWhenColliding(boundingBoxes, min, max) if (newY) { move.y = newY + this.elevation @@ -180,7 +186,7 @@ class MoveManager { for (const mesh of moved) { const prev = mesh.absolutePosition.asArray() mesh.setAbsolutePosition(mesh.absolutePosition.addInPlace(move)) - const zone = targetManager.findDropZone( + const zone = this.managers.target.findDropZone( mesh, /** @type {MoveBehavior} */ (this.behaviorByMeshId.get(mesh.id)) .state.kind @@ -188,7 +194,7 @@ class MoveManager { if (zone) { zones.add(zone) } - controlManager.record({ + this.managers.control.record({ mesh, pos: mesh.absolutePosition.asArray(), prev @@ -236,7 +242,7 @@ class MoveManager { // dynamically assign stop function to keep moved, zones and lastPosition in scope this.stop = async () => { if (actionObserver) { - controlManager.onActionObservable.remove(actionObserver) + this.managers.control.onActionObservable.remove(actionObserver) } this.continue = () => {} this.getActiveZones = () => [] @@ -254,7 +260,7 @@ class MoveManager { /** @type {Mesh[]} */ const dropped = [] for (const zone of zones) { - const meshes = targetManager.dropOn(zone) + const meshes = this.managers.target.dropOn(zone) logger.info( { zone, meshes }, `completes move operation on target ${meshes.map(({ id }) => id)}` @@ -287,7 +293,7 @@ class MoveManager { animateMove(mesh, absolutePosition, null, duration, true) ) .then(() => - controlManager.record({ + this.managers.control.record({ mesh, pos: mesh.absolutePosition.asArray(), prev: [x, y, z] @@ -339,7 +345,6 @@ class MoveManager { * Stops the move operation, releasing mesh(es) on its(their) target if any, or on the table. * When released on table, mesh(es) are snapped to the grid with possible animation. * Awaits until (all) animation(s) completes. - * @returns {Promise} */ async stop() {} @@ -407,15 +412,9 @@ class MoveManager { } } -/** - * Mesh move manager singleton. - * @type {MoveManager} - */ -export const moveManager = new MoveManager() - /** * @param {Mesh[]} moved - moved meshes. - * @returns {{ min: Vector3, max: Vector3 }} bounding box info for this group of meshes. + * @returns bounding box info for this group of meshes. */ function computeMovedExtend(moved) { /** @type {Vector3} */ @@ -441,18 +440,19 @@ function computeMovedExtend(moved) { /** * @param {Scene} scene - scene used for moving meshes . + * @param {import('@src/3d/managers').Managers} managers - other managers. * @param {Mesh[]} moved - moved meshes. * @param {Vector3} min - moved mesh bounding box minimum. - * @returns {BoundingInfo[]} a list of possibly colliding bounding boxes. + * @returns list of possibly colliding bounding boxes. */ -function findCollidingBoundingBoxes({ meshes }, moved, min) { +function findCollidingBoundingBoxes({ meshes }, { selection }, moved, min) { /** @type {BoundingInfo[]} */ const boxes = [] for (const mesh of meshes) { if ( mesh.isHittable && !moved.includes(mesh) && - !selectionManager.meshes.has(mesh) + !selection.meshes.has(mesh) ) { mesh.computeWorldMatrix(true) const box = mesh.getBoundingInfo() @@ -503,16 +503,16 @@ function elevateWhenColliding(boundingBoxes, min, max) { /** * @param {MoveManager} manager - manager instance. * @param {Mesh} mesh - tested mesh. - * @returns {boolean} whether this mesh could be moved. + * @returnswhether this mesh could be moved. */ -function isDisabled({ behaviorByMeshId }, mesh) { +function isDisabled({ behaviorByMeshId, managers }, mesh) { return ( behaviorByMeshId.get(mesh.id)?.enabled === false && (!mesh.metadata?.stack || mesh.metadata.stack.every( mesh => behaviorByMeshId.get(mesh.id)?.enabled === false ) || - !selectionManager.meshes.has( + !managers.selection.meshes.has( mesh.metadata.stack[mesh.metadata.stack.length - 1] )) ) diff --git a/apps/web/src/3d/managers/replay.js b/apps/web/src/3d/managers/replay.js index 5735fc92..db2d0c57 100644 --- a/apps/web/src/3d/managers/replay.js +++ b/apps/web/src/3d/managers/replay.js @@ -1,67 +1,64 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Engine} Engine - * @typedef {import('@babylonjs/core').Observer} Observer - * @typedef {import('@src/3d/managers').Action} Action - * @typedef {import('@src/3d/managers').Move} Move - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').HistoryRecord} HistoryRecord - */ - import { Observable } from '@babylonjs/core/Misc/observable.js' import { makeLogger } from '../../utils' import { actionNames } from '../utils/actions' -import { controlManager } from './control' const logger = makeLogger('replay') -class ReplayManager { - constructor() { - /** @type {Engine} */ - this.engine - /** @type {HistoryRecord[]} */ +export class ReplayManager { + /** + * Builds a manager to orchestrate replaying, backward and forward, history records. + * Invokes init() before any other function. + * @param {object} params - parameters, including: + * @param {import('@babylonjs/core').Engine} params.engine - 3d engine. + * @param {number} [params.moveDuration] - duration applied to moved meshes, in ms. + */ + constructor({ engine, moveDuration = 200 }) { + /** game engin. */ + this.engine = engine + /** @type {import('@tabulous/server/src/graphql').HistoryRecord[]} list of available history records. */ this.history = [] /** @type {number} current rank when replaying records */ this.rank = 0 - /** @type {ReturnType?} */ - this._save = null - /** @type {Observable} emits when history has changed. */ + /** @type {Observable} emits when history has changed. */ this.onHistoryObservable = new Observable() /** @type {Observable} emits when the replay ranks is modified. */ this.onReplayRankObservable = new Observable() - /** @type {Observer?} */ + /** @type {import('@babylonjs/core').Observer?} */ this.actionObserver /** @internal avoid concurrent replays */ this.inhibitReplay = false /** @internal */ - this.moveDuration = 200 + this.moveDuration = moveDuration + /** @internal @type {import('@src/3d/managers').Managers} */ + this.managers + /** @internal @type {string} */ + this.playerId + + this.engine.onDisposeObservable.addOnce(() => { + this.managers?.control.onActionObservable.remove(this.actionObserver) + this.onHistoryObservable.clear() + this.onReplayRankObservable.clear() + }) } /** + * Initializes with game data. * Set the initial history, and connects to the control manager to record new local actions. * @param {object} params - parameters, including: - * @param {HistoryRecord[]} params.history - initial history. * @param {string} params.playerId - id of the local player. - * @param {Engine} params.engine - 3d engine. + * @param {import('@src/3d/managers').Managers} params.managers - current managers. + * @param {import('@tabulous/server/src/graphql').HistoryRecord[]} [params.history] - initial history. */ - init({ history, engine, playerId }) { - this.engine = engine - this.actionObserver = controlManager.onActionObservable.add(action => { - if (!('isLocal' in action) || !action.isLocal) { - this.record(action, playerId) - } - }) - engine.onDisposeObservable.addOnce(() => { - controlManager.onActionObservable.remove(this.actionObserver) - this.onHistoryObservable.clear() - this.onReplayRankObservable.clear() - }) + init({ managers, history = [], playerId }) { + this.managers = managers + this.playerId = playerId this.reset(history) - logger.debug( - { history, playerId, rank: this.rank }, - 'replay manager initialized' + this.actionObserver = managers.control.onActionObservable.add(action => + this.record(action) ) + logger.debug({ history, rank: this.rank }, 'replay manager initialized') } /** Whether a replay is in progress. */ @@ -69,14 +66,9 @@ class ReplayManager { return this.rank < this.history.length } - /** Serialized engine, if replay is in progress. */ - get save() { - return this.isReplaying ? this._save : null - } - /** * Reset records and rank, and notifies listeners. - * @param {HistoryRecord[]} [history] - history content. + * @param {import('@tabulous/server/src/graphql').HistoryRecord[]} [history] - history content. */ reset(history = []) { this.history = history @@ -89,45 +81,51 @@ class ReplayManager { * Record a new action or move into history. * It collapses redundant moves together and notifies listeners. * Only updates current rank if it was on last. - * @param {Action|Move} record - received record. - * @param {string} playerId - id of the player who sent the record. + * @param {import('@src/3d/managers').ActionOrMove} record - received record. + * @param {string} [playerId] - id of the player who sent the record. */ - record(record, playerId) { - if (!record.fromHand && ('pos' in record || !record.isLocal)) { - logger.debug( - { record, history: this.history, rank: this.rank }, - 'adding to the history' - ) - const needsRankUpdate = this.rank === this.history.length - /** @type {HistoryRecord} */ - const result = - 'fn' in record - ? { - fn: /** @type {ActionName} */ (record.fn), - argsStr: JSON.stringify(record.args), - revertStr: record.revert - ? JSON.stringify(record.revert) - : undefined, - meshId: record.meshId, - duration: record.duration, - time: Date.now(), - playerId - } - : { - pos: record.pos, - prev: record.prev, - meshId: record.meshId, - duration: record.duration, - time: Date.now(), - playerId - } - this.history = collapseAndAppendHistory(this.history, result) - if (needsRankUpdate) { - this.rank = this.history.length - this.onReplayRankObservable.notifyObservers(this.rank) - } - this.onHistoryObservable.notifyObservers(this.history) + record(record, playerId = this.playerId) { + if ('isLocal' in record && record.isLocal) { + return + } + + logger.debug( + { record, history: this.history, rank: this.rank }, + 'adding to the history' + ) + const needsRankUpdate = this.rank === this.history.length + /** @type {import('@tabulous/server/src/graphql').HistoryRecord} */ + const result = + 'fn' in record + ? { + fn: /** @type {import('@tabulous/server/src/graphql').ActionName} */ ( + record.fn + ), + argsStr: JSON.stringify(record.args), + revertStr: record.revert + ? JSON.stringify(record.revert) + : undefined, + meshId: record.meshId, + duration: record.duration, + time: Date.now(), + playerId, + fromHand: record.fromHand + } + : { + pos: record.pos, + prev: record.prev, + meshId: record.meshId, + duration: record.duration, + time: Date.now(), + playerId, + fromHand: record.fromHand + } + this.history = collapseAndAppendHistory(this.history, result) + if (needsRankUpdate) { + this.rank = this.history.length + this.onReplayRankObservable.notifyObservers(this.rank) } + this.onHistoryObservable.notifyObservers(this.history) } /** @@ -139,9 +137,6 @@ class ReplayManager { return } this.inhibitReplay = true - if (!this.isReplaying) { - this._save = this.engine.serialize() - } if (untilRank >= 0 && untilRank < this.history.length) { const reverting = this.rank > untilRank do { @@ -161,28 +156,29 @@ class ReplayManager { } /** - * Replay manager singleton. - * @type {ReplayManager} - */ -export const replayManager = new ReplayManager() - -/** - * Apply or revert a given revord. + * Apply or revert a given revord, unless it comes from a peer's hand. * @param {ReplayManager} manager - current manager. - * @param {HistoryRecord} record - concerned record. + * @param {import('@tabulous/server/src/graphql').HistoryRecord} record - concerned record. * @param {boolean} [reverting] - whether the record should be reverted (true) or applied (false). */ -async function apply({ moveDuration }, record, reverting = false) { +async function apply( + { moveDuration, managers, playerId }, + record, + reverting = false +) { + if (record.fromHand && record.playerId !== playerId) { + return + } logger.debug({ record, reverting }, 'replaying record') if ('prev' in record && record.prev) { if (reverting) { - await controlManager.revert({ + await managers.control.revert({ ...record, duration: moveDuration }) } else { - await controlManager.apply({ ...record, duration: moveDuration }) + await managers.control.apply({ ...record, duration: moveDuration }) } } else if ('fn' in record && record.fn) { if (reverting) { @@ -192,9 +188,9 @@ async function apply({ moveDuration }, record, reverting = false) { : record.argsStr ? JSON.parse(record.argsStr) : [] - await controlManager.revert({ ...record, args }) + await managers.control.revert({ ...record, args }) } else { - await controlManager.apply({ + await managers.control.apply({ ...record, args: 'argsStr' in record && record.argsStr @@ -207,43 +203,36 @@ async function apply({ moveDuration }, record, reverting = false) { /** * Appends a record to history. Also handles these cases: - * - when moving a mesh, if this mesh's previous action is a move by the same player, then collapse moves. - * - when moving a mesh, if this mesh's previous action is a draw by the same player, then ignore the move. - * - when playing a mesh, if this mesh's previous action is a move by the same player, then ignore the move. - * @param {HistoryRecord[]} history - history of records. - * @param {HistoryRecord} added - candidate record to add. + * - when moving a mesh, if this mesh's previous action is a move by the same player in the same scene, then collapse moves. + * - when moving a mesh, if this mesh's previous action is a draw by the same player, then ignore the move on table. + * @param {import('@tabulous/server/src/graphql').HistoryRecord[]} history - history of records. + * @param {import('@tabulous/server/src/graphql').HistoryRecord} added - candidate record to add. * @returns the collapsed history. */ function collapseAndAppendHistory(history, added) { let add = true - if ( - 'pos' in added || - added.fn === actionNames.draw || - added.fn === actionNames.play - ) { + if ('pos' in added) { const previousIdx = history.findLastIndex( record => record.meshId === added.meshId ) if (previousIdx >= 0) { const previous = history[previousIdx] - if ('pos' in added) { - if ('pos' in previous && previous.playerId === added.playerId) { - // collapses moves together: updates `prev` but keeps `pos` - added.prev = previous.prev - history.splice(previousIdx, 1) - } else if ( - 'fn' in previous && - previous.fn === actionNames.draw && - previous.playerId === added.playerId - ) { - // ignore moves after draw - add = false - } - } else if (added.fn === actionNames.play) { - if ('pos' in previous && previous.playerId === added.playerId) { - // ignore moves before play - history.splice(previousIdx, 1) - } + if ( + 'pos' in previous && + previous.playerId === added.playerId && + previous.fromHand === added.fromHand + ) { + // collapses moves together: updates `prev` but keeps `pos` + added.prev = previous.prev + history.splice(previousIdx, 1) + } else if ( + 'fn' in previous && + previous.fn === actionNames.draw && + previous.playerId === added.playerId && + !added.fromHand + ) { + // ignore moves after draw + add = false } } } diff --git a/apps/web/src/3d/managers/selection.js b/apps/web/src/3d/managers/selection.js index 2fd82201..48dc14c4 100644 --- a/apps/web/src/3d/managers/selection.js +++ b/apps/web/src/3d/managers/selection.js @@ -1,10 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').LinesMesh} LinesMesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@src/3d/utils').ScreenPosition} ScreenPosition - */ - import { Axis, Space } from '@babylonjs/core/Maths/math.axis.js' import { Color3, Color4 } from '@babylonjs/core/Maths/math.color.js' import { Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector.js' @@ -18,18 +12,21 @@ import { isMeshLocked } from '../utils/behaviors' import { sortByElevation } from '../utils/gravity' import { isContaining } from '../utils/mesh' import { screenToGround } from '../utils/vector' -import { handManager } from './hand' const logger = makeLogger('selection') -class SelectionManager { +export class SelectionManager { /** * Creates a manager to manages mesh selection: * - draw a selection box (a single rectangle on the table) * - select all meshes contained in the selection box, highlighting them * - clear previous selection + * Invokes init() before any other function. + * @param {object} params - parameters, including: + * @param {import('@babylonjs/core').Scene} params.scene - scene attached to. + * @param {import('@babylonjs/core').Scene} params.handScene - scene for meshes in hand. */ - constructor() { + constructor({ scene, handScene }) { // we need to keep this set immutable, because it is referenced when adding to it const meshes = new Set() /** @type {Set} meshes - active selection of meshes. */ @@ -37,41 +34,37 @@ class SelectionManager { Object.defineProperty(this, 'meshes', { get: () => meshes }) /** @type {Observable>} emits when selection is modified. */ this.onSelectionObservable = new Observable() - /** @type {Scene} */ - this.scene - /** @type {Scene} */ - this.handScene - /** @type {Color4} */ - this.color = Color4.FromHexString('#00ff00') - /** @protected @type {?LinesMesh} */ + /** main scene */ + this.scene = scene + /** hand scene */ + this.handScene = handScene + /** @type {Color4} current player color, for selection */ + this.color + /** @internal @type {string} */ + this.playerId + /** @internal @type {?import('@babylonjs/core').LinesMesh} */ this.box = null - /** @protected @type {boolean} */ + /** @internal @type {boolean} */ this.skipNotify = false - /** @protected @type {Map>} */ + /** @internal @type {Map>} */ this.selectionByPeerId = new Map() - /** @protected @type {Map} */ - this.colorByPlayerId = new Map() - } - - /** - * Gives a scene to the manager. - * @param {object} params - parameters, including: - * @param {Scene} params.scene - scene attached to. - * @param {Scene} params.handScene - scene for meshes in hand. - */ - init({ scene, handScene }) { - this.scene = scene - this.handScene = handScene - this.color = Color4.FromHexString('#00ff00ff') + /** @internal @type {Map} */ this.colorByPlayerId = new Map() + /** @internal @type {import('@src/3d/managers').Managers} */ + this.managers } /** + * Initializes with game data. * Updates colors to reflect players in game. - * @param {string} playerId - current player id, to find selection box color. - * @param {Map} colorByPlayerId - map of hexadecimal color strings used for selection box, and selected mesh overlay, by player id. + * @param {object} params - parameters, including: + * @param {string} params.playerId - current player id, to find selection box color. + * @param {import('@src/3d/managers').Managers} params.managers - other managers. + * @param {Map} params.colorByPlayerId - map of hexadecimal color strings used for selection box, and selected mesh overlay, by player id. */ - updateColors(playerId, colorByPlayerId) { + init({ managers, playerId, colorByPlayerId }) { + this.playerId = playerId + this.managers = managers this.colorByPlayerId = new Map( [...colorByPlayerId.entries()].map(([playerId, color]) => [ playerId, @@ -83,13 +76,12 @@ class SelectionManager { /** * Draws selection box between two points (in screen coordinates) - * @param {ScreenPosition} start - selection box's start screen position. - * @param {ScreenPosition} end - selection box's end screen position. + * @param {import('@src/3d/utils').ScreenPosition} start - selection box's start screen position. + * @param {import('@src/3d/utils').ScreenPosition} end - selection box's end screen position. */ drawSelectionBox(start, end) { - if (!this.scene) return logger.debug({ start, end }, `draw selection box`) - const scene = handManager.isPointerInHand(start) + const scene = this.managers.hand.isPointerInHand(start) ? this.handScene : this.scene @@ -204,7 +196,6 @@ class SelectionManager { * Removes meshes from the selection, including anchored meshes. * Ignores meshes selected by other players. * @param {Mesh[]|Mesh} meshes - mesh(es) to remove from the active selection. - * @returns {void} */ unselect(meshes) { if (!Array.isArray(meshes)) { @@ -244,7 +235,7 @@ class SelectionManager { /** * @param {Mesh} mesh - tested mesh. - * @returns {Mesh[]} the tested mesh, or if it is contained in current selection, the entire selection. + * @returns the tested mesh, or if it is contained in current selection, the entire selection. */ getSelection(mesh) { return this.meshes.has(mesh) ? [...this.meshes] : [mesh] @@ -254,9 +245,12 @@ class SelectionManager { * Applies selection from peer players: highlight selected meshes with the player's own color. * Ignores meshes that are part of current player's selection. * @param {string[]} meshIds - ids of selected meshes. - * @param {string} playerId - id of the peer selecting these meshes.s. + * @param {string} [playerId] - id of the peer selecting these meshes (default to current player). */ apply(meshIds, playerId) { + if (!playerId) { + return + } const color = this.colorByPlayerId.get(playerId) if (!color) { return @@ -282,7 +276,7 @@ class SelectionManager { /** * @param {Mesh} mesh - tested mesh. - * @returns {boolean} whether this mesh is selected by another player or not. + * @returns whether this mesh is selected by another player or not. */ isSelectedByPeer(mesh) { for (const selection of this.selectionByPeerId.values()) { @@ -294,12 +288,6 @@ class SelectionManager { } } -/** - * Selection manager singleton. - * @type {SelectionManager} - */ -export const selectionManager = new SelectionManager() - /** * @param {Set[]} allSelections - list of all players' active selection. * @param {Set} selection - active selection. @@ -331,7 +319,7 @@ function addToSelection(allSelections, selection, observable, mesh, color) { /** * @param {?Mesh} mesh - tested mesh. - * @returns {Mesh[]} list of snapped meshes, if any. + * @returns list of snapped meshes, if any. */ function findSnapped(mesh) { if (!mesh || (mesh.metadata?.anchors?.length ?? 0) === 0) { @@ -376,7 +364,7 @@ function reorderSelection(manager) { /** * @param {SelectionManager} manager - manager instance. - * @returns {Mesh|undefined} first selected mesh. + * @returns first selected mesh. */ function getFirstSelected(manager) { return manager.meshes.values().next().value diff --git a/apps/web/src/3d/managers/target.js b/apps/web/src/3d/managers/target.js index 4e62f190..0cdcfc1f 100644 --- a/apps/web/src/3d/managers/target.js +++ b/apps/web/src/3d/managers/target.js @@ -20,7 +20,6 @@ import { getTargetableBehavior } from '../utils/behaviors' import { isAbove } from '../utils/gravity' -import { selectionManager } from './selection' const logger = makeLogger('target') @@ -43,20 +42,21 @@ const logger = makeLogger('target') /** @typedef {{ zone: SingleDropZone, distance: number }} Candidate */ -class TargetManager { +export class TargetManager { /** * Creates a manager to manages drop targets for draggable meshes: * - find relevant zones physically bellow a given mesh, according to their kind * - highlight zones * - drop mesh onto their relevant zones * Each registered behavior can have multiple zones + * Invokes init() before any other function. * - * - * @property {string} playerId - current player Id. + * @param {object} params - parameters, including: + * @param {Scene} params.scene - main scene. */ - constructor() { - /** @type {Scene} the main scene. */ - this.scene + constructor({ scene }) { + /** the main scene. */ + this.scene = scene /** @type {string} current player Id. */ this.playerId /** @type {Color4} current player color. */ @@ -67,20 +67,23 @@ class TargetManager { this.droppablesByDropZone = new Map() /** @internal @type {StandardMaterial} material applied to active drop zones. */ this.material + /** @internal @type {import('@src/3d/managers').Managers} */ + this.managers } /** - * Gives scenes to the manager. + * Initialize with game data. * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene. + * @param {import('@src/3d/managers').Managers} params.managers - other managers. * @param {string} params.playerId - current player Id. * @param {string} params.color - hexadecimal color string used for highlighting targets. */ - init({ scene, playerId, color }) { - this.scene = scene + init({ managers, playerId, color }) { + this.managers = managers this.playerId = playerId + /** current player color. */ this.color = Color4.FromHexString(color) - this.material = new StandardMaterial('target-material', scene) + this.material = new StandardMaterial('target-material', this.scene) this.material.diffuseColor = Color3.FromArray(this.color.asArray()) this.material.alpha = 0.5 } @@ -110,7 +113,7 @@ class TargetManager { /** * @param {Mesh} mesh - tested mesh. - * @returns {boolean} whether this mesh's target behavior is controlled or not + * @returns whether this mesh's target behavior is controlled or not */ isManaging(mesh) { const behavior = getTargetableBehavior(mesh) @@ -124,7 +127,7 @@ class TargetManager { * * @param {Mesh} dragged - a dragged mesh. * @param {string} [kind] - drag kind. - * @returns {?DropZone} matching zone, if any. + * @returns matching zone, if any. */ findPlayerZone(dragged, kind) { logger.debug( @@ -147,7 +150,7 @@ class TargetManager { * * @param {Mesh} dragged - a dragged mesh. * @param {string} [kind] - drag kind. - * @returns {?DropZone} matching zone, if any. + * @returns matching zone, if any. */ findDropZone(dragged, kind) { logger.debug( @@ -187,7 +190,7 @@ class TargetManager { * It clears the target. * @param {DropZone} zone - the zone dropped onto. * @param {Partial} props - other properties passed to the drop zone observables - * @returns {Mesh[]} list of droppable meshes, if any. + * @returns list of droppable meshes, if any. */ dropOn(zone, props = {}) { const dropped = this.droppablesByDropZone.get(zone) ?? [] @@ -216,7 +219,7 @@ class TargetManager { * Does not consider mesh position. * @param {Partial} [zone] - the tested zone. * @param {string} [kind] - the tested kind. - * @returns {boolean} true if provided kind is acceptable. + * @returns true if provided kind is acceptable. */ canAccept(zone, kind) { if (!zone || !zone.enabled) return false @@ -227,21 +230,15 @@ class TargetManager { } } -/** - * Mesh drop target manager singleton. - * @type {TargetManager} - */ -export const targetManager = new TargetManager() - /** * @param {TargetManager} manager - manager instance. * @param {Mesh} dragged - dragged mesh to check. * @param {(zone: SingleDropZone, partCenters: Vector3[]) => boolean} isMatching - matching function to test candidate zones. * @param {string} [kind] - dragged kind. - * @returns {?DropZone} matching zone, if any. + * @returns matching zone, if any. */ function findZone(manager, dragged, isMatching, kind) { - const { behaviors, scene: mainScene } = manager + const { behaviors, scene: mainScene, managers } = manager const partCenters = getMeshAbsolutePartCenters(dragged) /** @type {Candidate[]} */ const zones = [] @@ -250,7 +247,7 @@ function findZone(manager, dragged, isMatching, kind) { // until we support target in hand, rely on manager.scene only return null } - const excluded = [dragged, ...selectionManager.meshes] + const excluded = [dragged, ...managers.selection.meshes] for (const targetable of behaviors) { const { mesh } = /** @type {TargetBehavior & { mesh: Mesh }} */ (targetable) if (!excluded.includes(mesh) && mesh.getScene() === scene) { @@ -288,7 +285,7 @@ function findZone(manager, dragged, isMatching, kind) { * @param {?DropZone} zone - matching zone to highlight. * @param {Mesh} dragged - dragged mesh to check. * @param {string} [kind] - dragged kind. - * @returns {?DropZone} matching zone, if any. + * @returns matching zone, if any. */ function highlightZone(manager, zone, dragged, kind) { if (!zone) { diff --git a/apps/web/src/3d/meshes/box.js b/apps/web/src/3d/meshes/box.js index 3c701504..2df71fd1 100644 --- a/apps/web/src/3d/meshes/box.js +++ b/apps/web/src/3d/meshes/box.js @@ -1,15 +1,7 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@src/3d/utils/behaviors').SerializedMesh} SerializedMesh - */ - import { Vector3, Vector4 } from '@babylonjs/core/Maths/math.vector.js' import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js' -import { controlManager } from '../managers/control' -import { materialManager } from '../managers/material' import { registerBehaviors, serializeBehaviors } from '../utils/behaviors' import { applyInitialTransform, setExtras } from '../utils/mesh' @@ -23,9 +15,10 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * 5. negative Z (0°) * 6. positive Z (180°) * By default, boxes have a dimension of 1. - * @param {Omit} params - box parameters. - * @param {Scene} scene - scene for the created mesh. - * @returns {Mesh} the created box mesh. + * @param {Omit} params - box parameters. + * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. + * @returns the created box mesh. */ export function createBox( { @@ -48,6 +41,7 @@ export function createBox( transform = undefined, ...behaviorStates }, + managers, scene ) { const mesh = CreateBox( @@ -61,7 +55,7 @@ export function createBox( scene ) mesh.name = 'box' - materialManager.configure(mesh, texture) + managers.material.configure(mesh, texture) applyInitialTransform(mesh, transform) mesh.setAbsolutePosition(new Vector3(x, y, z)) mesh.isPickable = false @@ -85,8 +79,8 @@ export function createBox( } }) - registerBehaviors(mesh, behaviorStates) + registerBehaviors(mesh, behaviorStates, managers) - controlManager.registerControlable(mesh) + managers.control.registerControlable(mesh) return mesh } diff --git a/apps/web/src/3d/meshes/card.js b/apps/web/src/3d/meshes/card.js index 0481147f..4c95c13e 100644 --- a/apps/web/src/3d/meshes/card.js +++ b/apps/web/src/3d/meshes/card.js @@ -1,15 +1,7 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@src/3d/utils/behaviors').SerializedMesh} SerializedMesh - */ - import { Vector3, Vector4 } from '@babylonjs/core/Maths/math.vector.js' import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js' -import { controlManager } from '../managers/control' -import { materialManager } from '../managers/material' import { registerBehaviors, serializeBehaviors } from '../utils/behaviors' import { applyInitialTransform, setExtras } from '../utils/mesh' @@ -18,8 +10,9 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * Cards are boxes whith a given width, height and depth. Only top and back faces UVs can be specified * By default, the card dimension follows American poker card standard (beetween 1.39 & 1.41). * A card's texture must have 2 faces, back then front, aligned horizontally. - * @param {Omit} params - card parameters. - * @param {Scene} scene - scene for the created mesh. + * @param {Omit} params - card parameters. + * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. * @returns the created card mesh. */ export function createCard( @@ -39,6 +32,7 @@ export function createCard( transform = undefined, ...behaviorStates }, + managers, scene ) { const mesh = CreateBox( @@ -60,7 +54,7 @@ export function createCard( scene ) mesh.name = 'card' - materialManager.configure(mesh, texture) + managers.material.configure(mesh, texture) applyInitialTransform(mesh, transform) mesh.setAbsolutePosition(new Vector3(x, y, z)) mesh.isPickable = false @@ -84,8 +78,8 @@ export function createCard( } }) - registerBehaviors(mesh, behaviorStates) + registerBehaviors(mesh, behaviorStates, managers) - controlManager.registerControlable(mesh) + managers.control.registerControlable(mesh) return mesh } diff --git a/apps/web/src/3d/meshes/custom.js b/apps/web/src/3d/meshes/custom.js index 8cd3f20f..b54659e8 100644 --- a/apps/web/src/3d/meshes/custom.js +++ b/apps/web/src/3d/meshes/custom.js @@ -1,17 +1,8 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@src/3d/utils/behaviors').SerializedMesh} SerializedMesh - */ - import { SceneLoader } from '@babylonjs/core/Loading/sceneLoader.js' import { Vector2, Vector3 } from '@babylonjs/core/Maths/math.vector.js' import { OBJFileLoader } from '@babylonjs/loaders/OBJ' -import { controlManager } from '../managers/control' -import { customShapeManager } from '../managers/custom-shape' -import { materialManager } from '../managers/material' import { registerBehaviors, serializeBehaviors } from '../utils/behaviors' import { getGroundAltitude } from '../utils/gravity' import { applyInitialTransform, setExtras } from '../utils/mesh' @@ -21,9 +12,10 @@ OBJFileLoader.UV_SCALING = new Vector2(-1, 1) /** * Creates a custom mesh by importing .obj file. * It must contain the file parameter. - * @param {Omit & Required>} params - custom mesh parameters. - * @param {Scene} scene - scene for the created mesh. - * @returns {Promise} the created custom mesh. + * @param {Omit & Required>} params - custom mesh parameters. + * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. + * @returns the created custom mesh. */ export async function createCustom( { @@ -36,10 +28,11 @@ export async function createCustom( transform = undefined, ...behaviorStates }, + managers, scene ) { - const encodedData = `data:;base64,${customShapeManager.get(file)}` - /** @type {?Mesh} */ + const encodedData = `data:;base64,${managers.customShape.get(file)}` + /** @type {?import('@babylonjs/core').Mesh} */ const mesh = await new Promise((resolve, reject) => SceneLoader.ImportMesh( null, @@ -50,7 +43,7 @@ export async function createCustom( const mesh = meshes?.[0] resolve( mesh?.getTotalVertices() > 0 || mesh?.getChildMeshes()?.length - ? /** @type {Mesh} */ (mesh) + ? /** @type {import('@babylonjs/core').Mesh} */ (mesh) : null ) }, @@ -70,7 +63,7 @@ export async function createCustom( scene.addMesh(mesh, true) mesh.rotationQuaternion = null - materialManager.configure(mesh, texture) + managers.material.configure(mesh, texture) applyInitialTransform(mesh, transform) mesh.setAbsolutePosition(new Vector3(x, y ?? getGroundAltitude(mesh), z)) @@ -92,8 +85,8 @@ export async function createCustom( } }) - registerBehaviors(mesh, behaviorStates) + registerBehaviors(mesh, behaviorStates, managers) - controlManager.registerControlable(mesh) + managers.control.registerControlable(mesh) return mesh } diff --git a/apps/web/src/3d/meshes/die.js b/apps/web/src/3d/meshes/die.js index dd7ab618..4e8bb4b6 100644 --- a/apps/web/src/3d/meshes/die.js +++ b/apps/web/src/3d/meshes/die.js @@ -1,11 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@tabulous/server/src/graphql').RandomizableState} RandomizableState - * @typedef {import('@src/3d/utils/behaviors').SerializedMesh} SerializedMesh - */ - import { Matrix, Quaternion } from '@babylonjs/core/Maths/math.vector' import { toRad } from '../../utils/math' @@ -16,9 +9,10 @@ import { createCustom } from './custom' /** * Creates a die, which could have from 4, 6, or 8 faces. * By default, dices have a diameter of 1, and 6 faces. - * @param {Omit} params - die parameters. - * @param {Scene} scene - scene for the created mesh. - * @returns {Promise} the created die mesh. + * @param {Omit} params - die parameters. + * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. + * @returns the created die mesh. */ export async function createDie( { @@ -32,6 +26,7 @@ export async function createDie( transform = undefined, ...behaviorStates }, + managers, scene ) { const mesh = await createCustom( @@ -43,6 +38,7 @@ export async function createDie( z, file: getDieModelFile(faces) }, + managers, scene ) @@ -74,7 +70,7 @@ export async function createDie( max: faces, quaternionPerFace: getQuaternions(faces) } - registerBehaviors(mesh, behaviorStates) + registerBehaviors(mesh, behaviorStates, managers) return mesh } @@ -82,7 +78,7 @@ export async function createDie( /** * Computes the model file url for a given die. * @param {number} faces - number of faces for this die (4, 6, 8). - * @returns {string} url of the model file. + * @returns url of the model file. * @throws {Error} if the desired number of faces is not supported. */ export function getDieModelFile(faces) { diff --git a/apps/web/src/3d/meshes/index.js b/apps/web/src/3d/meshes/index.js index d6d7f3ac..f20e088d 100644 --- a/apps/web/src/3d/meshes/index.js +++ b/apps/web/src/3d/meshes/index.js @@ -1,3 +1,4 @@ +// @ts-check export * from './box' export * from './card' export * from './custom' diff --git a/apps/web/src/3d/meshes/prism.js b/apps/web/src/3d/meshes/prism.js index 484c9c3c..c98157ce 100644 --- a/apps/web/src/3d/meshes/prism.js +++ b/apps/web/src/3d/meshes/prism.js @@ -1,15 +1,7 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@src/3d/utils/behaviors').SerializedMesh} SerializedMesh - */ - import { Vector3, Vector4 } from '@babylonjs/core/Maths/math.vector.js' import { CreateCylinder } from '@babylonjs/core/Meshes/Builders/cylinderBuilder.js' -import { controlManager } from '../managers/control' -import { materialManager } from '../managers/material' import { registerBehaviors, serializeBehaviors } from '../utils/behaviors' import { applyInitialTransform, setExtras } from '../utils/mesh' @@ -17,9 +9,10 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * Creates a prism, with a given number of base edge (starting at 3). * A prism's texture must have edges + 2 faces, starting with back and ending with front, aligned horizontally. * By default, prisms have 6 edges and a width of 3. - * @param {Omit} params - prism parameters. - * @param {Scene} scene - scene for the created mesh. - * @returns {Mesh} the created prism mesh. + * @param {Omit} params - prism parameters. + * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. + * @returns the created prism mesh. */ export function createPrism( { @@ -39,6 +32,7 @@ export function createPrism( transform = undefined, ...behaviorStates }, + managers, scene ) { const mesh = CreateCylinder( @@ -52,7 +46,7 @@ export function createPrism( scene ) mesh.name = 'prism' - materialManager.configure(mesh, texture) + managers.material.configure(mesh, texture) applyInitialTransform(mesh, transform) mesh.setAbsolutePosition(new Vector3(x, y, z)) mesh.isPickable = false @@ -77,8 +71,8 @@ export function createPrism( } }) - registerBehaviors(mesh, behaviorStates) + registerBehaviors(mesh, behaviorStates, managers) - controlManager.registerControlable(mesh) + managers.control.registerControlable(mesh) return mesh } diff --git a/apps/web/src/3d/meshes/round-token.js b/apps/web/src/3d/meshes/round-token.js index 05c438b2..1bd5ad0d 100644 --- a/apps/web/src/3d/meshes/round-token.js +++ b/apps/web/src/3d/meshes/round-token.js @@ -1,15 +1,7 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@src/3d/utils/behaviors').SerializedMesh} SerializedMesh - */ - import { Vector3, Vector4 } from '@babylonjs/core/Maths/math.vector.js' import { CreateCylinder } from '@babylonjs/core/Meshes/Builders/cylinderBuilder.js' -import { controlManager } from '../managers/control' -import { materialManager } from '../managers/material' import { registerBehaviors, serializeBehaviors } from '../utils/behaviors' import { applyInitialTransform, setExtras } from '../utils/mesh' @@ -18,9 +10,10 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * Tokens are cylinders, so their position is their center. * A token's texture must have 3 faces, back then edge then front, aligned horizontally. * By default tokens have a diameter of 2. - * @param {Omit} params - token parameters. - * @param {Scene} scene - scene for the created mesh. - * @returns {Mesh} the created token mesh. + * @param {Omit} params - token parameters. + * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. + * @param {import('@src/3d/managers').Managers} managers - current managers. + * @returns the created token mesh. */ export function createRoundToken( { @@ -39,6 +32,7 @@ export function createRoundToken( transform = undefined, ...behaviorStates }, + managers, scene ) { const mesh = CreateCylinder( @@ -52,7 +46,7 @@ export function createRoundToken( scene ) mesh.name = 'roundToken' - materialManager.configure(mesh, texture) + managers.material.configure(mesh, texture) applyInitialTransform(mesh, transform) mesh.setAbsolutePosition(new Vector3(x, y, z)) mesh.isPickable = false @@ -76,8 +70,8 @@ export function createRoundToken( } }) - registerBehaviors(mesh, behaviorStates) + registerBehaviors(mesh, behaviorStates, managers) - controlManager.registerControlable(mesh) + managers.control.registerControlable(mesh) return mesh } diff --git a/apps/web/src/3d/meshes/rounded-tile.js b/apps/web/src/3d/meshes/rounded-tile.js index d1c2dca8..443efafc 100644 --- a/apps/web/src/3d/meshes/rounded-tile.js +++ b/apps/web/src/3d/meshes/rounded-tile.js @@ -1,18 +1,10 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@src/3d/utils/behaviors').SerializedMesh} SerializedMesh - */ - import { Axis } from '@babylonjs/core/Maths/math.axis.js' import { Vector3, Vector4 } from '@babylonjs/core/Maths/math.vector.js' import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js' import { CreateCylinder } from '@babylonjs/core/Meshes/Builders/cylinderBuilder.js' import { CSG } from '@babylonjs/core/Meshes/csg.js' -import { controlManager } from '../managers/control' -import { materialManager } from '../managers/material' import { registerBehaviors, serializeBehaviors } from '../utils/behaviors' import { applyInitialTransform, setExtras } from '../utils/mesh' @@ -21,9 +13,10 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * Tiles are boxes, so their position is their center. * A tile's texture must have 2 faces, back then front, aligned horizontally. * By default tiles have a width and depth of 3 with a border radius of 0.4. - * @param {Omit} params - token parameters. - * @param {Scene} scene - scene for the created mesh. - * @returns {Mesh} the created tile mesh. + * @param {Omit} params - token parameters. + * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. + * @returns the created tile mesh. */ export function createRoundedTile( { @@ -47,6 +40,7 @@ export function createRoundedTile( transform = undefined, ...behaviorStates }, + managers, scene ) { const tileMesh = CreateBox( @@ -75,7 +69,7 @@ export function createRoundedTile( tileCSG.subtractInPlace(makeCornerMesh(cornerParams, false, false)) const mesh = tileCSG.toMesh(id, undefined, scene) mesh.name = 'roundedTile' - materialManager.configure(mesh, texture) + managers.material.configure(mesh, texture) applyInitialTransform(mesh, transform) mesh.setAbsolutePosition(new Vector3(x, y, z)) mesh.isPickable = false @@ -101,17 +95,17 @@ export function createRoundedTile( } }) - registerBehaviors(mesh, behaviorStates) + registerBehaviors(mesh, behaviorStates, managers) - controlManager.registerControlable(mesh) + managers.control.registerControlable(mesh) return mesh } /** - * @param {Required & { faceUV: Vector4 }>} cornerParams - corner parameters + * @param {Required & { faceUV: Vector4 }>} cornerParams - corner parameters * @param {boolean} isTop - whether if this corner is on the top or the bottom. * @param {boolean} isLeft - whether if this corner is on the left or the right. - * @returns {CSG} Constructive Solid Geometry built for this corner. + * @returns Constructive Solid Geometry built for this corner. */ function makeCornerMesh( { borderRadius, width, height, depth, faceUV }, diff --git a/apps/web/src/3d/utils/actions.js b/apps/web/src/3d/utils/actions.js index 019548b7..132f9b51 100644 --- a/apps/web/src/3d/utils/actions.js +++ b/apps/web/src/3d/utils/actions.js @@ -56,7 +56,7 @@ export const buttonIds = { * Parse game data to build a map of supported action names by their shortcuts. * @param {SerializedMesh[]} meshes - serialized meshes to be analyzed. * @param {Translate} translate - translation - * @returns {Map} map of supported action names by shortcut. + * @returns map of supported action names by shortcut. */ export function buildActionNamesByKey(meshes, translate) { /** @type {Map} */ diff --git a/apps/web/src/3d/utils/behaviors.js b/apps/web/src/3d/utils/behaviors.js index c14b11e3..6f19ae28 100644 --- a/apps/web/src/3d/utils/behaviors.js +++ b/apps/web/src/3d/utils/behaviors.js @@ -51,7 +51,7 @@ import { setExtras } from './mesh' const animationLogger = makeLogger('animatable') -/** @type {?[BehaviorNames, { new (state: ?): Behavior }][]} */ +/** @type {?[BehaviorNames, { new (state: ?, managers: import('@src/3d/managers').Managers): Behavior }][]} */ let constructors = null function getConstructors() { @@ -80,11 +80,12 @@ function getConstructors() { * and attach it to the mesh. * @param {Mesh} mesh - the modified mesh. * @param {BehaviorState} params - parameters, which may contain behavior specific states. + * @param {import('@src/3d/managers').Managers} managers - current managers */ -export function registerBehaviors(mesh, params) { +export function registerBehaviors(mesh, params, managers) { for (const [name, constructor] of getConstructors()) { if (params[name]) { - mesh.addBehavior(new constructor(params[name]), true) + mesh.addBehavior(new constructor(params[name], managers), true) } } } @@ -135,10 +136,15 @@ export function animateMove( ) { const movable = getAnimatableBehavior(mesh) if (!mesh.getEngine().isLoading && movable && duration) { - return movable.moveTo(absolutePosition, rotation, duration, withGravity) + return movable.moveTo( + absolutePosition, + rotation, + mesh.getEngine().simulation === null ? 0 : duration, + withGravity + ) } else { mesh.setAbsolutePosition(absolutePosition) - if (rotation) { + if (rotation != undefined) { mesh.rotation = rotation } if (withGravity) { @@ -284,7 +290,6 @@ export function attachFunctions(behavior, ...functionNames) { * @param {AnimateBehavior} behavior - animated behavior. * @param {?() => void} onEnd - function invoked when all animations have completed. * @param {AnimationSpec[]} animationSpecs - list of animation specs. - * @returns {Promise} */ export function runAnimation({ mesh, frameRate }, onEnd, ...animationSpecs) { if (!mesh) { @@ -316,31 +321,43 @@ export function runAnimation({ mesh, frameRate }, onEnd, ...animationSpecs) { const wasHittable = mesh.isHittable mesh.isHittable = false mesh.animationInProgress = true - animationLogger.debug({ mesh, animations }, `starts animations on ${mesh.id}`) + const speed = mesh.getEngine().simulation === null ? 100 : 1 + animationLogger.debug( + { mesh, animations, speed }, + `starts animations on ${mesh.id}` + ) return new Promise(resolve => mesh .getScene() - .beginDirectAnimation(mesh, animations, 0, lastFrame, false, 1, () => { - animationLogger.debug( - { mesh, animations, wasPickable, wasHittable }, - `end animations on ${mesh.id}` - ) - mesh.isPickable = wasPickable - mesh.isHittable = wasHittable - mesh.animationInProgress = false - // framed animation may not exactly end where we want, so force the final position - for (const { animation } of animationSpecs) { - // @ts-expect-error can not use animation.targetProperty to index Mesh - mesh[animation.targetProperty] = - animation.getKeys()[animation.getKeys().length - 1].value + .beginDirectAnimation( + mesh, + animations, + 0, + lastFrame, + false, + speed, + () => { + animationLogger.debug( + { mesh, animations, wasPickable, wasHittable }, + `end animations on ${mesh.id}` + ) + mesh.isPickable = wasPickable + mesh.isHittable = wasHittable + mesh.animationInProgress = false + // framed animation may not exactly end where we want, so force the final position + for (const { animation } of animationSpecs) { + // @ts-expect-error can not use animation.targetProperty to index Mesh + mesh[animation.targetProperty] = + animation.getKeys()[animation.getKeys().length - 1].value + } + mesh.computeWorldMatrix(true) + if (onEnd) { + onEnd() + } + resolve(void 0) + mesh.onAnimationEnd.notifyObservers() } - mesh.computeWorldMatrix(true) - if (onEnd) { - onEnd() - } - resolve(void 0) - mesh.onAnimationEnd.notifyObservers() - }) + ) ) } diff --git a/apps/web/src/3d/utils/gravity.js b/apps/web/src/3d/utils/gravity.js index 5b31fd9e..371be9d0 100644 --- a/apps/web/src/3d/utils/gravity.js +++ b/apps/web/src/3d/utils/gravity.js @@ -31,7 +31,7 @@ export const altitudeGap = 0.01 * Return the altitude of a mesh center if it was lying on the ground * @template {AbstractMesh} T * @param {T} mesh - dested mesh. - * @returns {number} resulting y coordinage. + * @returns resulting y coordinage. */ export function getGroundAltitude(mesh) { return -mesh.getBoundingInfo().minimum.y @@ -41,7 +41,7 @@ export function getGroundAltitude(mesh) { * Returns the absolute altitude (Y axis) above a given mesh, including minimum spacing. * @template {AbstractMesh} T * @param {T} mesh - related mesh. - * @returns {number} resulting Y coordinate. + * @returns resulting Y coordinate. */ export function getAltitudeAbove(mesh) { return mesh.getBoundingInfo().boundingBox.maximumWorld.y + altitudeGap @@ -53,7 +53,7 @@ export function getAltitudeAbove(mesh) { * @template {AbstractMesh} T * @param {T} meshBelow - foundation to put the mesh on. * @param {T} meshAbove - positionned over the other mesh. - * @returns {number} resulting Y coordinate. + * @returns resulting Y coordinate. */ export function getCenterAltitudeAbove(meshBelow, meshAbove) { meshBelow.computeWorldMatrix(true) @@ -65,7 +65,7 @@ export function getCenterAltitudeAbove(meshBelow, meshAbove) { * It'll check all other meshes in the same scene to identify the ones below (partial overlap is supported). * Does not run any animation, and change its absolute position. * @param {Mesh} mesh - applied mesh. - * @returns {Vector3} the mesh's new absolute position + * @returns the mesh's new absolute position */ export function applyGravity(mesh) { logger.info( @@ -98,7 +98,7 @@ export function applyGravity(mesh) { * @template {AbstractMesh} T * @param {T} mesh - checked mesh. * @param {T} target - other mesh. - * @returns {boolean} true when mesh is hovering the target. + * @returns true when mesh is hovering the target. */ export function isAbove(mesh, target) { return findBelow(mesh, [target]).length === 1 @@ -110,7 +110,7 @@ export function isAbove(mesh, target) { * @template {AbstractMesh} T * @param {Iterable} [meshes] - array of meshes to order. * @param {boolean} [highestFirst = false] - false to return highest first. - * @returns {T[]} sorted array. + * @returns sorted array. */ export function sortByElevation(meshes, highestFirst = false) { return [...(meshes ?? [])].sort((a, b) => @@ -124,7 +124,7 @@ export function sortByElevation(meshes, highestFirst = false) { * @template {AbstractMesh} T * @param {T} mesh - reference mesh. * @param {T[]} candidates - list of candidate meshes to consider. - * @returns {T[]} candidate meshes bellow the reference mesh. + * @returns candidate meshes bellow the reference mesh. */ function findBelow(mesh, candidates) { const results = [] @@ -146,7 +146,7 @@ function findBelow(mesh, candidates) { /** * @param {Geometry} geometryA - first considered geometry. * @param {Geometry} geometryB - second considered geometry. - * @returns {boolean} whether these geometry instersect. + * @returns whether these geometry instersect. */ function intersectGeometries(geometryA, geometryB) { const circleA = 'center' in geometryA ? geometryA : null @@ -169,7 +169,7 @@ function intersectGeometries(geometryA, geometryB) { * @template {AbstractMesh} T * @param {T} mesh - reference mesh. * @param {BoundingBox} boundingBox - bounding box to build geometry for. - * @returns {Geometry} bounding box's geomertry object. + * @returns bounding box's geomertry object. */ function buildGeometry(mesh, boundingBox) { const rectangle = buildGroundRectangle(boundingBox) @@ -179,7 +179,7 @@ function buildGeometry(mesh, boundingBox) { /** * @param {BoundingBox} reference - reference bounding box. * @param {BoundingBox} tested - tested bounding box. - * @returns {boolean} whether tested bounding box is below the reference. + * @returns whether tested bounding box is below the reference. */ function isGloballyBelow(reference, tested) { return tested.maximumWorld.y <= reference.minimumWorld.y diff --git a/apps/web/src/3d/utils/lights.js b/apps/web/src/3d/utils/lights.js index 7347f3c0..072a2b8f 100644 --- a/apps/web/src/3d/utils/lights.js +++ b/apps/web/src/3d/utils/lights.js @@ -29,15 +29,14 @@ import { TableId } from './table.js' * @param {object} params - parameters, including: * @param {Scene} params.scene - main scene. * @param {Scene} params.handScene - hand scene. - * @param {boolean} [params.isWebGL1] - true if the rendering engine only supports WebGL1. * @returns {LightResult} an object containing created light and shadowGenerator. */ -export function createLights({ scene, handScene, isWebGL1 }) { +export function createLights({ scene, handScene }) { const light = makeDirectionalLight(scene) const ambientLight = makeAmbientLight(scene) const shadowGenerator = new ShadowGenerator(1024, light) - shadowGenerator.usePercentageCloserFiltering = !isWebGL1 + shadowGenerator.usePercentageCloserFiltering = scene.getEngine().version !== 1 // https://forum.babylonjs.com/t/shadow-darkness-darker-than-0/22837 // @ts-expect-error _darkness is private shadowGenerator._darkness = -1.5 diff --git a/apps/web/src/3d/utils/mesh.js b/apps/web/src/3d/utils/mesh.js index 5a0e2c99..26454a5b 100644 --- a/apps/web/src/3d/utils/mesh.js +++ b/apps/web/src/3d/utils/mesh.js @@ -47,7 +47,7 @@ export function isAnimationInProgress(mesh) { * @template {AbstractMesh} M * @param {M} container - container that may contain the mesh. * @param {M} mesh - tested mesh. - * @returns {boolean} true if container contains mesh, false otherwise. + * @returns true if container contains mesh, false otherwise. */ export function isContaining(container, mesh) { container.computeWorldMatrix(true) @@ -71,7 +71,7 @@ export function isContaining(container, mesh) { * **Requires a fresh world matrix**. * @template {AbstractMesh} T * @param {T} mesh - sized mesh. - * @returns {{ width: number, height: number, depth: number }} mesh's dimensions. + * @returns mesh's dimensions. */ export function getDimensions(mesh) { mesh.computeWorldMatrix(true) diff --git a/apps/web/src/3d/utils/scene-loader.js b/apps/web/src/3d/utils/scene-loader.js index 508fbfb1..2cea0179 100644 --- a/apps/web/src/3d/utils/scene-loader.js +++ b/apps/web/src/3d/utils/scene-loader.js @@ -8,6 +8,7 @@ * @typedef {import('@tabulous/server/src/graphql').StackableState} StackableState * @typedef {import('@src/3d/behaviors/anchorable').AnchorBehavior} AnchorBehavior * @typedef {import('@src/3d/behaviors/stackable').StackBehavior} StackBehavior + * @typedef {import('@src/3d/managers').Managers} Managers */ // mandatory side effect @@ -27,7 +28,7 @@ import { createRoundToken } from '../meshes/round-token' import { createRoundedTile } from '../meshes/rounded-tile' import { restoreBehaviors } from './behaviors' -/** @typedef {(state: Omit, scene: Scene) => Mesh|Promise} MeshCreator */ +/** @typedef {(state: Omit, managers: Managers, scene: Scene) => Mesh|Promise} MeshCreator */ const logger = makeLogger('scene-loader') @@ -47,7 +48,7 @@ const supportedNames = new Set([...meshCreatorByName.keys()]) /** * Indicates whether a mesh can be serialized and loaded * @param {Mesh} mesh - tested mesh. - * @returns {boolean} whether this mesh could be serialized and loaded. + * @returns whether this mesh could be serialized and loaded. */ export function isSerializable(mesh) { return supportedNames.has(/** @type {Shape} */ (mesh.name)) @@ -74,15 +75,20 @@ export function serializeMeshes(scene) { * Creates a meshes into the provided scene. * @param {SerializedMesh} state - serialized mesh state. * @param {Scene} scene - 3D scene used. + * @param {Managers} managers - current managers. * @returns mesh created. */ -export async function createMeshFromState(state, scene) { +export async function createMeshFromState(state, scene, managers) { const { shape } = state logger.debug({ state }, `create new ${shape} ${state.id}`) if (!supportedNames.has(shape)) { throw new Error(`mesh shape ${shape} is not supported`) } - return /** @type {MeshCreator} */ (meshCreatorByName.get(shape))(state, scene) + return /** @type {MeshCreator} */ (meshCreatorByName.get(shape))( + state, + managers, + scene + ) } /** @@ -91,8 +97,9 @@ export async function createMeshFromState(state, scene) { * - deletes existing mesh that are not found in the provided data * @param {Scene} scene - 3D scene used. * @param {SerializedMesh[]} meshes - a list of serialized meshes data. + * @param {Managers} managers - current managers. */ -export async function loadMeshes(scene, meshes) { +export async function loadMeshes(scene, meshes, managers) { const disposables = new Set(scene.meshes) for (const mesh of disposables) { if (!isSerializable(mesh)) { @@ -120,7 +127,11 @@ export async function loadMeshes(scene, meshes) { restoreBehaviors(mesh.behaviors, state) } else { logger.debug({ state }, `create new ${name} ${state.id}`) - mesh = await createMeshFromState(skipDelayableBehaviors(state), scene) + mesh = await createMeshFromState( + skipDelayableBehaviors(state), + scene, + managers + ) } const stackBehavior = mesh.getBehaviorByName(StackBehaviorName) if (stackable && stackBehavior) { diff --git a/apps/web/src/3d/utils/scene.js b/apps/web/src/3d/utils/scene.js index f5738d28..6c1b3af5 100644 --- a/apps/web/src/3d/utils/scene.js +++ b/apps/web/src/3d/utils/scene.js @@ -1,8 +1,5 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Engine} Engine - * @typedef {import('@babylonjs/core').Mesh} Mesh - */ +/** @typedef {import('@babylonjs/core').Mesh} Mesh */ import { Scene } from '@babylonjs/core/scene.js' @@ -11,7 +8,7 @@ import { Scene } from '@babylonjs/core/scene.js' */ export class ExtendedScene extends Scene { /** - * @param {Engine} engine - rendering engine. + * @param {import('@babylonjs/core').Engine} engine - rendering engine. * @param {?} [options] - scene options. * @see https://doc.babylonjs.com/typedoc/classes/babylon.scene#constructor */ @@ -42,7 +39,7 @@ export class ExtendedScene extends Scene { /** * @param {Mesh} mesh - removed mesh. * @param {boolean} [recursive] - whether to remove children meshes as well. - * @returns {number} removed mesh index. + * @returns removed mesh index. * @see https://doc.babylonjs.com/typedoc/classes/babylon.scene#removeMesh */ removeMesh(mesh, recursive) { @@ -52,7 +49,7 @@ export class ExtendedScene extends Scene { /** * @param {string} id - searched mesh id. - * @returns {?Mesh} found mesh. + * @returns found mesh. * @see https://doc.babylonjs.com/typedoc/classes/babylon.scene#getMeshByID */ getMeshById(id) { diff --git a/apps/web/src/3d/utils/table.js b/apps/web/src/3d/utils/table.js index 17214c69..51a4bb41 100644 --- a/apps/web/src/3d/utils/table.js +++ b/apps/web/src/3d/utils/table.js @@ -1,28 +1,20 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@babylonjs/core').Material} Material - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Texture} Texture - * @typedef {import('@tabulous/server/src/graphql').TableSpec} TableSpec - */ - import { Vector3 } from '@babylonjs/core/Maths/math.vector.js' import { CreateGround } from '@babylonjs/core/Meshes/Builders/groundBuilder.js' -import { materialManager } from '../managers/material' - export const TableId = 'table' /** * Creates ground mesh to act as table, that received shadows but can not receive rays. * Table is always 0.01 unit bellow (Y axis) origin. - * @param {TableSpec|undefined} tableSpec - table parameters - * @param {Scene} scene - scene to host the table (default to last scene). - * @returns {Mesh} the created table ground. + * @param {import('@tabulous/server/src/graphql').TableSpec|undefined} tableSpec - table parameters + * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@babylonjs/core').Scene} scene - scene to host the table (default to last scene). + * @returns the created table ground. */ export function createTable( { width = 400, height = 400, texture } = {}, + managers, scene ) { const table = CreateGround(TableId, { width: width, height: height }, scene) @@ -30,10 +22,11 @@ export function createTable( table.receiveShadows = true table.isPickable = false if (texture) { - table.material = materialManager.buildOnDemand(texture, scene) - const material = /** @type {Material & { diffuseTexture: Texture? }} */ ( - table.material - ) + table.material = managers.material.buildOnDemand(texture, scene) + const material = + /** @type {import('@babylonjs/core').Material & { diffuseTexture: import('@babylonjs/core').Texture? }} */ ( + table.material + ) if (material.diffuseTexture) { material.diffuseTexture.uScale = 5 material.diffuseTexture.vScale = 5 diff --git a/apps/web/src/3d/utils/vector.js b/apps/web/src/3d/utils/vector.js index a28ce9f0..5e8aa5a8 100644 --- a/apps/web/src/3d/utils/vector.js +++ b/apps/web/src/3d/utils/vector.js @@ -30,7 +30,7 @@ let table = null * Useful to know where on the ground a player has clicked. * @param {Scene} scene - current scene. * @param {ScreenPosition} position - screen position. - * @returns {Vector3} 3D point on the ground plane (Y axis) for this position, if any. + * @returns 3D point on the ground plane (Y axis) for this position, if any. */ export function screenToGround(scene, { x, y }) { return /** @type {Vector3} */ ( @@ -42,7 +42,7 @@ export function screenToGround(scene, { x, y }) { * Indicates whether a screen position (2D, DOM) is above the table mesh. * @param {Scene} scene - current scene. * @param {ScreenPosition} position - screen position. - * @returns {boolean} true if the point is within the table area, false otherwise. + * @returns true if the point is within the table area, false otherwise. */ export function isAboveTable(scene, { x, y }) { /* istanbul ignore next */ @@ -58,7 +58,7 @@ export function isAboveTable(scene, { x, y }) { * Indicates whether a world position (3D, scene) is above the table mesh. * @param {Scene} scene - current scene. * @param {Vector3} position - 3D position. - * @returns {boolean} true if the point is within the table area, false otherwise. + * @returns true if the point is within the table area, false otherwise. */ export function isPositionAboveTable(scene, position) { /* istanbul ignore next */ @@ -77,7 +77,7 @@ export function isPositionAboveTable(scene, position) { * @template {AbstractMesh} T * @param {T?} [mesh] - the tested mesh. * @param {[number, number, number]} [offset = [0, 0, 0]] - optional offset (3D coordinates) applied. - * @returns {ScreenPosition|null} this mesh's screen position. + * @returns this mesh's screen position. */ export function getMeshScreenPosition(mesh, offset = [0, 0, 0]) { if (!mesh || !mesh.getScene()?.activeCamera) { @@ -115,7 +115,7 @@ export function getScreenPosition(scene, position) { * @template {AbstractMesh} T * @param {Vector3} absolutePosition - absolute position to convert. * @param {T} mesh - mesh into which position is converted. - * @returns {Vector3} the converted position in mesh's local space. + * @returns the converted position in mesh's local space. */ export function convertToLocal(absolutePosition, mesh) { if (!mesh.parent) { @@ -130,7 +130,7 @@ export function convertToLocal(absolutePosition, mesh) { * Returns mesh local rotation in world space. * @template {Node} T * @param {T} mesh - related mesh. - * @returns {Vector3} absolute rotation (Euler angles). + * @returns absolute rotation (Euler angles). */ export function getAbsoluteRotation(mesh) { const rotation = Quaternion.Identity() diff --git a/apps/web/src/components/Aside/Container.svelte b/apps/web/src/components/Aside/Container.svelte index 60b530b9..eeb6ad7c 100644 --- a/apps/web/src/components/Aside/Container.svelte +++ b/apps/web/src/components/Aside/Container.svelte @@ -12,6 +12,7 @@ */ import { isLobby as checkIfLobby } from '@src/utils' + import { beforeUpdate } from 'svelte' import { _ } from 'svelte-intl' import { ControlsHelp, FriendList, MinimizableSection, RuleViewer } from '..' @@ -49,6 +50,7 @@ /** @type {SectionTab[]} */ let tabs = [] let hasPeers = false + let previousConnectedLength = 0 $: isLobby = checkIfLobby(game) @@ -57,35 +59,48 @@ $: hasInvites = friends?.some(({ isRequest }) => isRequest) $: { - tabs = [{ icon: 'people_alt', id: friendsId, key: 'F2' }] + // computes new tabs based onreceived game and peers + const newTabs = [{ icon: 'people_alt', id: friendsId, key: 'F2' }] if (game) { if ((game.availableSeats ?? 0) === 0 && playerById.size === 1) { - tabs.splice(0, 1) + newTabs.splice(0, 1) } if (!isLobby) { - tabs.push({ icon: 'help', id: helpId, key: 'F1' }) + newTabs.push({ icon: 'help', id: helpId, key: 'F1' }) if ((game.rulesBookPageCount ?? 0) > 1) { - tabs.splice(0, 0, { icon: 'auto_stories', id: rulesId, key: 'F3' }) + newTabs.splice(0, 0, { icon: 'auto_stories', id: rulesId, key: 'F3' }) } } if (hasPeers) { - tabs.splice(0, 0, { icon: 'contacts', id: playersId, key: 'F4' }) + newTabs.splice(0, 0, { icon: 'contacts', id: playersId, key: 'F4' }) } } - tab = isLobby ? tabs.length - 1 : 0 - } - $: if (hasPeers && connected?.length) { - tab = 0 + if (makeTabsKey(newTabs) !== makeTabsKey(tabs)) { + tabs = newTabs + tab = isLobby ? newTabs.length - 1 : 0 + } } + beforeUpdate(() => { + // when someone connects, automatically displays videos, + if (connected?.length > previousConnectedLength) { + tab = 0 + } + previousConnectedLength = connected?.length ?? 0 + }) + function handleSetTab( /** @type {CustomEvent<{ currentTab: number }>} */ { detail: { currentTab } } ) { - // do nor use `bind:currentTab={tab}` because it computes tabs again, which reset cuttent tab + // do nor use `bind:currentTab={tab}` because it computes tabs again, which reset current tab tab = currentTab } + + function makeTabsKey(/** @type {SectionTab[]} */ tabs) { + return tabs.map(({ id }) => id).join('-') + }